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
|
@@ -176,32 +176,86 @@ module Teems
|
|
|
176
176
|
end
|
|
177
177
|
end
|
|
178
178
|
|
|
179
|
+
# Writes video/audio output files from downloaded DASH tracks via ffmpeg
|
|
180
|
+
module RecordingOutputWriter
|
|
181
|
+
private
|
|
182
|
+
|
|
183
|
+
def produce_outputs(video_path, audio_path, media)
|
|
184
|
+
video_result = media[:video] ? write_video_output(video_path, audio_path) : true
|
|
185
|
+
audio_result = media[:audio] ? write_audio_output(audio_path) : true
|
|
186
|
+
FileUtils.rm_f([video_path, audio_path].compact)
|
|
187
|
+
video_result && audio_result ? 0 : 1
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def write_video_output(video_path, audio_path)
|
|
191
|
+
output_path = File.join(@rec_dir, "#{@recording_stem}.mp4")
|
|
192
|
+
info('Merging video and audio tracks...')
|
|
193
|
+
merged = run_ffmpeg('-i', video_path, '-i', audio_path, '-c', 'copy', output_path)
|
|
194
|
+
return error('ffmpeg merge failed') && false unless merged
|
|
195
|
+
|
|
196
|
+
embed_subtitle_if_available(output_path)
|
|
197
|
+
success("Recording saved to #{output_path}")
|
|
198
|
+
true
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def write_audio_output(audio_path)
|
|
202
|
+
output_path = File.join(@rec_dir, "#{@recording_stem}.m4a")
|
|
203
|
+
info('Remuxing audio track...')
|
|
204
|
+
remuxed = run_ffmpeg('-i', audio_path, '-c', 'copy', output_path)
|
|
205
|
+
return error('ffmpeg audio remux failed') && false unless remuxed
|
|
206
|
+
|
|
207
|
+
success("Audio saved to #{output_path}")
|
|
208
|
+
true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def embed_subtitle_if_available(video_path)
|
|
212
|
+
vtt_path = paired_vtt_path(video_path)
|
|
213
|
+
return unless vtt_path
|
|
214
|
+
|
|
215
|
+
info('Embedding transcript as subtitle track...')
|
|
216
|
+
final_path = video_path.sub('.mp4', '_subs.mp4')
|
|
217
|
+
return unless run_ffmpeg('-i', video_path, '-i', vtt_path, '-c', 'copy', '-c:s', 'mov_text', final_path)
|
|
218
|
+
|
|
219
|
+
FileUtils.mv(final_path, video_path)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def paired_vtt_path(video_path)
|
|
223
|
+
paired = video_path.sub(/\.mp4\z/, '.vtt')
|
|
224
|
+
return paired if File.exist?(paired)
|
|
225
|
+
|
|
226
|
+
Dir.glob(File.join(File.dirname(video_path), '*.vtt')).first
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
179
230
|
# Downloads meeting recordings via DASH streaming from SharePoint
|
|
180
231
|
module MeetingRecording
|
|
232
|
+
include MeetingFilename
|
|
181
233
|
include RecordingResolver
|
|
182
234
|
include SegmentDownloader
|
|
235
|
+
include RecordingOutputWriter
|
|
183
236
|
|
|
184
237
|
MANIFEST_PARAMS = 'format=dash&part=index'
|
|
185
238
|
|
|
186
239
|
private
|
|
187
240
|
|
|
188
|
-
def download_recording(target, classified)
|
|
241
|
+
def download_recording(target, classified, media: { video: true, audio: false })
|
|
189
242
|
sharing_url = target[:fileUrl] || classified[:recordings].filter_map { |rec| rec[:url] }.first
|
|
190
243
|
return error('No recordings found for this meeting') unless sharing_url
|
|
191
244
|
return error('ffmpeg is required. Install with: brew install ffmpeg') unless ffmpeg?
|
|
192
245
|
|
|
193
|
-
execute_recording_pipeline(sharing_url)
|
|
246
|
+
execute_recording_pipeline(sharing_url, media)
|
|
194
247
|
end
|
|
195
248
|
|
|
196
|
-
def execute_recording_pipeline(sharing_url)
|
|
197
|
-
info(
|
|
249
|
+
def execute_recording_pipeline(sharing_url, media)
|
|
250
|
+
info("Fetching #{media[:video] ? 'recording' : 'audio'} via SharePoint...")
|
|
198
251
|
file_info = resolve_recording_file_info(sharing_url)
|
|
199
252
|
return 1 unless file_info
|
|
200
253
|
|
|
254
|
+
@recording_stem = derive_output_stem(file_info[:name])
|
|
201
255
|
manifest = fetch_dash_manifest(file_info[:transform_url])
|
|
202
256
|
return error('Could not fetch DASH manifest') unless manifest
|
|
203
257
|
|
|
204
|
-
download_and_assemble(manifest)
|
|
258
|
+
download_and_assemble(manifest, media)
|
|
205
259
|
end
|
|
206
260
|
|
|
207
261
|
def fetch_dash_manifest(transform_url)
|
|
@@ -227,29 +281,33 @@ module Teems
|
|
|
227
281
|
nil
|
|
228
282
|
end
|
|
229
283
|
|
|
230
|
-
def download_and_assemble(manifest_xml)
|
|
284
|
+
def download_and_assemble(manifest_xml, media)
|
|
231
285
|
tracks = DashManifestParser.new(manifest_xml).parse
|
|
232
|
-
selected = select_tracks(tracks)
|
|
286
|
+
selected = select_tracks(tracks, media)
|
|
233
287
|
return error('No video/audio tracks found in manifest') unless selected
|
|
234
288
|
|
|
235
|
-
assemble_recording(selected, manifest_xml)
|
|
289
|
+
assemble_recording(selected, manifest_xml, media)
|
|
236
290
|
end
|
|
237
291
|
|
|
238
|
-
def select_tracks(tracks)
|
|
292
|
+
def select_tracks(tracks, media)
|
|
239
293
|
grouped = tracks.group_by(&:type)
|
|
240
|
-
video = grouped['video']&.first
|
|
241
294
|
audio = grouped['audio']&.first
|
|
242
|
-
|
|
295
|
+
return nil unless audio
|
|
296
|
+
|
|
297
|
+
video = grouped['video']&.first
|
|
298
|
+
return nil if media[:video] && !video
|
|
299
|
+
|
|
300
|
+
{ video: video, audio: audio }
|
|
243
301
|
end
|
|
244
302
|
|
|
245
|
-
def assemble_recording(selected, manifest_xml)
|
|
303
|
+
def assemble_recording(selected, manifest_xml, media)
|
|
246
304
|
@rec_dir = @options[:output_dir] || '.'
|
|
247
305
|
FileUtils.mkdir_p(@rec_dir)
|
|
248
306
|
@rec_base_url = manifest_xml.match(%r{<BaseURL>([^<]+)</BaseURL>})&.then { _1[1] } || ''
|
|
249
307
|
|
|
250
|
-
video_path = write_track(selected[:video], 'video')
|
|
251
308
|
audio_path = write_track(selected[:audio], 'audio')
|
|
252
|
-
|
|
309
|
+
video_path = media[:video] ? write_track(selected[:video], 'video') : nil
|
|
310
|
+
produce_outputs(video_path, audio_path, media)
|
|
253
311
|
end
|
|
254
312
|
|
|
255
313
|
def write_track(track, label)
|
|
@@ -261,29 +319,6 @@ module Teems
|
|
|
261
319
|
path
|
|
262
320
|
end
|
|
263
321
|
|
|
264
|
-
def merge_and_finalize(video_path, audio_path, dir)
|
|
265
|
-
output_path = File.join(dir, 'recording.mp4')
|
|
266
|
-
info('Merging video and audio tracks...')
|
|
267
|
-
merged = run_ffmpeg('-i', video_path, '-i', audio_path, '-c', 'copy', output_path)
|
|
268
|
-
FileUtils.rm_f([video_path, audio_path])
|
|
269
|
-
|
|
270
|
-
return error('ffmpeg merge failed') unless merged
|
|
271
|
-
|
|
272
|
-
embed_subtitle_if_available(output_path)
|
|
273
|
-
success("Recording saved to #{output_path}")
|
|
274
|
-
end
|
|
275
|
-
|
|
276
|
-
def embed_subtitle_if_available(video_path)
|
|
277
|
-
vtt_path = Dir.glob(File.join(File.dirname(video_path), '*.vtt')).first
|
|
278
|
-
return unless vtt_path
|
|
279
|
-
|
|
280
|
-
info('Embedding transcript as subtitle track...')
|
|
281
|
-
final_path = video_path.sub('.mp4', '_subs.mp4')
|
|
282
|
-
return unless run_ffmpeg('-i', video_path, '-i', vtt_path, '-c', 'copy', '-c:s', 'mov_text', final_path)
|
|
283
|
-
|
|
284
|
-
FileUtils.mv(final_path, video_path)
|
|
285
|
-
end
|
|
286
|
-
|
|
287
322
|
def run_ffmpeg(*)
|
|
288
323
|
system('ffmpeg', '-y', *, out: File::NULL, err: File::NULL)
|
|
289
324
|
end
|
|
@@ -2,6 +2,34 @@
|
|
|
2
2
|
|
|
3
3
|
module Teems
|
|
4
4
|
module Commands
|
|
5
|
+
# Derives a human-friendly output filename stem from a SharePoint recording/transcript name.
|
|
6
|
+
# SharePoint format is typically "<subject>-YYYYMMDD_HHMMSS[-Meeting Recording].<ext>"
|
|
7
|
+
# which we reshape into "YYYY-MM-DD - <subject>".
|
|
8
|
+
module MeetingFilename
|
|
9
|
+
SHAREPOINT_NAME_RE = /\A(.+)-(\d{4})(\d{2})(\d{2})_\d{6}\z/
|
|
10
|
+
UNSAFE_FILENAME_CHARS = %r{[/\\:*?"<>|\x00]}
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def derive_output_stem(sharepoint_name)
|
|
15
|
+
base = File.basename(sharepoint_name.to_s, '.*').sub(/-Meeting Recording\z/, '')
|
|
16
|
+
sanitize_filename(reshape_sharepoint_name(base))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reshape_sharepoint_name(base)
|
|
20
|
+
captures = base.match(SHAREPOINT_NAME_RE)&.captures
|
|
21
|
+
return base unless captures
|
|
22
|
+
|
|
23
|
+
subject, year, month, day = captures
|
|
24
|
+
"#{year}-#{month}-#{day} - #{subject}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sanitize_filename(name)
|
|
28
|
+
cleaned = name.gsub(UNSAFE_FILENAME_CHARS, '_').strip
|
|
29
|
+
cleaned.empty? ? 'recording' : cleaned
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
5
33
|
# Parses SharePoint embed page HTML to extract file metadata
|
|
6
34
|
module EmbedPageParser
|
|
7
35
|
SP_ITEM_RE = %r{(https://[^/]+(?::\d+)?/.+?)/_api/v2\.\d+/drives/([^/]+)/items/([^/?]+)}
|
|
@@ -113,6 +141,7 @@ module Teems
|
|
|
113
141
|
# Downloads meeting transcripts via SharePoint API (no Safari required)
|
|
114
142
|
module MeetingTranscript
|
|
115
143
|
include EmbedPageParser
|
|
144
|
+
include MeetingFilename
|
|
116
145
|
|
|
117
146
|
private
|
|
118
147
|
|
|
@@ -196,11 +225,11 @@ module Teems
|
|
|
196
225
|
url ? { url: url, name: File.basename(entry['name'] || 'transcript.vtt') } : nil
|
|
197
226
|
end
|
|
198
227
|
|
|
228
|
+
# Prefers the recording's name (via @transcript_info) so transcript and recording share a stem.
|
|
199
229
|
def save_transcript(transcript)
|
|
200
230
|
dir = @options[:output_dir] || '.'
|
|
201
231
|
FileUtils.mkdir_p(dir)
|
|
202
|
-
path = File.join(dir,
|
|
203
|
-
|
|
232
|
+
path = File.join(dir, "#{derive_output_stem(@transcript_info[:name] || transcript[:name])}.vtt")
|
|
204
233
|
info("Downloading transcript to #{path}...")
|
|
205
234
|
vtt = fetch_and_convert_transcript(transcript[:url])
|
|
206
235
|
return error('Failed to download transcript content') unless vtt
|
|
@@ -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/ooo.rb
CHANGED
|
@@ -15,9 +15,14 @@ module Teems
|
|
|
15
15
|
|
|
16
16
|
ON OPTIONS:
|
|
17
17
|
--message TEXT Auto-reply and status message
|
|
18
|
-
--start
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
--start TIME Schedule start; enables scheduled mode. Formats:
|
|
19
|
+
YYYY-MM-DD (all-day)
|
|
20
|
+
YYYY-MM-DD HH:MM (timed)
|
|
21
|
+
"today HH:MM", "tomorrow HH:MM", HH:MM
|
|
22
|
+
--end TIME Schedule end, required with --start.
|
|
23
|
+
Must match --start style (date or timed).
|
|
24
|
+
--event Create an OOO calendar event for notify list
|
|
25
|
+
--invite EMAILS Comma-separated invitees; overrides config notify list
|
|
21
26
|
--no-status Skip setting Teams status/presence
|
|
22
27
|
|
|
23
28
|
CONFIGURATION:
|
|
@@ -38,10 +43,43 @@ module Teems
|
|
|
38
43
|
teems ooo on # Enable OOO (always on)
|
|
39
44
|
teems ooo on --message "Vacation" # Custom message
|
|
40
45
|
teems ooo on --start 2026-04-14 --end 2026-04-18
|
|
46
|
+
teems ooo on --start "today 14:00" --end "today 17:00" \\
|
|
47
|
+
--message "Dr appt"
|
|
41
48
|
teems ooo off # Disable OOO
|
|
42
49
|
teems ooo config # Show config
|
|
43
50
|
HELP
|
|
44
51
|
|
|
52
|
+
# A parsed --start or --end value. Produces ISO datetime strings for
|
|
53
|
+
# auto-reply schedules and calendar events. Kind is :timed, :date, or
|
|
54
|
+
# :unscheduled; value is a Time, Date, or nil respectively.
|
|
55
|
+
OooScheduleEntry = Data.define(:kind, :value) do
|
|
56
|
+
def timed? = kind == :timed
|
|
57
|
+
def all_day? = kind == :date
|
|
58
|
+
def unscheduled? = kind == :unscheduled
|
|
59
|
+
|
|
60
|
+
def reply_start_iso
|
|
61
|
+
timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T00:00:00"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reply_end_iso
|
|
65
|
+
timed? ? value.strftime('%Y-%m-%dT%H:%M:%S') : "#{value}T23:59:59"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def event_start_iso
|
|
69
|
+
base = value || Date.today
|
|
70
|
+
timed? ? base.strftime('%Y-%m-%dT%H:%M:%S') : "#{base}T00:00:00"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def event_end_iso
|
|
74
|
+
base = value || Date.today
|
|
75
|
+
return base.strftime('%Y-%m-%dT%H:%M:%S') if timed?
|
|
76
|
+
|
|
77
|
+
"#{base + 1}T00:00:00"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
UNSCHEDULED_OOO_SCHEDULE = OooScheduleEntry.new(kind: :unscheduled, value: nil)
|
|
82
|
+
|
|
45
83
|
# Displays current OOO status (auto-reply + presence)
|
|
46
84
|
module OooDisplay
|
|
47
85
|
private
|
|
@@ -90,13 +128,20 @@ module Teems
|
|
|
90
128
|
end
|
|
91
129
|
|
|
92
130
|
def render_schedule(replies)
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
131
|
+
start_entry = replies['scheduledStartDateTime']
|
|
132
|
+
return unless start_entry
|
|
133
|
+
|
|
134
|
+
start_str = format_schedule_dt(start_entry)
|
|
135
|
+
end_str = format_schedule_dt(replies['scheduledEndDateTime'])
|
|
136
|
+
puts " Schedule: #{start_str} to #{end_str} #{short_tz_label}"
|
|
96
137
|
end
|
|
97
138
|
|
|
98
|
-
def
|
|
99
|
-
|
|
139
|
+
def format_schedule_dt(entry)
|
|
140
|
+
return '' unless entry
|
|
141
|
+
|
|
142
|
+
raw = entry['dateTime']
|
|
143
|
+
time = entry['timeZone'] == 'UTC' ? Time.parse("#{raw} UTC").getlocal : Time.parse(raw)
|
|
144
|
+
time.strftime('%Y-%m-%d %H:%M')
|
|
100
145
|
rescue ArgumentError
|
|
101
146
|
raw.to_s
|
|
102
147
|
end
|
|
@@ -118,6 +163,60 @@ module Teems
|
|
|
118
163
|
end
|
|
119
164
|
end
|
|
120
165
|
|
|
166
|
+
# Parses and validates --start/--end into @schedule_start and @schedule_end.
|
|
167
|
+
# Each parsed value is a tuple: [:date, Date] for all-day or [:timed, Time] for timed.
|
|
168
|
+
module OooSchedule
|
|
169
|
+
private
|
|
170
|
+
|
|
171
|
+
def validate_create_options
|
|
172
|
+
start_raw = @options[:start]
|
|
173
|
+
end_raw = @options[:end]
|
|
174
|
+
return unless start_raw
|
|
175
|
+
return missing_end_error unless end_raw
|
|
176
|
+
|
|
177
|
+
parse_schedule_pair(start_raw, end_raw)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def missing_end_error
|
|
181
|
+
error('--end is required when using --start')
|
|
182
|
+
1
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def parse_schedule_pair(start_raw, end_raw)
|
|
186
|
+
@schedule_start = parse_schedule_value(start_raw)
|
|
187
|
+
return invalid_schedule_error(:start, start_raw) unless @schedule_start
|
|
188
|
+
|
|
189
|
+
@schedule_end = parse_schedule_value(end_raw)
|
|
190
|
+
return invalid_schedule_error(:end, end_raw) unless @schedule_end
|
|
191
|
+
|
|
192
|
+
return mixed_schedule_error if schedule_kind(@schedule_start) != schedule_kind(@schedule_end)
|
|
193
|
+
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def parse_schedule_value(raw)
|
|
198
|
+
time = parse_time_input(raw)
|
|
199
|
+
return OooScheduleEntry.new(kind: :timed, value: time) if time
|
|
200
|
+
|
|
201
|
+
OooScheduleEntry.new(kind: :date, value: Date.parse(raw))
|
|
202
|
+
rescue Date::Error
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def invalid_schedule_error(key, raw)
|
|
207
|
+
error("Invalid value for --#{key}: #{raw}. " \
|
|
208
|
+
"Use YYYY-MM-DD, HH:MM, 'today HH:MM', 'tomorrow HH:MM', or 'YYYY-MM-DD HH:MM'")
|
|
209
|
+
1
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def mixed_schedule_error
|
|
213
|
+
error('--start and --end must both be dates or both include a time')
|
|
214
|
+
1
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def schedule_kind(parsed) = parsed.kind
|
|
218
|
+
end
|
|
219
|
+
|
|
121
220
|
# Enables/disables OOO: auto-reply, status message, presence, calendar event
|
|
122
221
|
module OooActions
|
|
123
222
|
private
|
|
@@ -139,28 +238,6 @@ module Teems
|
|
|
139
238
|
results.values.all? ? 0 : 1
|
|
140
239
|
end
|
|
141
240
|
|
|
142
|
-
def validate_create_options
|
|
143
|
-
validate_schedule_dates || validate_date_format(:start) || validate_date_format(:end)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def validate_schedule_dates
|
|
147
|
-
return unless @options[:start] && !@options[:end]
|
|
148
|
-
|
|
149
|
-
error('--end is required when using --start')
|
|
150
|
-
1
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def validate_date_format(key)
|
|
154
|
-
value = @options[key]
|
|
155
|
-
return unless value
|
|
156
|
-
|
|
157
|
-
Date.parse(value)
|
|
158
|
-
nil
|
|
159
|
-
rescue ArgumentError
|
|
160
|
-
error("Invalid date for --#{key}: #{value}. Expected format: YYYY-MM-DD")
|
|
161
|
-
1
|
|
162
|
-
end
|
|
163
|
-
|
|
164
241
|
def execute_ooo_steps
|
|
165
242
|
results = {}
|
|
166
243
|
results[:auto_reply] = set_auto_reply
|
|
@@ -261,37 +338,29 @@ module Teems
|
|
|
261
338
|
end
|
|
262
339
|
|
|
263
340
|
def add_schedule(settings)
|
|
264
|
-
|
|
265
|
-
settings[:scheduledStartDateTime] = { dateTime:
|
|
266
|
-
settings[:scheduledEndDateTime] = { dateTime:
|
|
341
|
+
timezone = detect_timezone
|
|
342
|
+
settings[:scheduledStartDateTime] = { dateTime: @schedule_start.reply_start_iso, timeZone: timezone }
|
|
343
|
+
settings[:scheduledEndDateTime] = { dateTime: @schedule_end.reply_end_iso, timeZone: timezone }
|
|
267
344
|
end
|
|
268
345
|
|
|
269
346
|
def build_ooo_event
|
|
347
|
+
timezone = detect_timezone
|
|
270
348
|
{ subject: status_message_text,
|
|
271
|
-
start:
|
|
272
|
-
end:
|
|
273
|
-
isAllDay:
|
|
274
|
-
|
|
275
|
-
isReminderOn: false,
|
|
276
|
-
isOnlineMeeting: false,
|
|
349
|
+
start: { dateTime: @schedule_start.event_start_iso, timeZone: timezone },
|
|
350
|
+
end: { dateTime: @schedule_end.event_end_iso, timeZone: timezone },
|
|
351
|
+
isAllDay: !@schedule_start.timed?, showAs: 'free',
|
|
352
|
+
isReminderOn: false, isOnlineMeeting: false,
|
|
277
353
|
transactionId: SecureRandom.uuid,
|
|
278
354
|
attendees: notify_list.map { |email| ooo_attendee(email) },
|
|
279
355
|
responseRequested: false }
|
|
280
356
|
end
|
|
281
357
|
|
|
282
|
-
def ooo_event_time(field)
|
|
283
|
-
date = @options[field] || Date.today.to_s
|
|
284
|
-
end_date = field == :end ? (Date.parse(date) + 1).to_s : date
|
|
285
|
-
tz = detect_timezone
|
|
286
|
-
{ dateTime: "#{end_date}T00:00:00", timeZone: tz }
|
|
287
|
-
end
|
|
288
|
-
|
|
289
358
|
def ooo_attendee(email)
|
|
290
359
|
{ emailAddress: { address: email }, type: 'required' }
|
|
291
360
|
end
|
|
292
361
|
|
|
293
362
|
def notify_list
|
|
294
|
-
@notify_list ||= ooo_config('notify') || []
|
|
363
|
+
@notify_list ||= @options[:invite] || ooo_config('notify') || []
|
|
295
364
|
end
|
|
296
365
|
|
|
297
366
|
def ooo_config(key)
|
|
@@ -302,20 +371,27 @@ module Teems
|
|
|
302
371
|
# Manage out-of-office: auto-reply, status, presence, and calendar event
|
|
303
372
|
class Ooo < Base
|
|
304
373
|
include Support::Timezone
|
|
374
|
+
include Support::TimeParsing
|
|
305
375
|
include OooDisplay
|
|
376
|
+
include OooSchedule
|
|
306
377
|
include OooActions
|
|
307
378
|
include OooBuildHelpers
|
|
308
379
|
|
|
309
380
|
def initialize(args, runner:)
|
|
310
381
|
@options = {}
|
|
382
|
+
@schedule_start = UNSCHEDULED_OOO_SCHEDULE
|
|
383
|
+
@schedule_end = UNSCHEDULED_OOO_SCHEDULE
|
|
311
384
|
super
|
|
312
385
|
end
|
|
313
386
|
|
|
387
|
+
SPLIT_OOO_EMAILS = ->(raw) { raw ? raw.split(',').map(&:strip).reject(&:empty?) : [] }
|
|
388
|
+
|
|
314
389
|
OOO_OPTIONS = {
|
|
315
390
|
'--message' => ->(opts, args) { opts[:message] = args.shift },
|
|
316
391
|
'--start' => ->(opts, args) { opts[:start] = args.shift },
|
|
317
392
|
'--end' => ->(opts, args) { opts[:end] = args.shift },
|
|
318
393
|
'--event' => ->(opts, _args) { opts[:event] = true },
|
|
394
|
+
'--invite' => ->(opts, args) { opts[:invite] = SPLIT_OOO_EMAILS.call(args.shift) },
|
|
319
395
|
'--no-status' => ->(opts, _args) { opts[:no_status] = true }
|
|
320
396
|
}.freeze
|
|
321
397
|
|
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
|