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.
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'cgi'
4
+
5
+ module Teems
6
+ module Models
7
+ # Extracts inline images (screenshots pasted into a message) from Teams message HTML.
8
+ # Teams stores them in its AMS object store and references them with
9
+ # <img itemtype="http://schema.skype.com/AMSImage" src="https://<ams-host>/v1/objects/<id>/views/imgo">.
10
+ module InlineImageParsing
11
+ IMG_TAG = /<img\b[^>]*>/i
12
+ ATTRIBUTE = /([\w:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/
13
+ AMS_ITEMTYPE = 'schema.skype.com/AMSImage'
14
+ AMS_PATH = %r{\Ahttps://[^/\s]+/v1/objects/([^/?#\s]+)/views/}
15
+
16
+ module_function
17
+
18
+ def parse(html)
19
+ return [] unless html.is_a?(String) && html.match?(/<img/i)
20
+
21
+ html.scan(IMG_TAG).filter_map { |tag| image_from(tag_attributes(tag)) }.uniq(&:url)
22
+ end
23
+
24
+ def tag_attributes(tag)
25
+ tag.scan(ATTRIBUTE).to_h { |name, double, single| [name.downcase, CGI.unescapeHTML(double || single)] }
26
+ end
27
+
28
+ def image_from(attrs)
29
+ url = attrs['src'].to_s
30
+ return unless ams_image?(attrs, url)
31
+
32
+ InlineImage.new(id: attrs['itemid'] || url[AMS_PATH, 1], url: url, alt: attrs['alt'],
33
+ width: dimension(attrs['width']), height: dimension(attrs['height']),
34
+ format: attrs['itemscope'])
35
+ end
36
+
37
+ def ams_image?(attrs, url)
38
+ url.start_with?('https://') && (attrs['itemtype'].to_s.include?(AMS_ITEMTYPE) || url.match?(AMS_PATH))
39
+ end
40
+
41
+ def dimension(value) = Integer(value.to_s, exception: false)&.then { |num| num.positive? ? num : nil }
42
+ end
43
+
44
+ # An image pasted inline into a Teams message body
45
+ InlineImage = Data.define(:id, :url, :alt, :width, :height, :format) do
46
+ def self.parse_html(html) = InlineImageParsing.parse(html)
47
+
48
+ # Rebuilds an image from its stored (JSON) hash form
49
+ def self.from_h(hash)
50
+ return unless hash.is_a?(Hash)
51
+
52
+ attrs = hash.transform_keys(&:to_sym).slice(*members)
53
+ attrs[:url] ? new(**attrs) : nil
54
+ end
55
+
56
+ # Only the URL is required; the other attributes default to nil
57
+ def initialize(url:, **optional)
58
+ super(url: url, id: nil, alt: nil, width: nil, height: nil, format: nil, **optional)
59
+ end
60
+
61
+ # The original upload: `src` usually points at a recompressed JPEG preview,
62
+ # while this view is what Teams opens for "view full image".
63
+ def full_size_url
64
+ url.sub(%r{(/v1/objects/[^/]+/views/)[^/?#]+}, '\1imgpsh_fullsize_anim')
65
+ end
66
+
67
+ # JSON form: the stored attributes plus the original-resolution URL
68
+ def as_json = to_h.merge(full_size_url: full_size_url)
69
+
70
+ def dimensions = width && height ? "#{width}x#{height}" : nil
71
+
72
+ def label
73
+ name = alt.to_s.strip
74
+ name = 'image' if name.empty?
75
+ size = dimensions
76
+ size ? "#{name} (#{size})" : name
77
+ end
78
+
79
+ # Filesystem-safe name derived from the AMS object id
80
+ def file_stem = (id || url[%r{/v1/objects/([^/]+)/}, 1] || 'image').to_s.gsub(/[^\w.-]/, '_')
81
+ end
82
+ end
83
+ end
@@ -7,10 +7,13 @@ module Teems
7
7
  Message = Data.define(
8
8
  :id, :sender_id, :sender_name, :content, :created_at,
9
9
  :message_type, :reply_to_id, :reactions, :attachments, :importance,
10
- :edited, :mentions
10
+ :edited, :mentions, :images
11
11
  ) do
12
12
  extend Parsing
13
13
 
14
+ # `images` (inline AMS images) defaults to empty so callers can omit it
15
+ def initialize(images: [], **attrs) = super
16
+
14
17
  def self.from_api(data)
15
18
  if data['message'] then from_teams_internal_api(data)
16
19
  elsif data['imdisplayname'] || data['messagetype'] then from_ng_msg_api(data)
@@ -27,7 +30,7 @@ module Teems
27
30
  {
28
31
  id: data['id'], sender_id: data['from'],
29
32
  sender_name: data['imdisplayname'] || data['fromDisplayNameInToken'] || 'Unknown',
30
- content: strip_html(data['content'] || ''),
33
+ **html_body_attrs(data['content']),
31
34
  created_at: parse_time(data['composetime'] || data['originalarrivaltime']),
32
35
  message_type: data['messagetype'],
33
36
  **ng_msg_extras(data, props)
@@ -57,7 +60,7 @@ module Teems
57
60
  new(
58
61
  id: data['id'], sender_id: msg['from'],
59
62
  sender_name: msg['imDisplayName'] || msg['fromDisplayNameInToken'] || 'Unknown',
60
- content: strip_html(msg['content'] || ''),
63
+ **html_body_attrs(msg['content']),
61
64
  created_at: parse_time(msg['composeTime'] || data['latestMessageTime']),
62
65
  message_type: msg['type'], reply_to_id: nil, reactions: [],
63
66
  attachments: parse_files_json(msg.dig('properties', 'files')),
@@ -74,7 +77,7 @@ module Teems
74
77
  id: data['id'],
75
78
  sender_id: data.dig('from', 'user', 'id') || data.dig('from', 'application', 'id'),
76
79
  sender_name: extract_sender_name(data),
77
- content: strip_html(data.dig('body', 'content') || ''),
80
+ **html_body_attrs(data.dig('body', 'content')),
78
81
  created_at: parse_time(data['createdDateTime']),
79
82
  **graph_extras(data)
80
83
  }
@@ -13,6 +13,11 @@ module Teems
13
13
  CGI.unescapeHTML(html.gsub(/<[^>]+>/, ' ')).gsub('&nbsp;', ' ').gsub(/\s+/, ' ').strip
14
14
  end
15
15
 
16
+ # Plain-text content plus the inline images that strip_html would discard
17
+ def html_body_attrs(html)
18
+ { content: strip_html(html || ''), images: InlineImage.parse_html(html) }
19
+ end
20
+
16
21
  def parse_time(time_str)
17
22
  return nil unless time_str
18
23
 
data/lib/teems/runner.rb CHANGED
@@ -72,6 +72,11 @@ module Teems
72
72
  Services::TokenRefresher.new(token_store: @token_store, output: @output)
73
73
  end
74
74
 
75
+ # Downloader for inline message images; reads the current skype token on each request
76
+ def inline_image_downloader
77
+ Services::InlineImageDownloader.new(token_provider: -> { account.skype_token })
78
+ end
79
+
75
80
  # Attempt to refresh the skype_token
76
81
  def refresh_tokens
77
82
  token_refresher.refresh
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Error responses from AMS, mapped so the caller can fall back or refresh tokens
6
+ module InlineImageErrors
7
+ # The requested AMS view does not exist for this object (try another view)
8
+ class ViewUnavailable < ApiError; end
9
+
10
+ MISSING_VIEW_CODES = %w[400 404 410].freeze
11
+
12
+ private
13
+
14
+ def raise_failure(response)
15
+ code = response.code
16
+ status = code.to_i
17
+ raise ApiError.new('Invalid token or session expired', status_code: 401) if status == 401
18
+ raise ViewUnavailable.new("HTTP #{code}", status_code: status) if MISSING_VIEW_CODES.include?(code)
19
+
20
+ raise ApiError.new("HTTP #{code}", status_code: status)
21
+ end
22
+
23
+ def untrusted_host!(uri)
24
+ raise Error, "Refusing to send Teams credentials to untrusted image host: #{uri.host}"
25
+ end
26
+ end
27
+
28
+ # Identifies image bytes by their magic number
29
+ module ImageSignature
30
+ SIGNATURES = { 'png' => "\x89PNG".b, 'jpg' => "\xFF\xD8\xFF".b, 'gif' => 'GIF8'.b }.freeze
31
+
32
+ module_function
33
+
34
+ def extension_for(body)
35
+ bytes = body.to_s.b
36
+ found = SIGNATURES.find { |_ext, magic| bytes.start_with?(magic) }&.first
37
+ found || ('webp' if bytes.start_with?('RIFF') && bytes.byteslice(8, 4) == 'WEBP')
38
+ end
39
+ end
40
+
41
+ # Fetches inline message images from Teams' AMS object store. AMS accepts the
42
+ # skype token as `Authorization: skype_token <token>` (Bearer tokens get a 401).
43
+ # Credentials are only sent over HTTPS to Microsoft Teams/Skype hosts.
44
+ class InlineImageDownloader
45
+ include InlineImageErrors
46
+
47
+ # Downloaded image bytes, the file extension they imply, and the URL that served them
48
+ Result = Data.define(:body, :extension, :url)
49
+
50
+ TRUSTED_HOST = /(?:\A|\.)(?:asm\.skype\.com|teams\.microsoft\.com|teams\.microsoft\.us)\z/i
51
+ MAX_REDIRECTS = 3
52
+ TIMEOUTS = { open_timeout: 10, read_timeout: 60 }.freeze
53
+
54
+ # token_provider is called per request so a refreshed skype token is picked up
55
+ def initialize(token_provider:, http_client: nil)
56
+ @token_provider = token_provider
57
+ @http_client = http_client
58
+ end
59
+
60
+ # Prefers the original upload and falls back to the `src` preview when that view is missing
61
+ def fetch(image)
62
+ full_size = image.full_size_url
63
+ preview = image.url
64
+ fetch_url(full_size)
65
+ rescue ViewUnavailable
66
+ raise if full_size == preview
67
+
68
+ fetch_url(preview)
69
+ end
70
+
71
+ def self.trusted?(uri) = uri.is_a?(URI::HTTPS) && TRUSTED_HOST.match?(uri.host.to_s)
72
+
73
+ private
74
+
75
+ def fetch_url(url)
76
+ uri = URI(url)
77
+ untrusted_host!(uri) unless self.class.trusted?(uri)
78
+
79
+ follow(uri, MAX_REDIRECTS)
80
+ end
81
+
82
+ def follow(uri, redirects_left)
83
+ response = http_get(uri)
84
+ case response
85
+ when Net::HTTPSuccess then build_result(response.body, uri)
86
+ when Net::HTTPRedirection then follow_redirect(uri, response['location'], redirects_left)
87
+ else raise_failure(response)
88
+ end
89
+ end
90
+
91
+ def follow_redirect(uri, location, redirects_left)
92
+ raise Error, 'Too many redirects' unless redirects_left.positive? && location
93
+
94
+ follow(uri + location, redirects_left - 1)
95
+ end
96
+
97
+ def build_result(body, uri)
98
+ extension = ImageSignature.extension_for(body)
99
+ raise Error, 'Response was not an image' unless extension
100
+
101
+ Result.new(body: body.to_s, extension: extension, url: uri.to_s)
102
+ end
103
+
104
+ # Redirect targets outside Teams/Skype (e.g. a signed CDN URL) get no credentials
105
+ def http_get(uri)
106
+ headers = self.class.trusted?(uri) ? { 'Authorization' => "skype_token #{@token_provider.call}" } : {}
107
+ return @http_client.call(uri, headers) if @http_client
108
+
109
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true, **TIMEOUTS) do |http|
110
+ http.request(Net::HTTP::Get.new(uri, headers))
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -23,7 +23,8 @@ module Teems
23
23
  { 'type' => reaction[:type], 'count' => reaction[:count] }
24
24
  end,
25
25
  'attachments' => message.attachments, 'importance' => message.importance,
26
- 'edited' => message.edited, 'mentions' => message.mentions
26
+ 'edited' => message.edited, 'mentions' => message.mentions,
27
+ 'images' => message.images.map(&:as_json)
27
28
  }
28
29
  end
29
30
 
@@ -49,7 +50,8 @@ module Teems
49
50
  attachments: stored_default(data, 'attachments', []),
50
51
  importance: data['importance'],
51
52
  edited: stored_default(data, 'edited', false),
52
- mentions: stored_default(data, 'mentions', []) }
53
+ mentions: stored_default(data, 'mentions', []),
54
+ images: Array(data['images']).filter_map { |image| Models::InlineImage.from_h(image) } }
53
55
  end
54
56
 
55
57
  def stored_default(data, key, fallback) = data[key] || fallback
@@ -140,10 +142,11 @@ module Teems
140
142
  filter_and_sort_messages(messages, start_time)
141
143
  end
142
144
 
143
- def merge_and_write(chat, existing_raw, new_messages)
145
+ # Yields (chat_dir, merged_messages) before writing, e.g. to save inline images first
146
+ def merge_and_write(chat, existing_raw, new_messages, &)
144
147
  existing = existing_raw.map { |data| message_from_stored(data) }
145
148
  all_messages = merge_messages(existing, new_messages)
146
- write_chat_files(chat, all_messages)
149
+ write_chat_files(chat, all_messages, &)
147
150
  all_messages
148
151
  end
149
152
 
@@ -172,10 +175,13 @@ module Teems
172
175
  def index_by_id(messages) = messages.to_h { |msg| [msg.id, msg] }
173
176
 
174
177
  def write_chat_files(chat, messages)
175
- fmt = Formatters::MarkdownFormatter.new(chat_name: chat.display_name,
176
- chat_type: chat.chat_type, synced_at: Time.now)
178
+ chat_id = chat.id
179
+ chat_dir = @sync_store.chat_dir(chat_id, state: @state)
180
+ yield(chat_dir, messages) if block_given?
181
+ fmt = Formatters::MarkdownFormatter.new(chat_name: chat.display_name, chat_type: chat.chat_type,
182
+ synced_at: Time.now, image_link: SyncImages.link_resolver(chat_dir))
177
183
  json = JSON.pretty_generate(messages.map { |msg| message_to_hash(msg) })
178
- @sync_store.write_messages(chat.id, messages_md: fmt.format(messages), messages_json: json, state: @state)
184
+ @sync_store.write_messages(chat_id, messages_md: fmt.format(messages), messages_json: json, state: @state)
179
185
  write_metadata(chat)
180
186
  end
181
187
 
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Inline images saved beside a synced chat (`teems sync --images`) and linked from messages.md.
6
+ # Files are named by AMS object id, so images already on disk are never fetched again.
7
+ class SyncImages
8
+ DIR = 'images'
9
+
10
+ # { file stem => file name } for the images already saved for a chat
11
+ def self.saved(chat_dir)
12
+ dir = File.join(chat_dir, DIR)
13
+ return {} unless File.directory?(dir)
14
+
15
+ Dir.children(dir).reject { |name| name.end_with?('.tmp') }.to_h { |name| [File.basename(name, '.*'), name] }
16
+ end
17
+
18
+ # Callable for MarkdownFormatter: relative path of a saved image, or nil
19
+ def self.link_resolver(chat_dir)
20
+ saved = saved(chat_dir)
21
+ lambda do |image|
22
+ name = saved[image.file_stem]
23
+ "#{DIR}/#{name}" if name
24
+ end
25
+ end
26
+
27
+ def initialize(downloader:, output: nil)
28
+ @downloader = downloader
29
+ @output = output
30
+ end
31
+
32
+ # Downloads images not yet on disk; returns how many were saved
33
+ def save(chat_dir, messages)
34
+ existing = self.class.saved(chat_dir)
35
+ pending = messages.flat_map(&:images).uniq(&:file_stem).reject { |image| existing.key?(image.file_stem) }
36
+ pending.sum { |image| save_image(File.join(chat_dir, DIR), image) }
37
+ end
38
+
39
+ private
40
+
41
+ def save_image(dir, image)
42
+ stem = image.file_stem
43
+ result = @downloader.fetch(image)
44
+ FileUtils.mkdir_p(dir)
45
+ path = File.join(dir, "#{stem}.#{result.extension}")
46
+ File.binwrite("#{path}.tmp", result.body)
47
+ File.rename("#{path}.tmp", path)
48
+ 1
49
+ rescue StandardError => e
50
+ @output&.warn(" Could not download image #{stem}: #{e.message}")
51
+ 0
52
+ end
53
+ end
54
+ end
55
+ end
@@ -17,6 +17,8 @@ module Teems
17
17
  module Subprocess
18
18
  module_function
19
19
 
20
+ # Named after Open3.capture3, which it wraps with the same signature and return value
21
+ # :reek:UncommunicativeMethodName
20
22
  def capture3(*command)
21
23
  previous = Thread.report_on_exception
22
24
  Thread.report_on_exception = false
data/lib/teems/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teems
4
- VERSION = '0.3.1'
4
+ VERSION = '0.3.3'
5
5
  end
data/lib/teems.rb CHANGED
@@ -49,6 +49,8 @@ module Teems
49
49
  autoload :Chat, 'teems/models/chat'
50
50
  autoload :Event, 'teems/models/event'
51
51
  autoload :Message, 'teems/models/message'
52
+ autoload :InlineImage, 'teems/models/inline_image'
53
+ autoload :InlineImageParsing, 'teems/models/inline_image'
52
54
  autoload :Parsing, 'teems/models/parsing'
53
55
  autoload :User, 'teems/models/user'
54
56
  autoload :UserProfile, 'teems/models/user_profile'
@@ -64,11 +66,13 @@ module Teems
64
66
  autoload :TokenRefresher, 'teems/services/token_refresher'
65
67
  autoload :CacheStore, 'teems/services/cache_store'
66
68
  autoload :FileDownloader, 'teems/services/file_downloader'
69
+ autoload :InlineImageDownloader, 'teems/services/inline_image_downloader'
67
70
  autoload :TeamsUrlParser, 'teems/services/teams_url_parser'
68
71
  autoload :SyncStore, 'teems/services/sync_store'
69
72
  autoload :SyncDirNaming, 'teems/services/sync_dir_naming'
70
73
  autoload :SafariJsRunner, 'teems/services/safari_js_runner'
71
74
  autoload :SyncEngine, 'teems/services/sync_engine'
75
+ autoload :SyncImages, 'teems/services/sync_images'
72
76
  end
73
77
 
74
78
  # Output formatters for messages and terminal output
@@ -78,6 +82,7 @@ module Teems
78
82
  autoload :MessageFormatter, 'teems/formatters/message_formatter'
79
83
  autoload :MarkdownFormatter, 'teems/formatters/markdown_formatter'
80
84
  autoload :CalendarFormatter, 'teems/formatters/calendar_formatter'
85
+ autoload :TranscriptMarkdown, 'teems/formatters/transcript_markdown'
81
86
  end
82
87
 
83
88
  # CLI commands implementing user-facing functionality
@@ -95,6 +100,7 @@ module Teems
95
100
  autoload :Ooo, 'teems/commands/ooo'
96
101
  autoload :Org, 'teems/commands/org'
97
102
  autoload :Meeting, 'teems/commands/meeting'
103
+ autoload :Transcripts, 'teems/commands/transcripts'
98
104
  autoload :Status, 'teems/commands/status'
99
105
  end
100
106
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: teems
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Eric Boehs
@@ -49,17 +49,20 @@ files:
49
49
  - lib/teems/commands/org.rb
50
50
  - lib/teems/commands/status.rb
51
51
  - lib/teems/commands/sync.rb
52
+ - lib/teems/commands/transcripts.rb
52
53
  - lib/teems/commands/who.rb
53
54
  - lib/teems/formatters/calendar_formatter.rb
54
55
  - lib/teems/formatters/format_utils.rb
55
56
  - lib/teems/formatters/markdown_formatter.rb
56
57
  - lib/teems/formatters/message_formatter.rb
57
58
  - lib/teems/formatters/output.rb
59
+ - lib/teems/formatters/transcript_markdown.rb
58
60
  - lib/teems/models/account.rb
59
61
  - lib/teems/models/channel.rb
60
62
  - lib/teems/models/chat.rb
61
63
  - lib/teems/models/duration.rb
62
64
  - lib/teems/models/event.rb
65
+ - lib/teems/models/inline_image.rb
63
66
  - lib/teems/models/message.rb
64
67
  - lib/teems/models/parsing.rb
65
68
  - lib/teems/models/user.rb
@@ -70,10 +73,12 @@ files:
70
73
  - lib/teems/services/configuration.rb
71
74
  - lib/teems/services/file_downloader.rb
72
75
  - lib/teems/services/headless_extract.rb
76
+ - lib/teems/services/inline_image_downloader.rb
73
77
  - lib/teems/services/safari_js_runner.rb
74
78
  - lib/teems/services/safari_oauth.rb
75
79
  - lib/teems/services/sync_dir_naming.rb
76
80
  - lib/teems/services/sync_engine.rb
81
+ - lib/teems/services/sync_images.rb
77
82
  - lib/teems/services/sync_store.rb
78
83
  - lib/teems/services/teams_url_parser.rb
79
84
  - lib/teems/services/token_exchange_scripts.rb