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