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,485 @@
1
+ import Foundation
2
+ import WebKit
3
+ import Security
4
+ import CommonCrypto
5
+
6
+ // Headless WKWebView token extractor for Microsoft Teams
7
+ // Uses OAuth2 implicit flow with redirect interception (inspired by fossteams/teams-token)
8
+ // Optional certificate auth from macOS Keychain for TLS client auth
9
+ // Outputs JSON to stdout, logs to stderr
10
+ // Exit codes: 0 = success, 1 = error, 2 = needs Safari
11
+
12
+ let TEAMS_APP_ID = "5e3ce6c0-2b1f-4285-8d4b-75ee78787346"
13
+ let SKYPE_RESOURCE = "https://api.spaces.skype.com"
14
+ let GRAPH_RESOURCE = "https://graph.microsoft.com"
15
+ let REDIRECT_URI = "https://teams.microsoft.com/go"
16
+
17
+ class TokenExtractor: NSObject, WKNavigationDelegate {
18
+ private let webView: WKWebView
19
+ private let timeoutSeconds: Int
20
+ private var started = Date()
21
+ private var loginPageCount = 0
22
+ private let loginPageThreshold = 30
23
+
24
+ // Collected tokens
25
+ private var teamsToken: String?
26
+ private var skypeToken: String?
27
+ private var graphToken: String?
28
+ private var refreshToken: String?
29
+ private var tenantId: String?
30
+ private var loginHint: String?
31
+
32
+ // PKCE for authorization code flow
33
+ private var codeVerifier: String?
34
+
35
+ private static let safariUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +
36
+ "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15"
37
+
38
+ private let useCertAuth: Bool
39
+
40
+ init(timeout: Int, loginHint: String?, tenantId: String?, certAuth: Bool = false) {
41
+ self.timeoutSeconds = timeout
42
+ self.loginHint = loginHint
43
+ self.tenantId = tenantId
44
+ self.useCertAuth = certAuth
45
+ let config = WKWebViewConfiguration()
46
+ config.websiteDataStore = WKWebsiteDataStore.default()
47
+ self.webView = WKWebView(frame: NSRect(x: 0, y: 0, width: 800, height: 600), configuration: config)
48
+ super.init()
49
+ self.webView.navigationDelegate = self
50
+ self.webView.customUserAgent = Self.safariUA
51
+ }
52
+
53
+ func run() {
54
+ started = Date()
55
+ log("Starting OAuth flow...")
56
+ authorizeTeams()
57
+
58
+ DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(timeoutSeconds)) { [weak self] in
59
+ self?.fail("Timeout after \(self?.timeoutSeconds ?? 0)s")
60
+ }
61
+ }
62
+
63
+ // MARK: - OAuth flow
64
+
65
+ private func authorizeTeams() {
66
+ let tenant = tenantId ?? "common"
67
+ let url = buildAuthorizeURL(responseType: "id_token", tenant: tenant)
68
+ log("Requesting Teams id_token (tenant=\(tenant))...")
69
+ webView.load(URLRequest(url: url))
70
+ }
71
+
72
+ private func authorizeSkype() {
73
+ guard let tid = tenantId else {
74
+ fail("No tenant ID for Skype authorization")
75
+ return
76
+ }
77
+ let url = buildAuthorizeURL(responseType: "token", tenant: tid, resource: SKYPE_RESOURCE)
78
+ log("Requesting Skype access_token...")
79
+ webView.load(URLRequest(url: url))
80
+ }
81
+
82
+ private func authorizeGraph() {
83
+ guard let tid = tenantId else {
84
+ fail("No tenant ID for Graph authorization")
85
+ return
86
+ }
87
+ let verifier = generateCodeVerifier()
88
+ codeVerifier = verifier
89
+ let challenge = generateCodeChallenge(verifier)
90
+ let baseURL = buildAuthorizeURL(responseType: "code", tenant: tid, resource: GRAPH_RESOURCE)
91
+ guard var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false) else {
92
+ fail("Failed to build Graph authorization URL")
93
+ return
94
+ }
95
+ components.queryItems?.append(URLQueryItem(name: "code_challenge", value: challenge))
96
+ components.queryItems?.append(URLQueryItem(name: "code_challenge_method", value: "S256"))
97
+ guard let url = components.url else {
98
+ fail("Failed to construct Graph authorization URL with PKCE")
99
+ return
100
+ }
101
+ log("Requesting Graph authorization code...")
102
+ webView.load(URLRequest(url: url))
103
+ }
104
+
105
+ private func generateCodeVerifier() -> String {
106
+ var bytes = [UInt8](repeating: 0, count: 32)
107
+ let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
108
+ guard status == errSecSuccess else {
109
+ fail("Failed to generate secure random bytes (status: \(status))")
110
+ return ""
111
+ }
112
+ return Data(bytes).base64EncodedString()
113
+ .replacingOccurrences(of: "+", with: "-")
114
+ .replacingOccurrences(of: "/", with: "_")
115
+ .replacingOccurrences(of: "=", with: "")
116
+ }
117
+
118
+ private func generateCodeChallenge(_ verifier: String) -> String {
119
+ let data = verifier.data(using: .utf8)!
120
+ var hash = [UInt8](repeating: 0, count: 32)
121
+ data.withUnsafeBytes { CC_SHA256($0.baseAddress, CC_LONG(data.count), &hash) }
122
+ return Data(hash).base64EncodedString()
123
+ .replacingOccurrences(of: "+", with: "-")
124
+ .replacingOccurrences(of: "/", with: "_")
125
+ .replacingOccurrences(of: "=", with: "")
126
+ }
127
+
128
+ private func buildAuthorizeURL(responseType: String, tenant: String, resource: String? = nil) -> URL {
129
+ var components = URLComponents(string: "https://login.microsoftonline.com/\(tenant)/oauth2/authorize")!
130
+ var items = [
131
+ URLQueryItem(name: "response_type", value: responseType),
132
+ URLQueryItem(name: "client_id", value: TEAMS_APP_ID),
133
+ URLQueryItem(name: "redirect_uri", value: REDIRECT_URI),
134
+ URLQueryItem(name: "state", value: UUID().uuidString),
135
+ URLQueryItem(name: "nonce", value: UUID().uuidString),
136
+ ]
137
+ if let resource = resource {
138
+ items.append(URLQueryItem(name: "resource", value: resource))
139
+ }
140
+ if let hint = loginHint {
141
+ items.append(URLQueryItem(name: "login_hint", value: hint))
142
+ if let domain = hint.split(separator: "@").last {
143
+ items.append(URLQueryItem(name: "domain_hint", value: String(domain)))
144
+ }
145
+ }
146
+ components.queryItems = items
147
+ return components.url!
148
+ }
149
+
150
+ // MARK: - WKNavigationDelegate
151
+
152
+ // Domains that are part of the normal OAuth/login flow
153
+ private static let allowedDomains = [
154
+ "login.microsoftonline.com",
155
+ "login.microsoft.com",
156
+ "login.live.com",
157
+ "device.login.microsoftonline.com",
158
+ "certauth.login.microsoftonline.com",
159
+ "teams.microsoft.com",
160
+ "aadcdn.msftauth.net",
161
+ "aadcdn.msauth.net",
162
+ ]
163
+
164
+ private func isAllowedDomain(_ host: String) -> Bool {
165
+ Self.allowedDomains.contains(where: { host == $0 || host.hasSuffix(".\($0)") })
166
+ }
167
+
168
+ func webView(_ webView: WKWebView,
169
+ decidePolicyFor navigationAction: WKNavigationAction,
170
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
171
+ guard let url = navigationAction.request.url, let host = url.host else {
172
+ decisionHandler(.allow)
173
+ return
174
+ }
175
+
176
+ let urlStr = url.absoluteString
177
+ if urlStr.hasPrefix(REDIRECT_URI + "#") || urlStr.hasPrefix(REDIRECT_URI + "?") {
178
+ decisionHandler(.cancel)
179
+ handleRedirect(url: url)
180
+ return
181
+ }
182
+
183
+ // Detect Conditional Access / MDM enrollment redirects (e.g. portal.manage.microsoft.com,
184
+ // workspaceone) that WKWebView can't handle — fall back to Safari immediately.
185
+ if !isAllowedDomain(host) {
186
+ log("Redirected to \(host) (Conditional Access / MDM?) — Safari required")
187
+ decisionHandler(.cancel)
188
+ needsSafari()
189
+ return
190
+ }
191
+
192
+ decisionHandler(.allow)
193
+ }
194
+
195
+ func webView(_ webView: WKWebView,
196
+ didReceive challenge: URLAuthenticationChallenge,
197
+ completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
198
+ let host = challenge.protectionSpace.host
199
+ if challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodClientCertificate {
200
+ log("Client cert requested from \(host)")
201
+ if useCertAuth, host.hasSuffix(".certauth.login.microsoftonline.com"),
202
+ let credential = findPIVCredential() {
203
+ completionHandler(.useCredential, credential)
204
+ return
205
+ }
206
+ log("Skipping cert for \(host)")
207
+ }
208
+ completionHandler(.performDefaultHandling, nil)
209
+ }
210
+
211
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
212
+ guard let url = webView.url?.absoluteString else { return }
213
+
214
+ if url.contains("login.microsoftonline.com") {
215
+ loginPageCount += 1
216
+ autoClickKMSI()
217
+ if loginPageCount >= loginPageThreshold {
218
+ needsSafari()
219
+ }
220
+ }
221
+ }
222
+
223
+ func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
224
+ let code = (error as NSError).code
225
+ if code != 102 { // 102 = frame load interrupted (expected from cancel)
226
+ log("Load error (\(code)): \(error.localizedDescription)")
227
+ }
228
+ }
229
+
230
+ // MARK: - Redirect handling
231
+
232
+ private func handleRedirect(url: URL) {
233
+ let fragmentParams = parseFragment(url.fragment ?? "")
234
+ let queryParams = parseFragment(url.query ?? "")
235
+
236
+ if let error = fragmentParams["error"] ?? queryParams["error"] {
237
+ let desc = (fragmentParams["error_description"] ?? queryParams["error_description"])?
238
+ .removingPercentEncoding ?? "unknown"
239
+ log("OAuth error: \(error) — \(desc)")
240
+ if error == "interaction_required" { needsSafari() } else { fail("OAuth error: \(error)") }
241
+ return
242
+ }
243
+
244
+ if let idToken = fragmentParams["id_token"] {
245
+ handleTeamsToken(idToken)
246
+ } else if let accessToken = fragmentParams["access_token"] {
247
+ handleAccessToken(accessToken)
248
+ } else if let code = queryParams["code"] {
249
+ exchangeCodeForTokens(code)
250
+ } else {
251
+ log("Unrecognized redirect — no token, code, or error")
252
+ }
253
+ }
254
+
255
+ private func handleTeamsToken(_ token: String) {
256
+ teamsToken = token
257
+
258
+ guard let payload = decodeJWT(token) else {
259
+ fail("Failed to decode Teams JWT")
260
+ return
261
+ }
262
+
263
+ tenantId = payload["tid"] as? String
264
+ loginHint = loginHint ?? (payload["upn"] as? String)
265
+ log("Got Teams token (tenant=\(tenantId ?? "?"), upn=\(loginHint ?? "?"))")
266
+ authorizeSkype()
267
+ }
268
+
269
+ private func handleAccessToken(_ token: String) {
270
+ guard let payload = decodeJWT(token) else { return }
271
+ let audience = payload["aud"] as? String ?? "unknown"
272
+
273
+ if audience == SKYPE_RESOURCE {
274
+ log("Got Skype spaces token")
275
+ skypeToken = token
276
+ authorizeGraph()
277
+ } else if audience == GRAPH_RESOURCE {
278
+ log("Got Graph access token")
279
+ graphToken = token
280
+ emitResult()
281
+ } else {
282
+ log("Unexpected token audience: \(audience)")
283
+ }
284
+ }
285
+
286
+ // MARK: - Authorization code exchange
287
+
288
+ private func urlEncode(_ value: String) -> String {
289
+ value.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? value
290
+ }
291
+
292
+ private func exchangeCodeForTokens(_ code: String) {
293
+ guard let tid = tenantId else { fail("No tenant ID for code exchange"); return }
294
+
295
+ let tokenURL = URL(string: "https://login.microsoftonline.com/\(tid)/oauth2/token")!
296
+ var request = URLRequest(url: tokenURL)
297
+ request.httpMethod = "POST"
298
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
299
+ request.setValue("https://teams.microsoft.com", forHTTPHeaderField: "Origin")
300
+
301
+ var bodyParts = [
302
+ "grant_type=authorization_code",
303
+ "client_id=\(urlEncode(TEAMS_APP_ID))",
304
+ "code=\(urlEncode(code))",
305
+ "redirect_uri=\(urlEncode(REDIRECT_URI))",
306
+ "resource=\(urlEncode(GRAPH_RESOURCE))"
307
+ ]
308
+ if let verifier = codeVerifier {
309
+ bodyParts.append("code_verifier=\(urlEncode(verifier))")
310
+ }
311
+ let body = bodyParts.joined(separator: "&")
312
+ request.httpBody = body.data(using: .utf8)
313
+
314
+ log("Exchanging authorization code for Graph tokens...")
315
+ URLSession.shared.dataTask(with: request) { [weak self] data, _, error in
316
+ DispatchQueue.main.async { self?.handleCodeExchangeResult(data, error) }
317
+ }.resume()
318
+ }
319
+
320
+ private func handleCodeExchangeResult(_ data: Data?, _ error: Error?) {
321
+ if let error = error { fail("Code exchange error: \(error.localizedDescription)"); return }
322
+ guard let data = data,
323
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
324
+ fail("Failed to parse code exchange response")
325
+ return
326
+ }
327
+
328
+ if let err = json["error"] as? String {
329
+ fail("Code exchange failed: \(err) — \(json["error_description"] as? String ?? "")")
330
+ return
331
+ }
332
+
333
+ guard let accessToken = json["access_token"] as? String else {
334
+ fail("No access_token in code exchange response")
335
+ return
336
+ }
337
+
338
+ graphToken = accessToken
339
+ refreshToken = json["refresh_token"] as? String
340
+ log("Got Graph access token + refresh token")
341
+ emitResult()
342
+ }
343
+
344
+ // MARK: - Auto-click "Stay signed in?"
345
+
346
+ private func autoClickKMSI() {
347
+ webView.evaluateJavaScript("""
348
+ (function() {
349
+ var btn = document.getElementById('idSIButton9');
350
+ if (btn) { btn.click(); return 'clicked'; }
351
+ return null;
352
+ })()
353
+ """) { result, _ in
354
+ if let action = result as? String {
355
+ self.log("KMSI: \(action)")
356
+ }
357
+ }
358
+ }
359
+
360
+ // MARK: - Result output
361
+
362
+ private func emitResult() {
363
+ var result: [String: String] = [
364
+ "client_id": TEAMS_APP_ID,
365
+ ]
366
+ if let g = graphToken {
367
+ result["auth_token"] = g
368
+ } else if let t = teamsToken {
369
+ log("Warning: Graph token unavailable, falling back to Teams id_token")
370
+ result["auth_token"] = t
371
+ }
372
+ if let s = skypeToken { result["skype_spaces_token"] = s }
373
+ if let tid = tenantId { result["tenant_id"] = tid }
374
+ if let rt = refreshToken { result["refresh_token"] = rt }
375
+
376
+ guard let data = try? JSONSerialization.data(withJSONObject: result),
377
+ let json = String(data: data, encoding: .utf8) else {
378
+ fail("Failed to serialize result")
379
+ return
380
+ }
381
+
382
+ log("Success!")
383
+ print(json)
384
+ exit(0)
385
+ }
386
+
387
+ // MARK: - JWT decoding
388
+
389
+ private func decodeJWT(_ token: String) -> [String: Any]? {
390
+ let parts = token.split(separator: ".")
391
+ guard parts.count >= 2 else { return nil }
392
+
393
+ // JWT uses base64url encoding — convert to standard base64
394
+ var payload = String(parts[1])
395
+ .replacingOccurrences(of: "-", with: "+")
396
+ .replacingOccurrences(of: "_", with: "/")
397
+ while payload.count % 4 != 0 { payload += "=" }
398
+
399
+ guard let data = Data(base64Encoded: payload),
400
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
401
+ return nil
402
+ }
403
+ return json
404
+ }
405
+
406
+ // MARK: - Helpers
407
+
408
+ private func parseFragment(_ fragment: String) -> [String: String] {
409
+ fragment.split(separator: "&").reduce(into: [:]) { result, pair in
410
+ let kv = pair.split(separator: "=", maxSplits: 1)
411
+ if kv.count == 2 {
412
+ result[String(kv[0])] = String(kv[1]).removingPercentEncoding ?? String(kv[1])
413
+ }
414
+ }
415
+ }
416
+
417
+ private func findPIVCredential() -> URLCredential? {
418
+ let query: [String: Any] = [
419
+ kSecClass as String: kSecClassIdentity,
420
+ kSecReturnRef as String: true,
421
+ kSecMatchLimit as String: kSecMatchLimitAll
422
+ ]
423
+ var result: AnyObject?
424
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
425
+ let identities = result as? [SecIdentity],
426
+ let identity = identities.first else { return nil }
427
+
428
+ return URLCredential(identity: identity, certificates: nil, persistence: .forSession)
429
+ }
430
+
431
+ private func log(_ message: String) {
432
+ let elapsed = String(format: "%.1f", Date().timeIntervalSince(started))
433
+ FileHandle.standardError.write("[\(elapsed)s] \(message)\n".data(using: .utf8)!)
434
+ }
435
+
436
+ private func needsSafari() {
437
+ log("Stuck on login — Safari required")
438
+ printJSON(["error": "needs_safari", "message": "No Entra ID session, Safari login required"])
439
+ exit(2)
440
+ }
441
+
442
+ private func fail(_ message: String) {
443
+ log("FAILED: \(message)")
444
+ printJSON(["error": message])
445
+ exit(1)
446
+ }
447
+
448
+ private func printJSON(_ dict: [String: String]) {
449
+ if let data = try? JSONSerialization.data(withJSONObject: dict),
450
+ let json = String(data: data, encoding: .utf8) {
451
+ print(json)
452
+ }
453
+ }
454
+ }
455
+
456
+ // MARK: - Main
457
+
458
+ var timeout = 90
459
+ var loginHint: String?
460
+ var tenantId: String?
461
+ var certAuth = false
462
+ var args = CommandLine.arguments.dropFirst()
463
+
464
+ while let arg = args.first {
465
+ args = args.dropFirst()
466
+ switch arg {
467
+ case "--timeout":
468
+ timeout = Int(args.first ?? "") ?? 90
469
+ args = args.dropFirst()
470
+ case "--login-hint":
471
+ loginHint = String(args.first ?? "")
472
+ args = args.dropFirst()
473
+ case "--tenant-id":
474
+ tenantId = String(args.first ?? "")
475
+ args = args.dropFirst()
476
+ case "--certauth":
477
+ certAuth = true
478
+ default:
479
+ break
480
+ }
481
+ }
482
+
483
+ let extractor = TokenExtractor(timeout: timeout, loginHint: loginHint, tenantId: tenantId, certAuth: certAuth)
484
+ extractor.run()
485
+ RunLoop.main.run()
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: teems
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Eric Boehs
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Read messages, list channels and chats from Microsoft Teams in the terminal.
13
+ Pure Ruby, no dependencies.
14
+ email:
15
+ - ericboehs@gmail.com
16
+ executables:
17
+ - teems
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - CHANGELOG.md
22
+ - LICENSE
23
+ - README.md
24
+ - bin/teems
25
+ - lib/teems.rb
26
+ - lib/teems/api/calendar.rb
27
+ - lib/teems/api/channels.rb
28
+ - lib/teems/api/chats.rb
29
+ - lib/teems/api/client.rb
30
+ - lib/teems/api/files.rb
31
+ - lib/teems/api/messages.rb
32
+ - lib/teems/api/users.rb
33
+ - lib/teems/api/users_mailbox.rb
34
+ - lib/teems/api/users_presence.rb
35
+ - lib/teems/cli.rb
36
+ - lib/teems/commands/activity.rb
37
+ - lib/teems/commands/auth.rb
38
+ - lib/teems/commands/base.rb
39
+ - lib/teems/commands/cal.rb
40
+ - lib/teems/commands/channels.rb
41
+ - lib/teems/commands/chats.rb
42
+ - lib/teems/commands/help.rb
43
+ - lib/teems/commands/messages.rb
44
+ - lib/teems/commands/ooo.rb
45
+ - lib/teems/commands/org.rb
46
+ - lib/teems/commands/status.rb
47
+ - lib/teems/commands/sync.rb
48
+ - lib/teems/commands/who.rb
49
+ - lib/teems/formatters/calendar_formatter.rb
50
+ - lib/teems/formatters/format_utils.rb
51
+ - lib/teems/formatters/markdown_formatter.rb
52
+ - lib/teems/formatters/message_formatter.rb
53
+ - lib/teems/formatters/output.rb
54
+ - lib/teems/models/account.rb
55
+ - lib/teems/models/channel.rb
56
+ - lib/teems/models/chat.rb
57
+ - lib/teems/models/duration.rb
58
+ - lib/teems/models/event.rb
59
+ - lib/teems/models/message.rb
60
+ - lib/teems/models/parsing.rb
61
+ - lib/teems/models/user.rb
62
+ - lib/teems/models/user_profile.rb
63
+ - lib/teems/runner.rb
64
+ - lib/teems/services/api_client.rb
65
+ - lib/teems/services/cache_store.rb
66
+ - lib/teems/services/configuration.rb
67
+ - lib/teems/services/file_downloader.rb
68
+ - lib/teems/services/headless_extract.rb
69
+ - lib/teems/services/safari_oauth.rb
70
+ - lib/teems/services/sync_dir_naming.rb
71
+ - lib/teems/services/sync_engine.rb
72
+ - lib/teems/services/sync_store.rb
73
+ - lib/teems/services/teams_url_parser.rb
74
+ - lib/teems/services/token_exchange_scripts.rb
75
+ - lib/teems/services/token_extractor.rb
76
+ - lib/teems/services/token_extractor_scripts.rb
77
+ - lib/teems/services/token_refresher.rb
78
+ - lib/teems/services/token_store.rb
79
+ - lib/teems/support/error_logger.rb
80
+ - lib/teems/support/help_formatter.rb
81
+ - lib/teems/support/timezone.rb
82
+ - lib/teems/support/xdg_paths.rb
83
+ - lib/teems/version.rb
84
+ - support/token_helper.swift
85
+ homepage: https://github.com/ericboehs/teems
86
+ licenses:
87
+ - MIT
88
+ metadata:
89
+ homepage_uri: https://github.com/ericboehs/teems
90
+ source_code_uri: https://github.com/ericboehs/teems
91
+ changelog_uri: https://github.com/ericboehs/teems/blob/main/CHANGELOG.md
92
+ rubygems_mfa_required: 'true'
93
+ rdoc_options: []
94
+ require_paths:
95
+ - lib
96
+ required_ruby_version: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - ">="
99
+ - !ruby/object:Gem::Version
100
+ version: 3.2.0
101
+ required_rubygems_version: !ruby/object:Gem::Requirement
102
+ requirements:
103
+ - - ">="
104
+ - !ruby/object:Gem::Version
105
+ version: '0'
106
+ requirements: []
107
+ rubygems_version: 3.6.9
108
+ specification_version: 4
109
+ summary: A command-line interface for Microsoft Teams
110
+ test_files: []