lex-microsoft_teams 0.6.10 → 0.6.14

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.
Files changed (27) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/Gemfile +0 -1
  4. data/lex-microsoft_teams.gemspec +7 -0
  5. data/lib/legion/extensions/microsoft_teams/actors/api_ingest.rb +94 -0
  6. data/lib/legion/extensions/microsoft_teams/actors/auth_validator.rb +2 -1
  7. data/lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb +4 -2
  8. data/lib/legion/extensions/microsoft_teams/actors/cache_sync.rb +2 -1
  9. data/lib/legion/extensions/microsoft_teams/actors/channel_poller.rb +4 -2
  10. data/lib/legion/extensions/microsoft_teams/actors/direct_chat_poller.rb +2 -1
  11. data/lib/legion/extensions/microsoft_teams/actors/incremental_sync.rb +7 -5
  12. data/lib/legion/extensions/microsoft_teams/actors/meeting_ingest.rb +4 -2
  13. data/lib/legion/extensions/microsoft_teams/actors/message_processor.rb +2 -1
  14. data/lib/legion/extensions/microsoft_teams/actors/observed_chat_poller.rb +2 -1
  15. data/lib/legion/extensions/microsoft_teams/actors/presence_poller.rb +2 -1
  16. data/lib/legion/extensions/microsoft_teams/actors/profile_ingest.rb +6 -3
  17. data/lib/legion/extensions/microsoft_teams/actors/token_refresher.rb +2 -1
  18. data/lib/legion/extensions/microsoft_teams/cli/auth.rb +2 -1
  19. data/lib/legion/extensions/microsoft_teams/helpers/high_water_mark.rb +2 -1
  20. data/lib/legion/extensions/microsoft_teams/helpers/prompt_resolver.rb +2 -1
  21. data/lib/legion/extensions/microsoft_teams/helpers/token_cache.rb +3 -2
  22. data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +307 -0
  23. data/lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb +15 -3
  24. data/lib/legion/extensions/microsoft_teams/runners/profile_ingest.rb +6 -3
  25. data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
  26. data/lib/legion/extensions/microsoft_teams.rb +1 -0
  27. metadata +101 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e42be4e2705458e0553a5ddda3ffc23eb02306dbc151bbb0b8eaa5b3843292b9
4
- data.tar.gz: eeef160d8547d2ccfeaa15be4a9752105b4a057f5c2f44b42a3e2f0fc6731a82
3
+ metadata.gz: 7e4252f4382060712bb112a7d7beaf55b10947fe6200185a3fb6ee413e4f3927
4
+ data.tar.gz: af5fb381e122efd02feab899a484242c71f5b67fb620717fad6dcb12c372e2cd
5
5
  SHA512:
6
- metadata.gz: 1ec79f9ea87a935bdf9d53b825040bc487e56c540f4b230bca4d76f196e12372b3cee9a316395d279a65dbdf93237645d1855f0f2bec1ad052a6aa63736677e2
7
- data.tar.gz: 04f084a840374730ac6394990d3c4c6ce74973706e23bd9631fcd1fd03014ab905570036be5c807c31c72cfdb8e96e486a52ccd95109ca2ce7b580e7abfa3137
6
+ metadata.gz: 22a8726642d3a8209c09c4fc797812825cd21ae7dcc8818438333a62291fa9a3a08356125e5003a426e8098ba56ad8033e77a1fd0bb4a162bd6c989bd20b127d
7
+ data.tar.gz: 89f55fcbe2210cc4f55a77d4ee04407fe0a3ba437cb1ad14e4699468f0dfbdcf1481ae72d01f230305c0ded9c9e2b1a1558360502ae43a27428d9f5d72634377
data/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.6.14] - 2026-03-23
4
+
5
+ ### Added
6
+ - Graph API ingest runner and actor for fetching top contacts and their 1:1 chat messages
7
+ - People-based chat matching with email, userId, and displayName fallbacks
8
+ - High-water mark support for incremental message fetching
9
+ - Paginated chat fetching with MAX_CHAT_PAGES cap
10
+
11
+ ### Changed
12
+ - Replace all silent rescue blocks with log.debug/warn/error entries
13
+ - Use `log.` helper consistently instead of `Legion::Logging.`
14
+ - Fix `IncrementalSync#resolve_token` to use `TokenCache.instance` instead of `.new`
15
+ - Clean up debug logging (remove log.unknown/log.fatal, use log.debug)
16
+
17
+ ## [0.6.13] - 2026-03-22
18
+
19
+ ### Changed
20
+ - Add legion-data, legion-json, and legion-transport as runtime dependencies
21
+ - Include `Legion::Data::Helper`, `Legion::JSON::Helper`, and `Legion::Transport::Helper` in spec_helper Lex stub
22
+
23
+ ## [0.6.12] - 2026-03-22
24
+
25
+ ### Changed
26
+ - Add legion-cache and legion-crypt as runtime dependencies
27
+ - Include `Legion::Cache::Helper` and `Legion::Crypt::Helper` in spec_helper Lex stub
28
+
29
+ ## [0.6.11] - 2026-03-22
30
+
31
+ ### Changed
32
+ - Add legion-logging and legion-settings as runtime dependencies
33
+ - Include `Legion::Settings::Helper` in spec_helper Lex stub for real settings access in tests
34
+
3
35
  ## [0.6.10] - 2026-03-22
