teems 0.3.1 → 0.3.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5932afeb278892c20e7e3fbb92980da31ff45cd9a528a3e2463de38ee7ea3f70
4
- data.tar.gz: 331a38b48c8bb1d87ff770a0579e1846aa643b5d1d1daf5061c0bbf8f0589264
3
+ metadata.gz: 554acde76f11331b9a92ba84e7a0972f0951b0e26f90019801550ab6767a3493
4
+ data.tar.gz: 39b410aaf606f2298871bb9f90f463f021dee51d08814fce7f0260d8432de8a5
5
5
  SHA512:
6
- metadata.gz: 5d750e3b09de3a6ce07612398e0e00a7eb5ac647aa9cd38d45023cd0605777625a9ec212aa81c111b69118d068ffd3b4bd946280d4194cc1ecd34f40708adf7f
7
- data.tar.gz: 355288361bbfa4e21166b18b248793403a023411e3588be731e205c9fe4f6994684426ae7037a09cf009bdb699e9a17caf932572aea9ab1588449c3298224624
6
+ metadata.gz: 209764374cdd5674d1f9d5398d33fd2bccf6a1869d0ff0ec11dd5d5202e78de3cb2a24eb3dc4a2431df348cda73ee7d928816ae1210690bdca9ba8138596fcdc
7
+ data.tar.gz: 06ad5d91c6ae16bb936910d5dbd1f9d6fa85df7a7bb27caaed5b652acd1ee265cd3a8408be8842aa3f2a7d6c06099c9580fa40e089a5ebd18b70d6cf91169c0d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.3] - 2026-10-02
4
+
5
+ ### Added
6
+ - Inline images (screenshots pasted into a message) are no longer dropped when message HTML is stripped. `teems messages` lists them under the message, and `--json` includes an `images` array with the AMS URL, full-size URL, alt text, and dimensions.
7
+ - `teems messages --download` saves inline images alongside file attachments, fetching the original-resolution AMS view (falling back to the message's preview URL) with the skype token. The token is only sent to Teams/AMS hosts over HTTPS and is dropped on redirects elsewhere.
8
+ - `teems sync` records inline images in `messages.json` and renders `[image: alt (WxH)]` placeholders in `messages.md`. `teems sync --images` downloads them into each chat's `images/` directory, links them from `messages.md`, and backfills already-synced chats.
9
+
10
+ ### Fixed
11
+ - `teems messages <teams-url>` for a message in a group or 1:1 chat no longer fails with "Failed to fetch message"; those chats have no reply threads, so the replies endpoint's 404 now means no replies.
12
+ - `teems messages --json <teams-url>` no longer crashes with "no implicit conversion from nil to integer" after printing the thread.
13
+
14
+ ## [0.3.2] - 2026-09-29
15
+
16
+ ### Added
17
+ - `teems transcripts sync` discovers calendar Teams meetings and downloads available WebVTT transcripts into a private XDG data directory. The first run scans 30 days; subsequent runs scan seven, with an idempotent manifest and catch-up after time away. `--date`, `--since`, and `--dry-run` are supported. Recordings and audio are not downloaded.
18
+ - Transcript sync writes speaker-turn Markdown copies to `~/.local/share/teems/transcripts-md/` for local search indexes such as qmd.
19
+ - Transcript sync can run a `transcripts.post_sync_command` from `config.json` (for example, a qmd index refresh) after a sync that changed Markdown. The command gets the changed paths in `TEEMS_TRANSCRIPTS_*` environment variables, is bounded by `post_sync_timeout` (default 300 seconds), and warns rather than failing the sync. `--no-post-sync` skips it for one run.
20
+ - Transcript sync keeps one transcript per recording, so meetings that were restarted mid-session no longer lose the later recording's transcript. Existing per-meeting downloads are reused when identical rather than duplicated.
21
+ - `teems meeting --json` prints call events, recording links, and transcript markers as JSON (the option was documented but ignored).
22
+ - `teems meeting --transcript --recording-url URL` downloads the transcript for a specific recording in the meeting instead of the first one.
23
+
3
24
  ## [0.3.1] - 2026-09-02
4
25
 
5
26
  ### Added
data/README.md CHANGED
@@ -56,8 +56,12 @@ teems cal delete 3 # Delete event #3
56
56
  teems messages <chat-id> # Read from a chat
57
57
  teems messages <channel-id> -t <team-id> # Read from a channel
58
58
  teems messages <chat-id> -n 50 # Show more messages
59
+ teems messages <chat-id> --json # JSON, including inline image refs
60
+ teems messages <chat-id> --download # Save attachments and pasted images
59
61
  ```
60
62
 
63
+ Screenshots pasted into a message are listed under it (`🖼️ image (881x179)`) and included in `--json` as `images` (AMS `url`, `full_size_url`, `alt`, `width`, `height`). `--download` saves the original-resolution image to the downloads directory as `image-<hash>-<n>.<ext>`, using your Teams skype token. The token is only sent to Teams/AMS hosts over HTTPS.
64
+
61
65
  ### Channels and Chats
62
66
 
63
67
  ```bash
@@ -87,11 +91,48 @@ teems meeting <thread-id> --transcript -o ~/Downloads # Download transcript (VT
87
91
  teems meeting <thread-id> --recording -o ~/Downloads # Download recording (MP4)
88
92
  teems meeting <thread-id> --recording --transcript -o ~/Downloads # Both, with embedded subtitles
89
93
  teems meeting <event-id> # By calendar event ID (AAMk...)
94
+ teems meeting <event-id> --date 2026-09-29 --json # Call events + recording links as JSON
95
+ teems meeting <event-id> --date 2026-09-29 --transcript --recording-url <url> # A specific recording
90
96
  teems meeting "https://teams.microsoft.com/..." # By Teams URL or recap link
91
97
  ```
92
98
 
93
99
  Recording download requires `ffmpeg` (`brew install ffmpeg`) and downloads via DASH streaming with 5 parallel threads. No browser required.
94
100
 
101
+ #### Transcript sync
102
+
103
+ ```bash
104
+ teems transcripts sync --dry-run # Preview meetings and recording counts
105
+ teems transcripts sync # First run: 30 days; later: 7 days
106
+ teems transcripts sync --date 2026-09-28 # Retry a specific day
107
+ teems transcripts sync --since 30 # Explicit historical backfill
108
+ teems transcripts sync --no-post-sync # Skip the configured post-sync command once
109
+ ```
110
+
111
+ This saves **only WebVTT transcripts** in `~/.local/share/teems/transcripts/` on the machine running the command, separate from live-caption `meeting-capture` files. Each sync also writes speaker-turn Markdown copies to `~/.local/share/teems/transcripts-md/` (regenerated when missing or older than the VTT) for local search tools such as [qmd](https://github.com/tobi/qmd). Each recording keeps its own transcript, so a meeting restarted mid-session yields one VTT per recording. A private manifest in `~/.local/state/teems/transcript-sync.json` makes repeats idempotent and catches up after time away (up to 30 days). Data and state directories are mode 0700; transcripts and the manifest are mode 0600. The calendar scan retries unavailable transcripts within the lookback window; errors return a nonzero exit code. To run every evening on a GFE, schedule `teems transcripts sync` there via launchd.
112
+
113
+ Coverage is **calendar Teams meetings with a saved and accessible recording transcript**, not every meeting attended: ad-hoc calls, meetings with no saved transcript/recording, and inaccessible organizer-owned artifacts cannot be recovered by this route. No audio/video is downloaded, and nothing is copied to another machine.
114
+
115
+ To search the Markdown locally with qmd, keep it in a dedicated index so refreshes don't re-scan other collections:
116
+
117
+ ```bash
118
+ qmd --index teems-transcripts collection add ~/.local/share/teems/transcripts-md --name teems-transcripts --mask '**/*.md'
119
+ qmd --index teems-transcripts update && qmd --index teems-transcripts embed
120
+ qmd --index teems-transcripts query "what did we decide about monitoring?"
121
+ ```
122
+
123
+ To refresh that index automatically, set a post-sync command in `~/.config/teems/config.json`:
124
+
125
+ ```json
126
+ {
127
+ "transcripts": {
128
+ "post_sync_command": "qmd --index teems-transcripts update && qmd --index teems-transcripts embed",
129
+ "post_sync_timeout": 900
130
+ }
131
+ }
132
+ ```
133
+
134
+ The command runs via `sh -c` only after a sync writes or regenerates Markdown; not on `--dry-run`, when nothing changed, or with `--no-post-sync`. It receives `TEEMS_TRANSCRIPTS_CHANGED` (newline-separated Markdown paths), `TEEMS_TRANSCRIPTS_CHANGED_COUNT`, `TEEMS_TRANSCRIPTS_MARKDOWN_DIR`, and `TEEMS_TRANSCRIPTS_DIR`, and is killed after `post_sync_timeout` seconds (default 300). A failure or timeout prints a warning but does not change the sync's exit code.
135
+
95
136
  ### People
96
137
 
97
138
  ```bash
@@ -114,8 +155,11 @@ teems activity # Show activity feed
114
155
 
115
156
  ```bash
116
157
  teems sync # Sync chat history locally
158
+ teems sync --images # Also save pasted images beside each chat
117
159
  ```
118
160
 
161
+ Each chat gets `messages.md`, `messages.json`, and `chat_metadata.json` under `~/.local/share/teems/sync/chats/`. Inline images are recorded in `messages.json` and rendered in `messages.md` as `[image: alt (WxH)]`. With `--images`, they are downloaded to the chat's `images/` directory (named by AMS object id, so each is fetched once) and `messages.md` links the saved copy instead. `--images` also rewrites already-synced chats so images in older messages are backfilled.
162
+
119
163
  ## Global Options
120
164
 
121
165
  | Option | Description |
data/lib/teems/cli.rb CHANGED
@@ -48,6 +48,7 @@ module Teems
48
48
  'channels' => Commands::Channels,
49
49
  'chats' => Commands::Chats,
50
50
  'meeting' => Commands::Meeting,
51
+ 'transcripts' => Commands::Transcripts,
51
52
  'messages' => Commands::Messages,
52
53
  'sync' => Commands::Sync,
53
54
  'who' => Commands::Who,
@@ -9,6 +9,7 @@ module Teems
9
9
  ['channels', 'List joined teams and channels'],
10
10
  ['chats', 'List recent chats'],
11
11
  ['meeting', 'View meeting details, chat, transcripts, and recordings'],
12
+ ['transcripts', 'Sync saved meeting transcripts to this machine'],
12
13
  ['messages', 'Read messages from a channel or chat'],
13
14
  ['sync', 'Sync chat history locally'],
14
15
  ['who', "Look up a user's profile"],
@@ -83,6 +84,7 @@ module Teems
83
84
  teems channels List all channels
84
85
  teems chats List recent chats
85
86
  teems messages <channel-id> Read messages from a channel
87
+ teems transcripts sync Download recent meeting transcripts
86
88
  teems who Show your profile
87
89
  teems who john Search for a user
88
90
  teems org Show your org chart
@@ -24,10 +24,12 @@ module Teems
24
24
  --chat Show meeting chat messages
25
25
  --date YYYY-MM-DD Pick a single occurrence of a recurring meeting by date
26
26
  (filters call events, recordings, and transcripts)
27
+ --recording-url URL With --transcript, use this recording's transcript
28
+ (a meeting restarted mid-session has several)
27
29
  -o, --output-dir Directory for downloads (default: current directory)
28
30
  -v, --verbose Show debug output
29
31
  -q, --quiet Suppress output
30
- --json Output as JSON
32
+ --json Output meeting details (call events, recordings) as JSON
31
33
  -h, --help Show this help
32
34
 
33
35
  EXAMPLES:
@@ -39,6 +41,7 @@ module Teems
39
41
  teems meeting 19:meeting_abc123@thread.v2 --recording -o ~/Downloads
40
42
  teems meeting 19:meeting_abc123@thread.v2 --recording --transcript -o ~/Downloads
41
43
  teems meeting "<recurring-url>" --date 2026-05-04 --audio --no-video --transcript
44
+ teems meeting AAMkAGVmMDEz... --date 2026-05-04 --json # List recordings
42
45
  teems meeting AAMkAGVmMDEz... # By calendar event ID
43
46
  HELP
44
47
 
@@ -51,6 +54,7 @@ module Teems
51
54
  '--no-video' => ->(opts, _args) { opts[:no_video] = true },
52
55
  '--chat' => ->(opts, _args) { opts[:chat] = true },
53
56
  '--date' => ->(opts, args) { opts[:date] = args.shift },
57
+ '--recording-url' => ->(opts, args) { opts[:recording_url] = args.shift },
54
58
  '-o' => ->(opts, args) { opts[:output_dir] = args.shift },
55
59
  '--output-dir' => ->(opts, args) { opts[:output_dir] = args.shift }
56
60
  }.freeze
