chat_sdk-teams 1.1.0 → 1.3.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: ebf993ca61c1fe27ad125af30af36361e87922b00ab33b5e80ab7f2cfc280eb3
4
+ data.tar.gz: 84470e0eaf8edc9eaeafd70d5e541ed0553c1ede14a1ee627f735d9f31aa8081
5
5
  SHA512:
6
- metadata.gz: 180473cb244ebb81bbbfc2c6027525dead9def447370eab1a22a4332b81cbe116a7391bc64dfb9ece492c40106d2984a705c6529fb0506755650370630cd6f34
7
- data.tar.gz: 225fa926d125f21ff5cb9c970015aa9da7e7d5415caf0fbceac7d9cd73ee11223ebe74b18a3e75fb5078fd1dd3efdab50eceb293286f7d6a5ef2e35cd00404fe
6
+ metadata.gz: 5b186d93dd6606ab6f8579c6ae1232ab217497e542e976a1e19de5cf08cdc84ffeeb0f8730905e66a30cfb9555a1b9ae01554f933dc29bfe3921b2db15d93175
7
+ data.tar.gz: 140ab32b11655a4732c06d15d0911600d6730d92f8f49963340b7e7a056b9e3050e893cd67e923bd32dccf4ab7b06e7d1797538c267322fea093c379e2a55702
@@ -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,41 @@ 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)
116
+ user = build_author(activity, bot_app_id)
111
117
 
112
- action_id = activity.dig("value", "action") || activity["name"] || "invoke"
113
- value = activity.dig("value", "data") || activity["value"]
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
+ if action_id == AUTO_SUBMIT_ACTION_ID
137
+ return auto_submit_actions(data, activity, conversation_id, user)
138
+ end
139
+
140
+ value = data["value"] || (nested_data unless nested_data.is_a?(Hash)) || data
114
141
  value = value.is_a?(Hash) ? JSON.generate(value) : value.to_s
115
142
 
116
143
  [ChatSDK::Events::Action.new(
117
144
  action_id: action_id,
118
145
  value: value,
146
+ values: action_values(data),
119
147
  user: user,
120
148
  thread_id: conversation_id,
121
149
  channel_id: conversation_id,
@@ -125,17 +153,115 @@ module ChatSDK
125
153
  )]
126
154
  end
127
155
 
128
- def build_author(activity)
156
+ def action_submission?(activity)
157
+ value = activity["value"]
158
+ value.is_a?(Hash) && (value.key?("data") || value.key?("action") || value.key?("actionId"))
159
+ end
160
+
161
+ def parse_installation(activity, bot_app_id)
162
+ return [] unless app_id?(activity.dig("recipient", "id"), bot_app_id)
163
+
164
+ action = activity["action"]
165
+ event_class = case action
166
+ when "add", "add-upgrade" then ChatSDK::Events::Installed
167
+ when "remove", "remove-upgrade" then ChatSDK::Events::Uninstalled
168
+ else return []
169
+ end
170
+
171
+ conversation_id = activity.dig("conversation", "id")
172
+ return [] unless conversation_id
173
+
174
+ team_id = activity.dig("channelData", "team", "id")
175
+ tenant_id = activity.dig("conversation", "tenantId") ||
176
+ activity.dig("channelData", "tenant", "id")
177
+ user_id = activity.dig("from", "aadObjectId") || activity.dig("from", "id")
178
+ installation_id = if team_id
179
+ team_id
180
+ elsif tenant_id && user_id
181
+ "#{tenant_id}:#{user_id}"
182
+ else
183
+ tenant_id || conversation_id
184
+ end
185
+ [event_class.new(
186
+ installation_id: installation_id,
187
+ team_id: team_id,
188
+ tenant_id: tenant_id,
189
+ conversation_id: conversation_id,
190
+ action: action,
191
+ user: build_author(activity, bot_app_id),
192
+ platform: :teams,
193
+ adapter_name: :teams,
194
+ raw: activity
195
+ )]
196
+ end
197
+
198
+ def build_author(activity, bot_app_id)
129
199
  from = activity["from"] || {}
