teems 0.1.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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- metadata +110 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
# Render helpers for presence status display
|
|
6
|
+
module StatusDisplay
|
|
7
|
+
AVAILABILITY_LABELS = {
|
|
8
|
+
'Available' => 'Available', 'Busy' => 'Busy',
|
|
9
|
+
'DoNotDisturb' => 'Do Not Disturb', 'Away' => 'Away',
|
|
10
|
+
'BeRightBack' => 'Be Right Back', 'Offline' => 'Offline',
|
|
11
|
+
'PresenceUnknown' => 'Unknown'
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def render_presence(data)
|
|
17
|
+
@options[:json] ? output_json(data) : render_presence_text(data)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def render_presence_text(data)
|
|
21
|
+
render_availability_line(data)
|
|
22
|
+
render_status_message_line(data)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render_availability_line(data)
|
|
26
|
+
raw, activity = data.values_at('availability', 'activity')
|
|
27
|
+
line = "Availability: #{AVAILABILITY_LABELS[raw] || raw}"
|
|
28
|
+
line += " (#{activity})" if activity && activity != raw
|
|
29
|
+
puts line
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def render_status_message_line(data)
|
|
33
|
+
msg = data.dig('statusMessage', 'message', 'content')
|
|
34
|
+
return unless msg && !msg.empty?
|
|
35
|
+
|
|
36
|
+
expiry_text = format_expiry(data.dig('statusMessage', 'expiryDateTime'))
|
|
37
|
+
line = "Status: #{msg}"
|
|
38
|
+
line += " (#{expiry_text})" if expiry_text
|
|
39
|
+
puts line
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def format_expiry(expiry_data)
|
|
43
|
+
return nil unless expiry_data
|
|
44
|
+
|
|
45
|
+
expiry_str = expiry_data['dateTime']
|
|
46
|
+
return nil unless expiry_str
|
|
47
|
+
|
|
48
|
+
remaining = Time.parse(expiry_str) - Time.now.utc
|
|
49
|
+
return 'expired' unless remaining.positive?
|
|
50
|
+
|
|
51
|
+
format_remaining(remaining)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def format_remaining(remaining)
|
|
55
|
+
total_minutes = (remaining / 60).ceil
|
|
56
|
+
hrs = total_minutes / 60
|
|
57
|
+
mins = total_minutes % 60
|
|
58
|
+
parts = []
|
|
59
|
+
parts << "#{hrs}h" if hrs.positive?
|
|
60
|
+
parts << "#{mins}m" if mins.positive?
|
|
61
|
+
"expires in #{parts.join(' ')}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Dispatch and mutation logic for setting/clearing presence
|
|
66
|
+
module StatusActions
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def dispatch_action
|
|
70
|
+
case positional_args
|
|
71
|
+
in ['clear', *] then clear_status
|
|
72
|
+
in [text, *rest] then set_status(text, rest)
|
|
73
|
+
in [] then @options[:presence] ? set_presence_only : show_status
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def show_status
|
|
78
|
+
data = with_token_refresh { runner.users_api.my_presence }
|
|
79
|
+
render_presence(data)
|
|
80
|
+
0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def set_status(text, rest)
|
|
84
|
+
duration = parse_duration(rest.first)
|
|
85
|
+
expiry = duration&.to_expiration
|
|
86
|
+
with_token_refresh { runner.users_api.set_status_message(message: text, expiry: expiry) }
|
|
87
|
+
send_presence(presence_duration(duration)) if @options[:presence]
|
|
88
|
+
msg = "Status set: #{text}"
|
|
89
|
+
msg += " (#{duration})" if duration
|
|
90
|
+
success(msg)
|
|
91
|
+
0
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def clear_status
|
|
95
|
+
with_token_refresh { runner.users_api.clear_status_message }
|
|
96
|
+
send_presence(self.class::DEFAULT_PRESENCE_DURATION) if @options[:presence]
|
|
97
|
+
success('Status cleared')
|
|
98
|
+
0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def set_presence_only
|
|
102
|
+
send_presence(self.class::DEFAULT_PRESENCE_DURATION)
|
|
103
|
+
success("Presence set: #{@options[:presence]}")
|
|
104
|
+
0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def presence_duration(duration)
|
|
108
|
+
duration ? duration.to_iso8601_duration : self.class::DEFAULT_PRESENCE_DURATION
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def send_presence(iso_duration)
|
|
112
|
+
availability, activity = self.class::PRESENCE_MAP[@options[:presence]]
|
|
113
|
+
with_token_refresh do
|
|
114
|
+
runner.users_api.set_presence(availability: availability, activity: activity, duration: iso_duration)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_duration(value)
|
|
119
|
+
return nil unless value
|
|
120
|
+
|
|
121
|
+
Models::Duration.parse(value)
|
|
122
|
+
rescue ArgumentError
|
|
123
|
+
debug("Invalid duration #{value.inspect}, ignoring")
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# View and manage your presence status
|
|
129
|
+
class Status < Base
|
|
130
|
+
include StatusDisplay
|
|
131
|
+
include StatusActions
|
|
132
|
+
|
|
133
|
+
PRESENCE_MAP = {
|
|
134
|
+
'available' => %w[Available Available],
|
|
135
|
+
'busy' => %w[Busy Busy],
|
|
136
|
+
'dnd' => %w[DoNotDisturb DoNotDisturb],
|
|
137
|
+
'away' => %w[Away Away],
|
|
138
|
+
'brb' => %w[BeRightBack BeRightBack],
|
|
139
|
+
'offline' => %w[Offline OffWork]
|
|
140
|
+
}.freeze
|
|
141
|
+
|
|
142
|
+
DEFAULT_PRESENCE_DURATION = 'PT4H'
|
|
143
|
+
|
|
144
|
+
STATUS_OPTIONS = {
|
|
145
|
+
'-p' => ->(opts, args) { opts[:presence] = args.shift },
|
|
146
|
+
'--presence' => ->(opts, args) { opts[:presence] = args.shift }
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
def initialize(args, runner:)
|
|
150
|
+
@options = {}
|
|
151
|
+
super
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def execute
|
|
155
|
+
result = validate_options
|
|
156
|
+
return result if result
|
|
157
|
+
|
|
158
|
+
auth_result = require_auth
|
|
159
|
+
return auth_result if auth_result
|
|
160
|
+
|
|
161
|
+
dispatch
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
protected
|
|
165
|
+
|
|
166
|
+
def handle_option(arg, pending)
|
|
167
|
+
handler = STATUS_OPTIONS[arg]
|
|
168
|
+
return super unless handler
|
|
169
|
+
|
|
170
|
+
handler.call(@options, pending)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def help_text
|
|
174
|
+
<<~HELP
|
|
175
|
+
#{output.bold('teems status')} - View and manage your presence status
|
|
176
|
+
|
|
177
|
+
#{output.bold('USAGE:')}
|
|
178
|
+
teems status Show current status
|
|
179
|
+
teems status "<message>" Set status message
|
|
180
|
+
teems status "<message>" <duration> Set with expiry (e.g. 2h, 30m, 1h30m)
|
|
181
|
+
teems status clear Clear status message
|
|
182
|
+
teems status --presence <value> Set presence only
|
|
183
|
+
|
|
184
|
+
#{output.bold('PRESENCE VALUES:')}
|
|
185
|
+
available, busy, dnd, away, brb, offline
|
|
186
|
+
|
|
187
|
+
#{output.bold('OPTIONS:')}
|
|
188
|
+
-p, --presence VALUE Set presence/availability
|
|
189
|
+
--json Output as JSON
|
|
190
|
+
-v, --verbose Show debug output
|
|
191
|
+
-q, --quiet Suppress output
|
|
192
|
+
-h, --help Show this help
|
|
193
|
+
|
|
194
|
+
#{output.bold('EXAMPLES:')}
|
|
195
|
+
teems status Show current status
|
|
196
|
+
teems status "In a meeting" Set status message
|
|
197
|
+
teems status "Focus time" 2h Set with 2h expiry
|
|
198
|
+
teems status clear Clear status message
|
|
199
|
+
teems status --presence away Set presence to away
|
|
200
|
+
teems status "Focus" 2h --presence dnd Set message + presence
|
|
201
|
+
HELP
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
private
|
|
205
|
+
|
|
206
|
+
def dispatch
|
|
207
|
+
return invalid_presence if @options[:presence] && !valid_presence?
|
|
208
|
+
|
|
209
|
+
dispatch_action
|
|
210
|
+
rescue ApiError => e
|
|
211
|
+
error("Status error: #{e.message}")
|
|
212
|
+
1
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def valid_presence? = PRESENCE_MAP.key?(@options[:presence])
|
|
216
|
+
|
|
217
|
+
def invalid_presence
|
|
218
|
+
error("Invalid presence: #{@options[:presence]}")
|
|
219
|
+
error("Valid values: #{PRESENCE_MAP.keys.join(', ')}")
|
|
220
|
+
1
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Teems
|
|
4
|
+
module Commands
|
|
5
|
+
SYNC_HELP = <<~HELP
|
|
6
|
+
teems sync - Sync chat history locally
|
|
7
|
+
|
|
8
|
+
USAGE:
|
|
9
|
+
teems sync [options]
|
|
10
|
+
|
|
11
|
+
OPTIONS:
|
|
12
|
+
--since DAYS Number of days of history to sync (default: 180)
|
|
13
|
+
--chat CHAT_ID Sync only this chat
|
|
14
|
+
--auth Authenticate via Safari before syncing
|
|
15
|
+
--dry-run Show what would be synced without writing files
|
|
16
|
+
-v, --verbose Show debug output
|
|
17
|
+
-q, --quiet Suppress output
|
|
18
|
+
|
|
19
|
+
EXAMPLES:
|
|
20
|
+
teems sync # Sync 6 months of all chats
|
|
21
|
+
teems sync --since 30 # Sync last 30 days
|
|
22
|
+
teems sync --chat 19:abc@thread.v2 # Sync a single chat
|
|
23
|
+
teems sync --dry-run # Preview what would be synced
|
|
24
|
+
|
|
25
|
+
OUTPUT:
|
|
26
|
+
Files are stored in ~/.local/share/teems/sync/chats/
|
|
27
|
+
Each chat gets: messages.md, messages.json, chat_metadata.json
|
|
28
|
+
HELP
|
|
29
|
+
|
|
30
|
+
# Handles syncing individual chats: fetch, merge, retry on 404
|
|
31
|
+
module SyncChatHandler
|
|
32
|
+
RETRY_DELAY_SECONDS = 2
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def sync_or_skip_chat(chat_data, label)
|
|
37
|
+
chat = Models::Chat.from_api(chat_data)
|
|
38
|
+
skip_reason(chat.id) ? log_skip(chat, label) : log_and_sync(chat, label)
|
|
39
|
+
rescue StandardError => e
|
|
40
|
+
handle_sync_error(chat, e)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def log_and_sync(chat, label)
|
|
44
|
+
info("#{label} Syncing: #{chat.display_name}")
|
|
45
|
+
with_404_retry(chat) { sync_single_chat(chat) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def log_skip(chat, label)
|
|
49
|
+
chat_id = chat.id
|
|
50
|
+
debug("#{label} Skipping #{skip_reason(chat_id)}: #{chat.display_name} (#{chat_id})")
|
|
51
|
+
@stats[:skipped] += 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def handle_sync_error(chat, err)
|
|
55
|
+
output.flush
|
|
56
|
+
warn(" Unexpected error syncing '#{chat.display_name}': #{err.message}")
|
|
57
|
+
debug(" #{err.backtrace&.first}")
|
|
58
|
+
@stats[:errors] += 1
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def sync_single_chat(chat)
|
|
62
|
+
@sync_store.ensure_dir_name(
|
|
63
|
+
@state, chat_info: { chat_id: chat.id, display_name: chat.display_name,
|
|
64
|
+
chat_type: chat.chat_type }
|
|
65
|
+
)
|
|
66
|
+
new_messages = fetch_new_messages(chat)
|
|
67
|
+
debug(" Fetched #{new_messages.length} new message(s)")
|
|
68
|
+
process_fetched_messages(chat, new_messages)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def process_fetched_messages(chat, new_messages)
|
|
72
|
+
chat_id = chat.id
|
|
73
|
+
return skip_unchanged(chat_id) if new_messages.empty? && @sync_store.last_synced_time(@state, chat_id)
|
|
74
|
+
|
|
75
|
+
merge_and_update(chat, new_messages)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fetch_new_messages(chat)
|
|
79
|
+
chat_id = chat.id
|
|
80
|
+
start_time = @sync_store.last_synced_time(@state, chat_id) || since_time
|
|
81
|
+
with_token_refresh { sync_engine.fetch_all_messages(chat_id, start_time) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def merge_and_update(chat, new_messages)
|
|
85
|
+
existing_raw = @sync_store.read_messages_json(chat.id, state: @state)
|
|
86
|
+
all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages)
|
|
87
|
+
update_chat_state(chat, all_messages)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def skip_unchanged(chat_id)
|
|
91
|
+
debug(" No new messages, skipping write (#{chat_id})")
|
|
92
|
+
@stats[:skipped] += 1
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def update_chat_state(chat, all_messages)
|
|
96
|
+
count = all_messages.length
|
|
97
|
+
debug(" Total: #{count} message(s) after merge")
|
|
98
|
+
@sync_store.update_chat_state(
|
|
99
|
+
@state, chat.id,
|
|
100
|
+
attrs: { last_synced_at: Time.now, message_count: count,
|
|
101
|
+
display_name: chat.display_name, chat_type: chat.chat_type }
|
|
102
|
+
)
|
|
103
|
+
@stats[:synced] += 1
|
|
104
|
+
@stats[:messages_total] += count
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def with_404_retry(chat, &)
|
|
108
|
+
yield
|
|
109
|
+
rescue ApiError => e
|
|
110
|
+
output.flush
|
|
111
|
+
return handle_non_404_error(chat, e) unless e.not_found?
|
|
112
|
+
|
|
113
|
+
retry_after_not_found(chat, &)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def handle_non_404_error(chat, error)
|
|
117
|
+
warn(" Failed to sync '#{chat.display_name}': #{error.message}")
|
|
118
|
+
@stats[:errors] += 1
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retry_after_not_found(chat)
|
|
122
|
+
debug(" Got 404, retrying in #{RETRY_DELAY_SECONDS}s...")
|
|
123
|
+
sleep(RETRY_DELAY_SECONDS)
|
|
124
|
+
yield
|
|
125
|
+
rescue ApiError => e
|
|
126
|
+
handle_persistent_not_found(chat, e)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def handle_persistent_not_found(chat, error)
|
|
130
|
+
display_name = chat.display_name
|
|
131
|
+
if error.not_found?
|
|
132
|
+
@sync_store.mark_unavailable(@state, chat.id, display_name: display_name, chat_type: chat.chat_type)
|
|
133
|
+
warn(" Chat unavailable (404): '#{display_name}' — will skip on future syncs")
|
|
134
|
+
else
|
|
135
|
+
warn(" Failed to sync '#{display_name}': #{error.message}")
|
|
136
|
+
end
|
|
137
|
+
@stats[:errors] += 1
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Chat list fetching, dry-run display, and summary reporting
|
|
142
|
+
module SyncDisplay
|
|
143
|
+
private
|
|
144
|
+
|
|
145
|
+
def fetch_chat_list
|
|
146
|
+
return build_single_chat if @options[:chat_id]
|
|
147
|
+
|
|
148
|
+
fetch_all_chats
|
|
149
|
+
rescue ApiError => e
|
|
150
|
+
error("Failed to fetch chats: #{e.message}")
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_single_chat
|
|
155
|
+
chat_id = @options[:chat_id]
|
|
156
|
+
info("Fetching chat info for #{chat_id}...")
|
|
157
|
+
[{ 'id' => chat_id, 'threadProperties' => {} }]
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fetch_all_chats
|
|
161
|
+
info('Fetching chat list...')
|
|
162
|
+
chats = fetch_raw_chats
|
|
163
|
+
chats.tap { |list| list.empty? ? info('No chats found') : debug("Found #{list.length} chats") }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def fetch_raw_chats
|
|
167
|
+
parse_chat_response(with_token_refresh { runner.chats_api.list(limit: 200) })
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def parse_chat_response(response)
|
|
171
|
+
response['conversations'] || response['value'] || []
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def show_dry_run(chats)
|
|
175
|
+
syncable = chats.reject { |chat| skip_reason(chat['id']) }
|
|
176
|
+
display_dry_run_list(chats.length - syncable.length, syncable)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def display_dry_run_list(skipped_count, syncable)
|
|
180
|
+
display_dry_run_header(skipped_count, syncable.length)
|
|
181
|
+
syncable.each { |chat| format_dry_run_chat(chat) }
|
|
182
|
+
0
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def display_dry_run_header(skipped, syncable_count)
|
|
186
|
+
info("Dry run — would sync #{syncable_count} chat(s) since #{since_time.strftime('%Y-%m-%d')}")
|
|
187
|
+
info(" (#{skipped} system streams skipped)") if skipped.positive?
|
|
188
|
+
puts
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def format_dry_run_chat(chat_data)
|
|
192
|
+
chat = Models::Chat.from_api(chat_data)
|
|
193
|
+
chat_id = chat.id
|
|
194
|
+
puts " #{chat.display_name}\n ID: #{chat_id}\n Status: #{dry_run_sync_status(chat_id)}\n"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def dry_run_sync_status(chat_id)
|
|
198
|
+
last_sync = @sync_store.last_synced_time(@state, chat_id)
|
|
199
|
+
last_sync ? "last synced #{last_sync.strftime('%Y-%m-%d %H:%M')}" : 'never synced'
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def show_summary
|
|
203
|
+
puts
|
|
204
|
+
success('Sync complete!')
|
|
205
|
+
show_summary_stats
|
|
206
|
+
info(" Output: #{@sync_store.sync_dir}")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def show_summary_stats
|
|
210
|
+
info(" Chats synced: #{@stats[:synced]}")
|
|
211
|
+
skipped = @stats[:skipped]
|
|
212
|
+
info(" Chats skipped (no new messages): #{skipped}") if skipped.positive?
|
|
213
|
+
info(" Total messages: #{@stats[:messages_total]}")
|
|
214
|
+
display_error_count
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def display_error_count
|
|
218
|
+
error_count = @stats[:errors]
|
|
219
|
+
return unless error_count.positive?
|
|
220
|
+
|
|
221
|
+
output.flush
|
|
222
|
+
warn(" Errors: #{error_count}")
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Authentication handling for sync --auth flag
|
|
227
|
+
module SyncAuth
|
|
228
|
+
private
|
|
229
|
+
|
|
230
|
+
def login_if_requested
|
|
231
|
+
return unless @options[:auth] && !tokens_already_valid?
|
|
232
|
+
|
|
233
|
+
tokens = runner.token_extractor.extract
|
|
234
|
+
return save_login_tokens(tokens) if tokens&.dig(:auth_token) && tokens[:skype_token]
|
|
235
|
+
|
|
236
|
+
tokens&.dig(:auth_token) ? report_partial_extraction : report_full_extraction_failure
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def report_partial_extraction
|
|
240
|
+
error('Failed to authenticate via Safari')
|
|
241
|
+
error(' auth_token extracted but skype_token exchange failed')
|
|
242
|
+
1
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def report_full_extraction_failure
|
|
246
|
+
error('Failed to authenticate via Safari')
|
|
247
|
+
1
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def tokens_already_valid?
|
|
251
|
+
return false unless runner.configured?
|
|
252
|
+
|
|
253
|
+
unless token_store.account
|
|
254
|
+
debug('Stored tokens incomplete, re-authenticating...')
|
|
255
|
+
return false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
check_tokens_with_api
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_tokens_with_api
|
|
262
|
+
debug('Checking if existing tokens are still valid...')
|
|
263
|
+
runner.chats_api.list(limit: 1)
|
|
264
|
+
log_valid_tokens
|
|
265
|
+
rescue ApiError
|
|
266
|
+
debug('Existing tokens are expired or invalid, re-authenticating...')
|
|
267
|
+
nil
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def log_valid_tokens
|
|
271
|
+
debug('Existing tokens are valid, skipping browser auth')
|
|
272
|
+
success('Using existing authentication (tokens still valid)')
|
|
273
|
+
:valid
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def save_login_tokens(tokens)
|
|
277
|
+
saved = token_store.save(name: 'default', **tokens.slice(:auth_token, :skype_token,
|
|
278
|
+
:skype_spaces_token, :chatsvc_token,
|
|
279
|
+
:refresh_token, :client_id, :tenant_id))
|
|
280
|
+
return error('Authentication tokens extracted but failed to save') || 1 unless saved
|
|
281
|
+
|
|
282
|
+
success('Authentication successful!')
|
|
283
|
+
nil
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Sync chat history locally as Markdown + JSON files
|
|
288
|
+
class Sync < Base
|
|
289
|
+
include SyncChatHandler
|
|
290
|
+
include SyncDisplay
|
|
291
|
+
include SyncAuth
|
|
292
|
+
|
|
293
|
+
DEFAULT_SINCE_DAYS = 180
|
|
294
|
+
SKIP_PREFIXES = %w[48:].freeze
|
|
295
|
+
|
|
296
|
+
def initialize(args, runner:)
|
|
297
|
+
@options = {}
|
|
298
|
+
@sync_store = nil
|
|
299
|
+
@state = nil
|
|
300
|
+
@stats = nil
|
|
301
|
+
super
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def execute
|
|
305
|
+
result = validate_and_authenticate
|
|
306
|
+
return result if result
|
|
307
|
+
|
|
308
|
+
run_sync
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def validate_and_authenticate
|
|
312
|
+
validate_options || login_if_requested || require_auth
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
protected
|
|
316
|
+
|
|
317
|
+
SYNC_OPTIONS = {
|
|
318
|
+
'--since' => ->(opts, args) { opts[:since_days] = args.shift.to_i },
|
|
319
|
+
'--chat' => ->(opts, args) { opts[:chat_id] = args.shift },
|
|
320
|
+
'--dry-run' => ->(opts, _args) { opts[:dry_run] = true },
|
|
321
|
+
'--auth' => ->(opts, _args) { opts[:auth] = true }
|
|
322
|
+
}.freeze
|
|
323
|
+
|
|
324
|
+
def handle_option(arg, pending)
|
|
325
|
+
handler = SYNC_OPTIONS[arg]
|
|
326
|
+
return super unless handler
|
|
327
|
+
|
|
328
|
+
handler.call(@options, pending)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def help_text = SYNC_HELP
|
|
332
|
+
|
|
333
|
+
private
|
|
334
|
+
|
|
335
|
+
def run_sync
|
|
336
|
+
init_sync_state
|
|
337
|
+
chats = fetch_chat_list
|
|
338
|
+
return 1 unless chats
|
|
339
|
+
return show_dry_run(chats) if @options[:dry_run]
|
|
340
|
+
|
|
341
|
+
sync_all_chats(chats)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def sync_all_chats(chats)
|
|
345
|
+
chats.each_with_index { |chat_data, index| sync_or_skip_chat(chat_data, "[#{index + 1}/#{chats.length}]") }
|
|
346
|
+
save_state_safely
|
|
347
|
+
show_summary
|
|
348
|
+
sync_exit_code
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def sync_exit_code = @stats[:errors].positive? ? 1 : 0
|
|
352
|
+
|
|
353
|
+
def init_sync_state
|
|
354
|
+
@sync_store = Services::SyncStore.new
|
|
355
|
+
@state = @sync_store.load_state
|
|
356
|
+
@stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0 }
|
|
357
|
+
setup_api_logging
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def skip_reason(chat_id)
|
|
361
|
+
return 'system stream' if SKIP_PREFIXES.any? { |prefix| chat_id.start_with?(prefix) }
|
|
362
|
+
return 'unavailable chat' if @sync_store.chat_unavailable?(@state, chat_id)
|
|
363
|
+
|
|
364
|
+
nil
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def sync_engine
|
|
368
|
+
@sync_engine ||= Services::SyncEngine.new(
|
|
369
|
+
runner: runner, sync_store: @sync_store, state: @state, output: output
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def save_state_safely
|
|
374
|
+
@sync_store.save_state(@state)
|
|
375
|
+
rescue StandardError => e
|
|
376
|
+
@stats[:errors] += 1
|
|
377
|
+
error("Warning: Failed to save sync state: #{e.message}")
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def since_time = Time.now - ((@options[:since_days] || DEFAULT_SINCE_DAYS) * 86_400)
|
|
381
|
+
|
|
382
|
+
def setup_api_logging
|
|
383
|
+
out = output
|
|
384
|
+
runner.api_client.on_response = lambda { |path, code|
|
|
385
|
+
out.debug(" API ← #{code} #{path[0..80]}") if out.verbose?
|
|
386
|
+
}
|
|
387
|
+
end
|
|
388
|
+
end
|
|
389
|
+
end
|
|
390
|
+
end
|