teems 0.3.0 → 0.3.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6b2001d1f6e7653df296a0443a0a2f2bec80068ca05b4693bfd2b74c5f6a5240
4
- data.tar.gz: 4f8e3920b836feb38237cd828a11ab63589855391f21cbea90eee6a54a34be9f
3
+ metadata.gz: 5932afeb278892c20e7e3fbb92980da31ff45cd9a528a3e2463de38ee7ea3f70
4
+ data.tar.gz: 331a38b48c8bb1d87ff770a0579e1846aa643b5d1d1daf5061c0bbf8f0589264
5
5
  SHA512:
6
- metadata.gz: 245ff54545cf04f3d0db0062ab51ae2e1dc001a474c45c05e7e7cde21e8a00a523813b5019b96acaba0665010b6a6d31b7922141cb01a4203a2850f10fe5ad56
7
- data.tar.gz: dffaa23979af2e85fffb8b0d3b8b3d8688aee1ff7c6d53eeeb5c151c81836380e93921eb5c37e2f2acf7d633a2f73e02a007e0c70af59a01e68f0bcb31a87f99
6
+ metadata.gz: 5d750e3b09de3a6ce07612398e0e00a7eb5ac647aa9cd38d45023cd0605777625a9ec212aa81c111b69118d068ffd3b4bd946280d4194cc1ecd34f40708adf7f
7
+ data.tar.gz: 355288361bbfa4e21166b18b248793403a023411e3588be731e205c9fe4f6994684426ae7037a09cf009bdb699e9a17caf932572aea9ab1588449c3298224624
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.1] - 2026-09-02
4
+
5
+ ### Added
6
+ - `teems meeting --date YYYY-MM-DD` - Pick a single occurrence of a recurring meeting series by date. Filters call events, recordings, transcripts, and chat messages to that day in the user's local timezone, so iterating multiple days from the same series is a one-liner shell loop. Errors when nothing matches.
7
+ - `teems meeting --date` automatically paginates the chat thread via `_metadata.backwardLink` until the page covers the requested date — using `lastCompleteSegmentStartTime` as the boundary cursor so pages with interleaved old replies/system messages don't terminate pagination prematurely. Capped at 50 pages of 200 messages as a safety net; cap-hit surfaces a user-visible error. API errors mid-pagination now exit non-zero instead of rendering partial data as success.
8
+ - `teems messages <teams-url>` now treats a message permalink as a thread root: it fetches the linked message and its replies and renders them with a `--- N replies ---` separator (modelled after `slk view`). Falls back to listing recent messages when the URL has no message ID.
9
+
10
+ ### Fixed
11
+ - `teems auth login` no longer hangs and then fails on a first login. The Safari OAuth flow read its tenant from `tokens.json` and bailed when it was absent, so the flow that obtains tokens required tokens to already exist; it now falls back to the `common` tenant. The redirect poll also assumed silent SSO with a 12-second budget, which never survived an account picker, MFA, or a PIV/CAC certificate prompt — it now polls every tab for three minutes and prints a visible "waiting for sign-in" notice instead of sitting silent
12
+ - `teems auth login` records the tenant the token was actually issued for instead of the placeholder used to start the flow, so refreshes and the headless helper use the tenant-specific endpoint
13
+ - Interrupting a command that is waiting on a child process (Safari sign-in, the headless helper, or the Swift compile) no longer prints `Open3` reader-thread `IOError` backtraces before exiting
14
+ - `teems org` no longer hangs when the manager chain contains a cycle; cycle detection breaks the walk on the first repeated manager
15
+
3
16
  ## [0.3.0] - 2026-04-23
4
17
 
5
18
  ### Added
@@ -39,6 +39,12 @@ module Teems
39
39
  get("/v1/users/ME/conversations/#{encoded_id}/messages", params: params)
40
40
  end
41
41
 
