lex-microsoft_teams 0.6.45 → 0.6.47

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 (63) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/CHANGELOG.md +2 -0
  4. data/CLAUDE.md +29 -266
  5. data/lex-microsoft_teams.gemspec +1 -0
  6. data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +29 -17
  7. data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +20 -14
  8. data/lib/legion/extensions/microsoft_teams/absorbers/meeting.rb +21 -14
  9. data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +7 -4
  10. data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +7 -4
  11. data/lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb +7 -4
  12. data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +13 -15
  13. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +3 -3
  14. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +2 -1
  15. data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +25 -16
  16. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +16 -10
  17. data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +8 -8
  18. data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +30 -22
  19. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +14 -8
  20. data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +14 -8
  21. data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +13 -16
  22. data/lib/legion/extensions/microsoft_teams/helpers/client.rb +10 -4
  23. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +3 -2
  24. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +4 -1
  25. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +8 -2
  26. data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +5 -3
  27. data/lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb +6 -1
  28. data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +1 -1
  29. data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +2 -1
  30. data/lib/legion/extensions/microsoft_teams/runners/activities.rb +43 -0
  31. data/lib/legion/extensions/microsoft_teams/runners/ai_insights.rb +62 -0
  32. data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +20 -14
  33. data/lib/legion/extensions/microsoft_teams/runners/app_installations.rb +86 -0
  34. data/lib/legion/extensions/microsoft_teams/runners/auth.rb +4 -107
  35. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +20 -12
  36. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +9 -5
  37. data/lib/legion/extensions/microsoft_teams/runners/call_events.rb +72 -0
  38. data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +85 -0
  39. data/lib/legion/extensions/microsoft_teams/runners/channels.rb +69 -0
  40. data/lib/legion/extensions/microsoft_teams/runners/chats.rb +57 -0
  41. data/lib/legion/extensions/microsoft_teams/runners/files.rb +77 -0
  42. data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +4 -0
  43. data/lib/legion/extensions/microsoft_teams/runners/loop.rb +6 -0
  44. data/lib/legion/extensions/microsoft_teams/runners/meeting_artifacts.rb +54 -0
  45. data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +92 -0
  46. data/lib/legion/extensions/microsoft_teams/runners/messages.rb +62 -0
  47. data/lib/legion/extensions/microsoft_teams/runners/ownership.rb +11 -0
  48. data/lib/legion/extensions/microsoft_teams/runners/people.rb +25 -0
  49. data/lib/legion/extensions/microsoft_teams/runners/presence.rb +14 -0
  50. data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +18 -4
  51. data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +86 -0
  52. data/lib/legion/extensions/microsoft_teams/runners/teams.rb +30 -0
  53. data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +35 -0
  54. data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
  55. data/lib/legion/extensions/microsoft_teams.rb +10 -3
  56. metadata +20 -8
  57. data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +0 -123
  58. data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +0 -122
  59. data/lib/legion/extensions/microsoft_teams/cli/auth.rb +0 -94
  60. data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +0 -270
  61. data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +0 -90
  62. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +0 -412
  63. data/lib/legion/extensions/microsoft_teams/hooks/auth.rb +0 -17
