chat_sdk-teams 0.1.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 +7 -0
- data/lib/chat_sdk/teams/activity_parser.rb +150 -0
- data/lib/chat_sdk/teams/adapter.rb +240 -0
- data/lib/chat_sdk/teams/adaptive_card_renderer.rb +176 -0
- data/lib/chat_sdk/teams/bot_framework_client.rb +108 -0
- data/lib/chat_sdk/teams/jwt_verifier.rb +90 -0
- data/lib/chat_sdk/teams.rb +26 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 405f17761300fe99e9b9546f42d8d418ab56aaa5e26f62fbf719113bf182f40e
|
|
4
|
+
data.tar.gz: 041c7673256c9d330effed6cc09c75dbfaf88641624b738dd7ea86522820a86d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a4656f0911cb47d3ac7606f224386e1daa5f51083f69515983236b9fb61163d158425431e14e45ba8cdbc78a8ef89dfed75cc2a195aca09638c02127c7b52d37
|
|
7
|
+
data.tar.gz: 64e7090b9f5f052bdde73fcb2acc58b8aec6160b30e26c46ad282e7ce9f03fe8e6d64c62e00d740c111b1ca7628424b81d68804e2a285abca43ada713bc3699f
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Teams
|
|
5
|
+
class ActivityParser
|
|
6
|
+
class << self
|
|
7
|
+
def parse(activity, bot_app_id: nil)
|
|
8
|
+
return [] unless activity.is_a?(Hash)
|
|
9
|
+
|
|
10
|
+
case activity["type"]
|
|
11
|
+
when "message"
|
|
12
|
+
parse_message(activity, bot_app_id)
|
|
13
|
+
when "messageReaction"
|
|
14
|
+
parse_message_reaction(activity)
|
|
15
|
+
when "invoke"
|
|
16
|
+
parse_invoke(activity)
|
|
17
|
+
else
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def parse_message(activity, bot_app_id)
|
|
25
|
+
return [] if activity.dig("from", "id") == bot_app_id
|
|
26
|
+
|
|
27
|
+
author = build_author(activity)
|
|
28
|
+
conversation_id = activity.dig("conversation", "id")
|
|
29
|
+
thread_id = activity.dig("conversation", "id")
|
|
30
|
+
|
|
31
|
+
message = ChatSDK::Message.new(
|
|
32
|
+
id: activity["id"],
|
|
33
|
+
text: activity["text"] || "",
|
|
34
|
+
author: author,
|
|
35
|
+
thread_id: thread_id,
|
|
36
|
+
channel_id: conversation_id,
|
|
37
|
+
platform: :teams,
|
|
38
|
+
raw: activity
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if bot_mentioned?(activity, bot_app_id)
|
|
42
|
+
[ChatSDK::Events::Mention.new(
|
|
43
|
+
message: message,
|
|
44
|
+
thread_id: thread_id,
|
|
45
|
+
channel_id: conversation_id,
|
|
46
|
+
platform: :teams,
|
|
47
|
+
adapter_name: :teams,
|
|
48
|
+
raw: activity
|
|
49
|
+
)]
|
|
50
|
+
elsif activity.dig("conversation", "conversationType") == "personal"
|
|
51
|
+
[ChatSDK::Events::DirectMessage.new(
|
|
52
|
+
message: message,
|
|
53
|
+
thread_id: thread_id,
|
|
54
|
+
channel_id: conversation_id,
|
|
55
|
+
platform: :teams,
|
|
56
|
+
adapter_name: :teams,
|
|
57
|
+
raw: activity
|
|
58
|
+
)]
|
|
59
|
+
else
|
|
60
|
+
[ChatSDK::Events::SubscribedMessage.new(
|
|
61
|
+
message: message,
|
|
62
|
+
thread_id: thread_id,
|
|
63
|
+
channel_id: conversation_id,
|
|
64
|
+
platform: :teams,
|
|
65
|
+
adapter_name: :teams,
|
|
66
|
+
raw: activity
|
|
67
|
+
)]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def parse_message_reaction(activity)
|
|
72
|
+
conversation_id = activity.dig("conversation", "id")
|
|
73
|
+
events = []
|
|
74
|
+
|
|
75
|
+
(activity["reactionsAdded"] || []).each do |reaction|
|
|
76
|
+
events << ChatSDK::Events::Reaction.new(
|
|
77
|
+
emoji: reaction["type"],
|
|
78
|
+
user_id: activity.dig("from", "id"),
|
|
79
|
+
message_id: activity["replyToId"],
|
|
80
|
+
thread_id: conversation_id,
|
|
81
|
+
channel_id: conversation_id,
|
|
82
|
+
added: true,
|
|
83
|
+
platform: :teams,
|
|
84
|
+
adapter_name: :teams,
|
|
85
|
+
raw: activity
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
(activity["reactionsRemoved"] || []).each do |reaction|
|
|
90
|
+
events << ChatSDK::Events::Reaction.new(
|
|
91
|
+
emoji: reaction["type"],
|
|
92
|
+
user_id: activity.dig("from", "id"),
|
|
93
|
+
message_id: activity["replyToId"],
|
|
94
|
+
thread_id: conversation_id,
|
|
95
|
+
channel_id: conversation_id,
|
|
96
|
+
added: false,
|
|
97
|
+
platform: :teams,
|
|
98
|
+
adapter_name: :teams,
|
|
99
|
+
raw: activity
|
|
100
|
+
)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
events
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def parse_invoke(activity)
|
|
107
|
+
return [] unless activity.dig("value")
|
|
108
|
+
|
|
109
|
+
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"]
|
|
114
|
+
value = value.is_a?(Hash) ? JSON.generate(value) : value.to_s
|
|
115
|
+
|
|
116
|
+
[ChatSDK::Events::Action.new(
|
|
117
|
+
action_id: action_id,
|
|
118
|
+
value: value,
|
|
119
|
+
user: user,
|
|
120
|
+
thread_id: conversation_id,
|
|
121
|
+
channel_id: conversation_id,
|
|
122
|
+
platform: :teams,
|
|
123
|
+
adapter_name: :teams,
|
|
124
|
+
raw: activity
|
|
125
|
+
)]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_author(activity)
|
|
129
|
+
from = activity["from"] || {}
|
|
130
|
+
ChatSDK::Author.new(
|
|
131
|
+
id: from["id"] || "unknown",
|
|
132
|
+
name: from["name"] || from["id"] || "unknown",
|
|
133
|
+
platform: :teams,
|
|
134
|
+
bot: from["role"] == "bot"
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def bot_mentioned?(activity, bot_app_id)
|
|
139
|
+
return false unless bot_app_id
|
|
140
|
+
|
|
141
|
+
entities = activity["entities"] || []
|
|
142
|
+
entities.any? do |entity|
|
|
143
|
+
entity["type"] == "mention" &&
|
|
144
|
+
entity.dig("mentioned", "id") == bot_app_id
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Teams
|
|
5
|
+
class Adapter < ChatSDK::Adapter::Base
|
|
6
|
+
capabilities :edit_messages, :delete_messages, :threads, :direct_messages,
|
|
7
|
+
:message_history, :reactions, :file_uploads, :streaming_edit
|
|
8
|
+
|
|
9
|
+
attr_reader :client
|
|
10
|
+
|
|
11
|
+
def initialize(app_id: nil, app_password: nil, tenant_id: nil)
|
|
12
|
+
@app_id = app_id || ENV["TEAMS_APP_ID"]
|
|
13
|
+
@app_password = app_password || ENV["TEAMS_APP_PASSWORD"]
|
|
14
|
+
@tenant_id = tenant_id || ENV["TEAMS_TENANT_ID"]
|
|
15
|
+
|
|
16
|
+
raise ChatSDK::ConfigurationError, "Teams app_id required" unless @app_id
|
|
17
|
+
raise ChatSDK::ConfigurationError, "Teams app_password required" unless @app_password
|
|
18
|
+
|
|
19
|
+
@client = BotFrameworkClient.new(app_id: @app_id, app_password: @app_password)
|
|
20
|
+
@jwt_verifier = JwtVerifier.new(app_id: @app_id)
|
|
21
|
+
@renderer = AdaptiveCardRenderer.new
|
|
22
|
+
@service_urls = {}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def name
|
|
26
|
+
:teams
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Inbound
|
|
30
|
+
def verify_request!(rack_request)
|
|
31
|
+
auth_header = rack_request.env["HTTP_AUTHORIZATION"]
|
|
32
|
+
raise ChatSDK::SignatureVerificationError, "Missing authorization header" unless auth_header
|
|
33
|
+
|
|
34
|
+
token = auth_header.sub(/^Bearer\s+/i, "")
|
|
35
|
+
@jwt_verifier.verify!(token)
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def parse_events(rack_request)
|
|
40
|
+
body = rack_request.body.read
|
|
41
|
+
rack_request.body.rewind
|
|
42
|
+
|
|
43
|
+
activity = JSON.parse(body)
|
|
44
|
+
|
|
45
|
+
# Cache the service URL for this conversation
|
|
46
|
+
if activity["serviceUrl"] && activity.dig("conversation", "id")
|
|
47
|
+
@service_urls[activity.dig("conversation", "id")] = activity["serviceUrl"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
ActivityParser.parse(activity, bot_app_id: @app_id)
|
|
51
|
+
rescue JSON::ParserError
|
|
52
|
+
[]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Outbound
|
|
56
|
+
def post_message(channel_id:, message:, thread_id: nil)
|
|
57
|
+
msg = ChatSDK::PostableMessage.from(message)
|
|
58
|
+
service_url = service_url_for(channel_id)
|
|
59
|
+
|
|
60
|
+
activity = build_activity(msg)
|
|
61
|
+
activity["replyToId"] = thread_id if thread_id
|
|
62
|
+
|
|
63
|
+
result = @client.send_activity(
|
|
64
|
+
service_url: service_url,
|
|
65
|
+
conversation_id: channel_id,
|
|
66
|
+
activity: activity
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
ChatSDK::Message.new(
|
|
70
|
+
id: result["id"],
|
|
71
|
+
text: msg.text || "",
|
|
72
|
+
author: ChatSDK::Author.new(id: @app_id, name: "bot", platform: :teams, bot: true),
|
|
73
|
+
thread_id: thread_id || result["id"],
|
|
74
|
+
channel_id: channel_id,
|
|
75
|
+
platform: :teams,
|
|
76
|
+
raw: result
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def edit_message(channel_id:, message_id:, message:)
|
|
81
|
+
require_capability!(:edit_messages)
|
|
82
|
+
msg = ChatSDK::PostableMessage.from(message)
|
|
83
|
+
service_url = service_url_for(channel_id)
|
|
84
|
+
|
|
85
|
+
activity = build_activity(msg)
|
|
86
|
+
activity["id"] = message_id
|
|
87
|
+
|
|
88
|
+
@client.update_activity(
|
|
89
|
+
service_url: service_url,
|
|
90
|
+
conversation_id: channel_id,
|
|
91
|
+
activity_id: message_id,
|
|
92
|
+
activity: activity
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def delete_message(channel_id:, message_id:)
|
|
97
|
+
require_capability!(:delete_messages)
|
|
98
|
+
service_url = service_url_for(channel_id)
|
|
99
|
+
|
|
100
|
+
@client.delete_activity(
|
|
101
|
+
service_url: service_url,
|
|
102
|
+
conversation_id: channel_id,
|
|
103
|
+
activity_id: message_id
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def post_ephemeral(channel_id:, user_id:, message:, thread_id: nil)
|
|
108
|
+
super # raises NotSupportedError
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def upload_file(channel_id:, io:, filename:, thread_id: nil, comment: nil)
|
|
112
|
+
require_capability!(:file_uploads)
|
|
113
|
+
# Teams file uploads are done via attachments in activities
|
|
114
|
+
service_url = service_url_for(channel_id)
|
|
115
|
+
content = Base64.strict_encode64(io.read)
|
|
116
|
+
|
|
117
|
+
activity = {
|
|
118
|
+
"type" => "message",
|
|
119
|
+
"text" => comment || "",
|
|
120
|
+
"attachments" => [{
|
|
121
|
+
"contentType" => "application/octet-stream",
|
|
122
|
+
"name" => filename,
|
|
123
|
+
"contentUrl" => "data:application/octet-stream;base64,#{content}"
|
|
124
|
+
}]
|
|
125
|
+
}
|
|
126
|
+
activity["replyToId"] = thread_id if thread_id
|
|
127
|
+
|
|
128
|
+
@client.send_activity(
|
|
129
|
+
service_url: service_url,
|
|
130
|
+
conversation_id: channel_id,
|
|
131
|
+
activity: activity
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def add_reaction(channel_id:, message_id:, emoji:)
|
|
136
|
+
require_capability!(:reactions)
|
|
137
|
+
# Teams Bot Framework API does not support adding reactions programmatically
|
|
138
|
+
# The capability is declared because inbound reactions are parsed
|
|
139
|
+
raise ChatSDK::PlatformError.new(
|
|
140
|
+
"Teams Bot Framework API does not support adding reactions programmatically",
|
|
141
|
+
adapter_name: :teams
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def remove_reaction(channel_id:, message_id:, emoji:)
|
|
146
|
+
require_capability!(:reactions)
|
|
147
|
+
raise ChatSDK::PlatformError.new(
|
|
148
|
+
"Teams Bot Framework API does not support removing reactions programmatically",
|
|
149
|
+
adapter_name: :teams
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def open_dm(user_id)
|
|
154
|
+
require_capability!(:direct_messages)
|
|
155
|
+
# To open a DM, we need a service URL. Use the first cached one.
|
|
156
|
+
service_url = @service_urls.values.first
|
|
157
|
+
unless service_url
|
|
158
|
+
raise ChatSDK::PlatformError.new(
|
|
159
|
+
"No service URL available. A Teams activity must be received first.",
|
|
160
|
+
adapter_name: :teams
|
|
161
|
+
)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
payload = {
|
|
165
|
+
"bot" => {"id" => @app_id},
|
|
166
|
+
"members" => [{"id" => user_id}],
|
|
167
|
+
"isGroup" => false
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
result = @client.create_conversation(service_url: service_url, payload: payload)
|
|
171
|
+
conversation_id = result["id"]
|
|
172
|
+
@service_urls[conversation_id] = service_url
|
|
173
|
+
conversation_id
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def fetch_messages(channel_id:, thread_id: nil, cursor: nil, limit: 50)
|
|
177
|
+
require_capability!(:message_history)
|
|
178
|
+
# Teams Bot Framework doesn't provide a message history API
|
|
179
|
+
# Return empty to satisfy the interface
|
|
180
|
+
[[], nil]
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def open_modal(trigger_id:, modal:)
|
|
184
|
+
super # raises NotSupportedError
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def start_typing(channel_id:, thread_id: nil)
|
|
188
|
+
super # raises NotSupportedError
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def mention(user_id)
|
|
192
|
+
"<at>#{user_id}</at>"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def render(postable_message)
|
|
196
|
+
msg = ChatSDK::PostableMessage.from(postable_message)
|
|
197
|
+
if msg.card?
|
|
198
|
+
@renderer.render(msg.card)
|
|
199
|
+
else
|
|
200
|
+
msg.text
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Store a service URL for a conversation (useful when sending proactive messages)
|
|
205
|
+
def register_service_url(conversation_id, service_url)
|
|
206
|
+
@service_urls[conversation_id] = service_url
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
private
|
|
210
|
+
|
|
211
|
+
def build_activity(postable_message)
|
|
212
|
+
activity = {"type" => "message"}
|
|
213
|
+
|
|
214
|
+
if postable_message.card?
|
|
215
|
+
card_json = @renderer.render(postable_message.card)
|
|
216
|
+
activity["attachments"] = [{
|
|
217
|
+
"contentType" => "application/vnd.microsoft.card.adaptive",
|
|
218
|
+
"content" => card_json
|
|
219
|
+
}]
|
|
220
|
+
activity["text"] = postable_message.text || postable_message.card.fallback_text
|
|
221
|
+
else
|
|
222
|
+
activity["text"] = postable_message.text
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
activity
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def service_url_for(channel_id)
|
|
229
|
+
url = @service_urls[channel_id]
|
|
230
|
+
unless url
|
|
231
|
+
raise ChatSDK::PlatformError.new(
|
|
232
|
+
"No service URL for conversation #{channel_id}. Register it first or receive an activity.",
|
|
233
|
+
adapter_name: :teams
|
|
234
|
+
)
|
|
235
|
+
end
|
|
236
|
+
url
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Teams
|
|
5
|
+
class AdaptiveCardRenderer
|
|
6
|
+
def render(node)
|
|
7
|
+
case node.type
|
|
8
|
+
when :card then render_card(node)
|
|
9
|
+
else wrap_card([render_node(node)].compact)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def render_card(node)
|
|
16
|
+
body = []
|
|
17
|
+
pending_separator = false
|
|
18
|
+
|
|
19
|
+
node.children.each do |child|
|
|
20
|
+
if child.type == :divider
|
|
21
|
+
pending_separator = true
|
|
22
|
+
next
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
element = render_node(child)
|
|
26
|
+
next unless element
|
|
27
|
+
|
|
28
|
+
if pending_separator
|
|
29
|
+
element["separator"] = true
|
|
30
|
+
pending_separator = false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
if element.is_a?(Array)
|
|
34
|
+
body.concat(element)
|
|
35
|
+
else
|
|
36
|
+
body << element
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
wrap_card(body)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def wrap_card(body)
|
|
44
|
+
{
|
|
45
|
+
"type" => "AdaptiveCard",
|
|
46
|
+
"$schema" => "http://adaptivecards.io/schemas/adaptive-card.json",
|
|
47
|
+
"version" => "1.4",
|
|
48
|
+
"body" => body
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def render_node(node)
|
|
53
|
+
case node.type
|
|
54
|
+
when :text then render_text(node)
|
|
55
|
+
when :divider then nil # handled in render_card
|
|
56
|
+
when :image then render_image(node)
|
|
57
|
+
when :fields then render_fields(node)
|
|
58
|
+
when :section then render_section(node)
|
|
59
|
+
when :actions then render_actions(node)
|
|
60
|
+
when :button then render_action_submit(node)
|
|
61
|
+
when :link_button then render_action_open_url(node)
|
|
62
|
+
when :select then render_select(node)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def render_text(node)
|
|
67
|
+
{
|
|
68
|
+
"type" => "TextBlock",
|
|
69
|
+
"text" => node.attributes[:content],
|
|
70
|
+
"wrap" => true
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_image(node)
|
|
75
|
+
block = {
|
|
76
|
+
"type" => "Image",
|
|
77
|
+
"url" => node.attributes[:url]
|
|
78
|
+
}
|
|
79
|
+
block["altText"] = node.attributes[:alt] if node.attributes[:alt]
|
|
80
|
+
block
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def render_fields(node)
|
|
84
|
+
{
|
|
85
|
+
"type" => "FactSet",
|
|
86
|
+
"facts" => node.children.map do |field|
|
|
87
|
+
{
|
|
88
|
+
"title" => field.attributes[:label],
|
|
89
|
+
"value" => field.attributes[:value]
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def render_section(node)
|
|
96
|
+
elements = []
|
|
97
|
+
if node.attributes[:title]
|
|
98
|
+
elements << {
|
|
99
|
+
"type" => "TextBlock",
|
|
100
|
+
"text" => node.attributes[:title],
|
|
101
|
+
"weight" => "bolder",
|
|
102
|
+
"size" => "medium",
|
|
103
|
+
"wrap" => true
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
node.children.each do |child|
|
|
107
|
+
el = render_node(child)
|
|
108
|
+
next unless el
|
|
109
|
+
if el.is_a?(Array)
|
|
110
|
+
elements.concat(el)
|
|
111
|
+
else
|
|
112
|
+
elements << el
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
{
|
|
116
|
+
"type" => "Container",
|
|
117
|
+
"items" => elements
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def render_actions(node)
|
|
122
|
+
actions = node.children.filter_map { |child| render_action_element(child) }
|
|
123
|
+
{
|
|
124
|
+
"type" => "ActionSet",
|
|
125
|
+
"actions" => actions
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_action_element(node)
|
|
130
|
+
case node.type
|
|
131
|
+
when :button then render_action_submit(node)
|
|
132
|
+
when :link_button then render_action_open_url(node)
|
|
133
|
+
when :select then render_select(node)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render_action_submit(node)
|
|
138
|
+
action = {
|
|
139
|
+
"type" => "Action.Submit",
|
|
140
|
+
"title" => node.attributes[:text],
|
|
141
|
+
"data" => {"action" => node.attributes[:id]}
|
|
142
|
+
}
|
|
143
|
+
action["data"]["value"] = node.attributes[:value] if node.attributes[:value]
|
|
144
|
+
if node.attributes[:style] == :primary
|
|
145
|
+
action["style"] = "positive"
|
|
146
|
+
elsif node.attributes[:style] == :danger
|
|
147
|
+
action["style"] = "destructive"
|
|
148
|
+
end
|
|
149
|
+
action
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def render_action_open_url(node)
|
|
153
|
+
{
|
|
154
|
+
"type" => "Action.OpenUrl",
|
|
155
|
+
"title" => node.attributes[:text],
|
|
156
|
+
"url" => node.attributes[:url]
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_select(node)
|
|
161
|
+
input = {
|
|
162
|
+
"type" => "Input.ChoiceSet",
|
|
163
|
+
"id" => node.attributes[:id],
|
|
164
|
+
"choices" => node.children.map do |opt|
|
|
165
|
+
{
|
|
166
|
+
"title" => opt.attributes[:text],
|
|
167
|
+
"value" => opt.attributes[:value]
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
}
|
|
171
|
+
input["placeholder"] = node.attributes[:placeholder] if node.attributes[:placeholder]
|
|
172
|
+
input
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Teams
|
|
5
|
+
class BotFrameworkClient
|
|
6
|
+
TOKEN_URL = "https://login.microsoftonline.com/botframework.com/oauth2/v2.0/token"
|
|
7
|
+
SCOPE = "https://api.botframework.com/.default"
|
|
8
|
+
|
|
9
|
+
attr_reader :app_id, :app_password
|
|
10
|
+
|
|
11
|
+
def initialize(app_id:, app_password:)
|
|
12
|
+
@app_id = app_id
|
|
13
|
+
@app_password = app_password
|
|
14
|
+
@access_token = nil
|
|
15
|
+
@token_expires_at = nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def send_activity(service_url:, conversation_id:, activity:)
|
|
19
|
+
url = "#{service_url.chomp("/")}/v3/conversations/#{conversation_id}/activities"
|
|
20
|
+
authorized_request(:post, url, activity)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def reply_to_activity(service_url:, conversation_id:, activity_id:, activity:)
|
|
24
|
+
url = "#{service_url.chomp("/")}/v3/conversations/#{conversation_id}/activities/#{activity_id}"
|
|
25
|
+
authorized_request(:put, url, activity)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update_activity(service_url:, conversation_id:, activity_id:, activity:)
|
|
29
|
+
url = "#{service_url.chomp("/")}/v3/conversations/#{conversation_id}/activities/#{activity_id}"
|
|
30
|
+
authorized_request(:put, url, activity)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def delete_activity(service_url:, conversation_id:, activity_id:)
|
|
34
|
+
url = "#{service_url.chomp("/")}/v3/conversations/#{conversation_id}/activities/#{activity_id}"
|
|
35
|
+
authorized_request(:delete, url)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_conversation(service_url:, payload:)
|
|
39
|
+
url = "#{service_url.chomp("/")}/v3/conversations"
|
|
40
|
+
authorized_request(:post, url, payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def get_conversation_members(service_url:, conversation_id:)
|
|
44
|
+
url = "#{service_url.chomp("/")}/v3/conversations/#{conversation_id}/members"
|
|
45
|
+
authorized_request(:get, url)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fetch_token
|
|
49
|
+
return @access_token if @access_token && @token_expires_at && Time.now < @token_expires_at
|
|
50
|
+
|
|
51
|
+
conn = Faraday.new do |f|
|
|
52
|
+
f.request :url_encoded
|
|
53
|
+
f.adapter :net_http
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
response = conn.post(TOKEN_URL, {
|
|
57
|
+
grant_type: "client_credentials",
|
|
58
|
+
client_id: @app_id,
|
|
59
|
+
client_secret: @app_password,
|
|
60
|
+
scope: SCOPE
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
unless response.success?
|
|
64
|
+
raise ChatSDK::PlatformError.new(
|
|
65
|
+
"Failed to acquire Bot Framework token: #{response.status}",
|
|
66
|
+
status: response.status,
|
|
67
|
+
body: response.body,
|
|
68
|
+
adapter_name: :teams
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
data = JSON.parse(response.body)
|
|
73
|
+
@access_token = data["access_token"]
|
|
74
|
+
@token_expires_at = Time.now + (data["expires_in"].to_i - 60)
|
|
75
|
+
@access_token
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def authorized_request(method, url, body = nil)
|
|
81
|
+
token = fetch_token
|
|
82
|
+
|
|
83
|
+
conn = Faraday.new do |f|
|
|
84
|
+
f.request :json if body
|
|
85
|
+
f.response :json
|
|
86
|
+
f.adapter :net_http
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
response = conn.public_send(method, url) do |req|
|
|
90
|
+
req.headers["Authorization"] = "Bearer #{token}"
|
|
91
|
+
req.headers["Content-Type"] = "application/json" if body
|
|
92
|
+
req.body = JSON.generate(body) if body && !method.to_s.start_with?("get")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
unless response.success?
|
|
96
|
+
raise ChatSDK::PlatformError.new(
|
|
97
|
+
"Bot Framework API error: #{response.status}",
|
|
98
|
+
status: response.status,
|
|
99
|
+
body: response.body,
|
|
100
|
+
adapter_name: :teams
|
|
101
|
+
)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
response.body
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ChatSDK
|
|
4
|
+
module Teams
|
|
5
|
+
class JwtVerifier
|
|
6
|
+
OPENID_CONFIG_URL = "https://login.botframework.com/v1/.well-known/openidconfiguration"
|
|
7
|
+
JWKS_CACHE_TTL = 3600 # 1 hour
|
|
8
|
+
|
|
9
|
+
def initialize(app_id:)
|
|
10
|
+
@app_id = app_id
|
|
11
|
+
@jwks_keys = nil
|
|
12
|
+
@jwks_fetched_at = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def verify!(token)
|
|
16
|
+
raise ChatSDK::SignatureVerificationError, "Missing authorization token" if token.nil? || token.empty?
|
|
17
|
+
|
|
18
|
+
jwks = fetch_jwks
|
|
19
|
+
header = JWT.decode(token, nil, false).last
|
|
20
|
+
kid = header["kid"]
|
|
21
|
+
|
|
22
|
+
key = jwks.find { |k| k[:kid] == kid }
|
|
23
|
+
raise ChatSDK::SignatureVerificationError, "Unknown signing key" unless key
|
|
24
|
+
|
|
25
|
+
decoded = JWT.decode(
|
|
26
|
+
token,
|
|
27
|
+
key[:key],
|
|
28
|
+
true,
|
|
29
|
+
algorithm: header["alg"] || "RS256",
|
|
30
|
+
aud: @app_id,
|
|
31
|
+
verify_aud: true
|
|
32
|
+
)
|
|
33
|
+
decoded.first
|
|
34
|
+
rescue JWT::DecodeError, JWT::VerificationError, JWT::ExpiredSignature, JWT::InvalidAudError => e
|
|
35
|
+
raise ChatSDK::SignatureVerificationError, "JWT verification failed: #{e.message}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def fetch_jwks
|
|
41
|
+
if @jwks_keys && @jwks_fetched_at && (Time.now - @jwks_fetched_at) < JWKS_CACHE_TTL
|
|
42
|
+
return @jwks_keys
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
config_response = Faraday.get(OPENID_CONFIG_URL)
|
|
46
|
+
config = JSON.parse(config_response.body)
|
|
47
|
+
jwks_uri = config["jwks_uri"]
|
|
48
|
+
|
|
49
|
+
jwks_response = Faraday.get(jwks_uri)
|
|
50
|
+
jwks_data = JSON.parse(jwks_response.body)
|
|
51
|
+
|
|
52
|
+
@jwks_keys = jwks_data["keys"].map do |key_data|
|
|
53
|
+
next unless key_data["kty"] == "RSA"
|
|
54
|
+
|
|
55
|
+
rsa_key = build_rsa_key(key_data)
|
|
56
|
+
{kid: key_data["kid"], key: rsa_key}
|
|
57
|
+
end.compact
|
|
58
|
+
|
|
59
|
+
@jwks_fetched_at = Time.now
|
|
60
|
+
@jwks_keys
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def build_rsa_key(key_data)
|
|
64
|
+
n = base64url_to_bn(key_data["n"])
|
|
65
|
+
e = base64url_to_bn(key_data["e"])
|
|
66
|
+
|
|
67
|
+
# Build RSA public key via DER encoding (compatible with OpenSSL 3.x)
|
|
68
|
+
rsa_public_key = OpenSSL::ASN1::Sequence.new([
|
|
69
|
+
OpenSSL::ASN1::Integer.new(n),
|
|
70
|
+
OpenSSL::ASN1::Integer.new(e)
|
|
71
|
+
])
|
|
72
|
+
|
|
73
|
+
der = OpenSSL::ASN1::Sequence.new([
|
|
74
|
+
OpenSSL::ASN1::Sequence.new([
|
|
75
|
+
OpenSSL::ASN1::ObjectId.new("rsaEncryption"),
|
|
76
|
+
OpenSSL::ASN1::Null.new(nil)
|
|
77
|
+
]),
|
|
78
|
+
OpenSSL::ASN1::BitString.new(rsa_public_key.to_der)
|
|
79
|
+
]).to_der
|
|
80
|
+
|
|
81
|
+
OpenSSL::PKey::RSA.new(der)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def base64url_to_bn(str)
|
|
85
|
+
decoded = Base64.urlsafe_decode64(str)
|
|
86
|
+
OpenSSL::BN.new(decoded, 2)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "chat_sdk"
|
|
4
|
+
require "faraday"
|
|
5
|
+
require "faraday/net_http"
|
|
6
|
+
require "jwt"
|
|
7
|
+
require "zeitwerk"
|
|
8
|
+
|
|
9
|
+
module ChatSDK
|
|
10
|
+
module Teams
|
|
11
|
+
class << self
|
|
12
|
+
def loader
|
|
13
|
+
@loader ||= begin
|
|
14
|
+
loader = Zeitwerk::Loader.new
|
|
15
|
+
loader.tag = "chat_sdk-teams"
|
|
16
|
+
loader.inflector.inflect("chat_sdk" => "ChatSDK")
|
|
17
|
+
loader.push_dir("#{__dir__}/teams", namespace: ChatSDK::Teams)
|
|
18
|
+
loader.setup
|
|
19
|
+
loader
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
ChatSDK::Teams.loader
|
metadata
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: chat_sdk-teams
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Rootly
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: chat_sdk
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: jwt
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '2.7'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '2.7'
|
|
54
|
+
description: Microsoft Teams bot adapter for the ChatSDK framework
|
|
55
|
+
email:
|
|
56
|
+
- eng@rootly.com
|
|
57
|
+
executables: []
|
|
58
|
+
extensions: []
|
|
59
|
+
extra_rdoc_files: []
|
|
60
|
+
files:
|
|
61
|
+
- lib/chat_sdk/teams.rb
|
|
62
|
+
- lib/chat_sdk/teams/activity_parser.rb
|
|
63
|
+
- lib/chat_sdk/teams/adapter.rb
|
|
64
|
+
- lib/chat_sdk/teams/adaptive_card_renderer.rb
|
|
65
|
+
- lib/chat_sdk/teams/bot_framework_client.rb
|
|
66
|
+
- lib/chat_sdk/teams/jwt_verifier.rb
|
|
67
|
+
homepage: https://github.com/rootlyhq/rootly-chat-sdk
|
|
68
|
+
licenses:
|
|
69
|
+
- MIT
|
|
70
|
+
metadata:
|
|
71
|
+
rubygems_mfa_required: 'true'
|
|
72
|
+
rdoc_options: []
|
|
73
|
+
require_paths:
|
|
74
|
+
- lib
|
|
75
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
76
|
+
requirements:
|
|
77
|
+
- - ">="
|
|
78
|
+
- !ruby/object:Gem::Version
|
|
79
|
+
version: 3.2.0
|
|
80
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
81
|
+
requirements:
|
|
82
|
+
- - ">="
|
|
83
|
+
- !ruby/object:Gem::Version
|
|
84
|
+
version: '0'
|
|
85
|
+
requirements: []
|
|
86
|
+
rubygems_version: 4.0.16
|
|
87
|
+
specification_version: 4
|
|
88
|
+
summary: Microsoft Teams adapter for ChatSDK
|
|
89
|
+
test_files: []
|