42
+ # Get a single message by ID
43
+ def message(thread_id:, message_id:)
44
+ encoded_id = URI.encode_www_form_component(thread_id)
45
+ get("/v1/users/ME/conversations/#{encoded_id}/messages/#{message_id}")
46
+ end
47
+
42
48
  # Get replies to a message
43
49
  def replies(thread_id:, message_id:, limit: 50)
44
50
  encoded_id = URI.encode_www_form_component(thread_id)
@@ -221,8 +221,8 @@ module Teems
221
221
 
222
222
  def print_login_banner
223
223
  puts 'Starting Teams authentication...'
224
- puts 'Safari will open to teams.microsoft.com'
225
- puts 'Please complete the login process'
224
+ puts 'Safari will open so you can sign in (PIV/CAC or MFA prompts appear there).'
225
+ puts 'Leave this running - it captures the tokens as soon as sign-in completes.'
226
226
  puts
227
227
  end
228
228
 
@@ -238,7 +238,9 @@ module Teems
238
238
 
239
239
  def suggest_manual_auth
240
240
  error('Failed to extract tokens automatically')
241
- puts "\nTry manual extraction instead:\n teems auth manual"
241
+ puts "\nRerun with -v to see where it stopped:\n teems auth login -v"
242
+ puts "\nOn VPN with a PIV card, try:\n teems auth login --certauth"
243
+ puts "\nOr extract the tokens by hand:\n teems auth manual"
242
244
  1
243
245
  end
244
246
 
@@ -22,6 +22,8 @@ module Teems
22
22
  --audio Also save a separate audio file (M4A) — ideal for transcription
23
23
  --no-video Skip the video file; with --audio produces audio-only output
24
24
  --chat Show meeting chat messages
25
+ --date YYYY-MM-DD Pick a single occurrence of a recurring meeting by date
26
+ (filters call events, recordings, and transcripts)
25
27
  -o, --output-dir Directory for downloads (default: current directory)
26
28
  -v, --verbose Show debug output
27
29
  -q, --quiet Suppress output
@@ -36,6 +38,7 @@ module Teems
36
38
  teems meeting 19:meeting_abc123@thread.v2 --audio --no-video -o ~/Downloads # audio only
37
39
  teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
38
40
  teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
41
+ teems meeting "<recurring-url>" --date 2026-05-04 --audio --no-video --transcript
39
42
  teems meeting AAMkAGVmMDEz... # By calendar event ID
40
43
  HELP
41
44
 
@@ -47,6 +50,7 @@ module Teems
47
50
  '--audio' => ->(opts, _args) { opts[:audio] = true },
48
51
  '--no-video' => ->(opts, _args) { opts[:no_video] = true },
49
52
  '--chat' => ->(opts, _args) { opts[:chat] = true },
53
+ '--date' => ->(opts, args) { opts[:date] = args.shift },
50
54
  '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
51
55
  '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
52
56
  }.freeze
@@ -164,6 +168,16 @@ module Teems
164
168
  CALLID_RE = %r{<callId>([^<]+)</callId>}
165
169
  INSTANCE_ICAL_RE = %r{<instanceDetails>.*?<iCalUid>([^<]+)</iCalUid>}m
166
170
 
171
+ # Public helper: parses an ISO/RFC time string, returning nil for nil/garbage input.
172
+ # Shared with MeetingPage which doesn't include the module.
173
+ def self.safe_parse_time(time_str)
174
+ return nil unless time_str
175
+
176
+ Time.parse(time_str)
177
+ rescue ArgumentError, TypeError
178
+ nil
179
+ end
180
+
167
181
  private
168
182
 
169
183
  def classify_meeting_messages(messages_data)
@@ -364,6 +378,200 @@ module Teems
364
378
  end
365
379
  end
366
380
 
