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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/teems/api/messages.rb +6 -0
- data/lib/teems/commands/auth.rb +5 -3
- data/lib/teems/commands/meeting.rb +227 -0
- data/lib/teems/commands/messages.rb +48 -1
- 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/version.rb +1 -1
- data/lib/teems.rb +1 -0
- metadata +2 -1
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,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
|
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
|
|
|
@@ -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
|
-
|
|
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
|
|
data/lib/teems/commands/org.rb
CHANGED
|
@@ -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(
|
|
11
|
+
collect_managers(target_id, fetch, Set[target_id])
|
|
14
12
|
end
|
|
15
13
|
|
|
16
|
-
def collect_managers(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
data/lib/teems.rb
CHANGED
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.
|
|
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
|