teems 0.1.0 → 0.3.0

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.
@@ -0,0 +1,488 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'meeting_transcript'
4
+ require_relative 'meeting_recording'
5
+
6
+ module Teems
7
+ module Commands
8
+ MEETING_HELP = <<~HELP
9
+ teems meeting - View meeting details, chat, transcripts, and recordings
10
+
11
+ USAGE:
12
+ teems meeting <target> [options]
13
+
14
+ ARGUMENTS:
15
+ target Thread ID (19:meeting_...@thread.v2), calendar event ID,
16
+ or Teams meeting URL
17
+
18
+ OPTIONS:
19
+ --transcript Download meeting transcript (WebVTT)
20
+ --recording Download meeting recording (MP4, requires ffmpeg)
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
24
+ --chat Show meeting chat messages
25
+ -o, --output-dir Directory for downloads (default: current directory)
26
+ -v, --verbose Show debug output
27
+ -q, --quiet Suppress output
28
+ --json Output as JSON
29
+ -h, --help Show this help
30
+
31
+ EXAMPLES:
32
+ teems meeting 19:meeting_abc123@thread.v2
33
+ teems meeting 19:meeting_abc123@thread.v2 --chat
34
+ teems meeting 19:meeting_abc123@thread.v2 --transcript
35
+ teems meeting 19:meeting_abc123@thread.v2 --audio -o ~/Downloads # video + audio
36
+ teems meeting 19:meeting_abc123@thread.v2 --audio --no-video -o ~/Downloads # audio only
37
+ teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
38
+ teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
39
+ teems meeting AAMkAGVmMDEz... # By calendar event ID
40
+ HELP
41
+
42
+ # Option definitions for the meeting command
43
+ module MeetingOptionDefs
44
+ ALL = {
45
+ '--transcript' => ->(opts, _args) { opts[:transcript] = true },
46
+ '--recording' => ->(opts, _args) { opts[:recording] = true },
47
+ '--audio' => ->(opts, _args) { opts[:audio] = true },
48
+ '--no-video' => ->(opts, _args) { opts[:no_video] = true },
49
+ '--chat' => ->(opts, _args) { opts[:chat] = true },
50
+ '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
51
+ '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
52
+ }.freeze
53
+ end
54
+
55
+ # Resolves meeting targets: thread ID, event ID, or Teams URL
56
+ module MeetingTargetResolver
57
+ MEETING_THREAD_PREFIX = '19:meeting_'
58
+ EVENT_ID_PREFIX = 'AAMk'
59
+ JOIN_URL_PATTERN = %r{/l/meetup-join/([^/?]+)}
60
+ CHAT_URL_PATTERN = %r{/l/chat/([^/?]+)}
61
+ RECAP_PARAMS = %w[callId organizerId tenantId iCalUid driveId driveItemId fileUrl].freeze
62
+
63
+ private
64
+
65
+ def resolve_meeting_target
66
+ raw = positional_args.first
67
+ return missing_target_error unless raw
68
+
69
+ resolve_raw_target(raw)
70
+ end
71
+
72
+ def resolve_raw_target(raw)
73
+ if raw.start_with?('https://')
74
+ resolve_url_target(raw)
75
+ elsif raw.start_with?(EVENT_ID_PREFIX)
76
+ resolve_event_target(raw)
77
+ else
78
+ { thread_id: raw }
79
+ end
80
+ end
81
+
82
+ def resolve_url_target(url)
83
+ thread_id = extract_meeting_thread(url)
84
+ return build_url_target(url, thread_id) if thread_id
85
+
86
+ parsed = Services::TeamsUrlParser.parse(url)
87
+ return { thread_id: parsed.conversation_id } if parsed
88
+
89
+ error('Could not parse meeting URL')
90
+ nil
91
+ end
92
+
93
+ def build_url_target(url, thread_id)
94
+ target = { thread_id: thread_id }
95
+ query = URI.parse(url).query
96
+ return target unless query
97
+
98
+ params = URI.decode_www_form(query).to_h
99
+ RECAP_PARAMS.each { |key| (val = params[key]) && target[key.to_sym] = val }
100
+ target
101
+ rescue URI::InvalidURIError
102
+ target
103
+ end
104
+
105
+ def extract_meeting_thread(url)
106
+ extract_thread_from_path(url) || extract_thread_from_query(url)
107
+ end
108
+
109
+ def extract_thread_from_path(url)
110
+ [JOIN_URL_PATTERN, CHAT_URL_PATTERN].each do |pattern|
111
+ match = url.match(pattern)
112
+ next unless match
113
+
114
+ decoded = URI.decode_www_form_component(match[1])
115
+ return decoded if decoded.start_with?(MEETING_THREAD_PREFIX)
116
+ end
117
+ nil
118
+ end
119
+
120
+ def extract_thread_from_query(url)
121
+ query = URI.parse(url).query
122
+ return unless query
123
+
124
+ thread_param = URI.decode_www_form(query).to_h['threadId']
125
+ return unless thread_param
126
+
127
+ decoded = URI.decode_www_form_component(thread_param)
128
+ decoded if decoded.start_with?(MEETING_THREAD_PREFIX)
129
+ rescue URI::InvalidURIError
130
+ nil
131
+ end
132
+
133
+ def resolve_event_target(event_id)
134
+ debug("Resolving event ID: #{event_id}")
135
+ join_url = fetch_event_join_url(event_id)
136
+ return nil unless join_url
137
+
138
+ thread_id = extract_meeting_thread(join_url)
139
+ return error('Could not extract thread ID from meeting link') && nil unless thread_id
140
+
141
+ { thread_id: thread_id, event_id: event_id }
142
+ end
143
+
144
+ def fetch_event_join_url(event_id)
145
+ event = with_token_refresh { runner.calendar_api.get_event(event_id: event_id, timezone: 'UTC') }
146
+ event.online_meeting_url || (error('Event has no Teams meeting link') && nil)
147
+ rescue ApiError => e
148
+ error("Failed to fetch event: #{e.message}")
149
+ nil
150
+ end
151
+
152
+ def missing_target_error
153
+ error('Target required. Specify a thread ID, event ID, or Teams meeting URL.')
154
+ puts
155
+ puts 'Usage: teems meeting <target> [options]'
156
+ nil
157
+ end
158
+ end
159
+
160
+ # Parses meeting chat messages to extract call events, recordings, transcripts
161
+ module MeetingMessageParser
162
+ PART_RE = %r{<part\s[^>]*identity="([^"]+)"[^>]*>.*?<name>([^<]*)</name>
163
+ .*?<displayName>([^<]*)</displayName>.*?<duration>([^<]*)</duration>}xm
164
+ CALLID_RE = %r{<callId>([^<]+)</callId>}
165
+ INSTANCE_ICAL_RE = %r{<instanceDetails>.*?<iCalUid>([^<]+)</iCalUid>}m
166
+
167
+ private
168
+
169
+ def classify_meeting_messages(messages_data)
170
+ result = empty_classification
171
+ messages_data.each { |msg| classify_single_message(msg, result) }
172
+ result[:call_events].reject! { |evt| evt[:participants].empty? }
173
+ result
174
+ end
175
+
176
+ def empty_classification
177
+ { call_events: [], recordings: [], transcripts: [], chat_messages: [] }
178
+ end
179
+
180
+ def classify_single_message(msg, result)
181
+ case msg['messagetype']
182
+ when 'Event/Call' then result[:call_events] << parse_call_event(msg)
183
+ when 'RichText/Media_CallRecording' then result[:recordings] << parse_recording(msg)
184
+ when 'RichText/Media_CallTranscript' then result[:transcripts] << parse_transcript(msg)
185
+ else result[:chat_messages] << msg unless system_activity?(msg)
186
+ end
187
+ end
188
+
189
+ def system_activity?(msg)
190
+ type = msg['messagetype'].to_s
191
+ type.start_with?('ThreadActivity/') || type == 'Control/Typing'
192
+ end
193
+
194
+ def parse_call_event(msg)
195
+ content = msg['content'].to_s
196
+ parts = extract_partlist(content)
197
+ build_msg_hash(msg).merge(
198
+ call_id: content.match(CALLID_RE)&.captures&.first,
199
+ ical_uid: content.match(INSTANCE_ICAL_RE)&.captures&.first,
200
+ duration: parts.first&.dig(:duration),
201
+ participants: parts
202
+ )
203
+ end
204
+
205
+ def extract_partlist(content)
206
+ content.scan(PART_RE).filter_map do |identity, _name, display, duration|
207
+ build_participant(identity, display, duration) unless identity.start_with?('28:')
208
+ end
209
+ end
210
+
211
+ def build_participant(identity, display_name, duration)
212
+ name = decode_xml_entities(display_name)
213
+ { identity: identity, name: name, duration: duration }
214
+ end
215
+
216
+ def decode_xml_entities(text)
217
+ text.gsub('&amp;', '&').gsub('&apos;', "'").gsub('&lt;', '<').gsub('&gt;', '>').gsub('&quot;', '"')
218
+ end
219
+
220
+ def parse_recording(msg)
221
+ content = msg['content'].to_s
222
+ build_msg_hash(msg).merge(url: extract_href(content), call_id: extract_call_id(content))
223
+ end
224
+
225
+ def parse_transcript(msg)
226
+ props_raw = msg.dig('properties', 'cards') || msg.dig('properties', 'callTranscript')
227
+ build_msg_hash(msg).merge(properties: safe_parse_json(props_raw))
228
+ end
229
+
230
+ def build_msg_hash(msg)
231
+ { id: msg['id'], time: msg['composetime'], content: msg['content'].to_s }
232
+ end
233
+
234
+ def extract_href(html)
235
+ match = html.match(/href="([^"]+)"/)
236
+ match ? match[1] : nil
237
+ end
238
+
239
+ def extract_call_id(content)
240
+ match = content.match(/callId=([a-f0-9-]+)/i)
241
+ match ? match[1] : nil
242
+ end
243
+
244
+ def safe_parse_json(raw)
245
+ return nil unless raw.is_a?(String) && !raw.empty?
246
+
247
+ JSON.parse(raw)
248
+ rescue JSON::ParserError
249
+ nil
250
+ end
251
+ end
252
+
253
+ # Displays meeting summary information
254
+ module MeetingDisplay
255
+ private
256
+
257
+ def display_meeting_summary(target, classified)
258
+ puts output.bold('Meeting Details')
259
+ puts " Thread: #{target[:thread_id]}"
260
+ display_organizer(target[:organizerId])
261
+ display_call_events(classified[:call_events])
262
+ display_assets_summary(classified)
263
+ end
264
+
265
+ def display_organizer(organizer_id)
266
+ return unless organizer_id
267
+
268
+ profile = with_token_refresh { runner.users_api.get_user(organizer_id) }
269
+ puts " Organizer: #{profile.display_name}"
270
+ rescue ApiError => e
271
+ debug("Could not resolve organizer: #{e.message}")
272
+ nil
273
+ end
274
+
275
+ def display_call_events(call_events)
276
+ return puts(' No call events found') if call_events.empty?
277
+
278
+ call_events.each { |event| display_single_call_event(event) }
279
+ end
280
+
281
+ def display_single_call_event(event)
282
+ puts
283
+ header = " #{output.bold('Call Event')} #{format_time_str(event[:time])}"
284
+ puts "#{header} (#{format_call_duration(event[:duration])})"
285
+ display_participants(event[:participants])
286
+ end
287
+
288
+ def display_participants(parts)
289
+ return if parts.empty?
290
+
291
+ puts " Participants (#{parts.length}):"
292
+ parts.each { |entry| puts format_participant_line(entry[:name], entry[:identity]) }
293
+ end
294
+
295
+ def format_participant_line(name, identity)
296
+ resolved = needs_resolution?(name) ? resolve_participant_name(identity) : name
297
+ " #{resolved}"
298
+ end
299
+
300
+ def needs_resolution?(name)
301
+ name.empty? || name.match?(/\A\d*:/)
302
+ end
303
+
304
+ def resolve_participant_name(identity)
305
+ uuid = identity.match(/8:orgid:(.+)/)&.captures&.first
306
+ return identity unless uuid
307
+
308
+ @name_cache ||= {}
309
+ @name_cache[uuid] ||= fetch_user_name(uuid, identity)
310
+ end
311
+
312
+ def fetch_user_name(uuid, identity)
313
+ profile = with_token_refresh { runner.users_api.get_user(uuid) }
314
+ profile.display_name
315
+ rescue ApiError => e
316
+ debug("Could not resolve user #{uuid}: #{e.message}")
317
+ identity
318
+ end
319
+
320
+ def format_call_duration(seconds_str)
321
+ total = seconds_str.to_i
322
+ return '< 1 min' if total < 60
323
+
324
+ mins = total / 60
325
+ "#{mins} min"
326
+ end
327
+
328
+ def display_assets_summary(classified)
329
+ lines = asset_lines(classified[:recordings], 'Recordings') +
330
+ asset_lines(classified[:transcripts], 'Transcripts')
331
+ return if lines.empty?
332
+
333
+ puts
334
+ puts " #{output.bold('Assets')}:"
335
+ lines.each { |line| puts line }
336
+ end
337
+
338
+ def asset_lines(items, label)
339
+ items.empty? ? [] : [" #{label}: #{items.length}"]
340
+ end
341
+
342
+ def format_time_str(time_str)
343
+ return '' unless time_str
344
+
345
+ parsed = Time.parse(time_str)
346
+ output.blue("[#{parsed.strftime('%Y-%m-%d %H:%M')}]")
347
+ rescue ArgumentError
348
+ ''
349
+ end
350
+ end
351
+
352
+ # Displays meeting chat messages (non-system messages)
353
+ module MeetingChatDisplay
354
+ private
355
+
356
+ def display_meeting_chat(chat_messages)
357
+ messages = chat_messages.map { |msg_data| Models::Message.from_api(msg_data) }
358
+ .reject(&:system_message?)
359
+ .reverse
360
+ return puts('No chat messages found') if messages.empty?
361
+
362
+ formatter = runner.message_formatter
363
+ messages.each { |msg| puts formatter.format(msg) }
364
+ end
365
+ end
366
+
367
+ # Filters classified messages to a specific call instance by callId
368
+ module MeetingCallFilter
369
+ private
370
+
371
+ def classify_and_filter(messages_data, target)
372
+ classified = classify_meeting_messages(messages_data)
373
+ filter_classified(classified, target)
374
+ end
375
+
376
+ def filter_classified(classified, target)
377
+ ical = target[:iCalUid]
378
+ return filter_by_ical(classified, ical) if ical
379
+
380
+ call_id = target[:callId]
381
+ call_id ? filter_by_call_id(classified, call_id) : classified
382
+ end
383
+
384
+ def filter_by_ical(classified, ical_uid)
385
+ events = classified[:call_events].select { |evt| evt[:ical_uid] == ical_uid }
386
+ return classified if events.empty?
387
+
388
+ classified.merge(call_events: events)
389
+ end
390
+
391
+ def filter_by_call_id(classified, call_id)
392
+ events = classified[:call_events].select { |evt| evt[:call_id] == call_id }
393
+ return classified if events.empty?
394
+
395
+ classified.merge(call_events: events)
396
+ end
397
+ end
398
+
399
+ # View meeting details, chat, transcripts, and recordings
400
+ class Meeting < Base
401
+ include MeetingTargetResolver
402
+ include MeetingMessageParser
403
+ include MeetingDisplay
404
+ include MeetingChatDisplay
405
+ include MeetingCallFilter
406
+ include MeetingTranscript
407
+ include MeetingRecording
408
+
409
+ def initialize(args, runner:)
410
+ @options = {}
411
+ super
412
+ end
413
+
414
+ def execute
415
+ result = validate_options
416
+ return result if result
417
+
418
+ auth_result = require_auth
419
+ return auth_result if auth_result
420
+
421
+ target = resolve_meeting_target
422
+ target ? process_meeting(target) : 1
423
+ end
424
+
425
+ protected
426
+
427
+ MEETING_OPTIONS = MeetingOptionDefs::ALL
428
+
429
+ def handle_option(arg, pending)
430
+ handler = MEETING_OPTIONS[arg]
431
+ return super unless handler
432
+
433
+ handler.call(@options, pending)
434
+ end
435
+
436
+ def help_text = MEETING_HELP
437
+
438
+ private
439
+
440
+ def process_meeting(target)
441
+ messages_data = fetch_meeting_messages(target[:thread_id])
442
+ return 1 unless messages_data
443
+
444
+ classified = classify_and_filter(messages_data, target)
445
+ dispatch_mode(target, classified)
446
+ end
447
+
448
+ def fetch_meeting_messages(thread_id)
449
+ debug("Fetching messages for thread: #{thread_id}")
450
+ response = with_token_refresh do
451
+ runner.messages_api.chat_messages(chat_id: thread_id, limit: @options[:limit])
452
+ end
453
+ response['messages'] || response['posts'] || response['value'] || []
454
+ rescue ApiError => e
455
+ error("Failed to fetch meeting messages: #{e.message}")
456
+ nil
457
+ end
458
+
459
+ def dispatch_mode(target, classified)
460
+ if @options[:chat]
461
+ display_meeting_chat(classified[:chat_messages])
462
+ return 0
463
+ end
464
+ media = media_output_spec
465
+ return download_media_with_transcript(target, classified, media) if media
466
+ return download_transcript(target, classified) || 0 if @options[:transcript]
467
+
468
+ display_meeting_summary(target, classified)
469
+ 0
470
+ end
471
+
472
+ def media_output_spec
473
+ audio, recording, no_video = @options.values_at(:audio, :recording, :no_video)
474
+ return nil unless audio || recording || no_video
475
+
476
+ { video: (recording || audio) && !no_video, audio: audio || no_video }
477
+ end
478
+
479
+ def download_media_with_transcript(target, classified, media)
480
+ if @options[:transcript]
481
+ result = download_transcript(target, classified)
482
+ warn("Transcript download failed; proceeding with #{media[:video] ? 'recording' : 'audio'}") if result == 1
483
+ end
484
+ download_recording(target, classified, media: media) || 0
485
+ end
486
+ end
487
+ end
488
+ end