381
+ # A single page of meeting chat messages with the cursor to the page before it.
382
+ # Constructor normalizes a blank backward_link to nil so `last?` is a simple nil check.
383
+ # Date comparisons use the user's local timezone to match `--date` semantics.
384
+ #
385
+ # `segment_cursor` is `_metadata.lastCompleteSegmentStartTime` (a Time) — the API's
386
+ # boundary marker for "all messages from here on are fully loaded." Prefer it for
387
+ # `covers_through?` because pages can intersperse old replies/system messages, making
388
+ # the message-level `oldest_local_date` an unreliable stop signal. Falls back to the
389
+ # oldest message when the cursor is absent (e.g. in synthetic test data).
390
+ MeetingPage = Data.define(:messages, :backward_link, :segment_cursor) do
391
+ def self.from_response(response)
392
+ msgs, value_msgs, meta_or_nil = response.values_at('messages', 'value', '_metadata')
393
+ meta = meta_or_nil || {}
394
+ new(messages: msgs || value_msgs || [],
395
+ backward_link: meta['backwardLink'],
396
+ segment_cursor: cursor_from_millis(meta['lastCompleteSegmentStartTime']))
397
+ end
398
+
399
+ def self.cursor_from_millis(millis)
400
+ return nil unless millis.is_a?(Numeric)
401
+
402
+ Time.at(millis / 1000.0)
403
+ end
404
+
405
+ def initialize(messages:, backward_link:, segment_cursor: nil)
406
+ super(messages: messages,
407
+ backward_link: backward_link.to_s.empty? ? nil : backward_link,
408
+ segment_cursor: segment_cursor)
409
+ end
410
+
411
+ def empty? = messages.empty?
412
+ def last? = !backward_link
413
+
414
+ def oldest_local_date
415
+ oldest_message_time&.localtime&.to_date
416
+ end
417
+
418
+ # Strict `<` is load-bearing: when the cursor equals target_date, we must keep
419
+ # paginating to ensure we've fetched all of target_date's messages.
420
+ def covers_through?(target_date)
421
+ cursor = segment_cursor || oldest_message_time
422
+ cursor && cursor.localtime.to_date < target_date
423
+ end
424
+
425
+ def terminal_for?(target_date)
426
+ empty? || last? || covers_through?(target_date)
427
+ end
428
+
429
+ private
430
+
431
+ def oldest_message_time
432
+ messages.filter_map { |msg| MeetingMessageParser.safe_parse_time(msg['composetime']) }.min
433
+ end
434
+ end
435
+
436
+ # Pages backward through chat messages until we've fetched past the target date.
437
+ # The ng.msg wrapper requests 200 messages per page and exposes no messageType filter,
438
+ # so for recurring meetings we paginate via _metadata.backwardLink.
439
+ module MeetingPagination
440
+ # Per-page request size used by Api::Messages#chat_messages_page.
441
+ PAGE_SIZE = 200
442
+
443
+ # Safety net: 50 pages × 200 = 10K messages worst case (~25s with the rate-limit
444
+ # cushion). Beyond this we assume the requested date is older than retained history
445
+ # and surface a user-visible error.
446
+ MAX_PAGES = 50
447
+
448
+ # Polite pause between page fetches. Matches Services::SyncEngine::API_DELAY_SECONDS.
449
+ PAGE_DELAY_SECONDS = 0.5
450
+
451
+ private
452
+
453
+ # Returns the accumulated messages, or nil if the API errored mid-pagination
454
+ # (so callers can fail loudly instead of rendering partial data as success).
455
+ def paginate_meeting_messages(thread_id, target_date)
456
+ @page_count = 0
457
+ @pagination_link = nil
458
+ @paginated_messages = []
459
+ return nil unless run_pagination_loop(thread_id, target_date)
460
+
461
+ @paginated_messages
462
+ end
463
+
464
+ def run_pagination_loop(thread_id, target_date)
465
+ loop do
466
+ page = fetch_meeting_page(thread_id, @pagination_link)
467
+ return false unless page
468
+
469
+ merge_meeting_page(page)
470
+ return true if pagination_done?(page, target_date)
471
+
472
+ @pagination_link = page.backward_link
473
+ page_pause
474
+ end
475
+ end
476
+
477
+ def page_pause
478
+ sleep(PAGE_DELAY_SECONDS)
479
+ end
480
+
481
+ def fetch_meeting_page(thread_id, backward_link)
482
+ response = with_token_refresh do
483
+ runner.messages_api.chat_messages_page(
484
+ chat_id: thread_id, limit: PAGE_SIZE, backward_link: backward_link
485
+ )
486
+ end
487
+ MeetingPage.from_response(response)
488
+ rescue ApiError => e
489
+ error("Failed to fetch meeting messages: #{e.message}")
490
+ nil
491
+ end
492
+
493
+ def merge_meeting_page(page)
494
+ page_messages = page.messages
495
+ @paginated_messages.concat(page_messages)
496
+ @page_count += 1
497
+ debug("Page #{@page_count}: #{page_messages.length} message(s) " \
498
+ "(oldest #{page.oldest_local_date}); total #{@paginated_messages.length}")
499
+ end
500
+
501
+ def pagination_done?(page, target_date)
502
+ return true if page.terminal_for?(target_date)
503
+
504
+ reached_pagination_cap?
505
+ end
506
+
507
+ def reached_pagination_cap?
508
+ return false if @page_count < MAX_PAGES
509
+
510
+ error("Stopped after #{MAX_PAGES} pages (~#{MAX_PAGES * PAGE_SIZE} messages); " \
511
+ '--date may predate available history')
512
+ true
513
+ end
514
+ end
515
+
516
+ # Filters classified call events, recordings, transcripts, and chat messages to a
517
+ # single date. Used to select one occurrence of a recurring meeting series.
518
+ # Date comparison uses the user's local timezone.
519
+ module MeetingDateFilter
520
+ # All four classification buckets are date-filtered. Parsed buckets (call_events,
521
+ # recordings, transcripts) carry :time (symbol); raw chat_messages carry
522
+ # 'composetime' (string) — `item_on_date?` accepts either shape.
523
+ DATE_KEYS = %i[call_events recordings transcripts chat_messages].freeze
524
+
525
+ private
526
+
527
+ def validate_date_option
528
+ raw = @options[:date]
529
+ return nil unless raw
530
+
531
+ @options[:target_date] = Date.parse(raw)
532
+ nil
533
+ rescue ArgumentError, TypeError
534
+ error("Invalid --date value: #{raw.inspect} (expected YYYY-MM-DD)")
535
+ 1
536
+ end
537
+
538
+ def apply_date_option(classified)
539
+ target_date = @options[:target_date]
540
+ return classified unless target_date
541
+
542
+ filtered = filter_classified_by_date(classified, target_date)
543
+ return filtered unless date_filter_empty?(filtered)
544
+
545
+ error("No meeting activity found for #{@options[:date]}")
546
+ nil
547
+ end
548
+
549
+ def date_filter_empty?(classified)
550
+ DATE_KEYS.all? { |key| classified[key].empty? }
551
+ end
552
+
553
+ def filter_classified_by_date(classified, target_date)
554
+ classified.to_h { |key, val| [key, filter_classified_value(key, val, target_date)] }
555
+ end
556
+
557
+ def filter_classified_value(key, val, target_date)
558
+ DATE_KEYS.include?(key) ? items_on_date(val, target_date) : val
559
+ end
560
+
561
+ def items_on_date(items, target_date)
562
+ items.select { |item| item_on_date?(item, target_date) }
563
+ end
564
+
565
+ def item_on_date?(item, target_date)
566
+ time_str = item[:time] || item['composetime']
567
+ return false unless time_str
568
+
569
+ Time.parse(time_str).localtime.to_date == target_date
570
+ rescue ArgumentError, TypeError
571
+ false
572
+ end
573
+ end
574
+
367
575
  # Filters classified messages to a specific call instance by callId
