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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +56 -0
- data/CHANGELOG.md +59 -0
- data/CLAUDE.md +206 -0
- data/Dockerfile +6 -0
- data/Gemfile +12 -0
- data/Gemfile.lock +103 -0
- data/LICENSE +21 -0
- data/README.md +183 -0
- data/docs/plans/2026-03-15-meetings-transcripts-design.md +506 -0
- data/docs/plans/2026-03-16-delegated-oauth-browser-flow-design.md +198 -0
- data/docs/plans/2026-03-16-delegated-oauth-browser-flow-plan.md +1176 -0
- data/lex-microsoft_teams.gemspec +32 -0
- data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +41 -0
- data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +54 -0
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +105 -0
- data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +23 -0
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +111 -0
- data/lib/legion/extensions/microsoft_teams/client.rb +68 -0
- data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +139 -0
- data/lib/legion/extensions/microsoft_teams/helpers/callback_server.rb +82 -0
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +38 -0
- data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +59 -0
- data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +64 -0
- data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +150 -0
- data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +140 -0
- data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +209 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +258 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/record_parser.rb +199 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +121 -0
- data/lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb +59 -0
- data/lib/legion/extensions/microsoft_teams/runners/auth.rb +116 -0
- data/lib/legion/extensions/microsoft_teams/runners/bot.rb +409 -0
- data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +122 -0
- data/lib/legion/extensions/microsoft_teams/runners/channel_messages.rb +52 -0
- data/lib/legion/extensions/microsoft_teams/runners/channels.rb +53 -0
- data/lib/legion/extensions/microsoft_teams/runners/chats.rb +51 -0
- data/lib/legion/extensions/microsoft_teams/runners/local_cache.rb +62 -0
- data/lib/legion/extensions/microsoft_teams/runners/meetings.rb +68 -0
- data/lib/legion/extensions/microsoft_teams/runners/messages.rb +48 -0
- data/lib/legion/extensions/microsoft_teams/runners/presence.rb +31 -0
- data/lib/legion/extensions/microsoft_teams/runners/subscriptions.rb +76 -0
- data/lib/legion/extensions/microsoft_teams/runners/teams.rb +33 -0
- data/lib/legion/extensions/microsoft_teams/runners/transcripts.rb +45 -0
- data/lib/legion/extensions/microsoft_teams/transport/exchanges/messages.rb +15 -0
- data/lib/legion/extensions/microsoft_teams/transport/messages/teams_message.rb +16 -0
- data/lib/legion/extensions/microsoft_teams/transport/queues/messages_process.rb +16 -0
- data/lib/legion/extensions/microsoft_teams/version.rb +9 -0
- data/lib/legion/extensions/microsoft_teams.rb +44 -0
- 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
|