chat_sdk-teams 1.1.0 → 1.2.0

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: 5dd8b4de05c46835a9f07d65dd88446c9b76bc3caef20447fbf2e660d6244e32
4
- data.tar.gz: ffd479d4c04b616d7f18559871f999f7b02d81ef627fd5a0da1eca3a2021d44e
3
+ metadata.gz: 7a6410c72972c0210380902fcf4a176c992c68d7c18154ff55f2a286fe1823a2
4
+ data.tar.gz: 59897106f6792d7ec3c73b76de36019fa1eb40126ec120bf28060fe35cc47cc2
5
5
  SHA512:
6
- metadata.gz: 180473cb244ebb81bbbfc2c6027525dead9def447370eab1a22a4332b81cbe116a7391bc64dfb9ece492c40106d2984a705c6529fb0506755650370630cd6f34
7
- data.tar.gz: 225fa926d125f21ff5cb9c970015aa9da7e7d5415caf0fbceac7d9cd73ee11223ebe74b18a3e75fb5078fd1dd3efdab50eceb293286f7d6a5ef2e35cd00404fe
6
+ metadata.gz: 485f8fea6b1f2702b71a7b1a78cbef630255c0a88d92d5b8e04a723bc30fd7629746eb9a84fc6e99871baec9dc488aeb7ee2bfebfaacf60a95e298f48a3b4571
7
+ data.tar.gz: f25e032254fee24a840cd7c14afd317ea45c279e404f2bfb6cf5a494d20803f980f2452d7e6a99e6f3d7b485a435c6cf4316682b3cdc3737fcd45eb5072ef8ab
@@ -9,11 +9,17 @@ module ChatSDK
9
9
 
10
10
  case activity["type"]
11
11
  when "message"
12
- parse_message(activity, bot_app_id)
12
+ if action_submission?(activity)
13
+ parse_invoke(activity, bot_app_id)
14
+ else
15
+ parse_message(activity, bot_app_id)
16
+ end
13
17
  when "messageReaction"
14
18
  parse_message_reaction(activity)
15
19
  when "invoke"
16
- parse_invoke(activity)
20
+ parse_invoke(activity, bot_app_id)
21
+ when "installationUpdate"
22
+ parse_installation(activity, bot_app_id)
17
23
  else
18
24
  []
19
25
  end
@@ -22,9 +28,9 @@ module ChatSDK
22
28
  private
23
29
 
24
30
  def parse_message(activity, bot_app_id)
25
- return [] if activity.dig("from", "id") == bot_app_id
31
+ return [] if self_message?(activity, bot_app_id)
26
32
 
27
- author = build_author(activity)
33
+ author = build_author(activity, bot_app_id)
28
34
  conversation_id = activity.dig("conversation", "id")
29
35
  thread_id = activity.dig("conversation", "id")
30
36
 
@@ -103,19 +109,37 @@ module ChatSDK
103
109
  events
104
110
  end
105
111
 
106
- def parse_invoke(activity)
112
+ def parse_invoke(activity, bot_app_id)
107
113
  return [] unless activity.dig("value")
108
114
 
109
115
  conversation_id = activity.dig("conversation", "id")
110
- user = build_author(activity)
111
-
112
- action_id = activity.dig("value", "action") || activity["name"] || "invoke"
113
- value = activity.dig("value", "data") || activity["value"]
116
+ user = build_author(activity, bot_app_id)
117
+
118
+ value_data = activity["value"]
119
+ nested_data = value_data["data"] if value_data.is_a?(Hash)
120
+ action = value_data["action"] if value_data.is_a?(Hash)
121
+ action_data = action.is_a?(Hash) ? action["data"] : nil
122
+ data = if nested_data.is_a?(Hash)
123
+ nested_data
124
+ elsif action_data.is_a?(Hash)
125
+ action_data
126
+ else
127
+ value_data
128
+ end
129
+ data = {} unless data.is_a?(Hash)
130
+ action_id = if action_data.is_a?(Hash)
131
+ action_data["actionId"] || action_data["action"]
132
+ end
133
+ action_id ||= action["verb"] if action.is_a?(Hash)
134
+ action_id ||= data["actionId"] || data["action"] ||
135
+ (action unless action.is_a?(Hash)) || activity["name"] || "invoke"
136
+ value = data["value"] || (nested_data unless nested_data.is_a?(Hash)) || data
114
137
  value = value.is_a?(Hash) ? JSON.generate(value) : value.to_s
