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 +4 -4
- data/lib/chat_sdk/teams/activity_parser.rb +138 -12
- data/lib/chat_sdk/teams/adapter.rb +118 -23
- data/lib/chat_sdk/teams/adaptive_card_renderer.rb +142 -29
- data/lib/chat_sdk/teams/graph_client.rb +74 -2
- data/lib/chat_sdk/teams.rb +2 -0
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ebf993ca61c1fe27ad125af30af36361e87922b00ab33b5e80ab7f2cfc280eb3
|
|
4
|
+
data.tar.gz: 84470e0eaf8edc9eaeafd70d5e541ed0553c1ede14a1ee627f735d9f31aa8081
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
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
|
|
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:
|
|
132
|
-
name: from["name"] ||
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42
|
+
elements.first["separator"] = true
|
|
30
43
|
pending_separator = false
|
|
31
44
|
end
|
|
32
45
|
|
|
33
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
end
|
|
149
|
+
elements = []
|
|
150
|
+
actions = []
|
|
151
|
+
has_inputs = false
|
|
152
|
+
has_buttons = false
|
|
132
153
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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" => {"
|
|
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
|
-
|
|
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" => "
|
|
187
|
-
"
|
|
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" => "
|
|
190
|
-
"
|
|
295
|
+
"type" => "TableRow",
|
|
296
|
+
"cells" => Array.new(column_count) do |cell_index|
|
|
191
297
|
{
|
|
192
|
-
"type" => "
|
|
193
|
-
"
|
|
194
|
-
|
|
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
|
data/lib/chat_sdk/teams.rb
CHANGED
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.
|
|
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: '
|
|
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: '
|
|
25
|
+
version: '1.3'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
27
27
|
name: faraday
|
|
28
28
|
requirement: !ruby/object:Gem::Requirement
|