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 +4 -4
- data/CHANGELOG.md +24 -0
- data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +41 -20
- data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +167 -0
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +22 -4
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +148 -0
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +78 -0
- data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +35 -9
- data/lib/legion/extensions/microsoft_teams/cli/auth.rb +47 -6
- data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +119 -14
- data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb +4 -3
- data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +168 -36
- data/lib/legion/extensions/microsoft_teams/runners/ai_insights.rb +48 -0
- data/lib/legion/extensions/microsoft_teams/runners/bot.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +4 -4
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +1 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1eae25ec2551dc1467fb72e447817bad5cebf0df377cb8bd432fc11fdd13472f
|
|
4
|
+
data.tar.gz: b8a562b68c05bad57ff0660756a4883d87cf01da93c6f3665bd5575c94765946
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
23
|
+
Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def manual
|
|
27
|
-
|
|
27
|
+
log_info('AuthValidator starting')
|
|
28
|
+
cache = token_cache
|
|
29
|
+
log_debug("Token loaded: authenticated?=#{cache.authenticated?}")
|
|
28
30
|
|
|
29
|
-
if
|
|
30
|
-
token =
|
|
31
|
+
if cache.authenticated?
|
|
32
|
+
token = cache.cached_delegated_token
|
|
31
33
|
if token
|
|
32
|
-
log_info('Teams delegated auth restored')
|
|
33
|
-
elsif
|
|
34
|
-
|
|
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
|
|
41
|
+
elsif cache.previously_authenticated?
|
|
37
42
|
log_warn('Token file found but could not load, attempting re-authentication')
|
|
38
|
-
attempt_browser_reauth(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|