teems 0.3.0 → 0.3.2

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: bdc6ea71a9d23f39629fd24d240992925cca1482b53b69df377a84870d66367f
4
+ data.tar.gz: 1d563de02f05c8a120130e3e792b508bbf4d1b2467937951a63f2a3efbb19410
5
5
  SHA512:
6
- metadata.gz: 245ff54545cf04f3d0db0062ab51ae2e1dc001a474c45c05e7e7cde21e8a00a523813b5019b96acaba0665010b6a6d31b7922141cb01a4203a2850f10fe5ad56
7
- data.tar.gz: dffaa23979af2e85fffb8b0d3b8b3d8688aee1ff7c6d53eeeb5c151c81836380e93921eb5c37e2f2acf7d633a2f73e02a007e0c70af59a01e68f0bcb31a87f99
6
+ metadata.gz: 8c903fdae8e9946614e093ab4370e74abc03f7fab0d0a76509da02d07814e0083382f4a8f46e0e9131dd1eb3b16f0c36c5eeb96bb381778679bb92028574ad13
7
+ data.tar.gz: b3e6ff9fa9ec3adb9708a3ab97b30929d53a820e9d6239daaf4d359cee179c32448b306a7ac3ab26f336aa3c723402868a1206711637af791a974aa0e3f0e832
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.2] - 2026-09-29
4
+
5
+ ### Added
6
+ - `teems transcripts sync` discovers calendar Teams meetings and downloads available WebVTT transcripts into a private XDG data directory. The first run scans 30 days; subsequent runs scan seven, with an idempotent manifest and catch-up after time away. `--date`, `--since`, and `--dry-run` are supported. Recordings and audio are not downloaded.
7
+ - Transcript sync writes speaker-turn Markdown copies to `~/.local/share/teems/transcripts-md/` for local search indexes such as qmd.
8
+ - Transcript sync can run a `transcripts.post_sync_command` from `config.json` (for example, a qmd index refresh) after a sync that changed Markdown. The command gets the changed paths in `TEEMS_TRANSCRIPTS_*` environment variables, is bounded by `post_sync_timeout` (default 300 seconds), and warns rather than failing the sync. `--no-post-sync` skips it for one run.
9
+ - Transcript sync keeps one transcript per recording, so meetings that were restarted mid-session no longer lose the later recording's transcript. Existing per-meeting downloads are reused when identical rather than duplicated.
10
+ - `teems meeting --json` prints call events, recording links, and transcript markers as JSON (the option was documented but ignored).
11
+ - `teems meeting --transcript --recording-url URL` downloads the transcript for a specific recording in the meeting instead of the first one.
12
+
13
+ ## [0.3.1] - 2026-09-02
14
+
15
+ ### Added
16
+ - `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.
17
+ - `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.
18
+ - `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.
19
+
20
+ ### Fixed
21
+ - `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
22
+ - `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
23
+ - 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
24
+ - `teems org` no longer hangs when the manager chain contains a cycle; cycle detection breaks the walk on the first repeated manager
25
+
3
26
  ## [0.3.0] - 2026-04-23
4
27
 
5
28
  ### Added
data/README.md CHANGED
@@ -87,11 +87,48 @@ teems meeting <thread-id> --transcript -o ~/Downloads # Download transcript (VT
87
87
  teems meeting <thread-id> --recording -o ~/Downloads # Download recording (MP4)
88
88
  teems meeting <thread-id> --recording --transcript -o ~/Downloads # Both, with embedded subtitles
89
89
  teems meeting <event-id> # By calendar event ID (AAMk...)
90
+ teems meeting <event-id> --date 2026-09-29 --json # Call events + recording links as JSON
91
+ teems meeting <event-id> --date 2026-09-29 --transcript --recording-url <url> # A specific recording
90
92
  teems meeting "https://teams.microsoft.com/..." # By Teams URL or recap link
91
93
  ```
92
94
 
