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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-microsoft_teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.45
4
+ version: 0.6.47
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -135,6 +135,20 @@ dependencies:
135
135
  - - ">="
136
136
  - !ruby/object:Gem::Version
137
137
  version: 1.3.9
138
+ - !ruby/object:Gem::Dependency
139
+ name: lex-identity-entra
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: 0.3.0
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: 0.3.0
138
152
  - !ruby/object:Gem::Dependency
139
153
  name: snappy
140
154
  requirement: !ruby/object:Gem::Requirement
@@ -182,7 +196,6 @@ files:
182
196
  - lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb
183
197
  - lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb
184
198
  - lib/legion/extensions/microsoft_teams/actors/api_ingest.rb
185
- - lib/legion/extensions/microsoft_teams/actors/auth_validator.rb
186
199
  - lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb
187
200
  - lib/legion/extensions/microsoft_teams/actors/cache_sync.rb
188
201
  - lib/legion/extensions/microsoft_teams/actors/channel_poller.rb
@@ -193,11 +206,7 @@ files:
193
206
  - lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb
194
207
  - lib/legion/extensions/microsoft_teams/actors/presence_poller.rb
195
208
  - lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb
196
- - lib/legion/extensions/microsoft_teams/actors/token_refresher.rb
197
- - lib/legion/extensions/microsoft_teams/cli/auth.rb
198
209
  - lib/legion/extensions/microsoft_teams/client.rb
199
- - lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb
200
- - lib/legion/extensions/microsoft_teams/helpers/callback_server.rb
201
210
  - lib/legion/extensions/microsoft_teams/helpers/client.rb
202
211
  - lib/legion/extensions/microsoft_teams/helpers/graph_client.rb
203
212
  - lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb
@@ -205,24 +214,27 @@ files:
205
214
  - lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb
206
215
  - lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
207
216
  - lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
208
- - lib/legion/extensions/microsoft_teams/helpers/token_cache.rb
209
217
  - lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb
210
218
  - lib/legion/extensions/microsoft_teams/helpers/transform_definitions.rb
211
- - lib/legion/extensions/microsoft_teams/hooks/auth.rb
212
219
  - lib/legion/extensions/microsoft_teams/local_cache/extractor.rb
213
220
  - lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb
214
221
  - lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb
222
+ - lib/legion/extensions/microsoft_teams/runners/activities.rb
215
223
  - lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb
216
224
  - lib/legion/extensions/microsoft_teams/runners/ai_insights.rb
217
225
  - lib/legion/extensions/microsoft_teams/runners/api_ingest.rb
226
+ - lib/legion/extensions/microsoft_teams/runners/app_installations.rb
218
227
  - lib/legion/extensions/microsoft_teams/runners/auth.rb
219
228
  - lib/legion/extensions/microsoft_teams/runners/bot.rb
220
229
  - lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb
230
+ - lib/legion/extensions/microsoft_teams/runners/call_events.rb
221
231
  - lib/legion/extensions/microsoft_teams/runners/channel_messages.rb
222
232
  - lib/legion/extensions/microsoft_teams/runners/channels.rb
223
233
  - lib/legion/extensions/microsoft_teams/runners/chats.rb
234
+ - lib/legion/extensions/microsoft_teams/runners/files.rb
224
235
  - lib/legion/extensions/microsoft_teams/runners/local_cache.rb
225
236
  - lib/legion/extensions/microsoft_teams/runners/loop.rb
237
+ - lib/legion/extensions/microsoft_teams/runners/meeting_artifacts.rb
226
238
  - lib/legion/extensions/microsoft_teams/runners/meetings.rb
227
239
  - lib/legion/extensions/microsoft_teams/runners/messages.rb
228
240
  - lib/legion/extensions/microsoft_teams/runners/ownership.rb
@@ -1,123 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module MicrosoftTeams
6
- module Actor
7
- class AuthValidator < Legion::Extensions::Actors::Once # rubocop:disable Legion/Extension/SelfContainedActorRunnerClass
8
- def use_runner? = false
9
- def check_subtask? = false
10
- def generate_task? = false
11
-
12
- def delay
13
- 90.0
14
- end
15
-
16
- def enabled?
17
- defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
18
- rescue StandardError => e
19
- log.debug("AuthValidator#enabled?: #{e.message}")
20
- false
21
- end
22
-
23
- def token_cache
24
- Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
25
- end
26
-
27
- def manual
28
- log.info('AuthValidator starting')
29
- cache = token_cache
30
- log.debug("Token loaded: authenticated?=#{cache.authenticated?}")
31
-
32
- if cache.authenticated?
33
- token = cache.cached_delegated_token
34
- if token
35
- log.info('Teams delegated auth restored (token valid)')
36
- elsif cache.previously_authenticated? || auto_authenticate?
37
- log.info('Token loaded but expired, attempting browser re-auth')
38
- attempt_browser_reauth(cache)
39
- else
40
- log.debug('Token loaded but expired, no re-auth configured')
41
- end
42
- elsif cache.previously_authenticated?
43
- log.warn('Token file found but could not load, attempting re-authentication')
44
- attempt_browser_reauth(cache)
45
- elsif auto_authenticate?
46
- log.info('auto_authenticate enabled, opening browser for initial authentication...')
47
- attempt_browser_reauth(cache)
48
- else
49
- log.debug('No Teams delegated auth configured, skipping')
50
- end
51
- log.info('AuthValidator complete')
52
- rescue StandardError => e
53
- log.error("AuthValidator: #{e.message}")
54
- end
55
-
56
- private
57
-
58
- def attempt_browser_reauth(cache)
59
- settings = teams_auth_settings
60
- unless settings[:tenant_id] && settings[:client_id]
61
- log.warn("Cannot re-auth: tenant_id=#{settings[:tenant_id] ? 'present' : 'nil'}, client_id=#{settings[:client_id] ? 'present' : 'nil'}")
62
- return false
63
- end
64
-
65
- log.warn('Delegated token expired, opening browser for re-authentication...')
66
-
67
- scopes = settings.dig(:delegated, :scopes) ||
68
- Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
69
- log.debug("Using scopes: #{scopes}")
70
- browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
71
- tenant_id: settings[:tenant_id],
72
- client_id: settings[:client_id],
73
- scopes: scopes
74
- )
75
-
76
- result = browser_auth.authenticate
77
- if result[:error]
78
- log.error("Browser auth returned error: #{result[:error]} - #{result[:description]}")
79
- return false
80
- end
81
-
82
- body = result[:result]
83
- log.info("Browser auth succeeded, storing token (expires_in=#{body['expires_in']})")
84
- cache.store_delegated_token(
85
- access_token: body['access_token'],
86
- refresh_token: body['refresh_token'],
87
- expires_in: body['expires_in'],
88
- scopes: scopes
89
- )
90
- cache.save_to_vault
91
- log.info('Teams delegated auth restored via browser')
92
- true
93
- rescue StandardError => e
94
- log.error("Browser re-auth failed: #{e.message}")
95
- false
96
- end
97
-
98
- def auto_authenticate?
99
- settings = teams_auth_settings
100
- result = settings.dig(:delegated, :auto_authenticate) == true
101
- log.debug("auto_authenticate? => #{result}")
102
- result
103
- end
104
-
105
- def teams_auth_settings
106
- settings = if defined?(Legion::Settings)
107
- ms = Legion::Settings[:microsoft_teams]
108
- auth = ms && ms[:auth].is_a?(Hash) ? ms[:auth].dup : {}
109
- auth[:tenant_id] ||= ms[:tenant_id] if ms
110
- auth[:client_id] ||= ms[:client_id] if ms
111
- auth
112
- else
113
- {}
114
- end
115
- settings[:tenant_id] ||= ENV.fetch('AZURE_TENANT_ID', nil)
116
- settings[:client_id] ||= ENV.fetch('AZURE_CLIENT_ID', nil)
117
- settings
118
- end
119
- end
120
- end
121
- end
122
- end
123
- end
@@ -1,122 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module Extensions
5
- module MicrosoftTeams
6
- module Actor
7
- class TokenRefresher < Legion::Extensions::Actors::Every
8
- DEFAULT_REFRESH_INTERVAL = 900
9
-
10
- def runner_class = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
11
- def runner_function = 'cached_delegated_token'
12
- def run_now? = false
13
- def use_runner? = false
14
- def check_subtask? = false
15
- def generate_task? = false
16
-
17
- def time
18
- settings = teams_auth_settings
19
- delegated = settings[:delegated]
20
- return DEFAULT_REFRESH_INTERVAL unless delegated.is_a?(Hash)
21
-
22
- delegated[:refresh_interval] || DEFAULT_REFRESH_INTERVAL
23
- end
24
-
25
- def enabled?
26
- defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
27
- rescue StandardError => e
28
- log.debug("TokenRefresher#enabled?: #{e.message}")
29
- false
30
- end
31
-
32
- def token_cache
33
- Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
34
- end
35
-
36
- def manual
37
- log.debug('TokenRefresher tick')
38
- unless token_cache.authenticated?
39
- log.debug('No active delegated token, skipping refresh')
40
- return
41
- end
42
-
43
- log.info('Checking delegated token freshness')
44
- token = token_cache.cached_delegated_token
45
- if token
46
- log.info('Delegated token still valid, persisting')
47
- token_cache.save_to_vault
48
- elsif token_cache.previously_authenticated?
49
- log.warn('Delegated token expired, attempting browser re-auth')
50
- attempt_browser_reauth(token_cache)
51
- else
52
- log.warn('Delegated token expired, no previous auth to restore')
53
- end
54
- rescue StandardError => e
55
- log.error("TokenRefresher: #{e.message}")
56
- end
57
-
58
- private
59
-
60
- def attempt_browser_reauth(cache)
61
- settings = teams_auth_settings
62
- unless settings[:tenant_id] && settings[:client_id]
63
- log.warn("Cannot re-auth: tenant_id=#{settings[:tenant_id] ? 'present' : 'nil'}, client_id=#{settings[:client_id] ? 'present' : 'nil'}")
64
- return false
65
- end
66
-
67
- log.warn('Delegated token expired, opening browser for re-authentication...')
68
-
69
- scopes = settings.dig(:delegated, :scopes) ||
70
- Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
71
- log.debug("Using scopes: #{scopes}")
72
- browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
73
- tenant_id: settings[:tenant_id],
74
- client_id: settings[:client_id],
75
- scopes: scopes
76
- )
77
-
78
- result = browser_auth.authenticate
79
- if result[:error]
80
- log.error("Browser auth returned error: #{result[:error]} - #{result[:description]}")
81
- return false
82
- end
83
-
84
- body = result[:result]
85
- log.info("Browser auth succeeded, storing token (expires_in=#{body['expires_in']})")
86
- cache.store_delegated_token(
87
- access_token: body['access_token'],
88
- refresh_token: body['refresh_token'],
89
- expires_in: body['expires_in'],
90
- scopes: scopes
91
- )
92
- cache.save_to_vault
93
- log.info('Teams delegated auth restored via browser')
94
- true
95
- rescue StandardError => e
96
- log.error("Browser re-auth failed: #{e.message}")
97
- false
98
- end
99
-
100
- def teams_auth_settings
101
- settings = if defined?(Legion::Settings)
102
- ms = Legion::Settings[:microsoft_teams]
103
- auth = if ms && ms[:auth].is_a?(Hash)
104
- ms[:auth].dup
105
- else
106
- {}
107
- end
108
- auth[:tenant_id] ||= ms[:tenant_id] if ms
109
- auth[:client_id] ||= ms[:client_id] if ms
110
- auth
111
- else
112
- {}
113
- end
114
- settings[:tenant_id] ||= ENV.fetch('AZURE_TENANT_ID', nil)
115
- settings[:client_id] ||= ENV.fetch('AZURE_CLIENT_ID', nil)
116
- settings
117
- end
118
- end
119
- end
120
- end
121
- end
122
- end
@@ -1,94 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'legion/extensions/microsoft_teams/helpers/browser_auth'
4
- require 'legion/extensions/microsoft_teams/helpers/token_cache'
5
-
6
- module Legion
7
- module Extensions
8
- module MicrosoftTeams
9
- module CLI
10
- class Auth
11
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
12
- Legion::Extensions::Helpers.const_defined?(:Lex, false)
13
-
14
- def self.cli_alias
15
- 'teams'
16
- end
17
-
18
- def self.descriptions
19
- {
20
- login: 'Authenticate with Microsoft Teams via browser OAuth',
21
- status: 'Show current Teams authentication state'
22
- }
23
- end
24
-
25
- def login(tenant_id: nil, client_id: nil)
26
- settings = resolve_settings
27
- tid = tenant_id || settings[:tenant_id] || ENV.fetch('AZURE_TENANT_ID', nil)
28
- cid = client_id || settings[:client_id] || ENV.fetch('AZURE_CLIENT_ID', nil)
29
-
30
- log.debug("Resolved tenant_id=#{tid ? 'present' : 'nil'}, client_id=#{cid ? 'present' : 'nil'}")
31
-
32
- unless tid && cid
33
- puts 'Error: tenant_id and client_id required (set in settings, env vars, or pass as args)'
34
- return
35
- end
36
-
37
- log.info('Starting Teams delegated auth login')
38
- browser_auth = Helpers::BrowserAuth.new(tenant_id: tid, client_id: cid, force_local_server: true)
39
- result = browser_auth.authenticate
40
-
41
- body = result&.dig(:result)
42
- if body&.dig('access_token')
43
- log.info('Authentication successful, storing token')
44
- store_token(body)
45
- puts 'Teams authenticated successfully.'
46
- else
47
- log.warn("Authentication result: #{result&.keys&.join(', ') || 'nil'}")
48
- puts 'Teams authentication failed or was cancelled.'
49
- end
50
- rescue StandardError => e
51
- log.error("Login failed: #{e.message}")
52
- puts "Error: #{e.message}"
53
- end
54
-
55
- def status
56
- token_file = File.expand_path('~/.legionio/tokens/microsoft_teams.json')
57
- if File.exist?(token_file)
58
- log.info("Token file found: #{token_file}")
59
- puts 'Teams: authenticated (token file present)'
60
- else
61
- log.info('No token file found')
62
- puts 'Teams: not authenticated'
63
- end
64
- end
65
-
66
- private
67
-
68
- def resolve_settings
69
- return {} unless defined?(Legion::Settings)
70
-
71
- Legion::Settings[:microsoft_teams]&.dig(:auth) || {}
72
- rescue StandardError => e
73
- log.debug("Auth: resolve_settings failed: #{e.message}")
74
- {}
75
- end
76
-
77
- def store_token(body)
78
- cache = Helpers::TokenCache.instance
79
- cache.store_delegated_token(
80
- access_token: body['access_token'],
81
- refresh_token: body['refresh_token'],
82
- expires_in: body['expires_in'],
83
- scopes: body['scope']
84
- )
85
- cache.save_to_vault
86
- log.info('Token stored successfully')
87
- rescue StandardError => e
88
- log.error("Failed to store token: #{e.message}")
89
- end
90
- end
91
- end
92
- end
93
- end
94
- end
@@ -1,270 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'securerandom'
4
- require 'digest'
5
- require 'base64'
6
- require 'rbconfig'
7
-
8
- require 'legion/extensions/microsoft_teams/runners/auth'
9
- require 'legion/extensions/microsoft_teams/helpers/callback_server'
10
-
11
- module Legion
12
- module Extensions
13
- module MicrosoftTeams
14
- module Helpers
15
- class BrowserAuth
16
- include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
17
- Legion::Extensions::Helpers.const_defined?(:Lex, false)
18
-
19
- DEFAULT_SCOPES = [
20
- 'offline_access', 'openid', 'profile', 'email',
21
- 'User.Read', 'People.Read', 'Presence.Read', 'Presence.Read.All',
22
- 'Chat.Read', 'Chat.ReadBasic', 'ChatMember.Read', 'ChatMessage.Read',
23
- 'Channel.ReadBasic.All', 'ChannelMember.Read.All', 'ChannelMessage.Read.All',
24
- 'Team.ReadBasic.All', 'Group-Conversation.Read.All',
25
- 'OnlineMeetings.Read', 'OnlineMeetingTranscript.Read.All',
26
- 'OnlineMeetingRecording.Read.All', 'OnlineMeetingArtifact.Read.All',
27
- 'OnlineMeetingAiInsight.Read.All', 'CallAiInsights.Read.All',
28
- 'CallEvents.Read', 'CallRecordings.Read.All', 'CallTranscripts.Read.All',
29
- 'TeamsActivity.Read', 'TeamsActivity.Send'
30
- ].join(' ').freeze
31
-
32
- attr_reader :tenant_id, :client_id, :scopes
33
-
34
- def initialize(tenant_id:, client_id:, scopes: DEFAULT_SCOPES, auth: nil, force_local_server: false, **)
35
- @tenant_id = tenant_id
36
- @client_id = client_id
37
- @scopes = scopes
38
- @auth = auth || Object.new.extend(Runners::Auth)
39
- @force_local_server = force_local_server
40
- log.debug("BrowserAuth initialized (tenant=#{tenant_id}, client=#{client_id}, force_local=#{force_local_server})")
41
- end
42
-
43
- def authenticate
44
- if gui_available?
45
- log.info('GUI available, using browser auth')
46
- authenticate_browser
47
- else
48
- log.info('No GUI detected, using device code flow')
49
- authenticate_device_code
50
- end
51
- end
52
-
53
- def api_hook_available?
54
- if @force_local_server
55
- log.debug('api_hook_available? => false (force_local_server)')
56
- return false
57
- end
58
-
59
- api_defined = defined?(Legion::API)
60
- events_defined = defined?(Legion::Events)
61
- hooks_defined = defined?(Legion::Extensions::Hooks::Base)
62
- route_ok = api_defined && events_defined && hooks_defined && hook_route_registered?
63
-
64
- log.debug("api_hook_available? => #{!route_ok.nil?} " \
65
- "(API=#{!api_defined.nil?}, Events=#{!events_defined.nil?}, Hooks=#{!hooks_defined.nil?}, route=#{route_ok})")
66
- !!route_ok
67
- end
68
-
69
- def hook_redirect_uri
70
- port = if defined?(Legion::Settings)
71
- Legion::Settings.dig(:api, :port) || 4567
72
- else
73
- 4567
74
- end
75
- "http://127.0.0.1:#{port}/api/extensions/microsoft_teams/hooks/auth/handle"
76
- end
77
-
78
- def generate_pkce
79
- verifier = SecureRandom.urlsafe_base64(32)
80
- challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false)
81
- log.debug('PKCE challenge generated')
82
- [verifier, challenge]
83
- end
84
-
85
- def gui_available?
86
- os = host_os
87
- return true if /darwin|mswin|mingw/.match?(os)
88
-
89
- !ENV['DISPLAY'].nil? || !ENV['WAYLAND_DISPLAY'].nil?
90
- end
91
-
92
- def open_browser(url)
93
- cmd = case host_os
94
- when /darwin/ then 'open'
95
- when /linux/ then 'xdg-open'
96
- when /mswin|mingw/ then 'start'
97
- end
98
- unless cmd
99
- log.warn('No browser command found for this OS')
100
- return false
101
- end
102
-
103
- log.debug("Opening browser with: #{cmd}")
104
- system(cmd, url)
105
- end
106
-
107
- private
108
-
109
- def host_os
110
- RbConfig::CONFIG['host_os']
111
- end
112
-
113
- def hook_route_registered?
114
- return false unless defined?(Legion::API)
115
-
116
- log.debug("Probing hook route at http://127.0.0.1:#{api_port}/api/extensions/microsoft_teams/hooks/auth/handle")
117
- conn = Faraday.new(url: "http://127.0.0.1:#{api_port}")
118
- resp = conn.head('/api/extensions/microsoft_teams/hooks/auth/handle')
119
- registered = resp.status != 404
120
- log.debug("Hook route probe returned #{resp.status} (registered=#{registered})")
121
- registered
122
- rescue StandardError => e
123
- log.debug("Hook route probe failed: #{e.message}")
124
- false
125
- end
126
-
127
- def api_port
128
- if defined?(Legion::Settings)
129
- Legion::Settings.dig(:api, :port) || 4567
130
- else
131
- 4567
132
- end
133
- end
134
-
135
- def authenticate_browser
136
- verifier, challenge = generate_pkce
137
- state = SecureRandom.hex(32)
138
-
139
- if api_hook_available?
140
- log.info('Using API hook for OAuth callback')
141
- authenticate_via_hook(verifier: verifier, challenge: challenge, state: state)
142
- else
143
- log.info('Using local callback server for OAuth callback')
144
- authenticate_via_server(verifier: verifier, challenge: challenge, state: state)
145
- end
146
- end
147
-
148
- def authenticate_via_hook(verifier:, challenge:, state:)
149
- callback_uri = hook_redirect_uri
150
- log.debug("Hook callback URI: #{callback_uri}")
151
- result_holder = { result: nil }
152
- mutex = Mutex.new
153
- cv = ConditionVariable.new
154
-
155
- listener = Legion::Events.once('microsoft_teams.oauth.callback') do |event|
156
- log.debug('OAuth callback event received via Legion::Events')
157
- mutex.synchronize do
158
- result_holder[:result] = event
159
- cv.broadcast
160
- end
161
- end
162
-
163
- url = @auth.authorize_url(
164
- tenant_id: tenant_id, client_id: client_id,
165
- redirect_uri: callback_uri, scope: scopes,
166
- state: state, code_challenge: challenge
167
- )
168
-
169
- log.info('Opening browser for authentication (using API hook)...')
170
- unless open_browser(url)
171
- Legion::Events.off('microsoft_teams.oauth.callback', listener)
172
- log.warn('Could not open browser. Falling back to device code flow.')
173
- return authenticate_device_code
174
- end
175
-
176
- log.debug('Waiting for OAuth callback (timeout=120s)...')
177
- mutex.synchronize { cv.wait(mutex, 120) unless result_holder[:result] }
178
- result = result_holder[:result]
179
-
180
- unless result && result[:code]
181
- log.error('OAuth callback timed out or missing code')
182
- return { error: 'timeout', description: 'No callback received within timeout' }
183
- end
184
-
185
- unless result[:state] == state
186
- log.error('OAuth state mismatch (possible CSRF)')
187
- return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' }
188
- end
189
-
190
- log.info('Exchanging authorization code for tokens (via hook)')
191
- @auth.exchange_code(
192
- tenant_id: tenant_id, client_id: client_id,
193
- code: result[:code], redirect_uri: callback_uri,
194
- code_verifier: verifier, scope: scopes
195
- )
196
- end
197
-
198
- def authenticate_via_server(verifier:, challenge:, state:)
199
- server = CallbackServer.new
200
- server.start
201
- callback_uri = server.redirect_uri
202
- log.info("Local callback server started on #{callback_uri}")
203
-
204
- url = @auth.authorize_url(
205
- tenant_id: tenant_id, client_id: client_id,
206
- redirect_uri: callback_uri, scope: scopes,
207
- state: state, code_challenge: challenge
208
- )
209
-
210
- log.info("Opening browser for authentication (callback: #{callback_uri})...")
211
- unless open_browser(url)
212
- log.warn('Could not open browser. Falling back to device code flow.')
213
- return authenticate_device_code
214
- end
215
-
216
- log.debug('Waiting for OAuth callback on local server (timeout=120s)...')
217
- result = server.wait_for_callback(timeout: 120)
218
-
219
- unless result && result[:code]
220
- log.error('OAuth callback timed out or missing code')
221
- return { error: 'timeout', description: 'No callback received within timeout' }
222
- end
223
-
224
- unless result[:state] == state
225
- log.error('OAuth state mismatch (possible CSRF)')
226
- return { error: 'state_mismatch', description: 'CSRF state parameter mismatch' }
227
- end
228
-
229
- log.info('Exchanging authorization code for tokens (via local server)')
230
- @auth.exchange_code(
231
- tenant_id: tenant_id, client_id: client_id,
232
- code: result[:code], redirect_uri: callback_uri,
233
- code_verifier: verifier, scope: scopes
234
- )
235
- ensure
236
- server&.shutdown
237
- log.debug('Local callback server shut down')
238
- end
239
-
240
- def authenticate_device_code
241
- log.info('Starting device code flow')
242
- dc = @auth.request_device_code(
243
- tenant_id: tenant_id,
244
- client_id: client_id,
245
- scope: scopes
246
- )
247
- if dc[:error]
248
- log.error("Device code request failed: #{dc[:error]} - #{dc[:description]}")
249
- return { error: dc[:error], description: dc[:description] }
250
- end
251
-
252
- body = dc[:result]
253
-
254
- log.info("Go to: #{body['verification_uri']}")
255
- log.info("Code: #{body['user_code']}")
256
-
257
- open_browser(body['verification_uri']) if gui_available?
258
-
259
- log.debug('Polling for device code authorization...')
260
- @auth.poll_device_code(
261
- tenant_id: tenant_id,
262
- client_id: client_id,
263
- device_code: body['device_code']
264
- )
265
- end
266
- end
267
- end
268
- end
269
- end
270
- end