lex-microsoft_teams 0.6.51 → 0.6.53
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/.rubocop.yml +4 -0
- data/CHANGELOG.md +10 -1
- data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +3 -3
- data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +3 -3
- data/lib/legion/extensions/microsoft_teams/absorbers/meeting.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +16 -6
- data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +14 -6
- data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +14 -5
- data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/faraday/retry_after.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +4 -4
- data/lib/legion/extensions/microsoft_teams/helpers/graph_cache.rb +3 -3
- data/lib/legion/extensions/microsoft_teams/helpers/graph_client.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/helpers/session_manager.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/helpers/throttle_aware.rb +185 -0
- data/lib/legion/extensions/microsoft_teams/helpers/trace_retriever.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +91 -19
- data/lib/legion/extensions/microsoft_teams/runners/bot.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +89 -35
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +3 -1
- metadata +2 -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
|
|
@@ -131,7 +131,7 @@ module Legion
|
|
|
131
131
|
return unless defined?(log)
|
|
132
132
|
|
|
133
133
|
if defined?(handle_exception)
|
|
134
|
-
handle_exception(error, level: :
|
|
134
|
+
handle_exception(error, level: :warn, operation: "TraceRetriever##{method}")
|
|
135
135
|
else
|
|
136
136
|
log.debug("TraceRetriever##{method} failed: #{error.message}")
|
|
137
137
|
end
|
|
@@ -2,6 +2,7 @@
|
|
|
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'
|
|
6
7
|
require 'legion/extensions/microsoft_teams/helpers/graph_cache'
|
|
7
8
|
require 'legion/extensions/microsoft_teams/helpers/permission_guard'
|
|
@@ -21,16 +22,12 @@ module Legion
|
|
|
21
22
|
|
|
22
23
|
definition :ingest_api, mcp_exposed: false
|
|
23
24
|
|
|
24
|
-
# Fetch top contacts via /me/people, then pull recent messages from
|
|
25
|
-
# their 1:1 chats. Stores each message as an individual memory trace
|
|
26
|
-
# (same format as CacheIngest) with dedup by content hash.
|
|
27
|
-
#
|
|
28
|
-
# Requires a delegated token with Chat.Read and People.Read scopes.
|
|
29
25
|
def ingest_api(token:, top_people: 15, message_depth: 50, skip_bots: true, imprint_active: false, **) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
30
26
|
log.debug("ApiIngest#ingest_api top_people=#{top_people} message_depth=#{message_depth}")
|
|
31
27
|
return error_result('lex-memory not loaded') unless memory_available?
|
|
32
28
|
return error_result('no token provided') unless token && !token.empty?
|
|
33
29
|
|
|
30
|
+
@fetch_failures = 0
|
|
34
31
|
restore_hwm_from_traces
|
|
35
32
|
|
|
36
33
|
people = fetch_top_people(token: token, top: top_people)
|
|
@@ -103,16 +100,29 @@ module Legion
|
|
|
103
100
|
|
|
104
101
|
{ result: { stored: stored, skipped: skipped, people_ingested: people_ingested,
|
|
105
102
|
people_found: people.length, chats_found: chats.length,
|
|
103
|
+
fetch_failures: @fetch_failures,
|
|
106
104
|
apollo: apollo_results } }
|
|
105
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
106
|
+
# Re-raise unlogged: surface the typed throttle to the caller (the
|
|
107
|
+
# ApiIngest actor) so it can defer its next scheduled run by the
|
|
108
|
+
# advertised retry_after. The throttle is already logged at the
|
|
109
|
+
# middleware/circuit layer; folding it into an error result here
|
|
110
|
+
# would hide the one signal the actor needs to stop re-charging
|
|
111
|
+
# the shared Graph circuit on its fixed interval.
|
|
112
|
+
rescue Errors::Throttled
|
|
113
|
+
raise
|
|
114
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
107
115
|
rescue StandardError => e
|
|
108
116
|
handle_exception(e, level: :error, operation: 'ApiIngest#ingest_api')
|
|
109
|
-
{ result: { stored: stored || 0, skipped: skipped || 0,
|
|
117
|
+
{ result: { stored: stored || 0, skipped: skipped || 0,
|
|
118
|
+
fetch_failures: @fetch_failures || 0, error: e.message } }
|
|
110
119
|
end
|
|
111
120
|
|
|
112
121
|
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
|
|
113
122
|
Legion::Extensions::Helpers.const_defined?(:Lex, false)
|
|
114
123
|
|
|
115
124
|
MAX_CHAT_PAGES = 10
|
|
125
|
+
CHAT_TYPE_PRIORITY = { 'oneOnOne' => 0, 'group' => 1, 'meeting' => 2 }.freeze
|
|
116
126
|
|
|
117
127
|
private
|
|
118
128
|
|
|
@@ -124,15 +134,24 @@ module Legion
|
|
|
124
134
|
resp = conn.get('me/people', { '$top' => top })
|
|
125
135
|
|
|
126
136
|
log.debug("ApiIngest: fetch_top_people status=#{resp.status} count=#{(resp.body || {}).fetch('value', []).size}")
|
|
127
|
-
|
|
128
|
-
|
|
137
|
+
unless (200..299).cover?(resp.status)
|
|
138
|
+
error_code = resp.body&.dig('error', 'code')
|
|
139
|
+
log.warn('[microsoft_teams][api_ingest] fetch_top_people non-2xx: ' \
|
|
140
|
+
"status=#{resp.status} error_code=#{error_code}")
|
|
141
|
+
record_denial('/me/people', resp.body&.dig('error', 'message') || 'Forbidden') if resp.status == 403
|
|
142
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
129
143
|
return []
|
|
130
144
|
end
|
|
131
145
|
|
|
132
146
|
people = (resp.body || {}).fetch('value', [])
|
|
133
147
|
people.sort_by { |p| -(p.dig('scoredEmailAddresses', 0, 'relevanceScore') || 0) }
|
|
148
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
149
|
+
rescue Errors::Throttled
|
|
150
|
+
raise
|
|
151
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
134
152
|
rescue StandardError => e
|
|
135
153
|
handle_exception(e, level: :warn, operation: 'ApiIngest#fetch_top_people')
|
|
154
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
136
155
|
[]
|
|
137
156
|
end
|
|
138
157
|
|
|
@@ -143,8 +162,17 @@ module Legion
|
|
|
143
162
|
params = { '$top' => 50 }
|
|
144
163
|
pages = 0
|
|
145
164
|
|
|
146
|
-
|
|
165
|
+
MAX_CHAT_PAGES.times do
|
|
147
166
|
resp = conn.get(url, params)
|
|
167
|
+
|
|
168
|
+
unless (200..299).cover?(resp.status)
|
|
169
|
+
error_code = resp.body&.dig('error', 'code')
|
|
170
|
+
log.warn('[microsoft_teams][api_ingest] fetch_one_on_one_chats non-2xx: ' \
|
|
171
|
+
"status=#{resp.status} error_code=#{error_code}")
|
|
172
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
173
|
+
break
|
|
174
|
+
end
|
|
175
|
+
|
|
148
176
|
body = resp.body || {}
|
|
149
177
|
chats = body.fetch('value', [])
|
|
150
178
|
all_chats.concat(chats)
|
|
@@ -152,7 +180,6 @@ module Legion
|
|
|
152
180
|
|
|
153
181
|
next_link = body['@odata.nextLink']
|
|
154
182
|
break unless next_link
|
|
155
|
-
break if pages >= MAX_CHAT_PAGES
|
|
156
183
|
|
|
157
184
|
url = next_link
|
|
158
185
|
params = {}
|
|
@@ -162,8 +189,13 @@ module Legion
|
|
|
162
189
|
filtered = all_chats.select { |c| allowed_types.include?(c['chatType']) }
|
|
163
190
|
log.info("ApiIngest: fetched #{all_chats.size} chats (#{pages} pages), #{filtered.size} eligible (1:1/group/meeting)")
|
|
164
191
|
filtered
|
|
192
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
193
|
+
rescue Errors::Throttled
|
|
194
|
+
raise
|
|
195
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
165
196
|
rescue StandardError => e
|
|
166
197
|
handle_exception(e, level: :warn, operation: 'ApiIngest#fetch_one_on_one_chats')
|
|
198
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
167
199
|
[]
|
|
168
200
|
end
|
|
169
201
|
|
|
@@ -172,9 +204,10 @@ module Legion
|
|
|
172
204
|
by_user_id = {}
|
|
173
205
|
by_name = {}
|
|
174
206
|
|
|
175
|
-
chats.
|
|
207
|
+
sorted = chats.sort_by { |c| CHAT_TYPE_PRIORITY[c['chatType']] || 99 }
|
|
208
|
+
sorted.each do |chat|
|
|
176
209
|
members = cached_graph_get(conn: conn, path: "chats/#{chat['id']}/members",
|
|
177
|
-
shared: true)
|
|
210
|
+
shared: true, ttl: members_cache_ttl)
|
|
178
211
|
.then { |body| (body || {}).fetch('value', []) }
|
|
179
212
|
members.each do |m|
|
|
180
213
|
email = m['email']&.downcase
|
|
@@ -190,8 +223,13 @@ module Legion
|
|
|
190
223
|
end
|
|
191
224
|
|
|
192
225
|
{ email: by_email, user_id: by_user_id, name: by_name }
|
|
226
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
227
|
+
rescue Errors::Throttled
|
|
228
|
+
raise
|
|
229
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
193
230
|
rescue StandardError => e
|
|
194
231
|
handle_exception(e, level: :warn, operation: 'ApiIngest#build_chat_member_index')
|
|
232
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
195
233
|
{ email: {}, user_id: {}, name: {} }
|
|
196
234
|
end
|
|
197
235
|
|
|
@@ -205,17 +243,41 @@ module Legion
|
|
|
205
243
|
chat_index[:name][display_name]
|
|
206
244
|
end
|
|
207
245
|
|
|
246
|
+
def hwm_client_side_cut(messages:, hwm:)
|
|
247
|
+
return messages unless hwm&.dig(:last_message_at)
|
|
248
|
+
|
|
249
|
+
cutoff = hwm[:last_message_at]
|
|
250
|
+
seen = Set.new
|
|
251
|
+
messages.take_while { |m| m['createdDateTime'] && m['createdDateTime'] > cutoff }
|
|
252
|
+
.select { |m| seen.add?(m['id'] || Digest::SHA256.hexdigest(m.dig('body', 'content').to_s)[0, 16]) }
|
|
253
|
+
end
|
|
254
|
+
|
|
208
255
|
def fetch_chat_messages(conn:, chat_id:, depth: 50)
|
|
209
256
|
hwm = get_extended_hwm(chat_id: chat_id)
|
|
210
257
|
params = { '$top' => depth, '$orderby' => 'createdDateTime desc' }
|
|
211
|
-
params['$filter'] = "createdDateTime gt #{hwm[:last_message_at]}" if hwm&.dig(:last_message_at)
|
|
212
258
|
|
|
213
259
|
resp = conn.get("chats/#{chat_id}/messages", params)
|
|
214
|
-
|
|
215
|
-
(
|
|
260
|
+
|
|
261
|
+
unless (200..299).cover?(resp.status)
|
|
262
|
+
error_code = resp.body&.dig('error', 'code')
|
|
263
|
+
log.warn('[microsoft_teams][api_ingest] fetch_chat_messages non-2xx: ' \
|
|
264
|
+
"chat_id=#{chat_id} status=#{resp.status} error_code=#{error_code}")
|
|
265
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
266
|
+
return []
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
messages = (resp.body || {}).fetch('value', [])
|
|
270
|
+
messages = hwm_client_side_cut(messages: messages, hwm: hwm)
|
|
271
|
+
log.debug("ApiIngest: fetch_messages chat=#{chat_id} count=#{messages.size}")
|
|
272
|
+
messages
|
|
273
|
+
# rubocop:disable Legion/RescueLogging/NoCapture
|
|
274
|
+
rescue Errors::Throttled
|
|
275
|
+
raise
|
|
276
|
+
# rubocop:enable Legion/RescueLogging/NoCapture
|
|
216
277
|
rescue StandardError => e
|
|
217
278
|
handle_exception(e, level: :warn, operation: 'ApiIngest#fetch_chat_messages',
|
|
218
279
|
chat_id: chat_id)
|
|
280
|
+
@fetch_failures = (@fetch_failures || 0) + 1
|
|
219
281
|
[]
|
|
220
282
|
end
|
|
221
283
|
|
|
@@ -284,7 +346,7 @@ module Legion
|
|
|
284
346
|
end
|
|
285
347
|
hashes
|
|
286
348
|
rescue StandardError => e
|
|
287
|
-
handle_exception(e, level: :
|
|
349
|
+
handle_exception(e, level: :warn, operation: 'ApiIngest#load_existing_hashes')
|
|
288
350
|
Set.new
|
|
289
351
|
end
|
|
290
352
|
|
|
@@ -313,12 +375,12 @@ module Legion
|
|
|
313
375
|
trace_ids.each_cons(2) do |id_a, id_b|
|
|
314
376
|
store.record_coactivation(id_a, id_b)
|
|
315
377
|
rescue StandardError => e
|
|
316
|
-
handle_exception(e, level: :
|
|
378
|
+
handle_exception(e, level: :warn, operation: 'ApiIngest#coactivate_thread_traces',
|
|
317
379
|
id_a: id_a, id_b: id_b)
|
|
318
380
|
end
|
|
319
381
|
end
|
|
320
382
|
rescue StandardError => e
|
|
321
|
-
handle_exception(e, level: :
|
|
383
|
+
handle_exception(e, level: :warn, operation: 'ApiIngest#coactivate_thread_traces')
|
|
322
384
|
end
|
|
323
385
|
|
|
324
386
|
def publish_to_apollo(person_texts)
|
|
@@ -377,7 +439,7 @@ module Legion
|
|
|
377
439
|
|
|
378
440
|
{ success: true, count: result[:entities].length }
|
|
379
441
|
rescue StandardError => e
|
|
380
|
-
handle_exception(e, level: :
|
|
442
|
+
handle_exception(e, level: :warn, operation: 'ApiIngest#extract_and_ingest_entities',
|
|
381
443
|
person_name: person_name)
|
|
382
444
|
{ success: false, count: 0 }
|
|
383
445
|
end
|
|
@@ -396,6 +458,16 @@ module Legion
|
|
|
396
458
|
@apollo_knowledge_runner ||= Object.new.extend(Legion::Extensions::Apollo::Runners::Knowledge)
|
|
397
459
|
end
|
|
398
460
|
|
|
461
|
+
def members_cache_ttl
|
|
462
|
+
return @members_cache_ttl if defined?(@members_cache_ttl)
|
|
463
|
+
|
|
464
|
+
@members_cache_ttl = if respond_to?(:settings, true) && settings.respond_to?(:dig)
|
|
465
|
+
settings.dig(:cache, :members_ttl) || 86_400
|
|
466
|
+
else
|
|
467
|
+
86_400
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
399
471
|
def error_result(message)
|
|
400
472
|
{ result: { stored: 0, skipped: 0, error: message } }
|
|
401
473
|
end
|
|
@@ -200,7 +200,7 @@ module Legion
|
|
|
200
200
|
|
|
201
201
|
retrieve_context(message: message, owner_id: owner_id, chat_id: chat_id)
|
|
202
202
|
rescue StandardError => e
|
|
203
|
-
handle_exception(e, level: :
|
|
203
|
+
handle_exception(e, level: :warn, operation: 'Bot#retrieve_trace_context') if defined?(handle_exception)
|
|
204
204
|
nil
|
|
205
205
|
end
|
|
206
206
|
|
|
@@ -231,7 +231,7 @@ module Legion
|
|
|
231
231
|
Legion::Settings.dig(:microsoft_teams, :bot, :observe, :enabled) == true
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
-
def notify_enabled?(**
|
|
234
|
+
def notify_enabled?(**)
|
|
235
235
|
return false unless defined?(Legion::Settings)
|
|
236
236
|
|
|
237
237
|
Legion::Settings.dig(:microsoft_teams, :bot, :observe, :notify) == true
|
|
@@ -123,12 +123,12 @@ module Legion
|
|
|
123
123
|
trace_ids.each_cons(2) do |id_a, id_b|
|
|
124
124
|
store.record_coactivation(id_a, id_b)
|
|
125
125
|
rescue StandardError => e
|
|
126
|
-
handle_exception(e, level: :
|
|
126
|
+
handle_exception(e, level: :warn, operation: 'CacheIngest#coactivate_thread_traces',
|
|
127
127
|
id_a: id_a, id_b: id_b)
|
|
128
128
|
end
|
|
129
129
|
end
|
|
130
130
|
rescue StandardError => e
|
|
131
|
-
handle_exception(e, level: :
|
|
131
|
+
handle_exception(e, level: :warn, operation: 'CacheIngest#coactivate_thread_traces')
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
end
|