teems 0.2.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: 644504d1f8d334a4ddec54ef8f099424d1251dd6d15beef3c1b713da93f0ce0e
4
- data.tar.gz: 9c93db615cbe58642f127856e6c70d8b9cd0eefd81625b666cdfab736095a4cd
3
+ metadata.gz: 5932afeb278892c20e7e3fbb92980da31ff45cd9a528a3e2463de38ee7ea3f70
4
+ data.tar.gz: 331a38b48c8bb1d87ff770a0579e1846aa643b5d1d1daf5061c0bbf8f0589264
5
5
  SHA512:
6
- metadata.gz: b72dbf0beb2fc14d5af939699526bcc060f7dab85afd9ee4f49d7fa5272add46fcda9f3a345afd67db81e8bc01ce40ca0e3b739d6dfed65d279be0ecb4172d8d
7
- data.tar.gz: 537ef91ebb3e91424172d4c3e17b8fb884cadccb5c670deaa62ee41737ffef59f650edde0cb068a81b4271983ac94fcc05befd82cd0cb1d6077803c8a488f817
6
+ metadata.gz: 5d750e3b09de3a6ce07612398e0e00a7eb5ac647aa9cd38d45023cd0605777625a9ec212aa81c111b69118d068ffd3b4bd946280d4194cc1ecd34f40708adf7f
7
+ data.tar.gz: 355288361bbfa4e21166b18b248793403a023411e3588be731e205c9fe4f6994684426ae7037a09cf009bdb699e9a17caf932572aea9ab1588449c3298224624
data/CHANGELOG.md CHANGED
@@ -1,5 +1,41 @@
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
+
16
+ ## [0.3.0] - 2026-04-23
17
+
18
+ ### Added
19
+ - `teems meeting --audio` - Download audio-only M4A alongside or instead of video (ideal for transcription)
20
+ - `teems meeting --no-video` - Skip the video download for audio-only output
21
+ - `teems ooo` now supports timed schedules via `--start`/`--end` (e.g., "today 14:00") in addition to all-day dates
22
+ - `teems ooo --invite` - Override the configured notify list for a single invocation
23
+
24
+ ### Changed
25
+ - Recording, audio, and transcript files share a common base name derived from the SharePoint file (e.g., `2026-01-20 - Team Sync.mp4`/`.m4a`/`.vtt`)
26
+
27
+ ### Fixed
28
+ - `teems meeting` shows call duration once on the event header instead of repeating it per participant
29
+
30
+ ## [0.2.0] - 2026-04-14
31
+
32
+ ### Added
33
+ - `teems meeting` - View meeting details, download transcripts, and download recordings
34
+ - Auto-authentication when tokens are missing or expired (no manual `auth login` needed)
35
+
36
+ ### Fixed
37
+ - `teems cal delete` now uses configured endpoints instead of hardcoded defaults
38
+
3
39
  ## [0.1.0] - 2026-04-10
4
40
 
5
41
  ### 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
 
@@ -477,56 +477,6 @@ module Teems
477
477
  end
478
478
  end
479
479
 