368
576
  module MeetingCallFilter
369
577
  private
@@ -403,6 +611,8 @@ module Teems
403
611
  include MeetingDisplay
404
612
  include MeetingChatDisplay
405
613
  include MeetingCallFilter
614
+ include MeetingDateFilter
615
+ include MeetingPagination
406
616
  include MeetingTranscript
407
617
  include MeetingRecording
408
618
 
@@ -422,6 +632,13 @@ module Teems
422
632
  target ? process_meeting(target) : 1
423
633
  end
424
634
 
635
+ def validate_options
636
+ result = super
637
+ return result if result
638
+
639
+ validate_date_option
640
+ end
641
+
425
642
  protected
426
643
 
427
644
  MEETING_OPTIONS = MeetingOptionDefs::ALL
@@ -442,11 +659,21 @@ module Teems
442
659
  return 1 unless messages_data
443
660
 
444
661
  classified = classify_and_filter(messages_data, target)
662
+ classified = apply_date_option(classified)
663
+ return 1 unless classified
664
+
445
665
  dispatch_mode(target, classified)
446
666
  end
447
667
 
448
668
  def fetch_meeting_messages(thread_id)
449
669
  debug("Fetching messages for thread: #{thread_id}")
670
+ target_date = @options[:target_date]
671
+ return paginate_meeting_messages(thread_id, target_date) if target_date
672
+
673
+ single_page_meeting_messages(thread_id)
674
+ end
675
+
676
+ def single_page_meeting_messages(thread_id)
450
677
  response = with_token_refresh do
