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 +4 -4
- data/CHANGELOG.md +36 -0
- data/lib/teems/api/messages.rb +6 -0
- data/lib/teems/commands/auth.rb +5 -3
- data/lib/teems/commands/cal.rb +1 -51
- data/lib/teems/commands/meeting.rb +253 -9
- data/lib/teems/commands/meeting_recording.rb +72 -37
- data/lib/teems/commands/meeting_transcript.rb +31 -2
- data/lib/teems/commands/messages.rb +48 -1
- data/lib/teems/commands/ooo.rb +123 -47
- data/lib/teems/commands/org.rb +7 -5
- data/lib/teems/services/headless_extract.rb +2 -3
- data/lib/teems/services/safari_js_runner.rb +1 -3
- data/lib/teems/services/safari_oauth.rb +54 -20
- data/lib/teems/services/token_extractor.rb +2 -2
- data/lib/teems/support/subprocess.rb +29 -0
- data/lib/teems/support/time_parsing.rb +55 -0
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +2 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5932afeb278892c20e7e3fbb92980da31ff45cd9a528a3e2463de38ee7ea3f70
|
|
4
|
+
data.tar.gz: 331a38b48c8bb1d87ff770a0579e1846aa643b5d1d1daf5061c0bbf8f0589264
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/teems/api/messages.rb
CHANGED
|
@@ -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/commands/auth.rb
CHANGED
|
@@ -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
|
|
225
|
-
puts '
|
|
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 "\
|
|
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
|
|
data/lib/teems/commands/cal.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
306
|
+
parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity]) }
|
|
284
307
|
end
|
|
285
308
|
|
|
286
|
-
def format_participant_line(name, identity
|
|
309
|
+
def format_participant_line(name, identity)
|
|
287
310
|
resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
|
|
288
|
-
" #{resolved}
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|