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.
Files changed (66) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +24 -0
  3. data/LICENSE +21 -0
  4. data/README.md +136 -0
  5. data/bin/teems +7 -0
  6. data/lib/teems/api/calendar.rb +94 -0
  7. data/lib/teems/api/channels.rb +26 -0
  8. data/lib/teems/api/chats.rb +29 -0
  9. data/lib/teems/api/client.rb +40 -0
  10. data/lib/teems/api/files.rb +12 -0
  11. data/lib/teems/api/messages.rb +58 -0
  12. data/lib/teems/api/users.rb +88 -0
  13. data/lib/teems/api/users_mailbox.rb +16 -0
  14. data/lib/teems/api/users_presence.rb +43 -0
  15. data/lib/teems/cli.rb +133 -0
  16. data/lib/teems/commands/activity.rb +222 -0
  17. data/lib/teems/commands/auth.rb +268 -0
  18. data/lib/teems/commands/base.rb +146 -0
  19. data/lib/teems/commands/cal.rb +891 -0
  20. data/lib/teems/commands/channels.rb +115 -0
  21. data/lib/teems/commands/chats.rb +159 -0
  22. data/lib/teems/commands/help.rb +107 -0
  23. data/lib/teems/commands/messages.rb +281 -0
  24. data/lib/teems/commands/ooo.rb +385 -0
  25. data/lib/teems/commands/org.rb +232 -0
  26. data/lib/teems/commands/status.rb +224 -0
  27. data/lib/teems/commands/sync.rb +390 -0
  28. data/lib/teems/commands/who.rb +377 -0
  29. data/lib/teems/formatters/calendar_formatter.rb +227 -0
  30. data/lib/teems/formatters/format_utils.rb +56 -0
  31. data/lib/teems/formatters/markdown_formatter.rb +113 -0
  32. data/lib/teems/formatters/message_formatter.rb +67 -0
  33. data/lib/teems/formatters/output.rb +105 -0
  34. data/lib/teems/models/account.rb +59 -0
  35. data/lib/teems/models/channel.rb +31 -0
  36. data/lib/teems/models/chat.rb +111 -0
  37. data/lib/teems/models/duration.rb +46 -0
  38. data/lib/teems/models/event.rb +124 -0
  39. data/lib/teems/models/message.rb +125 -0
  40. data/lib/teems/models/parsing.rb +56 -0
  41. data/lib/teems/models/user.rb +25 -0
  42. data/lib/teems/models/user_profile.rb +45 -0
  43. data/lib/teems/runner.rb +81 -0
  44. data/lib/teems/services/api_client.rb +217 -0
  45. data/lib/teems/services/cache_store.rb +32 -0
  46. data/lib/teems/services/configuration.rb +56 -0
  47. data/lib/teems/services/file_downloader.rb +39 -0
  48. data/lib/teems/services/headless_extract.rb +192 -0
  49. data/lib/teems/services/safari_oauth.rb +285 -0
  50. data/lib/teems/services/sync_dir_naming.rb +42 -0
  51. data/lib/teems/services/sync_engine.rb +194 -0
  52. data/lib/teems/services/sync_store.rb +193 -0
  53. data/lib/teems/services/teams_url_parser.rb +78 -0
  54. data/lib/teems/services/token_exchange_scripts.rb +56 -0
  55. data/lib/teems/services/token_extractor.rb +401 -0
  56. data/lib/teems/services/token_extractor_scripts.rb +116 -0
  57. data/lib/teems/services/token_refresher.rb +169 -0
  58. data/lib/teems/services/token_store.rb +116 -0
  59. data/lib/teems/support/error_logger.rb +35 -0
  60. data/lib/teems/support/help_formatter.rb +80 -0
  61. data/lib/teems/support/timezone.rb +44 -0
  62. data/lib/teems/support/xdg_paths.rb +62 -0
  63. data/lib/teems/version.rb +5 -0
  64. data/lib/teems.rb +117 -0
  65. data/support/token_helper.swift +485 -0
  66. 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