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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +44 -0
- data/lib/teems/cli.rb +1 -0
- data/lib/teems/commands/help.rb +2 -0
- data/lib/teems/commands/meeting.rb +25 -1
- data/lib/teems/commands/meeting_transcript.rb +19 -4
- data/lib/teems/commands/messages.rb +51 -8
- data/lib/teems/commands/sync.rb +30 -3
- data/lib/teems/commands/transcripts.rb +534 -0
- data/lib/teems/formatters/format_utils.rb +3 -0
- data/lib/teems/formatters/markdown_formatter.rb +10 -1
- data/lib/teems/formatters/message_formatter.rb +7 -2
- data/lib/teems/formatters/transcript_markdown.rb +97 -0
- data/lib/teems/models/inline_image.rb +83 -0
- data/lib/teems/models/message.rb +7 -4
- data/lib/teems/models/parsing.rb +5 -0
- data/lib/teems/runner.rb +5 -0
- data/lib/teems/services/inline_image_downloader.rb +115 -0
- data/lib/teems/services/sync_engine.rb +13 -7
- data/lib/teems/services/sync_images.rb +55 -0
- data/lib/teems/support/subprocess.rb +2 -0
- data/lib/teems/version.rb +1 -1
- data/lib/teems.rb +6 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 554acde76f11331b9a92ba84e7a0972f0951b0e26f90019801550ab6767a3493
|
|
4
|
+
data.tar.gz: 39b410aaf606f2298871bb9f90f463f021dee51d08814fce7f0260d8432de8a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
data/lib/teems/commands/help.rb
CHANGED
|
@@ -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
|
-
|
|
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,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
|
-
|
|
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
|
|