480
- # Time parsing for create subcommand
481
- module CalTimeParsing
482
- private
483
-
484
- def parse_time_input(raw)
485
- date, time_str = split_time_input(raw)
486
- return unless time_str
487
-
488
- date.is_a?(String) ? parse_absolute_time(date, time_str) : parse_relative_time(date, time_str)
489
- end
490
-
491
- def split_time_input(raw)
492
- base = Date.today
493
- split_tomorrow_time(raw, base) || split_today_time(raw, base) || split_absolute_time(raw)
494
- end
495
-
496
- def split_tomorrow_time(raw, base)
497
- return unless raw.start_with?('tomorrow ')
498
-
499
- [base + 1, raw.delete_prefix('tomorrow ')]
500
- end
501
-
502
- def split_today_time(raw, base)
503
- return unless raw.match?(/\A(?:today\s+)?\d{1,2}:\d{2}\z/)
504
-
505
- [base, raw.delete_prefix('today ')]
506
- end
507
-
508
- def split_absolute_time(raw)
509
- return unless raw.match?(/\A\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}\z/)
510
-
511
- raw.split(/\s+/, 2)
512
- end
513
-
514
- def parse_relative_time(date, time_str)
515
- hour, min = time_str.split(':').map(&:to_i)
516
- debug("Parsing time #{hour}:#{min} for #{@options[:date]}") if @options[:verbose]
517
- Time.new(date.year, date.month, date.day, hour, min, 0)
518
- rescue ArgumentError
519
- nil
520
- end
521
-
522
- def parse_absolute_time(date_str, time_str)
523
- date = Date.parse(date_str)
524
- parse_relative_time(date, time_str)
525
- rescue Date::Error
526
- nil
527
- end
528
- end
529
-
530
480
  # Builds the event body hash fields for create requests
531
481
  module CalEventFields
532
482
  private
@@ -640,7 +590,7 @@ module Teems
640
590
 
641
591
  # Event creation subcommand
642
592
  module CalCreateActions
643
- include CalTimeParsing
593
+ include Support::TimeParsing
644
594
  include CalEventFields
645
595
  include CalCreateValidation
646
596
 
@@ -19,7 +19,11 @@ module Teems
19
19
  --transcript Download meeting transcript (WebVTT)
20
20
  --recording Download meeting recording (MP4, requires ffmpeg)
21
21
  Combine with --transcript to embed subtitles
22
+ --audio Also save a separate audio file (M4A) — ideal for transcription
23
+ --no-video Skip the video file; with --audio produces audio-only output
22
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)
23
27
  -o, --output-dir Directory for downloads (default: current directory)
24
28
  -v, --verbose Show debug output
25
29
  -q, --quiet Suppress output
@@ -30,8 +34,11 @@ module Teems
30
34
  teems meeting 19:meeting_abc123@thread.v2
31
35
  teems meeting 19:meeting_abc123@thread.v2 --chat
32
36
  teems meeting 19:meeting_abc123@thread.v2 --transcript
37
+ teems meeting 19:meeting_abc123@thread.v2 --audio -o ~/Downloads # video + audio
38
+ teems meeting 19:meeting_abc123@thread.v2 --audio --no-video -o ~/Downloads # audio only
33
39
  teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
34
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
35
42
  teems meeting AAMkAGVmMDEz... # By calendar event ID
36
43
  HELP
37
44
 
@@ -40,7 +47,10 @@ module Teems
40
47
  ALL = {
41
48
  '--transcript' => ->(opts, _args) { opts[:transcript] = true },
42
49
  '--recording' => ->(opts, _args) { opts[:recording] = true },
50
+ '--audio' => ->(opts, _args) { opts[:audio] = true },
51
+ '--no-video' => ->(opts, _args) { opts[:no_video] = true },
43
52
  '--chat' => ->(opts, _args) { opts[:chat] = true },
53
+ '--date' => ->(opts, args) { opts[:date] = args.shift },
44
54
  '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
45
55
  '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
46
56
  }.freeze
@@ -158,6 +168,16 @@ module Teems
158
168
  CALLID_RE = %r{<callId>([^<]+)</callId>}
159
169
  INSTANCE_ICAL_RE = %r{<instanceDetails>.*?<iCalUid>([^<]+)</iCalUid>}m
160
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
+
161
181
  private
162
182
 
163
183
  def classify_meeting_messages(messages_data)
@@ -187,10 +207,12 @@ module Teems
187
207
 
188
208
  def parse_call_event(msg)
189
209
  content = msg['content'].to_s
210
+ parts = extract_partlist(content)
190
211
  build_msg_hash(msg).merge(
191
212
  call_id: content.match(CALLID_RE)&.captures&.first,
192
213
  ical_uid: content.match(INSTANCE_ICAL_RE)&.captures&.first,
193
- participants: extract_partlist(content)
214
+ duration: parts.first&.dig(:duration),
215
+ participants: parts
194
216
  )
