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,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+ require 'json'
5
+ require 'net/http'
6
+ require 'uri'
7
+
8
+ module Teems
9
+ module Services
10
+ # Compiles and manages the Swift WKWebView helper binary
11
+ module HelperBinary
12
+ SWIFT_FRAMEWORKS = %w[WebKit Security AppKit].freeze
13
+
14
+ private
15
+
16
+ def ensure_helper_binary
17
+ source = helper_source_path
18
+ binary = helper_binary_path
19
+ return nil unless File.exist?(source)
20
+ return binary if File.exist?(binary) && File.mtime(binary) >= File.mtime(source)
21
+
22
+ compile_helper(source, binary)
23
+ end
24
+
25
+ def compile_helper(source, binary)
26
+ log('Compiling headless token helper...')
27
+ _stdout, stderr, status = Open3.capture3(*swiftc_command(source, binary))
28
+ log_helper_stderr(stderr)
29
+ status.success? ? binary : log_and_nil('Failed to compile helper')
30
+ rescue Errno::ENOENT
31
+ log_and_nil('swiftc not found')
32
+ end
33
+
34
+ # :reek:UtilityFunction
35
+ def swiftc_command(source, binary)
36
+ ['swiftc', *SWIFT_FRAMEWORKS.flat_map { |fw| ['-framework', fw] }, source, '-o', binary]
37
+ end
38
+
39
+ def log_and_nil(message)
40
+ log(message)
41
+ nil
42
+ end
43
+
44
+ def log_helper_stderr(stderr)
45
+ stderr.each_line { |line| log("[helper] #{line.chomp}") }
46
+ end
47
+
48
+ def helper_source_path
49
+ File.expand_path('../../../support/token_helper.swift', __dir__)
50
+ end
51
+
52
+ def helper_binary_path
53
+ helper_source_path.sub(/\.swift$/, '')
54
+ end
55
+ end
56
+
57
+ # HTTP-based Skype token exchange (no browser needed)
58
+ module HttpSkypeExchange
59
+ AUTHSVC_URL = 'https://teams.microsoft.com/api/authsvc/v1.0/authz'
60
+
61
+ private
62
+
63
+ # :reek:UtilityFunction :reek:TooManyStatements :reek:UncommunicativeVariableName
64
+ def exchange_skype_via_http(skype_spaces_token)
65
+ return nil unless skype_spaces_token
66
+
67
+ response = post_authsvc_exchange(skype_spaces_token)
68
+ return nil unless response.is_a?(Net::HTTPSuccess)
69
+
70
+ JSON.parse(response.body).dig('tokens', 'skypeToken')
71
+ rescue StandardError => e
72
+ log("Skype exchange failed: #{e.message}")
73
+ nil
74
+ end
75
+
76
+ # :reek:UtilityFunction
77
+ def post_authsvc_exchange(token)
78
+ uri = URI(AUTHSVC_URL)
79
+ http = build_authsvc_http(uri)
80
+ http.request(build_authsvc_request(uri, token))
81
+ end
82
+
83
+ # :reek:UtilityFunction
84
+ def build_authsvc_http(uri)
85
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
86
+ http.use_ssl = true
87
+ http.open_timeout = 10
88
+ http.read_timeout = 30
89
+ end
90
+ end
91
+
92
+ # :reek:UtilityFunction
93
+ def build_authsvc_request(uri, token)
94
+ Net::HTTP::Post.new(uri).tap do |req|
95
+ req['Authorization'] = "Bearer #{token}"
96
+ req['Content-Type'] = 'application/json'
97
+ req.body = '{}'
98
+ end
99
+ end
100
+ end
101
+
102
+ # Headless token extraction via WKWebView Swift helper.
103
+ # Uses OAuth2 implicit flow with redirect interception — no Safari needed.
104
+ # Falls through to Safari when no cached Entra ID session exists.
105
+ module HeadlessExtract
106
+ include HelperBinary
107
+ include HttpSkypeExchange
108
+
109
+ HELPER_TIMEOUT = 15
110
+ NEEDS_SAFARI_EXIT = 2
111
+
112
+ private
113
+
114
+ # :reek:TooManyStatements :reek:UncommunicativeVariableName
115
+ def try_headless_extract
116
+ binary = ensure_helper_binary
117
+ return nil unless binary
118
+
119
+ log('Trying headless token extraction...')
120
+ output, stderr, status = Open3.capture3(binary, *build_helper_args)
121
+ log_helper_stderr(stderr)
122
+ handle_helper_result(output, status.exitstatus)
123
+ rescue StandardError => e
124
+ log("Headless extraction error: #{e.message}")
125
+ nil
126
+ end
127
+
128
+ def handle_helper_result(output, exit_code)
129
+ return parse_headless_result(output) if exit_code.zero?
130
+
131
+ message = exit_code == NEEDS_SAFARI_EXIT ? 'No cached session' : "Helper exited #{exit_code}"
132
+ log("#{message}, falling back to Safari...")
133
+ nil
134
+ end
135
+
136
+ def build_helper_args
137
+ hint, tenant = stored_login_hint
138
+ ['--timeout', HELPER_TIMEOUT.to_s,
139
+ *(hint ? ['--login-hint', hint] : []),
140
+ *(tenant ? ['--tenant-id', tenant] : []),
141
+ *(@auth_mode == :certauth ? ['--certauth'] : [])]
142
+ end
143
+
144
+ # :reek:UtilityFunction
145
+ def stored_login_hint
146
+ path = locate_token_store
147
+ return [nil, nil] unless path && File.exist?(path)
148
+
149
+ data = JSON.parse(File.read(path))
150
+ [extract_upn(data['auth_token']), data['tenant_id']]
151
+ rescue StandardError
152
+ [nil, nil]
153
+ end
154
+
155
+ # :reek:UtilityFunction
156
+ def extract_upn(jwt)
157
+ return nil unless jwt && (payload = jwt.split('.')[1])
158
+
159
+ padded = payload.tr('-_', '+/').ljust((payload.length + 3) & ~3, '=')
160
+ JSON.parse(padded.unpack1('m'))['upn']
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ # :reek:UtilityFunction
166
+ def locate_token_store
167
+ config = ENV.fetch('XDG_CONFIG_HOME', File.join(Dir.home, '.config'))
168
+ File.join(config, 'teems', 'tokens.json')
169
+ end
170
+
171
+ # :reek:TooManyStatements :reek:UncommunicativeVariableName
172
+ def parse_headless_result(output)
173
+ parsed = JSON.parse(output.strip)
174
+ return nil unless parsed['auth_token']
175
+
176
+ build_headless_tokens(parsed)
177
+ rescue JSON::ParserError => e
178
+ log("Failed to parse headless result: #{e.message}")
179
+ nil
180
+ end
181
+
182
+ # :reek:FeatureEnvy
183
+ def build_headless_tokens(parsed)
184
+ spaces_token = parsed['skype_spaces_token']
185
+
186
+ { auth_token: parsed['auth_token'], skype_token: exchange_skype_via_http(spaces_token),
187
+ skype_spaces_token: spaces_token, chatsvc_token: nil,
188
+ refresh_token: parsed['refresh_token'], client_id: parsed['client_id'], tenant_id: parsed['tenant_id'] }
189
+ end
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'digest'
5
+ require 'net/http'
6
+ require 'json'
7
+ require 'uri'
8
+
9
+ module Teems
10
+ module Services
11
+ # Builds OAuth authorize URLs for the Teams app registration
12
+ module OAuthUrlBuilder
13
+ TEAMS_APP_ID = '5e3ce6c0-2b1f-4285-8d4b-75ee78787346'
14
+ REDIRECT_URI = 'https://teams.microsoft.com/go'
15
+ AUTHORIZE_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/authorize'
16
+
17
+ private
18
+
19
+ def build_authorize_url(context, response_type, **opts)
20
+ params = base_authorize_params(response_type)
21
+ apply_optional_params(params, context[:hint], opts)
22
+ build_authorize_uri(context[:tenant], params)
23
+ end
24
+
25
+ def apply_optional_params(params, hint, opts)
26
+ resource = opts[:resource]
27
+ pkce = opts[:pkce]
28
+ params['resource'] = resource if resource
29
+ add_login_hints(params, hint)
30
+ add_pkce_params(params, pkce) if pkce
31
+ end
32
+
33
+ def base_authorize_params(response_type)
34
+ nonces = Array.new(2) { SecureRandom.uuid }
35
+ { 'response_type' => response_type, 'client_id' => TEAMS_APP_ID,
36
+ 'redirect_uri' => REDIRECT_URI, 'state' => nonces[0], 'nonce' => nonces[1] }
37
+ end
38
+
39
+ def add_login_hints(params, hint)
40
+ return unless hint
41
+
42
+ params['login_hint'] = hint
43
+ domain = hint.split('@').last
44
+ params['domain_hint'] = domain if domain
45
+ end
46
+
47
+ def add_pkce_params(params, pkce)
48
+ params['code_challenge'] = pkce[:challenge]
49
+ params['code_challenge_method'] = 'S256'
50
+ end
51
+
52
+ def build_authorize_uri(tenant, params)
53
+ uri = URI(format(AUTHORIZE_ENDPOINT, tenant))
54
+ uri.query = URI.encode_www_form(params)
55
+ uri.to_s
56
+ end
57
+ end
58
+
59
+ # PKCE helpers and Graph authorization code exchange
60
+ module OAuthCodeExchange
61
+ TOKEN_ENDPOINT = 'https://login.microsoftonline.com/%s/oauth2/token'
62
+ GRAPH_RESOURCE = 'https://graph.microsoft.com'
63
+
64
+ private
65
+
66
+ def generate_pkce
67
+ verifier = base64url(SecureRandom.random_bytes(32))
68
+ challenge = base64url(Digest::SHA256.digest(verifier))
69
+ { verifier: verifier, challenge: challenge }
70
+ end
71
+
72
+ def base64url(data) = [data].pack('m0').tr('+/', '-_').delete('=')
73
+
74
+ def exchange_graph_code(exchange)
75
+ response = post_token_exchange(exchange)
76
+ return nil unless response.is_a?(Net::HTTPSuccess)
77
+
78
+ JSON.parse(response.body)
79
+ rescue StandardError => e
80
+ log("Graph code exchange failed: #{e.message}")
81
+ nil
82
+ end
83
+
84
+ def post_token_exchange(exchange)
85
+ uri = URI(format(TOKEN_ENDPOINT, exchange[:tenant]))
86
+ post = build_exchange_request(uri, exchange)
87
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true,
88
+ open_timeout: 10, read_timeout: 30) { |http| http.request(post) }
89
+ end
90
+
91
+ def build_exchange_request(uri, exchange)
92
+ Net::HTTP::Post.new(uri, 'Content-Type' => 'application/x-www-form-urlencoded',
93
+ 'Origin' => 'https://teams.microsoft.com').tap do |post|
94
+ post.body = token_exchange_body(exchange)
95
+ end
96
+ end
97
+
98
+ def token_exchange_body(exchange)
99
+ URI.encode_www_form(
100
+ 'grant_type' => 'authorization_code', 'client_id' => OAuthUrlBuilder::TEAMS_APP_ID,
101
+ 'code' => exchange[:code], 'redirect_uri' => OAuthUrlBuilder::REDIRECT_URI,
102
+ 'resource' => GRAPH_RESOURCE, 'code_verifier' => exchange[:verifier]
103
+ )
104
+ end
105
+
106
+ def decode_jwt(token)
107
+ payload = token.split('.')[1]
108
+ return nil unless payload
109
+
110
+ padded = payload.tr('-_', '+/').ljust((payload.length + 3) & ~3, '=')
111
+ JSON.parse(padded.unpack1('m'))
112
+ rescue StandardError
113
+ nil
114
+ end
115
+ end
116
+
117
+ # Polls Safari's URL bar via AppleScript and manages Safari tab navigation
118
+ module SafariOAuthPolling
119
+ private
120
+
121
+ def poll_safari_query_redirect
122
+ raw = run_applescript(poll_query_redirect_script).to_s
123
+ if raw.empty? || raw == 'timeout'
124
+ log('Safari OAuth: fast capture missed, falling back')
125
+ return nil
126
+ end
127
+
128
+ log('Safari OAuth: captured authorization code')
129
+ parse_oauth_params(raw)
130
+ end
131
+
132
+ # Poll for ?code= in the URL — Safari's URL property includes query strings
133
+ def poll_query_redirect_script
134
+ <<~APPLESCRIPT
135
+ tell application "Safari"
136
+ repeat 600 times
137
+ try
138
+ set pageURL to URL of current tab of front window
139
+ if pageURL contains "teams.microsoft.com/go?" then
140
+ set codeStart to offset of "?" in pageURL
141
+ return text (codeStart + 1) thru -1 of pageURL
142
+ end if
143
+ end try
144
+ delay 0.02
145
+ end repeat
146
+ return "timeout"
147
+ end tell
148
+ APPLESCRIPT
149
+ end
150
+
151
+ def parse_oauth_params(raw)
152
+ raw.split('&').each_with_object({}) do |pair, hash|
153
+ key, value = pair.split('=', 2)
154
+ hash[key] = URI.decode_www_form_component(value.to_s)
155
+ end
156
+ end
157
+
158
+ def open_safari_to(url)
159
+ run_applescript(open_safari_tab_script(url))
160
+ end
161
+
162
+ def open_safari_tab_script(url)
163
+ <<~APPLESCRIPT
164
+ tell application "Safari"
165
+ activate
166
+ if (count of windows) = 0 then
167
+ make new document with properties {URL:"#{url}"}
168
+ else
169
+ tell front window
170
+ set current tab to (make new tab with properties {URL:"#{url}"})
171
+ end tell
172
+ end if
173
+ end tell
174
+ APPLESCRIPT
175
+ end
176
+
177
+ def navigate_safari_to(url)
178
+ run_applescript(<<~APPLESCRIPT)
179
+ tell application "Safari"
180
+ set URL of current tab of front window to "#{url}"
181
+ end tell
182
+ APPLESCRIPT
183
+ end
184
+ end
185
+
186
+ # Single-hop Safari OAuth: one authorization code request via Safari (SSO Extension
187
+ # handles device auth), then HTTP exchanges for remaining tokens. Uses query string
188
+ # (?code=...) which Safari preserves, unlike fragments (#token=...) which get lost.
189
+ module SafariOAuth
190
+ include OAuthUrlBuilder
191
+ include OAuthCodeExchange
192
+ include SafariOAuthPolling
193
+
194
+ SKYPE_RESOURCE = 'https://api.spaces.skype.com'
195
+
196
+ private
197
+
198
+ def try_safari_oauth
199
+ hint, tenant = stored_login_hint
200
+ return nil unless tenant
201
+
202
+ log('Trying fast Safari OAuth flow...')
203
+ safari_code_flow({ tenant: tenant, hint: hint })
204
+ rescue StandardError => e
205
+ log("Safari OAuth error: #{e.class}: #{e.message}")
206
+ nil
207
+ ensure
208
+ close_teams_tab if tenant
209
+ end
210
+
211
+ def safari_code_flow(context)
212
+ graph = fetch_graph_via_safari(context)
213
+ return nil unless graph
214
+
215
+ skype = fetch_skype_via_refresh(graph, context[:tenant])
216
+ return nil unless skype
217
+
218
+ log('Safari OAuth: all tokens acquired!')
219
+ assemble_safari_result(context, graph, skype)
220
+ end
221
+
222
+ def fetch_graph_via_safari(context)
223
+ pkce = generate_pkce
224
+ url = build_authorize_url(context, 'code', resource: GRAPH_RESOURCE, pkce: pkce)
225
+ log('Safari OAuth: requesting authorization code...')
226
+ open_safari_to(url)
227
+ params = poll_safari_query_redirect
228
+ return nil unless params&.dig('code')
229
+
230
+ log('Safari OAuth: exchanging code for Graph tokens...')
231
+ exchange_graph_code(code: params['code'], verifier: pkce[:verifier], tenant: context[:tenant])
232
+ end
233
+
234
+ def fetch_skype_via_refresh(graph, tenant)
235
+ graph_rt = graph['refresh_token']
236
+ return nil unless graph_rt
237
+
238
+ log('Safari OAuth: refreshing for Skype token...')
239
+ result = refresh_for_resource(token: graph_rt, tenant: tenant, resource: SKYPE_RESOURCE)
240
+ return nil unless result
241
+
242
+ { spaces_token: result['access_token'], refresh_token: result['refresh_token'] }
243
+ end
244
+
245
+ def refresh_for_resource(grant)
246
+ response = post_refresh_request(grant)
247
+ return log_refresh_error(response) unless response.is_a?(Net::HTTPSuccess)
248
+
249
+ JSON.parse(response.body)
250
+ rescue StandardError => e
251
+ log("Token refresh failed: #{e.class}: #{e.message}")
252
+ nil
253
+ end
254
+
255
+ def log_refresh_error(response)
256
+ log("Token refresh failed: HTTP #{response.code}")
257
+ nil
258
+ end
259
+
260
+ # :reek:FeatureEnvy
261
+ def post_refresh_request(grant)
262
+ uri = URI(format(OAuthCodeExchange::TOKEN_ENDPOINT, grant[:tenant]))
263
+ post = Net::HTTP::Post.new(uri, 'Content-Type' => 'application/x-www-form-urlencoded',
264
+ 'Origin' => 'https://teams.microsoft.com')
265
+ post.body = URI.encode_www_form('grant_type' => 'refresh_token',
266
+ 'client_id' => OAuthUrlBuilder::TEAMS_APP_ID,
267
+ 'refresh_token' => grant[:token],
268
+ 'resource' => grant[:resource])
269
+ Net::HTTP.start(uri.host, uri.port, use_ssl: true,
270
+ open_timeout: 10, read_timeout: 30) { |http| http.request(post) }
271
+ end
272
+
273
+ # :reek:FeatureEnvy
274
+ def assemble_safari_result(context, graph, skype)
275
+ spaces_token = skype[:spaces_token]
276
+ { auth_token: graph['access_token'],
277
+ skype_token: exchange_skype_via_http(spaces_token),
278
+ skype_spaces_token: spaces_token, chatsvc_token: nil,
279
+ refresh_token: skype[:refresh_token],
280
+ client_id: OAuthUrlBuilder::TEAMS_APP_ID,
281
+ tenant_id: context[:tenant] }
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teems
4
+ module Services
5
+ # Directory naming helpers for SyncStore
6
+ module SyncDirNaming
7
+ GENERIC_LABELS = ['Group Chat', '1:1 Chat', 'Meeting Chat', 'Channel', 'Space'].freeze
8
+ MAX_DIR_NAME_LENGTH = 100
9
+ TYPE_DIRS = {
10
+ 'oneOnOne' => 'dms', 'group' => 'groups', 'meeting' => 'meetings',
11
+ 'channel' => 'channels', 'space' => 'spaces'
12
+ }.freeze
13
+
14
+ module_function
15
+
16
+ def type_dir(chat_type) = TYPE_DIRS[chat_type] || 'other'
17
+
18
+ private
19
+
20
+ def sanitize_id(id)
21
+ id.gsub(/[:@]/, '_')
22
+ end
23
+
24
+ def sanitize_display_name(name)
25
+ return nil if name.to_s.strip.empty?
26
+
27
+ sanitized = name.strip.gsub(%r{[/\\:*?"<>|]}, '-').gsub(/\s+/, ' ')
28
+ sanitized = sanitized[0, MAX_DIR_NAME_LENGTH].gsub(/[\s.]+\z/, '')
29
+ sanitized.empty? ? nil : sanitized
30
+ end
31
+
32
+ def build_dir_name(chat_id, display_name)
33
+ sanitized = sanitize_display_name(display_name)
34
+ safe_id = sanitize_id(chat_id)
35
+ return safe_id unless sanitized
36
+ return sanitized unless GENERIC_LABELS.include?(display_name.strip)
37
+
38
+ "#{sanitized} (#{safe_id[0, 20]})"
39
+ end
40
+ end
41
+ end
42
+ end