4
36
 
5
37
  ### Changed
data/Gemfile CHANGED
@@ -4,7 +4,6 @@ source 'https://rubygems.org'
4
4
  gemspec
5
5
 
6
6
  group :test do
7
- gem 'legion-logging', '>= 1.3.2'
8
7
  gem 'rake'
9
8
  gem 'rspec'
10
9
  gem 'rspec_junit_formatter'
@@ -28,5 +28,12 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.add_dependency 'base64', '>= 0.1'
30
30
  spec.add_dependency 'faraday', '>= 2.0'
31
+ spec.add_dependency 'legion-cache', '>= 1.3.11'
32
+ spec.add_dependency 'legion-crypt', '>= 1.4.9'
33
+ spec.add_dependency 'legion-data', '>= 1.4.17'
34
+ spec.add_dependency 'legion-json', '>= 1.2.1'
35
+ spec.add_dependency 'legion-logging', '>= 1.3.2'
36
+ spec.add_dependency 'legion-settings', '>= 1.3.14'
37
+ spec.add_dependency 'legion-transport', '>= 1.3.9'
31
38
  spec.add_dependency 'snappy', '>= 0.5'
32
39
  end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class ApiIngest < Legion::Extensions::Actors::Every
8
+ def runner_class = Legion::Extensions::MicrosoftTeams::Runners::ApiIngest
9
+
10
+ def runner_function = 'ingest_api'
11
+
12
+ def use_runner? = false
13
+
14
+ def check_subtask? = false
15
+
16
+ def generate_task? = false
17
+
18
+ def run_now? = true
19
+
20
+ def delay
21
+ 10.0 # let memory + cache ingest initialize first
22
+ end
23
+
24
+ def time
25
+ interval = teams_settings.dig(:ingest, :api_interval) || 1800
26
+ interval.to_i
27
+ end
28
+
29
+ def enabled?
30
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
31
+ rescue StandardError => e
32
+ log.warn("ApiIngest#enabled?: #{e.message}")
33
+ false
34
+ end
35
+
36
+ def manual
37
+ token = resolve_token
38
+ unless token
39
+ log.warn('ApiIngest: no delegated token, skipping')
40
+ return
41
+ end
42
+
43
+ ingest = teams_settings[:ingest] || {}
44
+ log.info('ApiIngest: starting Graph API ingest')
45
+ result = runner_class.ingest_api(
46
+ token: token,
47
+ top_people: ingest.fetch(:top_people, 15),
48
+ message_depth: ingest.fetch(:message_depth, 50),
49
+ skip_bots: ingest.fetch(:skip_bots, true),
50
+ imprint_active: imprint_active?
51
+ )
52
+ log.info("ApiIngest: #{result.inspect[0, 200]}")
53
+ result
54
+ rescue StandardError => e
55
+ log.error("ApiIngest: #{e.message}")
56
+ end
57
+
58
+ private
59
+
60
+ def token_available?
61
+ resolve_token != nil
62
+ end
63
+
64
+ def resolve_token
65
+ if defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
66
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance.cached_delegated_token
67
+ end
68
+ rescue StandardError => e
69
+ log.warn("ApiIngest#resolve_token: #{e.message}")
70
+ nil
71
+ end
72
+
73
+ def teams_settings
74
+ return {} unless defined?(Legion::Settings)
75
+
76
+ Legion::Settings[:microsoft_teams] || {}
77
+ rescue StandardError => e
78
+ log.warn("ApiIngest#teams_settings: #{e.message}")
79
+ {}
80
+ end
81
+
82
+ def imprint_active?
83
+ return false unless defined?(Legion::Extensions::Coldstart::Helpers::Bootstrap)
84
+
85
+ Legion::Extensions::Coldstart::Helpers::Bootstrap.new.imprint_active?
86
+ rescue StandardError => e
87
+ log.debug("ApiIngest#imprint_active?: #{e.message}")
88
+ false
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -15,7 +15,8 @@ module Legion
15
15
 
16
16
  def enabled?
17
17
  defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
18
- rescue StandardError
18
+ rescue StandardError => e
19
+ log.debug("AuthValidator#enabled?: #{e.message}")
19
20
  false
