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 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: []