teams_rb 2.0.1 → 2.0.3

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.
@@ -12,17 +12,41 @@ module Teams
12
12
  # Sub-clients are constructed eagerly like the other SDKs' ApiClient
13
13
  # constructors, which also keeps the shared client thread-safe.
14
14
  # oauth_url is the Bot Framework token service (cloud-dependent), used
15
- # by the user token and bot sign-in clients.
16
- def initialize(service_url:, http:, logger: nil, oauth_url: DEFAULT_OAUTH_URL)
15
+ # by the user token and bot sign-in clients. token_provider is a
16
+ # callable(agentic_identity) returning a bearer token; it enables
17
+ # for_agentic_identity scoping (the App wires it to its TokenManager).
18
+ def initialize(service_url:, http:, logger: nil, oauth_url: DEFAULT_OAUTH_URL, token_provider: nil)
17
19
  @service_url = service_url.sub(%r{/+\z}, "")
18
20
  @http = http
19
21
  @logger = logger
22
+ @oauth_url = oauth_url
23
+ @token_provider = token_provider
20
24
  @conversations = ConversationClient.new(service_url: @service_url, http:, logger:)
21
25
  @teams = TeamClient.new(service_url: @service_url, http:, logger:)
22
26
  @meetings = MeetingClient.new(service_url: @service_url, http:, logger:)
23
27
  @users = UserClient.new(oauth_url:, http:, logger:)
24
28
  @bots = BotClient.new(oauth_url:, http:, logger:)
25
29
  end
30
+
31
+ # Returns a client scoped to an Agent 365 identity (and optionally a
32
+ # different service URL): requests authenticate with the identity's
33
+ # agentic token instead of the app token. The other SDKs call this
34
+ # clone(); Ruby avoids shadowing Object#clone, like post vs send.
35
+ # A nil identity scopes back to the app token.
36
+ def for_agentic_identity(agentic_identity, service_url: nil)
37
+ unless @token_provider
38
+ raise Error, "This API client was constructed without a token provider; agentic scoping is unavailable."
39
+ end
40
+
41
+ identity = agentic_identity
42
+ self.class.new(
43
+ service_url: service_url || @service_url,
44
+ http: Common::HttpClient.new(token: -> { @token_provider.call(identity) }),
45
+ logger: @logger,
46
+ oauth_url: @oauth_url,
47
+ token_provider: @token_provider
48
+ )
49
+ end
26
50
  end
27
51
  end
28
52
  end
@@ -41,7 +41,9 @@ module Teams
41
41
  def reply_to_activity(conversation_id, activity_id, activity, service_url: nil)
42
42
  body = activity_to_h(activity)
43
43
  body = body.merge("replyToId" => activity_id) if body.is_a?(Hash)
44
- path = "/v3/conversations/#{escape(conversation_id)}/activities/#{escape(activity_id)}"
44
+ # Replies POST to the base activities collection; the reply
45
+ # semantics ride entirely in the body's replyToId.
46
+ path = "/v3/conversations/#{escape(conversation_id)}/activities"
45
47
  url = absolute(path, service_url:)
46
48
  @logger&.debug("Teams API POST #{url}")
47
49
  http.post(url, json: body)
@@ -3,6 +3,7 @@
3
3
  module Teams
4
4
  module Api
5
5
  class MessageActivity
6
+ # "extendedmarkdown" is in public preview and may be subject to change.
6
7
  TEXT_FORMATS = %w[plain markdown xml extendedmarkdown].freeze
7
8
  FEEDBACK_MODES = %w[default custom].freeze
8
9
  AI_MESSAGE_ENTITY_TYPE = "https://schema.org/Message"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Api