20
21
  end
21
22
 
@@ -17,7 +17,8 @@ module Legion
17
17
 
18
18
  def enabled?
19
19
  defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
20
- rescue StandardError
20
+ rescue StandardError => e
21
+ log.debug("CacheBulkIngest#enabled?: #{e.message}")
21
22
  false
22
23
  end
23
24
 
@@ -40,7 +41,8 @@ module Legion
40
41
  return false unless defined?(Legion::Extensions::Coldstart::Helpers::Bootstrap)
41
42
 
42
43
  Legion::Extensions::Coldstart::Helpers::Bootstrap.new.imprint_active?
43
- rescue StandardError
44
+ rescue StandardError => e
45
+ log.debug("CacheBulkIngest#imprint_active?: #{e.message}")
44
46
  false
45
47
  end
46
48
  end
@@ -24,7 +24,8 @@ module Legion
24
24
 
25
25
  def enabled?
26
26
  defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
27
- rescue StandardError
27
+ rescue StandardError => e
28
+ log.debug("CacheSync#enabled?: #{e.message}")
28
29
  false
29
30
  end
30
31
 
@@ -38,7 +38,8 @@ module Legion
38
38
  return false unless defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
39
39
 
40
40
  channel_setting(:enabled, false) == true
41
- rescue StandardError
41
+ rescue StandardError => e
42
+ log.debug("ChannelPoller#enabled?: #{e.message}")
42
43
  false
43
44
  end
44
45
 
@@ -157,7 +158,8 @@ module Legion
157
158
  return default unless defined?(Legion::Settings)
158
159
 
159
160
  Legion::Settings.dig(:microsoft_teams, :channels, key) || default
160
- rescue StandardError
161
+ rescue StandardError => e
162
+ log.debug("ChannelPoller#channel_setting(#{key}): #{e.message}")
161
163
  default
162
164
  end
163
165
 
@@ -28,7 +28,8 @@ module Legion
28
28
  def enabled?
29
29
  defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
30
30
  defined?(Legion::Transport)
31
- rescue StandardError
31
+ rescue StandardError => e
32
+ log.debug("DirectChatPoller#enabled?: #{e.message}")
32
33
  false
33
34
  end
34
35
 
@@ -15,7 +15,8 @@ module Legion
15
15
  def delay
16
16
  settings = begin
17
17
  Legion::Settings[:microsoft_teams] || {}
18
- rescue StandardError
18
+ rescue StandardError => e
19
+ log.debug("IncrementalSync#delay: #{e.message}")
19
20
  {}
20
21
  end
21
22
  settings.dig(:ingest, :incremental_interval) || 900
@@ -24,7 +25,8 @@ module Legion
24
25
  def enabled?
25
26
  defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
26
27
  token_available?
27
- rescue StandardError
28
+ rescue StandardError => e
29
+ log.debug("IncrementalSync#enabled?: #{e.message}")
28
30
  false
29
31
  end
30
32
 
@@ -55,10 +57,10 @@ module Legion
55
57
 
56
58
  def resolve_token
57
59
  if defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
58
- cache = Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.new
59
- cache.cached_delegated_token
60
+ Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance.cached_delegated_token
60
61
  end
61
- rescue StandardError
62
+ rescue StandardError => e
63
+ log.warn("IncrementalSync#resolve_token: #{e.message}")
62
64
  nil
63
65
  end
64
66
  end
@@ -24,7 +24,8 @@ module Legion
24
24
  def time
25
25
  settings = begin
26
26
  Legion::Settings[:microsoft_teams] || {}
27
- rescue StandardError
27
+ rescue StandardError => e
28
+ log.debug("MeetingIngest#time: #{e.message}")
28
29
  {}
29
30
  end
30
31
  settings.dig(:meetings, :ingest_interval) || DEFAULT_INGEST_INTERVAL
@@ -32,7 +33,8 @@ module Legion
32
33
 
33
34
  def enabled?
34
35
  defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
35
- rescue StandardError
36
+ rescue StandardError => e
37
+ log.debug("MeetingIngest#enabled?: #{e.message}")
36
38
  false
37
39
  end
38
40
 
@@ -13,7 +13,8 @@ module Legion
13
13
  def enabled?
14
14
  defined?(Legion::Extensions::MicrosoftTeams::Runners::Bot) &&
15
15
  defined?(Legion::Transport)
16
- rescue StandardError
16
+ rescue StandardError => e
17
+ log.debug("MessageProcessor#enabled?: #{e.message}")
17
18
  false
18
19
  end
19
20
  end
@@ -30,7 +30,8 @@ module Legion
30
30
  return false unless defined?(Legion::Settings)
31
31
 