451
678
  runner.messages_api.chat_messages(chat_id: thread_id, limit: @options[:limit])
452
679
  end
@@ -24,6 +24,7 @@ module Teems
24
24
  teems messages 19:abc123@thread.v2 # Read chat messages
25
25
  teems messages 19:abc123@thread.v2 -n 50 # Show 50 messages
26
26
  teems messages "https://teams.microsoft.com/l/message/19:abc@thread.v2/123?context=..."
27
+ # Show the linked message and its thread replies
27
28
  HELP
28
29
 
29
30
  # Display formatting for messages command
@@ -167,10 +168,51 @@ module Teems
167
168
  end
168
169
  end
169
170
 
171
+ # Fetch and render a single message and its thread replies
172
+ module ThreadFetch
173
+ private
174
+
175
+ def fetch_thread(thread_id, message_id)
176
+ api = runner.messages_api
177
+ parent_data = with_token_refresh { api.message(thread_id: thread_id, message_id: message_id) }
178
+ replies_data = with_token_refresh do
179
+ api.replies(thread_id: thread_id, message_id: message_id, limit: @options[:limit])
180
+ end
181
+ display_thread(parent_data, extract_messages_data(replies_data))
182
+ rescue ApiError => e
183
+ error("Failed to fetch message: #{e.message}")
184
+ end
185
+
186
+ def display_thread(parent_data, replies_data)
187
+ parent = Models::Message.from_api(parent_data)
188
+ replies = replies_data.map { |data| Models::Message.from_api(data) }.reject(&:system_message?).reverse
189
+ return output_json(thread_to_hash(parent, replies)) if @options[:json]
190
+
191
+ render_thread(parent, replies)
192
+ download_attachments([parent, *replies]) if @options[:download]
193
+ 0
194
+ end
195
+
196
+ def render_thread(parent, replies)
197
+ display_message(parent)
198
+ count = replies.length
199
+ return if count.zero?
200
+
201
+ puts output.gray("--- #{count} #{count == 1 ? 'reply' : 'replies'} ---")
202
+ puts
203
+ replies.each { |reply| display_message(reply) }
204
+ end
205
+
206
+ def thread_to_hash(parent, replies)
207
+ { parent: message_to_hash(parent), replies: replies.map { |reply| message_to_hash(reply) } }
208
+ end
209
+ end
210
+
170
211
  # Read messages from a channel or chat
171
212
  class Messages < Base