@@ -604,11 +608,30 @@ module Teems
604
608
  end
605
609
  end
606
610
 
611
+ # Machine-readable meeting details; `teems transcripts sync` uses these to enumerate recordings.
612
+ module MeetingJsonSummary
613
+ private
614
+
615
+ def output_meeting_json(target, classified)
616
+ call_events, recordings, transcripts = classified.values_at(:call_events, :recordings, :transcripts)
617
+ output_json(thread_id: target[:thread_id],
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) })
621
+ 0
622
+ end
623
+
624
+ def json_call_event(event)
625
+ event.slice(:time, :call_id, :duration).merge(participants: event[:participants].map { |part| part[:name] })
626
+ end
627
+ end
628
+
607
629
  # View meeting details, chat, transcripts, and recordings
608
630
  class Meeting < Base
609
631
  include MeetingTargetResolver
610
632
  include MeetingMessageParser
611
633
  include MeetingDisplay
634
+ include MeetingJsonSummary
612
635
  include MeetingChatDisplay
613
636
  include MeetingCallFilter
614
637
  include MeetingDateFilter
@@ -691,6 +714,7 @@ module Teems
691
714
  media = media_output_spec
692
715
  return download_media_with_transcript(target, classified, media) if media
693
716
  return download_transcript(target, classified) || 0 if @options[:transcript]
