lex-microsoft_teams 0.6.4 → 0.6.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6fccc37def8926f79ed1626030932012a3ac6875ee711b577c7ccd027a1158b4
4
- data.tar.gz: 251f6d348cc76c15d226090375f121bac73c5e1b0de95bd70ea9bc975a4e93a9
3
+ metadata.gz: 1eae25ec2551dc1467fb72e447817bad5cebf0df377cb8bd432fc11fdd13472f
4
+ data.tar.gz: b8a562b68c05bad57ff0660756a4883d87cf01da93c6f3665bd5575c94765946
5
5
  SHA512:
6
- metadata.gz: 3b326a71937fd36b9c98385b41d21f9600ce9ab9b28e7f91261e85b3bb117af6a84365c47d86152f67b34260f8068c1d1d8891abe82925e204f89380f2080e4e
7
- data.tar.gz: 4ba5d4507933666d677e8d53bf1100556d88ad7abc7617b6b20b854419a2951f4de569abcb3faea4052d0c3bbe0d8eba6b4c96f85ad36125d1bc412ed8573f89
6
+ metadata.gz: 88f9605803f81b12cbb7065d9d607a9f5106dfaa7b8ab4802ecbdda3ba3d5d5de2d670ca1015620defeb6573893dc5b10e669d470b9f9f06677e271b2c96d1b3
7
+ data.tar.gz: 1683ec4477164cbe81ded4d0827f8e2912ec2ed386decd58ce4e914fda8d20779a4b8cfb263c338fe78f5b655b363fb186b3651f932cafa5f069810eaece382f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.5] - 2026-03-22
4
+
5
+ ### Added
6
+ - `Actors::ChannelPoller` (Every, 60s): polls joined team channels for new messages with HWM dedup
7
+ - `Actors::MeetingIngest` (Every, 5min): polls online meetings, fetches transcripts (VTT) and AI insights
8
+ - `Actors::PresencePoller` (Every, 60s): polls Graph API presence, logs changes at INFO
9
+ - `Runners::AiInsights` for Graph API meeting AI insights, recordings, and call records
10
+ - All 28 Entra delegated permission scopes in `BrowserAuth::DEFAULT_SCOPES`
11
+ - Comprehensive tagged logging throughout auth, token, and poller lifecycles
12
+ - `TokenCache.instance` singleton pattern for shared token state across all actors
13
+ - `force_local_server` option in `BrowserAuth` for CLI OAuth flow
14
+ - `hook_route_registered?` HTTP probe for daemon OAuth callback detection
15
+ - Environment variable fallback (`AZURE_TENANT_ID`, `AZURE_CLIENT_ID`) in CLI and actors
16
+
17
+ ### Fixed
18
+ - Fix memory namespace: `Legion::Extensions::Memory::*` -> `Legion::Extensions::Agentic::Memory::Trace::*` across 6 files
19
+ - Fix `SubscriptionRegistry` using nonexistent `recall_trace` method, now uses `retrieve_by_domain`
20
+ - Fix Vault write attempts when `crypt.vault.enabled` is false (added `vault_available?` guard)
21
+ - Fix token not shared across actors (each created own `TokenCache.new` instead of singleton)
22
+ - Fix app token warning spam with warn-once pattern and delegated token fallback
23
+
24
+ ### Changed
25
+ - Updated `AuthValidator` spec to match rewritten `manual` method logic
26
+
3
27
  ## [0.6.4] - 2026-03-22
4
28
 
