lex-microsoft_teams 0.6.37 → 0.6.42

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 850930c48981007e5ba07fb8315bf3f5da8cb9eff02ca11911e3a531b820ca71
4
- data.tar.gz: b199dce28d0301f690870ae99e9930a8b178a3842ce6800e3f4fc6d4134943e4
3
+ metadata.gz: b320641fa223ed514787d28161d2072d039da5a34ec81ab9cfe2f4289b5403ba
4
+ data.tar.gz: b13504e4b41e1ff97360a7a0cedea33989b9649798009a1f2bda16bcff312962
5
5
  SHA512:
6
- metadata.gz: 998911159c181f88202dcd509fafb838adcbd2f1efd2368297ac20fa86cefdff591481986f7d063a69c5f4ffe3225a254ef70f4f6cc9a3f1e7c43080540bec59
7
- data.tar.gz: 33ae5434516d523cf1433f0a387d00646a06ae364714a52fb467403b0e93094b41d42f68daf847f1c052e2845f273c2e8e87d5af43d3a74e6eb7aeeba1fa41f6
6
+ metadata.gz: 1064e76e545459d2e14c399b5610ede6e24fbb126a9c917806d992b6aa738e69fa27cc4ee6de50b8aec357f4fb53029c7c7f113e38d7cc720dd1c1a9f8879936
7
+ data.tar.gz: dff546a2489dc75715e2355c16237f4e1ad0f0677866c1f464f35791c3747ab70b7e31b692d3602680f57434c7d0529f099191c6b59f079a07353bdc9933299b
data/.rubocop.yml CHANGED
@@ -16,3 +16,8 @@ Legion/Extension/EveryActorRequiresTime:
16
16
  # RunnerReturnHash fires on private helper methods inside runner modules (false positives)
17
17
  Legion/Extension/RunnerReturnHash:
18
18
  Enabled: false
19
+
20
+ Metrics/CyclomaticComplexity:
21
+ Max: 25
22
+ Metrics/PerceivedComplexity:
23
+ Max: 25
data/CHANGELOG.md CHANGED
@@ -2,6 +2,42 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.6.42] - 2026-04-23
6
+
7
+ ### Fixed
8
+ - `Helpers::TokenCache#vault_path` — replaced `ENV.fetch('USER', 'default')` with `Legion::Identity::Process.canonical_name` (the resolved Kerberos/LDAP identity), matching the pattern used in `legion-llm` PR #80. Falls back to `ENV['USER'].split('@').first` (email domain stripped) if Identity is not yet resolved
9
+ - Also removed the `microsoft_teams` namespace segment from the returned suffix — `Crypt::Helper#vault_write` wraps the path in its own `vault_path(suffix)` which already prepends the lex namespace, so including it again caused double-prefixed Vault paths (`microsoft_teams/users/.../microsoft_teams/delegated_token`).
10
+ - Final Vault path is now: `microsoft_teams/users/{canonical_name}/delegated_token`
11
+ - Configurable via `settings[:auth][:delegated][:vault_path]` if a custom path is needed
12
+
13
+ ## [0.6.41] - 2026-04-23
14
+
15
+ ### Fixed
16
+ - `Helpers::Client#graph_connection` and `#bot_connection` — replaced `Legion::Settings[:microsoft_teams]` with `settings` (provided by `Legion::Settings::Helper` via `Helpers::Lex`), which correctly scopes to the lex key automatically and is the approved pattern inside a lex
17
+ - `Helpers::TokenCache#teams_auth_settings` — same fix; replaced `Legion::Settings[:microsoft_teams]` with `settings`, removed the now-redundant `defined?(Legion::Settings)` guard and `if ms` nil guards (settings always returns a Hash)
18
+ - The only remaining `Legion::Settings` references in `token_cache.rb` are for cross-lex lookups (`:crypt, :vault, :connected`) which are intentional and correct
19
+ - Token write paths (`save_to_local`, `save_to_vault`) confirmed clean — write to `local_token_path` (defaulting to `~/.legionio/tokens/`) and Vault respectively, not into settings
20
+
21
+ ## [0.6.40] - 2026-04-23
22
+
23
+ ### Fixed
24
+ - `Helpers::BrowserAuth` — removed private `log` method that was shadowing the `log` accessor provided by `Legion::Extensions::Helpers::Lex`. The `include Helpers::Lex` guard was already present but the manual fallback overrode it; all `log.debug/info/warn/error` calls now correctly route through the Lex helper (structured logging, context propagation, etc.)
25
+
26
+ ## [0.6.39] - 2026-04-23
27
+
28
+ ### Added
29
+ - `Absorbers::Chat` — absorbs a Teams chat thread into Apollo by URL (`teams.microsoft.com/l/chat/19:*@*`). Extracts the chat ID from the URL, fetches metadata, pulls messages (with inline replies, HTML stripped), and ingests participants. Content types: `teams_chat_thread`, `teams_chat_participants`
30
+ - `Absorbers::Channel` — absorbs a Teams channel or specific thread into Apollo by URL (`teams.microsoft.com/l/channel/*`, `teams.microsoft.com/l/message/*`). Extracts `team_id` from `groupId` query param and `channel_id` from path. When a `message_id` is present (deep-link to a specific thread) only that thread is ingested; otherwise the top 50 channel messages are ingested. Replies are fetched and inlined. Content types: `teams_channel_thread`, `teams_channel_members`
31
+ - `Actors::AbsorbChat` — Subscription actor delegating to `Absorbers::Chat#absorb`, mirrors the `AbsorbMeeting` actor pattern
32
+ - `Actors::AbsorbChannel` — Subscription actor delegating to `Absorbers::Channel#absorb`, mirrors the `AbsorbMeeting` actor pattern
33
+ - Both new absorbers and actors are conditionally required (gated on `Legion::Extensions::Absorbers::Base` presence) in `microsoft_teams.rb`
34
+
35
+ ## [0.6.38] - 2026-04-23
36
+
37
+ ### Fixed
38
+ - `Helpers::Client#graph_connection` now falls back to `Legion::Settings[:microsoft_teams]&.dig(:auth, :delegated, :token)` when no `token:` is explicitly passed — fixes unauthenticated Graph API calls when runners are invoked as standalone modules via Lex tool dispatch (not via an instantiated `Client` object where `@opts` carries the token)
39
+ - `Helpers::Client#bot_connection` applies the same fallback using `Legion::Settings[:microsoft_teams]&.dig(:auth, :bot, :token)`
40
+
5
41
  ## [0.7.0] - unreleased