172
213
  include MessagesDisplay
173
214
  include AttachmentDownload
215
+ include ThreadFetch
174
216
 
175
217
  def initialize(args, runner:)
176
218
  @options = {}
@@ -231,12 +273,17 @@ module Teems
231
273
  def apply_parsed_url(result)
232
274
  conversation_id = result.conversation_id
233
275
  team_id = result.team_id
234
- debug("Parsed URL: conversation=#{conversation_id}, team=#{team_id}")
276
+ message_id = result.message_id
277
+ debug("Parsed URL: conversation=#{conversation_id}, team=#{team_id}, message=#{message_id}")
235
278
  @options[:team_id] = team_id if team_id
279
+ @options[:message_id] = message_id if message_id
236
280
  conversation_id
237
281
  end
238
282
 
239
283
  def fetch_messages(target)
284
+ message_id = @options[:message_id]
285
+ return fetch_thread(target, message_id) if message_id
286
+
240
287
  @options[:team_id] ? fetch_channel_messages(target) : fetch_chat_messages(target)
241
288
  end
242
289
 
@@ -7,16 +7,18 @@ module Teems
7
7
  private
8
8
 
9
9
  def walk_manager_chain(target_id)
10
- managers = []
11
- current_id = target_id
12
10
  fetch = method(target_is_me? ? :fetch_my_manager : :fetch_user_manager)
13
- collect_managers(managers, current_id, fetch)
11
+ collect_managers(target_id, fetch, Set[target_id])
14
12
  end
15
13
 
16
- def collect_managers(managers, current_id, fetch)
14
+ def collect_managers(current_id, fetch, visited)
15
+ managers = []
17
16
  while (mgr = fetch.call(current_id))
17
+ mgr_id = mgr.id
18
+ break unless visited.add?(mgr_id)
19
+
18
20
  managers.unshift(mgr)
19
- current_id = mgr.id
21
+ current_id = mgr_id
20
22
  fetch = method(:fetch_user_manager)
21
23
  end
22
24
  managers
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
3
  require 'json'
5
4
  require 'net/http'
6
5
  require 'uri'
@@ -24,7 +23,7 @@ module Teems
24
23
 
25
24
  def compile_helper(source, binary)
26
25
  log('Compiling headless token helper...')
27
- _stdout, stderr, status = Open3.capture3(*swiftc_command(source, binary))
26
+ _stdout, stderr, status = Support::Subprocess.capture3(*swiftc_command(source, binary))
28
27
  log_helper_stderr(stderr)
29
28
  status.success? ? binary : log_and_nil('Failed to compile helper')
30
29
  rescue Errno::ENOENT
@@ -117,7 +116,7 @@ module Teems
117
116
  return nil unless binary
118
117
 
119
118
  log('Trying headless token extraction...')
120
- output, stderr, status = Open3.capture3(binary, *build_helper_args)
119
+ output, stderr, status = Support::Subprocess.capture3(binary, *build_helper_args)
121
120
  log_helper_stderr(stderr)
122
121
  handle_helper_result(output, status.exitstatus)
123
122
  rescue StandardError => e
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
-
5
3
  module Teems
6
4
  module Services
7
5
  # Runs JavaScript in Safari's current tab via AppleScript
@@ -74,7 +72,7 @@ module Teems
74
72
  end
75
73
 
76
74
  def run_applescript(script)
77
- out, _err, status = Open3.capture3('osascript', '-e', script)
75
+ out, _err, status = Support::Subprocess.capture3('osascript', '-e', script)
78
76
  return nil unless status.success?
79
77
 
80
78
  out.strip
@@ -116,12 +116,28 @@ module Teems
116
116
 
117
117
  # Polls Safari's URL bar via AppleScript and manages Safari tab navigation
118
118
  module SafariOAuthPolling