32
32
  Legion::Settings.dig(:microsoft_teams, :bot, :observe, :enabled) == true
33
- rescue StandardError
33
+ rescue StandardError => e
34
+ log.debug("ObservedChatPoller#enabled?: #{e.message}")
34
35
  false
35
36
  end
36
37
 
@@ -24,7 +24,8 @@ module Legion
24
24
 
25
25
  def enabled?
26
26
  defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
27
- rescue StandardError
27
+ rescue StandardError => e
28
+ log.debug("PresencePoller#enabled?: #{e.message}")
28
29
  false
29
30
  end
30
31
 
@@ -18,7 +18,8 @@ module Legion
18
18
  def enabled?
19
19
  defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces) &&
20
20
  token_available?
21
- rescue StandardError
21
+ rescue StandardError => e
22
+ log.debug("ProfileIngest#enabled?: #{e.message}")
22
23
  false
23
24
  end
24
25
 
@@ -33,7 +34,8 @@ module Legion
33
34
 
34
35
  settings = begin
35
36
  Legion::Settings[:microsoft_teams] || {}
36
- rescue StandardError
37
+ rescue StandardError => e
38
+ log.debug("ProfileIngest#manual settings: #{e.message}")
37
39
  {}
38
40
  end
39
41
  ingest = settings[:ingest] || {}
@@ -56,7 +58,8 @@ module Legion
56
58
  if defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
57
59
  Legion::Extensions::MicrosoftTeams::Helpers::TokenCache.instance.cached_delegated_token
58
60
  end
59
- rescue StandardError
61
+ rescue StandardError => e
62
+ log.warn("ProfileIngest#resolve_token: #{e.message}")
60
63
  nil
61
64
  end
62
65
  end
@@ -24,7 +24,8 @@ module Legion
24
24
 
25
25
  def enabled?
26
26
  defined?(Legion::Extensions::MicrosoftTeams::Helpers::TokenCache)
27
- rescue StandardError
27
+ rescue StandardError => e
28
+ log.debug("TokenRefresher#enabled?: #{e.message}")
28
29
  false
29
30
  end
30
31
 
@@ -68,7 +68,8 @@ module Legion
68
68
  return {} unless defined?(Legion::Settings)
69
69
 
70
70
  Legion::Settings[:microsoft_teams]&.dig(:auth) || {}
71
- rescue StandardError
71
+ rescue StandardError => e
72
+ log.debug("Auth: resolve_settings failed: #{e.message}")
72
73
  {}
73
74
  end
74
75
 
@@ -58,7 +58,8 @@ module Legion
58
58
  return nil unless raw
59
59
 
60
60
  raw.is_a?(Hash) ? raw : ::JSON.parse(raw, symbolize_names: true)
61
- rescue StandardError
61
+ rescue StandardError => e
62
+ log.debug("HighWaterMark: get_extended_hwm failed to parse cached value: #{e.message}")
62
63
  nil
63
64
  end
64
65
 
@@ -51,7 +51,8 @@ module Legion
51
51
 
52
52
  profile = Legion::Extensions::Mesh::Helpers::PreferenceProfile.resolve(owner_id: owner_id)
53
53
  Legion::Extensions::Mesh::Helpers::PreferenceProfile.preference_instructions(profile: profile)
54
- rescue StandardError
54
+ rescue StandardError => e
55
+ log.debug("PromptResolver: preference_instructions_for failed: #{e.message}") if defined?(log)
55
56
  nil
56
57
  end
57
58
  end
@@ -25,7 +25,7 @@ module Legion
25
25
  @instance ||= begin
26
26
  cache = new
27
27
  cache.load_from_vault
28
- Legion::Logging.info('[Teams::TokenCache] Shared instance created and loaded') if defined?(Legion::Logging)
28
+ log.info('[Teams::TokenCache] Shared instance created and loaded')
29
29
  cache
30
30
  end
31
31
  end
@@ -249,7 +249,8 @@ module Legion
249
249
  enabled = Legion::Settings.dig(:crypt, :vault, :enabled) == true
250
250
  log.debug("vault_available? => #{enabled}")
251
251
  enabled
252
- rescue StandardError
252
+ rescue StandardError => e
253
+ log.debug("TokenCache: vault_available? check failed: #{e.message}")
253
254
  false
254
255
  end
255
256
 