200
+ from_id = from["id"] || "unknown"
130
201
  ChatSDK::Author.new(
131
- id: from["id"] || "unknown",
132
- name: from["name"] || from["id"] || "unknown",
202
+ id: from_id,
203
+ name: from["name"] || from_id,
133
204
  platform: :teams,
134
- bot: from["role"] == "bot",
205
+ bot: from["role"].to_s.downcase == "bot" || from_id.start_with?("28:"),
206
+ me: self_message?(activity, bot_app_id),
135
207
  locale: activity["locale"]
136
208
  )
137
209
  end
138
210
 
211
+ def self_message?(activity, bot_app_id)
212
+ from_id = activity.dig("from", "id")
213
+ return true if app_id?(from_id, bot_app_id)
214
+
215
+ recipient_id = activity.dig("recipient", "id")
216
+ from_id && recipient_id && from_id == recipient_id
217
+ end
218
+
219
+ def app_id?(id, bot_app_id)
220
+ return false unless id && bot_app_id
221
+
222
+ id == bot_app_id || id == "28:#{bot_app_id}"
223
+ end
224
+
225
+ def action_values(data)
226
+ values = data.except("action", "actionId", "value", "msteams")
227
+ values.empty? ? nil : values
228
+ end
229
+
230
+ def auto_submit_actions(data, activity, conversation_id, user)
231
+ values = auto_submit_values(data)
232
+ values = nil if values.empty?
233
+
234
+ data.each_with_object([]) do |(action_id, input_value), events|
235
+ next if %w[actionId msteams].include?(action_id)
236
+
237
+ events << ChatSDK::Events::Action.new(
238
+ action_id: action_id,
239
+ value: submit_value_to_string(input_value),
240
+ values: values,
241
+ user: user,
242
+ thread_id: conversation_id,
243
+ channel_id: conversation_id,
244
+ platform: :teams,
245
+ adapter_name: :teams,
246
+ raw: activity
247
+ )
248
+ end
249
+ end
250
+
251
+ def auto_submit_values(data)
252
+ data.each_with_object({}) do |(key, value), inputs|
253
+ next if %w[actionId msteams].include?(key)
254
+
255
+ input_value = submit_value_to_string(value)
256
+ inputs[key] = input_value unless input_value.nil?
257
+ end
258
+ end
259
+
260
+ def submit_value_to_string(value)
261
+ return value if value.is_a?(String)
262
+ value.to_s if value.is_a?(Numeric)
263
+ end
264
+
139
265
  def bot_mentioned?(activity, bot_app_id)
140
266
  return false unless bot_app_id
141
267
 
@@ -1,15 +1,22 @@
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
+ USER_SERVICE_URL_TTL = 30 * 24 * 60 * 60
10
+ VERIFIED_JWT_CLAIMS_ENV_KEY = "chat_sdk.teams.verified_jwt_claims"
11
+
6
12
  capabilities :edit_messages, :delete_messages, :threads, :direct_messages,
7
13
  :file_uploads, :streaming_edit, :typing_indicator, :message_history
8
14
 
9
15
  attr_reader :client
10
16
 
11
17
  def initialize(app_id: nil, app_password: nil, tenant_id: nil,
12
- graph_client_id: nil, graph_client_secret: nil, graph_tenant_id: nil)
18
+ graph_client_id: nil, graph_client_secret: nil, graph_tenant_id: nil,
19
+ service_url: nil)
13
20
  @app_id = app_id || ENV["TEAMS_APP_ID"]
14
21
  @app_password = app_password || ENV["TEAMS_APP_PASSWORD"]
15
22
  @tenant_id = tenant_id || ENV["TEAMS_TENANT_ID"]
@@ -21,6 +28,7 @@ module ChatSDK
21
28
  @graph_client_secret = graph_client_secret || ENV["TEAMS_GRAPH_CLIENT_SECRET"]
22
29
  @graph_tenant_id = graph_tenant_id || ENV["TEAMS_GRAPH_TENANT_ID"]
23
30
  @graph_client = GraphClient.new(@graph_client_id, @graph_client_secret, @graph_tenant_id) if @graph_client_id
31
+ @default_service_url = service_url || ENV["TEAMS_SERVICE_URL"] || DEFAULT_SERVICE_URL
24
32
 
25
33
  @client = BotFrameworkClient.new(app_id: @app_id, app_password: @app_password)
26
34
  @jwt_verifier = JwtVerifier.new(app_id: @app_id)