717
+ return output_meeting_json(target, classified) if @options[:json]
694
718
 
695
719
  display_meeting_summary(target, classified)
696
720
  0
@@ -138,24 +138,39 @@ module Teems
138
138
  end
139
139
  end
140
140
 
141
+ # Chooses which recording's transcript to fetch (--recording-url, else the first link).
142
+ module MeetingRecordingSelection
143
+ private
144
+
145
+ def first_recording_url(classified)
146
+ @options[:recording_url] || recording_urls(classified).first
147
+ end
148
+
149
+ def unknown_recording_url?(classified)
150
+ requested = @options[:recording_url]
151
+ requested && !recording_urls(classified).include?(requested)
152
+ end
153
+
154
+ def recording_urls(classified) = classified[:recordings].filter_map { |rec| rec[:url] }
155
+ end
156
+
141
157
  # Downloads meeting transcripts via SharePoint API (no Safari required)
142
158
  module MeetingTranscript
159
+ include MeetingRecordingSelection
143
160
  include EmbedPageParser
144
161
  include MeetingFilename
145
162
 
146
163
  private
147
164
 
148
165
  def download_transcript(target, classified)
166
+ return error('--recording-url is not a recording in this meeting') if unknown_recording_url?(classified)
167
+
149
168
  sharing_url = target[:fileUrl] || first_recording_url(classified)