115
138
 
116
139
  [ChatSDK::Events::Action.new(
117
140
  action_id: action_id,
118
141
  value: value,
142
+ values: action_values(data),
119
143
  user: user,
120
144
  thread_id: conversation_id,
121
145
  channel_id: conversation_id,
@@ -125,17 +149,80 @@ module ChatSDK
125
149
  )]
126
150
  end
127
151
 
128
- def build_author(activity)
152
+ def action_submission?(activity)
153
+ value = activity["value"]
154
+ value.is_a?(Hash) && (value.key?("data") || value.key?("action") || value.key?("actionId"))
155
+ end
156
+
157
+ def parse_installation(activity, bot_app_id)
158
+ return [] unless app_id?(activity.dig("recipient", "id"), bot_app_id)
159
+
160
+ action = activity["action"]
161
+ event_class = case action
162
+ when "add", "add-upgrade" then ChatSDK::Events::Installed
163
+ when "remove", "remove-upgrade" then ChatSDK::Events::Uninstalled
164
+ else return []
165
+ end
166
+
167
+ conversation_id = activity.dig("conversation", "id")
168
+ return [] unless conversation_id
169
+
170
+ team_id = activity.dig("channelData", "team", "id")
171
+ tenant_id = activity.dig("conversation", "tenantId") ||
172
+ activity.dig("channelData", "tenant", "id")
173
+ user_id = activity.dig("from", "aadObjectId") || activity.dig("from", "id")
174
+ installation_id = if team_id
175
+ team_id
176
+ elsif tenant_id && user_id
177
+ "#{tenant_id}:#{user_id}"
178
+ else
179
+ tenant_id || conversation_id
180
+ end
181
+ [event_class.new(
182
+ installation_id: installation_id,
183
+ team_id: team_id,
184
+ tenant_id: tenant_id,
185
+ conversation_id: conversation_id,
186
+ action: action,
187
+ user: build_author(activity, bot_app_id),
188
+ platform: :teams,
189
+ adapter_name: :teams,
190
+ raw: activity
191
+ )]
192
+ end
193
+
194
+ def build_author(activity, bot_app_id)
129
195
  from = activity["from"] || {}
196
+ from_id = from["id"] || "unknown"
130
197
  ChatSDK::Author.new(
131
- id: from["id"] || "unknown",
132
- name: from["name"] || from["id"] || "unknown",
198
+ id: from_id,
199
+ name: from["name"] || from_id,
133
200
  platform: :teams,
134
- bot: from["role"] == "bot",
201
+ bot: from["role"].to_s.downcase == "bot" || from_id.start_with?("28:"),
202
+ me: self_message?(activity, bot_app_id),
135
203
  locale: activity["locale"]
136
204
  )
137
205
  end
138
206
 
207
+ def self_message?(activity, bot_app_id)
208
+ from_id = activity.dig("from", "id")
209
+ return true if app_id?(from_id, bot_app_id)
210
+
211
+ recipient_id = activity.dig("recipient", "id")
212
+ from_id && recipient_id && from_id == recipient_id
213
+ end
214
+
215
+ def app_id?(id, bot_app_id)
216
+ return false unless id && bot_app_id
217
+
218
+ id == bot_app_id || id == "28:#{bot_app_id}"
219
+ end
220
+
221
+ def action_values(data)
222
+ values = data.except("action", "actionId", "value", "msteams")
223
+ values.empty? ? nil : values
224
+ end
225
+
139
226
  def bot_mentioned?(activity, bot_app_id)
140
227
  return false unless bot_app_id
141
228
 
@@ -1,15 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "uri"
4
+
3
5
  module ChatSDK
4
6
  module Teams
5
7
  class Adapter < ChatSDK::Adapter::Base
8
+ DEFAULT_SERVICE_URL = "https://smba.trafficmanager.net/teams/"
9
+ VERIFIED_JWT_CLAIMS_ENV_KEY = "chat_sdk.teams.verified_jwt_claims"
10
+
6
11
  capabilities :edit_messages, :delete_messages, :threads, :direct_messages,