195
217
  end
196
218
 
@@ -272,7 +294,8 @@ module Teems
272
294
 
273
295
  def display_single_call_event(event)
274
296
  puts
275
- puts " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
297
+ header = " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
298
+ puts "#{header} (#{format_call_duration(event[:duration])})"
276
299
  display_participants(event[:participants])
277
300
  end
278
301
 
@@ -280,12 +303,12 @@ module Teems
280
303
  return if parts.empty?
281
304
 
282
305
  puts " Participants (#{parts.length}):"
283
- parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity], entry[:duration]) }
306
+ parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity]) }
284
307
  end
285
308
 
286
- def format_participant_line(name, identity, duration)
309
+ def format_participant_line(name, identity)
287
310
  resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
288
- " #{resolved} (#{format_call_duration(duration)})"
311
+ " #{resolved}"
289
312
  end
290
313
 
291
314
  def needs_resolution?(name)
@@ -355,6 +378,200 @@ module Teems
355
378
  end
356
379
  end
357
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
+
358
575
  # Filters classified messages to a specific call instance by callId
359
576
  module MeetingCallFilter
360
577
  private
@@ -394,6 +611,8 @@ module Teems
394
611
  include MeetingDisplay
395
612
  include MeetingChatDisplay
396
613
  include MeetingCallFilter
614
+ include MeetingDateFilter
615
+ include MeetingPagination
397
616
  include MeetingTranscript
398
617
  include MeetingRecording
399
618
 
@@ -413,6 +632,13 @@ module Teems
413
632
  target ? process_meeting(target) : 1
414
633
  end
415
634
 
635
+ def validate_options
636
+ result = super
637
+ return result if result
638
+
639
+ validate_date_option
640
+ end
641
+
416
642
  protected
417
643
 
418
644
  MEETING_OPTIONS = MeetingOptionDefs::ALL
@@ -433,11 +659,21 @@ module Teems
433
659
  return 1 unless messages_data
434
660
 
435
661
  classified = classify_and_filter(messages_data, target)
662
+ classified = apply_date_option(classified)
663
+ return 1 unless classified
664
+
436
665
  dispatch_mode(target, classified)
437
666
  end
438
667
 
439
668
  def fetch_meeting_messages(thread_id)
440
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)
441
677
  response = with_token_refresh do
442
678
  runner.messages_api.chat_messages(chat_id: thread_id, limit: @options[:limit])
443
679
  end
@@ -452,19 +688,27 @@ module Teems
452
688
  display_meeting_chat(classified[:chat_messages])
453
689
  return 0
454
690
  end
455
- return download_recording_with_transcript(target, classified) if @options[:recording]
691
+ media = media_output_spec
692
+ return download_media_with_transcript(target, classified, media) if media
456
693
  return download_transcript(target, classified) || 0 if @options[:transcript]
457
694
 
458
695
  display_meeting_summary(target, classified)
459
696
  0
460
697
  end
461
698
 
462
- def download_recording_with_transcript(target, classified)
699
+ def media_output_spec
700
+ audio, recording, no_video = @options.values_at(:audio, :recording, :no_video)
701
+ return nil unless audio || recording || no_video
702
+
703
+ { video: (recording || audio) && !no_video, audio: audio || no_video }
704
+ end
705
+
706
+ def download_media_with_transcript(target, classified, media)
463
707
  if @options[:transcript]
464
708
  result = download_transcript(target, classified)
465
- warn('Transcript download failed; proceeding with recording') if result == 1
709
+ warn("Transcript download failed; proceeding with #{media[:video] ? 'recording' : 'audio'}") if result == 1
466
710
  end
467
- download_recording(target, classified) || 0
711
+ download_recording(target, classified, media: media) || 0
468
712
  end
469
713
  end
470
714
  end