6
42
 
7
43
  ### Added
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Absorbers
7
+ class Channel < Legion::Extensions::Absorbers::Base
8
+ pattern :url, 'teams.microsoft.com/l/channel/*'
9
+ pattern :url, 'teams.microsoft.com/l/message/*'
10
+ description 'Absorbs a Teams channel thread (messages, replies, members) into Apollo'
11
+
12
+ def absorb(url: nil, content: nil, metadata: {}, context: {}) # rubocop:disable Lint/UnusedMethodArgument
13
+ report_progress(message: 'extracting ids from url')
14
+ ids = extract_ids(url)
15
+ return { success: false, error: 'could not extract team/channel ids from url' } unless ids
16
+
17
+ team_id = ids[:team_id]
18
+ channel_id = ids[:channel_id]
19
+ message_id = ids[:message_id]
20
+
21
+ report_progress(message: 'fetching channel metadata', percent: 10)
22
+ channel = resolve_channel(team_id, channel_id)
23
+ return { success: false, error: 'could not resolve channel' } unless channel
24
+
25
+ channel_name = channel['displayName'] || channel[:displayName] || 'untitled channel'
26
+ results = { team_id: team_id, channel_id: channel_id, channel_name: channel_name, chunks: 0 }
27
+
28
+ if message_id
29
+ # Scoped to a specific thread
30
+ ingest_thread(team_id, channel_id, message_id, channel_name, results)
31
+ else
32
+ # Full channel ingest
33
+ ingest_messages(team_id, channel_id, channel_name, results)
34
+ end
35
+
36
+ ingest_members(team_id, channel_id, channel_name, results)
37
+
38
+ report_progress(message: 'done', percent: 100)
39
+ results.merge(success: true)
40
+ rescue StandardError => e
41
+ log.error("Channel absorber failed: #{e.message}")
42
+ { success: false, error: e.message }
43
+ end
44
+
45
+ private
46
+
47
+ def channels_runner
48
+ @channels_runner ||= Object.new.extend(Runners::Channels)
49
+ end
50
+
51
+ def channel_messages_runner
52
+ @channel_messages_runner ||= Object.new.extend(Runners::ChannelMessages)
53
+ end
54
+
55
+ def graph_token
56
+ return @graph_token if defined?(@graph_token)
57
+
58
+ @graph_token = begin
59
+ Helpers::TokenCache.instance.cached_delegated_token if defined?(Helpers::TokenCache)
60
+ rescue StandardError => e
61
+ log.warn("graph_token unavailable: #{e.message}")
62
+ nil
63
+ end
64
+ end
65
+
66
+ # Teams channel URL formats:
67
+ # /l/channel/<encoded_channel_id>/<channel_name>?groupId=<team_id>&...
68
+ # /l/message/<encoded_channel_id>/<message_id>?groupId=<team_id>&...
69
+ def extract_ids(url)
70
+ return nil unless url.is_a?(String)
71
+
72
+ uri = URI.parse(url)
73
+ params = URI.decode_www_form(uri.query.to_s).to_h
74
+
75
+ team_id = params['groupId'] || params['groupid']
76
+ return nil unless team_id
77
+
78
+ path_parts = uri.path.split('/')
79
+ # path: ["", "l", "channel"|"message", <encoded_id>, ...]
80
+ encoded_id = path_parts[3]
81
+ return nil unless encoded_id
82
+
83
+ channel_id = URI.decode_uri_component(encoded_id)
84
+
85
+ message_id = (path_parts[4] if uri.path.include?('/l/message/'))
86
+
87
+ { team_id: team_id, channel_id: channel_id, message_id: message_id }
88
+ rescue StandardError => e
89
+ log.debug("extract_ids failed: #{e.message}")
90
+ nil
91
+ end
92
+
93
+ def resolve_channel(team_id, channel_id)
94
+ response = channels_runner.get_channel(team_id: team_id, channel_id: channel_id, token: graph_token)
95
+ body = response.is_a?(Hash) ? response[:result] : nil
96
+ return nil unless body.is_a?(Hash) && !body['error'] && !body[:error]
97
+
98
+ body
99
+ rescue StandardError => e
100
+ log.warn("resolve_channel failed: #{e.message}")
101
+ nil
102
+ end
103
+
104
+ def ingest_messages(team_id, channel_id, channel_name, results)
105
+ report_progress(message: 'fetching channel messages', percent: 25)
106
+ response = channel_messages_runner.list_channel_messages(
107
+ team_id: team_id, channel_id: channel_id, top: 50, token: graph_token
108
+ )
109
+ body = response.is_a?(Hash) ? response[:result] : nil
110
+ items = body.is_a?(Hash) ? (body['value'] || body[:value]) : nil
111
+ return unless items.is_a?(Array) && items.any?
112
+
113
+ items.reverse_each do |msg|
114
+ ingest_single_message(team_id, channel_id, msg, channel_name, results)
115
+ end
116
+ rescue StandardError => e
117
+ log.warn("Channel message ingest failed: #{e.message}")
118
+ end
119
+
120
+ def ingest_thread(team_id, channel_id, message_id, channel_name, results)
121
+ report_progress(message: 'fetching thread root message', percent: 20)
122
+ response = channel_messages_runner.get_channel_message(
123
+ team_id: team_id, channel_id: channel_id, message_id: message_id, token: graph_token
124
+ )
125
+ body = response.is_a?(Hash) ? response[:result] : nil
126
+ return unless body.is_a?(Hash) && !body['error']
127
+
128
+ ingest_single_message(team_id, channel_id, body, channel_name, results, scoped_thread: true)
129
+ rescue StandardError => e
130
+ log.warn("Thread ingest failed: #{e.message}")
131
+ end
132
+
133
+ def ingest_single_message(team_id, channel_id, msg, channel_name, results, scoped_thread: false)
134
+ return unless msg.is_a?(Hash)
135
+ return if msg['deletedDateTime'] || msg[:deletedDateTime]
136
+ return if (msg['messageType'] || msg[:messageType]) == 'unknownFutureValue'
137
+
138
+ msg_id = msg['id'] || msg[:id]
139
+ sender = msg.dig('from', 'user', 'displayName') ||
140
+ msg.dig(:from, :user, :displayName) ||
141
+ 'unknown'
142
+ body_content = msg.dig('body', 'content') || msg.dig(:body, :content) || ''
143
+ text = body_content.gsub(/<[^>]+>/, '').strip
144
+ return if text.empty? && !scoped_thread
145
+
146
+ subject = msg['subject'] || msg[:subject]
147
+ timestamp = msg['createdDateTime'] || msg[:createdDateTime]
148
+ lines = []
149
+ lines << "Subject: #{subject}" if subject && !subject.empty?
150
+ lines << "[#{timestamp}] #{sender}: #{text}" unless text.empty?
151
+
152
+ reply_lines = fetch_reply_lines(team_id, channel_id, msg_id)
153
+ lines.concat(reply_lines)
154
+
155
+ return if lines.empty?
156
+
157
+ percent = scoped_thread ? 60 : nil
158
+ report_progress(message: "ingesting message #{msg_id}", percent: percent) if percent
159
+
160
+ absorb_to_knowledge(
161
+ content: lines.join("\n"),
162
+ tags: ['teams', 'channel', 'thread', channel_name],
163
+ source_file: "teams://teams/#{team_id}/channels/#{channel_id}/messages/#{msg_id}",
164
+ heading: "Channel Thread: #{channel_name}#{" — #{subject}" if subject}",
165
+ content_type: 'teams_channel_thread'
166
+ )
167
+ results[:chunks] += 1
168
+ rescue StandardError => e
169
+ log.warn("ingest_single_message failed: #{e.message}")
170
+ end
171
+
172
+ def fetch_reply_lines(team_id, channel_id, message_id)
173
+ return [] unless message_id
174
+
175
+ response = channel_messages_runner.list_channel_message_replies(
176
+ team_id: team_id, channel_id: channel_id, message_id: message_id, top: 50, token: graph_token
177
+ )
178
+ body = response.is_a?(Hash) ? response[:result] : nil
179
+ items = body.is_a?(Hash) ? (body['value'] || body[:value]) : nil
180
+ return [] unless items.is_a?(Array) && items.any?
181
+
182
+ items.filter_map do |reply|
183
+ next if reply['deletedDateTime'] || reply[:deletedDateTime]
184
+
185
+ sender = reply.dig('from', 'user', 'displayName') ||
186
+ reply.dig(:from, :user, :displayName) ||
187
+ 'unknown'
188
+ body_content = reply.dig('body', 'content') || reply.dig(:body, :content) || ''
189
+ text = body_content.gsub(/<[^>]+>/, '').strip
190
+ next if text.empty?
191
+
192
+ timestamp = reply['createdDateTime'] || reply[:createdDateTime]
193
+ " ↳ [#{timestamp}] #{sender}: #{text}"
194
+ end
195
+ rescue StandardError => e
196
+ log.debug("fetch_reply_lines failed: #{e.message}")
197
+ []
198
+ end
199
+
200
+ def ingest_members(team_id, channel_id, channel_name, results)
201
+ report_progress(message: 'fetching channel members', percent: 85)
202
+ response = channels_runner.list_channel_members(
203
+ team_id: team_id, channel_id: channel_id, token: graph_token
204
+ )
205
+ body = response.is_a?(Hash) ? response[:result] : nil
206
+ items = body.is_a?(Hash) ? (body['value'] || body[:value]) : nil
207
+ return unless items.is_a?(Array) && items.any?
208
+
209
+ names = items.filter_map { |m| m['displayName'] || m[:displayName] }
210
+ return if names.empty?
211
+
212
+ absorb_raw(
213
+ content: "Channel members for '#{channel_name}': #{names.join(', ')}",
214
+ tags: ['teams', 'channel', 'members', channel_name],
215
+ content_type: 'teams_channel_members',
216
+ metadata: { team_id: team_id, channel_id: channel_id, member_count: names.length }
217
+ )
218
+ results[:chunks] += 1
219
+ rescue StandardError => e
220
+ log.warn("Member ingest failed: #{e.message}")
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Absorbers
7
+ class Chat < Legion::Extensions::Absorbers::Base
8
+ pattern :url, 'teams.microsoft.com/l/chat/19:*@*'
9
+ pattern :url, 'teams.microsoft.com/l/chat/19:*_*@*'
10
+ description 'Absorbs a Teams chat thread (messages, replies, participants) into Apollo'
11
+
12
+ def absorb(url: nil, content: nil, metadata: {}, context: {}) # rubocop:disable Lint/UnusedMethodArgument
13
+ report_progress(message: 'extracting chat id from url')
14
+ chat_id = extract_chat_id(url)
15
+ return { success: false, error: 'could not extract chat id from url' } unless chat_id
16
+
17
+ report_progress(message: 'fetching chat metadata', percent: 10)
18
+ chat = resolve_chat(chat_id)
19
+ return { success: false, error: 'could not resolve chat' } unless chat
20
+
21
+ topic = chat['topic'] || chat[:topic] || 'untitled chat'
22
+ results = { chat_id: chat_id, topic: topic, chunks: 0 }
23
+
24
+ ingest_messages(chat_id, topic, results)
25
+ ingest_members(chat_id, topic, results)
26
+
27
+ report_progress(message: 'done', percent: 100)
28
+ results.merge(success: true)
29
+ rescue StandardError => e
30
+ log.error("Chat absorber failed: #{e.message}")
31
+ { success: false, error: e.message }
32
+ end
33
+
34
+ private
35
+
36
+ def chats_runner
37
+ @chats_runner ||= Object.new.extend(Runners::Chats)
38
+ end
39
+
40
+ def messages_runner
41
+ @messages_runner ||= Object.new.extend(Runners::Messages)
42
+ end
43
+
44
+ def graph_token
45
+ return @graph_token if defined?(@graph_token)
46
+
47
+ @graph_token = begin
48
+ Helpers::TokenCache.instance.cached_delegated_token if defined?(Helpers::TokenCache)
49
+ rescue StandardError => e
50
+ log.warn("graph_token unavailable: #{e.message}")
51
+ nil
52
+ end
53
+ end
54
+
55
+ def extract_chat_id(url)
56
+ return nil unless url.is_a?(String)
57
+
58
+ # teams.microsoft.com/l/chat/19:XXXXX@unq.gbl.spaces/...
59
+ match = url.match(%r{/l/chat/(19:[^/?#]+)})
60
+ return unless match
61
+
62
+ URI.decode_uri_component(match[1])
63
+ rescue StandardError => e
64
+ log.debug("extract_chat_id failed: #{e.message}")
65
+ nil
66
+ end
67
+
68
+ def resolve_chat(chat_id)
69
+ response = chats_runner.get_chat(chat_id: chat_id, token: graph_token)
70
+ body = response.is_a?(Hash) ? response[:result] : nil
71
+ return nil unless body.is_a?(Hash) && !body['error'] && !body[:error]
72
+
73
+ body
74
+ rescue StandardError => e
75
+ log.warn("resolve_chat failed: #{e.message}")
76
+ nil
77
+ end
78
+
79
+ def ingest_messages(chat_id, topic, results)
80
+ report_progress(message: 'fetching messages', percent: 25)
81
+ response = messages_runner.list_chat_messages(chat_id: chat_id, top: 50, token: graph_token)
82
+ body = response.is_a?(Hash) ? response[:result] : nil
83
+ return unless body.is_a?(Hash)
84
+
85
+ items = body['value'] || body[:value]
86
+ return unless items.is_a?(Array) && items.any?
87
+
88
+ # Filter out system/deleted messages and build a readable transcript
89
+ lines = []
90
+ items.reverse_each do |msg|
91
+ next if msg['messageType'] != 'message' && !msg['messageType'].nil?
92
+ next if msg['deletedDateTime'] || msg[:deletedDateTime]
93
+
94
+ sender = msg.dig('from', 'user', 'displayName') ||
95
+ msg.dig(:from, :user, :displayName) ||
96
+ 'unknown'
97
+ body_content = msg.dig('body', 'content') || msg.dig(:body, :content) || ''
98
+ # Strip HTML tags for plain text
99
+ text = body_content.gsub(/<[^>]+>/, '').strip
100
+ next if text.empty?
101
+
102
+ timestamp = msg['createdDateTime'] || msg[:createdDateTime]
103
+ lines << "[#{timestamp}] #{sender}: #{text}"
104
+
105
+ # Pull replies for this message
106
+ reply_lines = fetch_reply_lines(chat_id, msg['id'] || msg[:id], topic)
107
+ lines.concat(reply_lines) if reply_lines.any?
108
+ end
109
+
110
+ return if lines.empty?
111
+
112
+ report_progress(message: 'ingesting message thread', percent: 60)
113
+ absorb_to_knowledge(
114
+ content: lines.join("\n"),
115
+ tags: ['teams', 'chat', 'messages', topic],
116
+ source_file: "teams://chats/#{chat_id}/messages",
117
+ heading: "Chat: #{topic}",
118
+ content_type: 'teams_chat_thread'
119
+ )
120
+ results[:chunks] += 1
121
+ rescue StandardError => e
122
+ log.warn("Message ingest failed: #{e.message}")
123
+ end
124
+
125
+ def fetch_reply_lines(chat_id, message_id, _topic)
126
+ return [] unless message_id
127
+
128
+ response = messages_runner.list_message_replies(
129
+ chat_id: chat_id, message_id: message_id, top: 50, token: graph_token
130
+ )
131
+ body = response.is_a?(Hash) ? response[:result] : nil
132
+ items = body.is_a?(Hash) ? (body['value'] || body[:value]) : nil
133
+ return [] unless items.is_a?(Array) && items.any?
134
+
135
+ items.filter_map do |reply|
136
+ next if reply['deletedDateTime'] || reply[:deletedDateTime]
137
+
138
+ sender = reply.dig('from', 'user', 'displayName') ||
139
+ reply.dig(:from, :user, :displayName) ||
140
+ 'unknown'
141
+ body_content = reply.dig('body', 'content') || reply.dig(:body, :content) || ''
142
+ text = body_content.gsub(/<[^>]+>/, '').strip
143
+ next if text.empty?
144
+
145
+ timestamp = reply['createdDateTime'] || reply[:createdDateTime]
146
+ " ↳ [#{timestamp}] #{sender}: #{text}"
147
+ end
148
+ rescue StandardError => e
149
+ log.debug("fetch_reply_lines failed: #{e.message}")
150
+ []
151
+ end
152
+
153
+ def ingest_members(chat_id, topic, results)
154
+ report_progress(message: 'fetching members', percent: 80)
155
+ response = chats_runner.list_chat_members(chat_id: chat_id, token: graph_token)
156
+ body = response.is_a?(Hash) ? response[:result] : nil
157
+ return unless body.is_a?(Hash)
158
+
159
+ items = body['value'] || body[:value]
160
+ return unless items.is_a?(Array) && items.any?
161
+
162
+ names = items.filter_map do |m|
163
+ m['displayName'] || m[:displayName]
164
+ end
165
+ return if names.empty?
166
+
167
+ absorb_raw(
168
+ content: "Chat participants for '#{topic}': #{names.join(', ')}",
169
+ tags: ['teams', 'chat', 'participants', topic],
170
+ content_type: 'teams_chat_participants',
171
+ metadata: { chat_id: chat_id, participant_count: names.length }
172
+ )
173
+ results[:chunks] += 1
174
+ rescue StandardError => e
175
+ log.warn("Member ingest failed: #{e.message}")
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class AbsorbChannel < Legion::Extensions::Actors::Subscription
8
+ def runner_class = 'Legion::Extensions::MicrosoftTeams::Absorbers::Channel'
9
+ def runner_function = 'absorb'
10
+ def check_subtask? = false
11
+ def generate_task? = false
12
+
13
+ def enabled?
14
+ defined?(Legion::Extensions::Absorbers::Base) &&
15
+ defined?(Legion::Extensions::MicrosoftTeams::Absorbers::Channel)
16
+ rescue StandardError => e
17
+ log.debug("AbsorbChannel#enabled?: #{e.message}")
18
+ false
19
+ end
20
+
21
+ def work(payload)
22
+ parsed = parse_payload(payload)
23
+ absorber = Absorbers::Channel.new
24
+ result = absorber.absorb(
25
+ url: parsed[:url],
26
+ metadata: parsed[:metadata] || {},
27
+ context: parsed[:context] || {}
28
+ )
29
+ if result.respond_to?(:[]) && result.key?(:success)
30
+ if result[:success]
31
+ ack!
32
+ else
33
+ log.error("AbsorbChannel actor absorb failed: #{result.inspect}")
34
+ reject!(requeue: false)
35
+ end
36
+ else
37
+ ack!
38
+ end
39
+ result
40
+ rescue StandardError => e
41
+ log.error("AbsorbChannel actor error: #{e.message}")
42
+ reject!(requeue: false)
43
+ end
44
+
45
+ private
46
+
47
+ def parse_payload(payload)
48
+ data = payload.is_a?(String) ? json_load(payload) : payload
49
+ return {} unless data.is_a?(Hash)
50
+
51
+ data.transform_keys(&:to_sym)
52
+ rescue StandardError => e
53
+ log.debug("AbsorbChannel#parse_payload: #{e.message}")
54
+ {}
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MicrosoftTeams
6
+ module Actor
7
+ class AbsorbChat < Legion::Extensions::Actors::Subscription
8
+ def runner_class = 'Legion::Extensions::MicrosoftTeams::Absorbers::Chat'
9
+ def runner_function = 'absorb'
10
+ def check_subtask? = false
11
+ def generate_task? = false
12
+
13
+ def enabled?
14
+ defined?(Legion::Extensions::Absorbers::Base) &&
15
+ defined?(Legion::Extensions::MicrosoftTeams::Absorbers::Chat)
16
+ rescue StandardError => e
17
+ log.debug("AbsorbChat#enabled?: #{e.message}")
18
+ false
19
+ end
20
+
21
+ def work(payload)
22
+ parsed = parse_payload(payload)
23
+ absorber = Absorbers::Chat.new
24
+ result = absorber.absorb(
25
+ url: parsed[:url],
26
+ metadata: parsed[:metadata] || {},
27
+ context: parsed[:context] || {}
28
+ )
29
+ if result.respond_to?(:[]) && result.key?(:success)
30
+ if result[:success]
31
+ ack!
32
+ else
33
+ log.error("AbsorbChat actor absorb failed: #{result.inspect}")
34
+ reject!(requeue: false)
35
+ end
36
+ else
37
+ ack!
38
+ end
39
+ result
40
+ rescue StandardError => e
41
+ log.error("AbsorbChat actor error: #{e.message}")
42
+ reject!(requeue: false)
43
+ end
44
+
45
+ private
46
+
47
+ def parse_payload(payload)
48
+ data = payload.is_a?(String) ? json_load(payload) : payload
49
+ return {} unless data.is_a?(Hash)
50
+
51
+ data.transform_keys(&:to_sym)
52
+ rescue StandardError => e
53
+ log.debug("AbsorbChat#parse_payload: #{e.message}")
54
+ {}
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -106,14 +106,6 @@ module Legion
106
106
 
107
107
  private
108
108
 
109
- def log
110
- return Legion::Logging if defined?(Legion::Logging) # rubocop:disable Legion/HelperMigration/LoggingGuard
111
-
112
- @log ||= Object.new.tap do |nl|
113
- %i[debug info warn error fatal].each { |m| nl.define_singleton_method(m) { |*| nil } }
114
- end
115
- end
116
-
117
109
  def host_os
118
110
  RbConfig::CONFIG['host_os']
119
111
  end
@@ -7,7 +7,11 @@ module Legion
7
7
  module MicrosoftTeams
8
8
  module Helpers
9
9
  module Client
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
+
10
13
  def graph_connection(token: nil, api_url: 'https://graph.microsoft.com/v1.0', **_opts)
14
+ token ||= settings&.dig(:auth, :delegated, :token)
11
15
  Faraday.new(url: api_url) do |conn|
12
16
  conn.request :json
13
17
  conn.response :json, content_type: /\bjson$/
@@ -17,6 +21,7 @@ module Legion
17
21
  end
18
22
 
19
23
  def bot_connection(token: nil, service_url: 'https://smba.trafficmanager.net/teams/', **_opts)
24
+ token ||= settings&.dig(:auth, :bot, :token)
20
25
  Faraday.new(url: service_url) do |conn|
21
26
  conn.request :json
22
27
  conn.response :json, content_type: /\bjson$/
@@ -277,8 +277,14 @@ module Legion
277
277
  def vault_path(_suffix = nil)
278
278
  settings = teams_auth_settings
279
279
  delegated = settings[:delegated]
280
- custom = delegated[:vault_path] if delegated.is_a?(Hash)
281
- custom || "users/#{ENV.fetch('USER', 'default')}/microsoft_teams/delegated_token"
280
+ return delegated[:vault_path] if delegated.is_a?(Hash) && delegated[:vault_path]
281
+
282
+ identity = if defined?(Legion::Identity::Process) && Legion::Identity::Process.resolved?
283
+ Legion::Identity::Process.canonical_name
284
+ else
285
+ ENV.fetch('USER', 'default').split('@').first
286
+ end
287
+ "users/#{identity}/delegated_token"
282
288
  end
283
289
 
284
290
  def local_token_path
@@ -387,13 +393,11 @@ module Legion
387
393
  end
388
394
 
389
395
  def teams_auth_settings
390
- return {} unless defined?(Legion::Settings)
391
-
392
- ms = Legion::Settings[:microsoft_teams]
393
- auth = ms && ms[:auth].is_a?(Hash) ? ms[:auth].dup : {}
394
- auth[:tenant_id] ||= ms[:tenant_id] if ms
395
- auth[:client_id] ||= ms[:client_id] if ms
396
- auth[:client_secret] ||= ms[:client_secret] if ms
396
+ ms = settings
397
+ auth = ms[:auth].is_a?(Hash) ? ms[:auth].dup : {}
398
+ auth[:tenant_id] ||= ms[:tenant_id]
399
+ auth[:client_id] ||= ms[:client_id]
400
+ auth[:client_secret] ||= ms[:client_secret]
397
401
  auth[:tenant_id] ||= ENV.fetch('AZURE_TENANT_ID', nil)
398
402
  auth[:client_id] ||= ENV.fetch('AZURE_CLIENT_ID', nil)
399
403
  auth[:client_secret] ||= ENV.fetch('AZURE_CLIENT_SECRET', nil)
@@ -143,7 +143,7 @@ module Legion
143
143
 
144
144
  # Extract individual messages from a messageMap record.
145
145
  # These contain multiple messages in a reply chain.
146
- def extract_message_map(strings, messages, seen_hashes) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
146
+ def extract_message_map(strings, messages, seen_hashes)
147
147
  conversation_id = nil
148
148
  i = 0
149
149
 
@@ -232,7 +232,7 @@ module Legion
232
232
  )
233
233
  end
234
234
 
235
- def apply_filters(messages, since:, channels:, senders:, skip_bots:) # rubocop:disable Metrics/CyclomaticComplexity
235
+ def apply_filters(messages, since:, channels:, senders:, skip_bots:)
236
236
  messages.select do |msg|
237
237
  next false if since && msg.compose_time && Time.parse(msg.compose_time) < since
238
238
  next false if channels&.none? { |c| msg.thread_topic&.downcase&.include?(c.downcase) }
@@ -21,7 +21,7 @@ module Legion
21
21
  # (same format as CacheIngest) with dedup by content hash.
22
22
  #
23
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
24
+ def ingest_api(token:, top_people: 15, message_depth: 50, skip_bots: true, imprint_active: false, **) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
25
25
  return error_result('lex-memory not loaded') unless memory_available?
26
26
  return error_result('no token provided') unless token && !token.empty?
27
27
 
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.6.37'
6
+ VERSION = '0.6.42'
7
7
  end
8
8
  end
9
9
  end
@@ -48,6 +48,8 @@ if defined?(Legion::Extensions) &&
48
48
  Legion::Extensions.const_defined?(:Absorbers, false) &&
49
49
  Legion::Extensions::Absorbers.const_defined?(:Base, false)
50
50
  require_relative 'microsoft_teams/absorbers/meeting'
51
+ require_relative 'microsoft_teams/absorbers/chat'
52
+ require_relative 'microsoft_teams/absorbers/channel'
51
53
  end
52
54
 
53
55
  module Legion
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.37
4
+ version: 0.6.42
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -175,7 +175,11 @@ files:
175
175
  - docs/plans/2026-03-19-teams-token-lifecycle-implementation.md
176
176
  - lex-microsoft_teams.gemspec
177
177
  - lib/legion/extensions/microsoft_teams.rb
178
+ - lib/legion/extensions/microsoft_teams/absorbers/channel.rb
179
+ - lib/legion/extensions/microsoft_teams/absorbers/chat.rb
178
180
  - lib/legion/extensions/microsoft_teams/absorbers/meeting.rb
181
+ - lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb
182
+ - lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb
179
183
  - lib/legion/extensions/microsoft_teams/actors/absorb_meeting.rb
180
184
  - lib/legion/extensions/microsoft_teams/actors/api_ingest.rb
181
185
  - lib/legion/extensions/microsoft_teams/actors/auth_validator.rb