119
+ # Sign-in can require an account picker, MFA or a PIV/CAC certificate prompt, so the
120
+ # redirect is polled in chunks for several minutes rather than a single short burst.
121
+ POLL_TICK_SECONDS = 0.1
122
+ POLL_CHUNK_SECONDS = 15
123
+ POLL_CHUNKS = 12
124
+
119
125
  private
120
126
 
121
127
  def poll_safari_query_redirect
122
- raw = run_applescript(poll_query_redirect_script).to_s
123
- if raw.empty? || raw == 'timeout'
124
- log('Safari OAuth: fast capture missed, falling back')
128
+ POLL_CHUNKS.times do |chunk|
129
+ raw = run_applescript(poll_query_redirect_script).to_s
130
+ return handle_poll_result(raw) unless raw == 'timeout'
131
+
132
+ announce_sign_in_wait(chunk)
133
+ end
134
+ log('Safari OAuth: timed out waiting for the sign-in redirect')
135
+ nil
136
+ end
137
+
138
+ def handle_poll_result(raw)
139
+ if raw.empty?
140
+ log('Safari OAuth: unable to read Safari tab URLs')
125
141
  return nil
126
142
  end
127
143
 
@@ -129,19 +145,30 @@ module Teems
129
145
  parse_oauth_params(raw)
130
146
  end
131
147
 
132
- # Poll for ?code= in the URL — Safari's URL property includes query strings
148
+ def announce_sign_in_wait(chunk)
149
+ return notify('Waiting for you to finish signing in to Safari...') if chunk.zero?
150
+
151
+ log("Safari OAuth: still waiting for sign-in (#{(chunk + 1) * POLL_CHUNK_SECONDS}s)")
152
+ end
153
+
154
+ # Poll every tab for ?code= — Safari's URL property includes query strings, and the
155
+ # redirect can land in a tab other than the one we opened.
133
156
  def poll_query_redirect_script
134
157
  <<~APPLESCRIPT
135
158
  tell application "Safari"
136
- repeat 600 times
137
- try
138
- set pageURL to URL of current tab of front window
139
- if pageURL contains "teams.microsoft.com/go?" then
140
- set codeStart to offset of "?" in pageURL
141
- return text (codeStart + 1) thru -1 of pageURL
142
- end if
143
- end try
144
- delay 0.02
159
+ repeat #{(POLL_CHUNK_SECONDS / POLL_TICK_SECONDS).to_i} times
160
+ repeat with w in windows
161
+ repeat with t in tabs of w
162
+ try
163
+ set pageURL to (URL of t) as text
164
+ if pageURL contains "teams.microsoft.com/go?" then
165
+ set codeStart to offset of "?" in pageURL
166
+ return text (codeStart + 1) thru -1 of pageURL
167
+ end if
168
+ end try
169
+ end repeat
170
+ end repeat
171
+ delay #{POLL_TICK_SECONDS}
145
172
  end repeat
146
173
  return "timeout"
147
174
  end tell
@@ -192,20 +219,20 @@ module Teems
192
219
  include SafariOAuthPolling
193
220
 
194
221
  SKYPE_RESOURCE = 'https://api.spaces.skype.com'
222
+ # Used when no previous login is on disk; Entra ID resolves the tenant from the account.
223
+ DEFAULT_TENANT = 'common'
195
224
 
196
225
  private
197
226
 
198
227
  def try_safari_oauth
199
228
  hint, tenant = stored_login_hint
200
- return nil unless tenant
201
-
202
- log('Trying fast Safari OAuth flow...')
203
- safari_code_flow({ tenant: tenant, hint: hint })
229
+ log('Trying Safari OAuth flow...')
230
+ safari_code_flow({ tenant: tenant || DEFAULT_TENANT, hint: hint })
204
231
  rescue StandardError => e
205
232
  log("Safari OAuth error: #{e.class}: #{e.message}")
206
233
  nil
207
234
  ensure
208
- close_teams_tab if tenant
235
+ close_teams_tab
209
236
  end
210
237
 
211
238
  def safari_code_flow(context)