5
29
  ### 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,167 @@
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 enabled?
30
+ return false unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
31
+
32
+ channel_setting(:enabled, false) == true
33
+ rescue StandardError
34
+ false
35
+ end
36
+
37
+ def token_cache
38
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
39
+ end
40
+
41
+ def manual
42
+ token = token_cache.cached_graph_token
43
+ unless token
44
+ log_debug('No token available, skipping poll')
45
+ return
46
+ end
47
+
48
+ teams = fetch_joined_teams(token: token)
49
+ log_debug("Found #{teams.length} joined team(s)")
50
+
51
+ teams.first(max_teams).each do |team|
52
+ poll_team(team: team, token: token)
53
+ rescue StandardError => e
54
+ log_error("Error polling team #{team['displayName']}: #{e.message}")
55
+ end
56
+ rescue StandardError => e
57
+ log_error("ChannelPoller: #{e.message}")
58
+ end
59
+
60
+ private
61
+
62
+ def fetch_joined_teams(token:)
63
+ conn = graph_connection(token: token)
64
+ response = conn.get('me/joinedTeams')
65
+ response.body&.dig('value') || []
66
+ rescue StandardError => e
67
+ log_error("Failed to fetch joined teams: #{e.message}")
68
+ []
69
+ end
70
+
71
+ def poll_team(team:, token:)
72
+ team_id = team['id']
73
+ team_name = team['displayName'] || team_id
74
+
75
+ channels = fetch_channels(team_id: team_id, token: token)
76
+ selected = select_channels(channels)
77
+
78
+ selected.first(max_channels_per_team).each do |channel|
79
+ poll_channel(team_id: team_id, team_name: team_name, channel: channel, token: token)
80
+ rescue StandardError => e
81
+ log_error("Error polling channel #{channel['displayName']} in #{team_name}: #{e.message}")
82
+ end
83
+ end
84
+
85
+ def fetch_channels(team_id:, token:)
86
+ conn = graph_connection(token: token)
87
+ response = conn.get("teams/#{team_id}/channels")
88
+ response.body&.dig('value') || []
89
+ rescue StandardError => e
90
+ log_error("Failed to fetch channels for team #{team_id}: #{e.message}")
91
+ []
92
+ end
93
+
94
+ def select_channels(channels)
95
+ return channels if channel_setting(:all_channels, false) == true
96
+
97
+ general = channels.select { |c| c['displayName'] == 'General' }
98
+ general.any? ? general : channels
99
+ end
100
+
101
+ def poll_channel(team_id:, team_name:, channel:, token:)
102
+ channel_id = channel['id']
103
+ channel_name = channel['displayName'] || channel_id
104
+
105
+ conn = graph_connection(token: token)
106
+ response = conn.get(
107
+ "teams/#{team_id}/channels/#{channel_id}/messages",
108
+ { '$top' => 10, '$orderby' => 'lastModifiedDateTime desc' }
109
+ )
110
+ messages = response.body&.dig('value') || []
111
+
112
+ new_msgs = filter_new_messages(channel_id: channel_id, messages: messages)
113
+ return if new_msgs.empty?
114
+
115
+ log_info("#{team_name} / #{channel_name}: #{new_msgs.length} new message(s)")
116
+ new_msgs.each { |msg| log_message(team_name: team_name, channel_name: channel_name, msg: msg) }
117
+
118
+ latest = new_msgs.map { |m| m['createdDateTime'] }.compact.max
119
+ @channel_hwm[channel_id] = latest if latest
120
+ end
121
+
122
+ def filter_new_messages(channel_id:, messages:)
123
+ hwm = @channel_hwm[channel_id]
124
+ return messages unless hwm
125
+
126
+ messages.select { |m| m['createdDateTime'].to_s > hwm }
127
+ end
128
+
129
+ def log_message(team_name:, channel_name:, msg:)
130
+ sender = msg.dig('from', 'user', 'displayName') || 'Unknown'
131
+ content = (msg.dig('body', 'content') || '').gsub(/<[^>]+>/, '').strip
132
+ snippet = content.length > 100 ? "#{content[0, 100]}..." : content
133
+ log_info(" [#{team_name}] ##{channel_name} | #{sender}: #{snippet}")
134
+ end
135
+
136
+ def max_teams
137
+ channel_setting(:max_teams, DEFAULT_MAX_TEAMS)
138
+ end
139
+
140
+ def max_channels_per_team
141
+ channel_setting(:max_channels_per_team, DEFAULT_MAX_CHANNELS)
142
+ end
143
+
144
+ def channel_setting(key, default)
145
+ return default unless defined?(Legion::Settings)
146
+
147
+ Legion::Settings.dig(:microsoft_teams, :channels, key) || default
148
+ rescue StandardError
149
+ default
150
+ end
151
+
152
+ def log_debug(msg)
153
+ Legion::Logging.debug("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
154
+ end
155
+
156
+ def log_info(msg)
157
+ Legion::Logging.info("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
158
+ end
159
+
160
+ def log_error(msg)
161
+ Legion::Logging.error("[Teams::ChannelPoller] #{msg}") if defined?(Legion::Logging)
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ 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,148 @@
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 token_cache
40
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
41
+ end
42
+
43
+ def manual
44
+ token = token_cache.cached_graph_token
45
+ return if token.nil?
46
+
47
+ conn = graph_connection(token: token)
48
+ response = conn.get("#{user_path('me')}/onlineMeetings")
49
+ meetings = response.body&.dig('value') || []
50
+ log_info("Found #{meetings.length} online meeting(s)")
51
+
52
+ meetings.each do |meeting|
53
+ meeting_id = meeting['id']
54
+ next if @processed_meetings.include?(meeting_id)
55
+
56
+ begin
57
+ process_meeting(meeting_id: meeting_id, subject: meeting['subject'], token: token)
58
+ @processed_meetings.add(meeting_id)
59
+ rescue StandardError => e
60
+ log_error("Failed to process meeting #{meeting_id}: #{e.message}")
61
+ end
62
+ end
63
+ rescue StandardError => e
64
+ log_error("MeetingIngest: #{e.message}")
65
+ end
66
+
67
+ private
68
+
69
+ def process_meeting(meeting_id:, subject:, token:)
70
+ conn = graph_connection(token: token)
71
+
72
+ transcripts = fetch_transcripts(conn: conn, meeting_id: meeting_id)
73
+ log_info("Meeting '#{subject}' (#{meeting_id}): #{transcripts.length} transcript(s)")
74
+
75
+ transcripts.each do |transcript|
76
+ fetch_and_log_transcript_content(
77
+ conn: conn,
78
+ meeting_id: meeting_id,
79
+ subject: subject,
80
+ token: token,
81
+ transcript: transcript
82
+ )
83
+ end
84
+
85
+ fetch_and_log_ai_insights(conn: conn, meeting_id: meeting_id, subject: subject)
86
+ end
87
+
88
+ def fetch_transcripts(conn:, meeting_id:)
89
+ response = conn.get("#{user_path('me')}/onlineMeetings/#{meeting_id}/transcripts")
90
+ response.body&.dig('value') || []
91
+ rescue StandardError => e
92
+ log_warn("Could not fetch transcripts for meeting #{meeting_id}: #{e.message}")
93
+ []
94
+ end
95
+
96
+ def fetch_and_log_transcript_content(conn:, meeting_id:, subject:, token:, transcript:) # rubocop:disable Lint/UnusedMethodArgument
97
+ tid = transcript['id']
98
+ content_conn = graph_connection(token: token)
99
+ content_response = content_conn.get(
100
+ "#{user_path('me')}/onlineMeetings/#{meeting_id}/transcripts/#{tid}/content",
101
+ {},
102
+ { 'Accept' => 'text/vtt' }
103
+ )
104
+ content = content_response.body.to_s
105
+ preview = content[0, 200]
106
+ log_debug("Meeting '#{subject}' transcript #{tid}: #{preview}")
107
+ rescue StandardError => e
108
+ log_warn("Could not fetch transcript content #{tid} for meeting #{meeting_id}: #{e.message}")
109
+ end
110
+
111
+ def fetch_and_log_ai_insights(conn:, meeting_id:, subject:)
112
+ response = conn.get("#{user_path('me')}/onlineMeetings/#{meeting_id}/aiInsights")
113
+ insights = response.body&.dig('value') || []
114
+ log_info("Meeting '#{subject}' (#{meeting_id}): #{insights.length} AI insight(s)")
115
+
116
+ insights.each do |insight|
117
+ action_items = insight['actionItems'] || []
118
+ next if action_items.empty?
119
+
120
+ log_info("Meeting '#{subject}' AI insight action items (#{action_items.length}):")
121
+ action_items.each do |item|
122
+ log_info(" - #{item['text'] || item.inspect}")
123
+ end
124
+ end
125
+ rescue StandardError => e
126
+ log_warn("Could not fetch AI insights for meeting #{meeting_id}: #{e.message}")
127
+ end
128
+
129
+ def log_debug(msg)
130
+ Legion::Logging.debug("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
131
+ end
132
+
133
+ def log_info(msg)
134
+ Legion::Logging.info("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
135
+ end
136
+
137
+ def log_warn(msg)
138
+ Legion::Logging.warn("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
139
+ end
140
+
141
+ def log_error(msg)
142
+ Legion::Logging.error("[Teams::MeetingIngest] #{msg}") if defined?(Legion::Logging)
143
+ end
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -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
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class PresencePoller < Legion::Extensions::Actors::Every
8
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
9
+
10
+ DEFAULT_POLL_INTERVAL = 60
11
+
12
+ def runner_class = self.class
13
+ def runner_function = 'manual'
14
+ def run_now? = false
15
+ def use_runner? = false
16
+ def check_subtask? = false
17
+ def generate_task? = false
18
+
19
+ def time
20
+ return DEFAULT_POLL_INTERVAL unless defined?(Legion::Settings)
21
+
22
+ Legion::Settings.dig(:microsoft_teams, :presence, :poll_interval) || DEFAULT_POLL_INTERVAL
23
+ end
24
+
25
+ def enabled?
26
+ defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
27
+ rescue StandardError
28
+ false
29
+ end
30
+
31
+ def token_cache
32
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
33
+ end
34
+
35
+ def manual
36
+ token = token_cache.cached_graph_token
37
+ unless token
38
+ log_debug('No token available, skipping presence poll')
39
+ return
40
+ end
41
+
42
+ conn = graph_connection(token: token)
43
+ response = conn.get("#{user_path}/presence")
44
+ presence = response.body
45
+ return unless presence.is_a?(Hash)
46
+
47
+ availability = presence['availability']
48
+ activity = presence['activity']
49
+ current = { availability: availability, activity: activity }
50
+
51
+ if current == @last_presence
52
+ log_debug("Presence unchanged: availability=#{availability}, activity=#{activity}")
53
+ else
54
+ log_info("Presence changed: availability=#{availability}, activity=#{activity}")
55
+ @last_presence = current
56
+ end
57
+ rescue StandardError => e
58
+ log_error("PresencePoller: #{e.message}")
59
+ end
60
+
61
+ private
62
+
63
+ def log_debug(msg)
64
+ Legion::Logging.debug("[Teams::PresencePoller] #{msg}") if defined?(Legion::Logging)
65
+ end
66
+
67
+ def log_info(msg)
68
+ Legion::Logging.info("[Teams::PresencePoller] #{msg}") if defined?(Legion::Logging)
69
+ end
70
+
71
+ def log_error(msg)
72
+ Legion::Logging.error("[Teams::PresencePoller] #{msg}") if defined?(Legion::Logging)
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end