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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac161b519f294da86eb8956d3aab55f11f4cd87d7fe710c43c033640b630efe5
|
|
4
|
+
data.tar.gz: bf64b0446e44cd89f9f9cc8be018a2629f0d375ccbfea4848e4668a16ad04598
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 926fd8d8f6aa8bbe022fbb17491b959faea7580dd28dee76c48199392fb3d50bd917934ed4025f4a65f20f7353412abbc6788de4634ebeb00d3bf07497e53e28
|
|
7
|
+
data.tar.gz: 26ee2c81c7e0330b575b2aedb8f2284d86c4d40a5b42d089b8edce9a6cd33b06699cf2bac2e7671ff62eff830940ed179b27db1cbcdf2e171d856916a24a296f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.6.52] - 2026-05-29
|
|
4
|
+
### Fixed
|
|
5
|
+
- **Actors now defer their next scheduled run on a Graph throttle** instead of re-firing on their fixed timer interval — resolves the 0.6.51 "Known follow-up". New `Helpers::ThrottleAware` mixin records a "suppress until" instant of `now + retry_after` when a Graph call raises `Errors::Throttled`; subsequent ticks short-circuit until that window elapses. Wired into `PresencePoller`, `MeetingIngest`, and `ApiIngest`. Falls back to a bounded 60s deferral when the server gave no usable `Retry-After`, and clamps any single deferral to 600s. Previously a poller on a 30–300s interval would walk straight back into the still-open shared circuit every interval, emitting a `[throttle_circuit] hard circuit` ERROR (and a duplicate `Errors::Throttled` WARN/ERROR) each tick while ingesting nothing.
|
|
6
|
+
- **`ProfileIngest#full_ingest` and `ApiIngest#ingest_api` now propagate `Errors::Throttled`** rather than folding it into a per-stage error hash. `full_ingest` catches it once and aborts the remaining fan-out stages — once the shared circuit is open every later stage would only raise `Throttled(attempts: 0)` instantly, so continuing produced a burst of identical errors. The actor that drives `ingest_api` uses the propagated throttle to open its deferral window. Non-throttle errors keep their existing error-result behavior.
|
|
7
|
+
|
|
8
|
+
## [0.6.51] - 2026-05-27
|
|
9
|
+
### Added
|
|
10
|
+
- `Faraday::RetryAfter` middleware honoring `Retry-After` per RFC 9110 §10.2.3 (originally RFC 7231 §7.1.3) in both delta-seconds and HTTP-date forms. Retries HTTP 429 by default; 503/504 opt-in. Bounded by `max_retries` and cumulative `max_wait`; ±jitter on advertised wait to avoid thundering-herd. Configurable via `microsoft_teams.client.retry.{max_retries,max_wait,jitter,fallback_wait,retry_statuses}`; `fetch` semantics preserve explicit falsey values.
|
|
11
|
+
- `Errors::Throttled` exception with `status`, `retry_after` (nullable; `nil` distinguishes "no server guidance" from "retry immediately"), `retry_after_known?` predicate, `request`, and `attempts`.
|
|
12
|
+
- `Faraday::RetryAfter.parse_header` shared class method (single source of truth for Retry-After parsing).
|
|
13
|
+
- `default_settings` for all actors with explicit `enabled`, `interval`, and tuning knobs. Actors no longer need nil guards — values are guaranteed by the extension settings merge on boot. Defaults: `api_ingest` 3600s, `incremental_sync` 900s, `presence_poller` disabled, `channel_poller` disabled, `direct_chat_poller` disabled, `observed_chat_poller` disabled, `meeting_ingest` 900s, `profile_ingest` enabled (once on boot).
|
|
14
|
+
- `Helpers::GraphCache` module — `cached_graph_get` wraps Graph API calls with `Legion::Cache` TTL-based caching. Supports `shared: true` for resource-scoped endpoints (e.g., `/chats/{id}/members` — same data for all participants) vs user-scoped for `/me/*` endpoints. Cache keys incorporate the process identity UUID to prevent cross-user leakage.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Stuck or chatty consumers no longer brown out other users on the same Entra app registration's Graph quota. The `RetryAfter` middleware now raises `Errors::Throttled` **centrally on exhaustion** — every consumer of `graph_connection` and `bot_connection` gets the typed event without per-callsite handling. Fixes #18.
|
|
18
|
+
- `Helpers::GraphClient#handle_graph_response` retains a defensive 429/503/504 branch that raises `Errors::Throttled` for callers that build a Faraday connection without the middleware (custom tests, ad-hoc tooling).
|
|
19
|
+
- Logger acquisition failures no longer silently drop retry telemetry — falls back to `Legion::Logging` unconditionally; loss of those signals was how the original outage went undiagnosed for days.
|
|
20
|
+
- **O(N×M) member scan eliminated** — `ProfileIngest#find_chat_for_person` and `ApiIngest#match_chat_to_person` previously called `GET /chats/{id}/members` for every chat × every person (up to 500+ calls per tick). Replaced with `build_chat_member_index` that fetches members once per chat and builds an in-memory lookup hash. Reduces ~514 calls/tick to ~50 for `IncrementalSync`; ~7,500 calls/tick to ~65 for `ApiIngest`.
|
|
21
|
+
- `IncrementalSync` interval raised from 120s to 900s (was the single largest source of sustained Graph pressure).
|
|
22
|
+
- `ApiIngest` interval raised from 1800s to 3600s.
|
|
23
|
+
- Actors that were accidentally re-enabled by `952607c` (rubocop removed `return false` guards) are now properly gated by `settings[:actor_name][:enabled]` — `presence_poller`, `channel_poller`, `direct_chat_poller`, and `observed_chat_poller` all default to `false`.
|
|
24
|
+
|
|
25
|
+
### Known follow-up
|
|
26
|
+
- Actors (`*_poller.rb`, `meeting_ingest`, `profile_ingest`) still catch `Errors::Throttled` via the generic `rescue StandardError` block but do not yet *defer their next scheduled run* using the carried `retry_after`. To be addressed in a follow-up issue. *(Resolved in [Unreleased] — see `Helpers::ThrottleAware`.)*
|
|
27
|
+
|
|
3
28
|
## [0.6.50] - 2026-05-27
|
|
4
29
|
### Added
|
|
5
30
|
- Full OData query parameter support across all Graph API runner methods per Microsoft Graph REST v1.0 docs
|
|
@@ -5,6 +5,8 @@ module Legion
|
|
|
5
5
|
module MicrosoftTeams
|
|
6
6
|
module Actor
|
|
7
7
|
class ApiIngest < Legion::Extensions::Actors::Every
|
|
8
|
+
include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
|
|
9
|
+
|
|
8
10
|
def runner_class = Legion::Extensions::MicrosoftTeams::Runners::ApiIngest
|
|
9
11
|
|
|
10
12
|
def runner_function = 'ingest_api'
|
|
@@ -27,12 +29,12 @@ module Legion
|
|
|
27
29
|
end
|
|
28
30
|
|
|
29
31
|
def time
|
|
30
|
-
|
|
31
|
-
interval.to_i
|
|
32
|
+
teams_settings.dig(:api_ingest, :interval)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def enabled?
|
|
35
|
-
|
|
36
|
+
teams_settings.dig(:api_ingest, :enabled) &&
|
|
37
|
+
defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
|
|
36
38
|
rescue StandardError => e
|
|
37
39
|
handle_exception(e, level: :warn, operation: 'ApiIngest#enabled?')
|
|
38
40
|
false
|
|
@@ -40,29 +42,37 @@ module Legion
|
|
|
40
42
|
|
|
41
43
|
def manual
|
|
42
44
|
log.debug('ApiIngest#manual starting')
|
|
45
|
+
# The runner re-raises Errors::Throttled (rather than folding it
|
|
46
|
+
# into an error result) precisely so this actor can defer its
|
|
47
|
+
# next run by the advertised retry_after instead of charging the
|
|
48
|
+
# shared Graph circuit again on the next interval.
|
|
49
|
+
with_throttle_deferral { run_ingest }
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
handle_exception(e, level: :error, operation: 'ApiIngest#manual')
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def run_ingest
|
|
43
57
|
token = resolve_token
|
|
44
58
|
unless token
|
|
45
59
|
log.warn('ApiIngest: no delegated token, skipping')
|
|
46
60
|
return
|
|
47
61
|
end
|
|
48
62
|
|
|
49
|
-
|
|
63
|
+
ai_settings = teams_settings[:api_ingest]
|
|
50
64
|
log.info('ApiIngest: starting Graph API ingest')
|
|
51
65
|
result = runner_class.ingest_api(
|
|
52
66
|
token: token,
|
|
53
|
-
top_people:
|
|
54
|
-
message_depth:
|
|
55
|
-
skip_bots:
|
|
67
|
+
top_people: ai_settings[:top_people],
|
|
68
|
+
message_depth: ai_settings[:message_depth],
|
|
69
|
+
skip_bots: ai_settings[:skip_bots],
|
|
56
70
|
imprint_active: imprint_active?
|
|
57
71
|
)
|
|
58
72
|
log.info("ApiIngest: #{result.inspect[0, 200]}")
|
|
59
73
|
result
|
|
60
|
-
rescue StandardError => e
|
|
61
|
-
handle_exception(e, level: :error, operation: 'ApiIngest#manual')
|
|
62
74
|
end
|
|
63
75
|
|
|
64
|
-
private
|
|
65
|
-
|
|
66
76
|
def token_available?
|
|
67
77
|
resolve_token != nil
|
|
68
78
|
end
|
|
@@ -75,12 +85,7 @@ module Legion
|
|
|
75
85
|
end
|
|
76
86
|
|
|
77
87
|
def teams_settings
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
Legion::Settings[:microsoft_teams] || {}
|
|
81
|
-
rescue StandardError => e
|
|
82
|
-
handle_exception(e, level: :warn, operation: 'ApiIngest#teams_settings')
|
|
83
|
-
{}
|
|
88
|
+
settings
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
def imprint_active?
|
|
@@ -7,10 +7,6 @@ module Legion
|
|
|
7
7
|
class ChannelPoller < Legion::Extensions::Actors::Every
|
|
8
8
|
include Legion::Extensions::MicrosoftTeams::Helpers::Client
|
|
9
9
|
|
|
10
|
-
DEFAULT_INTERVAL = 60
|
|
11
|
-
DEFAULT_MAX_TEAMS = 10
|
|
12
|
-
DEFAULT_MAX_CHANNELS = 5
|
|
13
|
-
|
|
14
10
|
def initialize(**opts)
|
|
15
11
|
return unless enabled?
|
|
16
12
|
|
|
@@ -20,7 +16,7 @@ module Legion
|
|
|
20
16
|
|
|
21
17
|
def runner_class = self.class
|
|
22
18
|
def runner_function = 'manual'
|
|
23
|
-
def time =
|
|
19
|
+
def time = settings.dig(:channel_poller, :interval)
|
|
24
20
|
def delay = 300
|
|
25
21
|
def run_now? = false
|
|
26
22
|
def use_runner? = false
|
|
@@ -36,7 +32,7 @@ module Legion
|
|
|
36
32
|
end
|
|
37
33
|
|
|
38
34
|
def enabled?
|
|
39
|
-
|
|
35
|
+
settings.dig(:channel_poller, :enabled)
|
|
40
36
|
rescue StandardError => e
|
|
41
37
|
handle_exception(e, level: :debug, operation: 'ChannelPoller#enabled?')
|
|
42
38
|
false
|
|
@@ -148,11 +144,11 @@ module Legion
|
|
|
148
144
|
end
|
|
149
145
|
|
|
150
146
|
def max_teams
|
|
151
|
-
|
|
147
|
+
settings.dig(:channel_poller, :max_teams)
|
|
152
148
|
end
|
|
153
149
|
|
|
154
150
|
def max_channels_per_team
|
|
155
|
-
|
|
151
|
+
settings.dig(:channel_poller, :max_channels_per_team)
|
|
156
152
|
end
|
|
157
153
|
|
|
158
154
|
def delegated_token
|
|
@@ -162,17 +158,8 @@ module Legion
|
|
|
162
158
|
nil
|
|
163
159
|
end
|
|
164
160
|
|
|
165
|
-
def channel_setting(key, default)
|
|
166
|
-
return default unless defined?(Legion::Settings)
|
|
167
|
-
|
|
168
|
-
Legion::Settings.dig(:microsoft_teams, :channels, key) || default
|
|
169
|
-
rescue StandardError => e
|
|
170
|
-
handle_exception(e, level: :debug, operation: "ChannelPoller#channel_setting(#{key})")
|
|
171
|
-
default
|
|
172
|
-
end
|
|
173
|
-
|
|
174
161
|
def channel_traces_enabled?
|
|
175
|
-
|
|
162
|
+
settings.dig(:channel_poller, :store_traces) == true
|
|
176
163
|
end
|
|
177
164
|
|
|
178
165
|
def store_channel_message_trace(team_name:, channel_name:, msg:)
|
|
@@ -8,8 +8,6 @@ module Legion
|
|
|
8
8
|
include Legion::Extensions::MicrosoftTeams::Helpers::Client
|
|
9
9
|
include Legion::Extensions::MicrosoftTeams::Helpers::HighWaterMark
|
|
10
10
|
|
|
11
|
-
POLL_INTERVAL = 15
|
|
12
|
-
|
|
13
11
|
def initialize(**opts)
|
|
14
12
|
return unless enabled?
|
|
15
13
|
|
|
@@ -19,7 +17,7 @@ module Legion
|
|
|
19
17
|
|
|
20
18
|
def runner_class = Legion::Extensions::MicrosoftTeams::Runners::Bot
|
|
21
19
|
def runner_function = 'handle_message'
|
|
22
|
-
def time =
|
|
20
|
+
def time = settings.dig(:direct_chat_poller, :interval)
|
|
23
21
|
def delay = 60
|
|
24
22
|
def run_now? = false
|
|
25
23
|
def use_runner? = false
|
|
@@ -27,7 +25,8 @@ module Legion
|
|
|
27
25
|
def generate_task? = false
|
|
28
26
|
|
|
29
27
|
def enabled?
|
|
30
|
-
|
|
28
|
+
settings.dig(:direct_chat_poller, :enabled) &&
|
|
29
|
+
defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
|
|
31
30
|
Legion.const_defined?(:Transport, false)
|
|
32
31
|
rescue StandardError => e
|
|
33
32
|
handle_exception(e, level: :debug, operation: 'DirectChatPoller#enabled?')
|
|
@@ -95,9 +94,7 @@ module Legion
|
|
|
95
94
|
end
|
|
96
95
|
|
|
97
96
|
def bot_id_from_settings
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
Legion::Settings.dig(:microsoft_teams, :bot, :bot_id)
|
|
97
|
+
settings.dig(:bot, :bot_id)
|
|
101
98
|
end
|
|
102
99
|
|
|
103
100
|
def delegated_token
|
|
@@ -106,12 +103,6 @@ module Legion
|
|
|
106
103
|
handle_exception(e, level: :warn, operation: 'DirectChatPoller#delegated_token')
|
|
107
104
|
nil
|
|
108
105
|
end
|
|
109
|
-
|
|
110
|
-
def settings_interval(key, default)
|
|
111
|
-
return default unless defined?(Legion::Settings)
|
|
112
|
-
|
|
113
|
-
Legion::Settings.dig(:microsoft_teams, :bot, key) || default
|
|
114
|
-
end
|
|
115
106
|
end
|
|
116
107
|
end
|
|
117
108
|
end
|
|
@@ -14,17 +14,12 @@ module Legion
|
|
|
14
14
|
def delay = 60
|
|
15
15
|
|
|
16
16
|
def time
|
|
17
|
-
settings
|
|
18
|
-
Legion::Settings[:microsoft_teams] || {}
|
|
19
|
-
rescue StandardError => e
|
|
20
|
-
handle_exception(e, level: :debug, operation: 'IncrementalSync#time')
|
|
21
|
-
{}
|
|
22
|
-
end
|
|
23
|
-
settings.dig(:ingest, :incremental_interval) || 120
|
|
17
|
+
settings.dig(:incremental_sync, :interval)
|
|
24
18
|
end
|
|
25
19
|
|
|
26
20
|
def enabled?
|
|
27
|
-
|
|
21
|
+
settings.dig(:incremental_sync, :enabled) &&
|
|
22
|
+
defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
|
|
28
23
|
token_available?
|
|
29
24
|
rescue StandardError => e
|
|
30
25
|
handle_exception(e, level: :debug, operation: 'IncrementalSync#enabled?')
|
|
@@ -36,17 +31,11 @@ module Legion
|
|
|
36
31
|
token = resolve_token
|
|
37
32
|
return unless token
|
|
38
33
|
|
|
39
|
-
|
|
40
|
-
Legion::Settings[:microsoft_teams] || {}
|
|
41
|
-
rescue StandardError => e
|
|
42
|
-
handle_exception(e, level: :debug, operation: 'IncrementalSync#manual settings')
|
|
43
|
-
{}
|
|
44
|
-
end
|
|
45
|
-
ingest = settings[:ingest] || {}
|
|
34
|
+
is_settings = settings[:incremental_sync]
|
|
46
35
|
runner_class.incremental_sync(
|
|
47
36
|
token: token,
|
|
48
|
-
top_people:
|
|
49
|
-
message_depth:
|
|
37
|
+
top_people: is_settings[:top_people],
|
|
38
|
+
message_depth: is_settings[:message_depth]
|
|
50
39
|
)
|
|
51
40
|
rescue StandardError => e
|
|
52
41
|
handle_exception(e, level: :error, operation: 'IncrementalSync#manual')
|
|
@@ -6,8 +6,7 @@ module Legion
|
|
|
6
6
|
module Actor
|
|
7
7
|
class MeetingIngest < Legion::Extensions::Actors::Every
|
|
8
8
|
include Legion::Extensions::MicrosoftTeams::Helpers::Client
|
|
9
|
-
|
|
10
|
-
DEFAULT_INGEST_INTERVAL = 300
|
|
9
|
+
include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
|
|
11
10
|
|
|
12
11
|
def runner_class = self.class
|
|
13
12
|
def runner_function = 'manual'
|
|
@@ -22,17 +21,12 @@ module Legion
|
|
|
22
21
|
end
|
|
23
22
|
|
|
24
23
|
def time
|
|
25
|
-
settings
|
|
26
|
-
Legion::Settings[:microsoft_teams] || {}
|
|
27
|
-
rescue StandardError => e
|
|
28
|
-
handle_exception(e, level: :debug, operation: 'MeetingIngest#time')
|
|
29
|
-
{}
|
|
30
|
-
end
|
|
31
|
-
settings.dig(:meetings, :ingest_interval) || DEFAULT_INGEST_INTERVAL
|
|
24
|
+
settings.dig(:meeting_ingest, :interval)
|
|
32
25
|
end
|
|
33
26
|
|
|
34
27
|
def enabled?
|
|
35
|
-
|
|
28
|
+
settings.dig(:meeting_ingest, :enabled) &&
|
|
29
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
|
|
36
30
|
rescue StandardError => e
|
|
37
31
|
handle_exception(e, level: :debug, operation: 'MeetingIngest#enabled?')
|
|
38
32
|
false
|
|
@@ -48,6 +42,17 @@ module Legion
|
|
|
48
42
|
|
|
49
43
|
def manual
|
|
50
44
|
log.info('MeetingIngest polling for meetings')
|
|
45
|
+
# Defer the next run when Graph throttles the meetings listing
|
|
46
|
+
# (the first, fixed call every tick makes); per-meeting throttles
|
|
47
|
+
# are handled by the inner rescue below and don't change cadence.
|
|
48
|
+
with_throttle_deferral { ingest_meetings }
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
handle_exception(e, level: :error, operation: 'MeetingIngest#manual')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def ingest_meetings
|
|
51
56
|
token = Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
|
|
52
57
|
return if token.nil?
|
|
53
58
|
|
|
@@ -64,16 +69,12 @@ module Legion
|
|
|
64
69
|
process_meeting(meeting_id: meeting_id, subject: meeting['subject'], token: token)
|
|
65
70
|
@processed_meetings.add(meeting_id)
|
|
66
71
|
rescue StandardError => e
|
|
67
|
-
handle_exception(e, level: :error, operation: 'MeetingIngest#
|
|
72
|
+
handle_exception(e, level: :error, operation: 'MeetingIngest#ingest_meetings',
|
|
68
73
|
meeting_id: meeting_id)
|
|
69
74
|
end
|
|
70
75
|
end
|
|
71
|
-
rescue StandardError => e
|
|
72
|
-
handle_exception(e, level: :error, operation: 'MeetingIngest#manual')
|
|
73
76
|
end
|
|
74
77
|
|
|
75
|
-
private
|
|
76
|
-
|
|
77
78
|
def process_meeting(meeting_id:, subject:, token:)
|
|
78
79
|
log.debug("MeetingIngest#process_meeting meeting_id=#{meeting_id} subject=#{subject}")
|
|
79
80
|
conn = graph_connection(token: token)
|
|
@@ -8,8 +8,6 @@ module Legion
|
|
|
8
8
|
include Legion::Extensions::MicrosoftTeams::Helpers::Client
|
|
9
9
|
include Legion::Extensions::MicrosoftTeams::Helpers::HighWaterMark
|
|
10
10
|
|
|
11
|
-
POLL_INTERVAL = 30
|
|
12
|
-
|
|
13
11
|
def initialize(**opts)
|
|
14
12
|
return unless enabled?
|
|
15
13
|
|
|
@@ -18,7 +16,7 @@ module Legion
|
|
|
18
16
|
|
|
19
17
|
def runner_class = Legion::Extensions::MicrosoftTeams::Runners::Bot
|
|
20
18
|
def runner_function = 'observe_message'
|
|
21
|
-
def time =
|
|
19
|
+
def time = settings.dig(:observed_chat_poller, :interval)
|
|
22
20
|
def delay = 180
|
|
23
21
|
def run_now? = false
|
|
24
22
|
def use_runner? = false
|
|
@@ -26,11 +24,9 @@ module Legion
|
|
|
26
24
|
def generate_task? = false
|
|
27
25
|
|
|
28
26
|
def enabled?
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
Legion::Settings.dig(:microsoft_teams, :bot, :observe, :enabled) == true
|
|
27
|
+
settings.dig(:observed_chat_poller, :enabled) &&
|
|
28
|
+
defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
|
|
29
|
+
Legion.const_defined?(:Transport, false)
|
|
34
30
|
rescue StandardError => e
|
|
35
31
|
handle_exception(e, level: :debug, operation: 'ObservedChatPoller#enabled?')
|
|
36
32
|
false
|
|
@@ -106,12 +102,6 @@ module Legion
|
|
|
106
102
|
handle_exception(e, level: :warn, operation: 'ObservedChatPoller#delegated_token')
|
|
107
103
|
nil
|
|
108
104
|
end
|
|
109
|
-
|
|
110
|
-
def settings_interval(key, default)
|
|
111
|
-
return default unless defined?(Legion::Settings)
|
|
112
|
-
|
|
113
|
-
Legion::Settings.dig(:microsoft_teams, :bot, key) || default
|
|
114
|
-
end
|
|
115
105
|
end
|
|
116
106
|
end
|
|
117
107
|
end
|
|
@@ -6,8 +6,7 @@ module Legion
|
|
|
6
6
|
module Actor
|
|
7
7
|
class PresencePoller < Legion::Extensions::Actors::Every
|
|
8
8
|
include Legion::Extensions::MicrosoftTeams::Helpers::Client
|
|
9
|
-
|
|
10
|
-
DEFAULT_POLL_INTERVAL = 60
|
|
9
|
+
include Legion::Extensions::MicrosoftTeams::Helpers::ThrottleAware
|
|
11
10
|
|
|
12
11
|
def runner_class = self.class
|
|
13
12
|
def runner_function = 'manual'
|
|
@@ -17,13 +16,12 @@ module Legion
|
|
|
17
16
|
def generate_task? = false
|
|
18
17
|
|
|
19
18
|
def time
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
Legion::Settings.dig(:microsoft_teams, :presence, :poll_interval) || DEFAULT_POLL_INTERVAL
|
|
19
|
+
settings.dig(:presence_poller, :interval)
|
|
23
20
|
end
|
|
24
21
|
|
|
25
22
|
def enabled?
|
|
26
|
-
|
|
23
|
+
settings.dig(:presence_poller, :enabled) &&
|
|
24
|
+
Legion::Extensions::Identity::Entra::Helpers::TokenManager.respond_to?(:load_token)
|
|
27
25
|
rescue StandardError => e
|
|
28
26
|
handle_exception(e, level: :debug, operation: 'PresencePoller#enabled?')
|
|
29
27
|
false
|
|
@@ -31,6 +29,18 @@ module Legion
|
|
|
31
29
|
|
|
32
30
|
def manual
|
|
33
31
|
log.debug('PresencePoller#manual starting')
|
|
32
|
+
# Honour a recent Graph throttle by deferring this run; on a
|
|
33
|
+
# 429 the block raises Errors::Throttled, which the mixin
|
|
34
|
+
# converts into a deferral window so the next tick stands down
|
|
35
|
+
# for retry_after seconds instead of re-firing on the interval.
|
|
36
|
+
with_throttle_deferral { poll_presence }
|
|
37
|
+
rescue StandardError => e
|
|
38
|
+
handle_exception(e, level: :error, operation: 'PresencePoller#manual')
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def poll_presence
|
|
34
44
|
token = delegated_token
|
|
35
45
|
unless token
|
|
36
46
|
log.debug('No token available, skipping presence poll')
|
|
@@ -52,12 +62,8 @@ module Legion
|
|
|
52
62
|
log.info("Presence changed: availability=#{availability}, activity=#{activity}")
|
|
53
63
|
@last_presence = current
|
|
54
64
|
end
|
|
55
|
-
rescue StandardError => e
|
|
56
|
-
handle_exception(e, level: :error, operation: 'PresencePoller#manual')
|
|
57
65
|
end
|
|
58
66
|
|
|
59
|
-
private
|
|
60
|
-
|
|
61
67
|
def delegated_token
|
|
62
68
|
Legion::Extensions::Identity::Entra::Helpers::TokenManager.load_token(:delegated)
|
|
63
69
|
rescue StandardError => e
|
|
@@ -21,7 +21,8 @@ module Legion
|
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def enabled?
|
|
24
|
-
|
|
24
|
+
settings.dig(:profile_ingest, :enabled) &&
|
|
25
|
+
defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
|
|
25
26
|
token_available?
|
|
26
27
|
rescue StandardError => e
|
|
27
28
|
handle_exception(e, level: :debug, operation: 'ProfileIngest#enabled?')
|
|
@@ -37,17 +38,11 @@ module Legion
|
|
|
37
38
|
end
|
|
38
39
|
log.info('ProfileIngest: token acquired, starting ingest')
|
|
39
40
|
|
|
40
|
-
|
|
41
|
-
Legion::Settings[:microsoft_teams] || {}
|
|
42
|
-
rescue StandardError => e
|
|
43
|
-
handle_exception(e, level: :debug, operation: 'ProfileIngest#manual settings')
|
|
44
|
-
{}
|
|
45
|
-
end
|
|
46
|
-
ingest = settings[:ingest] || {}
|
|
41
|
+
pi_settings = settings[:profile_ingest]
|
|
47
42
|
runner_class.full_ingest(
|
|
48
43
|
token: token,
|
|
49
|
-
top_people:
|
|
50
|
-
message_depth:
|
|
44
|
+
top_people: pi_settings[:top_people],
|
|
45
|
+
message_depth: pi_settings[:message_depth]
|
|
51
46
|
)
|
|
52
47
|
rescue StandardError => e
|
|
53
48
|
handle_exception(e, level: :error, operation: 'ProfileIngest#manual')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MicrosoftTeams
|
|
6
|
+
module Errors
|
|
7
|
+
# Raised when Microsoft Graph (or the Bot Framework) throttles the
|
|
8
|
+
# caller and the retry policy has been exhausted, or when an actor
|
|
9
|
+
# wants to surface a throttle event without retrying further.
|
|
10
|
+
#
|
|
11
|
+
# `retry_after` carries the last advertised Retry-After interval
|
|
12
|
+
# (in seconds) as parsed from the upstream header. **It is nil when
|
|
13
|
+
# the server returned no Retry-After header or one we could not
|
|
14
|
+
# parse** — callers must check `retry_after_known?` before treating
|
|
15
|
+
# the value as a server directive. Conflating "header missing" with
|
|
16
|
+
# "retry immediately" was the bug the original fleet outage exposed.
|
|
17
|
+
class Throttled < StandardError
|
|
18
|
+
attr_reader :status, :retry_after, :request, :attempts
|
|
19
|
+
|
|
20
|
+
# @param status [Integer] the upstream HTTP status (e.g. 429)
|
|
21
|
+
# @param retry_after [Float, Integer, nil] seconds the server
|
|
22
|
+
# advised waiting, or nil if the header was absent/unparseable
|
|
23
|
+
# @param request [String, nil] the path or URL that was throttled
|
|
24
|
+
# @param attempts [Integer, nil] how many retries the middleware
|
|
25
|
+
# tried before giving up; nil means "not tracked"
|
|
26
|
+
def initialize(status:, retry_after: nil, request: nil, attempts: nil)
|
|
27
|
+
@status = coerce_status(status)
|
|
28
|
+
@retry_after = coerce_retry_after(retry_after)
|
|
29
|
+
@request = request
|
|
30
|
+
@attempts = attempts.nil? ? nil : Integer(attempts)
|
|
31
|
+
super(build_message)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @return [Boolean] true when the upstream advised a specific wait
|
|
35
|
+
# interval; false when the header was missing or unparseable. Use
|
|
36
|
+
# this to decide whether to honor the wait verbatim or apply a
|
|
37
|
+
# local policy default before re-scheduling.
|
|
38
|
+
def retry_after_known?
|
|
39
|
+
!@retry_after.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def coerce_status(value)
|
|
45
|
+
Integer(value)
|
|
46
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
47
|
+
# No logger available during exception construction; we re-raise
|
|
48
|
+
# with a clearer message instead.
|
|
49
|
+
rescue ArgumentError, TypeError
|
|
50
|
+
raise ArgumentError, "Throttled status must be an Integer, got #{value.inspect}"
|
|
51
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def coerce_retry_after(value)
|
|
55
|
+
return nil if value.nil?
|
|
56
|
+
|
|
57
|
+
seconds = Float(value)
|
|
58
|
+
seconds.negative? ? 0.0 : seconds
|
|
59
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
60
|
+
# Unparseable retry_after intentionally collapses to nil so the
|
|
61
|
+
# public `retry_after_known?` predicate is the single source of
|
|
62
|
+
# truth for "did the server give us usable guidance." Logging
|
|
63
|
+
# belongs at the parse site (Faraday::RetryAfter), not here.
|
|
64
|
+
rescue ArgumentError, TypeError
|
|
65
|
+
nil
|
|
66
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_message
|
|
70
|
+
parts = ["Microsoft Graph throttled (HTTP #{@status})"]
|
|
71
|
+
parts << "after #{@attempts} attempt(s)" if @attempts
|
|
72
|
+
parts << if retry_after_known?
|
|
73
|
+
"retry_after=#{format('%.2f', @retry_after)}s"
|
|
74
|
+
else
|
|
75
|
+
'retry_after=unknown'
|
|
76
|
+
end
|
|
77
|
+
parts << "request=#{@request}" if @request
|
|
78
|
+
parts.join('; ')
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|