5
+ # A single application/search result: title is the display text, value
6
+ # is submitted when the user selects it.
7
+ class SearchInvokeResult
8
+ def initialize(title:, value:)
9
+ @title = title
10
+ @value = value
11
+ end
12
+
13
+ def to_h
14
+ { "title" => @title, "value" => @value }
15
+ end
16
+ end
17
+
18
+ # Response body for application/search invokes (Adaptive Card dynamic
19
+ # typeahead Input.ChoiceSet queries). results accepts SearchInvokeResult
20
+ # objects or {title:, value:} hashes.
21
+ class SearchResponse
22
+ def initialize(results, status_code: 200)
23
+ @results = results
24
+ @status_code = status_code
25
+ end
26
+
27
+ def to_h
28
+ {
29
+ "statusCode" => @status_code,
30
+ "type" => "application/vnd.microsoft.search.searchResponse",
31
+ "value" => {
32
+ "results" => Array(@results).map do |result|
33
+ body = result.is_a?(Hash) ? result : result.to_h
34
+ Common::Hashes.deep_stringify_keys(body)
35
+ end
36
+ }
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
data/lib/teams/app.rb CHANGED
@@ -5,6 +5,10 @@ require "logger"
5
5
  module Teams
6
6
  class App
7
7
  DEFAULT_MESSAGING_ENDPOINT = "/api/messages"
8
+ DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR = "DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS"
9
+ MAX_PROCESSED_EXCHANGE_IDS = 1000
10
+ TRUE_ENV_VALUES = %w[1 true yes on].freeze
11
+ FALSE_ENV_VALUES = %w[0 false no off].freeze
8
12
 
9
13
  attr_reader :api, :logger, :storage, :messaging_endpoint, :default_connection_name, :graph
10
14
 
@@ -22,7 +26,8 @@ module Teams
22
26
  storage: Storage::MemoryStore.new,
23
27
  api: nil,
24
28
  token_manager: nil,
25
- skip_auth: false,
29
+ dangerously_allow_unauthenticated_requests: nil,
30
+ skip_auth: nil,
26
31
  messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT,
27
32
  default_connection_name: "graph"
28
33
  )
@@ -33,7 +38,9 @@ module Teams
33
38
  @logger = logger
34
39
  @storage = storage
35
40
  @cloud = cloud
36
- @skip_auth = skip_auth
41
+ @dangerously_allow_unauthenticated_requests = resolve_unauthenticated_requests_option(
42
+ dangerously_allow_unauthenticated_requests, skip_auth
43
+ )
37
44
  @messaging_endpoint = normalize_messaging_endpoint(messaging_endpoint)
38
45
  @router = Router.new
39
46
 
@@ -42,7 +49,8 @@ module Teams
42
49
  service_url:,
43
50
  http: Common::HttpClient.new(token: -> { @token_manager.bot_token }),
44
51
  logger:,
45
- oauth_url: cloud.token_service_url
52
+ oauth_url: cloud.token_service_url,
53
+ token_provider: method(:token_for_agentic_identity)
46
54
  )
