lex-microsoft_teams 0.6.36 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 82da6ce40d44d7e0db48fa308c66744d08e9c3acef735947b0a7eaffcc43d779
4
- data.tar.gz: 49350f0fae7ae8d5abb60c37939de50d514bda4644f9c916116b98096ed7a7d6
3
+ metadata.gz: f6f9d39d6ddf7f53a0d22f727e6ca9cfede2aa3487cef4c8ffee064b549a158c
4
+ data.tar.gz: efbe44cedda75981daae45b173f795c968f2883e17d8526b512c0324a9269e73
5
5
  SHA512:
6
- metadata.gz: 2caec65f224e1d07e9a723d01ba087b7b287f105b679786a76d526a8e984e461b355156ea62e54f65ed8c2579190242aa2ecb971c26c0af23128889dda1c22e1
7
- data.tar.gz: ed7b550be8e342a45e0f059847a08cf12c4356d30a60ff33e02ac055eb0b2ddbdbca53dc9a1f80fb27a65fb19c21ef93a7727d4820df50243afaf72b06f22cc6
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,37 @@
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
+
25
+ ## [0.7.0] - unreleased
26
+
27
+ ### Added
28
+ - New `Runners::Loop` module with four functions for Microsoft Loop component support via `fluidEmbedCard`:
29
+ - `create_loop_file` — Creates a new `.loop` file in a user's OneDrive via the Graph API; returns drive item metadata including `webUrl`
30
+ - `loop_attachment` — Builds the `fluidEmbedCard` + placeholder attachment pair required to embed a Loop component in a Teams message
31
+ - `post_loop_to_chat` — Posts a Loop component inline into a Teams chat thread
32
+ - `post_loop_to_channel` — Posts a Loop component inline into a Teams channel thread
33
+ - Requires `Files.ReadWrite` and `Sites.ReadWrite.All` Graph API permissions for `create_loop_file`; `Chat.ReadWrite` and `ChannelMessage.Send` are already required by existing runners
34
+ - **Note:** Programmatic write access to Loop page *content* (Fluid Framework) is not yet available via Microsoft Graph; Loop files must be opened in Teams to initialize the collaborative session
35
+
5
36
  ## [0.6.36] - 2026-04-13
6
37
 
7
38
  ### Fixed
data/README.md CHANGED
@@ -113,6 +113,14 @@ gem install lex-microsoft_teams
113
113
  - `action_submit` — Create a Submit action
114
114
  - `message_attachment` — Wrap a card as a message attachment
115
115
 
116
+ ### Loop Components
117
+ - `create_loop_file` — Create a new `.loop` file in a user's OneDrive; returns drive item metadata including `webUrl`
118
+ - `loop_attachment` — Build a `fluidEmbedCard` attachment array for embedding an existing Loop component URL in a Teams message
119
+ - `post_loop_to_chat` — Post a Loop component inline into a Teams chat thread
120
+ - `post_loop_to_channel` — Post a Loop component inline into a Teams channel thread
121
+
122
+ > **Note:** Creating a `.loop` file provisions the OneDrive item; the Fluid Framework collaborative session is initialized by Teams on first open. Programmatic write access to Loop page *content* is not yet available via Microsoft Graph.
123
+
116
124
  ### Bot Framework
117
125
  - `send_activity` — Send an activity to a conversation
118
126
  - `reply_to_activity` — Reply to an existing activity
@@ -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
@@ -15,6 +15,7 @@ require 'legion/extensions/microsoft_teams/runners/meetings'
15
15
  require 'legion/extensions/microsoft_teams/runners/transcripts'
16
16
  require 'legion/extensions/microsoft_teams/runners/people'
17
17
  require 'legion/extensions/microsoft_teams/runners/ownership'
18
+ require 'legion/extensions/microsoft_teams/runners/loop'
18
19
 
19
20
  module Legion
20
21
  module Extensions
@@ -37,6 +38,7 @@ module Legion
37
38
  include Runners::CacheIngest
38
39
  include Runners::People
39
40
  include Runners::Ownership
41
+ include Runners::Loop
40
42
 
41
43
  attr_reader :opts
42
44
 