7
12
  :file_uploads, :streaming_edit, :typing_indicator, :message_history
8
13
 
9
14
  attr_reader :client
10
15
 
11
16
  def initialize(app_id: nil, app_password: nil, tenant_id: nil,
12
- graph_client_id: nil, graph_client_secret: nil, graph_tenant_id: nil)
17
+ graph_client_id: nil, graph_client_secret: nil, graph_tenant_id: nil,
18
+ service_url: nil)
13
19
  @app_id = app_id || ENV["TEAMS_APP_ID"]
14
20
  @app_password = app_password || ENV["TEAMS_APP_PASSWORD"]
15
21
  @tenant_id = tenant_id || ENV["TEAMS_TENANT_ID"]
@@ -21,6 +27,7 @@ module ChatSDK
21
27
  @graph_client_secret = graph_client_secret || ENV["TEAMS_GRAPH_CLIENT_SECRET"]
22
28
  @graph_tenant_id = graph_tenant_id || ENV["TEAMS_GRAPH_TENANT_ID"]
23
29
  @graph_client = GraphClient.new(@graph_client_id, @graph_client_secret, @graph_tenant_id) if @graph_client_id
30
+ @default_service_url = service_url || ENV["TEAMS_SERVICE_URL"] || DEFAULT_SERVICE_URL
24
31
 
25
32
  @client = BotFrameworkClient.new(app_id: @app_id, app_password: @app_password)
26
33
  @jwt_verifier = JwtVerifier.new(app_id: @app_id)
@@ -28,6 +35,10 @@ module ChatSDK
28
35
  @service_urls = {}
29
36
  end
30
37
 
38
+ def set_state(state)
39
+ @state = state
40
+ end
41
+
31
42
  def name
32
43
  :teams
33
44
  end
@@ -38,16 +49,17 @@ module ChatSDK
38
49
  raise ChatSDK::SignatureVerificationError, "Missing authorization header" unless auth_header
39
50
 
40
51
  token = auth_header.sub(/^Bearer\s+/i, "")
41
- @jwt_verifier.verify!(token)
52
+ rack_request.env[VERIFIED_JWT_CLAIMS_ENV_KEY] = @jwt_verifier.verify!(token)
42
53
  true
43
54
  end
44
55
 
45
56
  def parse_events(rack_request)
46
57
  activity = read_json_body(rack_request)
47
58
 
48
- # Cache the service URL for this conversation
49
- if activity["serviceUrl"] && activity.dig("conversation", "id")
50
- @service_urls[activity.dig("conversation", "id")] = activity["serviceUrl"]
59
+ conversation_id = activity.dig("conversation", "id")
60
+ claims = rack_request.env[VERIFIED_JWT_CLAIMS_ENV_KEY]
61
+ if conversation_id && trusted_service_url?(activity["serviceUrl"], claims)
62
+ register_service_url(conversation_id, activity["serviceUrl"])
51
63
  end
52
64
 
53
65
  ActivityParser.parse(activity, bot_app_id: @app_id)
@@ -61,7 +73,7 @@ module ChatSDK
61
73
  service_url = service_url_for(channel_id)
62
74
 
63
75
  activity = build_activity(msg)
64
- activity["replyToId"] = thread_id if thread_id
76
+ activity["replyToId"] = activity_reply_id(channel_id, thread_id) if thread_id
65
77
 
66
78
  result = @client.send_activity(
67
79
  service_url: service_url,
@@ -73,14 +85,14 @@ module ChatSDK
73
85
  id: result["id"],
74
86
  text: msg.text || "",
75
87
  author: ChatSDK::Author.new(id: @app_id, name: "bot", platform: :teams, bot: true),
76
- thread_id: thread_id || result["id"],
88
+ thread_id: thread_id || channel_thread_id(channel_id, result["id"]),
77
89
  channel_id: channel_id,
78
90
  platform: :teams,
79
91
  raw: result
80
92
  )
81
93
  end
82
94
 
83
- def edit_message(channel_id:, message_id:, message:)
95
+ def edit_message(channel_id:, message_id:, message:, **_options)
84
96
  require_capability!(:edit_messages)