@@ -0,0 +1,307 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'digest'
5
+ require 'legion/extensions/microsoft_teams/helpers/client'
6
+ require 'legion/extensions/microsoft_teams/helpers/permission_guard'
7
+ require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module MicrosoftTeams
12
+ module Runners
13
+ module ApiIngest
14
+ include Helpers::Client
15
+ include Helpers::PermissionGuard
16
+ include Helpers::HighWaterMark
17
+ extend self
18
+
19
+ # Fetch top contacts via /me/people, then pull recent messages from
20
+ # their 1:1 chats. Stores each message as an individual memory trace
21
+ # (same format as CacheIngest) with dedup by content hash.
22
+ #
23
+ # Requires a delegated token with Chat.Read and People.Read scopes.
24
+ def ingest_api(token:, top_people: 15, message_depth: 50, skip_bots: true, imprint_active: false, **) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
25
+ return error_result('lex-memory not loaded') unless memory_available?
26
+ return error_result('no token provided') unless token && !token.empty?
27
+
28
+ people = fetch_top_people(token: token, top: top_people)
29
+ log.debug("ApiIngest: fetched #{people.size} top people")
30
+ return error_result('people endpoint denied or empty') if people.empty?
31
+
32
+ chats = fetch_one_on_one_chats(token: token)
33
+ log.debug("ApiIngest: fetched #{chats.size} oneOnOne chats")
34
+ return error_result('no 1:1 chats found') if chats.empty?
35
+
36
+ existing_hashes = load_existing_hashes
37
+ conn = graph_connection(token: token)
38
+ stored = 0
39
+ skipped = 0
40
+ people_ingested = 0
41
+ thread_groups = Hash.new { |h, k| h[k] = [] }
42
+
43
+ people.each do |person|
44
+ chat = match_chat_to_person(chats: chats, person: person, conn: conn)
45
+ unless chat
46
+ log.debug("ApiIngest: no chat match for #{person['displayName']} " \
47
+ "(email=#{person.dig('scoredEmailAddresses', 0, 'address')}, id=#{person['id']})")
48
+ next
49
+ end
50
+ log.info("ApiIngest: matched #{person['displayName']} to chat #{chat['id']}")
51
+
52
+ messages = fetch_chat_messages(conn: conn, chat_id: chat['id'], depth: message_depth)
53
+ next if messages.empty?
54
+
55
+ msg_stored = 0
56
+ messages.each do |msg|
57
+ next if skip_bots && bot_message_graph?(msg)
58
+
59
+ text = extract_body_text(msg)
60
+ next if text.length < 5
61
+
62
+ content_hash = msg['id'] || Digest::SHA256.hexdigest(text)[0, 16]
63
+ if existing_hashes.include?(content_hash)
64
+ skipped += 1
65
+ next
66
+ end
67
+
68
+ trace_result = store_graph_message(msg, text, person, chat['id'],
69
+ content_hash: content_hash,
70
+ imprint_active: imprint_active)
71
+ if trace_result
72
+ stored += 1
73
+ msg_stored += 1
74
+ existing_hashes << content_hash
75
+ thread_groups[chat['id']] << trace_result[:trace_id]
76
+ else
77
+ skipped += 1
78
+ end
79
+ end
80
+
81
+ next unless msg_stored.positive?
82
+
83
+ people_ingested += 1
84
+ update_extended_hwm(chat_id: chat['id'],
85
+ last_message_at: messages.map { |m| m['createdDateTime'] }.compact.max,
86
+ new_message_count: msg_stored, ingested: true)
87
+ end
88
+
89
+ coactivate_thread_traces(thread_groups)
90
+ flush_trace_store if stored.positive?
91
+
92
+ { result: { stored: stored, skipped: skipped, people_ingested: people_ingested,
93
+ people_found: people.length, chats_found: chats.length } }
94
+ rescue StandardError => e
95
+ log_msg = "ApiIngest failed: #{e.class} — #{e.message}"
96
+ log.error(log_msg)
97
+ { result: { stored: stored || 0, skipped: skipped || 0, error: e.message } }
98
+ end
99
+
100
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
101
+ Legion::Extensions::Helpers.const_defined?(:Lex)
102
+
103
+ MAX_CHAT_PAGES = 10
104
+
105
+ private
106
+
107
+ def fetch_top_people(token:, top:)
108
+ return [] if permission_denied?('/me/people')
109
+
110
+ conn = graph_connection(token: token)
111
+ resp = conn.get('me/people', { '$top' => top })
112
+
113
+ log.debug("ApiIngest: fetch_top_people status=#{resp.status} count=#{(resp.body || {}).fetch('value', []).size}")
114
+ if resp.status == 403
115
+ record_denial('/me/people', resp.body.dig('error', 'message') || 'Forbidden')
116
+ return []
117
+ end
118
+
119
+ people = (resp.body || {}).fetch('value', [])
120
+ people.sort_by { |p| -(p.dig('scoredEmailAddresses', 0, 'relevanceScore') || 0) }
121
+ rescue StandardError => e
122
+ log.warn("ApiIngest: fetch_top_people failed: #{e.message}")
123
+ []
124
+ end
125
+
126
+ def fetch_one_on_one_chats(token:)
127
+ conn = graph_connection(token: token)
128
+ all_chats = []
129
+ url = 'me/chats'
130
+ params = { '$top' => 50 }
131
+ pages = 0
132
+
133
+ loop do
134
+ resp = conn.get(url, params)
135
+ body = resp.body || {}
136
+ chats = body.fetch('value', [])
137
+ all_chats.concat(chats)
138
+ pages += 1
139
+
140
+ next_link = body['@odata.nextLink']
141
+ break unless next_link
142
+ break if pages >= MAX_CHAT_PAGES
143
+
144
+ url = next_link
145
+ params = {}
146
+ end
147
+
148
+ one_on_one = all_chats.select { |c| c['chatType'] == 'oneOnOne' }
149
+ log.info("ApiIngest: fetched #{all_chats.size} chats (#{pages} pages), #{one_on_one.size} oneOnOne")
150
+ one_on_one
151
+ rescue StandardError => e
152
+ log.warn("ApiIngest: fetch_chats failed: #{e.message}")
153
+ []
154
+ end
155
+
156
+ def match_chat_to_person(chats:, person:, conn:)
157
+ email = person.dig('scoredEmailAddresses', 0, 'address')&.downcase
158
+ display_name = person['displayName']&.downcase
159
+ user_id = person['id']
160
+ return nil unless email || user_id || display_name
161
+
162
+ chats.find do |chat|
163
+ members_resp = conn.get("chats/#{chat['id']}/members")
164
+ members = (members_resp.body || {}).fetch('value', [])
165
+ members.any? do |m|
166
+ match_member?(m, email: email, user_id: user_id, display_name: display_name)
167
+ end
168
+ end
169
+ rescue StandardError => e
170
+ log.debug("ApiIngest: match_chat_to_person failed: #{e.message}")
171
+ nil
172
+ end
173
+
174
+ def match_member?(member, email:, user_id:, display_name:)
175
+ return true if email && member['email']&.downcase == email
176
+ return true if user_id && member['userId'] == user_id
177
+ return true if email && member.dig('additionalData', 'email')&.downcase == email
178
+
179
+ member_name = member['displayName']&.downcase
180
+ return true if display_name && member_name && member_name == display_name
181
+
182
+ false
183
+ end
184
+
185
+ def fetch_chat_messages(conn:, chat_id:, depth: 50)
186
+ hwm = get_extended_hwm(chat_id: chat_id)
187
+ params = { '$top' => depth, '$orderby' => 'createdDateTime desc' }
188
+ params['$filter'] = "createdDateTime gt #{hwm[:last_message_at]}" if hwm&.dig(:last_message_at)
189
+
190
+ resp = conn.get("chats/#{chat_id}/messages", params)
191
+ log.debug("ApiIngest: fetch_messages chat=#{chat_id} count=#{(resp.body || {}).fetch('value', []).size}")
192
+ (resp.body || {}).fetch('value', [])
193
+ rescue StandardError => e
194
+ log.warn("ApiIngest: fetch_messages failed for #{chat_id}: #{e.message}")
195
+ []
196
+ end
197
+
198
+ def extract_body_text(msg)
199
+ html = msg.dig('body', 'content') || ''
200
+ strip_html(html)
201
+ end
202
+
203
+ def strip_html(html)
204
+ return '' if html.nil? || html.empty?
205
+
206
+ html.gsub(/<[^>]+>/, ' ').gsub('&nbsp;', ' ').gsub('&amp;', '&')
207
+ .gsub('&lt;', '<').gsub('&gt;', '>').gsub('&quot;', '"')
208
+ .gsub(/\s+/, ' ').strip
209
+ end
210
+
211
+ def bot_message_graph?(msg)
212
+ app = msg.dig('from', 'application')
213
+ return true if app && app['id']
214
+
215
+ user_type = msg.dig('from', 'user', 'userIdentityType')
216
+ %w[anonymousGuest azureCommunicationServicesUser].include?(user_type)
217
+ end
218
+
219
+ def store_graph_message(msg, text, person, chat_id, content_hash:, imprint_active: false)
220
+ sender = msg.dig('from', 'user', 'displayName') || person['displayName'] || 'Unknown'
221
+ compose_time = msg['createdDateTime']
222
+
223
+ domain_tags = build_graph_domain_tags(sender: sender, chat_id: chat_id,
224
+ compose_time: compose_time, content_hash: content_hash,
225
+ message_id: msg['id'])
226
+
227
+ memory_runner.store_trace(
228
+ type: :episodic,
229
+ content_payload: text,
230
+ domain_tags: domain_tags,
231
+ origin: :direct_experience,
232
+ confidence: 0.7,
233
+ emotional_valence: 0.1,
234
+ emotional_intensity: 0.2,
235
+ imprint_active: imprint_active
236
+ )
237
+ rescue StandardError => e
238
+ log.warn("ApiIngest: store trace failed: #{e.message}")
239
+ nil
240
+ end
241
+
242
+ def build_graph_domain_tags(sender:, chat_id:, compose_time:, content_hash:, message_id:)
243
+ tags = %w[teams graph_api]
244
+ tags << "sender:#{sender}"
245
+ tags << "peer:#{sender}"
246
+ tags << "chat_id:#{chat_id}" if chat_id
247
+ tags << "hash:#{content_hash}" if content_hash
248
+ tags << "time:#{compose_time}" if compose_time
249
+ tags << "msg_id:#{message_id}" if message_id
250
+ tags
251
+ end
252
+
253
+ def load_existing_hashes
254
+ store = Legion::Extensions::Agentic::Memory::Trace.shared_store
255
+ hashes = Set.new
256
+ store.all_traces(min_strength: 0.0).each do |trace|
257
+ trace[:domain_tags]&.each do |tag|
258
+ hashes << tag.delete_prefix('hash:') if tag.start_with?('hash:')
259
+ end
260
+ end
261
+ hashes
262
+ rescue StandardError => e
263
+ log.debug("ApiIngest: load_existing_hashes failed: #{e.message}")
264
+ Set.new
265
+ end
266
+
267
+ def memory_available?
268
+ defined?(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
269
+ end
270
+
271
+ def memory_runner
272
+ @memory_runner ||= Object.new.extend(Legion::Extensions::Agentic::Memory::Trace::Runners::Traces)
273
+ end
274
+
275
+ def flush_trace_store
276
+ store = Legion::Extensions::Agentic::Memory::Trace.shared_store
277
+ store.flush if store.respond_to?(:flush)
278
+ rescue StandardError => e
279
+ log.warn("ApiIngest: flush failed: #{e.message}")
280
+ end
281
+
282
+ def coactivate_thread_traces(thread_groups)
283
+ return unless defined?(Legion::Extensions::Agentic::Memory::Trace::Helpers::Store)
284
+
285
+ store = Legion::Extensions::Agentic::Memory::Trace.shared_store
286
+ thread_groups.each_value do |trace_ids|
287
+ next if trace_ids.length < 2
288
+
289
+ trace_ids.each_cons(2) do |id_a, id_b|
290
+ store.record_coactivation(id_a, id_b)
291
+ rescue StandardError => e
292
+ log.debug("ApiIngest: coactivation link failed for #{id_a}/#{id_b}: #{e.message}")
293
+ nil
294
+ end
295
+ end
296
+ rescue StandardError => e
297
+ log.debug("ApiIngest: coactivation skipped: #{e.message}")
298
+ end
299
+
300
+ def error_result(message)
301
+ { result: { stored: 0, skipped: 0, error: message } }
302
+ end
303
+ end
304
+ end
305
+ end
306
+ end
307
+ end
@@ -42,6 +42,7 @@ module Legion
42
42
  end
43
43
 
44
44
  coactivate_thread_traces(thread_groups)
45
+ flush_trace_store if stored.positive?
45
46
 
46
47
  { result: { stored: stored, skipped: skipped, latest_time: latest_time } }
47
48
  end
@@ -87,7 +88,10 @@ module Legion
87
88
 
88
89
  def build_domain_tags(msg)
89
90
  tags = ['teams']
90
- tags << "sender:#{msg.sender}" if msg.sender
91
+ if msg.sender
92
+ tags << "sender:#{msg.sender}"
93
+ tags << "peer:#{msg.sender}"
94
+ end
91
95
  tags << "thread:#{msg.thread_topic}" if msg.thread_topic
92
96
  tags << "thread_id:#{msg.thread_id}" if msg.thread_id
93
97
  tags << "thread_type:#{msg.thread_type}" if msg.thread_type
@@ -97,17 +101,25 @@ module Legion
97
101
  tags
98
102
  end
99
103
 
104
+ def flush_trace_store
105
+ store = Legion::Extensions::Agentic::Memory::Trace.shared_store
106
+ store.flush if store.respond_to?(:flush)
107
+ rescue StandardError => e
108
+ log.warn("CacheIngest: flush failed: #{e.message}")
109
+ end
110
+
100
111
  # Seed Hebbian coactivation links between messages in the same thread.
101
112
  def coactivate_thread_traces(thread_groups)
102
113
  return unless defined?(Legion::Extensions::Agentic::Memory::Trace::Helpers::Store)
103
114
 
104
- store = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
115
+ store = Legion::Extensions::Agentic::Memory::Trace.shared_store
105
116
  thread_groups.each_value do |trace_ids|
106
117
  next if trace_ids.length < 2
107
118
 
108
119
  trace_ids.each_cons(2) do |id_a, id_b|
109
120
  store.record_coactivation(id_a, id_b)
110
- rescue StandardError
121
+ rescue StandardError => e
122
+ log.debug("CacheIngest: coactivation link failed for #{id_a}/#{id_b}: #{e.message}")
111
123
  nil
112
124
  end
113
125
  end
@@ -198,7 +198,8 @@ module Legion
198
198
  members = (members_resp.body || {}).fetch('value', [])
199
199
  members.any? { |m| m['email']&.downcase == email.downcase }
200
200
  end
201
- rescue StandardError
201
+ rescue StandardError => e
202
+ log.debug("ProfileIngest: find_chat_for_person failed: #{e.message}")
202
203
  nil
203
204
  end
204
205
 
@@ -209,7 +210,8 @@ module Legion
209
210
 
210
211
  resp = conn.get("chats/#{chat_id}/messages", params)
211
212
  (resp.body || {}).fetch('value', [])
212
- rescue StandardError
213
+ rescue StandardError => e
214
+ log.warn("ProfileIngest: fetch_new_messages failed for #{chat_id}: #{e.message}")
213
215
  []
214
216
  end
215
217
 
@@ -229,7 +231,8 @@ module Legion
229
231
  elsif defined?(Legion::LLM)
230
232
  Legion::LLM.ask(prompt: "#{definition[:prompt]}\n\nConversation with #{peer_name}:\n#{text}")
231
233
  end
232
- rescue StandardError
234
+ rescue StandardError => e
235
+ log.debug("ProfileIngest: extract_conversation failed: #{e.message}")
233
236
  nil
234
237
  end
235
238
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.6.10'
6
+ VERSION = '0.6.14'
7
7
  end
8
8
  end
9
9
  end
@@ -20,6 +20,7 @@ require 'legion/extensions/microsoft_teams/runners/cache_ingest'
20
20
  require 'legion/extensions/microsoft_teams/runners/people'
21
21
  require 'legion/extensions/microsoft_teams/runners/profile_ingest'
22
22
  require 'legion/extensions/microsoft_teams/runners/ownership'
23
+ require 'legion/extensions/microsoft_teams/runners/api_ingest'
23
24
 
24
25
  # Helpers (bot)
25
26
  require 'legion/extensions/microsoft_teams/helpers/high_water_mark'
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.10
4
+ version: 0.6.14
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -37,6 +37,104 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: legion-cache
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 1.3.11
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: 1.3.11
54
+ - !ruby/object:Gem::Dependency
55
+ name: legion-crypt
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: 1.4.9
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 1.4.9
68
+ - !ruby/object:Gem::Dependency
69
+ name: legion-data
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: 1.4.17
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: 1.4.17
82
+ - !ruby/object:Gem::Dependency
83
+ name: legion-json
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: 1.2.1
89
+ type: :runtime
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: 1.2.1
96
+ - !ruby/object:Gem::Dependency
97
+ name: legion-logging
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: 1.3.2
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: 1.3.2
110
+ - !ruby/object:Gem::Dependency
111
+ name: legion-settings
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: 1.3.14
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: 1.3.14
124
+ - !ruby/object:Gem::Dependency
125
+ name: legion-transport
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: 1.3.9
131
+ type: :runtime
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: 1.3.9
40
138
  - !ruby/object:Gem::Dependency
41
139
  name: snappy
42
140
  requirement: !ruby/object:Gem::Requirement
@@ -75,6 +173,7 @@ files:
75
173
  - docs/plans/2026-03-19-teams-token-lifecycle-implementation.md
76
174
  - lex-microsoft_teams.gemspec
77
175
  - lib/legion/extensions/microsoft_teams.rb
176
+ - lib/legion/extensions/microsoft_teams/actors/api_ingest.rb
78
177
  - lib/legion/extensions/microsoft_teams/actors/auth_validator.rb
79
178
  - lib/legion/extensions/microsoft_teams/actors/cache_bulk_ingest.rb
80
179
  - lib/legion/extensions/microsoft_teams/actors/cache_sync.rb
@@ -105,6 +204,7 @@ files:
105
204
  - lib/legion/extensions/microsoft_teams/local_cache/sstable_reader.rb
106
205
  - lib/legion/extensions/microsoft_teams/runners/adaptive_cards.rb
107
206
  - lib/legion/extensions/microsoft_teams/runners/ai_insights.rb
207
+ - lib/legion/extensions/microsoft_teams/runners/api_ingest.rb
108
208
  - lib/legion/extensions/microsoft_teams/runners/auth.rb
109
209
  - lib/legion/extensions/microsoft_teams/runners/bot.rb
110
210
  - lib/legion/extensions/microsoft_teams/runners/cache_ingest.rb