lex-microsoft_teams 0.6.4 → 0.6.6

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 (23) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +41 -20
  4. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +1 -1
  5. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +1 -1
  6. data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +193 -0
  7. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +22 -4
  8. data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +184 -0
  9. data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +1 -1
  10. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +1 -1
  11. data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +78 -0
  12. data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +35 -9
  13. data/lib/legion/extensions/microsoft_teams/cli/auth.rb +47 -6
  14. data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +119 -14
  15. data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +2 -2
  16. data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +4 -3
  17. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +168 -36
  18. data/lib/legion/extensions/microsoft_teams/runners/ai_insights.rb +48 -0
  19. data/lib/legion/extensions/microsoft_teams/runners/bot.rb +11 -2
  20. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +4 -4
  21. data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
  22. data/lib/legion/extensions/microsoft_teams.rb +1 -0
  23. metadata +5 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fccc37def8926f79ed1626030932012a3ac6875ee711b577c7ccd027a1158b4
4
- data.tar.gz: 251f6d348cc76c15d226090375f121bac73c5e1b0de95bd70ea9bc975a4e93a9
3
+ metadata.gz: fd2d2ac7556a1fcc52771005868ce843b3a8fef46dc9976be60a7eb012213bdc
4
+ data.tar.gz: 07d0d60609364d9a622bc26976b7c34e2a18fa5fba8c0b519873613d106cfb9d
5
5
  SHA512:
6
- metadata.gz: 3b326a71937fd36b9c98385b41d21f9600ce9ab9b28e7f91261e85b3bb117af6a84365c47d86152f67b34260f8068c1d1d8891abe82925e204f89380f2080e4e
7
- data.tar.gz: 4ba5d4507933666d677e8d53bf1100556d88ad7abc7617b6b20b854419a2951f4de569abcb3faea4052d0c3bbe0d8eba6b4c96f85ad36125d1bc412ed8573f89
6
+ metadata.gz: 94583b4e0b8fa58b1d5fbb49a851859a42cb22078debb5379cc68f44e5e0fa61da55aca0ab469791a4ee72c0bf763526fe69e7473186a6dbf3deb9395b4d5298
7
+ data.tar.gz: 7f6971571bc4fde09bf690a922b5aa262fdb2b314e2cffd261530b77643f124be67336d2d6008c1bf14d3ae493d936fa79ab38ece856f62774489ee4427d841d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.6] - 2026-03-22
4
+
5
+ ### Added
6
+ - `Bot.dispatch_message` routes AMQP messages by mode (direct -> handle_message, observe -> observe_message)
7
+ - MeetingIngest stores transcripts as episodic traces and AI insights as semantic traces in lex-agentic-memory
8
+ - ChannelPoller stores new channel messages as episodic traces in lex-agentic-memory
9
+ - INFO-level poll logging in MeetingIngest and ChannelPoller for visibility
10
+
11
+ ### Changed
12
+ - MessageProcessor actor now calls `dispatch_message` instead of `handle_message` directly
13
+
14
+ ## [0.6.5] - 2026-03-22
15
+
16
+ ### Added
17
+ - `Actors::ChannelPoller` (Every, 60s): polls joined team channels for new messages with HWM dedup
18
+ - `Actors::MeetingIngest` (Every, 5min): polls online meetings, fetches transcripts (VTT) and AI insights
19
+ - `Actors::PresencePoller` (Every, 60s): polls Graph API presence, logs changes at INFO
20
+ - `Runners::AiInsights` for Graph API meeting AI insights, recordings, and call records
21
+ - All 28 Entra delegated permission scopes in `BrowserAuth::DEFAULT_SCOPES`
22
+ - Comprehensive tagged logging throughout auth, token, and poller lifecycles
23
+ - `TokenCache.instance` singleton pattern for shared token state across all actors
24
+ - `force_local_server` option in `BrowserAuth` for CLI OAuth flow
25
+ - `hook_route_registered?` HTTP probe for daemon OAuth callback detection
26
+ - Environment variable fallback (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`) in CLI and actors
27
+
28
+ ### Fixed
29
+ - Fix memory namespace: `Legion::Extensions::Memory::*` -> `Legion::Extensions::Agentic::Memory::Trace::*` across 6 files
30
+ - Fix `SubscriptionRegistry` using nonexistent `recall_trace` method, now uses `retrieve_by_domain`
31
+ - Fix Vault write attempts when `crypt.vault.enabled` is false (added `vault_available?` guard)
32
+ - Fix token not shared across actors (each created own `TokenCache.new` instead of singleton)
33
+ - Fix app token warning spam with warn-once pattern and delegated token fallback
34
+
35
+ ### Changed
36
+ - Updated `AuthValidator` spec to match rewritten `manual` method logic
37
+
3
38
  ## [0.6.4] - 2026-03-22
4
39
 
5
40
  ### Added
@@ -20,28 +20,34 @@ module Legion
20
20
  end
21
21
 
22
22
  def token_cache
23
- @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
23
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
24
24
  end
25
25
 
26
26
  def manual
27
- loaded = token_cache.load_from_vault
27
+ log_info('AuthValidator starting')
28
+ cache = token_cache
29
+ log_debug("Token loaded: authenticated?=#{cache.authenticated?}")
28
30
 
29
- if loaded
30
- token = token_cache.cached_delegated_token
31
+ if cache.authenticated?
32
+ token = cache.cached_delegated_token
31
33
  if token
32
- log_info('Teams delegated auth restored')
33
- elsif token_cache.previously_authenticated? || auto_authenticate?
34
- attempt_browser_reauth(token_cache)
34
+ log_info('Teams delegated auth restored (token valid)')
35
+ elsif cache.previously_authenticated? || auto_authenticate?
36
+ log_info('Token loaded but expired, attempting browser re-auth')
37
+ attempt_browser_reauth(cache)
38
+ else
39
+ log_debug('Token loaded but expired, no re-auth configured')
35
40
  end
36
- elsif token_cache.previously_authenticated?
41
+ elsif cache.previously_authenticated?
37
42
  log_warn('Token file found but could not load, attempting re-authentication')
38
- attempt_browser_reauth(token_cache)
43
+ attempt_browser_reauth(cache)
39
44
  elsif auto_authenticate?
40
45
  log_info('auto_authenticate enabled, opening browser for initial authentication...')
41
- attempt_browser_reauth(token_cache)
46
+ attempt_browser_reauth(cache)
42
47
  else
43
48
  log_debug('No Teams delegated auth configured, skipping')
44
49
  end
50
+ log_info('AuthValidator complete')
45
51
  rescue StandardError => e
46
52
  log_error("AuthValidator: #{e.message}")
47
53
  end
@@ -50,12 +56,16 @@ module Legion
50
56
 
51
57
  def attempt_browser_reauth(cache)
52
58
  settings = teams_auth_settings
53
- return false unless settings[:tenant_id] && settings[:client_id]
59
+ unless settings[:tenant_id] && settings[:client_id]
60
+ log_warn("Cannot re-auth: tenant_id=#{settings[:tenant_id] ? 'present' : 'nil'}, client_id=#{settings[:client_id] ? 'present' : 'nil'}")
61
+ return false
62
+ end
54
63
 
55
64
  log_warn('Delegated token expired, opening browser for re-authentication...')
56
65
 
57
66
  scopes = settings.dig(:delegated, :scopes) ||
58
67
  Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth::DEFAULT_SCOPES
68
+ log_debug("Using scopes: #{scopes}")
59
69
  browser_auth = Legion::Extensions::MicrosoftTeams::Helpers::BrowserAuth.new(
60
70
  tenant_id: settings[:tenant_id],
61
71
  client_id: settings[:client_id],
@@ -63,9 +73,13 @@ module Legion
63
73
  )
64
74
 
65
75
  result = browser_auth.authenticate
66
- return false if result[:error]
76
+ if result[:error]
77
+ log_error("Browser auth returned error: #{result[:error]} - #{result[:description]}")
78
+ return false
79
+ end
67
80
 
68
81
  body = result[:result]
82
+ log_info("Browser auth succeeded, storing token (expires_in=#{body['expires_in']})")
69
83
  cache.store_delegated_token(
70
84
  access_token: body['access_token'],
71
85
  refresh_token: body['refresh_token'],
@@ -82,29 +96,36 @@ module Legion
82
96
 
83
97
  def auto_authenticate?
84
98
  settings = teams_auth_settings
85
- settings.dig(:delegated, :auto_authenticate) == true
99
+ result = settings.dig(:delegated, :auto_authenticate) == true
100
+ log_debug("auto_authenticate? => #{result}")
101
+ result
86
102
  end
87
103
 
88
104
  def teams_auth_settings
89
- return {} unless defined?(Legion::Settings)
90
-
91
- Legion::Settings.dig(:microsoft_teams, :auth) || {}
105
+ settings = if defined?(Legion::Settings)
106
+ Legion::Settings.dig(:microsoft_teams, :auth) || {}
107
+ else
108
+ {}
109
+ end
110
+ settings[:tenant_id] ||= ENV.fetch('AZURE_TENANT_ID', nil)
111
+ settings[:client_id] ||= ENV.fetch('AZURE_CLIENT_ID', nil)
112
+ settings
92
113
  end
93
114
 
94
115
  def log_info(msg)
95
- Legion::Logging.info(msg) if defined?(Legion::Logging)
116
+ Legion::Logging.info("[Teams::AuthValidator] #{msg}") if defined?(Legion::Logging)
96
117
  end
97
118
 
98
119
  def log_warn(msg)
99
- Legion::Logging.warn(msg) if defined?(Legion::Logging)
120
+ Legion::Logging.warn("[Teams::AuthValidator] #{msg}") if defined?(Legion::Logging)
100
121
  end
101
122
 
102
123
  def log_debug(msg)
103
- Legion::Logging.debug(msg) if defined?(Legion::Logging)
124
+ Legion::Logging.debug("[Teams::AuthValidator] #{msg}") if defined?(Legion::Logging)
104
125
  end
105
126
 
106
127
  def log_error(msg)
107
- Legion::Logging.error(msg) if defined?(Legion::Logging)
128
+ Legion::Logging.error("[Teams::AuthValidator] #{msg}") if defined?(Legion::Logging)
108
129
  end
109
130
  end
110
131
  end
@@ -16,7 +16,7 @@ module Legion
16
16
  end
17
17
 
18
18
  def enabled?
19
- defined?(Legion::Extensions::Memory::Runners::Traces)
19
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
20
20
  rescue StandardError
21
21
  false
22
22
  end
@@ -23,7 +23,7 @@ module Legion
23
23
  def generate_task? = false
24
24
 
25
25
  def enabled?
26
- defined?(Legion::Extensions::Memory::Runners::Traces)
26
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
27
27
  rescue StandardError
28
28
  false
29
29
  end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class ChannelPoller < Legion::Extensions::Actors::Every
8
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
+
10
+ DEFAULT_INTERVAL = 60
11
+ DEFAULT_MAX_TEAMS = 10
12
+ DEFAULT_MAX_CHANNELS = 5
13
+
14
+ def initialize(**opts)
15
+ return unless enabled?
16
+
17
+ @channel_hwm = {}
18
+ super
19
+ end
20
+
21
+ def runner_class = self.class
22
+ def runner_function = 'manual'
23
+ def time = channel_setting(:poll_interval, DEFAULT_INTERVAL)
24
+ def run_now? = false
25
+ def use_runner? = false
26
+ def check_subtask? = false
27
+ def generate_task? = false
28
+
29
+ def memory_available?
30
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
31
+ end
32
+
33
+ def memory_runner
34
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
35
+ end
36
+
37
+ def enabled?
38
+ return false unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
39
+
40
+ channel_setting(:enabled, false) == true
41
+ rescue StandardError
42
+ false
43
+ end
44
+
45
+ def token_cache
46
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
47
+ end
48
+
49
+ def manual
50
+ log_info('ChannelPoller polling team channels')
51
+ token = token_cache.cached_graph_token
52
+ unless token
53
+ log_debug('No token available, skipping poll')
54
+ return
55
+ end
56
+
57
+ teams = fetch_joined_teams(token: token)
58
+ log_debug("Found #{teams.length} joined team(s)")
59
+
60
+ teams.first(max_teams).each do |team|
61
+ poll_team(team: team, token: token)
62
+ rescue StandardError => e
63
+ log_error("Error polling team #{team['displayName']}: #{e.message}")
64
+ end
65
+ rescue StandardError => e
66
+ log_error("ChannelPoller: #{e.message}")
67
+ end
68
+
69
+ private
70
+
71
+ def fetch_joined_teams(token:)
72
+ conn = graph_connection(token: token)
73
+ response = conn.get('me/joinedTeams')
74
+ response.body&.dig('value') || []
75
+ rescue StandardError => e
76
+ log_error("Failed to fetch joined teams: #{e.message}")
77
+ []
78
+ end
79
+
80
+ def poll_team(team:, token:)
81
+ team_id = team['id']
82
+ team_name = team['displayName'] || team_id
83
+
84
+ channels = fetch_channels(team_id: team_id, token: token)
85
+ selected = select_channels(channels)
86
+
87
+ selected.first(max_channels_per_team).each do |channel|
88
+ poll_channel(team_id: team_id, team_name: team_name, channel: channel, token: token)
89
+ rescue StandardError => e
90
+ log_error("Error polling channel #{channel['displayName']} in #{team_name}: #{e.message}")
91
+ end
92
+ end
93
+
94
+ def fetch_channels(team_id:, token:)
95
+ conn = graph_connection(token: token)
96
+ response = conn.get("teams/#{team_id}/channels")
97
+ response.body&.dig('value') || []
98
+ rescue StandardError => e
99
+ log_error("Failed to fetch channels for team #{team_id}: #{e.message}")
100
+ []
101
+ end
102
+
103
+ def select_channels(channels)
104
+ return channels if channel_setting(:all_channels, false) == true
105
+
106
+ general = channels.select { |c| c['displayName'] == 'General' }
107
+ general.any? ? general : channels
108
+ end
109
+
110
+ def poll_channel(team_id:, team_name:, channel:, token:)
111
+ channel_id = channel['id']
112
+ channel_name = channel['displayName'] || channel_id
113
+
114
+ conn = graph_connection(token: token)
115
+ response = conn.get(
116
+ "teams/#{team_id}/channels/#{channel_id}/messages",
117
+ { '$top' => 10, '$orderby' => 'lastModifiedDateTime desc' }
118
+ )
119
+ messages = response.body&.dig('value') || []
120
+
121
+ new_msgs = filter_new_messages(channel_id: channel_id, messages: messages)
122
+ return if new_msgs.empty?
123
+
124
+ log_info("#{team_name} / #{channel_name}: #{new_msgs.length} new message(s)")
125
+ new_msgs.each do |msg|
126
+ log_message(team_name: team_name, channel_name: channel_name, msg: msg)
127
+ store_channel_message_trace(team_name: team_name, channel_name: channel_name, msg: msg) if memory_available?
128
+ end
129
+
130
+ latest = new_msgs.map { |m| m['createdDateTime'] }.compact.max
131
+ @channel_hwm[channel_id] = latest if latest
132
+ end
133
+
134
+ def filter_new_messages(channel_id:, messages:)
135
+ hwm = @channel_hwm[channel_id]
136
+ return messages unless hwm
137
+
138
+ messages.select { |m| m['createdDateTime'].to_s > hwm }
139
+ end
140
+
141
+ def log_message(team_name:, channel_name:, msg:)
142
+ sender = msg.dig('from', 'user', 'displayName') || 'Unknown'
143
+ content = (msg.dig('body', 'content') || '').gsub(/<[^>]+>/, '').strip
144
+ snippet = content.length > 100 ? "#{content[0, 100]}..." : content
145
+ log_info(" [#{team_name}] ##{channel_name} | #{sender}: #{snippet}")
146
+ end
147
+
148
+ def max_teams
149
+ channel_setting(:max_teams, DEFAULT_MAX_TEAMS)
150
+ end
151
+
152
+ def max_channels_per_team
153
+ channel_setting(:max_channels_per_team, DEFAULT_MAX_CHANNELS)
154
+ end
155
+
156
+ def channel_setting(key, default)
157
+ return default unless defined?(Legion::Settings)
158
+
159
+ Legion::Settings.dig(:microsoft_teams, :channels, key) || default
160
+ rescue StandardError
161
+ default
162
+ end
163
+
164
+ def store_channel_message_trace(team_name:, channel_name:, msg:)
165
+ sender = msg.dig('from', 'user', 'displayName') || 'Unknown'
166
+ content = (msg.dig('body', 'content') || '').gsub(/<[^>]+>/, '').strip
167
+ memory_runner.store_trace(
168
+ type: :episodic,
169
+ content_payload: "#{sender} in #{team_name}/##{channel_name}: #{content}"[0, 5000],
170
+ domain_tags: ['teams', 'channel', "team:#{team_name}", "channel:#{channel_name}", "sender:#{sender}"],
171
+ origin: :direct_experience,
172
+ confidence: 0.7
173
+ )
174
+ rescue StandardError => e
175
+ log_error("Failed to store channel message trace: #{e.message}")
176
+ end
177
+
178
+ def log_debug(msg)
179
+ Legion::Logging.debug("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
180
+ end
181
+
182
+ def log_info(msg)
183
+ Legion::Logging.info("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
184
+ end
185
+
186
+ def log_error(msg)
187
+ Legion::Logging.error("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
188
+ end
189
+ end
190
+ end
191
+ end
192
+ end
193
+ end
@@ -33,17 +33,22 @@ module Legion
33
33
  end
34
34
 
35
35
  def token_cache
36
- @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
36
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
37
37
  end
38
38
 
39
39
  def manual
40
40
  token = token_cache.cached_graph_token
41
- return unless token
41
+ unless token
42
+ log_debug('No token available, skipping poll')
43
+ return
44
+ end
42
45
 
46
+ log_info('Polling bot DM chats')
43
47
  chats = fetch_bot_chats(token: token)
48
+ log_info("Found #{chats.length} bot chats")
44
49
  chats.each { |chat| poll_chat(chat_id: chat[:id], token: token) }
45
50
  rescue StandardError => e
46
- Legion::Logging.error("DirectChatPoller: #{e.message}") if defined?(Legion::Logging)
51
+ log_error("DirectChatPoller: #{e.message}")
47
52
  end
48
53
 
49
54
  private
@@ -64,6 +69,7 @@ module Legion
64
69
  new_msgs.reject! { |m| m[:from_id] == @bot_id }
65
70
  return if new_msgs.empty?
66
71
 
72
+ log_info("Chat #{chat_id}: #{new_msgs.length} new message(s)")
67
73
  new_msgs.each { |msg| publish_message(msg.merge(chat_id: chat_id, mode: :direct)) }
68
74
  update_hwm_from_messages(chat_id: chat_id, messages: new_msgs)
69
75
  end
@@ -71,7 +77,7 @@ module Legion
71
77
  def publish_message(payload)
72
78
  Legion::Extensions::MicrosoftTeams::Transport::Messages::TeamsMessage.new.publish(payload)
73
79
  rescue StandardError => e
74
- Legion::Logging.error("DirectChatPoller publish failed: #{e.message}") if defined?(Legion::Logging)
80
+ log_error("DirectChatPoller publish failed: #{e.message}")
75
81
  end
76
82
 
77
83
  def normalize_messages(messages)
@@ -98,6 +104,18 @@ module Legion
98
104
 
99
105
  Legion::Settings.dig(:microsoft_teams, :bot, key) || default
100
106
  end
107
+
108
+ def log_debug(msg)
109
+ Legion::Logging.debug("[Teams::DirectChatPoller] #{msg}") if defined?(Legion::Logging)
110
+ end
111
+
112
+ def log_info(msg)
113
+ Legion::Logging.info("[Teams::DirectChatPoller] #{msg}") if defined?(Legion::Logging)
114
+ end
115
+
116
+ def log_error(msg)
117
+ Legion::Logging.error("[Teams::DirectChatPoller] #{msg}") if defined?(Legion::Logging)
118
+ end
101
119
  end
102
120
  end
103
121
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class MeetingIngest < Legion::Extensions::Actors::Every
8
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
+
10
+ DEFAULT_INGEST_INTERVAL = 300
11
+
12
+ def runner_class = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache
13
+ def runner_function = 'cached_graph_token'
14
+ def run_now? = false
15
+ def use_runner? = false
16
+ def check_subtask? = false
17
+ def generate_task? = false
18
+
19
+ def initialize(**opts)
20
+ @processed_meetings = Set.new
21
+ super
22
+ end
23
+
24
+ def time
25
+ settings = begin
26
+ Legion::Settings[:microsoft_teams] || {}
27
+ rescue StandardError
28
+ {}
29
+ end
30
+ settings.dig(:meetings, :ingest_interval) || DEFAULT_INGEST_INTERVAL
31
+ end
32
+
33
+ def enabled?
34
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
35
+ rescue StandardError
36
+ false
37
+ end
38
+
39
+ def memory_available?
40
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
41
+ end
42
+
43
+ def memory_runner
44
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
45
+ end
46
+
47
+ def token_cache
48
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
49
+ end
50
+
51
+ def manual
52
+ log_info('MeetingIngest polling for meetings')
53
+ token = token_cache.cached_graph_token
54
+ return if token.nil?
55
+
56
+ conn = graph_connection(token: token)
57
+ response = conn.get("#{user_path('me')}/onlineMeetings")
58
+ meetings = response.body&.dig('value') || []
59
+ log_info("Found #{meetings.length} online meeting(s)")
60
+
61
+ meetings.each do |meeting|
62
+ meeting_id = meeting['id']
63
+ next if @processed_meetings.include?(meeting_id)
64
+
65
+ begin
66
+ process_meeting(meeting_id: meeting_id, subject: meeting['subject'], token: token)
67
+ @processed_meetings.add(meeting_id)
68
+ rescue StandardError => e
69
+ log_error("Failed to process meeting #{meeting_id}: #{e.message}")
70
+ end
71
+ end
72
+ rescue StandardError => e
73
+ log_error("MeetingIngest: #{e.message}")
74
+ end
75
+
76
+ private
77
+
78
+ def process_meeting(meeting_id:, subject:, token:)
79
+ conn = graph_connection(token: token)
80
+
81
+ transcripts = fetch_transcripts(conn: conn, meeting_id: meeting_id)
82
+ log_info("Meeting '#{subject}' (#{meeting_id}): #{transcripts.length} transcript(s)")
83
+
84
+ transcripts.each do |transcript|
85
+ fetch_and_log_transcript_content(
86
+ conn: conn,
87
+ meeting_id: meeting_id,
88
+ subject: subject,
89
+ token: token,
90
+ transcript: transcript
91
+ )
92
+ end
93
+
94
+ fetch_and_log_ai_insights(conn: conn, meeting_id: meeting_id, subject: subject)
95
+ end
96
+
97
+ def fetch_transcripts(conn:, meeting_id:)
98
+ response = conn.get("#{user_path('me')}/onlineMeetings/#{meeting_id}/transcripts")
99
+ response.body&.dig('value') || []
100
+ rescue StandardError => e
101
+ log_warn("Could not fetch transcripts for meeting #{meeting_id}: #{e.message}")
102
+ []
103
+ end
104
+
105
+ def fetch_and_log_transcript_content(conn:, meeting_id:, subject:, token:, transcript:) # rubocop:disable Lint/UnusedMethodArgument
106
+ tid = transcript['id']
107
+ content_conn = graph_connection(token: token)
108
+ content_response = content_conn.get(
109
+ "#{user_path('me')}/onlineMeetings/#{meeting_id}/transcripts/#{tid}/content",
110
+ {},
111
+ { 'Accept' => 'text/vtt' }
112
+ )
113
+ content = content_response.body.to_s
114
+ preview = content[0, 200]
115
+ log_debug("Meeting '#{subject}' transcript #{tid}: #{preview}")
116
+ store_transcript_trace(meeting_id: meeting_id, subject: subject, transcript_id: tid, content: content) if memory_available?
117
+ rescue StandardError => e
118
+ log_warn("Could not fetch transcript content #{tid} for meeting #{meeting_id}: #{e.message}")
119
+ end
120
+
121
+ def fetch_and_log_ai_insights(conn:, meeting_id:, subject:)
122
+ response = conn.get("#{user_path('me')}/onlineMeetings/#{meeting_id}/aiInsights")
123
+ insights = response.body&.dig('value') || []
124
+ log_info("Meeting '#{subject}' (#{meeting_id}): #{insights.length} AI insight(s)")
125
+
126
+ insights.each do |insight|
127
+ action_items = insight['actionItems'] || []
128
+ next if action_items.empty?
129
+
130
+ log_info("Meeting '#{subject}' AI insight action items (#{action_items.length}):")
131
+ action_items.each do |item|
132
+ log_info(" - #{item['text'] || item.inspect}")
133
+ end
134
+
135
+ store_insight_trace(meeting_id: meeting_id, subject: subject, insight: insight) if memory_available?
136
+ end
137
+ rescue StandardError => e
138
+ log_warn("Could not fetch AI insights for meeting #{meeting_id}: #{e.message}")
139
+ end
140
+
141
+ def store_transcript_trace(meeting_id:, subject:, transcript_id:, content:) # rubocop:disable Lint/UnusedMethodArgument
142
+ memory_runner.store_trace(
143
+ type: :episodic,
144
+ content_payload: content[0, 10_000],
145
+ domain_tags: ['teams', 'transcript', "meeting:#{meeting_id}", "transcript:#{transcript_id}"],
146
+ origin: :direct_experience,
147
+ confidence: 0.9
148
+ )
149
+ rescue StandardError => e
150
+ log_warn("Could not store transcript trace for meeting #{meeting_id}: #{e.message}")
151
+ end
152
+
153
+ def store_insight_trace(meeting_id:, subject:, insight:) # rubocop:disable Lint/UnusedMethodArgument
154
+ memory_runner.store_trace(
155
+ type: :semantic,
156
+ content_payload: insight.to_s,
157
+ domain_tags: ['teams', 'ai-insight', "meeting:#{meeting_id}"],
158
+ origin: :inferred,
159
+ confidence: 0.8
160
+ )
161
+ rescue StandardError => e
162
+ log_warn("Could not store insight trace for meeting #{meeting_id}: #{e.message}")
163
+ end
164
+
165
+ def log_debug(msg)
166
+ Legion::Logging.debug("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
167
+ end
168
+
169
+ def log_info(msg)
170
+ Legion::Logging.info("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
171
+ end
172
+
173
+ def log_warn(msg)
174
+ Legion::Logging.warn("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
175
+ end
176
+
177
+ def log_error(msg)
178
+ Legion::Logging.error("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -6,7 +6,7 @@ module Legion
6
6
  module Actor
7
7
  class MessageProcessor < Legion::Extensions::Actors::Subscription
8
8
  def runner_class = 'Legion::Extensions::MicrosoftTeams::Runners::Bot'
9
- def runner_function = 'handle_message'
9
+ def runner_function = 'dispatch_message'
10
10
  def check_subtask? = false
11
11
  def generate_task? = false
12
12
 
@@ -35,7 +35,7 @@ module Legion
35
35
  end
36
36
 
37
37
  def token_cache
38
- @token_cache ||= Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
38
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
39
39
  end
40
40
 
41
41
  def subscription_registry