@@ -1,90 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'socket'
4
- require 'uri'
5
-
6
- module Legion
7
- module Extensions
8
- module MicrosoftTeams
9
- module Helpers
10
- class CallbackServer
11
- RESPONSE_HTML = <<~HTML
12
- <html><body style="font-family:sans-serif;text-align:center;padding:40px;">
13
- <h2>Authentication complete</h2><p>You can close this window.</p></body></html>
14
- HTML
15
-
16
- attr_reader :port
17
-
18
- def initialize
19
- @server = nil
20
- @port = nil
21
- @result = nil
22
- @mutex = Mutex.new
23
- @cv = ConditionVariable.new
24
- end
25
-
26
- def start
27
- @server = TCPServer.new('127.0.0.1', 0)
28
- @port = @server.addr[1]
29
- @thread = Thread.new { listen } # rubocop:disable ThreadSafety/NewThread
30
- end
31
-
32
- def wait_for_callback(timeout: 120)
33
- @mutex.synchronize do
34
- @cv.wait(@mutex, timeout) unless @result
35
- @result
36
- end
37
- end
38
-
39
- def shutdown
40
- @server&.close rescue nil # rubocop:disable Style/RescueModifier
41
- @thread&.join(2)
42
- @thread&.kill
43
- end
44
-
45
- def redirect_uri
46
- "http://127.0.0.1:#{@port}/callback"
47
- end
48
-
49
- private
50
-
51
- def listen
52
- loop do
53
- client = @server.accept
54
- request_line = client.gets
55
- # drain headers
56
- loop do
57
- line = client.gets
58
- break if line.nil? || line.strip.empty?
59
- end
60
-
61
- if request_line&.include?('/callback?')
62
- query = request_line.split[1].split('?', 2).last
63
- params = URI.decode_www_form(query).to_h
64
-
65
- @mutex.synchronize do
66
- @result = {
67
- code: params['code'],
68
- state: params['state']
69
- }
70
- @cv.broadcast
71
- end
72
- end
73
-
74
- client.print "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nConnection: close\r\n\r\n#{RESPONSE_HTML}"
75
- client.close
76
- break if @result
77
- end
78
- rescue IOError # rubocop:disable Legion/RescueLogging/NoCapture
79
- nil # server closed during shutdown
80
- rescue StandardError => e
81
- @mutex.synchronize do
82
- @result ||= { error: e.message }
83
- @cv.broadcast
84
- end
85
- end
86
- end
87
- end
88
- end
89
- end
90
- end
@@ -1,412 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'time'
4
- require 'json'
5
- require 'fileutils'
6
- require 'legion/extensions/microsoft_teams/runners/auth'
7
-
8
- module Legion
9
- module Extensions
10
- module MicrosoftTeams
11
- module Helpers
12
- class TokenCache
13
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
14
- Legion::Extensions::Helpers.const_defined?(:Lex, false)
15
- include Legion::Crypt::Helper if defined?(Legion::Crypt::Helper)
16
-
17
- REFRESH_BUFFER = 60
18
- DEFAULT_LOCAL_DIR = File.join(Dir.home, '.legionio', 'tokens')
19
- DEFAULT_LOCAL_FILE = File.join(DEFAULT_LOCAL_DIR, 'microsoft_teams.json')
20
-
21
- @instance_mutex = Mutex.new
22
-
23
- def self.instance
24
- @instance_mutex.synchronize do
25
- @instance ||= begin
26
- cache = new
27
- cache.load_from_vault
28
- log.info('[Teams::TokenCache] Shared instance created and loaded')
29
- cache
30
- end
31
- end
32
- end
33
-
34
- def self.reset_instance!
35
- @instance_mutex.synchronize { @instance = nil }
36
- end
37
-
38
- def initialize
39
- @token_cache = nil
40
- @delegated_cache = nil
41
- @mutex = Mutex.new
42
- @app_token_warned = false
43
- log.debug('TokenCache initialized')
44
- end
45
-
46
- # --- Application token (client_credentials) ---
47
-
48
- def cached_app_token
49
- @mutex.synchronize do
50
- # Phase 8: Broker-managed Entra app token (short-circuits cache path)
51
- if defined?(Legion::Identity::Broker)
52
- token = Legion::Identity::Broker.token_for(:entra)
53
- return token if token
54
- end
55
-
56
- # Legacy: self-managed client_credentials + cache
57
- if @token_cache && !token_expired?(@token_cache)
58
- log.debug('Using cached app token')
59
- return @token_cache[:token]
60
- end
61
-
62
- result = refresh_app_token
63
- return result if result
64
-
65
- unless @app_token_warned
66
- log.warn('No app token available for Graph API calls')
67
- @app_token_warned = true
68
- end
69
- nil
70
- end
71
- end
72
-
73
- alias cached_graph_token cached_app_token
74
-
75
- def clear_token_cache!
76
- @mutex.synchronize do
77
- @token_cache = nil
78
- @app_token_warned = false
79
- end
80
- log.debug('App token cache cleared')
81
- end
82
-
83
- # --- Delegated token (user auth) ---
84
-
85
- def cached_delegated_token
86
- needs_refresh = @mutex.synchronize do
87
- unless @delegated_cache
88
- log.debug('No delegated token in cache')
89
- return nil
90
- end
91
-
92
- unless token_expired?(@delegated_cache)
93
- log.debug("Using cached delegated token (expires #{@delegated_cache[:expires_at]})")
94
- return @delegated_cache[:token]
95
- end
96
-
97
- true
98
- end
99
-
100
- return unless needs_refresh
101
-
102
- log.info('Delegated token expired, attempting refresh')
103
- refresh_delegated
104
- end
105
-
106
- def store_delegated_token(access_token:, refresh_token:, expires_in:, scopes:)
107
- expires_at = Time.now + expires_in.to_i
108
- @mutex.synchronize do
109
- @delegated_cache = {
110
- token: access_token,
111
- refresh_token: refresh_token,
112
- expires_at: expires_at,
113
- scopes: scopes
114
- }
115
- @app_token_warned = false
116
- end
117
- log.info("Delegated token stored (expires_in=#{expires_in}s, expires_at=#{expires_at})")
118
- end
119
-
120
- def clear_delegated_token!
121
- @mutex.synchronize { @delegated_cache = nil }
122
- log.debug('Delegated token cache cleared')
123
- end
124
-
125
- def authenticated?
126
- result = @mutex.synchronize { !@delegated_cache.nil? }
127
- log.debug("authenticated? => #{result}")
128
- result
129
- end
130
-
131
- def previously_authenticated?
132
- path = local_token_path
133
- result = File.exist?(path)
134
- log.debug("previously_authenticated? => #{result} (#{path})")
135
- result
136
- end
137
-
138
- def load_from_vault
139
- if vault_available?
140
- log.info("Loading delegated token from Vault (#{vault_path})")
141
- data = vault_get(vault_path)
142
- if data && data[:access_token]
143
- @mutex.synchronize do
144
- @delegated_cache = {
145
- token: data[:access_token],
146
- refresh_token: data[:refresh_token],
147
- expires_at: Time.parse(data[:expires_at]),
148
- scopes: data[:scopes]
149
- }
150
- end
151
- log.info('Delegated token loaded from Vault')
152
- true
153
- else
154
- log.warn('Vault had no delegated token, falling back to local')
155
- load_from_local
156
- end
157
- else
158
- log.debug('Vault not available, loading from local file')
159
- load_from_local
160
- end
161
- rescue StandardError => e
162
- log.error("Failed to load delegated token from Vault: #{e.message}")
163
- load_from_local
164
- end
165
-
166
- def save_to_vault
167
- save_to_local
168
-
169
- unless vault_available?
170
- log.debug('Vault not available, skipping Vault save')
171
- return false
172
- end
173
-
174
- data = @mutex.synchronize { @delegated_cache&.dup }
175
- unless data
176
- log.warn('No delegated token to save to Vault')
177
- return false
178
- end
179
-
180
- log.info("Saving delegated token to Vault (#{vault_path})")
181
- vault_write(vault_path, access_token: data[:token],
182
- refresh_token: data[:refresh_token],
183
- expires_at: data[:expires_at].utc.iso8601,
184
- scopes: data[:scopes])
185
- log.info('Delegated token saved to Vault')
186
- true
187
- rescue StandardError => e
188
- log.error("Failed to save delegated token to Vault: #{e.message}")
189
- false
190
- end
191
-
192
- def load_from_local
193
- path = local_token_path
194
- unless File.exist?(path)
195
- log.debug("Local token file not found: #{path}")
196
- return false
197
- end
198
-
199
- log.info("Loading delegated token from local file: #{path}")
200
- raw = File.read(path)
201
- data = ::JSON.parse(raw)
202
- unless data['access_token'] && data['refresh_token']
203
- log.warn('Local token file missing access_token or refresh_token')
204
- return false
205
- end
206
-
207
- expires_at = Time.parse(data['expires_at'])
208
- @mutex.synchronize do
209
- @delegated_cache = {
210
- token: data['access_token'],
211
- refresh_token: data['refresh_token'],
212
- expires_at: expires_at,
213
- scopes: data['scopes']
214
- }
215
- end
216
- log.info("Delegated token loaded from local file (expires_at=#{expires_at})")
217
- true
218
- rescue StandardError => e
219
- log.error("Failed to load delegated token from local file: #{e.message}")
220
- false
221
- end
222
-
223
- def save_to_local
224
- data = @mutex.synchronize { @delegated_cache&.dup }
225
- unless data
226
- log.warn('No delegated token to save locally')
227
- return false
228
- end
229
-
230
- path = local_token_path
231
- FileUtils.mkdir_p(File.dirname(path))
232
- File.write(path, ::JSON.pretty_generate(
233
- 'access_token' => data[:token],
234
- 'refresh_token' => data[:refresh_token],
235
- 'expires_at' => data[:expires_at].utc.iso8601,
236
- 'scopes' => data[:scopes]
237
- ))
238
- File.chmod(0o600, path)
239
- log.info("Delegated token saved to local file: #{path}")
240
- true
241
- rescue StandardError => e
242
- log.error("Failed to save delegated token to local file: #{e.message}")
243
- false
244
- end
245
-
246
- private
247
-
248
- def vault_available?
249
- return false unless defined?(Legion::Crypt)
250
- return false unless defined?(Legion::Settings)
251
-
252
- connected = Legion::Settings.dig(:crypt, :vault, :connected) == true
253
- log.debug("vault_available? => #{connected}")
254
- connected
255
- rescue StandardError => e
256
- log.debug("TokenCache: vault_available? check failed: #{e.message}")
257
- false
258
- end
259
-
260
- def token_expired?(cache_entry)
261
- return true unless cache_entry
262
-
263
- buffer = delegated_refresh_buffer
264
- expired = Time.now >= (cache_entry[:expires_at] - buffer)
265
- log.debug("token_expired? => #{expired} (expires_at=#{cache_entry[:expires_at]}, buffer=#{buffer}s)") if expired
266
- expired
267
- end
268
-
269
- def delegated_refresh_buffer
270
- settings = teams_auth_settings
271
- delegated = settings[:delegated]
272
- return REFRESH_BUFFER unless delegated.is_a?(Hash)
273
-
274
- delegated[:refresh_buffer] || REFRESH_BUFFER
275
- end
276
-
277
- def vault_path(_suffix = nil)
278
- settings = teams_auth_settings
279
- delegated = settings[:delegated]
280
- return delegated[:vault_path] if delegated.is_a?(Hash) && delegated[:vault_path]
281
-
282
- identity = if defined?(Legion::Identity::Process) && Legion::Identity::Process.resolved?
283
- Legion::Identity::Process.canonical_name
284
- else
285
- ENV.fetch('USER', 'default').split('@').first
286
- end
287
- "users/#{identity}/delegated_token"
288
- end
289
-
290
- def local_token_path
291
- settings = teams_auth_settings
292
- delegated = settings[:delegated]
293
- return DEFAULT_LOCAL_FILE unless delegated.is_a?(Hash)
294
-
295
- delegated[:local_token_path] || DEFAULT_LOCAL_FILE
296
- end
297
-
298
- def refresh_app_token
299
- result = acquire_fresh_token
300
- unless result
301
- unless @app_token_warned
302
- log.info('No client_secret configured, app token (client_credentials) unavailable')
303
- @app_token_warned = true
304
- end
305
- return nil
306
- end
307
-
308
- access_token = result.dig(:result, 'access_token')
309
- expires_in = result.dig(:result, 'expires_in') || 3600
310
-
311
- @token_cache = {
312
- token: access_token,
313
- expires_at: Time.now + expires_in
314
- }
315
-
316
- log.info("App token refreshed (expires_in=#{expires_in}s)")
317
- access_token
318
- rescue StandardError => e
319
- log.error("TokenCache app refresh failed: #{e.message}")
320
- nil
321
- end
322
-
323
- def refresh_delegated
324
- unless @delegated_cache&.dig(:refresh_token)
325
- log.warn('No refresh token available for delegated refresh')
326
- return nil
327
- end
328
-
329
- settings = teams_auth_settings
330
- unless settings[:tenant_id] && settings[:client_id]
331
- log.warn('Missing tenant_id or client_id for delegated refresh')
332
- return nil
333
- end
334
-
335
- log.info('Refreshing delegated token via refresh_token grant')
336
- auth = Object.new.extend(Legion::Extensions::MicrosoftTeams::Runners::Auth)
337
- result = auth.refresh_delegated_token(
338
- tenant_id: settings[:tenant_id],
339
- client_id: settings[:client_id],
340
- refresh_token: @delegated_cache[:refresh_token],
341
- scope: @delegated_cache[:scopes]
342
- )
343
-
344
- body = result[:result]
345
- unless body&.dig('access_token')
346
- log.warn("Delegated token refresh failed: #{result[:error]}")
347
- return handle_refresh_failure(result)
348
- end
349
-
350
- expires_in = (body['expires_in'] || 3600).to_i
351
- @delegated_cache = {
352
- token: body['access_token'],
353
- refresh_token: body['refresh_token'] || @delegated_cache[:refresh_token],
354
- expires_at: Time.now + expires_in,
355
- scopes: @delegated_cache[:scopes]
356
- }
357
-
358
- log.info("Delegated token refreshed (expires_in=#{expires_in}s)")
359
- save_to_vault
360
- @delegated_cache[:token]
361
- rescue StandardError => e
362
- log.error("TokenCache delegated refresh failed: #{e.message}")
363
- nil
364
- end
365
-
366
- def handle_refresh_failure(result)
367
- if result[:error] == 'invalid_grant'
368
- log.warn('Refresh token invalid (invalid_grant), clearing delegated cache')
369
- @delegated_cache = nil
370
- emit_expired_event
371
- end
372
- nil
373
- end
374
-
375
- def emit_expired_event
376
- Legion::Events.emit('microsoft_teams.auth.expired') if defined?(Legion::Events)
377
- end
378
-
379
- def acquire_fresh_token
380
- settings = teams_auth_settings
381
- unless settings[:tenant_id] && settings[:client_id] && settings[:client_secret]
382
- log.debug('Missing credentials for app token acquisition') unless @app_token_warned
383
- return nil
384
- end
385
-
386
- log.debug('Acquiring fresh app token via client_credentials')
387
- auth = Object.new.extend(Legion::Extensions::MicrosoftTeams::Runners::Auth)
388
- auth.acquire_token(
389
- tenant_id: settings[:tenant_id],
390
- client_id: settings[:client_id],
391
- client_secret: settings[:client_secret]
392
- )
393
- end
394
-
395
- def teams_auth_settings
396
- ms = settings
397
- ms = Legion::Settings[:microsoft_teams] if defined?(Legion::Settings) && (ms.nil? || ms.empty?)
398
- ms ||= {}
399
- auth = ms[:auth].is_a?(Hash) ? ms[:auth].dup : {}
400
- auth[:tenant_id] ||= ms[:tenant_id]
401
- auth[:client_id] ||= ms[:client_id]
402
- auth[:client_secret] ||= ms[:client_secret]
403
- auth[:tenant_id] ||= ENV.fetch('AZURE_TENANT_ID', nil)
404
- auth[:client_id] ||= ENV.fetch('AZURE_CLIENT_ID', nil)
405
- auth[:client_secret] ||= ENV.fetch('AZURE_CLIENT_SECRET', nil)
406
- auth
407
- end
408
- end
409
- end
410
- end
411
- end
412
- end
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module MicrosoftTeams
6
- module Hooks
7
- class Auth < Legion::Extensions::Hooks::Base # rubocop:disable Legion/Extension/HookMissingRunnerClass
8
- mount '/callback'
9
-
10
- def self.runner_class
11
- 'Legion::Extensions::MicrosoftTeams::Runners::Auth'
12
- end
13
- end
14
- end
15
- end
16
- end
17
- end