85
97
  msg = ChatSDK::PostableMessage.from(message)
86
98
  service_url = service_url_for(channel_id)
@@ -96,7 +108,7 @@ module ChatSDK
96
108
  )
97
109
  end
98
110
 
99
- def delete_message(channel_id:, message_id:)
111
+ def delete_message(channel_id:, message_id:, **_options)
100
112
  require_capability!(:delete_messages)
101
113
  service_url = service_url_for(channel_id)
102
114
 
@@ -126,7 +138,7 @@ module ChatSDK
126
138
  "contentUrl" => "data:application/octet-stream;base64,#{content}"
127
139
  }]
128
140
  }
129
- activity["replyToId"] = thread_id if thread_id
141
+ activity["replyToId"] = activity_reply_id(channel_id, thread_id) if thread_id
130
142
 
131
143
  @client.send_activity(
132
144
  service_url: service_url,
@@ -137,14 +149,7 @@ module ChatSDK
137
149
 
138
150
  def open_dm(user_id)
139
151
  require_capability!(:direct_messages)
140
- # To open a DM, we need a service URL. Use the first cached one.
141
- service_url = @service_urls.values.first
142
- unless service_url
143
- raise ChatSDK::PlatformError.new(
144
- "No service URL available. A Teams activity must be received first.",
145
- adapter_name: :teams
146
- )
147
- end
152
+ service_url = @service_urls.values.first || @default_service_url
148
153
 
149
154
  payload = {
150
155
  "bot" => {"id" => @app_id},
@@ -154,7 +159,7 @@ module ChatSDK
154
159
 
155
160
  result = @client.create_conversation(service_url: service_url, payload: payload)
156
161
  conversation_id = result["id"]
157
- @service_urls[conversation_id] = service_url
162
+ register_service_url(conversation_id, service_url)
158
163
  conversation_id
159
164
  end
160
165
 
@@ -162,7 +167,7 @@ module ChatSDK
162
167
  super
163
168
  end
164
169
 
165
- def start_typing(channel_id:, thread_id: nil)
170
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
166
171
  service_url = service_url_for(channel_id)
167
172
  @client.send_typing(
168
173
  service_url: service_url,
@@ -191,7 +196,7 @@ module ChatSDK
191
196
  "(TEAMS_GRAPH_CLIENT_ID, TEAMS_GRAPH_CLIENT_SECRET, TEAMS_GRAPH_TENANT_ID)"
192
197
  end
193
198
 
194
- result = @graph_client.fetch_messages(chat_id: channel_id, limit: limit)
199
+ result = @graph_client.fetch_messages(chat_id: channel_id, limit: limit, cursor: cursor)
195
200
  messages = (result["value"] || []).map { |m| parse_graph_message(m, channel_id) }
196
201
  next_link = result["@odata.nextLink"]
197
202
  [messages, next_link]
@@ -200,6 +205,18 @@ module ChatSDK
200
205
  # Store a service URL for a conversation (useful when sending proactive messages)
201
206
  def register_service_url(conversation_id, service_url)
202
207
  @service_urls[conversation_id] = service_url
208
+ @state&.set(service_url_key(conversation_id), service_url)
209
+ end
210
+
211
+ def webhook_response(events)
212
+ return unless events.any? { |event| event.raw.is_a?(Hash) && event.raw["type"] == "invoke" }
213
+
214
+ body = {
215
+ "statusCode" => 200,
216
+ "type" => "application/vnd.microsoft.activity.message",
217
+ "value" => "OK"
218
+ }
219
+ [200, {"content-type" => "application/json"}, [JSON.generate(body)]]
203
220
  end
204
221
 
205
222
  private
@@ -218,11 +235,25 @@ module ChatSDK
218
235
  activity["text"] = postable_message.text
219
236
  end
220
237
 
238
+ if postable_message.ai_generated
239
+ activity["entities"] = [{
240
+ "type" => "https://schema.org/Message",
241
+ "@type" => "Message",
242
+ "@context" => "https://schema.org",
243
+ "additionalType" => ["AIGeneratedContent"]
244
+ }]
245
+ end
246
+
221
247
  activity
222
248
  end
223
249
 
224
250
  def service_url_for(channel_id)
225
251
  url = @service_urls[channel_id]
252
+ if !url && @state
253
+ url = @state.get(service_url_key(channel_id))
254
+ @service_urls[channel_id] = url if url
255
+ end
256
+ url ||= @default_service_url
226
257
  unless url
227
258
  raise ChatSDK::PlatformError.new(
228
259
  "No service URL for conversation #{channel_id}. Register it first or receive an activity.",
@@ -232,10 +263,61 @@ module ChatSDK
232
263
  url
233
264
  end
234
265
 
266
+ def service_url_key(conversation_id)
267
+ "chat_sdk:teams:service_url:#{conversation_id}"
268
+ end
269
+
270
+ def trusted_service_url?(service_url, claims)
271
+ return false unless service_url.is_a?(String)
272
+
273
+ uri = URI.parse(service_url)
274
+ return false unless uri.scheme&.casecmp?("https") && uri.host
275
+
276
+ service_url_claim = claims.is_a?(Hash) ? claims["serviceurl"] : nil
277
+ service_url_claim.nil? ||
278
+ (service_url_claim.is_a?(String) &&
279
+ normalize_service_url(service_url) == normalize_service_url(service_url_claim))
280
+ rescue URI::InvalidURIError
281
+ false
282
+ end
283
+
284
+ def normalize_service_url(service_url)
285
+ uri = URI.parse(service_url)
286
+ return unless uri.scheme && uri.host
287
+
288
+ uri.scheme = uri.scheme.downcase
289
+ uri.host = uri.host.downcase
290
+ uri.path = uri.path.delete_suffix("/")
291
+ uri.to_s
292
+ rescue URI::InvalidURIError
293
+ nil
294
+ end
295
+
296
+ def channel_thread_id(channel_id, activity_id)
297
+ return activity_id unless channel_id.to_s.include?("@thread.tacv2") && activity_id
298
+
299
+ "#{channel_id};messageid=#{activity_id}"
300
+ end
301
+
302
+ def activity_reply_id(channel_id, thread_id)
303
+ prefix = "#{channel_id};messageid="
304
+ thread_id.start_with?(prefix) ? thread_id.delete_prefix(prefix) : thread_id
305
+ end
306
+
235
307
  def parse_graph_message(data, channel_id)
236
308
  from = data["from"] || {}
237
309
  user = from["user"] || from["application"] || {}
238
310
  is_bot = !from["application"].nil?
311
+ attachments = Array(data["attachments"]).filter_map do |attachment|
312
+ next unless attachment["contentType"] == "reference" && attachment["contentUrl"]
313
+
314
+ begin
315
+ resolved = @graph_client.resolve_reference_attachment(content_url: attachment["contentUrl"])
316
+ resolved.merge("filename" => resolved["filename"] || attachment["name"])
317
+ rescue ChatSDK::PlatformError, ArgumentError, Faraday::Error
318
+ nil
319
+ end
320
+ end
239
321
 
240
322
  ChatSDK::Message.new(
241
323
  id: data["id"],
@@ -244,11 +326,13 @@ module ChatSDK
244
326
  id: user["id"] || "unknown",
245
327
  name: user["displayName"] || "unknown",
246
328
  platform: :teams,
247
- bot: is_bot
329
+ bot: is_bot,
330
+ me: is_bot && user["id"] == @app_id
248
331
  ),
249
332
  thread_id: data["replyToId"] || data["id"],
250
333
  channel_id: channel_id,
251
334
  platform: :teams,
335
+ attachments: attachments,
252
336
  timestamp: data["createdDateTime"],
253
337
  raw: data
254
338
  )
@@ -62,6 +62,7 @@ module ChatSDK
62
62
  when :button then render_action_submit(node)
63
63
  when :link_button then render_action_open_url(node)
64
64
  when :select then render_select(node)
65
+ when :text_input then render_text_input(node)
65
66
  when :table then render_table(node)
66
67
  when :chart then render_chart(node)
67
68
  end
@@ -142,7 +143,7 @@ module ChatSDK
142
143
  action = {
143
144
  "type" => "Action.Submit",
144
145
  "title" => node.attributes[:text],
145
- "data" => {"action" => node.attributes[:id]}
146
+ "data" => {"actionId" => node.attributes[:id]}
146
147
  }
147
148
  action["data"]["value"] = node.attributes[:value] if node.attributes[:value]
148
149
  action["tooltip"] = node.attributes[:tooltip] if node.attributes[:tooltip]
@@ -180,23 +181,65 @@ module ChatSDK
180
181
  input
181
182
  end
182
183
 
184
+ def render_text_input(node)
185
+ input = {
186
+ "type" => "Input.Text",
187
+ "id" => node.attributes[:id],
188
+ "label" => node.attributes[:label],
189
+ "isMultiline" => node.attributes[:multiline],
190
+ "isRequired" => !node.attributes[:optional]
191
+ }
192
+ input["placeholder"] = node.attributes[:placeholder] if node.attributes[:placeholder]
193
+ input["value"] = node.attributes[:value] unless node.attributes[:value].nil?
194
+ input
195
+ end
196
+
183
197
  def render_table(node)
184
- rows = [Array(node.attributes[:headers]), *Array(node.attributes[:rows])]
198
+ headers = Array(node.attributes[:headers])
199
+ data_rows = Array(node.attributes[:rows])
200
+ column_count = data_rows.reduce(headers.length) { |count, row| [count, Array(row).length].max }
201
+ return if column_count.zero?
202
+
203
+ alignment = {left: "Left", center: "Center", right: "Right"}
204
+ vertical_alignment = {top: "Top", center: "Center", bottom: "Bottom"}
205
+ widths = Array(node.attributes[:widths])
206
+ aligns = Array(node.attributes[:align])
207
+ rows = data_rows.map { |row| Array(row) }
208
+ rows.unshift(headers) unless headers.empty?
209
+
185
210
  {
186
- "type" => "Container",
187
- "items" => rows.each_with_index.map do |row, index|
211
+ "type" => "Table",
212
+ "firstRowAsHeaders" => !headers.empty?,
213
+ "showGridLines" => node.attributes.fetch(:grid_lines, true),
214
+ "columns" => Array.new(column_count) do |index|
215
+ width = widths[index]
216
+ column_width = width
217
+ column_width = 1 unless column_width.is_a?(Integer) && column_width.positive?
218
+ column = {"width" => column_width}
219
+ column_alignment = alignment[aligns[index]&.to_sym]
220
+ column["horizontalCellContentAlignment"] = column_alignment if column_alignment
221
+ column
222
+ end,
223
+ "rows" => rows.each_with_index.map do |row, index|
188
224
  {
189
- "type" => "ColumnSet",
190
- "columns" => Array(row).map do |cell|
225
+ "type" => "TableRow",
226
+ "cells" => Array.new(column_count) do |cell_index|
191
227
  {
192
- "type" => "Column",
193
- "width" => "stretch",
194
- "items" => [{"type" => "TextBlock", "text" => cell.to_s, "weight" => ("bolder" if index.zero?), "wrap" => true}.compact]
228
+ "type" => "TableCell",
229
+ "items" => [{
230
+ "type" => "TextBlock",
231
+ "text" => Array(row)[cell_index].to_s,
232
+ "weight" => ("Bolder" if index.zero? && !headers.empty?),
233
+ "wrap" => true
234
+ }.compact]
195
235
  }
196
236
  end
197
237
  }
198
- end
238
+ end,
239
+ "gridStyle" => node.attributes[:grid_style],
240
+ "verticalCellContentAlignment" => vertical_alignment[node.attributes[:vertical_align]&.to_sym]
199
241
  }
242
+ .compact
200
243
  end
201
244
 
202
245
  def render_chart(node)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
4
+ require "uri"
5
+
3
6
  module ChatSDK
4
7
  module Teams
5
8
  class GraphClient
@@ -15,9 +18,11 @@ module ChatSDK
15
18
  @token_expires_at = nil
16
19
  end
17
20
 
18
- def fetch_messages(chat_id:, limit: 50)
21
+ def fetch_messages(chat_id:, limit: 50, cursor: nil)
22
+ messages_url = chat_messages_url(chat_id)
23
+ url = cursor || "#{messages_url}?$top=#{limit}&$orderby=createdDateTime desc"
24
+ validate_graph_url!(url, chat_id) if cursor
19
25
  token = fetch_token
20
- url = "#{GRAPH_BASE_URL}/chats/#{chat_id}/messages?$top=#{limit}&$orderby=createdDateTime desc"
21
26
 
22
27
  response = api_connection.get(url) do |req|
23
28
  req.headers["Authorization"] = "Bearer #{token}"
@@ -35,6 +40,27 @@ module ChatSDK
35
40
  response.body
36
41
  end
37
42
 
43
+ def resolve_reference_attachment(content_url:)
44
+ item = fetch_reference_item(content_url)
45
+ {
46
+ "url" => item["@microsoft.graph.downloadUrl"],
47
+ "mime_type" => item.dig("file", "mimeType") || "application/octet-stream",
48
+ "filename" => item["name"]
49
+ }
50
+ end
51
+
52
+ def resolve_reference_url(content_url:)
53
+ resolve_reference_attachment(content_url: content_url).fetch("url")
54
+ end
55
+
56
+ def download_reference_attachment(content_url:)
57
+ download_url = resolve_reference_url(content_url: content_url)
58
+ uri = URI.parse(download_url)
59
+ raise ArgumentError, "Teams download URL must use HTTPS" unless uri.is_a?(URI::HTTPS)
60
+
61
+ Faraday.get(download_url)
62
+ end
63
+
38
64
  def fetch_token
39
65
  return @access_token if @access_token && @token_expires_at && Time.now < @token_expires_at
40
66
 
@@ -78,6 +104,52 @@ module ChatSDK
78
104
  f.adapter :net_http
79
105
  end
80
106
  end
107
+
108
+ def fetch_reference_item(content_url)
109
+ uri = URI.parse(content_url)
110
+ raise ArgumentError, "Teams attachment URL must use HTTPS" unless uri.is_a?(URI::HTTPS)
111
+
112
+ token = fetch_token
113
+ share_id = "u!#{Base64.urlsafe_encode64(content_url, padding: false)}"
114
+ response = api_connection.get("#{GRAPH_BASE_URL}/shares/#{share_id}/driveItem") do |req|
115
+ req.headers["Authorization"] = "Bearer #{token}"
116
+ end
117
+ unless response.success?
118
+ raise ChatSDK::PlatformError.new(
119
+ "Graph API error: #{response.status}",
120
+ status: response.status,
121
+ body: response.body,
122
+ adapter_name: :teams
123
+ )
124
+ end
125
+
126
+ item = response.body
127
+ unless item["@microsoft.graph.downloadUrl"]
128
+ raise ChatSDK::PlatformError.new("Graph API did not return an attachment download URL", adapter_name: :teams)
129
+ end
130
+
131
+ item
132
+ rescue URI::InvalidURIError
133
+ raise ArgumentError, "Invalid Teams attachment URL"
134
+ end
135
+
136
+ def chat_messages_url(chat_id)
137
+ "#{GRAPH_BASE_URL}/chats/#{URI::DEFAULT_PARSER.escape(chat_id.to_s, /[^A-Za-z0-9._~-]/)}/messages"
138
+ end
139
+
140
+ def validate_graph_url!(url, chat_id)
141
+ uri = URI.parse(url)
142
+ expected_path = URI::DEFAULT_PARSER.unescape(URI.parse(chat_messages_url(chat_id)).path)
143
+ actual_path = URI::DEFAULT_PARSER.unescape(uri.path)
144
+ valid = uri.is_a?(URI::HTTPS) && uri.host == "graph.microsoft.com" &&
145
+ uri.port == 443 && !uri.userinfo && !uri.fragment && actual_path == expected_path
146
+ unless valid
147
+ raise ArgumentError,
148
+ "Graph pagination cursor must use https://graph.microsoft.com and the requested chat path"
149
+ end
150
+ rescue URI::InvalidURIError
151
+ raise ArgumentError, "Invalid Graph pagination cursor"
152
+ end
81
153
  end
82
154
  end
83
155
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chat_sdk-teams
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Quentin Rousseau
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - ">="
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '1.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: '1.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement