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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +136 -0
- data/bin/teems +7 -0
- data/lib/teems/api/calendar.rb +94 -0
- data/lib/teems/api/channels.rb +26 -0
- data/lib/teems/api/chats.rb +29 -0
- data/lib/teems/api/client.rb +40 -0
- data/lib/teems/api/files.rb +12 -0
- data/lib/teems/api/messages.rb +58 -0
- data/lib/teems/api/users.rb +88 -0
- data/lib/teems/api/users_mailbox.rb +16 -0
- data/lib/teems/api/users_presence.rb +43 -0
- data/lib/teems/cli.rb +133 -0
- data/lib/teems/commands/activity.rb +222 -0
- data/lib/teems/commands/auth.rb +268 -0
- data/lib/teems/commands/base.rb +146 -0
- data/lib/teems/commands/cal.rb +891 -0
- data/lib/teems/commands/channels.rb +115 -0
- data/lib/teems/commands/chats.rb +159 -0
- data/lib/teems/commands/help.rb +107 -0
- data/lib/teems/commands/messages.rb +281 -0
- data/lib/teems/commands/ooo.rb +385 -0
- data/lib/teems/commands/org.rb +232 -0
- data/lib/teems/commands/status.rb +224 -0
- data/lib/teems/commands/sync.rb +390 -0
- data/lib/teems/commands/who.rb +377 -0
- data/lib/teems/formatters/calendar_formatter.rb +227 -0
- data/lib/teems/formatters/format_utils.rb +56 -0
- data/lib/teems/formatters/markdown_formatter.rb +113 -0
- data/lib/teems/formatters/message_formatter.rb +67 -0
- data/lib/teems/formatters/output.rb +105 -0
- data/lib/teems/models/account.rb +59 -0
- data/lib/teems/models/channel.rb +31 -0
- data/lib/teems/models/chat.rb +111 -0
- data/lib/teems/models/duration.rb +46 -0
- data/lib/teems/models/event.rb +124 -0
- data/lib/teems/models/message.rb +125 -0
- data/lib/teems/models/parsing.rb +56 -0
- data/lib/teems/models/user.rb +25 -0
- data/lib/teems/models/user_profile.rb +45 -0
- data/lib/teems/runner.rb +81 -0
- data/lib/teems/services/api_client.rb +217 -0
- data/lib/teems/services/cache_store.rb +32 -0
- data/lib/teems/services/configuration.rb +56 -0
- data/lib/teems/services/file_downloader.rb +39 -0
- data/lib/teems/services/headless_extract.rb +192 -0
- data/lib/teems/services/safari_oauth.rb +285 -0
- data/lib/teems/services/sync_dir_naming.rb +42 -0
- data/lib/teems/services/sync_engine.rb +194 -0
- data/lib/teems/services/sync_store.rb +193 -0
- data/lib/teems/services/teams_url_parser.rb +78 -0
- data/lib/teems/services/token_exchange_scripts.rb +56 -0
- data/lib/teems/services/token_extractor.rb +401 -0
- data/lib/teems/services/token_extractor_scripts.rb +116 -0
- data/lib/teems/services/token_refresher.rb +169 -0
- data/lib/teems/services/token_store.rb +116 -0
- data/lib/teems/support/error_logger.rb +35 -0
- data/lib/teems/support/help_formatter.rb +80 -0
- data/lib/teems/support/timezone.rb +44 -0
- data/lib/teems/support/xdg_paths.rb +62 -0
- data/lib/teems/version.rb +5 -0
- data/lib/teems.rb +117 -0
- data/support/token_helper.swift +485 -0
- 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(' ', ' ').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
|
data/lib/teems/runner.rb
ADDED
|
@@ -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
|