47
55
  @jwt_validator = Auth::JwtValidator.new(
48
56
  client_id: @token_manager.client_id,
@@ -140,6 +148,14 @@ module Teams
140
148
  self
141
149
  end
142
150
 
151
+ # application/search invokes from Adaptive Card dynamic typeahead
152
+ # Input.ChoiceSet queries; the handler's return (Api::SearchResponse or
153
+ # hash) becomes the invoke response body.
154
+ def on_card_search(&block)
155
+ @router.on_card_search(&block)
156
+ self
157
+ end
158
+
143
159
  # Called with (ctx, token_response) whenever a sign-in completes through
144
160
  # the default token-exchange or verify-state handlers.
145
161
  def on_sign_in(&block)
@@ -206,6 +222,36 @@ module Teams
206
222
  self
207
223
  end
208
224
 
225
+ # conversationUpdate activities plus their channel/team lifecycle
226
+ # sub-events (on_channel_created, on_team_renamed, ...), routed by
227
+ # channelData.eventType with the Python method names.
228
+ def on_conversation_update(&block)
229
+ @router.on_conversation_update(&block)
230
+ self
231
+ end
232
+
233
+ Router::CONVERSATION_UPDATE_EVENTS.each_key do |method_name|
234
+ define_method(method_name) do |&block|
235
+ @router.public_send(method_name, &block)
236
+ self
237
+ end
238
+ end
239
+
240
+ # Agent 365 agentLifecycle events plus their per-variant routes
241
+ # (on_agentic_user_identity_created, on_agentic_user_enabled, ...),
242
+ # discriminated by the activity-level valueType.
243
+ def on_agent_lifecycle(&block)
244
+ @router.on_agent_lifecycle(&block)
245
+ self
246
+ end
247
+
248
+ Router::AGENT_LIFECYCLE_EVENTS.each_key do |method_name|
249
+ define_method(method_name) do |&block|
250
+ @router.public_send(method_name, &block)
251
+ self
252
+ end
253
+ end
254
+
209
255
  # Message extension handlers (on_message_ext_query, on_message_ext_submit,
210
256
  # on_message_ext_open, ...) route the composeExtension/* invokes with the
211
257
  # TypeScript/Python route names. Handler return values (typed responses
@@ -237,11 +283,17 @@ module Teams
237
283
  validate_inbound!(env, activity)
238
284
 
239
285
  conversation_reference = Api::ConversationReference.from_activity(activity)
286
+ # Agentic activities carry the identity on the recipient account; the
287
+ # whole turn (context and stream) is scoped to it so replies
288
+ # authenticate as the agent, like the other SDKs' per-turn clients.
289
+ agentic_identity = activity.recipient.agentic_identity
290
+ turn_api = agentic_identity ? api.for_agentic_identity(agentic_identity) : api
240
291
  context = ActivityContext.new(
241
292
  app: self,
242
293
  activity:,
243
294
  conversation_reference:,
244
- stream: HttpStream.new(app: self, conversation_reference:)
295
+ api: turn_api,
296
+ stream: HttpStream.new(app: self, conversation_reference:, api: turn_api)
245
297
  )
246
298
  result = run_handlers(context)
247
299
  context.stream.close
@@ -254,21 +306,34 @@ module Teams
254
306
  Response.new(status: 200)
255
307
  end
256
308
 
309
+ # Builds an Api::AgenticIdentity for proactive agentic operations: the
310
+ # blueprint defaults to this app's client id and the tenant to the
311
+ # configured credentials tenant, like the Python SDK.
312
+ def agentic_identity(agentic_app_id: nil, agentic_user_id: nil, tenant_id: nil, agentic_app_blueprint_id: nil)
313
+ Api::AgenticIdentity.new(
314
+ agentic_app_blueprint_id: agentic_app_blueprint_id || client_id,
315
+ agentic_app_id:,
316
+ agentic_user_id:,
317
+ tenant_id: tenant_id || @token_manager.credentials&.tenant_id
318
+ )
319
+ end
320
+
257
321
  # The TypeScript, Python, and .NET SDKs call this operation `send`.
258
322
  # Ruby already defines Object#send for dynamic dispatch, so the public
259
323
  # Ruby API uses `post` to avoid shadowing a core language method.
260
- def post(conversation_id, activity_or_text, service_url: nil)
324
+ def post(conversation_id, activity_or_text, service_url: nil, agentic_identity: nil)
261
325
  assert_string!(conversation_id, "conversation_id")
262
326
 
263
327
  send_activity(
264
328
  proactive_reference(conversation_id, service_url:),
265
- activity_or_text
329
+ activity_or_text,
330
+ api: agentic_identity ? api.for_agentic_identity(agentic_identity) : api
266
331
  )
267
332
  end
268
333
 
269
334
  # Proactive threaded replies use a ";messageid=" conversation ID like the
270
335
  # 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)
336
+ def reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil, agentic_identity: nil)
272
337
  assert_string!(conversation_id, "conversation_id")
273
338
 
274
339
  if activity_or_text
@@ -277,23 +342,24 @@ module Teams
277
342
  post(
278
343
  Teams.to_threaded_conversation_id(conversation_id, activity_id_or_activity),
279
344
  activity_or_text,
280
- service_url:
345
+ service_url:,
346
+ agentic_identity:
281
347
  )
282
348
  else
283
- post(conversation_id, activity_id_or_activity, service_url:)
349
+ post(conversation_id, activity_id_or_activity, service_url:, agentic_identity:)
284
350
  end
285
351
  end
286
352
 
287
353
  # Sugar over post: the SDKs update by sending an activity that already