@@ -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) # 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
 
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'legion/extensions/microsoft_teams/helpers/client'
5
+
6
+ module Legion
7
+ module Extensions
8
+ module MicrosoftTeams
9
+ module Runners
10
+ module Loop
11
+ include Legion::Extensions::MicrosoftTeams::Helpers::Client
12
+ include Legion::JSON::Helper
13
+
14
+ # Creates a new .loop file in the user's OneDrive via the Graph API.
15
+ # The Fluid Framework collaborative session is initialized by Teams on first open.
16
+ # Returns the drive item metadata including +webUrl+, which can be passed to
17
+ # +post_loop_to_chat+ or +post_loop_to_channel+.
18
+ #
19
+ # @param filename [String] Name of the file (e.g. "incident-status" or "incident-status.loop")
20
+ # @param folder_path [String] OneDrive folder path relative to root (default: root)
21
+ # @param user_id [String] Graph user ID or 'me' (default: 'me')
22
+ def create_loop_file(filename:, folder_path: nil, user_id: 'me', **)
23
+ filename = "#{filename}.loop" unless filename.end_with?('.loop')
24
+
25
+ path = if folder_path.nil? || folder_path.empty?
26
+ "users/#{user_id}/drive/root:/#{filename}:/content"
27
+ else
28
+ "users/#{user_id}/drive/root:/#{folder_path}/#{filename}:/content"
29
+ end
30
+
31
+ response = graph_connection(**).put(path, '', 'Content-Type' => 'application/octet-stream')
32
+ { result: response.body }
33
+ end
34
+
35
+ # Builds the fluidEmbedCard attachment array required to embed a Loop component
36
+ # in a Teams message. Pass the resulting array as +attachments:+ to
37
+ # +send_chat_message+ / +send_channel_message+, or use the convenience methods
38
+ # +post_loop_to_chat+ and +post_loop_to_channel+.
39
+ #
40
+ # @param component_url [String] SharePoint/OneDrive URL of the .loop file
41
+ # (the +webUrl+ from +create_loop_file+)
42
+ # @param source_type [String] 'Compose' (default) or 'Loop'
43
+ def loop_attachment(component_url:, source_type: 'Compose', **)
44
+ attachment_id = SecureRandom.hex(16)
45
+ {
46
+ result: [
47
+ {
48
+ id: attachment_id,
49
+ contentType: 'application/vnd.microsoft.card.fluidEmbedCard',
50
+ contentUrl: nil,
51
+ content: json_generate({ componentUrl: component_url, sourceType: source_type }),
52
+ teamsAppId: 'FluidEmbedCard'
53
+ },
54
+ {
55
+ id: 'placeholderCard',
56
+ contentType: 'application/vnd.microsoft.card.codesnippet',
57
+ content: '{}',
58
+ teamsAppId: 'FLUID_PLACEHOLDER_CARD'
59
+ }
60
+ ]
61
+ }
62
+ end
63
+
64
+ # Posts a message into a Teams chat thread with a Loop component embedded inline.
65
+ #
66
+ # @param chat_id [String] Teams chat thread ID (e.g. 19:...@thread.v2)
67
+ # @param component_url [String] SharePoint/OneDrive URL of the .loop file
68
+ # @param body_text [String] Optional plain-text preamble shown above the component
69
+ def post_loop_to_chat(chat_id:, component_url:, body_text: '', **)
70
+ attachments = loop_attachment(component_url: component_url, **)[:result]
71
+ content = body_text.empty? ? '<p></p>' : "<p>#{body_text}</p>"
72
+ payload = { body: { contentType: 'html', content: content }, attachments: attachments }
73
+ response = graph_connection(**).post("chats/#{chat_id}/messages", payload)
74
+ { result: response.body }
75
+ end
76
+
77
+ # Posts a message into a Teams channel thread with a Loop component embedded inline.
78
+ #
79
+ # @param team_id [String] Teams team ID
80
+ # @param channel_id [String] Teams channel ID
81
+ # @param component_url [String] SharePoint/OneDrive URL of the .loop file
82
+ # @param body_text [String] Optional plain-text preamble shown above the component
83
+ # @param subject [String] Optional thread subject line
84
+ def post_loop_to_channel(team_id:, channel_id:, component_url:, body_text: '', subject: nil, **)
85
+ attachments = loop_attachment(component_url: component_url, **)[:result]
86
+ content = body_text.empty? ? '<p></p>' : "<p>#{body_text}</p>"
87
+ payload = { body: { contentType: 'html', content: content }, attachments: attachments }
88
+ payload[:subject] = subject if subject
89
+ response = graph_connection(**).post("teams/#{team_id}/channels/#{channel_id}/messages", payload)
90
+ { result: response.body }
91
+ end
92
+
93
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
94
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MicrosoftTeams
6
- VERSION = '0.6.36'
6
+ VERSION = '0.6.40'
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.36
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
@@ -218,6 +222,7 @@ files:
218
222
  - lib/legion/extensions/microsoft_teams/runners/channels.rb
219
223
  - lib/legion/extensions/microsoft_teams/runners/chats.rb
220
224
  - lib/legion/extensions/microsoft_teams/runners/local_cache.rb
225
+ - lib/legion/extensions/microsoft_teams/runners/loop.rb
221
226
  - lib/legion/extensions/microsoft_teams/runners/meetings.rb
222
227
  - lib/legion/extensions/microsoft_teams/runners/messages.rb
223
228
  - lib/legion/extensions/microsoft_teams/runners/ownership.rb