150
169
  return error('No recording sharing link found for transcript download') unless sharing_url
151
170
 
152
171
  execute_transcript_pipeline(sharing_url)
153
172
  end
154
173
 
155
- def first_recording_url(classified)
156
- classified[:recordings].filter_map { |rec| rec[:url] }.first
157
- end
158
-
159
174
  def execute_transcript_pipeline(sharing_url)
160
175
  info('Fetching transcript via SharePoint...')
161
176
  embed_url = fetch_embed_url(sharing_url)
@@ -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
- return puts('No downloadable attachments found') if attachments.empty?
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 = with_token_refresh do
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:)
@@ -11,6 +11,7 @@ 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
16
  --dry-run Show what would be synced without writing files
16
17
  -v, --verbose Show debug output
@@ -20,11 +21,13 @@ module Teems
20
21
  teems sync # Sync 6 months of all chats
21
22
  teems sync --since 30 # Sync last 30 days
22
23
  teems sync --chat 19:abc@thread.v2 # Sync a single chat
24
+ teems sync --images # Include pasted screenshots
23
25
  teems sync --dry-run # Preview what would be synced
24
26
 
25
27
  OUTPUT:
26
28
  Files are stored in ~/.local/share/teems/sync/chats/
27
29
  Each chat gets: messages.md, messages.json, chat_metadata.json
