teems 0.3.2 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +78 -0
- data/README.md +84 -0
- data/bin/teems +8 -1
- data/lib/teems/api/calendar.rb +45 -14
- data/lib/teems/api/client.rb +19 -4
- data/lib/teems/api/mail.rb +132 -0
- data/lib/teems/api/mail_actions.rb +22 -0
- data/lib/teems/api/mail_rules.rb +24 -0
- data/lib/teems/cli.rb +2 -0
- data/lib/teems/commands/base.rb +31 -7
- data/lib/teems/commands/cal.rb +109 -28
- data/lib/teems/commands/cal_ics.rb +47 -0
- data/lib/teems/commands/confirmation.rb +19 -0
- data/lib/teems/commands/help.rb +4 -0
- data/lib/teems/commands/mail.rb +207 -0
- data/lib/teems/commands/mail_decrypting.rb +46 -0
- data/lib/teems/commands/mail_help.rb +85 -0
- data/lib/teems/commands/mail_rules.rb +201 -0
- data/lib/teems/commands/mail_sync.rb +92 -0
- data/lib/teems/commands/mail_thread.rb +87 -0
- data/lib/teems/commands/mail_triage.rb +241 -0
- data/lib/teems/commands/meet.rb +159 -0
- data/lib/teems/commands/meeting.rb +4 -3
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +45 -11
- data/lib/teems/commands/sync_folders.rb +118 -0
- data/lib/teems/commands/transcripts.rb +205 -137
- data/lib/teems/formatters/format_utils.rb +26 -2
- data/lib/teems/formatters/html_entities.rb +49 -0
- data/lib/teems/formatters/html_text.rb +181 -0
- data/lib/teems/formatters/ics_format.rb +112 -0
- data/lib/teems/formatters/link_cleaner.rb +91 -0
- data/lib/teems/formatters/link_collector.rb +70 -0
- data/lib/teems/formatters/mail_formatter.rb +58 -0
- data/lib/teems/formatters/mail_markdown.rb +70 -0
- data/lib/teems/formatters/mail_rule_formatter.rb +45 -0
- data/lib/teems/formatters/mail_rule_text.rb +67 -0
- data/lib/teems/formatters/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/output.rb +45 -8
- data/lib/teems/formatters/transcript_markdown.rb +16 -12
- data/lib/teems/models/chat.rb +19 -5
- data/lib/teems/models/event.rb +1 -23
- data/lib/teems/models/event_display.rb +40 -0
- data/lib/teems/models/ics_event.rb +60 -0
- data/lib/teems/models/inline_image.rb +83 -0
- data/lib/teems/models/mail_conversation.rb +62 -0
- data/lib/teems/models/mail_encryption.rb +41 -0
- data/lib/teems/models/mail_folder.rb +44 -0
- data/lib/teems/models/mail_message.rb +152 -0
- data/lib/teems/models/mail_rule.rb +38 -0
- data/lib/teems/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +11 -0
- data/lib/teems/services/api_client.rb +23 -11
- data/lib/teems/services/browser.rb +17 -0
- data/lib/teems/services/clipboard.rb +17 -0
- data/lib/teems/services/inline_image_downloader.rb +115 -0
- data/lib/teems/services/mail_folders.rb +76 -0
- data/lib/teems/services/mail_id_cache.rb +51 -0
- data/lib/teems/services/mail_legacy_files.rb +96 -0
- data/lib/teems/services/mail_sync_attachments.rb +103 -0
- data/lib/teems/services/mail_sync_engine.rb +98 -0
- data/lib/teems/services/mail_sync_folder.rb +142 -0
- data/lib/teems/services/mail_sync_store.rb +116 -0
- data/lib/teems/services/smime_content.rb +36 -0
- data/lib/teems/services/smime_decryptor.rb +36 -0
- data/lib/teems/services/sync_dir_naming.rb +16 -10
- data/lib/teems/services/sync_dir_ownership.rb +111 -0
- data/lib/teems/services/sync_dir_planning.rb +130 -0
- data/lib/teems/services/sync_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -0
- data/lib/teems/services/sync_store.rb +20 -43
- data/lib/teems/services/token_extractor.rb +4 -0
- data/lib/teems/support/control_chars.rb +19 -0
- data/lib/teems/support/mime_header.rb +61 -0
- data/lib/teems/support/mime_part.rb +97 -0
- data/lib/teems/support/signed_data.rb +23 -0
- data/lib/teems/support/subprocess.rb +2 -0
- data/lib/teems/support/time_parsing.rb +9 -1
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +67 -2
- metadata +51 -1
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Teems
|
|
6
|
+
module Commands
|
|
7
|
+
MEET_HELP = <<~HELP
|
|
8
|
+
teems meet - Start a Teams meeting and print its join link
|
|
9
|
+
|
|
10
|
+
USAGE:
|
|
11
|
+
teems meet [minutes] [options]
|
|
12
|
+
|
|
13
|
+
Puts a Teams meeting on your calendar, starting now for 30 minutes unless told
|
|
14
|
+
otherwise, prints its join link (alone on stdout, so it pipes) and opens it here
|
|
15
|
+
to join. With nobody invited, nothing is sent.
|
|
16
|
+
|
|
17
|
+
Joining here matters: people from organizations yours does not trust wait in the
|
|
18
|
+
lobby until a member of yours joins, even with "Everyone" set to bypass the lobby
|
|
19
|
+
in Meeting options, and even when invited. Once you are in you can admit them;
|
|
20
|
+
the meeting usually carries on after you leave.
|
|
21
|
+
|
|
22
|
+
OPTIONS:
|
|
23
|
+
--at TIME Start: now (default), HH:MM, "today HH:MM",
|
|
24
|
+
"tomorrow HH:MM", or "YYYY-MM-DD HH:MM"
|
|
25
|
+
--title TEXT Event title (default: Meet now)
|
|
26
|
+
--with EMAILS Comma-separated people to invite. Exchange emails
|
|
27
|
+
each an invitation, so this asks first
|
|
28
|
+
-y, --yes Invite without asking (needed without a terminal)
|
|
29
|
+
--copy Also copy the link to the clipboard
|
|
30
|
+
--no-open Do not open the link here (just print it)
|
|
31
|
+
-q, --quiet Just the link
|
|
32
|
+
--json Event ID, title, start, end and link as JSON
|
|
33
|
+
|
|
34
|
+
EXAMPLES:
|
|
35
|
+
teems meet # Now, 30 minutes, and join here
|
|
36
|
+
teems meet 60 --title "Pairing" # Now, an hour
|
|
37
|
+
teems meet --copy # Also copy the link
|
|
38
|
+
teems meet --no-open | pbcopy # Just the link, for someone else
|
|
39
|
+
teems meet --at 14:30 --with alice@example.com
|
|
40
|
+
HELP
|
|
41
|
+
|
|
42
|
+
# `teems meet`: a calendar event with a Teams meeting, starting now by default, and
|
|
43
|
+
# its join link. Calendars.ReadWrite is enough for that, where creating a bare online
|
|
44
|
+
# meeting would need OnlineMeetings.ReadWrite.
|
|
45
|
+
class Meet < Base
|
|
46
|
+
include Support::Timezone
|
|
47
|
+
include Support::TimeParsing
|
|
48
|
+
include Confirmation
|
|
49
|
+
|
|
50
|
+
DEFAULT_MINUTES = 30
|
|
51
|
+
MAX_MINUTES = 24 * 60
|
|
52
|
+
VALUE_OPTIONS = { '--at' => :at, '--title' => :title, '--with' => :with }.freeze
|
|
53
|
+
# --open is the default; it is still accepted, so scripts that pass it keep working
|
|
54
|
+
FLAGS = { '--copy' => :copy, '--open' => :open, '--no-open' => :no_open, '--yes' => :yes, '-y' => :yes }.freeze
|
|
55
|
+
|
|
56
|
+
def initialize(args, runner:)
|
|
57
|
+
@options = {}
|
|
58
|
+
super
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def execute
|
|
62
|
+
validation = validate_options
|
|
63
|
+
return validation if validation
|
|
64
|
+
|
|
65
|
+
auth = require_auth
|
|
66
|
+
return auth if auth
|
|
67
|
+
|
|
68
|
+
with_token_refresh { create_meeting }
|
|
69
|
+
rescue ApiError => e
|
|
70
|
+
api_error_result('Could not create the meeting', e)
|
|
71
|
+
rescue Teems::Error => e
|
|
72
|
+
error(e.message)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
protected
|
|
76
|
+
|
|
77
|
+
def handle_option(arg, pending)
|
|
78
|
+
return @options[FLAGS[arg]] = true if FLAGS.key?(arg)
|
|
79
|
+
return super unless VALUE_OPTIONS.key?(arg)
|
|
80
|
+
|
|
81
|
+
@options[VALUE_OPTIONS[arg]] = pending.shift
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def help_text = MEET_HELP
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def create_meeting
|
|
89
|
+
start = start_time
|
|
90
|
+
finish = start + (minutes * 60)
|
|
91
|
+
return error('Cancelled; no meeting created') unless invite_confirmed?
|
|
92
|
+
|
|
93
|
+
event = runner.calendar_api.create_event(event_body(start, finish))
|
|
94
|
+
link = event.online_meeting_url or raise Error, 'Graph created the event but gave no Teams link'
|
|
95
|
+
report(event, link, start..finish)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def minutes
|
|
99
|
+
given = positional_args.first
|
|
100
|
+
return DEFAULT_MINUTES unless given
|
|
101
|
+
|
|
102
|
+
count = Integer(given, 10, exception: false)
|
|
103
|
+
raise Error, "Minutes must be a whole number from 1 to #{MAX_MINUTES}" unless count&.between?(1, MAX_MINUTES)
|
|
104
|
+
|
|
105
|
+
count
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def start_time
|
|
109
|
+
given = @options[:at] || 'now'
|
|
110
|
+
parse_time_input(given) or raise Error, "Unknown time: #{given} (use now, HH:MM, \"tomorrow HH:MM\", ...)"
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def invitees = @options[:with].to_s.split(',').map(&:strip).reject(&:empty?)
|
|
114
|
+
|
|
115
|
+
# Inviting people sends them mail, so it needs a yes (at the prompt, or --yes)
|
|
116
|
+
def invite_confirmed?
|
|
117
|
+
people = invitees
|
|
118
|
+
return true if people.empty? || @options[:yes]
|
|
119
|
+
|
|
120
|
+
confirm?("Invite #{people.join(', ')} (Exchange emails each an invitation)",
|
|
121
|
+
refusal: 'Inviting people sends them email; pass --yes to go ahead')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def event_body(start, finish)
|
|
125
|
+
zone = detect_timezone
|
|
126
|
+
{ subject: @options[:title] || 'Meet now', isOnlineMeeting: true, onlineMeetingProvider: 'teamsForBusiness',
|
|
127
|
+
start: { dateTime: graph_time(start), timeZone: zone }, end: { dateTime: graph_time(finish), timeZone: zone },
|
|
128
|
+
attendees: invitees.map { |address| { emailAddress: { address: address }, type: 'required' } },
|
|
129
|
+
transactionId: SecureRandom.uuid }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# On stderr, beside the link on stdout; -q leaves just the link
|
|
133
|
+
def note(message) = @options[:quiet] || output.note(message)
|
|
134
|
+
|
|
135
|
+
def graph_time(time) = time.strftime('%Y-%m-%dT%H:%M:%S')
|
|
136
|
+
|
|
137
|
+
def report(event, link, times)
|
|
138
|
+
return json_report(event, link, times) if @options[:json]
|
|
139
|
+
|
|
140
|
+
output.puts(link)
|
|
141
|
+
note("#{event.subject}, #{times.begin.strftime('%-I:%M')}-#{times.end.strftime('%-I:%M %p')}")
|
|
142
|
+
hand_off(link)
|
|
143
|
+
0
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def json_report(event, link, times)
|
|
147
|
+
hand_off(link)
|
|
148
|
+
output_json(id: event.id, title: event.subject, start: times.begin.iso8601, end: times.end.iso8601,
|
|
149
|
+
join_url: link)
|
|
150
|
+
0
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def hand_off(link)
|
|
154
|
+
runner.clipboard.copy(link) && note('Copied the link') if @options[:copy]
|
|
155
|
+
runner.browser.open(link) && note('Opened it to join') unless @options[:no_open]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -613,10 +613,11 @@ module Teems
|
|
|
613
613
|
private
|
|
614
614
|
|
|
615
615
|
def output_meeting_json(target, classified)
|
|
616
|
+
call_events, recordings, transcripts = classified.values_at(:call_events, :recordings, :transcripts)
|
|
616
617
|
output_json(thread_id: target[:thread_id],
|
|
617
|
-
call_events:
|
|
618
|
-
recordings:
|
|
619
|
-
transcripts:
|
|
618
|
+
call_events: call_events.map { |event| json_call_event(event) },
|
|
619
|
+
recordings: recordings.map { |rec| rec.slice(:time, :url, :call_id) },
|
|
620
|
+
transcripts: transcripts.map { |item| item.slice(:time) })
|
|
620
621
|
0
|
|
621
622
|
end
|
|
622
623
|
|
|
@@ -14,7 +14,7 @@ module Teems
|
|
|
14
14
|
OPTIONS:
|
|
15
15
|
-t, --team ID Team ID (required for channel messages)
|
|
16
16
|
-n, --limit N Number of messages (default: 20)
|
|
17
|
-
--download Download file attachments
|
|
17
|
+
--download Download file attachments and inline images
|
|
18
18
|
-o, --output-dir Directory for downloads (default: ~/.local/share/teems/downloads)
|
|
19
19
|
-v, --verbose Show debug output
|
|
20
20
|
-q, --quiet Suppress output
|
|
@@ -23,10 +23,13 @@ module Teems
|
|
|
23
23
|
EXAMPLES:
|
|
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
|
+
teems messages 19:abc123@thread.v2 --download # Save attachments + pasted images
|
|
26
27
|
teems messages "https://teams.microsoft.com/l/message/19:abc@thread.v2/123?context=..."
|
|
27
28
|
# Show the linked message and its thread replies
|
|
28
29
|
HELP
|
|
29
30
|
|
|
31
|
+
IMAGE_ICON = "\u{1F5BC}\u{FE0F}"
|
|
32
|
+
|
|
30
33
|
# Display formatting for messages command
|
|
31
34
|
module MessagesDisplay
|
|
32
35
|
REACTION_EMOJI = {
|
|
@@ -45,6 +48,7 @@ module Teems
|
|
|
45
48
|
puts format_message_header(message)
|
|
46
49
|
puts " #{formatted_content(message)}"
|
|
47
50
|
display_attachments(message)
|
|
51
|
+
display_images(message)
|
|
48
52
|
display_reactions(message)
|
|
49
53
|
puts
|
|
50
54
|
end
|
|
@@ -66,6 +70,11 @@ module Teems
|
|
|
66
70
|
puts " #{output.gray("\u{1F4CE} #{names.join(', ')}")}"
|
|
67
71
|
end
|
|
68
72
|
|
|
73
|
+
def display_images(message)
|
|
74
|
+
summary = Formatters::FormatUtils.image_summary(message.images)
|
|
75
|
+
puts " #{output.gray("#{IMAGE_ICON} #{summary}")}" unless summary.empty?
|
|
76
|
+
end
|
|
77
|
+
|
|
69
78
|
def display_reactions(message)
|
|
70
79
|
reactions = message.reactions
|
|
71
80
|
return unless reactions.any?
|
|
@@ -85,7 +94,7 @@ module Teems
|
|
|
85
94
|
created_at: message.created_at&.iso8601,
|
|
86
95
|
importance: message.importance, reactions: message.reactions,
|
|
87
96
|
attachments: message.attachments, edited: message.edited,
|
|
88
|
-
mentions: message.mentions }
|
|
97
|
+
mentions: message.mentions, images: message.images.map(&:as_json) }
|
|
89
98
|
end
|
|
90
99
|
end
|
|
91
100
|
|
|
@@ -95,14 +104,15 @@ module Teems
|
|
|
95
104
|
|
|
96
105
|
def download_attachments(messages)
|
|
97
106
|
attachments = find_downloadable(messages)
|
|
98
|
-
|
|
107
|
+
images = inline_images(messages)
|
|
108
|
+
return puts('No downloadable attachments found') if attachments.empty? && images.empty?
|
|
99
109
|
|
|
100
|
-
execute_downloads(attachments)
|
|
110
|
+
execute_downloads(attachments, images)
|
|
101
111
|
end
|
|
102
112
|
|
|
103
|
-
def execute_downloads(attachments)
|
|
113
|
+
def execute_downloads(attachments, images)
|
|
104
114
|
dir = prepare_output_dir
|
|
105
|
-
count = attachments.sum { |att| download_one(att, dir) }
|
|
115
|
+
count = attachments.sum { |att| download_one(att, dir) } + images.sum { |entry| download_image(entry, dir) }
|
|
106
116
|
puts "Downloaded #{count} file#{'s' if count != 1} to #{dir}" if count.positive?
|
|
107
117
|
end
|
|
108
118
|
|
|
@@ -168,6 +178,29 @@ module Teems
|
|
|
168
178
|
end
|
|
169
179
|
end
|
|
170
180
|
|
|
181
|
+
# Download logic for images pasted inline into message bodies (Teams AMS objects)
|
|
182
|
+
module InlineImageDownload
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
# [message, image, 1-based position within the message] for every inline image
|
|
186
|
+
def inline_images(messages) = messages.flat_map { |msg| numbered_images(msg) }
|
|
187
|
+
|
|
188
|
+
def numbered_images(msg) = msg.images.map.with_index(1) { |image, number| [msg, image, number] }
|
|
189
|
+
|
|
190
|
+
def download_image(entry, dir)
|
|
191
|
+
msg, image, number = entry
|
|
192
|
+
print "#{IMAGE_ICON} Downloading #{image.label}..."
|
|
193
|
+
result = with_token_refresh { image_downloader.fetch(image) }
|
|
194
|
+
path = unique_path(dir, "image-#{msg.short_hash}-#{number}.#{result.extension}")
|
|
195
|
+
puts " done (#{Formatters::FormatUtils.format_bytes(File.binwrite(path, result.body))}) #{path}"
|
|
196
|
+
1
|
|
197
|
+
rescue StandardError => e
|
|
198
|
+
handle_download_error(e)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def image_downloader = @image_downloader ||= runner.inline_image_downloader
|
|
202
|
+
end
|
|
203
|
+
|
|
171
204
|
# Fetch and render a single message and its thread replies
|
|
172
205
|
module ThreadFetch
|
|
173
206
|
private
|
|
@@ -175,7 +208,7 @@ module Teems
|
|
|
175
208
|
def fetch_thread(thread_id, message_id)
|
|
176
209
|
api = runner.messages_api
|
|
177
210
|
parent_data = with_token_refresh { api.message(thread_id: thread_id, message_id: message_id) }
|
|
178
|
-
replies_data =
|
|
211
|
+
replies_data = fetch_replies do
|
|
179
212
|
api.replies(thread_id: thread_id, message_id: message_id, limit: @options[:limit])
|
|
180
213
|
end
|
|
181
214
|
display_thread(parent_data, extract_messages_data(replies_data))
|
|
@@ -183,10 +216,19 @@ module Teems
|
|
|
183
216
|
error("Failed to fetch message: #{e.message}")
|
|
184
217
|
end
|
|
185
218
|
|
|
219
|
+
# Group and 1:1 chats have no reply threads; their replies endpoint returns 404
|
|
220
|
+
def fetch_replies(&)
|
|
221
|
+
with_token_refresh(&)
|
|
222
|
+
rescue ApiError => e
|
|
223
|
+
raise unless e.not_found?
|
|
224
|
+
|
|
225
|
+
{}
|
|
226
|
+
end
|
|
227
|
+
|
|
186
228
|
def display_thread(parent_data, replies_data)
|
|
187
229
|
parent = Models::Message.from_api(parent_data)
|
|
188
230
|
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]
|
|
231
|
+
return output_json(thread_to_hash(parent, replies)) || 0 if @options[:json]
|
|
190
232
|
|
|
191
233
|
render_thread(parent, replies)
|
|
192
234
|
download_attachments([parent, *replies]) if @options[:download]
|
|
@@ -212,6 +254,7 @@ module Teems
|
|
|
212
254
|
class Messages < Base
|
|
213
255
|
include MessagesDisplay
|
|
214
256
|
include AttachmentDownload
|
|
257
|
+
include InlineImageDownload
|
|
215
258
|
include ThreadFetch
|
|
216
259
|
|
|
217
260
|
def initialize(args, runner:)
|
data/lib/teems/commands/sync.rb
CHANGED
|
@@ -11,8 +11,11 @@ module Teems
|
|
|
11
11
|
OPTIONS:
|
|
12
12
|
--since DAYS Number of days of history to sync (default: 180)
|
|
13
13
|
--chat CHAT_ID Sync only this chat
|
|
14
|
+
--images Also download inline (pasted) images into each chat's images/ dir
|
|
14
15
|
--auth Authenticate via Safari before syncing
|
|
15
|
-
--dry-run Show what would be synced without writing
|
|
16
|
+
--dry-run Show what would be synced and every folder change, without writing anything
|
|
17
|
+
--migrate-dirs Also rename existing chat folders to "<name> (<chat ID>)" and write
|
|
18
|
+
a map of old to new paths (sync/dir-maps/)
|
|
16
19
|
-v, --verbose Show debug output
|
|
17
20
|
-q, --quiet Suppress output
|
|
18
21
|
|
|
@@ -20,11 +23,15 @@ module Teems
|
|
|
20
23
|
teems sync # Sync 6 months of all chats
|
|
21
24
|
teems sync --since 30 # Sync last 30 days
|
|
22
25
|
teems sync --chat 19:abc@thread.v2 # Sync a single chat
|
|
26
|
+
teems sync --images # Include pasted screenshots
|
|
23
27
|
teems sync --dry-run # Preview what would be synced
|
|
28
|
+
teems sync --dry-run --migrate-dirs # Preview renaming every folder
|
|
24
29
|
|
|
25
30
|
OUTPUT:
|
|
26
31
|
Files are stored in ~/.local/share/teems/sync/chats/
|
|
27
32
|
Each chat gets: messages.md, messages.json, chat_metadata.json
|
|
33
|
+
(plus images/ with --images; messages.md links saved images, otherwise [image: ...])
|
|
34
|
+
New chats get a "<name> (<chat ID>)" folder; existing folders keep their names.
|
|
28
35
|
HELP
|
|
29
36
|
|
|
30
37
|
# Handles syncing individual chats: fetch, merge, retry on 404
|
|
@@ -59,10 +66,7 @@ module Teems
|
|
|
59
66
|
end
|
|
60
67
|
|
|
61
68
|
def sync_single_chat(chat)
|
|
62
|
-
@sync_store.
|
|
63
|
-
@state, chat_info: { chat_id: chat.id, display_name: chat.display_name,
|
|
64
|
-
chat_type: chat.chat_type }
|
|
65
|
-
)
|
|
69
|
+
report_dir_move(@sync_store.ensure_chat_dir(@state, chat_info: dir_info(chat)))
|
|
66
70
|
new_messages = fetch_new_messages(chat)
|
|
67
71
|
debug(" Fetched #{new_messages.length} new message(s)")
|
|
68
72
|
process_fetched_messages(chat, new_messages)
|
|
@@ -70,20 +74,24 @@ module Teems
|
|
|
70
74
|
|
|
71
75
|
def process_fetched_messages(chat, new_messages)
|
|
72
76
|
chat_id = chat.id
|
|
73
|
-
|
|
77
|
+
# --images rewrites unchanged chats too, so images in already-synced messages are backfilled
|
|
78
|
+
unchanged = new_messages.empty? && !@options[:images]
|
|
79
|
+
return skip_unchanged(chat_id) if unchanged && @sync_store.last_synced_time(@state, chat_id)
|
|
74
80
|
|
|
75
81
|
merge_and_update(chat, new_messages)
|
|
76
82
|
end
|
|
77
83
|
|
|
78
84
|
def fetch_new_messages(chat)
|
|
79
85
|
chat_id = chat.id
|
|
80
|
-
start_time = @sync_store.last_synced_time(@state, chat_id) ||
|
|
86
|
+
start_time = @sync_store.last_synced_time(@state, chat_id) || resync_start(@state, chat_id)
|
|
81
87
|
with_token_refresh { sync_engine.fetch_all_messages(chat_id, start_time) }
|
|
82
88
|
end
|
|
83
89
|
|
|
84
90
|
def merge_and_update(chat, new_messages)
|
|
85
91
|
existing_raw = @sync_store.read_messages_json(chat.id, state: @state)
|
|
86
|
-
all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages)
|
|
92
|
+
all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages) do |chat_dir, messages|
|
|
93
|
+
save_chat_images(chat_dir, messages)
|
|
94
|
+
end
|
|
87
95
|
update_chat_state(chat, all_messages)
|
|
88
96
|
end
|
|
89
97
|
|
|
@@ -138,6 +146,23 @@ module Teems
|
|
|
138
146
|
end
|
|
139
147
|
end
|
|
140
148
|
|
|
149
|
+
# Opt-in (--images) download of inline images into each synced chat directory
|
|
150
|
+
module SyncImageHandler
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
def save_chat_images(chat_dir, messages)
|
|
154
|
+
return unless @options[:images]
|
|
155
|
+
|
|
156
|
+
saved = sync_images.save(chat_dir, messages)
|
|
157
|
+
debug(" Saved #{saved} image(s)")
|
|
158
|
+
@stats[:images] += saved
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def sync_images
|
|
162
|
+
@sync_images ||= Services::SyncImages.new(downloader: runner.inline_image_downloader, output: output)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
141
166
|
# Chat list fetching, dry-run display, and summary reporting
|
|
142
167
|
module SyncDisplay
|
|
143
168
|
private
|
|
@@ -174,12 +199,13 @@ module Teems
|
|
|
174
199
|
def show_dry_run(chats)
|
|
175
200
|
syncable = chats.reject { |chat| skip_reason(chat['id']) }
|
|
176
201
|
display_dry_run_list(chats.length - syncable.length, syncable)
|
|
202
|
+
show_dir_plan(syncable)
|
|
203
|
+
0
|
|
177
204
|
end
|
|
178
205
|
|
|
179
206
|
def display_dry_run_list(skipped_count, syncable)
|
|
180
207
|
display_dry_run_header(skipped_count, syncable.length)
|
|
181
208
|
syncable.each { |chat| format_dry_run_chat(chat) }
|
|
182
|
-
0
|
|
183
209
|
end
|
|
184
210
|
|
|
185
211
|
def display_dry_run_header(skipped, syncable_count)
|
|
@@ -204,6 +230,7 @@ module Teems
|
|
|
204
230
|
success('Sync complete!')
|
|
205
231
|
show_summary_stats
|
|
206
232
|
info(" Output: #{@sync_store.sync_dir}")
|
|
233
|
+
show_folder_summary
|
|
207
234
|
end
|
|
208
235
|
|
|
209
236
|
def show_summary_stats
|
|
@@ -211,6 +238,7 @@ module Teems
|
|
|
211
238
|
skipped = @stats[:skipped]
|
|
212
239
|
info(" Chats skipped (no new messages): #{skipped}") if skipped.positive?
|
|
213
240
|
info(" Total messages: #{@stats[:messages_total]}")
|
|
241
|
+
info(" Images downloaded: #{@stats[:images]}") if @options[:images]
|
|
214
242
|
display_error_count
|
|
215
243
|
end
|
|
216
244
|
|
|
@@ -287,8 +315,10 @@ module Teems
|
|
|
287
315
|
# Sync chat history locally as Markdown + JSON files
|
|
288
316
|
class Sync < Base
|
|
289
317
|
include SyncChatHandler
|
|
318
|
+
include SyncImageHandler
|
|
290
319
|
include SyncDisplay
|
|
291
320
|
include SyncAuth
|
|
321
|
+
include SyncFolders
|
|
292
322
|
|
|
293
323
|
DEFAULT_SINCE_DAYS = 180
|
|
294
324
|
SKIP_PREFIXES = %w[48:].freeze
|
|
@@ -318,6 +348,8 @@ module Teems
|
|
|
318
348
|
'--since' => ->(opts, args) { opts[:since_days] = args.shift.to_i },
|
|
319
349
|
'--chat' => ->(opts, args) { opts[:chat_id] = args.shift },
|
|
320
350
|
'--dry-run' => ->(opts, _args) { opts[:dry_run] = true },
|
|
351
|
+
'--images' => ->(opts, _args) { opts[:images] = true },
|
|
352
|
+
'--migrate-dirs' => ->(opts, _args) { opts[:migrate_dirs] = true },
|
|
321
353
|
'--auth' => ->(opts, _args) { opts[:auth] = true }
|
|
322
354
|
}.freeze
|
|
323
355
|
|
|
@@ -342,6 +374,7 @@ module Teems
|
|
|
342
374
|
end
|
|
343
375
|
|
|
344
376
|
def sync_all_chats(chats)
|
|
377
|
+
detach_shared_dirs
|
|
345
378
|
chats.each_with_index { |chat_data, index| sync_or_skip_chat(chat_data, "[#{index + 1}/#{chats.length}]") }
|
|
346
379
|
save_state_safely
|
|
347
380
|
show_summary
|
|
@@ -353,7 +386,7 @@ module Teems
|
|
|
353
386
|
def init_sync_state
|
|
354
387
|
@sync_store = Services::SyncStore.new
|
|
355
388
|
@state = @sync_store.load_state
|
|
356
|
-
@stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0 }
|
|
389
|
+
@stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0, images: 0 }
|
|
357
390
|
setup_api_logging
|
|
358
391
|
end
|
|
359
392
|
|
|
@@ -377,7 +410,8 @@ module Teems
|
|
|
377
410
|
error("Warning: Failed to save sync state: #{e.message}")
|
|
378
411
|
end
|
|
379
412
|
|
|
380
|
-
def
|
|
413
|
+
def since_days = @options[:since_days] || DEFAULT_SINCE_DAYS
|
|
414
|
+
def since_time = Time.now - (since_days * 86_400)
|
|
381
415
|
|
|
382
416
|
def setup_api_logging
|
|
383
417
|
out = output
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Chat folder changes made by sync: detaching shared folders, moving folders, listing old
|
|
6
|
+
# shared folders still on disk, and previewing all of it for --dry-run
|
|
7
|
+
module SyncFolders
|
|
8
|
+
# How each kind of folder change is described; :split is described by split_note, and :new
|
|
9
|
+
# and :kept aren't reported
|
|
10
|
+
MOVE_NOTES = {
|
|
11
|
+
move: '',
|
|
12
|
+
retarget: ' (the old folder is already gone)',
|
|
13
|
+
repoint: ' (the new folder already exists: switching to it, the old folder is left in place)',
|
|
14
|
+
release: " (the old folder also holds another chat's messages and is left in place)"
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def dir_info(chat) = { **chat.sync_identity, migrate: @options[:migrate_dirs] }
|
|
20
|
+
|
|
21
|
+
# sync --chat X only detaches X, so the other chats keep their cursors until they sync
|
|
22
|
+
def detach_shared_dirs
|
|
23
|
+
detached = @sync_store.detach_shared_dirs(@state, only: @options[:chat_id])
|
|
24
|
+
@detached_dirs = detached.keys
|
|
25
|
+
warn_shared_dirs(detached)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def warn_shared_dirs(shared)
|
|
29
|
+
return if shared.empty?
|
|
30
|
+
|
|
31
|
+
warn([shared_dirs_intro(shared), *dir_lines(shared), shared_dirs_plan].join("\n"))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def shared_dirs_intro(shared)
|
|
35
|
+
"#{shared.length} folder(s) in #{File.join(@sync_store.sync_dir, 'chats')} mix messages from " \
|
|
36
|
+
'several chats (an older teems gave those chats the same folder name):'
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def shared_dirs_plan
|
|
40
|
+
'These folders are left as they are. Each affected chat now syncs to its own folder, re-fetching ' \
|
|
41
|
+
'history back to the oldest message in the old folder. Older history may still exist only in the ' \
|
|
42
|
+
"old folders, so keep them until you've checked the new ones."
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def dir_lines(dirs) = dirs.map { |path, ids| " #{path} (#{ids.length} chat#{'s' unless ids.one?})" }
|
|
46
|
+
|
|
47
|
+
def report_dir_move(move)
|
|
48
|
+
line = dir_move_line(move, @state)
|
|
49
|
+
return unless line
|
|
50
|
+
|
|
51
|
+
(@dir_moves ||= []) << move
|
|
52
|
+
move.kind == :repoint ? warn(" Folder: #{line}") : info(" Folder: #{line}")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def dir_move_line(move, state)
|
|
56
|
+
note = move_note(move.to_h, state)
|
|
57
|
+
"#{move}#{note}" if note
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def move_note(move_attrs, state)
|
|
61
|
+
kind, chat_id = move_attrs.values_at(:kind, :chat_id)
|
|
62
|
+
return MOVE_NOTES[kind] unless kind == :split
|
|
63
|
+
|
|
64
|
+
start = resync_start(state, chat_id).strftime('%Y-%m-%d')
|
|
65
|
+
" (split from a shared folder, which is left in place; re-fetching from #{start})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A detached chat re-syncs from the oldest message in its old folder, or --since if earlier
|
|
69
|
+
def resync_start(state, chat_id) = [@sync_store.resync_from(state, chat_id), since_time].compact.min
|
|
70
|
+
|
|
71
|
+
def show_folder_summary
|
|
72
|
+
write_dir_map
|
|
73
|
+
show_legacy_dirs(@detached_dirs)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write_dir_map
|
|
77
|
+
moves = @dir_moves
|
|
78
|
+
return unless @options[:migrate_dirs] && moves
|
|
79
|
+
|
|
80
|
+
info(" Folder map: #{@sync_store.write_dir_map(moves)}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def show_legacy_dirs(exclude)
|
|
84
|
+
legacy = @sync_store.legacy_shared_dirs(@state).except(*exclude)
|
|
85
|
+
return if legacy.empty?
|
|
86
|
+
|
|
87
|
+
info("Old shared folders still on disk (they may hold history the new folders don't):\n" \
|
|
88
|
+
"#{dir_lines(legacy).join("\n")}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Plans on a copy of the state, so nothing changes on disk or in sync_state.json
|
|
92
|
+
def show_dir_plan(syncable)
|
|
93
|
+
plan = JSON.parse(JSON.generate(@state))
|
|
94
|
+
detached = @sync_store.detach_shared_dirs(plan, only: @options[:chat_id])
|
|
95
|
+
lines = syncable.filter_map do |chat_data|
|
|
96
|
+
dir_move_line(@sync_store.plan_chat_dir(plan, dir_info(Models::Chat.from_api(chat_data))), plan)
|
|
97
|
+
end
|
|
98
|
+
show_planned_moves(lines)
|
|
99
|
+
show_planned_detaches(detached)
|
|
100
|
+
show_legacy_dirs([])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def show_planned_moves(lines)
|
|
104
|
+
puts
|
|
105
|
+
return info('Folder changes: none') if lines.empty?
|
|
106
|
+
|
|
107
|
+
info("Folder changes (#{lines.length}):\n#{lines.map { |line| " #{line}" }.join("\n")}")
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def show_planned_detaches(detached)
|
|
111
|
+
return if detached.empty?
|
|
112
|
+
|
|
113
|
+
info("Shared folders that would be detached (#{detached.length}):\n" \
|
|
114
|
+
"#{dir_lines(detached).join("\n")}\n#{shared_dirs_plan}")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|