lex-microsoft_teams 0.6.37 → 0.6.40
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 +5 -0
- data/CHANGELOG.md +20 -0
- data/lib/legion/extensions/microsoft_teams/absorbers/channel.rb +226 -0
- data/lib/legion/extensions/microsoft_teams/absorbers/chat.rb +181 -0
- data/lib/legion/extensions/microsoft_teams/actors/absorb_channel.rb +60 -0
- data/lib/legion/extensions/microsoft_teams/actors/absorb_chat.rb +60 -0
- data/lib/legion/extensions/microsoft_teams/helpers/browser_auth.rb +0 -8
- data/lib/legion/extensions/microsoft_teams/helpers/client.rb +2 -0
- data/lib/legion/extensions/microsoft_teams/local_cache/extractor.rb +2 -2
- data/lib/legion/extensions/microsoft_teams/runners/api_ingest.rb +1 -1
- data/lib/legion/extensions/microsoft_teams/version.rb +1 -1
- data/lib/legion/extensions/microsoft_teams.rb +2 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6f9d39d6ddf7f53a0d22f727e6ca9cfede2aa3487cef4c8ffee064b549a158c
|
|
4
|
+
data.tar.gz: efbe44cedda75981daae45b173f795c968f2883e17d8526b512c0324a9269e73
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2147849085641ca61d971a3f7b37009a481285cf9edb1dead731d03f6007dac3845168d2aeeb19f6737cd8673144e0673b860e924676136b7c6213ef61240114
|
|
7
|
+
data.tar.gz: 297f2da23ebd6a9db658ca27eecb1504376cee05745f4dea3e5b9b5d4a29d33923c28375a766b99de9a5e58d935dc6688c3552cb55442514b4a187085335f7a2
|
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,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.6.40] - 2026-04-23
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `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.)
|
|
9
|
+
|
|
10
|
+
## [0.6.39] - 2026-04-23
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `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`
|
|
14
|
+
- `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`
|
|
15
|
+
- `Actors::AbsorbChat` — Subscription actor delegating to `Absorbers::Chat#absorb`, mirrors the `AbsorbMeeting` actor pattern
|
|
16
|
+
- `Actors::AbsorbChannel` — Subscription actor delegating to `Absorbers::Channel#absorb`, mirrors the `AbsorbMeeting` actor pattern
|
|
17
|
+
- Both new absorbers and actors are conditionally required (gated on `Legion::Extensions::Absorbers::Base` presence) in `microsoft_teams.rb`
|
|
18
|
+
|
|
19
|
+
## [0.6.38] - 2026-04-23
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `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)
|
|
23
|
+
- `Helpers::Client#bot_connection` applies the same fallback using `Legion::Settings[:microsoft_teams]&.dig(:auth, :bot, :token)`
|
|
24
|
+
|
|
5
25
|
## [0.7.0] - unreleased
|
|
6
26
|
|
|
7
27
|
### 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
|
|
@@ -8,6 +8,7 @@ module Legion
|
|
|
8
8
|
module Helpers
|
|
9
9
|
module Client
|
|
10
10
|
def graph_connection(token: nil, api_url: 'https://graph.microsoft.com/v1.0', **_opts)
|
|
11
|
+
token ||= Legion::Settings[:microsoft_teams]&.dig(:auth, :delegated, :token)
|
|
11
12
|
Faraday.new(url: api_url) do |conn|
|
|
12
13
|
conn.request :json
|
|
13
14
|
conn.response :json, content_type: /\bjson$/
|
|
@@ -17,6 +18,7 @@ module Legion
|
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
def bot_connection(token: nil, service_url: 'https://smba.trafficmanager.net/teams/', **_opts)
|
|
21
|
+
token ||= Legion::Settings[:microsoft_teams]&.dig(:auth, :bot, :token)
|
|
20
22
|
Faraday.new(url: service_url) do |conn|
|
|
21
23
|
conn.request :json
|
|
22
24
|
conn.response :json, content_type: /\bjson$/
|
|
@@ -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)
|
|
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:)
|
|
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/
|
|
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
|
|
|
@@ -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.
|
|
4
|
+
version: 0.6.40
|
|
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
|