30
+ (plus images/ with --images; messages.md links saved images, otherwise [image: ...])
28
31
  HELP
29
32
 
30
33
  # Handles syncing individual chats: fetch, merge, retry on 404
@@ -70,7 +73,9 @@ module Teems
70
73
 
71
74
  def process_fetched_messages(chat, new_messages)
72
75
  chat_id = chat.id
73
- return skip_unchanged(chat_id) if new_messages.empty? && @sync_store.last_synced_time(@state, chat_id)
76
+ # --images rewrites unchanged chats too, so images in already-synced messages are backfilled
77
+ unchanged = new_messages.empty? && !@options[:images]
78
+ return skip_unchanged(chat_id) if unchanged && @sync_store.last_synced_time(@state, chat_id)
74
79
 
75
80
  merge_and_update(chat, new_messages)
76
81
  end
@@ -83,7 +88,9 @@ module Teems
83
88
 
84
89
  def merge_and_update(chat, new_messages)
85
90
  existing_raw = @sync_store.read_messages_json(chat.id, state: @state)
86
- all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages)
91
+ all_messages = sync_engine.merge_and_write(chat, existing_raw, new_messages) do |chat_dir, messages|
92
+ save_chat_images(chat_dir, messages)
93
+ end
87
94
  update_chat_state(chat, all_messages)
88
95
  end
89
96
 
@@ -138,6 +145,23 @@ module Teems
138
145
  end
139
146
  end
140
147
 
148
+ # Opt-in (--images) download of inline images into each synced chat directory
149
+ module SyncImageHandler
150
+ private
151
+
152
+ def save_chat_images(chat_dir, messages)
153
+ return unless @options[:images]
154
+
155
+ saved = sync_images.save(chat_dir, messages)
156
+ debug(" Saved #{saved} image(s)")
157
+ @stats[:images] += saved
158
+ end
159
+
160
+ def sync_images
161
+ @sync_images ||= Services::SyncImages.new(downloader: runner.inline_image_downloader, output: output)
162
+ end
163
+ end
164
+
141
165
  # Chat list fetching, dry-run display, and summary reporting
142
166
  module SyncDisplay
143
167
  private
@@ -211,6 +235,7 @@ module Teems
211
235
  skipped = @stats[:skipped]
212
236
  info(" Chats skipped (no new messages): #{skipped}") if skipped.positive?
213
237
  info(" Total messages: #{@stats[:messages_total]}")
238
+ info(" Images downloaded: #{@stats[:images]}") if @options[:images]
214
239
  display_error_count
215
240
  end
216
241
 
@@ -287,6 +312,7 @@ module Teems
287
312
  # Sync chat history locally as Markdown + JSON files
288
313
  class Sync < Base
289
314
  include SyncChatHandler
315
+ include SyncImageHandler
290
316
  include SyncDisplay
291
317
  include SyncAuth
292
318
 
@@ -318,6 +344,7 @@ module Teems
318
344
  '--since' => ->(opts, args) { opts[:since_days] = args.shift.to_i },
319
345
  '--chat' => ->(opts, args) { opts[:chat_id] = args.shift },
320
346
  '--dry-run' => ->(opts, _args) { opts[:dry_run] = true },
347
+ '--images' => ->(opts, _args) { opts[:images] = true },
321
348
  '--auth' => ->(opts, _args) { opts[:auth] = true }
322
349
  }.freeze
323
350
 
@@ -353,7 +380,7 @@ module Teems
353
380
  def init_sync_state
354
381
  @sync_store = Services::SyncStore.new
355
382
  @state = @sync_store.load_state
356
- @stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0 }
383
+ @stats = { synced: 0, skipped: 0, errors: 0, messages_total: 0, images: 0 }
357
384
  setup_api_logging
358
385
  end
359
386