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,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Represents a message in Teams
6
+ # Handles both Graph API and Teams internal API response formats
7
+ Message = Data.define(
8
+ :id, :sender_id, :sender_name, :content, :created_at,
9
+ :message_type, :reply_to_id, :reactions, :attachments, :importance,
10
+ :edited, :mentions
11
+ ) do
12
+ extend Parsing
13
+
14
+ def self.from_api(data)
15
+ if data['message'] then from_teams_internal_api(data)
16
+ elsif data['imdisplayname'] || data['messagetype'] then from_ng_msg_api(data)
17
+ else from_graph_api(data)
18
+ end
19
+ end
20
+
21
+ def self.from_ng_msg_api(data)
22
+ new(**ng_msg_attrs(data))
23
+ end
24
+
25
+ def self.ng_msg_attrs(data)
26
+ props = data['properties'] || {}
27
+ {
28
+ id: data['id'], sender_id: data['from'],
29
+ sender_name: data['imdisplayname'] || data['fromDisplayNameInToken'] || 'Unknown',
30
+ content: strip_html(data['content'] || ''),
31
+ created_at: parse_time(data['composetime'] || data['originalarrivaltime']),
32
+ message_type: data['messagetype'],
33
+ **ng_msg_extras(data, props)
34
+ }
35
+ end
36
+
37
+ def self.ng_msg_extras(data, props)
38
+ root_message_id = data['rootMessageId']
39
+ {
40
+ reply_to_id: root_message_id == data['id'] ? nil : root_message_id,
41
+ reactions: parse_ng_msg_reactions(props['emotions']),
42
+ attachments: parse_files_json(props['files']),
43
+ importance: props['importance'],
44
+ edited: props.key?('edittime'),
45
+ mentions: parse_mentions(props['mentions'])
46
+ }
47
+ end
48
+
49
+ def self.parse_ng_msg_reactions(emotions)
50
+ return [] unless emotions.is_a?(Array)
51
+
52
+ emotions.map { |emotion| { type: emotion['key'], count: emotion['users']&.length || 1 } }
53
+ end
54
+
55
+ def self.from_teams_internal_api(data)
56
+ msg = data['message']
57
+ new(
58
+ id: data['id'], sender_id: msg['from'],
59
+ sender_name: msg['imDisplayName'] || msg['fromDisplayNameInToken'] || 'Unknown',
60
+ content: strip_html(msg['content'] || ''),
61
+ created_at: parse_time(msg['composeTime'] || data['latestMessageTime']),
62
+ message_type: msg['type'], reply_to_id: nil, reactions: [],
63
+ attachments: parse_files_json(msg.dig('properties', 'files')),
64
+ importance: nil, edited: false, mentions: []
65
+ )
66
+ end
67
+
68
+ def self.from_graph_api(data)
69
+ new(**graph_attrs(data))
70
+ end
71
+
72
+ def self.graph_attrs(data)
73
+ {
74
+ id: data['id'],
75
+ sender_id: data.dig('from', 'user', 'id') || data.dig('from', 'application', 'id'),
76
+ sender_name: extract_sender_name(data),
77
+ content: strip_html(data.dig('body', 'content') || ''),
78
+ created_at: parse_time(data['createdDateTime']),
79
+ **graph_extras(data)
80
+ }
81
+ end
82
+
83
+ def self.graph_extras(data)
84
+ {
85
+ message_type: data['messageType'], reply_to_id: data['replyToId'],
86
+ reactions: parse_reactions(data['reactions']),
87
+ attachments: data['attachments'] || [],
88
+ importance: data['importance'],
89
+ edited: false, mentions: []
90
+ }
91
+ end
92
+
93
+ def self.extract_sender_name(data)
94
+ data.dig('from', 'user', 'displayName') || data.dig('from', 'application', 'displayName') || 'Unknown'
95
+ end
96
+
97
+ def self.parse_reactions(reactions_data)
98
+ return [] unless reactions_data.is_a?(Array)
99
+
100
+ reactions_data.map { |reaction| { type: reaction['reactionType'], count: reaction['user']&.length || 1 } }
101
+ end
102
+
103
+ def downloadable_attachments = attachments.grep(Hash).select { |att| att.key?('sharepointIds') }
104
+
105
+ def content_with_mentions_highlighted
106
+ return content if mentions.empty?
107
+
108
+ mentions.inject(content) { |text, name| text.gsub(name, yield(name)) }
109
+ end
110
+
111
+ def short_hash = Digest::SHA256.hexdigest(id.to_s)[0, 6]
112
+ def timestamp = created_at
113
+ def reply? = !!reply_to_id
114
+ def edited? = !!edited
115
+ def important? = %w[urgent high].include?(importance)
116
+ def system_message? = message_type && !normal_message_type? && !rich_text_type?
117
+ def to_s = "[#{created_at&.strftime('%H:%M')}] #{sender_name}: #{content}"
118
+
119
+ private
120
+
121
+ def normal_message_type? = %w[Message message Text].include?(message_type)
122
+ def rich_text_type? = message_type.start_with?('RichText')
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Shared parsing helpers for API response data
6
+ module Parsing
7
+ module_function
8
+
9
+ def strip_html(html)
10
+ return nil unless html
11
+
12
+ require 'cgi'
13
+ CGI.unescapeHTML(html.gsub(/<[^>]+>/, ' ')).gsub('&nbsp;', ' ').gsub(/\s+/, ' ').strip
14
+ end
15
+
16
+ def parse_time(time_str)
17
+ return nil unless time_str
18
+
19
+ Time.parse(time_str)
20
+ rescue ArgumentError
21
+ nil
22
+ end
23
+
24
+ def parse_files_json(files_json)
25
+ return [] unless files_json
26
+
27
+ JSON.parse(files_json)
28
+ rescue JSON::ParserError
29
+ []
30
+ end
31
+
32
+ def parse_mentions(mentions_json)
33
+ raw = normalize_mentions(mentions_json)
34
+ return [] unless raw
35
+
36
+ raw.group_by { |mention| mention['mri'] }
37
+ .except(nil)
38
+ .each_value.filter_map { |entries| mention_display_name(entries) }
39
+ end
40
+
41
+ def normalize_mentions(data)
42
+ return nil unless data
43
+
44
+ parsed = data.is_a?(String) ? JSON.parse(data) : data
45
+ parsed if parsed.is_a?(Array)
46
+ rescue JSON::ParserError
47
+ nil
48
+ end
49
+
50
+ def mention_display_name(entries)
51
+ name = entries.filter_map { |entry| entry['displayName'] }.join(' ')
52
+ name unless name.empty?
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Represents a user in Teams
6
+ User = Data.define(:id, :display_name, :email, :user_principal_name) do
7
+ def self.from_api(data)
8
+ new(
9
+ id: data['id'],
10
+ display_name: data['displayName'],
11
+ email: data['mail'] || data['email'],
12
+ user_principal_name: data['userPrincipalName']
13
+ )
14
+ end
15
+
16
+ def best_name
17
+ [display_name, email, user_principal_name, id].find { |value| value && !value.empty? }
18
+ end
19
+
20
+ def to_s
21
+ best_name
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Models
5
+ # Rich user profile with extended fields for who/org commands
6
+ UserProfile = Data.define(
7
+ :id,
8
+ :display_name,
9
+ :email,
10
+ :user_principal_name,
11
+ :job_title,
12
+ :department,
13
+ :office_location,
14
+ :business_phones,
15
+ :mobile_phone
16
+ ) do
17
+ def self.from_api(data)
18
+ new(**identity_attrs(data), **detail_attrs(data))
19
+ end
20
+
21
+ def self.identity_attrs(data)
22
+ { id: data['id'], display_name: data['displayName'],
23
+ email: data['mail'] || data['email'], user_principal_name: data['userPrincipalName'] }
24
+ end
25
+
26
+ def self.detail_attrs(data)
27
+ { job_title: data['jobTitle'], department: data['department'],
28
+ office_location: data['officeLocation'],
29
+ business_phones: data['businessPhones'] || [], mobile_phone: data['mobilePhone'] }
30
+ end
31
+
32
+ def best_name
33
+ [display_name, email, user_principal_name, id].find { |value| value && !value.empty? }
34
+ end
35
+
36
+ def json_attrs
37
+ [to_h, id]
38
+ end
39
+
40
+ def search_display
41
+ [best_name, job_title, email]
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ # API factory methods for Runner, extracted to keep method count manageable
5
+ module ApiFactories
6
+ def channels_api = Api::Channels.new(api_client, account)
7
+ def chats_api = Api::Chats.new(api_client, account)
8
+ def messages_api = Api::Messages.new(api_client, account)
9
+ def calendar_api = Api::Calendar.new(api_client, account)
10
+ def users_api = Api::Users.new(api_client, account)
11
+ def files_api = Api::Files.new(api_client, account)
12
+ end
13
+
14
+ # Dependency injection container providing services to commands
15
+ class Runner
16
+ include ApiFactories
17
+
18
+ attr_reader :output, :config, :token_store
19
+
20
+ def initialize(
21
+ output: Formatters::Output.new,
22
+ config: Services::Configuration.new,
23
+ token_store: Services::TokenStore.new,
24
+ api_client: nil,
25
+ cache_store: Services::CacheStore.new
26
+ )
27
+ api_client ||= Services::ApiClient.new(endpoints: config['endpoints'] || {})
28
+ @output = output
29
+ @config = config
30
+ @token_store = token_store
31
+ @services = { api_client: api_client, cache_store: cache_store }
32
+
33
+ wire_up_warnings
34
+ end
35
+
36
+ def api_client = @services[:api_client]
37
+ def cache_store = @services[:cache_store]
38
+
39
+ # Account helpers - always get fresh from token_store (important for token refresh)
40
+ def account
41
+ @token_store.account or raise ConfigError, 'No account configured. Run: teems auth login'
42
+ end
43
+
44
+ def configured?
45
+ @token_store.configured?
46
+ end
47
+
48
+ # Clear any cached API instances after token refresh
49
+ def clear_api_cache
50
+ # API instances are not cached, but account is fetched fresh each time
51
+ # This method exists for future extensibility
52
+ end
53
+
54
+ # Formatter helpers
55
+ def message_formatter
56
+ Formatters::MessageFormatter.new(output: @output, cache_store: cache_store)
57
+ end
58
+
59
+ # Token extractor for Safari automation
60
+ def token_extractor(auth_mode: :default)
61
+ Services::TokenExtractor.new(output: @output, auth_mode: auth_mode)
62
+ end
63
+
64
+ # Token refresher for automatic token refresh
65
+ def token_refresher
66
+ Services::TokenRefresher.new(token_store: @token_store, output: @output)
67
+ end
68
+
69
+ # Attempt to refresh the skype_token
70
+ def refresh_tokens
71
+ token_refresher.refresh
72
+ end
73
+
74
+ private
75
+
76
+ def wire_up_warnings
77
+ warning_handler = ->(message) { @output.warn(message) }
78
+ @config.register_warning_handler(warning_handler)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,217 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # HTTP connection pool management for API endpoints
6
+ module ConnectionPool
7
+ DEFAULT_ENDPOINTS = {
8
+ graph: 'https://graph.microsoft.com',
9
+ teams: 'https://teams.microsoft.com',
10
+ msgservice: 'https://amer.ng.msg.teams.microsoft.com',
11
+ presence: 'https://presence.teams.microsoft.com'
12
+ }.freeze
13
+
14
+ TIMEOUTS = { open_timeout: 10, read_timeout: 30, keep_alive_timeout: 30 }.freeze
15
+
16
+ private
17
+
18
+ def get_http_for_endpoint(endpoint_key)
19
+ uri = URI(resolve_endpoint(endpoint_key))
20
+ cache_key = "#{uri.host}:#{uri.port}"
21
+ (http = @http_cache[cache_key])&.started? ? http : @http_cache[cache_key] = start_http(uri)
22
+ end
23
+
24
+ def start_http(uri)
25
+ build_http(uri.host, uri.port, ssl_scheme?(uri)).tap(&:start)
26
+ end
27
+
28
+ def ssl_scheme?(uri) = uri.scheme == 'https'
29
+
30
+ def build_http(host, port, use_ssl)
31
+ http = Net::HTTP.new(host, port)
32
+ apply_timeouts(http)
33
+ http.use_ssl = use_ssl
34
+ configure_ssl(http) if use_ssl
35
+ http
36
+ end
37
+
38
+ def apply_timeouts(http)
39
+ TIMEOUTS.each { |attr, val| http.send(:"#{attr}=", val) }
40
+ end
41
+
42
+ def configure_ssl(http)
43
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
44
+ http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
45
+ end
46
+ end
47
+
48
+ # POST/PATCH request methods, extracted to manage class size
49
+ module WriteRequests
50
+ def post(endpoint_key, path, **options)
51
+ account = options.delete(:account)
52
+ body = options.delete(:body)
53
+ req = Net::HTTP::Post.new(URI("#{resolve_endpoint(endpoint_key)}#{path}"))
54
+ apply_auth(req, account, endpoint_key)
55
+ req.body = JSON.generate(body) if body
56
+ run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
57
+ end
58
+
59
+ def patch(endpoint_key, path, **options)
60
+ account = options.delete(:account)
61
+ body = options.delete(:body)
62
+ req = Net::HTTP::Patch.new(URI("#{resolve_endpoint(endpoint_key)}#{path}"))
63
+ apply_auth(req, account, endpoint_key)
64
+ req.body = JSON.generate(body) if body
65
+ run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
66
+ end
67
+ end
68
+
69
+ # HTTP response handling for API client
70
+ module ResponseHandler
71
+ private
72
+
73
+ def handle_response(response)
74
+ return parse_json_body(response) if response.is_a?(Net::HTTPSuccess)
75
+
76
+ raise_http_error(response)
77
+ end
78
+
79
+ def raise_http_error(response)
80
+ case response
81
+ when Net::HTTPUnauthorized then raise ApiError.new('Invalid token or session expired', status_code: 401)
82
+ when Net::HTTPForbidden then raise ApiError.new('Access forbidden', status_code: 403)
83
+ when Net::HTTPTooManyRequests then raise_rate_limit(response)
84
+ else
85
+ code = response.code
86
+ raise ApiError.new("HTTP #{code}: #{response.message}", status_code: code.to_i)
87
+ end
88
+ end
89
+
90
+ def raise_rate_limit(response)
91
+ retry_after = response['Retry-After']
92
+ suffix = retry_after ? "retry after #{retry_after} seconds" : 'please wait and try again'
93
+ raise ApiError.new("Rate limited - #{suffix}", status_code: 429)
94
+ end
95
+
96
+ def parse_json_body(response)
97
+ body = response.body
98
+ return {} if body.to_s.empty?
99
+
100
+ JSON.parse(body)
101
+ rescue JSON::ParserError
102
+ raise ApiError, 'Invalid JSON response from Teams API'
103
+ end
104
+ end
105
+
106
+ # HTTP client for Teams API with connection pooling and multi-endpoint support
107
+ class ApiClient
108
+ include ConnectionPool
109
+ include ResponseHandler
110
+ include WriteRequests
111
+
112
+ NETWORK_ERRORS = [
113
+ SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
114
+ Errno::EHOSTUNREACH, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError
115
+ ].freeze
116
+
117
+ AUTH_HEADERS = {
118
+ msgservice: ->(account) { ['Authentication', account.skype_auth_header] },
119
+ teams: ->(account) { ['Authorization', "Bearer #{account.skype_token}"] },
120
+ presence: ->(account) { ['Authorization', "Bearer #{account.presence_token}"] }
121
+ }.freeze
122
+ DEFAULT_AUTH_HEADER = ->(account) { ['Authorization', account.teams_auth_header] }
123
+
124
+ attr_reader :call_count
125
+
126
+ def initialize(on_request: nil, on_response: nil, endpoints: {})
127
+ @call_count = 0
128
+ @callbacks = { on_request: on_request, on_response: on_response }
129
+ @http_cache = {}
130
+ @endpoints = DEFAULT_ENDPOINTS.merge(endpoints.transform_keys(&:to_sym))
131
+ end
132
+
133
+ def on_request = @callbacks[:on_request]
134
+ def on_response = @callbacks[:on_response]
135
+
136
+ def on_request=(callback)
137
+ close_idle_connections
138
+ @callbacks[:on_request] = callback
139
+ end
140
+
141
+ def on_response=(callback)
142
+ close_idle_connections
143
+ @callbacks[:on_response] = callback
144
+ end
145
+
146
+ def close
147
+ @http_cache.each_value do |http|
148
+ http.finish if http.started?
149
+ rescue IOError
150
+ nil
151
+ end
152
+ @http_cache.clear
153
+ end
154
+
155
+ def get(endpoint_key, path, **options)
156
+ account = options.delete(:account)
157
+ base_url = resolve_endpoint(endpoint_key)
158
+ uri = build_request_uri(base_url, path, options.fetch(:params, {}))
159
+ req = Net::HTTP::Get.new(uri)
160
+ apply_headers(req, options.fetch(:headers, {}))
161
+ apply_auth(req, account, endpoint_key)
162
+ run_request(path, get_http_for_endpoint(endpoint_key)) { |http| http.request(req) }
163
+ end
164
+
165
+ def delete(url, endpoint_key:, account:)
166
+ uri = URI(url)
167
+ run_request(uri.path, get_http_for_endpoint(endpoint_key)) do |http|
168
+ req = Net::HTTP::Delete.new(uri)
169
+ apply_auth(req, account, endpoint_key)
170
+ http.request(req)
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def close_idle_connections
177
+ @http_cache.select! { |_key, http| http.started? }
178
+ end
179
+
180
+ def apply_headers(request, headers)
181
+ headers.each { |key, value| request[key] = value }
182
+ end
183
+
184
+ def build_request_uri(base_url, path, params)
185
+ URI(path.start_with?('http') ? path : "#{base_url}#{path}").tap do |uri|
186
+ uri.query = URI.encode_www_form(params) if params&.any?
187
+ end
188
+ end
189
+
190
+ def resolve_endpoint(key) = @endpoints[key] || raise(ArgumentError, "Unknown endpoint: #{key}")
191
+
192
+ def apply_auth(request, account, endpoint_key)
193
+ request['Content-Type'] = 'application/json'
194
+ header, value = AUTH_HEADERS.fetch(endpoint_key, DEFAULT_AUTH_HEADER).call(account)
195
+ request[header] = value
196
+ end
197
+
198
+ def run_request(path, http)
199
+ track_request(path)
200
+ response = yield(http)
201
+ process_response(path, response)
202
+ rescue *NETWORK_ERRORS => e
203
+ raise ApiError, "Network error: #{e.message}"
204
+ end
205
+
206
+ def track_request(path)
207
+ @call_count += 1
208
+ @callbacks[:on_request]&.call(path, @call_count)
209
+ end
210
+
211
+ def process_response(path, response)
212
+ @callbacks[:on_response]&.call(path, response.code)
213
+ handle_response(response)
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Manages caching of user and channel data
6
+ class CacheStore
7
+ def initialize(paths: Support::XdgPaths.new)
8
+ @paths = paths
9
+ @user_cache = {}
10
+ end
11
+
12
+ def get_user(user_id)
13
+ @user_cache[user_id]
14
+ end
15
+
16
+ def set_user(user_id, display_name)
17
+ @user_cache[user_id] = display_name
18
+ end
19
+
20
+ def clear
21
+ @user_cache.clear
22
+ FileUtils.rm_f(users_cache_file)
23
+ end
24
+
25
+ private
26
+
27
+ def users_cache_file
28
+ @paths.cache_file('users.json')
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Manages CLI configuration stored in XDG config directory
6
+ class Configuration
7
+ def initialize(paths: Support::XdgPaths.new)
8
+ @paths = paths
9
+ @on_warning = nil
10
+ @data = nil
11
+ end
12
+
13
+ def register_warning_handler(handler)
14
+ @on_warning = handler
15
+ end
16
+
17
+ def [](key)
18
+ data[key]
19
+ end
20
+
21
+ def []=(key, value)
22
+ data[key] = value
23
+ save_config
24
+ end
25
+
26
+ def to_h
27
+ data.dup
28
+ end
29
+
30
+ private
31
+
32
+ def data
33
+ @data ||= load_config
34
+ end
35
+
36
+ def config_file
37
+ @paths.config_file('config.json')
38
+ end
39
+
40
+ def load_config
41
+ return {} unless File.exist?(config_file)
42
+
43
+ JSON.parse(File.read(config_file))
44
+ rescue JSON::ParserError => e
45
+ @on_warning&.call("Config file #{config_file} is corrupted (#{e.message}). Using defaults.")
46
+ {}
47
+ end
48
+
49
+ def save_config
50
+ @paths.ensure_config_dir
51
+ File.write(config_file, JSON.pretty_generate(data))
52
+ File.chmod(0o600, config_file)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Downloads files from pre-authenticated URLs with redirect following
6
+ class FileDownloader
7
+ MAX_REDIRECTS = 5
8
+
9
+ def initialize(http_client: nil)
10
+ @http_client = http_client
11
+ end
12
+
13
+ def download(url, output_path)
14
+ response = follow_redirects(URI(url))
15
+ File.binwrite(output_path, response.body)
16
+ end
17
+
18
+ private
19
+
20
+ def follow_redirects(uri, limit = MAX_REDIRECTS)
21
+ raise Teems::Error, 'Too many redirects' if limit.zero?
22
+
23
+ handle_response(http_get(uri), limit)
24
+ end
25
+
26
+ def http_get(uri)
27
+ @http_client ? @http_client.call(uri) : Net::HTTP.get_response(uri)
28
+ end
29
+
30
+ def handle_response(response, limit)
31
+ case response
32
+ when Net::HTTPSuccess then response
33
+ when Net::HTTPRedirection then follow_redirects(URI(response['location']), limit - 1)
34
+ else raise Teems::Error, "Download failed: HTTP #{response.code}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end