93
95
  Recording download requires `ffmpeg` (`brew install ffmpeg`) and downloads via DASH streaming with 5 parallel threads. No browser required.
94
96
 
97
+ #### Transcript sync
98
+
99
+ ```bash
100
+ teems transcripts sync --dry-run # Preview meetings and recording counts
101
+ teems transcripts sync # First run: 30 days; later: 7 days
102
+ teems transcripts sync --date 2026-09-28 # Retry a specific day
103
+ teems transcripts sync --since 30 # Explicit historical backfill
104
+ teems transcripts sync --no-post-sync # Skip the configured post-sync command once
105
+ ```
106
+
107
+ This saves **only WebVTT transcripts** in `~/.local/share/teems/transcripts/` on the machine running the command, separate from live-caption `meeting-capture` files. Each sync also writes speaker-turn Markdown copies to `~/.local/share/teems/transcripts-md/` (regenerated when missing or older than the VTT) for local search tools such as [qmd](https://github.com/tobi/qmd). Each recording keeps its own transcript, so a meeting restarted mid-session yields one VTT per recording. A private manifest in `~/.local/state/teems/transcript-sync.json` makes repeats idempotent and catches up after time away (up to 30 days). Data and state directories are mode 0700; transcripts and the manifest are mode 0600. The calendar scan retries unavailable transcripts within the lookback window; errors return a nonzero exit code. To run every evening on a GFE, schedule `teems transcripts sync` there via launchd.
108
+
109
+ Coverage is **calendar Teams meetings with a saved and accessible recording transcript**, not every meeting attended: ad-hoc calls, meetings with no saved transcript/recording, and inaccessible organizer-owned artifacts cannot be recovered by this route. No audio/video is downloaded, and nothing is copied to another machine.
110
+
111
+ To search the Markdown locally with qmd, keep it in a dedicated index so refreshes don't re-scan other collections:
112
+
113
+ ```bash
114
+ qmd --index teems-transcripts collection add ~/.local/share/teems/transcripts-md --name teems-transcripts --mask '**/*.md'
115
+ qmd --index teems-transcripts update && qmd --index teems-transcripts embed
116
+ qmd --index teems-transcripts query "what did we decide about monitoring?"
117
+ ```
118
+
119
+ To refresh that index automatically, set a post-sync command in `~/.config/teems/config.json`:
120
+
121
+ ```json
122
+ {
123
+ "transcripts": {
124
+ "post_sync_command": "qmd --index teems-transcripts update && qmd --index teems-transcripts embed",
125
+ "post_sync_timeout": 900
126
+ }
127
+ }
128
+ ```
129
+
130
+ The command runs via `sh -c` only after a sync writes or regenerates Markdown; not on `--dry-run`, when nothing changed, or with `--no-post-sync`. It receives `TEEMS_TRANSCRIPTS_CHANGED` (newline-separated Markdown paths), `TEEMS_TRANSCRIPTS_CHANGED_COUNT`, `TEEMS_TRANSCRIPTS_MARKDOWN_DIR`, and `TEEMS_TRANSCRIPTS_DIR`, and is killed after `post_sync_timeout` seconds (default 300). A failure or timeout prints a warning but does not change the sync's exit code.
131
+
95
132
  ### People
96
133
 
97
134
  ```bash
@@ -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)
data/lib/teems/cli.rb CHANGED
@@ -48,6 +48,7 @@ module Teems
48
48
  'channels' => Commands::Channels,
49
49
  'chats' => Commands::Chats,
50
50
  'meeting' => Commands::Meeting,
51
+ 'transcripts' => Commands::Transcripts,
51
52
  'messages' => Commands::Messages,
52
53
  'sync' => Commands::Sync,
53
54
  'who' => Commands::Who,
@@ -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
 
@@ -9,6 +9,7 @@ module Teems
9
9
  ['channels', 'List joined teams and channels'],
10
10
  ['chats', 'List recent chats'],
11
11
  ['meeting', 'View meeting details, chat, transcripts, and recordings'],
12
+ ['transcripts', 'Sync saved meeting transcripts to this machine'],
12
13
  ['messages', 'Read messages from a channel or chat'],
13
14
  ['sync', 'Sync chat history locally'],
14
15
  ['who', "Look up a user's profile"],
@@ -83,6 +84,7 @@ module Teems
83
84
  teems channels List all channels
84
85
  teems chats List recent chats
85
86
  teems messages <channel-id> Read messages from a channel
87
+ teems transcripts sync Download recent meeting transcripts
86
88
  teems who Show your profile
87
89
  teems who john Search for a user
88
90
  teems org Show your org chart
@@ -22,10 +22,14 @@ 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)
27
+ --recording-url URL With --transcript, use this recording's transcript
28
+ (a meeting restarted mid-session has several)
25
29
  -o, --output-dir Directory for downloads (default: current directory)
26
30
  -v, --verbose Show debug output
27
31
  -q, --quiet Suppress output
28
- --json Output as JSON
32
+ --json Output meeting details (call events, recordings) as JSON
29
33
  -h, --help Show this help
30
34
 
31
35
  EXAMPLES:
@@ -36,6 +40,8 @@ module Teems
36
40
  teems meeting 19:meeting_abc123@thread.v2 --audio --no-video -o ~/Downloads # audio only
37
41
  teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
38
42
  teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
43
+ teems meeting "<recurring-url>" --date 2026-05-04 --audio --no-video --transcript
44
+ teems meeting AAMkAGVmMDEz... --date 2026-05-04 --json # List recordings
39
45
  teems meeting AAMkAGVmMDEz... # By calendar event ID
40
46
  HELP
41
47
 
@@ -47,6 +53,8 @@ module Teems
47
53
  '--audio' => ->(opts, _args) { opts[:audio] = true },
48
54
  '--no-video' => ->(opts, _args) { opts[:no_video] = true },
49
55
  '--chat' => ->(opts, _args) { opts[:chat] = true },
56
+ '--date' => ->(opts, args) { opts[:date] = args.shift },
57
+ '--recording-url' => ->(opts, args) { opts[:recording_url] = args.shift },
50
58
  '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
51
59
  '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
52
60
  }.freeze
@@ -164,6 +172,16 @@ module Teems
164
172
  CALLID_RE = %r{<callId>([^<]+)</callId>}
165
173
  INSTANCE_ICAL_RE = %r{<instanceDetails>.*?<iCalUid>([^<]+)</iCalUid>}m
166
174
 
175
+ # Public helper: parses an ISO/RFC time string, returning nil for nil/garbage input.
176
+ # Shared with MeetingPage which doesn't include the module.
177
+ def self.safe_parse_time(time_str)
178
+ return nil unless time_str
179
+
180
+ Time.parse(time_str)
181
+ rescue ArgumentError, TypeError
182
+ nil
183
+ end
184
+
167
185
  private
168
186
 
169
187
  def classify_meeting_messages(messages_data)
@@ -364,6 +382,200 @@ module Teems
364
382
  end
365
383
  end
366
384
 
385
+ # A single page of meeting chat messages with the cursor to the page before it.
386
+ # Constructor normalizes a blank backward_link to nil so `last?` is a simple nil check.
387
+ # Date comparisons use the user's local timezone to match `--date` semantics.
388
+ #
389
+ # `segment_cursor` is `_metadata.lastCompleteSegmentStartTime` (a Time) — the API's
390
+ # boundary marker for "all messages from here on are fully loaded." Prefer it for
391
+ # `covers_through?` because pages can intersperse old replies/system messages, making
392
+ # the message-level `oldest_local_date` an unreliable stop signal. Falls back to the
393
+ # oldest message when the cursor is absent (e.g. in synthetic test data).
394
+ MeetingPage = Data.define(:messages, :backward_link, :segment_cursor) do
395
+ def self.from_response(response)
396
+ msgs, value_msgs, meta_or_nil = response.values_at('messages', 'value', '_metadata')
397
+ meta = meta_or_nil || {}
398
+ new(messages: msgs || value_msgs || [],
399
+ backward_link: meta['backwardLink'],
400
+ segment_cursor: cursor_from_millis(meta['lastCompleteSegmentStartTime']))
401
+ end
402
+
403
+ def self.cursor_from_millis(millis)
404
+ return nil unless millis.is_a?(Numeric)
405
+
406
+ Time.at(millis / 1000.0)
407
+ end
408
+
409
+ def initialize(messages:, backward_link:, segment_cursor: nil)
410
+ super(messages: messages,
411
+ backward_link: backward_link.to_s.empty? ? nil : backward_link,
412
+ segment_cursor: segment_cursor)
413
+ end
414
+
415
+ def empty? = messages.empty?
416
+ def last? = !backward_link
417
+
418
+ def oldest_local_date
419
+ oldest_message_time&.localtime&.to_date
420
+ end
421
+
422
+ # Strict `<` is load-bearing: when the cursor equals target_date, we must keep
423
+ # paginating to ensure we've fetched all of target_date's messages.
424
+ def covers_through?(target_date)
425
+ cursor = segment_cursor || oldest_message_time
426
+ cursor && cursor.localtime.to_date < target_date
427
+ end
428
+
429
+ def terminal_for?(target_date)
430
+ empty? || last? || covers_through?(target_date)
431
+ end
432
+
433
+ private
434
+
435
+ def oldest_message_time
436
+ messages.filter_map { |msg| MeetingMessageParser.safe_parse_time(msg['composetime']) }.min
437
+ end
438
+ end
439
+
440
+ # Pages backward through chat messages until we've fetched past the target date.
441
+ # The ng.msg wrapper requests 200 messages per page and exposes no messageType filter,
442
+ # so for recurring meetings we paginate via _metadata.backwardLink.
443
+ module MeetingPagination
444
+ # Per-page request size used by Api::Messages#chat_messages_page.
445
+ PAGE_SIZE = 200
446
+
447
+ # Safety net: 50 pages × 200 = 10K messages worst case (~25s with the rate-limit
448
+ # cushion). Beyond this we assume the requested date is older than retained history
449
+ # and surface a user-visible error.
450
+ MAX_PAGES = 50
451
+
452
+ # Polite pause between page fetches. Matches Services::SyncEngine::API_DELAY_SECONDS.
453
+ PAGE_DELAY_SECONDS = 0.5
454
+
455
+ private
456
+
457
+ # Returns the accumulated messages, or nil if the API errored mid-pagination
458
+ # (so callers can fail loudly instead of rendering partial data as success).
459
+ def paginate_meeting_messages(thread_id, target_date)
460
+ @page_count = 0
461
+ @pagination_link = nil
462
+ @paginated_messages = []
463
+ return nil unless run_pagination_loop(thread_id, target_date)
464
+
465
+ @paginated_messages
466
+ end
467
+
468
+ def run_pagination_loop(thread_id, target_date)
469
+ loop do
470
+ page = fetch_meeting_page(thread_id, @pagination_link)
471
+ return false unless page
472
+
473
+ merge_meeting_page(page)
474
+ return true if pagination_done?(page, target_date)
475
+
476
+ @pagination_link = page.backward_link
477
+ page_pause
478
+ end
479
+ end
480
+
481
+ def page_pause
482
+ sleep(PAGE_DELAY_SECONDS)
483
+ end
484
+
485
+ def fetch_meeting_page(thread_id, backward_link)
486
+ response = with_token_refresh do
487
+ runner.messages_api.chat_messages_page(
488
+ chat_id: thread_id, limit: PAGE_SIZE, backward_link: backward_link
489
+ )
490
+ end
491
+ MeetingPage.from_response(response)
492
+ rescue ApiError => e
493
+ error("Failed to fetch meeting messages: #{e.message}")
494
+ nil
495
+ end
496
+
497
+ def merge_meeting_page(page)
498
+ page_messages = page.messages
499
+ @paginated_messages.concat(page_messages)
500
+ @page_count += 1
501
+ debug("Page #{@page_count}: #{page_messages.length} message(s) " \
502
+ "(oldest #{page.oldest_local_date}); total #{@paginated_messages.length}")
503
+ end
504
+
505
+ def pagination_done?(page, target_date)
506
+ return true if page.terminal_for?(target_date)
507
+
508
+ reached_pagination_cap?
509
+ end
510
+
511
+ def reached_pagination_cap?
512
+ return false if @page_count < MAX_PAGES
513
+
514
+ error("Stopped after #{MAX_PAGES} pages (~#{MAX_PAGES * PAGE_SIZE} messages); " \
515
+ '--date may predate available history')
516
+ true
517
+ end
518
+ end
519
+
520
+ # Filters classified call events, recordings, transcripts, and chat messages to a
521
+ # single date. Used to select one occurrence of a recurring meeting series.
522
+ # Date comparison uses the user's local timezone.
523
+ module MeetingDateFilter
524
+ # All four classification buckets are date-filtered. Parsed buckets (call_events,
525
+ # recordings, transcripts) carry :time (symbol); raw chat_messages carry
526
+ # 'composetime' (string) — `item_on_date?` accepts either shape.
527
+ DATE_KEYS = %i[call_events recordings transcripts chat_messages].freeze
528
+
529
+ private
530
+
531
+ def validate_date_option
532
+ raw = @options[:date]
533
+ return nil unless raw
534
+
535
+ @options[:target_date] = Date.parse(raw)
536
+ nil
537
+ rescue ArgumentError, TypeError
538
+ error("Invalid --date value: #{raw.inspect} (expected YYYY-MM-DD)")
539
+ 1
540
+ end
541
+
542
+ def apply_date_option(classified)
543
+ target_date = @options[:target_date]
544
+ return classified unless target_date
545
+
546
+ filtered = filter_classified_by_date(classified, target_date)
547
+ return filtered unless date_filter_empty?(filtered)
548
+
549
+ error("No meeting activity found for #{@options[:date]}")
550
+ nil
551
+ end
552
+
553
+ def date_filter_empty?(classified)
554
+ DATE_KEYS.all? { |key| classified[key].empty? }
555
+ end
556
+
557
+ def filter_classified_by_date(classified, target_date)
558
+ classified.to_h { |key, val| [key, filter_classified_value(key, val, target_date)] }
559
+ end
560
+
561
+ def filter_classified_value(key, val, target_date)
562
+ DATE_KEYS.include?(key) ? items_on_date(val, target_date) : val
563
+ end
564
+
565
+ def items_on_date(items, target_date)
566
+ items.select { |item| item_on_date?(item, target_date) }
567
+ end
568
+
569
+ def item_on_date?(item, target_date)
570
+ time_str = item[:time] || item['composetime']
571
+ return false unless time_str
572
+
573
+ Time.parse(time_str).localtime.to_date == target_date
574
+ rescue ArgumentError, TypeError
575
+ false
576
+ end
577
+ end
578
+
367
579
  # Filters classified messages to a specific call instance by callId
368
580
  module MeetingCallFilter
369
581
  private
@@ -396,13 +608,33 @@ module Teems
396
608
  end
397
609
  end
398
610
 
611
+ # Machine-readable meeting details; `teems transcripts sync` uses these to enumerate recordings.
612
+ module MeetingJsonSummary
613
+ private
614
+
615
+ def output_meeting_json(target, classified)
616
+ output_json(thread_id: target[:thread_id],
617
+ call_events: classified[:call_events].map { |event| json_call_event(event) },
618
+ recordings: classified[:recordings].map { |rec| rec.slice(:time, :url, :call_id) },
619
+ transcripts: classified[:transcripts].map { |item| item.slice(:time) })
620
+ 0
621
+ end
622
+
623
+ def json_call_event(event)
624
+ event.slice(:time, :call_id, :duration).merge(participants: event[:participants].map { |part| part[:name] })
625
+ end
626
+ end
627
+
399
628
  # View meeting details, chat, transcripts, and recordings
400
629
  class Meeting < Base
401
630
  include MeetingTargetResolver
402
631
  include MeetingMessageParser
403
632
  include MeetingDisplay
633
+ include MeetingJsonSummary
404
634
  include MeetingChatDisplay
405
635
  include MeetingCallFilter
636
+ include MeetingDateFilter
637
+ include MeetingPagination
406
638
  include MeetingTranscript
407
639
  include MeetingRecording
408
640
 
@@ -422,6 +654,13 @@ module Teems
422
654
  target ? process_meeting(target) : 1
423
655
  end
424
656
 
657
+ def validate_options
658
+ result = super
659
+ return result if result
660
+
661
+ validate_date_option
662
+ end
663
+
425
664
  protected
426
665
 
427
666
  MEETING_OPTIONS = MeetingOptionDefs::ALL
@@ -442,11 +681,21 @@ module Teems
442
681
  return 1 unless messages_data
443
682
 
444
683
  classified = classify_and_filter(messages_data, target)
684
+ classified = apply_date_option(classified)
685
+ return 1 unless classified
686
+
445
687
  dispatch_mode(target, classified)
446
688
  end
447
689
 
448
690
  def fetch_meeting_messages(thread_id)
449
691
  debug("Fetching messages for thread: #{thread_id}")
692
+ target_date = @options[:target_date]
693
+ return paginate_meeting_messages(thread_id, target_date) if target_date
694
+
695
+ single_page_meeting_messages(thread_id)
696
+ end
697
+
698
+ def single_page_meeting_messages(thread_id)
450
699
  response = with_token_refresh do
451
700
  runner.messages_api.chat_messages(chat_id: thread_id, limit: @options[:limit])
452
701
  end
@@ -464,6 +713,7 @@ module Teems
464
713
  media = media_output_spec
465
714
  return download_media_with_transcript(target, classified, media) if media
466
715
  return download_transcript(target, classified) || 0 if @options[:transcript]
716
+ return output_meeting_json(target, classified) if @options[:json]
467
717
 
468
718
  display_meeting_summary(target, classified)
469
719
  0
@@ -138,24 +138,39 @@ module Teems
138
138
  end
139
139
  end
140
140
 
141
+ # Chooses which recording's transcript to fetch (--recording-url, else the first link).
142
+ module MeetingRecordingSelection
143
+ private
144
+
145
+ def first_recording_url(classified)
146
+ @options[:recording_url] || recording_urls(classified).first
147
+ end
148
+
149
+ def unknown_recording_url?(classified)
150
+ requested = @options[:recording_url]
151
+ requested && !recording_urls(classified).include?(requested)
152
+ end
153
+
154
+ def recording_urls(classified) = classified[:recordings].filter_map { |rec| rec[:url] }
155
+ end
156
+
141
157
  # Downloads meeting transcripts via SharePoint API (no Safari required)
142
158
  module MeetingTranscript
159
+ include MeetingRecordingSelection
143
160
  include EmbedPageParser
144
161
  include MeetingFilename
145
162
 
146
163
  private
147
164
 
148
165
  def download_transcript(target, classified)
166
+ return error('--recording-url is not a recording in this meeting') if unknown_recording_url?(classified)
167
+
149
168
  sharing_url = target[:fileUrl] || first_recording_url(classified)
150
169
  return error('No recording sharing link found for transcript download') unless sharing_url
151
170
 
152
171
  execute_transcript_pipeline(sharing_url)
153
172
  end
154
173
 
155
- def first_recording_url(classified)
156
- classified[:recordings].filter_map { |rec| rec[:url] }.first
157
- end
158
-
159
174
  def execute_transcript_pipeline(sharing_url)
160
175
  info('Fetching transcript via SharePoint...')
161
176
  embed_url = fetch_embed_url(sharing_url)
@@ -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