@@ -28,6 +36,10 @@ module ChatSDK
28
36
  @service_urls = {}
29
37
  end
30
38
 
39
+ def set_state(state)
40
+ @state = state
41
+ end
42
+
31
43
  def name
32
44
  :teams
33
45
  end
@@ -38,16 +50,19 @@ module ChatSDK
38
50
  raise ChatSDK::SignatureVerificationError, "Missing authorization header" unless auth_header
39
51
 
40
52
  token = auth_header.sub(/^Bearer\s+/i, "")
41
- @jwt_verifier.verify!(token)
53
+ rack_request.env[VERIFIED_JWT_CLAIMS_ENV_KEY] = @jwt_verifier.verify!(token)
42
54
  true
43
55
  end
44
56
 
45
57
  def parse_events(rack_request)
46
58
  activity = read_json_body(rack_request)
47
59
 
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"]
60
+ conversation_id = activity.dig("conversation", "id")
61
+ claims = rack_request.env[VERIFIED_JWT_CLAIMS_ENV_KEY]
62
+ if trusted_service_url?(activity["serviceUrl"], claims)
63
+ register_service_url(conversation_id, activity["serviceUrl"]) if conversation_id
64
+ user_id = activity.dig("from", "id")
65
+ @state&.set(user_service_url_key(user_id), activity["serviceUrl"], ttl: USER_SERVICE_URL_TTL) if user_id
51
66
  end
52
67
 
53
68
  ActivityParser.parse(activity, bot_app_id: @app_id)
@@ -61,7 +76,7 @@ module ChatSDK
61
76
  service_url = service_url_for(channel_id)
62
77
 
63
78
  activity = build_activity(msg)
64
- activity["replyToId"] = thread_id if thread_id
79
+ activity["replyToId"] = activity_reply_id(channel_id, thread_id) if thread_id
65
80
 
66
81
  result = @client.send_activity(
67
82
  service_url: service_url,
@@ -73,14 +88,14 @@ module ChatSDK
73
88
  id: result["id"],
74
89
  text: msg.text || "",
75
90
  author: ChatSDK::Author.new(id: @app_id, name: "bot", platform: :teams, bot: true),
76
- thread_id: thread_id || result["id"],
91
+ thread_id: thread_id || channel_thread_id(channel_id, result["id"]),
77
92
  channel_id: channel_id,
78
93
  platform: :teams,
79
94
  raw: result
80
95
  )
81
96
  end
82
97
 
83
- def edit_message(channel_id:, message_id:, message:)
98
+ def edit_message(channel_id:, message_id:, message:, **_options)
84
99
  require_capability!(:edit_messages)
85
100
  msg = ChatSDK::PostableMessage.from(message)
86
101
  service_url = service_url_for(channel_id)
@@ -96,7 +111,7 @@ module ChatSDK
96
111
  )
97
112
  end
98
113
 
99
- def delete_message(channel_id:, message_id:)
114
+ def delete_message(channel_id:, message_id:, **_options)
100
115
  require_capability!(:delete_messages)
101
116
  service_url = service_url_for(channel_id)
102
117
 
@@ -126,7 +141,7 @@ module ChatSDK
126
141
  "contentUrl" => "data:application/octet-stream;base64,#{content}"
127
142
  }]
128
143
  }
129
- activity["replyToId"] = thread_id if thread_id
144
+ activity["replyToId"] = activity_reply_id(channel_id, thread_id) if thread_id
130
145
 
