lex-microsoft_teams 0.5.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 (53) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +15 -0
  4. data/.rspec +3 -0
  5. data/.rubocop.yml +56 -0
  6. data/CHANGELOG.md +59 -0
  7. data/CLAUDE.md +206 -0
  8. data/Dockerfile +6 -0
  9. data/Gemfile +12 -0
  10. data/Gemfile.lock +103 -0
  11. data/LICENSE +21 -0
  12. data/README.md +183 -0
  13. data/docs/plans/2026-03-15-meetings-transcripts-design.md +506 -0
  14. data/docs/plans/2026-03-16-delegated-oauth-browser-flow-design.md +198 -0
  15. data/docs/plans/2026-03-16-delegated-oauth-browser-flow-plan.md +1176 -0
  16. data/lex-microsoft_teams.gemspec +32 -0
  17. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +41 -0
  18. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +54 -0
  19. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +105 -0
  20. data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +23 -0
  21. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +111 -0
  22. data/lib/legion/extensions/microsoft_teams/client.rb +68 -0
  23. data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +139 -0
  24. data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +82 -0
  25. data/lib/legion/extensions/microsoft_teams/helpers/client.rb +38 -0
  26. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +59 -0
  27. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +64 -0
  28. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +150 -0
  29. data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +140 -0
  30. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +209 -0
  31. data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +258 -0
  32. data/lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb +199 -0
  33. data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +121 -0
  34. data/lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb +59 -0
  35. data/lib/legion/extensions/microsoft_teams/runners/auth.rb +116 -0
  36. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +409 -0
  37. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +122 -0
  38. data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +52 -0
  39. data/lib/legion/extensions/microsoft_teams/runners/channels.rb +53 -0
  40. data/lib/legion/extensions/microsoft_teams/runners/chats.rb +51 -0
  41. data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +62 -0
  42. data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +68 -0
  43. data/lib/legion/extensions/microsoft_teams/runners/messages.rb +48 -0
  44. data/lib/legion/extensions/microsoft_teams/runners/presence.rb +31 -0
  45. data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +76 -0
  46. data/lib/legion/extensions/microsoft_teams/runners/teams.rb +33 -0
  47. data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +45 -0
  48. data/lib/legion/extensions/microsoft_teams/transport/exchanges/messages.rb +15 -0
  49. data/lib/legion/extensions/microsoft_teams/transport/messages/teams_message.rb +16 -0
  50. data/lib/legion/extensions/microsoft_teams/transport/queues/messages_process.rb +16 -0
  51. data/lib/legion/extensions/microsoft_teams/version.rb +9 -0
  52. data/lib/legion/extensions/microsoft_teams.rb +44 -0
  53. metadata +139 -0
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module MicrosoftTeams
8
+ module Helpers
9
+ module Client
10
+ def graph_connection(token: nil, api_url: 'https://graph.microsoft.com/v1.0', **_opts)
11
+ Faraday.new(url: api_url) do |conn|
12
+ conn.request :json
13
+ conn.response :json, content_type: /\bjson$/
14
+ conn.headers['Authorization'] = "Bearer #{token}" if token
15
+ conn.headers['Content-Type'] = 'application/json'
16
+ end
17
+ end
18
+
19
+ def bot_connection(token: nil, service_url: 'https://smba.trafficmanager.net/teams/', **_opts)
20
+ Faraday.new(url: service_url) do |conn|
21
+ conn.request :json
22
+ conn.response :json, content_type: /\bjson$/
23
+ conn.headers['Authorization'] = "Bearer #{token}" if token
24
+ conn.headers['Content-Type'] = 'application/json'
25
+ end
26
+ end
27
+
28
+ def oauth_connection(tenant_id: 'common', **_opts)
29
+ Faraday.new(url: "https://login.microsoftonline.com/#{tenant_id}") do |conn|
30
+ conn.request :url_encoded
31
+ conn.response :json, content_type: /\bjson$/
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Helpers
7
+ module HighWaterMark
8
+ HWM_TTL = 86_400 # 24 hours
9
+
10
+ def hwm_key(chat_id:)
11
+ "teams:hwm:#{chat_id}"
12
+ end
13
+
14
+ def get_hwm(chat_id:)
15
+ key = hwm_key(chat_id: chat_id)
16
+ if cache_available?
17
+ Legion::Cache.get(key)
18
+ else
19
+ @hwm_fallback ||= {}
20
+ @hwm_fallback[key]
21
+ end
22
+ end
23
+
24
+ def set_hwm(chat_id:, timestamp:)
25
+ key = hwm_key(chat_id: chat_id)
26
+ if cache_available?
27
+ Legion::Cache.set(key, timestamp, HWM_TTL)
28
+ else
29
+ @hwm_fallback ||= {}
30
+ @hwm_fallback[key] = timestamp
31
+ end
32
+ end
33
+
34
+ def new_messages(chat_id:, messages:)
35
+ hwm = get_hwm(chat_id: chat_id)
36
+ return messages if hwm.nil?
37
+
38
+ messages.select { |m| m[:createdDateTime] > hwm }
39
+ end
40
+
41
+ def update_hwm_from_messages(chat_id:, messages:)
42
+ return if messages.empty?
43
+
44
+ latest = messages.map { |m| m[:createdDateTime] }.max
45
+ set_hwm(chat_id: chat_id, timestamp: latest)
46
+ end
47
+
48
+ private
49
+
50
+ def cache_available?
51
+ defined?(Legion::Cache) &&
52
+ Legion::Cache.respond_to?(:connected?) &&
53
+ Legion::Cache.connected?
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Helpers
7
+ module PromptResolver
8
+ def resolve_prompt(mode:, conversation_id:, owner_id: nil)
9
+ settings = teams_settings
10
+ base = settings.dig(:bot, :system_prompt) || ''
11
+
12
+ mode_prompt = settings.dig(:bot, mode, :system_prompt)
13
+ prompt = mode_prompt || base
14
+
15
+ overrides = conversation_overrides(conversation_id: conversation_id)
16
+ prompt = "#{prompt}\n\n#{overrides[:system_prompt_append]}" if overrides && overrides[:system_prompt_append]
17
+
18
+ pref_instructions = preference_instructions_for(owner_id: owner_id)
19
+ prompt = "#{prompt}\n\n#{pref_instructions}" if pref_instructions
20
+
21
+ prompt
22
+ end
23
+
24
+ def resolve_llm_config(conversation_id:, mode: nil, owner_id: nil) # rubocop:disable Lint/UnusedMethodArgument
25
+ settings = teams_settings
26
+ base_llm = settings.dig(:bot, :llm) || {}
27
+
28
+ overrides = conversation_overrides(conversation_id: conversation_id)
29
+ override_llm = overrides&.dig(:llm) || {}
30
+
31
+ base_llm.merge(override_llm)
32
+ end
33
+
34
+ private
35
+
36
+ def teams_settings
37
+ if defined?(Legion::Settings) && Legion::Settings[:microsoft_teams]
38
+ Legion::Settings[:microsoft_teams]
39
+ else
40
+ { bot: {} }
41
+ end
42
+ end
43
+
44
+ def conversation_overrides(conversation_id: nil)
45
+ return nil unless conversation_id
46
+ return nil unless defined?(Legion::Extensions::Memory::Runners::Traces)
47
+
48
+ nil # TODO: query lex-memory for conversation_config by conversation_id
49
+ end
50
+
51
+ def preference_instructions_for(owner_id:)
52
+ return nil unless owner_id
53
+ return nil unless defined?(Legion::Extensions::Mesh::Helpers::PreferenceProfile)
54
+
55
+ profile = Legion::Extensions::Mesh::Helpers::PreferenceProfile.resolve(owner_id: owner_id)
56
+ Legion::Extensions::Mesh::Helpers::PreferenceProfile.preference_instructions(profile: profile)
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'prompt_resolver'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module MicrosoftTeams
8
+ module Helpers
9
+ class SessionManager
10
+ include PromptResolver
11
+
12
+ DEFAULT_FLUSH_THRESHOLD = 20
13
+ DEFAULT_IDLE_TIMEOUT = 900
14
+ DEFAULT_MAX_RECENT = 5
15
+
16
+ def initialize(flush_threshold: nil, idle_timeout: nil, max_recent: nil)
17
+ @sessions = {}
18
+ @flush_threshold = flush_threshold || settings_val(:flush_threshold, DEFAULT_FLUSH_THRESHOLD)
19
+ @idle_timeout = idle_timeout || settings_val(:idle_timeout, DEFAULT_IDLE_TIMEOUT)
20
+ @max_recent = max_recent || settings_val(:max_recent_messages, DEFAULT_MAX_RECENT)
21
+ end
22
+
23
+ def get_or_create(conversation_id:, owner_id:, mode:)
24
+ return @sessions[conversation_id] if @sessions.key?(conversation_id)
25
+
26
+ session = {
27
+ owner_id: owner_id,
28
+ mode: mode,
29
+ message_count: 0,
30
+ last_active: Time.now,
31
+ messages: [],
32
+ system_prompt: resolve_prompt(mode: mode, conversation_id: conversation_id, owner_id: owner_id),
33
+ llm_config: resolve_llm_config(mode: mode, conversation_id: conversation_id, owner_id: owner_id)
34
+ }
35
+
36
+ @sessions[conversation_id] = session
37
+ end
38
+
39
+ def refresh_prompt(conversation_id:)
40
+ return nil unless @sessions.key?(conversation_id)
41
+
42
+ session = @sessions[conversation_id]
43
+ session[:system_prompt] = resolve_prompt(
44
+ mode: session[:mode], conversation_id: conversation_id, owner_id: session[:owner_id]
45
+ )
46
+ session[:system_prompt]
47
+ end
48
+
49
+ def touch(conversation_id:)
50
+ return unless @sessions.key?(conversation_id)
51
+
52
+ @sessions[conversation_id][:message_count] += 1
53
+ @sessions[conversation_id][:last_active] = Time.now
54
+ end
55
+
56
+ def add_message(conversation_id:, role:, content:)
57
+ return unless @sessions.key?(conversation_id)
58
+
59
+ @sessions[conversation_id][:messages] << { role: role, content: content, at: Time.now }
60
+ end
61
+
62
+ def recent_messages(conversation_id:, count: nil)
63
+ return [] unless @sessions.key?(conversation_id)
64
+
65
+ msgs = @sessions[conversation_id][:messages]
66
+ msgs.last(count || @max_recent)
67
+ end
68
+
69
+ def should_flush?(conversation_id:)
70
+ return false unless @sessions.key?(conversation_id)
71
+
72
+ @sessions[conversation_id][:message_count] >= @flush_threshold
73
+ end
74
+
75
+ def persist(conversation_id:)
76
+ return unless @sessions.key?(conversation_id)
77
+
78
+ session = @sessions[conversation_id]
79
+ store_session_to_memory(conversation_id: conversation_id, session: session)
80
+ session[:message_count] = 0
81
+ end
82
+
83
+ def flush_idle(timeout: nil)
84
+ timeout ||= @idle_timeout
85
+ cutoff = Time.now - timeout
86
+ flushed = []
87
+
88
+ @sessions.each do |conv_id, session|
89
+ next unless session[:last_active] < cutoff
90
+
91
+ persist(conversation_id: conv_id)
92
+ flushed << conv_id
93
+ end
94
+
95
+ flushed.each { |conv_id| @sessions.delete(conv_id) }
96
+ flushed
97
+ end
98
+
99
+ def shutdown
100
+ @sessions.each_key { |conv_id| persist(conversation_id: conv_id) }
101
+ @sessions.clear
102
+ end
103
+
104
+ def active_sessions
105
+ @sessions.size
106
+ end
107
+
108
+ private
109
+
110
+ def store_session_to_memory(conversation_id:, session:)
111
+ return unless defined?(Legion::Extensions::Memory::Runners::Traces)
112
+
113
+ memory_runner.store_trace(
114
+ type: :episodic,
115
+ content_payload: {
116
+ type: :bot_session,
117
+ conversation_id: conversation_id,
118
+ owner_id: session[:owner_id],
119
+ recent_messages: session[:messages].last(@max_recent),
120
+ message_count: session[:message_count],
121
+ last_active: session[:last_active].iso8601
122
+ }.to_s,
123
+ domain_tags: ['teams', 'bot-session', "conv:#{conversation_id}"],
124
+ origin: :direct_experience,
125
+ confidence: 0.8
126
+ )
127
+ rescue StandardError => e
128
+ log_error("SessionManager persist failed: #{e.message}")
129
+ end
130
+
131
+ def memory_runner
132
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
133
+ end
134
+
135
+ def settings_val(key, default)
136
+ if defined?(Legion::Settings) && Legion::Settings.dig(:microsoft_teams, :bot, :session, key)
137
+ Legion::Settings[:microsoft_teams][:bot][:session][key]
138
+ else
139
+ default
140
+ end
141
+ end
142
+
143
+ def log_error(msg)
144
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module MicrosoftTeams
8
+ module Helpers
9
+ class SubscriptionRegistry
10
+ MEMORY_KEY = 'teams_bot_subscriptions'
11
+
12
+ def initialize
13
+ @subscriptions = {}
14
+ @mutex = Mutex.new
15
+ end
16
+
17
+ def subscribe(owner_id:, chat_id:, peer_name:)
18
+ @mutex.synchronize do
19
+ return if @subscriptions.key?(chat_id)
20
+
21
+ @subscriptions[chat_id] = {
22
+ owner_id: owner_id,
23
+ peer_name: peer_name,
24
+ enabled: true,
25
+ notify: true,
26
+ created_at: Time.now
27
+ }
28
+ end
29
+ persist
30
+ end
31
+
32
+ def unsubscribe(owner_id:, chat_id:) # rubocop:disable Lint/UnusedMethodArgument
33
+ @mutex.synchronize { @subscriptions.delete(chat_id) }
34
+ persist
35
+ end
36
+
37
+ def list(owner_id:)
38
+ @mutex.synchronize do
39
+ @subscriptions.select { |_, v| v[:owner_id] == owner_id }
40
+ .map { |chat_id, v| v.merge(chat_id: chat_id) }
41
+ end
42
+ end
43
+
44
+ def pause(owner_id:, chat_id:) # rubocop:disable Lint/UnusedMethodArgument
45
+ @mutex.synchronize do
46
+ @subscriptions[chat_id][:enabled] = false if @subscriptions.key?(chat_id)
47
+ end
48
+ persist
49
+ end
50
+
51
+ def resume(owner_id:, chat_id:) # rubocop:disable Lint/UnusedMethodArgument
52
+ @mutex.synchronize do
53
+ @subscriptions[chat_id][:enabled] = true if @subscriptions.key?(chat_id)
54
+ end
55
+ persist
56
+ end
57
+
58
+ def active_subscriptions
59
+ @mutex.synchronize do
60
+ @subscriptions.select { |_, v| v[:enabled] }
61
+ .map { |chat_id, v| v.merge(chat_id: chat_id) }
62
+ end
63
+ end
64
+
65
+ def find_by_peer_name(owner_id:, peer_name:)
66
+ @mutex.synchronize do
67
+ @subscriptions.each do |chat_id, v|
68
+ next unless v[:owner_id] == owner_id
69
+ next unless v[:peer_name].downcase == peer_name.downcase
70
+
71
+ return v.merge(chat_id: chat_id)
72
+ end
73
+ nil
74
+ end
75
+ end
76
+
77
+ def load
78
+ return unless memory_available?
79
+
80
+ stored = memory_runner.recall_trace(domain_tags: [MEMORY_KEY])
81
+ return unless stored&.dig(:content_payload)
82
+
83
+ parsed = parse_stored(stored[:content_payload])
84
+ @mutex.synchronize { @subscriptions = parsed } if parsed.is_a?(Hash)
85
+ rescue StandardError => e
86
+ log_error("SubscriptionRegistry load failed: #{e.message}")
87
+ end
88
+
89
+ def persist
90
+ return unless memory_available?
91
+
92
+ memory_runner.store_trace(
93
+ type: :semantic,
94
+ content_payload: serialize_subscriptions,
95
+ domain_tags: [MEMORY_KEY],
96
+ origin: :system,
97
+ confidence: 1.0
98
+ )
99
+ rescue StandardError => e
100
+ log_error("SubscriptionRegistry persist failed: #{e.message}")
101
+ end
102
+
103
+ private
104
+
105
+ def serialize_subscriptions
106
+ serializable = @subscriptions.transform_values do |v|
107
+ v.merge(created_at: v[:created_at]&.iso8601)
108
+ end
109
+ ::JSON.generate(serializable)
110
+ end
111
+
112
+ def parse_stored(payload)
113
+ return payload if payload.is_a?(Hash)
114
+ return {} unless payload.is_a?(String)
115
+
116
+ parsed = ::JSON.parse(payload, symbolize_names: true)
117
+ parsed.transform_values do |v|
118
+ v[:created_at] = Time.parse(v[:created_at]) if v[:created_at].is_a?(String)
119
+ v
120
+ end
121
+ rescue ::JSON::ParserError
122
+ {}
123
+ end
124
+
125
+ def memory_available?
126
+ defined?(Legion::Extensions::Memory::Runners::Traces)
127
+ end
128
+
129
+ def memory_runner
130
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
131
+ end
132
+
133
+ def log_error(msg)
134
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'legion/extensions/microsoft_teams/runners/auth'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module MicrosoftTeams
9
+ module Helpers
10
+ class TokenCache
11
+ REFRESH_BUFFER = 60
12
+ DEFAULT_VAULT_PATH = 'legionio/microsoft_teams/delegated_token'
13
+
14
+ def initialize
15
+ @token_cache = nil
16
+ @delegated_cache = nil
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # --- Application token (client_credentials) ---
21
+
22
+ def cached_graph_token
23
+ @mutex.synchronize do
24
+ return @token_cache[:token] if @token_cache && !token_expired?(@token_cache)
25
+
26
+ refresh_app_token
27
+ end
28
+ end
29
+
30
+ def clear_token_cache!
31
+ @mutex.synchronize { @token_cache = nil }
32
+ end
33
+
34
+ # --- Delegated token (user auth) ---
35
+
36
+ def cached_delegated_token
37
+ @mutex.synchronize do
38
+ return nil unless @delegated_cache
39
+
40
+ return @delegated_cache[:token] unless token_expired?(@delegated_cache)
41
+
42
+ refresh_delegated
43
+ end
44
+ end
45
+
46
+ def store_delegated_token(access_token:, refresh_token:, expires_in:, scopes:)
47
+ @mutex.synchronize do
48
+ @delegated_cache = {
49
+ token: access_token,
50
+ refresh_token: refresh_token,
51
+ expires_at: Time.now + expires_in.to_i,
52
+ scopes: scopes
53
+ }
54
+ end
55
+ end
56
+
57
+ def clear_delegated_token!
58
+ @mutex.synchronize { @delegated_cache = nil }
59
+ end
60
+
61
+ def load_from_vault
62
+ return false unless defined?(Legion::Crypt)
63
+
64
+ data = Legion::Crypt.get(vault_path)
65
+ return false unless data && data[:access_token]
66
+
67
+ @mutex.synchronize do
68
+ @delegated_cache = {
69
+ token: data[:access_token],
70
+ refresh_token: data[:refresh_token],
71
+ expires_at: Time.parse(data[:expires_at]),
72
+ scopes: data[:scopes]
73
+ }
74
+ end
75
+ true
76
+ rescue StandardError => e
77
+ log_error("Failed to load delegated token from Vault: #{e.message}")
78
+ false
79
+ end
80
+
81
+ def save_to_vault
82
+ return false unless defined?(Legion::Crypt)
83
+
84
+ data = @mutex.synchronize { @delegated_cache&.dup }
85
+ return false unless data
86
+
87
+ Legion::Crypt.write(vault_path,
88
+ access_token: data[:token],
89
+ refresh_token: data[:refresh_token],
90
+ expires_at: data[:expires_at].utc.iso8601,
91
+ scopes: data[:scopes])
92
+ true
93
+ rescue StandardError => e
94
+ log_error("Failed to save delegated token to Vault: #{e.message}")
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def token_expired?(cache_entry)
101
+ return true unless cache_entry
102
+
103
+ buffer = delegated_refresh_buffer
104
+ Time.now >= (cache_entry[:expires_at] - buffer)
105
+ end
106
+
107
+ def delegated_refresh_buffer
108
+ settings = teams_auth_settings
109
+ delegated = settings[:delegated]
110
+ return REFRESH_BUFFER unless delegated.is_a?(Hash)
111
+
112
+ delegated[:refresh_buffer] || REFRESH_BUFFER
113
+ end
114
+
115
+ def vault_path
116
+ settings = teams_auth_settings
117
+ delegated = settings[:delegated]
118
+ return DEFAULT_VAULT_PATH unless delegated.is_a?(Hash)
119
+
120
+ delegated[:vault_path] || DEFAULT_VAULT_PATH
121
+ end
122
+
123
+ def refresh_app_token
124
+ result = acquire_fresh_token
125
+ return nil unless result
126
+
127
+ access_token = result.dig(:result, 'access_token')
128
+ expires_in = result.dig(:result, 'expires_in') || 3600
129
+
130
+ @token_cache = {
131
+ token: access_token,
132
+ expires_at: Time.now + expires_in
133
+ }
134
+
135
+ access_token
136
+ rescue StandardError => e
137
+ log_error("TokenCache app refresh failed: #{e.message}")
138
+ nil
139
+ end
140
+
141
+ def refresh_delegated
142
+ return nil unless @delegated_cache&.dig(:refresh_token)
143
+
144
+ settings = teams_auth_settings
145
+ return nil unless settings[:tenant_id] && settings[:client_id]
146
+
147
+ auth = Object.new.extend(Legion::Extensions::MicrosoftTeams::Runners::Auth)
148
+ result = auth.refresh_delegated_token(
149
+ tenant_id: settings[:tenant_id],
150
+ client_id: settings[:client_id],
151
+ refresh_token: @delegated_cache[:refresh_token],
152
+ scope: @delegated_cache[:scopes]
153
+ )
154
+
155
+ body = result[:result]
156
+ return handle_refresh_failure(result) unless body&.dig('access_token')
157
+
158
+ @delegated_cache = {
159
+ token: body['access_token'],
160
+ refresh_token: body['refresh_token'] || @delegated_cache[:refresh_token],
161
+ expires_at: Time.now + (body['expires_in'] || 3600).to_i,
162
+ scopes: @delegated_cache[:scopes]
163
+ }
164
+
165
+ save_to_vault
166
+ @delegated_cache[:token]
167
+ rescue StandardError => e
168
+ log_error("TokenCache delegated refresh failed: #{e.message}")
169
+ nil
170
+ end
171
+
172
+ def handle_refresh_failure(result)
173
+ if result[:error] == 'invalid_grant'
174
+ @delegated_cache = nil
175
+ emit_expired_event
176
+ end
177
+ nil
178
+ end
179
+
180
+ def emit_expired_event
181
+ Legion::Events.emit('microsoft_teams.auth.expired') if defined?(Legion::Events)
182
+ end
183
+
184
+ def acquire_fresh_token
185
+ settings = teams_auth_settings
186
+ return nil unless settings[:tenant_id] && settings[:client_id] && settings[:client_secret]
187
+
188
+ auth = Object.new.extend(Legion::Extensions::MicrosoftTeams::Runners::Auth)
189
+ auth.acquire_token(
190
+ tenant_id: settings[:tenant_id],
191
+ client_id: settings[:client_id],
192
+ client_secret: settings[:client_secret]
193
+ )
194
+ end
195
+
196
+ def teams_auth_settings
197
+ return {} unless defined?(Legion::Settings)
198
+
199
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
200
+ end
201
+
202
+ def log_error(msg)
203
+ Legion::Logging.error(msg) if defined?(Legion::Logging)
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end