lex-microsoft_teams 0.6.50 → 0.6.52
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 +25 -0
- data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +22 -17
- data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +5 -18
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +4 -13
- data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +6 -17
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +16 -15
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +4 -14
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +16 -10
- data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +5 -10
- data/lib/legion/extensions/microsoft_teams/errors.rb +84 -0
- data/lib/legion/extensions/microsoft_teams/faraday/retry_after.rb +209 -0
- data/lib/legion/extensions/microsoft_teams/faraday/throttle_circuit.rb +150 -0
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +80 -3
- data/lib/legion/extensions/microsoft_teams/helpers/graph_cache.rb +65 -0
- data/lib/legion/extensions/microsoft_teams/helpers/graph_client.rb +20 -0
- data/lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb +185 -0
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +45 -22
- data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +73 -11
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +60 -0
- metadata +6 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'monitor'
|
|
4
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Extensions
|
|
8
|
+
module MicrosoftTeams
|
|
9
|
+
module Helpers
|
|
10
|
+
# Mixin that lets an `Actors::Every`-style poller honour a Graph
|
|
11
|
+
# throttle by *deferring its own next run* instead of re-firing on
|
|
12
|
+
# its fixed timer interval.
|
|
13
|
+
#
|
|
14
|
+
# Background: `Faraday::RetryAfter` raises `Errors::Throttled`
|
|
15
|
+
# centrally when it exhausts retries (or when the advertised wait
|
|
16
|
+
# alone exceeds the retry budget), and `Faraday::ThrottleCircuit`
|
|
17
|
+
# opens a shared circuit so the whole fleet stops hammering a quota
|
|
18
|
+
# that Graph has already flagged. But the actors that *drive* those
|
|
19
|
+
# calls schedule themselves with a `Concurrent::TimerTask` whose
|
|
20
|
+
# `execution_interval` is fixed at boot. Catching `Throttled` in a
|
|
21
|
+
# `rescue` block stops the current tick, but the next tick still
|
|
22
|
+
# fires on the original cadence — so a poller on a 30–300s interval
|
|
23
|
+
# keeps walking straight back into an open circuit, emitting an
|
|
24
|
+
# ERROR every interval and contributing nothing but log noise (and,
|
|
25
|
+
# via the shared circuit, refreshing the block for cheaper callers).
|
|
26
|
+
#
|
|
27
|
+
# This mixin closes that gap. An actor wraps its Graph work in
|
|
28
|
+
# {#with_throttle_deferral}. On `Errors::Throttled` it records a
|
|
29
|
+
# "suppress until" instant of `now + retry_after` (falling back to a
|
|
30
|
+
# bounded default when the server gave no usable `Retry-After`).
|
|
31
|
+
# Subsequent ticks short-circuit via {#throttle_suppressed?} until
|
|
32
|
+
# that instant passes, so the actor effectively reschedules itself
|
|
33
|
+
# at `now + retry_after` rather than `now + interval` — exactly the
|
|
34
|
+
# behaviour the throttle integration spec and the 0.6.51 CHANGELOG
|
|
35
|
+
# flagged as the outstanding follow-up.
|
|
36
|
+
#
|
|
37
|
+
# State is per-actor-instance and guarded by a monitor because an
|
|
38
|
+
# actor's `manual` runs on a `Concurrent` thread-pool worker. The
|
|
39
|
+
# mixin reads the carried `retry_after` only when
|
|
40
|
+
# `retry_after_known?` is true; otherwise it applies
|
|
41
|
+
# {DEFAULT_DEFERRAL} so a throttle with a missing/garbage header
|
|
42
|
+
# still backs the poller off rather than letting it spin.
|
|
43
|
+
module ThrottleAware
|
|
44
|
+
# Fallback deferral (seconds) used when a `Throttled` carries no
|
|
45
|
+
# usable `retry_after` (header absent or unparseable). One minute
|
|
46
|
+
# matches Microsoft Graph's documented typical Retry-After and the
|
|
47
|
+
# circuit middleware's `DEFAULT_FALLBACK_TTL`.
|
|
48
|
+
DEFAULT_DEFERRAL = 60.0
|
|
49
|
+
|
|
50
|
+
# Hard ceiling (seconds) on a single deferral so a pathological
|
|
51
|
+
# advertised `Retry-After` can't park a poller for an unbounded
|
|
52
|
+
# time. Mirrors the circuit middleware's 600s cap in
|
|
53
|
+
# `ThrottleCircuit#set_hard_circuit`.
|
|
54
|
+
MAX_DEFERRAL = 600.0
|
|
55
|
+
|
|
56
|
+
# Run `block` unless the actor is currently deferring after a
|
|
57
|
+
# recent throttle. If a `Throttled` escapes the block, record the
|
|
58
|
+
# deferral window and swallow it (the throttle has already been
|
|
59
|
+
# logged at the middleware/circuit layer; re-raising would just
|
|
60
|
+
# trip the actor's generic `rescue StandardError` and double-log).
|
|
61
|
+
#
|
|
62
|
+
# @param now [Time] injectable clock for deterministic tests
|
|
63
|
+
# @return [Object, nil] the block's value, or nil when suppressed
|
|
64
|
+
# or when a throttle was caught
|
|
65
|
+
def with_throttle_deferral(now: Time.now)
|
|
66
|
+
if throttle_suppressed?(now: now)
|
|
67
|
+
log_throttle_skip(now: now)
|
|
68
|
+
return nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
yield
|
|
72
|
+
rescue Legion::Extensions::MicrosoftTeams::Errors::Throttled => e
|
|
73
|
+
defer_after_throttle(e, now: now)
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# @return [Boolean] true while the actor is inside a deferral
|
|
78
|
+
# window opened by a previous throttle.
|
|
79
|
+
def throttle_suppressed?(now: Time.now)
|
|
80
|
+
until_at = throttled_until
|
|
81
|
+
!until_at.nil? && now < until_at
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The instant before which the actor should not poll again, or nil
|
|
85
|
+
# if no deferral is active. Thread-safe read.
|
|
86
|
+
def throttled_until
|
|
87
|
+
throttle_monitor.synchronize { @throttled_until }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Seconds remaining in the current deferral window (0.0 if none /
|
|
91
|
+
# elapsed). Useful for logging and for tests.
|
|
92
|
+
def throttle_remaining(now: Time.now)
|
|
93
|
+
until_at = throttled_until
|
|
94
|
+
return 0.0 if until_at.nil?
|
|
95
|
+
|
|
96
|
+
remaining = until_at - now
|
|
97
|
+
remaining.positive? ? remaining : 0.0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Open (or extend) a deferral window of `seconds` from `now`. A
|
|
101
|
+
# later throttle never *shortens* an existing window — we keep the
|
|
102
|
+
# furthest-out instant so overlapping throttles compose safely.
|
|
103
|
+
def defer_for(seconds, now: Time.now)
|
|
104
|
+
window = clamp_deferral(seconds)
|
|
105
|
+
target = now + window
|
|
106
|
+
throttle_monitor.synchronize do
|
|
107
|
+
@throttled_until = if @throttled_until.nil? || target > @throttled_until
|
|
108
|
+
target
|
|
109
|
+
else
|
|
110
|
+
@throttled_until
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
window
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Clear any active deferral. Called implicitly is unnecessary —
|
|
117
|
+
# {#throttle_suppressed?} expires on its own once the clock passes
|
|
118
|
+
# `throttled_until` — but exposed for tests and explicit resets.
|
|
119
|
+
def reset_throttle_deferral
|
|
120
|
+
throttle_monitor.synchronize { @throttled_until = nil }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def defer_after_throttle(error, now: Time.now)
|
|
126
|
+
seconds = deferral_seconds_for(error)
|
|
127
|
+
window = defer_for(seconds, now: now)
|
|
128
|
+
log.warn(
|
|
129
|
+
"[microsoft_teams][throttle_defer] #{actor_label}: " \
|
|
130
|
+
"deferring next run #{format('%.1f', window)}s " \
|
|
131
|
+
"(retry_after=#{error.retry_after_known? ? format('%.1f', error.retry_after) : 'unknown'} " \
|
|
132
|
+
"path=#{error.request})"
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Short, log-friendly name for the including actor. Falls back to
|
|
137
|
+
# the full class string for anonymous classes (whose `name` is
|
|
138
|
+
# nil), so the log line never raises on `name.split`.
|
|
139
|
+
def actor_label
|
|
140
|
+
(self.class.name || self.class.to_s).split('::').last
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Prefer the server-advised wait; fall back to {DEFAULT_DEFERRAL}
|
|
144
|
+
# only when the header was absent or unparseable.
|
|
145
|
+
def deferral_seconds_for(error)
|
|
146
|
+
if error.respond_to?(:retry_after_known?) && error.retry_after_known?
|
|
147
|
+
error.retry_after
|
|
148
|
+
else
|
|
149
|
+
DEFAULT_DEFERRAL
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def clamp_deferral(seconds)
|
|
154
|
+
value = begin
|
|
155
|
+
Float(seconds)
|
|
156
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
157
|
+
# Pure coercion fallback for a non-numeric deferral; the value
|
|
158
|
+
# is bounded immediately below, so a bad input degrades to the
|
|
159
|
+
# default rather than warranting its own log line.
|
|
160
|
+
rescue ArgumentError, TypeError
|
|
161
|
+
DEFAULT_DEFERRAL
|
|
162
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
163
|
+
end
|
|
164
|
+
value = DEFAULT_DEFERRAL if value <= 0
|
|
165
|
+
[value, MAX_DEFERRAL].min
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def log_throttle_skip(now: Time.now)
|
|
169
|
+
log.debug(
|
|
170
|
+
"[microsoft_teams][throttle_defer] #{actor_label}: " \
|
|
171
|
+
"skipping run, #{format('%.1f', throttle_remaining(now: now))}s deferral remaining"
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def throttle_monitor
|
|
176
|
+
# `||=` is safe here: the first tick of an actor runs before any
|
|
177
|
+
# concurrent re-entry (the `Every` base guards re-entry with its
|
|
178
|
+
# own AtomicBoolean), so the monitor is initialised single-threaded.
|
|
179
|
+
@throttle_monitor ||= Monitor.new
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'digest'
|
|
5
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
5
6
|
require 'legion/extensions/microsoft_teams/helpers/client'
|
|
7
|
+
require 'legion/extensions/microsoft_teams/helpers/graph_cache'
|
|
6
8
|
require 'legion/extensions/microsoft_teams/helpers/permission_guard'
|
|
7
9
|
require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
|
|
8
10
|
|
|
@@ -15,6 +17,7 @@ module Legion
|
|
|
15
17
|
include Helpers::Client
|
|
16
18
|
include Helpers::PermissionGuard
|
|
17
19
|
include Helpers::HighWaterMark
|
|
20
|
+
include Helpers::GraphCache
|
|
18
21
|
extend self
|
|
19
22
|
|
|
20
23
|
definition :ingest_api, mcp_exposed: false
|
|
@@ -41,6 +44,7 @@ module Legion
|
|
|
41
44
|
|
|
42
45
|
existing_hashes = load_existing_hashes
|
|
43
46
|
conn = graph_connection(token: token)
|
|
47
|
+
chat_index = build_chat_member_index(conn: conn, chats: chats)
|
|
44
48
|
stored = 0
|
|
45
49
|
skipped = 0
|
|
46
50
|
people_ingested = 0
|
|
@@ -48,7 +52,7 @@ module Legion
|
|
|
48
52
|
person_texts = Hash.new { |h, k| h[k] = [] }
|
|
49
53
|
|
|
50
54
|
people.each do |person|
|
|
51
|
-
chat =
|
|
55
|
+
chat = find_chat_for_person_indexed(person: person, chat_index: chat_index)
|
|
52
56
|
unless chat
|
|
53
57
|
log.debug("ApiIngest: no chat match for #{person['displayName']} " \
|
|
54
58
|
"(email=#{person.dig('scoredEmailAddresses', 0, 'address')}, id=#{person['id']})")
|
|
@@ -101,6 +105,16 @@ module Legion
|
|
|
101
105
|
{ result: { stored: stored, skipped: skipped, people_ingested: people_ingested,
|
|
102
106
|
people_found: people.length, chats_found: chats.length,
|
|
103
107
|
apollo: apollo_results } }
|
|
108
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
109
|
+
# Re-raise unlogged: surface the typed throttle to the caller (the
|
|
110
|
+
# ApiIngest actor) so it can defer its next scheduled run by the
|
|
111
|
+
# advertised retry_after. The throttle is already logged at the
|
|
112
|
+
# middleware/circuit layer; folding it into an error result here
|
|
113
|
+
# would hide the one signal the actor needs to stop re-charging
|
|
114
|
+
# the shared Graph circuit on its fixed interval.
|
|
115
|
+
rescue Errors::Throttled
|
|
116
|
+
raise
|
|
117
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
104
118
|
rescue StandardError => e
|
|
105
119
|
handle_exception(e, level: :error, operation: 'ApiIngest#ingest_api')
|
|
106
120
|
{ result: { stored: stored || 0, skipped: skipped || 0, error: e.message } }
|
|
@@ -164,33 +178,42 @@ module Legion
|
|
|
164
178
|
[]
|
|
165
179
|
end
|
|
166
180
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
members.
|
|
177
|
-
|
|
181
|
+
def build_chat_member_index(conn:, chats:)
|
|
182
|
+
by_email = {}
|
|
183
|
+
by_user_id = {}
|
|
184
|
+
by_name = {}
|
|
185
|
+
|
|
186
|
+
chats.each do |chat|
|
|
187
|
+
members = cached_graph_get(conn: conn, path: "chats/#{chat['id']}/members",
|
|
188
|
+
shared: true)
|
|
189
|
+
.then { |body| (body || {}).fetch('value', []) }
|
|
190
|
+
members.each do |m|
|
|
191
|
+
email = m['email']&.downcase
|
|
192
|
+
alt_email = m.dig('additionalData', 'email')&.downcase
|
|
193
|
+
uid = m['userId']
|
|
194
|
+
name = m['displayName']&.downcase
|
|
195
|
+
|
|
196
|
+
by_email[email] ||= chat if email
|
|
197
|
+
by_email[alt_email] ||= chat if alt_email
|
|
198
|
+
by_user_id[uid] ||= chat if uid
|
|
199
|
+
by_name[name] ||= chat if name
|
|
178
200
|
end
|
|
179
201
|
end
|
|
202
|
+
|
|
203
|
+
{ email: by_email, user_id: by_user_id, name: by_name }
|
|
180
204
|
rescue StandardError => e
|
|
181
|
-
handle_exception(e, level: :
|
|
182
|
-
|
|
205
|
+
handle_exception(e, level: :warn, operation: 'ApiIngest#build_chat_member_index')
|
|
206
|
+
{ email: {}, user_id: {}, name: {} }
|
|
183
207
|
end
|
|
184
208
|
|
|
185
|
-
def
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
member_name = member['displayName']&.downcase
|
|
191
|
-
return true if display_name && member_name && member_name == display_name
|
|
209
|
+
def find_chat_for_person_indexed(person:, chat_index:)
|
|
210
|
+
email = person.dig('scoredEmailAddresses', 0, 'address')&.downcase
|
|
211
|
+
user_id = person['id']
|
|
212
|
+
display_name = person['displayName']&.downcase
|
|
192
213
|
|
|
193
|
-
|
|
214
|
+
chat_index[:email][email] ||
|
|
215
|
+
chat_index[:user_id][user_id] ||
|
|
216
|
+
chat_index[:name][display_name]
|
|
194
217
|
end
|
|
195
218
|
|
|
196
219
|
def fetch_chat_messages(conn:, chat_id:, depth: 50)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
4
5
|
require 'legion/extensions/microsoft_teams/helpers/client'
|
|
6
|
+
require 'legion/extensions/microsoft_teams/helpers/graph_cache'
|
|
5
7
|
require 'legion/extensions/microsoft_teams/helpers/permission_guard'
|
|
6
8
|
require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
|
|
7
9
|
require 'legion/extensions/microsoft_teams/helpers/transform_definitions'
|
|
@@ -15,6 +17,7 @@ module Legion
|
|
|
15
17
|
include Helpers::Client
|
|
16
18
|
include Helpers::PermissionGuard
|
|
17
19
|
include Helpers::HighWaterMark
|
|
20
|
+
include Helpers::GraphCache
|
|
18
21
|
extend self
|
|
19
22
|
|
|
20
23
|
definition :full_ingest, mcp_exposed: false
|
|
@@ -34,6 +37,15 @@ module Legion
|
|
|
34
37
|
teams_result = ingest_teams_and_meetings(token: token)
|
|
35
38
|
|
|
36
39
|
{ self: self_result, people: people_result, conversations: conv_result, teams: teams_result }
|
|
40
|
+
rescue Errors::Throttled => e
|
|
41
|
+
# Once Graph throttles us, the shared circuit is open and every
|
|
42
|
+
# remaining stage would just raise Throttled(attempts: 0)
|
|
43
|
+
# instantly — a burst of identical errors that ingests nothing.
|
|
44
|
+
# Stop the fan-out at the first throttled stage and report it.
|
|
45
|
+
log.warn('[microsoft_teams][profile_ingest] throttled mid-ingest; ' \
|
|
46
|
+
'aborting remaining stages (retry_after=' \
|
|
47
|
+
"#{e.retry_after_known? ? format('%.1f', e.retry_after) : 'unknown'} path=#{e.request})")
|
|
48
|
+
{ throttled: true, retry_after: e.retry_after, request: e.request }
|
|
37
49
|
end
|
|
38
50
|
|
|
39
51
|
def ingest_self(token:, **)
|
|
@@ -65,6 +77,13 @@ module Legion
|
|
|
65
77
|
end
|
|
66
78
|
|
|
67
79
|
{ profile: profile, presence: presence }
|
|
80
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
81
|
+
# Re-raise unlogged: full_ingest logs the throttle once and aborts
|
|
82
|
+
# the fan-out. A throttle is a fleet signal, not a per-stage error;
|
|
83
|
+
# logging it here too would just duplicate the line per stage.
|
|
84
|
+
rescue Errors::Throttled
|
|
85
|
+
raise
|
|
86
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
68
87
|
rescue StandardError => e
|
|
69
88
|
handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_self')
|
|
70
89
|
{ error: e.message }
|
|
@@ -97,6 +116,13 @@ module Legion
|
|
|
97
116
|
end
|
|
98
117
|
|
|
99
118
|
{ people: people, count: people.length }
|
|
119
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
120
|
+
# Re-raise unlogged: full_ingest logs the throttle once and aborts
|
|
121
|
+
# the fan-out. A throttle is a fleet signal, not a per-stage error;
|
|
122
|
+
# logging it here too would just duplicate the line per stage.
|
|
123
|
+
rescue Errors::Throttled
|
|
124
|
+
raise
|
|
125
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
100
126
|
rescue StandardError => e
|
|
101
127
|
handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_people')
|
|
102
128
|
{ error: e.message, skipped: false }
|
|
@@ -106,15 +132,19 @@ module Legion
|
|
|
106
132
|
return { ingested: 0 } if people.empty?
|
|
107
133
|
|
|
108
134
|
conn = graph_connection(token: token)
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
chats = cached_graph_get(conn: conn, path: 'me/chats',
|
|
136
|
+
params: { '$top' => 50 })
|
|
137
|
+
.then { |body| (body || {}).fetch('value', []) }
|
|
138
|
+
one_on_ones = chats.select { |c| c['chatType'] == 'oneOnOne' }
|
|
139
|
+
|
|
140
|
+
email_to_chat = build_chat_member_index(conn: conn, chats: one_on_ones)
|
|
111
141
|
ingested = 0
|
|
112
142
|
|
|
113
143
|
people.first(top_people).each do |person|
|
|
114
|
-
email = person.dig('scoredEmailAddresses', 0, 'address')
|
|
144
|
+
email = person.dig('scoredEmailAddresses', 0, 'address')&.downcase
|
|
115
145
|
next unless email
|
|
116
146
|
|
|
117
|
-
chat =
|
|
147
|
+
chat = email_to_chat[email]
|
|
118
148
|
next unless chat
|
|
119
149
|
|
|
120
150
|
messages = fetch_new_messages(conn: conn, chat_id: chat['id'], depth: message_depth)
|
|
@@ -142,6 +172,13 @@ module Legion
|
|
|
142
172
|
end
|
|
143
173
|
|
|
144
174
|
{ ingested: ingested }
|
|
175
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
176
|
+
# Re-raise unlogged: full_ingest logs the throttle once and aborts
|
|
177
|
+
# the fan-out. A throttle is a fleet signal, not a per-stage error;
|
|
178
|
+
# logging it here too would just duplicate the line per stage.
|
|
179
|
+
rescue Errors::Throttled
|
|
180
|
+
raise
|
|
181
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
145
182
|
rescue StandardError => e
|
|
146
183
|
handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_conversations')
|
|
147
184
|
{ error: e.message, ingested: ingested || 0 }
|
|
@@ -188,6 +225,13 @@ module Legion
|
|
|
188
225
|
end
|
|
189
226
|
|
|
190
227
|
{ teams: teams_count, meetings: meetings_count }
|
|
228
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
229
|
+
# Re-raise unlogged: full_ingest logs the throttle once and aborts
|
|
230
|
+
# the fan-out. A throttle is a fleet signal, not a per-stage error;
|
|
231
|
+
# logging it here too would just duplicate the line per stage.
|
|
232
|
+
rescue Errors::Throttled
|
|
233
|
+
raise
|
|
234
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
191
235
|
rescue StandardError => e
|
|
192
236
|
handle_exception(e, level: :error, operation: 'ProfileIngest#ingest_teams_and_meetings')
|
|
193
237
|
{ error: e.message }
|
|
@@ -206,15 +250,27 @@ module Legion
|
|
|
206
250
|
|
|
207
251
|
private
|
|
208
252
|
|
|
209
|
-
def
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
members = (
|
|
213
|
-
|
|
253
|
+
def build_chat_member_index(conn:, chats:)
|
|
254
|
+
index = {}
|
|
255
|
+
chats.each do |chat|
|
|
256
|
+
members = cached_graph_get(conn: conn, path: "chats/#{chat['id']}/members",
|
|
257
|
+
shared: true)
|
|
258
|
+
.then { |body| (body || {}).fetch('value', []) }
|
|
259
|
+
members.each do |m|
|
|
260
|
+
email = m['email']&.downcase
|
|
261
|
+
index[email] = chat if email && !index.key?(email)
|
|
262
|
+
end
|
|
214
263
|
end
|
|
264
|
+
index
|
|
265
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
266
|
+
# Re-raise unlogged: propagate to full_ingest so the per-chat
|
|
267
|
+
# fan-out stops on the first throttle; full_ingest logs it once.
|
|
268
|
+
rescue Errors::Throttled
|
|
269
|
+
raise
|
|
270
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
215
271
|
rescue StandardError => e
|
|
216
|
-
handle_exception(e, level: :
|
|
217
|
-
|
|
272
|
+
handle_exception(e, level: :warn, operation: 'ProfileIngest#build_chat_member_index')
|
|
273
|
+
{}
|
|
218
274
|
end
|
|
219
275
|
|
|
220
276
|
def fetch_new_messages(conn:, chat_id:, depth: 50)
|
|
@@ -224,6 +280,12 @@ module Legion
|
|
|
224
280
|
|
|
225
281
|
resp = conn.get("chats/#{chat_id}/messages", params)
|
|
226
282
|
(resp.body || {}).fetch('value', [])
|
|
283
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
284
|
+
# Re-raise unlogged: propagate to full_ingest so the per-chat
|
|
285
|
+
# fan-out stops on the first throttle; full_ingest logs it once.
|
|
286
|
+
rescue Errors::Throttled
|
|
287
|
+
raise
|
|
288
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
227
289
|
rescue StandardError => e
|
|
228
290
|
handle_exception(e, level: :warn, operation: 'ProfileIngest#fetch_new_messages',
|
|
229
291
|
chat_id: chat_id)
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
require 'legion/extensions/identity/entra/helpers/token_manager'
|
|
4
4
|
require 'legion/extensions/microsoft_teams/version'
|
|
5
|
+
require 'legion/extensions/microsoft_teams/errors'
|
|
6
|
+
require 'legion/extensions/microsoft_teams/faraday/retry_after'
|
|
7
|
+
require 'legion/extensions/microsoft_teams/helpers/throttle_aware'
|
|
5
8
|
require 'legion/extensions/microsoft_teams/helpers/client'
|
|
6
9
|
require 'legion/extensions/microsoft_teams/runners/auth'
|
|
7
10
|
require 'legion/extensions/microsoft_teams/runners/teams'
|
|
@@ -29,6 +32,7 @@ require 'legion/extensions/microsoft_teams/runners/app_installations'
|
|
|
29
32
|
require 'legion/extensions/microsoft_teams/runners/files'
|
|
30
33
|
|
|
31
34
|
# Helpers (bot)
|
|
35
|
+
require 'legion/extensions/microsoft_teams/helpers/graph_cache'
|
|
32
36
|
require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
|
|
33
37
|
require 'legion/extensions/microsoft_teams/helpers/prompt_resolver'
|
|
34
38
|
require 'legion/extensions/microsoft_teams/helpers/trace_retriever'
|
|
@@ -60,6 +64,62 @@ module Legion
|
|
|
60
64
|
module MicrosoftTeams
|
|
61
65
|
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core, false
|
|
62
66
|
|
|
67
|
+
def self.default_settings # rubocop:disable Metrics/MethodLength
|
|
68
|
+
{
|
|
69
|
+
api_ingest: {
|
|
70
|
+
enabled: true,
|
|
71
|
+
interval: 3600,
|
|
72
|
+
top_people: 15,
|
|
73
|
+
message_depth: 50,
|
|
74
|
+
skip_bots: true
|
|
75
|
+
},
|
|
76
|
+
incremental_sync: {
|
|
77
|
+
enabled: true,
|
|
78
|
+
interval: 900,
|
|
79
|
+
top_people: 10,
|
|
80
|
+
message_depth: 50
|
|
81
|
+
},
|
|
82
|
+
profile_ingest: {
|
|
83
|
+
enabled: true,
|
|
84
|
+
top_people: 10,
|
|
85
|
+
message_depth: 50
|
|
86
|
+
},
|
|
87
|
+
presence_poller: {
|
|
88
|
+
enabled: false,
|
|
89
|
+
interval: 300
|
|
90
|
+
},
|
|
91
|
+
meeting_ingest: {
|
|
92
|
+
enabled: true,
|
|
93
|
+
interval: 900
|
|
94
|
+
},
|
|
95
|
+
channel_poller: {
|
|
96
|
+
enabled: false,
|
|
97
|
+
interval: 120,
|
|
98
|
+
max_teams: 10,
|
|
99
|
+
max_channels_per_team: 5
|
|
100
|
+
},
|
|
101
|
+
direct_chat_poller: {
|
|
102
|
+
enabled: false,
|
|
103
|
+
interval: 30
|
|
104
|
+
},
|
|
105
|
+
observed_chat_poller: {
|
|
106
|
+
enabled: false,
|
|
107
|
+
interval: 60
|
|
108
|
+
},
|
|
109
|
+
cache: {
|
|
110
|
+
graph_ttl: 300
|
|
111
|
+
},
|
|
112
|
+
client: {
|
|
113
|
+
throttle_circuit: {
|
|
114
|
+
soft_percentage: 0.8,
|
|
115
|
+
soft_ttl: 60,
|
|
116
|
+
fallback_ttl: 60,
|
|
117
|
+
insights_ttl: 600
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
end
|
|
122
|
+
|
|
63
123
|
def self.trigger_words
|
|
64
124
|
%w[teams microsoft_teams microsoftteams microsoft-teams msteams ms-teams]
|
|
65
125
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-microsoft_teams
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.6.
|
|
4
|
+
version: 0.6.52
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -207,13 +207,18 @@ files:
|
|
|
207
207
|
- lib/legion/extensions/microsoft_teams/actors/presence_poller.rb
|
|
208
208
|
- lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb
|
|
209
209
|
- lib/legion/extensions/microsoft_teams/client.rb
|
|
210
|
+
- lib/legion/extensions/microsoft_teams/errors.rb
|
|
211
|
+
- lib/legion/extensions/microsoft_teams/faraday/retry_after.rb
|
|
212
|
+
- lib/legion/extensions/microsoft_teams/faraday/throttle_circuit.rb
|
|
210
213
|
- lib/legion/extensions/microsoft_teams/helpers/client.rb
|
|
214
|
+
- lib/legion/extensions/microsoft_teams/helpers/graph_cache.rb
|
|
211
215
|
- lib/legion/extensions/microsoft_teams/helpers/graph_client.rb
|
|
212
216
|
- lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb
|
|
213
217
|
- lib/legion/extensions/microsoft_teams/helpers/permission_guard.rb
|
|
214
218
|
- lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb
|
|
215
219
|
- lib/legion/extensions/microsoft_teams/helpers/session_manager.rb
|
|
216
220
|
- lib/legion/extensions/microsoft_teams/helpers/subscription_registry.rb
|
|
221
|
+
- lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb
|
|
217
222
|
- lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb
|
|
218
223
|
- lib/legion/extensions/microsoft_teams/helpers/transform_definitions.rb
|
|
219
224
|
- lib/legion/extensions/microsoft_teams/local_cache/extractor.rb
|