131
146
  @client.send_activity(
132
147
  service_url: service_url,
@@ -137,14 +152,7 @@ module ChatSDK
137
152
 
138
153
  def open_dm(user_id)
139
154
  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
155
+ service_url = @state&.get(user_service_url_key(user_id)) || @service_urls.values.first || @default_service_url
148
156
 
149
157
  payload = {
150
158
  "bot" => {"id" => @app_id},
@@ -154,7 +162,7 @@ module ChatSDK
154
162
 
155
163
  result = @client.create_conversation(service_url: service_url, payload: payload)
156
164
  conversation_id = result["id"]
157
- @service_urls[conversation_id] = service_url
165
+ register_service_url(conversation_id, service_url)
158
166
  conversation_id
159
167
  end
160
168
 
@@ -162,7 +170,7 @@ module ChatSDK
162
170
  super
163
171
  end
164
172
 
165
- def start_typing(channel_id:, thread_id: nil)
173
+ def start_typing(channel_id:, thread_id: nil, message_id: nil)
166
174
  service_url = service_url_for(channel_id)
167
175
  @client.send_typing(
168
176
  service_url: service_url,
@@ -191,7 +199,7 @@ module ChatSDK
191
199
  "(TEAMS_GRAPH_CLIENT_ID, TEAMS_GRAPH_CLIENT_SECRET, TEAMS_GRAPH_TENANT_ID)"
192
200
  end
193
201
 
194
- result = @graph_client.fetch_messages(chat_id: channel_id, limit: limit)
202
+ result = @graph_client.fetch_messages(chat_id: channel_id, limit: limit, cursor: cursor)
195
203
  messages = (result["value"] || []).map { |m| parse_graph_message(m, channel_id) }
196
204
  next_link = result["@odata.nextLink"]
197
205
  [messages, next_link]
@@ -200,10 +208,31 @@ module ChatSDK
200
208
  # Store a service URL for a conversation (useful when sending proactive messages)
201
209
  def register_service_url(conversation_id, service_url)
202
210
  @service_urls[conversation_id] = service_url
211
+ @state&.set(service_url_key(conversation_id), service_url)
212
+ end
213
+
214
+ def webhook_response(events, rack_request = nil)
215
+ invoke_activity = rack_request&.respond_to?(:body) && invoke_activity?(rack_request)
216
+ invoke_event = events.any? { |event| event.raw.is_a?(Hash) && event.raw["type"] == "invoke" }
217
+ return unless invoke_activity || invoke_event
218
+
219
+ body = {
220
+ "statusCode" => 200,
221
+ "type" => "application/vnd.microsoft.activity.message",
222
+ "value" => "OK"
223
+ }
224
+ [200, {"content-type" => "application/json"}, [JSON.generate(body)]]
203
225
  end
204
226
 
205
227
  private
206
228
 
229
+ def invoke_activity?(rack_request)
230
+ activity = read_json_body(rack_request)
231
+ activity.is_a?(Hash) && activity["type"] == "invoke"
232
+ rescue JSON::ParserError
233
+ false
234
+ end
235
+
207
236
  def build_activity(postable_message)
208
237
  activity = {"type" => "message"}
209
238
 
@@ -218,11 +247,20 @@ module ChatSDK
218
247
  activity["text"] = postable_message.text
219
248
  end
220
249
 
250
+ if postable_message.ai_generated
251
+ activity["entities"] = [{
252
+ "type" => "https://schema.org/Message",
253
+ "@type" => "Message",
254
+ "@context" => "https://schema.org",
255
+ "additionalType" => ["AIGeneratedContent"]
256
+ }]
257
+ end
258
+
221
259
  activity
222
260
  end
223
261
 
224
262
  def service_url_for(channel_id)
225
- url = @service_urls[channel_id]
263
+ url = @service_urls[channel_id] || @state&.get(service_url_key(channel_id)) || @default_service_url
226
264
  unless url
227
265
  raise ChatSDK::PlatformError.new(
228
266
  "No service URL for conversation #{channel_id}. Register it first or receive an activity.",
@@ -232,10 +270,65 @@ module ChatSDK
232
270
  url
233
271
  end
234
272
 
273
+ def service_url_key(conversation_id)
274
+ "chat_sdk:teams:service_url:#{conversation_id}"
275
+ end
276
+
277
+ def user_service_url_key(user_id)
278
+ "chat_sdk:teams:user_service_url:#{user_id}"
279
+ end
280
+
281
+ def trusted_service_url?(service_url, claims)
282
+ return false unless service_url.is_a?(String)
283
+
284
+ uri = URI.parse(service_url)
285
+ return false unless uri.scheme&.casecmp?("https") && uri.host
286
+
287
+ service_url_claim = claims.is_a?(Hash) ? claims["serviceurl"] : nil
288
+ service_url_claim.nil? ||
289
+ (service_url_claim.is_a?(String) &&
290
+ normalize_service_url(service_url) == normalize_service_url(service_url_claim))
291
+ rescue URI::InvalidURIError
292
+ false
293
+ end
294
+
295
+ def normalize_service_url(service_url)
296
+ uri = URI.parse(service_url)
297
+ return unless uri.scheme && uri.host
298
+
299
+ uri.scheme = uri.scheme.downcase
300
+ uri.host = uri.host.downcase
301
+ uri.path = uri.path.delete_suffix("/")
302
+ uri.to_s
303
+ rescue URI::InvalidURIError
304
+ nil
305
+ end
306
+
307
+ def channel_thread_id(channel_id, activity_id)
308
+ return activity_id unless channel_id.to_s.include?("@thread.tacv2") && activity_id
309
+
310
+ "#{channel_id};messageid=#{activity_id}"
311
+ end
312
+
313
+ def activity_reply_id(channel_id, thread_id)
314
+ prefix = "#{channel_id};messageid="
315
+ thread_id.start_with?(prefix) ? thread_id.delete_prefix(prefix) : thread_id
316
+ end
317
+
235
318
  def parse_graph_message(data, channel_id)
236
319
  from = data["from"] || {}
237
320
  user = from["user"] || from["application"] || {}
238
321
  is_bot = !from["application"].nil?
322
+ attachments = Array(data["attachments"]).filter_map do |attachment|
323
+ next unless attachment["contentType"] == "reference" && attachment["contentUrl"]
324
+
325
+ begin
326
+ resolved = @graph_client.resolve_reference_attachment(content_url: attachment["contentUrl"])
327
+ resolved.merge("filename" => resolved["filename"] || attachment["name"])
328
+ rescue ChatSDK::PlatformError, ArgumentError, Faraday::Error
329
+ nil
330
+ end
331
+ end
239
332
 
240
333
  ChatSDK::Message.new(
241
334
  id: data["id"],
@@ -244,11 +337,13 @@ module ChatSDK
244
337
  id: user["id"] || "unknown",
245
338
  name: user["displayName"] || "unknown",
246
339
  platform: :teams,
247
- bot: is_bot
340
+ bot: is_bot,
341
+ me: is_bot && user["id"] == @app_id
248
342
  ),
249
343
  thread_id: data["replyToId"] || data["id"],
250
344
  channel_id: channel_id,
251
345
  platform: :teams,
346
+ attachments: attachments,
252
347
  timestamp: data["createdDateTime"],
253
348
  raw: data
254
349
  )
@@ -6,7 +6,10 @@ module ChatSDK
6
6
  def render(node)
7
7
  case node.type
8
8
  when :card then render_card(node)
9
- else wrap_card([render_node(node)].compact)
9
+ else
10
+ rendered = render_node(node)
11
+ elements = rendered.is_a?(Array) ? rendered.flatten.compact : [rendered].compact
12
+ wrap_card(elements)
10
13
  end
11
14
  end
12
15
 
@@ -15,6 +18,13 @@ module ChatSDK
15
18
  def render_card(node)
16
19
  body = []
17
20
  pending_separator = false
21
+ if node.attributes[:image_url] && !node.attributes[:image_url].empty?
22
+ body << {
23
+ "type" => "Image",
24
+ "url" => node.attributes[:image_url],
25
+ "size" => "Stretch"
26
+ }
27
+ end
18
28
 
19
29
  node.children.each do |child|
20
30
  if child.type == :divider
@@ -25,16 +35,15 @@ module ChatSDK
25
35
  element = render_node(child)
26
36
  next unless element
27
37
 
38
+ elements = element.is_a?(Array) ? element.compact : [element]
39
+ next if elements.empty?
40
+
28
41
  if pending_separator
29
- element["separator"] = true
42
+ elements.first["separator"] = true
30
43
  pending_separator = false
31
44
  end
32
45
 
33
- if element.is_a?(Array)
34
- body.concat(element)
35
- else
36
- body << element
37
- end
46
+ body.concat(elements)
38
47
  end
39
48
 
40
49
  wrap_card(body, width: node.attributes[:width])
@@ -54,6 +63,7 @@ module ChatSDK
54
63
  def render_node(node)
55
64
  case node.type
56
65
  when :text then render_text(node)
66
+ when :link then render_link(node)
57
67
  when :divider then nil # handled in render_card
58
68
  when :image then render_image(node)
59
69
  when :fields then render_fields(node)
@@ -62,17 +72,30 @@ module ChatSDK
62
72
  when :button then render_action_submit(node)
63
73
  when :link_button then render_action_open_url(node)
64
74
  when :select then render_select(node)
75
+ when :radio_select then render_radio_select(node)
76
+ when :text_input then render_text_input(node)
65
77
  when :table then render_table(node)
66
78
  when :chart then render_chart(node)
67
79
  end
68
80
  end
69
81
 
70
82
  def render_text(node)
71
- {
83
+ text = {
72
84
  "type" => "TextBlock",
73
85
  "text" => node.attributes[:content],
74
86
  "wrap" => true
75
87
  }
88
+ text["weight"] = "Bolder" if node.attributes[:style] == :bold
89
+ text["isSubtle"] = true if node.attributes[:style] == :muted
90
+ text
91
+ end
92
+
93
+ def render_link(node)
94
+ {
95
+ "type" => "TextBlock",
96
+ "text" => "[#{node.attributes[:label]}](#{node.attributes[:url]})",
97
+ "wrap" => true
98
+ }
76
99
  end
77
100
 
78
101
  def render_image(node)
@@ -123,26 +146,44 @@ module ChatSDK
123
146
  end
124
147
 
125
148
  def render_actions(node)
126
- actions = node.children.filter_map { |child| render_action_element(child) }
127
- {
128
- "type" => "ActionSet",
129
- "actions" => actions
130
- }
131
- end
149
+ elements = []
150
+ actions = []
151
+ has_inputs = false
152
+ has_buttons = false
132
153
 
133
- def render_action_element(node)
134
- case node.type
135
- when :button then render_action_submit(node)
136
- when :link_button then render_action_open_url(node)
137
- when :select then render_select(node)
154
+ node.children.each do |child|
155
+ case child.type
156
+ when :button
157
+ has_buttons = true
158
+ actions << render_action_submit(child)
159
+ when :link_button
160
+ actions << render_action_open_url(child)
161
+ when :select
162
+ has_inputs = true
163
+ elements << render_select(child)
164
+ when :radio_select
165
+ has_inputs = true
166
+ elements << render_radio_select(child)
167
+ end
168
+ end
169
+
170
+ if has_inputs && !has_buttons
171
+ actions << {
172
+ "type" => "Action.Submit",
173
+ "title" => "Submit",
174
+ "data" => {"actionId" => AUTO_SUBMIT_ACTION_ID}
175
+ }
138
176
  end
177
+
178
+ elements << {"type" => "ActionSet", "actions" => actions} unless actions.empty?
179
+ elements
139
180
  end
140
181
 
141
182
  def render_action_submit(node)
142
183
  action = {
143
184
  "type" => "Action.Submit",
144
185
  "title" => node.attributes[:text],
145
- "data" => {"action" => node.attributes[:id]}
186
+ "data" => {"actionId" => node.attributes[:id]}
146
187
  }
147
188
  action["data"]["value"] = node.attributes[:value] if node.attributes[:value]
148
189
  action["tooltip"] = node.attributes[:tooltip] if node.attributes[:tooltip]
@@ -162,6 +203,11 @@ module ChatSDK
162
203
  }
163
204
  action["id"] = node.attributes[:id] if node.attributes[:id]
164
205
  action["tooltip"] = node.attributes[:tooltip] if node.attributes[:tooltip]
206
+ if node.attributes[:style] == :primary
207
+ action["style"] = "positive"
208
+ elsif node.attributes[:style] == :danger
209
+ action["style"] = "destructive"
210
+ end
165
211
  action
166
212
  end
167
213
 
@@ -177,26 +223,93 @@ module ChatSDK
177
223
  end
178
224
  }
179
225
  input["placeholder"] = node.attributes[:placeholder] if node.attributes[:placeholder]
226
+ if [node.attributes[:label], node.attributes[:initial_option], node.attributes[:optional]].any? { |value| !value.nil? }
227
+ input["label"] = node.attributes[:label] if node.attributes[:label]
228
+ input["style"] = "compact"
229
+ input["isRequired"] = !node.attributes[:optional]
230
+ end
231
+ input["value"] = node.attributes[:initial_option] unless node.attributes[:initial_option].nil?
232
+ input
233
+ end
234
+
235
+ def render_radio_select(node)
236
+ input = {
237
+ "type" => "Input.ChoiceSet",
238
+ "id" => node.attributes[:id],
239
+ "label" => node.attributes[:label],
240
+ "style" => "expanded",
241
+ "isRequired" => !node.attributes[:optional],
242
+ "choices" => node.children.map do |option|
243
+ {
244
+ "title" => option.attributes[:text],
245
+ "value" => option.attributes[:value]
246
+ }
247
+ end
248
+ }
249
+ input["value"] = node.attributes[:initial_option] unless node.attributes[:initial_option].nil?
250
+ input
251
+ end
252
+
253
+ def render_text_input(node)
254
+ input = {
255
+ "type" => "Input.Text",
256
+ "id" => node.attributes[:id],
257
+ "label" => node.attributes[:label],
258
+ "isMultiline" => node.attributes[:multiline],
259
+ "isRequired" => !node.attributes[:optional]
260
+ }
261
+ input["placeholder"] = node.attributes[:placeholder] if node.attributes[:placeholder]
262
+ input["value"] = node.attributes[:value] unless node.attributes[:value].nil?
263
+ input["maxLength"] = node.attributes[:max_length] unless node.attributes[:max_length].nil?
180
264
  input
181
265
  end
182
266
 
183
267
  def render_table(node)
184
- rows = [Array(node.attributes[:headers]), *Array(node.attributes[:rows])]
268
+ headers = Array(node.attributes[:headers])
269
+ data_rows = Array(node.attributes[:rows])
270
+ column_count = data_rows.reduce(headers.length) { |count, row| [count, Array(row).length].max }
271
+ return if column_count.zero?
272
+
273
+ alignment = {left: "Left", center: "Center", right: "Right"}
274
+ vertical_alignment = {top: "Top", center: "Center", bottom: "Bottom"}
275
+ widths = Array(node.attributes[:widths])
276
+ aligns = Array(node.attributes[:align])
277
+ rows = data_rows.map { |row| Array(row) }
278
+ rows.unshift(headers) unless headers.empty?
279
+
185
280
  {
186
- "type" => "Container",
187
- "items" => rows.each_with_index.map do |row, index|
281
+ "type" => "Table",
282
+ "firstRowAsHeaders" => !headers.empty?,
283
+ "showGridLines" => node.attributes.fetch(:grid_lines, true),
284
+ "columns" => Array.new(column_count) do |index|
285
+ width = widths[index]
286
+ column_width = width
287
+ column_width = 1 unless column_width.is_a?(Integer) && column_width.positive?
288
+ column = {"width" => column_width}
289
+ column_alignment = alignment[aligns[index]&.to_sym]
290
+ column["horizontalCellContentAlignment"] = column_alignment if column_alignment
291
+ column
292
+ end,
293
+ "rows" => rows.each_with_index.map do |row, index|
188
294
  {
189
- "type" => "ColumnSet",
190
- "columns" => Array(row).map do |cell|
295
+ "type" => "TableRow",
296
+ "cells" => Array.new(column_count) do |cell_index|
191
297
  {
192
- "type" => "Column",
193
- "width" => "stretch",
194
- "items" => [{"type" => "TextBlock", "text" => cell.to_s, "weight" => ("bolder" if index.zero?), "wrap" => true}.compact]
298
+ "type" => "TableCell",
299
+ "items" => [{
300
+ "type" => "TextBlock",
301
+ "text" => Array(row)[cell_index].to_s,
302
+ "weight" => ("Bolder" if index.zero? && !headers.empty?),
303
+ "wrap" => true
304
+ }.compact]
195
305
  }
196
306
  end
197
307
  }
198
- end
308
+ end,
309
+ "gridStyle" => node.attributes[:grid_style],
310
+ "verticalCellContentAlignment" => vertical_alignment[node.attributes[:vertical_align]&.to_sym]
199
311
  }
312
+ .compact
200
313
  end
201
314
 
202
315
  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
@@ -8,6 +8,8 @@ require "zeitwerk"
8
8
 
9
9
  module ChatSDK
10
10
  module Teams
11
+ AUTO_SUBMIT_ACTION_ID = "__auto_submit"
12
+
11
13
  class << self
12
14
  def loader
13
15
  @loader ||= begin
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.3.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.3'
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.3'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: faraday
28
28
  requirement: !ruby/object:Gem::Requirement