288
354
  # carries an id, and post does exactly that. See AGENTS.md.
289
- def update(conversation_id, activity_id, activity_or_text, service_url: nil)
355
+ def update(conversation_id, activity_id, activity_or_text, service_url: nil, agentic_identity: nil)
290
356
  assert_string!(conversation_id, "conversation_id")
291
357
  assert_string!(activity_id, "activity_id")
292
358
 
293
- post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:)
359
+ post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:, agentic_identity:)
294
360
  end
295
361
 
296
- def send_activity(conversation_reference, activity_or_text)
362
+ def send_activity(conversation_reference, activity_or_text, api: self.api)
297
363
  activity = activity_for_reference(conversation_reference, activity_or_text)
298
364
  targeted = targeted_activity?(activity)
299
365
 
@@ -326,7 +392,8 @@ module Teams
326
392
  # ending in ctx.post must not leak the SentActivity into the response.
327
393
  def invoke_response_body(result)
328
394
  case result
329
- when Api::TaskModuleResponse, Api::MessagingExtensionResponse, Api::MessagingExtensionActionResponse
395
+ when Api::TaskModuleResponse, Api::MessagingExtensionResponse, Api::MessagingExtensionActionResponse,
396
+ Api::SearchResponse
330
397
  result.to_h
331
398
  when Hash
332
399
  Common::Hashes.deep_stringify_keys(result)
@@ -396,7 +463,18 @@ module Teams
396
463
  def register_default_oauth_handlers
397
464
  app = self
398
465
 
399
- @router.on_signin_token_exchange do |ctx, nxt|
466
+ # Each Teams client (desktop, web, mobile) that renders the OAuth card
467
+ # attempts its own silent exchange, so one sign-in can produce several
468
+ # signin/tokenExchange invokes sharing the same value id. Only the first
469
+ # runs the exchange and fires sign-in; a duplicate of a completed
470
+ # exchange answers 200 without re-emitting or chaining, and a duplicate
471
+ # arriving while the first is in flight waits and returns its result.
472
+ # Failed exchanges are not marked, so each attempt gets its own 412.
473
+ exchange_mutex = Mutex.new
474
+ processed_exchange_ids = []
475
+ exchange_locks = {}
476
+
477
+ perform_token_exchange = lambda do |ctx, nxt|
400
478
  value = ctx.activity.value
401
479
  begin
402
480
  if value.raw["connectionName"] != app.default_connection_name
@@ -437,6 +515,43 @@ module Teams
437
515
  end
438
516
  end
439
517
 
518
+ @router.on_signin_token_exchange do |ctx, nxt|
519
+ exchange_id = ctx.activity.value.raw["id"]
520
+ next perform_token_exchange.call(ctx, nxt) unless exchange_id
521
+
522
+ lock = nil
523
+ duplicate = exchange_mutex.synchronize do
524
+ if processed_exchange_ids.include?(exchange_id)
525
+ true
526
+ else
527
+ lock = (exchange_locks[exchange_id] ||= { mutex: Mutex.new, settled: false, result: nil })
528
+ false
529
+ end
530
+ end
531
+ next Response.new(status: 200) if duplicate
532
+
533
+ outcome = lock[:mutex].synchronize do
534
+ if lock[:settled]
535
+ lock[:result]
536
+ else
537
+ result = perform_token_exchange.call(ctx, nxt)
538
+ if result.nil?
539
+ exchange_mutex.synchronize do
540
+ processed_exchange_ids << exchange_id
541
+ processed_exchange_ids.shift while processed_exchange_ids.length > MAX_PROCESSED_EXCHANGE_IDS
542
+ end
543
+ lock[:result] = Response.new(status: 200)
544
+ else
545
+ lock[:result] = result
546
+ end
547
+ lock[:settled] = true
548
+ result
549
+ end
550
+ end
551
+ exchange_mutex.synchronize { exchange_locks.delete(exchange_id) }
552
+ outcome
553
+ end
554
+
440
555
  @router.on_signin_verify_state do |ctx, nxt|
441
556
  begin
442
557
  state = ctx.activity.value.raw["state"]
