teams_rb 2.0.1

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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +500 -0
  5. data/docs/README.md +40 -0
  6. data/docs/essentials/README.md +13 -0
  7. data/docs/essentials/api-client.md +62 -0
  8. data/docs/essentials/app-authentication.md +27 -0
  9. data/docs/essentials/app-basics.md +50 -0
  10. data/docs/essentials/graph.md +41 -0
  11. data/docs/essentials/on-activity.md +72 -0
  12. data/docs/essentials/on-event.md +29 -0
  13. data/docs/essentials/proactive-messaging.md +44 -0
  14. data/docs/essentials/sending-messages.md +76 -0
  15. data/docs/essentials/sovereign-cloud.md +26 -0
  16. data/docs/getting-started/README.md +7 -0
  17. data/docs/getting-started/code-basics.md +61 -0
  18. data/docs/getting-started/quickstart.md +54 -0
  19. data/docs/getting-started/running-in-teams.md +44 -0
  20. data/docs/in-depth-guides/README.md +14 -0
  21. data/docs/in-depth-guides/adaptive-cards.md +62 -0
  22. data/docs/in-depth-guides/dialogs.md +77 -0
  23. data/docs/in-depth-guides/feedback.md +29 -0
  24. data/docs/in-depth-guides/meeting-events.md +27 -0
  25. data/docs/in-depth-guides/message-extensions.md +77 -0
  26. data/docs/in-depth-guides/message-reactions.md +29 -0
  27. data/docs/in-depth-guides/observability.md +28 -0
  28. data/docs/in-depth-guides/streaming.md +52 -0
  29. data/docs/in-depth-guides/tabs.md +59 -0
  30. data/docs/in-depth-guides/user-authentication.md +60 -0
  31. data/lib/teams/activity.rb +160 -0
  32. data/lib/teams/activity_context.rb +278 -0
  33. data/lib/teams/api/account.rb +90 -0
  34. data/lib/teams/api/activity_value.rb +49 -0
  35. data/lib/teams/api/bot_sign_in_client.rb +54 -0
  36. data/lib/teams/api/channel_data.rb +86 -0
  37. data/lib/teams/api/channel_info.rb +19 -0
  38. data/lib/teams/api/citation_appearance.rb +83 -0
  39. data/lib/teams/api/client.rb +28 -0
  40. data/lib/teams/api/conversation_account.rb +40 -0
  41. data/lib/teams/api/conversation_client.rb +156 -0
  42. data/lib/teams/api/conversation_reference.rb +83 -0
  43. data/lib/teams/api/conversation_resource.rb +19 -0
  44. data/lib/teams/api/meeting_client.rb +57 -0
  45. data/lib/teams/api/meeting_info.rb +26 -0
  46. data/lib/teams/api/meeting_notification_response.rb +29 -0
  47. data/lib/teams/api/meeting_participant.rb +33 -0
  48. data/lib/teams/api/message_activity.rb +201 -0
  49. data/lib/teams/api/message_extension.rb +110 -0
  50. data/lib/teams/api/model.rb +49 -0
  51. data/lib/teams/api/notification_info.rb +28 -0
  52. data/lib/teams/api/paged_members_result.rb +16 -0
  53. data/lib/teams/api/quoted_reply_entity.rb +65 -0
  54. data/lib/teams/api/reaction_client.rb +34 -0
  55. data/lib/teams/api/sent_activity.rb +48 -0
  56. data/lib/teams/api/task_module.rb +83 -0
  57. data/lib/teams/api/team_client.rb +45 -0
  58. data/lib/teams/api/team_details.rb +36 -0
  59. data/lib/teams/api/team_info.rb +44 -0
  60. data/lib/teams/api/tenant_info.rb +11 -0
  61. data/lib/teams/api/token.rb +81 -0
  62. data/lib/teams/api/typing_activity.rb +19 -0
  63. data/lib/teams/api/user_client.rb +83 -0
  64. data/lib/teams/app.rb +602 -0
  65. data/lib/teams/auth/client_secret_credentials.rb +7 -0
  66. data/lib/teams/auth/jwt_validator.rb +148 -0
  67. data/lib/teams/auth/token.rb +39 -0
  68. data/lib/teams/auth/token_manager.rb +68 -0
  69. data/lib/teams/cards/generated.rb +1764 -0
  70. data/lib/teams/cards/generated_base.rb +105 -0
  71. data/lib/teams/cards.rb +81 -0
  72. data/lib/teams/cloud_environment.rb +41 -0
  73. data/lib/teams/common/hashes.rb +20 -0
  74. data/lib/teams/common/http_client.rb +111 -0
  75. data/lib/teams/common/retry.rb +31 -0
  76. data/lib/teams/errors.rb +50 -0
  77. data/lib/teams/function_context.rb +82 -0
  78. data/lib/teams/graph/client.rb +73 -0
  79. data/lib/teams/http_stream.rb +460 -0
  80. data/lib/teams/rack_app.rb +62 -0
  81. data/lib/teams/response.rb +9 -0
  82. data/lib/teams/router.rb +171 -0
  83. data/lib/teams/storage/memory_store.rb +27 -0
  84. data/lib/teams/version.rb +5 -0
  85. data/lib/teams.rb +70 -0
  86. metadata +217 -0
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Teams
6
+ module Api
7
+ # User token operations against the Bot Framework token service (a
8
+ # different host than the conversation service). Requires an OAuth
9
+ # connection configured on the bot registration.
10
+ class UserClient
11
+ attr_reader :oauth_url, :http
12
+
13
+ def initialize(oauth_url:, http:, logger: nil)
14
+ @oauth_url = oauth_url.sub(%r{/+\z}, "")
15
+ @http = http
16
+ @logger = logger
17
+ end
18
+
19
+ def get_token(user_id:, connection_name:, channel_id: nil, code: nil)
20
+ url = endpoint(
21
+ "api/usertoken/GetToken",
22
+ "userId" => user_id, "connectionName" => connection_name,
23
+ "channelId" => channel_id, "code" => code
24
+ )
25
+ @logger&.debug("Teams API GET #{url}")
26
+ TokenResponse.new(http.get(url))
27
+ end
28
+
29
+ def get_aad_tokens(user_id:, connection_name:, resource_urls:, channel_id:)
30
+ # resourceUrls are repeated query keys on the wire (Python's shape);
31
+ # the array goes through params: so the flat encoder handles it —
32
+ # hand-built query strings get re-encoded by Faraday, which would
33
+ # collapse repeated keys.
34
+ url = "#{oauth_url}/api/usertoken/GetAadTokens"
35
+ @logger&.debug("Teams API POST #{url}")
36
+ response = http.post(url, params: {
37
+ "userId" => user_id,
38
+ "connectionName" => connection_name,
39
+ "channelId" => channel_id,
40
+ "resourceUrls" => Array(resource_urls)
41
+ })
42
+ (response || {}).transform_values { |value| TokenResponse.new(value) }
43
+ end
44
+
45
+ def get_token_status(user_id:, channel_id:, include_filter: nil)
46
+ url = endpoint(
47
+ "api/usertoken/GetTokenStatus",
48
+ "userId" => user_id, "channelId" => channel_id, "includeFilter" => include_filter
49
+ )
50
+ @logger&.debug("Teams API GET #{url}")
51
+ Array(http.get(url)).map { |status| TokenStatus.new(status) }
52
+ end
53
+
54
+ def sign_out(user_id:, connection_name:, channel_id:)
55
+ url = endpoint(
56
+ "api/usertoken/SignOut",
57
+ "userId" => user_id, "connectionName" => connection_name, "channelId" => channel_id
58
+ )
59
+ @logger&.debug("Teams API DELETE #{url}")
60
+ http.delete(url)
61
+ nil
62
+ end
63
+
64
+ # exchange_request carries either a token exchange token (SSO) or a
65
+ # uri: {"token" => ...} or {"uri" => ...}.
66
+ def exchange_token(user_id:, connection_name:, channel_id:, exchange_request:)
67
+ url = endpoint(
68
+ "api/usertoken/exchange",
69
+ "userId" => user_id, "connectionName" => connection_name, "channelId" => channel_id
70
+ )
71
+ @logger&.debug("Teams API POST #{url}")
72
+ TokenResponse.new(http.post(url, json: Common::Hashes.deep_stringify_keys(exchange_request)))
73
+ end
74
+
75
+ private
76
+
77
+ def endpoint(path, params)
78
+ query = URI.encode_www_form(params.compact)
79
+ "#{oauth_url}/#{path}?#{query}"
80
+ end
81
+ end
82
+ end
83
+ end
data/lib/teams/app.rb ADDED
@@ -0,0 +1,602 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module Teams
6
+ class App
7
+ DEFAULT_MESSAGING_ENDPOINT = "/api/messages"
8
+
9
+ attr_reader :api, :logger, :storage, :messaging_endpoint, :default_connection_name, :graph
10
+
11
+ def client_id
12
+ @token_manager.client_id
13
+ end
14
+
15
+ def initialize(
16
+ client_id: ENV["CLIENT_ID"],
17
+ client_secret: ENV["CLIENT_SECRET"],
18
+ tenant_id: ENV["TENANT_ID"],
19
+ service_url: ENV.fetch("SERVICE_URL", "https://smba.trafficmanager.net/teams"),
20
+ cloud: CloudEnvironments.public,
21
+ logger: Logger.new($stdout),
22
+ storage: Storage::MemoryStore.new,
23
+ api: nil,
24
+ token_manager: nil,
25
+ skip_auth: false,
26
+ messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT,
27
+ default_connection_name: "graph"
28
+ )
29
+ @default_connection_name = default_connection_name
30
+ @sign_in_handlers = []
31
+ @error_handlers = []
32
+ @functions = {}
33
+ @logger = logger
34
+ @storage = storage
35
+ @cloud = cloud
36
+ @skip_auth = skip_auth
37
+ @messaging_endpoint = normalize_messaging_endpoint(messaging_endpoint)
38
+ @router = Router.new
39
+
40
+ @token_manager = token_manager || Auth::TokenManager.from_env(client_id:, client_secret:, tenant_id:, cloud:)
41
+ @api = api || Api::Client.new(
42
+ service_url:,
43
+ http: Common::HttpClient.new(token: -> { @token_manager.bot_token }),
44
+ logger:,
45
+ oauth_url: cloud.token_service_url
46
+ )
47
+ @jwt_validator = Auth::JwtValidator.new(
48
+ client_id: @token_manager.client_id,
49
+ tenant_id: @token_manager.credentials&.tenant_id,
50
+ cloud:
51
+ ) if @token_manager.client_id
52
+
53
+ # Graph base URL derives from the cloud's graph scope host (sovereign
54
+ # clouds), like the TypeScript SDK; the app-identity client requests
55
+ # app-only tokens through the client-credentials flow.
56
+ graph_root = cloud.graph_scope.to_s[%r{\Ahttps?://[^/]+}]
57
+ @graph = Graph::Client.new(
58
+ token: -> { @token_manager.token_for(cloud.graph_scope, @token_manager.credentials&.tenant_id || cloud.login_tenant) },
59
+ base_url_root: graph_root
60
+ )
61
+
62
+ register_default_oauth_handlers
63
+ warn_missing_credentials
64
+ end
65
+
66
+ def to_rack
67
+ RackApp.new(self)
68
+ end
69
+
70
+ def initialize!
71
+ @token_manager.bot_token
72
+ true
73
+ end
74
+
75
+ def use(&block)
76
+ @router.use(&block)
77
+ self
78
+ end
79
+
80
+ def on(name, &block)
81
+ @router.on(name, &block)
82
+ self
83
+ end
84
+
85
+ def on_message(pattern = nil, &block)
86
+ @router.on_message(pattern, &block)
87
+ self
88
+ end
89
+
90
+ def on_suggested_action_submit(&block)
91
+ @router.on("suggested-action.submit", &block)
92
+ self
93
+ end
94
+
95
+ # Registers a dialog (task module) open handler for task/fetch invokes.
96
+ # With a dialog_id, only invokes whose card action data carries that
97
+ # "dialog_id" value match. The handler's return value (a
98
+ # Api::TaskModuleResponse or hash) becomes the invoke response body.
99
+ def on_dialog_open(dialog_id = nil, &block)
100
+ @router.on_dialog_open(dialog_id, &block)
101
+ self
102
+ end
103
+
104
+ # Registers a dialog (task module) submit handler for task/submit
105
+ # invokes, optionally filtered by the "action" value in the submit data.
106
+ def on_dialog_submit(action = nil, &block)
107
+ @router.on_dialog_submit(action, &block)
108
+ self
109
+ end
110
+
111
+ # Sign-in invokes: signin/tokenExchange arrives for silent SSO token
112
+ # exchange, signin/verifyState after interactive OAuth card sign-in,
113
+ # signin/failure when the Teams client reports a failed SSO attempt.
114
+ # Default handlers registered at construction complete these flows and
115
+ # fire on_sign_in / on_error; handlers registered here run after them.
116
+ def on_signin_token_exchange(&block)
117
+ @router.on_signin_token_exchange(&block)
118
+ self
119
+ end
120
+
121
+ def on_signin_verify_state(&block)
122
+ @router.on_signin_verify_state(&block)
123
+ self
124
+ end
125
+
126
+ def on_signin_failure(&block)
127
+ @router.on_signin_failure(&block)
128
+ self
129
+ end
130
+
131
+ # message/submitAction invokes; on_message_submit_feedback filters to
132
+ # feedback-loop submissions (thumbs up/down from add_feedback).
133
+ def on_message_submit(&block)
134
+ @router.on_message_submit(&block)
135
+ self
136
+ end
137
+
138
+ def on_message_submit_feedback(&block)
139
+ @router.on_message_submit_feedback(&block)
140
+ self
141
+ end
142
+
143
+ # Called with (ctx, token_response) whenever a sign-in completes through
144
+ # the default token-exchange or verify-state handlers.
145
+ def on_sign_in(&block)
146
+ @sign_in_handlers << block
147
+ self
148
+ end
149
+
150
+ # Called with (error, activity) when a default OAuth handler hits an
151
+ # unexpected failure or the Teams client reports a sign-in failure.
152
+ def on_error(&block)
153
+ @error_handlers << block
154
+ self
155
+ end
156
+
157
+ def emit_sign_in(context, token_response)
158
+ @sign_in_handlers.each { |handler| handler.call(context, token_response) }
159
+ end
160
+
161
+ def emit_error(error, activity: nil)
162
+ @error_handlers.each { |handler| handler.call(error, activity) }
163
+ end
164
+
165
+ # Registers a remote function callable from tabs via
166
+ # POST /api/functions/{name}. Requests carry an Entra token for the tab
167
+ # user, validated against the app's client id and tenant; the handler
168
+ # receives a FunctionContext and its return value becomes the JSON
169
+ # response body.
170
+ def on_function(name, &block)
171
+ raise ArgumentError, "handler block is required" unless block
172
+
173
+ @functions[name.to_s] = block
174
+ self
175
+ end
176
+
177
+ def process_function(name, data, env: {})
178
+ handler = @functions[name.to_s]
179
+ unless handler
180
+ return Response.new(status: 404, body: { "detail" => "function #{name.inspect} is not registered" })
181
+ end
182
+
183
+ context, error = build_function_context(name.to_s, data, env)
184
+ unless context
185
+ logger&.warn("Rejected function call #{name.inspect}: #{error}")
186
+ return Response.new(status: 401, body: { "detail" => error })
187
+ end
188
+
189
+ result = handler.call(context)
190
+ body = result.respond_to?(:to_h) ? result.to_h : result
191
+ # Function responses always carry valid JSON: callers fetch and parse
192
+ # them, so a nil handler return becomes an empty object rather than an
193
+ # empty body with a JSON content type.
194
+ Response.new(status: 200, body: body.nil? ? {} : body)
195
+ end
196
+
197
+ # Meeting start/end events (Teams posts them to bots installed in the
198
+ # meeting chat when the meeting begins and ends).
199
+ def on_meeting_start(&block)
200
+ @router.on_meeting_start(&block)
201
+ self
202
+ end
203
+
204
+ def on_meeting_end(&block)
205
+ @router.on_meeting_end(&block)
206
+ self
207
+ end
208
+
209
+ # Message extension handlers (on_message_ext_query, on_message_ext_submit,
210
+ # on_message_ext_open, ...) route the composeExtension/* invokes with the
211
+ # TypeScript/Python route names. Handler return values (typed responses
212
+ # or hashes) become the invoke response body.
213
+ Router::MESSAGE_EXTENSION_HANDLER_METHODS.each_key do |method_name|
214
+ define_method(method_name) do |&block|
215
+ @router.public_send(method_name, &block)
216
+ self
217
+ end
218
+ end
219
+
220
+ def on_message_update(&block)
221
+ @router.on_message_update(&block)
222
+ self
223
+ end
224
+
225
+ def on_edit_message(&block)
226
+ @router.on_edit_message(&block)
227
+ self
228
+ end
229
+
230
+ def on_undelete_message(&block)
231
+ @router.on_undelete_message(&block)
232
+ self
233
+ end
234
+
235
+ def process_inbound(payload, env: {})
236
+ activity = Activity.new(payload)
237
+ validate_inbound!(env, activity)
238
+
239
+ conversation_reference = Api::ConversationReference.from_activity(activity)
240
+ context = ActivityContext.new(
241
+ app: self,
242
+ activity:,
243
+ conversation_reference:,
244
+ stream: HttpStream.new(app: self, conversation_reference:)
245
+ )
246
+ result = run_handlers(context)
247
+ context.stream.close
248
+
249
+ return result if result.is_a?(Response)
250
+
251
+ body = activity.invoke? ? invoke_response_body(result) : nil
252
+ Response.new(status: 200, body:)
253
+ rescue StreamCancelledError
254
+ Response.new(status: 200)
255
+ end
256
+
257
+ # The TypeScript, Python, and .NET SDKs call this operation `send`.
258
+ # Ruby already defines Object#send for dynamic dispatch, so the public
259
+ # Ruby API uses `post` to avoid shadowing a core language method.
260
+ def post(conversation_id, activity_or_text, service_url: nil)
261
+ assert_string!(conversation_id, "conversation_id")
262
+
263
+ send_activity(
264
+ proactive_reference(conversation_id, service_url:),
265
+ activity_or_text
266
+ )
267
+ end
268
+
269
+ # Proactive threaded replies use a ";messageid=" conversation ID like the
270
+ # TypeScript and Python SDKs; the service decides whether threading applies.
271
+ def reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil)
272
+ assert_string!(conversation_id, "conversation_id")
273
+
274
+ if activity_or_text
275
+ assert_string!(activity_id_or_activity, "activity_id")
276
+
277
+ post(
278
+ Teams.to_threaded_conversation_id(conversation_id, activity_id_or_activity),
279
+ activity_or_text,
280
+ service_url:
281
+ )
282
+ else
283
+ post(conversation_id, activity_id_or_activity, service_url:)
284
+ end
285
+ end
286
+
287
+ # Sugar over post: the SDKs update by sending an activity that already
288
+ # carries an id, and post does exactly that. See AGENTS.md.
289
+ def update(conversation_id, activity_id, activity_or_text, service_url: nil)
290
+ assert_string!(conversation_id, "conversation_id")
291
+ assert_string!(activity_id, "activity_id")
292
+
293
+ post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:)
294
+ end
295
+
296
+ def send_activity(conversation_reference, activity_or_text)
297
+ activity = activity_for_reference(conversation_reference, activity_or_text)
298
+ targeted = targeted_activity?(activity)
299
+
300
+ if targeted && conversation_reference.conversation.conversation_type == "personal"
301
+ raise ArgumentError, "Targeted messages are not supported in 1:1 (personal) chats."
302
+ end
303
+
304
+ id = activity_id(activity)
305
+ conversation_id = conversation_reference.conversation_id
306
+ service_url = conversation_reference.service_url
307
+
308
+ response = if id && targeted
309
+ api.conversations.update_targeted_activity(conversation_id, id, activity, service_url:)
310
+ elsif id
311
+ api.conversations.update_activity(conversation_id, id, activity, service_url:)
312
+ elsif targeted
313
+ api.conversations.create_targeted_activity(conversation_id, activity, service_url:)
314
+ else
315
+ api.conversations.create_activity(conversation_id, activity, service_url:)
316
+ end
317
+
318
+ Api::SentActivity.merge(activity, response)
319
+ end
320
+
321
+ private
322
+
323
+ # Invoke handler return values become the invoke response body, like the
324
+ # TypeScript/Python SDKs. Only explicit response shapes count: Ruby
325
+ # methods implicitly return their last expression, so an invoke handler
326
+ # ending in ctx.post must not leak the SentActivity into the response.
327
+ def invoke_response_body(result)
328
+ case result
329
+ when Api::TaskModuleResponse, Api::MessagingExtensionResponse, Api::MessagingExtensionActionResponse
330
+ result.to_h
331
+ when Hash
332
+ Common::Hashes.deep_stringify_keys(result)
333
+ end
334
+ end
335
+
336
+ def activity_with_id(activity_id, activity_or_text)
337
+ outbound = normalize_activity(activity_or_text)
338
+ body = outbound.respond_to?(:to_h) ? outbound.to_h : outbound
339
+ body.merge("id" => activity_id)
340
+ end
341
+
342
+ # Validates a remote function request like the TypeScript/Python SDKs:
343
+ # required client headers, an Entra bearer token verified against the
344
+ # app's client id/tenant, and the oid/tid/name claims. Returns
345
+ # [context, nil] or [nil, error_message].
346
+ def build_function_context(name, data, env)
347
+ auth_header = env["HTTP_AUTHORIZATION"].to_s
348
+ app_session_id = env["HTTP_X_TEAMS_APP_SESSION_ID"].to_s
349
+ page_id = env["HTTP_X_TEAMS_PAGE_ID"].to_s
350
+
351
+ return [nil, "missing X-Teams-App-Session-Id header"] if app_session_id.empty?
352
+ return [nil, "missing X-Teams-Page-Id header"] if page_id.empty?
353
+ return [nil, "missing Authorization bearer token"] if auth_header.empty?
354
+ return [nil, "Token validator not configured"] unless @jwt_validator
355
+
356
+ begin
357
+ payload = @jwt_validator.validate!(auth_header)
358
+ rescue AuthenticationError => error
359
+ return [nil, error.message]
360
+ end
361
+
362
+ %w[oid tid name].each do |claim|
363
+ return [nil, "missing #{claim} claim in token payload"] if payload[claim].to_s.empty?
364
+ end
365
+
366
+ context = FunctionContext.new(
367
+ app: self,
368
+ function_name: name,
369
+ data:,
370
+ app_session_id:,
371
+ page_id:,
372
+ auth_token: auth_header.split(" ", 2).last,
373
+ tenant_id: payload["tid"],
374
+ user_id: payload["oid"],
375
+ user_name: payload["name"],
376
+ app_id: payload["appId"],
377
+ channel_id: presence(env["HTTP_X_TEAMS_CHANNEL_ID"]),
378
+ chat_id: presence(env["HTTP_X_TEAMS_CHAT_ID"]),
379
+ meeting_id: presence(env["HTTP_X_TEAMS_MEETING_ID"]),
380
+ message_id: presence(env["HTTP_X_TEAMS_MESSAGE_ID"]),
381
+ sub_page_id: presence(env["HTTP_X_TEAMS_SUB_PAGE_ID"]),
382
+ team_id: presence(env["HTTP_X_TEAMS_TEAM_ID"])
383
+ )
384
+ [context, nil]
385
+ end
386
+
387
+ def presence(value)
388
+ value.to_s.empty? ? nil : value
389
+ end
390
+
391
+ # Default OAuth handlers, mirroring the TypeScript/Python OauthHandlers:
392
+ # registered first on the signin routes, they complete the flows, fire
393
+ # the sign-in/error events, and chain to user handlers via next. Their
394
+ # return value is the invoke response; 412 tells the Teams client to
395
+ # fall back to the interactive card flow.
396
+ def register_default_oauth_handlers
397
+ app = self
398
+
399
+ @router.on_signin_token_exchange do |ctx, nxt|
400
+ value = ctx.activity.value
401
+ begin
402
+ if value.raw["connectionName"] != app.default_connection_name
403
+ app.logger&.warn(
404
+ "Sign-in token exchange invoked with connection name #{value.raw["connectionName"].inspect}, " \
405
+ "but the default connection name is #{app.default_connection_name.inspect}. " \
406
+ "Token verification will likely fail."
407
+ )
408
+ end
409
+
410
+ begin
411
+ token = ctx.api.users.exchange_token(
412
+ user_id: ctx.activity.from.id,
413
+ connection_name: value.raw["connectionName"],
414
+ channel_id: ctx.activity.channel_id,
415
+ exchange_request: { "token" => value.raw["token"] }
416
+ )
417
+ app.emit_sign_in(ctx, token)
418
+ nil
419
+ rescue HttpError => error
420
+ unless [404, 400, 412].include?(error.status)
421
+ app.logger&.error("Error exchanging token: #{error.message}")
422
+ app.emit_error(error, activity: ctx.activity)
423
+ next Response.new(status: error.status || 500)
424
+ end
425
+
426
+ Response.new(
427
+ status: 412,
428
+ body: {
429
+ "id" => value.raw["id"],
430
+ "connectionName" => value.raw["connectionName"],
431
+ "failureDetail" => error.message
432
+ }
433
+ )
434
+ end
435
+ ensure
436
+ nxt.call
437
+ end
438
+ end
439
+
440
+ @router.on_signin_verify_state do |ctx, nxt|
441
+ begin
442
+ state = ctx.activity.value.raw["state"]
443
+ if state.to_s.empty?
444
+ app.logger&.warn("Auth state not present on signin/verifyState invoke")
445
+ next Response.new(status: 404)
446
+ end
447
+
448
+ begin
449
+ token = ctx.api.users.get_token(
450
+ user_id: ctx.activity.from.id,
451
+ connection_name: app.default_connection_name,
452
+ channel_id: ctx.activity.channel_id,
453
+ code: state
454
+ )
455
+ app.emit_sign_in(ctx, token)
456
+ nil
457
+ rescue HttpError => error
458
+ app.logger&.error("Error verifying sign-in state: #{error.message}")
459
+ unless [404, 400, 412].include?(error.status)
460
+ app.emit_error(error, activity: ctx.activity)
461
+ next Response.new(status: error.status || 500)
462
+ end
463
+
464
+ Response.new(status: 412)
465
+ end
466
+ ensure
467
+ nxt.call
468
+ end
469
+ end
470
+
471
+ @router.on_signin_failure do |ctx, nxt|
472
+ begin
473
+ value = ctx.activity.value
474
+ app.logger&.warn(
475
+ "Sign-in failed: #{value.raw["code"]} - #{value.raw["message"]}. " \
476
+ "If the code is 'resourcematchfailed', verify the Entra app registration's " \
477
+ "Application ID URI matches the OAuth connection's Token Exchange URL."
478
+ )
479
+ app.emit_error(
480
+ Error.new("Sign-in failure: #{value.raw["code"]} - #{value.raw["message"]}"),
481
+ activity: ctx.activity
482
+ )
483
+ nil
484
+ ensure
485
+ nxt.call
486
+ end
487
+ end
488
+ end
489
+
490
+ # The same two startup warnings the TypeScript, Python, and .NET SDKs
491
+ # log when no credentials are configured. Settled 2026-07-12: exact
492
+ # upstream branches only; credentials-plus-skip_auth stays silent like
493
+ # the other SDKs.
494
+ def warn_missing_credentials
495
+ return if @token_manager.client_id
496
+
497
+ if @skip_auth
498
+ logger&.warn(
499
+ "No credentials configured (CLIENT_ID / CLIENT_SECRET / TENANT_ID), " \
500
+ "but skip_auth is enabled. Bot will accept unauthenticated requests on #{@messaging_endpoint}."
501
+ )
502
+ else
503
+ logger&.warn(
504
+ "No credentials configured and skip_auth is not enabled. All incoming requests will be rejected. " \
505
+ "Configure client authentication to securely receive messages, or set skip_auth: true for local development."
506
+ )
507
+ end
508
+ end
509
+
510
+ def validate_inbound!(env, activity)
511
+ return if @skip_auth
512
+
513
+ raise AuthenticationError, "CLIENT_ID is required for inbound validation" unless @jwt_validator
514
+
515
+ @jwt_validator.validate!(env["HTTP_AUTHORIZATION"], service_url: activity.service_url)
516
+ end
517
+
518
+ def run_handlers(context)
519
+ routes = @router.matching(context.activity)
520
+ activity = context.activity
521
+ logger&.debug(
522
+ "Teams inbound #{activity.type}#{activity.name ? " #{activity.name}" : ""} " \
523
+ "id=#{activity.id} matched #{routes.length} route(s)"
524
+ )
525
+ index = -1
526
+ next_handler = nil
527
+ next_handler = lambda do
528
+ index += 1
529
+ route = routes[index]
530
+ return nil unless route
531
+
532
+ if route.handler.arity >= 2
533
+ route.handler.call(context, next_handler)
534
+ else
535
+ route.handler.call(context)
536
+ end
537
+ end
538
+ next_handler.call
539
+ end
540
+
541
+ def proactive_reference(conversation_id, service_url:)
542
+ Api::ConversationReference.new(
543
+ bot: { "id" => @token_manager.client_id },
544
+ conversation: { "id" => conversation_id },
545
+ channel_id: "msteams",
546
+ service_url: service_url || api.service_url
547
+ )
548
+ end
549
+
550
+ # Merges only the sender and conversation from the reference, matching the
551
+ # TypeScript and Python activity senders.
552
+ def activity_for_reference(conversation_reference, activity_or_text)
553
+ activity = normalize_activity(activity_or_text)
554
+ body = activity.respond_to?(:to_h) ? activity.to_h : activity
555
+ return body unless body.is_a?(Hash)
556
+
557
+ body = body.dup
558
+ body["from"] = conversation_reference.bot.to_h if conversation_reference.bot
559
+ body["conversation"] = conversation_reference.conversation.to_h
560
+ body
561
+ end
562
+
563
+ def activity_id(activity)
564
+ return unless activity.is_a?(Hash)
565
+
566
+ activity["id"] || activity[:id]
567
+ end
568
+
569
+ def targeted_activity?(activity)
570
+ activity.is_a?(Hash) &&
571
+ activity["type"] == "message" &&
572
+ activity.dig("recipient", "isTargeted") == true
573
+ end
574
+
575
+ def assert_string!(value, name)
576
+ return if value.is_a?(String)
577
+
578
+ raise ArgumentError, "#{name} must be a String"
579
+ end
580
+
581
+ def normalize_activity(activity_or_text)
582
+ case activity_or_text
583
+ when String
584
+ Api::MessageActivity.new(activity_or_text)
585
+ when Cards::AdaptiveCard
586
+ Api::MessageActivity.new.add_card(activity_or_text)
587
+ when Hash
588
+ Common::Hashes.deep_stringify_keys(activity_or_text)
589
+ else
590
+ activity_or_text
591
+ end
592
+ end
593
+
594
+ def normalize_messaging_endpoint(value)
595
+ endpoint = value.to_s.strip
596
+ return endpoint if endpoint.start_with?("/") && !endpoint.empty?
597
+
598
+ raise ArgumentError, "messaging_endpoint must be a non-empty path starting with '/'"
599
+ end
600
+
601
+ end
602
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Auth
5
+ ClientSecretCredentials = Struct.new(:client_id, :client_secret, :tenant_id, keyword_init: true)
6
+ end
7
+ end