@@ -273,12 +300,19 @@ module Teems
273
300
  # :reek:FeatureEnvy
274
301
  def assemble_safari_result(context, graph, skype)
275
302
  spaces_token = skype[:spaces_token]
276
- { auth_token: graph['access_token'],
303
+ auth_token = graph['access_token']
304
+ { auth_token: auth_token,
277
305
  skype_token: exchange_skype_via_http(spaces_token),
278
306
  skype_spaces_token: spaces_token, chatsvc_token: nil,
279
307
  refresh_token: skype[:refresh_token],
280
308
  client_id: OAuthUrlBuilder::TEAMS_APP_ID,
281
- tenant_id: context[:tenant] }
309
+ tenant_id: resolved_tenant(auth_token, context[:tenant]) }
310
+ end
311
+
312
+ # Prefer the tenant the token was actually issued for over the placeholder used to
313
+ # start the flow, so refreshes hit the tenant-specific endpoint.
314
+ def resolved_tenant(auth_token, requested)
315
+ decode_jwt(auth_token.to_s)&.fetch('tid', nil) || requested
282
316
  end
283
317
  end
284
318
  end
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'open3'
4
3
  require_relative 'token_extractor_scripts'
5
4
  require_relative 'token_exchange_scripts'
6
5
  require_relative 'headless_extract'
@@ -58,7 +57,7 @@ module Teems
58
57
  end
59
58
 
60
59
  def run_applescript(script)
61
- output, _stderr, status = Open3.capture3('osascript', '-e', script)
60
+ output, _stderr, status = Support::Subprocess.capture3('osascript', '-e', script)
62
61
  return applescript_failure(status) unless status.success?
63
62
 
64
63
  output.strip
@@ -396,6 +395,7 @@ module Teems
396
395
  end
397
396
 
398
397
  def log(message) = @output&.debug(message)
398
+ def notify(message) = @output&.info(message)
399
399
  end
400
400
  end
401
401
  end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Teems
6
+ module Support
7
+ # Wrapper around Open3.capture3 for long-running child processes.
8
+ #
9
+ # Open3 reads stdout and stderr on background threads. When the process is signalled
10
+ # while those reads are in flight, the pipes close underneath them and each thread
11
+ # raises IOError, which Ruby prints as a backtrace before the main thread can exit
12
+ # cleanly. Waiting on Safari sign-in or the headless helper keeps us inside capture3
13
+ # for minutes at a time, so Ctrl-C there is routine rather than exceptional.
14
+ #
15
+ # Reader threads inherit Thread.report_on_exception when they are created, so muting
16
+ # it for the duration of the call silences exactly those threads and nothing else.
17
+ module Subprocess
18
+ module_function
19
+
20
+ def capture3(*command)
21
+ previous = Thread.report_on_exception
22
+ Thread.report_on_exception = false
23
+ Open3.capture3(*command)
24
+ ensure
25
+ Thread.report_on_exception = previous
26
+ end
27
+ end
28
+ end
29
+ 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.3.0'
4
+ VERSION = '0.3.1'
5
5
  end
data/lib/teems.rb CHANGED
@@ -117,5 +117,6 @@ module Teems
117
117
  autoload :ErrorLogger, 'teems/support/error_logger'
118
118
  autoload :Timezone, 'teems/support/timezone'
119
119
  autoload :TimeParsing, 'teems/support/time_parsing'
120
+ autoload :Subprocess, 'teems/support/subprocess'
120
121
  end
121
122
  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.3.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -83,6 +83,7 @@ files:
83
83
  - lib/teems/services/token_store.rb
84
84
  - lib/teems/support/error_logger.rb
85
85
  - lib/teems/support/help_formatter.rb
86
+ - lib/teems/support/subprocess.rb
86
87
  - lib/teems/support/time_parsing.rb
87
88
  - lib/teems/support/timezone.rb
88
89
  - lib/teems/support/xdg_paths.rb