@@ -487,32 +602,80 @@ module Teams
487
602
  end
488
603
  end
489
604
 
605
+ # Resolves the auth-bypass setting like the Python SDK: the explicit
606
+ # option wins, then the deprecated skip_auth alias, then the env var,
607
+ # defaulting to false. Passing skip_auth always warns, even when the
608
+ # new option overrides it.
609
+ def resolve_unauthenticated_requests_option(explicit, skip_auth)
610
+ unless skip_auth.nil?
611
+ warn("skip_auth is deprecated; use dangerously_allow_unauthenticated_requests instead.", uplevel: 2)
612
+ end
613
+ return explicit unless explicit.nil?
614
+ return skip_auth unless skip_auth.nil?
615
+
616
+ parse_bool_env_var(DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR) || false
617
+ end
618
+
619
+ def parse_bool_env_var(name)
620
+ value = ENV[name].to_s.strip.downcase
621
+ return nil if value.empty?
622
+ return true if TRUE_ENV_VALUES.include?(value)
623
+ return false if FALSE_ENV_VALUES.include?(value)
624
+
625
+ raise ArgumentError, "#{name} must be a boolean value: true/false, 1/0, yes/no, or on/off."
626
+ end
627
+
490
628
  # The same two startup warnings the TypeScript, Python, and .NET SDKs
491
629
  # log when no credentials are configured. Settled 2026-07-12: exact
492
- # upstream branches only; credentials-plus-skip_auth stays silent like
630
+ # upstream branches only; credentials-plus-bypass stays silent like
493
631
  # the other SDKs.
494
632
  def warn_missing_credentials
495
633
  return if @token_manager.client_id
496
634
 
497
- if @skip_auth
635
+ if @dangerously_allow_unauthenticated_requests
498
636
  logger&.warn(
499
637
  "No credentials configured (CLIENT_ID / CLIENT_SECRET / TENANT_ID), " \
500
- "but skip_auth is enabled. Bot will accept unauthenticated requests on #{@messaging_endpoint}."
638
+ "but dangerously_allow_unauthenticated_requests is enabled. " \
639
+ "Bot will accept unauthenticated requests on #{@messaging_endpoint}."
501
640
  )
502
641
  else
503
642
  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."
643
+ "No credentials configured and dangerously_allow_unauthenticated_requests is not enabled. " \
644
+ "All incoming requests will be rejected. Configure client authentication to securely receive " \
645
+ "messages, or set dangerously_allow_unauthenticated_requests: true for local development."
646
+ )
647
+ end
648
+ end
649
+
650
+ # Token dispatch for scoped API clients: no identity uses the app
651
+ # token; an identity with an agentic user uses the agentic user token;
652
+ # otherwise the agentic app token. Kinds never fall back to each other -
653
+ # failing is safer than authenticating under the wrong identity.
654
+ def token_for_agentic_identity(identity)
655
+ return @token_manager.bot_token unless identity
656
+
657
+ if identity.agentic_user_id
658
+ @token_manager.agentic_user_token(
659
+ @cloud.agent_bot_scope,
660
+ agentic_app_id: identity.agentic_app_id,
661
+ agentic_user_id: identity.agentic_user_id,
662
+ tenant_id: identity.tenant_id
663
+ )
664
+ else
665
+ @token_manager.agentic_app_token(
666
+ @cloud.agent_bot_scope,
667
+ agentic_app_id: identity.agentic_app_id,
668
+ tenant_id: identity.tenant_id
506
669
  )
507
670
  end
508
671
  end
509
672
 
510
673
  def validate_inbound!(env, activity)
511
- return if @skip_auth
674
+ return if @dangerously_allow_unauthenticated_requests
512
675
 
513
676
  raise AuthenticationError, "CLIENT_ID is required for inbound validation" unless @jwt_validator
514
677
 
515
- @jwt_validator.validate!(env["HTTP_AUTHORIZATION"], service_url: activity.service_url)
678
+ @jwt_validator.validate_inbound_activity!(env["HTTP_AUTHORIZATION"], service_url: activity.service_url)
516
679
  end
517
680
 
518
681
  def run_handlers(context)