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.
@@ -1,56 +1,117 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "json"
5
- require "openssl"
3
+ require "jwt"
6
4
 
7
5
  module Teams
8
6
  module Auth
9
7
  class JwtValidator
8
+ ENTRA_V1_ISSUER_PREFIX = "https://sts.windows.net/"
9
+ JWKS_CACHE_TTL = 10 * 60
10
+ JWKS_CACHE_MAX_ENTRIES = 5
11
+ # Endpoints are bounded so random tenant ids in unverified inbound
12
+ # tokens cannot grow the JWKS caches forever, like the
13
+ # TypeScript/Python validators.
14
+ MAX_JWKS_ENDPOINTS = 100
15
+ CLOCK_TOLERANCE = 5 * 60
16
+
10
17
  def initialize(client_id:, tenant_id: nil, cloud: PUBLIC_CLOUD, http: nil)
11
18
  @client_id = client_id
12
19
  @tenant_id = tenant_id
13
20
  @cloud = cloud
14
21
  @http = http || Common::HttpClient.new
15
22
  @jwks = {}
16
- # The validator is shared across request threads; the mutex keeps
17
- # concurrent cold-cache requests from fetching the JWKS repeatedly.
18
23
  @jwks_mutex = Mutex.new
24
+ # Coordinate misses per endpoint without blocking cached-key reads.
25
+ @jwks_fetch_mutexes = {}
26
+ @discovery_mutex = Mutex.new
19
27
  end
20
28
 
21
29
  def validate!(authorization_header, service_url: nil)
30
+ token = bearer_token!(authorization_header)
31
+ payload = decode!(token, issuers: valid_issuers) do |header, claims|
32
+ signing_key(jwks_uri_for_issuer(claims["iss"]), header["kid"])
33
+ end
34
+ validate_service_url!(payload, service_url) if service_url
35
+ payload
36
+ end
37
+
38
+ # Validates an inbound activity token. Classic bot activities carry
39
+ # Bot Framework service tokens and validate exactly like validate!;
40
+ # Agent 365 activities carry Entra tokens audienced to the agentic app
41
+ # blueprint, validated against the token's own tenant. The Entra path
42
+ # skips the serviceurl claim: agentic inbound tokens do not carry it
43
+ # (upstream revisits once the platform defines a signed equivalent).
44
+ def validate_inbound_activity!(authorization_header, service_url: nil)
45
+ token = bearer_token!(authorization_header)
46
+ claims = unverified_claims(token)
47
+ return validate_entra_inbound!(token, claims) if entra_issuer?(claims["iss"])
48
+
49
+ payload = decode!(token, issuers: valid_issuers) do |header, decoded|
50
+ signing_key(jwks_uri_for_issuer(decoded["iss"]), header["kid"])
51
+ end
52
+ validate_service_url!(payload, service_url) if service_url
53
+ payload
54
+ end
55
+
56
+ private
57
+
58
+ def bearer_token!(authorization_header)
22
59
  raise AuthenticationError, "Authorization header is required" if authorization_header.to_s.empty?
23
60
 
24
61
  scheme, token = authorization_header.split(" ", 2)
25
62
  raise AuthenticationError, "Authorization must be Bearer" unless scheme&.casecmp("Bearer")&.zero? && token
26
63
 
27
- header, payload, signing_input, signature = decode(token)
28
- validate_claims!(payload)
29
- validate_service_url!(payload, service_url) if service_url
30
- verify_signature!(header, signing_input, signature)
64
+ token
65
+ end
66
+
67
+ def decode!(token, issuers:)
68
+ payload, = JWT.decode(
69
+ token, nil, true,
70
+ algorithms: ["RS256"], leeway: CLOCK_TOLERANCE,
71
+ verify_expiration: true, verify_not_before: true,
72
+ verify_iss: true, iss: issuers,
73
+ verify_aud: true, aud: valid_audiences
74
+ ) do |header, claims|
75
+ raise AuthenticationError, "JWT payload must be an object" unless claims.is_a?(Hash)
76
+ unless header["kid"].nil? || header["kid"].is_a?(String)
77
+ raise AuthenticationError, "JWT signing key ID is invalid"
78
+ end
79
+
80
+ yield(header, claims)
81
+ end
31
82
  payload
83
+ rescue JWT::DecodeError => error
84
+ raise AuthenticationError, "JWT validation failed: #{error.message}"
32
85
  end
33
86
 
34
- private
87
+ def unverified_claims(token)
88
+ claims, = JWT.decode(token, nil, false)
89
+ claims.is_a?(Hash) ? claims : {}
90
+ rescue JWT::DecodeError => error
91
+ raise AuthenticationError, "JWT validation failed: #{error.message}"
92
+ end
35
93
 
36
- def decode(token)
37
- header_segment, payload_segment, signature_segment = token.split(".")
38
- raise AuthenticationError, "JWT must contain three segments" unless signature_segment
94
+ def entra_issuer?(issuer)
95
+ return false unless issuer.is_a?(String)
39
96
 
40
- header = JSON.parse(Base64.urlsafe_decode64(pad(header_segment)))
41
- payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
42
- signature = Base64.urlsafe_decode64(pad(signature_segment))
43
- [header, payload, "#{header_segment}.#{payload_segment}", signature]
44
- rescue JSON::ParserError, ArgumentError
45
- raise AuthenticationError, "JWT is malformed"
97
+ issuer.start_with?("#{@cloud.login_endpoint}/") || issuer.start_with?(ENTRA_V1_ISSUER_PREFIX)
46
98
  end
47
99
 
48
- def validate_claims!(payload)
49
- now = Time.now.to_i
50
- raise AuthenticationError, "JWT expired" if payload["exp"] && now >= payload["exp"].to_i
51
- raise AuthenticationError, "JWT not active yet" if payload["nbf"] && now < payload["nbf"].to_i
52
- raise AuthenticationError, "JWT issuer is invalid" unless valid_issuer?(payload["iss"])
53
- raise AuthenticationError, "JWT audience is invalid" if (Array(payload["aud"]) & valid_audiences).empty?
100
+ def validate_entra_inbound!(token, claims)
101
+ tenant_id = claims["tid"]
102
+ raise AuthenticationError, "Entra inbound token is missing tid" if tenant_id.to_s.empty?
103
+
104
+ issuers = ["#{@cloud.login_endpoint}/#{tenant_id}/v2.0", "#{ENTRA_V1_ISSUER_PREFIX}#{tenant_id}/"]
105
+ # Cheap claim checks run first so junk inbound tokens cannot trigger
106
+ # JWKS fetches for arbitrary tenants; decode! re-verifies both after
107
+ # the signature.
108
+ raise AuthenticationError, "JWT issuer is invalid" unless issuers.include?(claims["iss"])
109
+ raise AuthenticationError, "JWT audience is invalid" if (Array(claims["aud"]) & valid_audiences).empty?
110
+
111
+ uri = entra_tenant_jwks_uri(tenant_id)
112
+ decode!(token, issuers: issuers) do |header, _decoded|
113
+ signing_key(uri, header["kid"])
114
+ end
54
115
  end
55
116
 
56
117
  # Inbound tokens may be audienced as the bare app id, api://{appId},
@@ -71,77 +132,121 @@ module Teams
71
132
  raise AuthenticationError, "Service URL mismatch. Token: #{normalized_token_url}, Expected: #{normalized_expected_url}"
72
133
  end
73
134
 
74
- def verify_signature!(header, signing_input, signature)
75
- raise AuthenticationError, "only RS256 JWTs are supported" unless header["alg"] == "RS256"
135
+ def signing_key(uri, kid)
136
+ unless kid.nil? || kid.is_a?(String)
137
+ raise AuthenticationError, "JWT signing key ID is invalid"
138
+ end
76
139
 
77
- key = jwks_for_issuer(signing_input).fetch("keys").find { |candidate| candidate["kid"] == header["kid"] }
78
- raise AuthenticationError, "JWT signing key was not found" unless key
140
+ cached = cached_signing_key(uri, kid)
141
+ return cached if cached
79
142
 
80
- public_key = rsa_public_key(key)
81
- unless public_key.verify(OpenSSL::Digest.new("SHA256"), signature, signing_input)
82
- raise AuthenticationError, "JWT signature is invalid"
143
+ fetch_mutex = @jwks_mutex.synchronize do
144
+ mutex = @jwks_fetch_mutexes[uri]
145
+ unless mutex
146
+ @jwks_fetch_mutexes.shift while @jwks_fetch_mutexes.length >= MAX_JWKS_ENDPOINTS
147
+ mutex = @jwks_fetch_mutexes[uri] = Mutex.new
148
+ end
149
+ mutex
150
+ end
151
+ fetch_mutex.synchronize do
152
+ cached = cached_signing_key(uri, kid)
153
+ return cached if cached
154
+
155
+ key = fetch_signing_key(uri, kid)
156
+ @jwks_mutex.synchronize do
157
+ cache = endpoint_cache(uri)
158
+ cache.shift if cache.size >= JWKS_CACHE_MAX_ENTRIES
159
+ cache[kid] = { key:, expires_at: monotonic_time + JWKS_CACHE_TTL }
160
+ end
161
+ key
83
162
  end
84
163
  end
85
164
 
86
- def jwks_for_issuer(signing_input)
87
- _header_segment, payload_segment = signing_input.split(".", 2)
88
- payload = JSON.parse(Base64.urlsafe_decode64(pad(payload_segment)))
89
- uri = jwks_uri_for_issuer(payload["iss"])
165
+ def cached_signing_key(uri, kid)
166
+ @jwks_mutex.synchronize do
167
+ cache = endpoint_cache(uri)
168
+ cached = cache.delete(kid)
169
+ if cached && monotonic_time < cached[:expires_at]
170
+ cache[kid] = cached
171
+ cached[:key]
172
+ end
173
+ end
174
+ end
175
+
176
+ # Must be called while holding @jwks_mutex.
177
+ def endpoint_cache(uri)
178
+ cache = @jwks[uri]
179
+ unless cache
180
+ @jwks.shift while @jwks.length >= MAX_JWKS_ENDPOINTS
181
+ cache = @jwks[uri] = {}
182
+ end
183
+ cache
184
+ end
185
+
186
+ def fetch_signing_key(uri, kid)
187
+ document = @http.get(uri)
188
+ unless document.is_a?(Hash) && document["keys"].is_a?(Array)
189
+ raise AuthenticationError, "JWT signing keys response is invalid"
190
+ end
191
+
192
+ keys = document["keys"].select do |key|
193
+ key.is_a?(Hash) && key["kty"] == "RSA" && [nil, "sig"].include?(key["use"])
194
+ end
195
+ if kid.nil? && keys.size != 1
196
+ raise AuthenticationError, "JWT signing key was not found"
197
+ end
198
+ key = kid.nil? ? keys.first : keys.find { |candidate| candidate["kid"] == kid }
199
+ raise AuthenticationError, "JWT signing key was not found" unless key
90
200
 
91
- @jwks_mutex.synchronize { @jwks[uri] ||= @http.get(uri) }
92
- rescue JSON::ParserError, ArgumentError
93
- raise AuthenticationError, "JWT is malformed"
201
+ unless [key["n"], key["e"]].all? { |value| value.is_a?(String) && !value.empty? }
202
+ raise AuthenticationError, "JWT signing key is invalid"
203
+ end
204
+
205
+ JWT::JWK.import(key).verify_key
206
+ rescue HttpError, Faraday::Error, JWT::DecodeError, ArgumentError, OpenSSL::PKey::PKeyError, OpenSSL::ASN1::ASN1Error => error
207
+ raise AuthenticationError, "Unable to retrieve JWT signing key: #{error.message}"
94
208
  end
95
209
 
96
210
  def jwks_uri_for_issuer(issuer)
97
- return bot_framework_jwks_uri if issuer == @cloud.token_issuer
98
- return entra_jwks_uri if @tenant_id && tenant_issuer?(issuer)
211
+ raise AuthenticationError, "JWT issuer is invalid" unless valid_issuers.include?(issuer)
99
212
 
100
- bot_framework_jwks_uri
213
+ issuer == @cloud.token_issuer ? bot_framework_jwks_uri : entra_tenant_jwks_uri(@tenant_id)
101
214
  end
102
215
 
103
216
  def bot_framework_jwks_uri
104
- @jwks_mutex.synchronize do
217
+ @discovery_mutex.synchronize do
105
218
  @bot_framework_jwks_uri ||= begin
106
219
  metadata = @http.get(@cloud.open_id_metadata_url)
107
- metadata.fetch("jwks_uri")
220
+ unless metadata.is_a?(Hash) && metadata["jwks_uri"].is_a?(String) && !metadata["jwks_uri"].empty?
221
+ raise AuthenticationError, "JWT signing keys metadata is invalid"
222
+ end
223
+
224
+ metadata["jwks_uri"]
108
225
  end
109
226
  end
227
+ rescue HttpError, Faraday::Error => error
228
+ raise AuthenticationError, "Unable to retrieve JWT signing keys metadata: #{error.message}"
110
229
  end
111
230
 
112
- def entra_jwks_uri
113
- "#{@cloud.login_endpoint}/#{@tenant_id}/discovery/v2.0/keys"
231
+ def entra_tenant_jwks_uri(tenant_id)
232
+ "#{@cloud.login_endpoint}/#{tenant_id}/discovery/v2.0/keys"
114
233
  end
115
234
 
116
- def valid_issuer?(issuer)
117
- return true if issuer == @cloud.token_issuer
118
- return false unless @tenant_id
119
-
120
- tenant_issuer?(issuer)
121
- end
235
+ def valid_issuers
236
+ issuers = [@cloud.token_issuer]
237
+ if @tenant_id
238
+ issuers.concat(["#{@cloud.login_endpoint}/#{@tenant_id}/v2.0", "#{ENTRA_V1_ISSUER_PREFIX}#{@tenant_id}/"])
239
+ end
122
240
 
123
- def tenant_issuer?(issuer)
124
- issuer == "#{@cloud.login_endpoint}/#{@tenant_id}/v2.0" ||
125
- issuer == "https://sts.windows.net/#{@tenant_id}/"
241
+ issuers
126
242
  end
127
243
 
128
244
  def normalize_url(value)
129
245
  value.to_s.sub(%r{/+\z}, "").downcase
130
246
  end
131
247
 
132
- def rsa_public_key(jwk)
133
- n = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk.fetch("n"))), 2)
134
- e = OpenSSL::BN.new(Base64.urlsafe_decode64(pad(jwk.fetch("e"))), 2)
135
-
136
- sequence = OpenSSL::ASN1::Sequence([
137
- OpenSSL::ASN1::Integer(n),
138
- OpenSSL::ASN1::Integer(e)
139
- ])
140
- OpenSSL::PKey::RSA.new(sequence.to_der)
141
- end
142
-
143
- def pad(segment)
144
- segment + ("=" * ((4 - segment.length % 4) % 4))
248
+ def monotonic_time
249
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
145
250
  end
146
251
  end
147
252
  end
@@ -5,6 +5,10 @@ require "uri"
5
5
  module Teams
6
6
  module Auth
7
7
  class TokenManager
8
+ TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default"
9
+ CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
10
+ JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"
11
+
8
12
  attr_reader :credentials, :cloud, :http
9
13
 
10
14
  def initialize(credentials:, cloud: PUBLIC_CLOUD, http: nil)
@@ -63,6 +67,134 @@ module Teams
63
67
  access_token
64
68
  end
65
69
  end
70
+
71
+ # Agent 365 tokens use a three-step exchange (the other SDKs delegate
72
+ # it to MSAL; Ruby implements the grants directly):
73
+ # 1. client-credentials with fmi_path=<agenticAppId> for the token
74
+ # exchange scope - the blueprint assertion.
75
+ # 2. client-credentials as client_id=<agenticAppId>, authenticated
76
+ # by the step-1 assertion - the agentic app token.
77
+ # 3. the user federated-identity grant (requested_token_use
78
+ # on_behalf_of, user_object_id=<agenticUserId>), asserted by a
79
+ # step-2 exchange token - the agentic user token.
80
+ # Steps 1 and 3 reproduce MSAL-internal wire forms from its documented
81
+ # parameters.
82
+ # There is deliberately no app-token fallback: failing is safer than
83
+ # authenticating under the wrong identity, like the other SDKs.
84
+ def agentic_app_token(scope, agentic_app_id:, tenant_id: nil)
85
+ tenant = resolve_agentic_tenant!(tenant_id)
86
+ raise ArgumentError, "agentic_app_id is required for agentic tokens" if agentic_app_id.to_s.empty?
87
+
88
+ @mutex.synchronize do
89
+ cached_token([:agentic_app, scope, agentic_app_id, tenant]) do
90
+ acquire_agentic_app_token(scope, agentic_app_id, tenant)
91
+ end
92
+ end
93
+ end
94
+
95
+ def agentic_user_token(scope, agentic_app_id:, agentic_user_id:, tenant_id: nil)
96
+ tenant = resolve_agentic_tenant!(tenant_id)
97
+ raise ArgumentError, "agentic_app_id is required for agentic tokens" if agentic_app_id.to_s.empty?
98
+ raise ArgumentError, "agentic_user_id is required for agentic user tokens" if agentic_user_id.to_s.empty?
99
+
100
+ @mutex.synchronize do
101
+ cached_token([:agentic_user, scope, agentic_app_id, agentic_user_id, tenant]) do
102
+ exchange_token = acquire_agentic_app_token(TOKEN_EXCHANGE_SCOPE, agentic_app_id, tenant)
103
+ response = http.post(
104
+ token_url(tenant),
105
+ body: URI.encode_www_form(
106
+ grant_type: JWT_BEARER_GRANT_TYPE,
107
+ client_id: agentic_app_id,
108
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
109
+ client_assertion: blueprint_assertion(agentic_app_id, tenant),
110
+ assertion: exchange_token,
111
+ requested_token_use: "on_behalf_of",
112
+ user_object_id: agentic_user_id,
113
+ scope:
114
+ ),
115
+ headers: FORM_HEADERS
116
+ )
117
+ fetch_access_token!(response, "Agent token exchange step 3 failed")
118
+ end
119
+ end
120
+ end
121
+
122
+ private
123
+
124
+ FORM_HEADERS = { "Content-Type" => "application/x-www-form-urlencoded" }.freeze
125
+
126
+ def token_url(tenant_id)
127
+ "#{cloud.login_endpoint}/#{tenant_id}/oauth2/v2.0/token"
128
+ end
129
+
130
+ # Agentic tenant resolution is strict: the identity's tenant or the
131
+ # configured credentials tenant, never the shared login tenant.
132
+ def resolve_agentic_tenant!(tenant_id)
133
+ raise ConfigurationError, "CLIENT_ID and CLIENT_SECRET are required" unless credentials
134
+
135
+ tenant = tenant_id || credentials.tenant_id
136
+ raise ArgumentError, "tenant_id is required for agentic tokens" if tenant.to_s.empty?
137
+
138
+ tenant
139
+ end
140
+
141
+ def cached_token(key)
142
+ cached = @tokens[key]
143
+ return cached.value if cached && !cached.expired?
144
+
145
+ token = yield
146
+ @tokens[key] = Token.new(token)
147
+ token
148
+ end
149
+
150
+ # Step 1: the blueprint assertion - the app's own client-credentials
151
+ # grant carrying MSAL's federated-managed-identity path parameter.
152
+ # Cached like any token so multi-step flows reuse it while valid.
153
+ def blueprint_assertion(agentic_app_id, tenant_id)
154
+ cached_token([:blueprint_assertion, agentic_app_id, tenant_id]) do
155
+ request_blueprint_assertion(agentic_app_id, tenant_id)
156
+ end
157
+ end
158
+
159
+ def request_blueprint_assertion(agentic_app_id, tenant_id)
160
+ response = http.post(
161
+ token_url(tenant_id),
162
+ body: URI.encode_www_form(
163
+ grant_type: "client_credentials",
164
+ client_id: credentials.client_id,
165
+ client_secret: credentials.client_secret,
166
+ scope: TOKEN_EXCHANGE_SCOPE,
167
+ fmi_path: agentic_app_id
168
+ ),
169
+ headers: FORM_HEADERS
170
+ )
171
+ fetch_access_token!(response, "Agent token exchange step 1 failed")
172
+ end
173
+
174
+ # Step 2: client-credentials as the agentic app, authenticated by the
175
+ # step-1 assertion instead of a secret.
176
+ def acquire_agentic_app_token(scope, agentic_app_id, tenant_id)
177
+ response = http.post(
178
+ token_url(tenant_id),
179
+ body: URI.encode_www_form(
180
+ grant_type: "client_credentials",
181
+ client_id: agentic_app_id,
182
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
183
+ client_assertion: blueprint_assertion(agentic_app_id, tenant_id),
184
+ scope:
185
+ ),
186
+ headers: FORM_HEADERS
187
+ )
188
+ fetch_access_token!(response, "Agent token exchange step 2 failed")
189
+ end
190
+
191
+ def fetch_access_token!(response, error_prefix)
192
+ token = response["access_token"] if response.is_a?(Hash)
193
+ return token unless token.to_s.empty?
194
+
195
+ detail = response.is_a?(Hash) ? response["error_description"] || response["error"] : nil
196
+ raise Error, "#{error_prefix}: #{detail || "could not acquire token"}"
197
+ end
66
198
  end
67
199
  end
68
200
  end
@@ -9,6 +9,7 @@ module Teams
9
9
  :open_id_metadata_url,
10
10
  :token_issuer,
11
11
  :graph_scope,
12
+ :agent_bot_scope,
12
13
  keyword_init: true
13
14
  )
14
15
 
@@ -19,7 +20,10 @@ module Teams
19
20
  token_service_url: "https://token.botframework.com",
20
21
  open_id_metadata_url: "https://login.botframework.com/v1/.well-known/openidconfiguration",
21
22
  token_issuer: "https://api.botframework.com",
22
- graph_scope: "https://graph.microsoft.com/.default"
23
+ graph_scope: "https://graph.microsoft.com/.default",
24
+ # Agent ID Bot API scope; sovereign-cloud values are unconfirmed
25
+ # upstream too.
26
+ agent_bot_scope: "https://botapi.skype.com/.default"
23
27
  )
24
28
 
25
29
  module CloudEnvironments
@@ -14,9 +14,11 @@ module Teams
14
14
 
15
15
  attr_reader :app, :conversation_reference
16
16
 
17
- def initialize(app:, conversation_reference:)
17
+ def initialize(app:, conversation_reference:, api: nil)
18
18
  @app = app
19
19
  @conversation_reference = conversation_reference
20
+ # Scoped to the turn's agentic identity when the app runs as an agent.
21
+ @api = api || app.api
20
22
  @mutex = Mutex.new
21
23
  @flusher = nil
22
24
  @flushing = false
@@ -356,18 +358,21 @@ module Teams
356
358
  body = activity.dup
357
359
  body["from"] = conversation_reference.bot.to_h if conversation_reference.bot
358
360
  body["conversation"] = conversation_reference.conversation.to_h
361
+ # Every streamed activity replies to the inbound message, restoring
362
+ # Bot Framework v1 threading behavior.
363
+ body["replyToId"] = conversation_reference.activity_id if conversation_reference.activity_id
359
364
 
360
365
  # Stream chunks and the streamed final carry a streaminfo entity and are
361
366
  # always created; only the timed-out in-place final routes through update.
362
367
  if body["id"] && !streaminfo_entity?(body)
363
- app.api.conversations.update_activity(
368
+ @api.conversations.update_activity(
364
369
  conversation_reference.conversation_id,
365
370
  body["id"],
366
371
  body,
367
372
  service_url: conversation_reference.service_url
368
373
  )
369
374
  else
370
- app.api.conversations.create_activity(
375
+ @api.conversations.create_activity(
371
376
  conversation_reference.conversation_id,
372
377
  body,
373
378
  service_url: conversation_reference.service_url
data/lib/teams/router.rb CHANGED
@@ -72,6 +72,12 @@ module Teams
72
72
  register("message.submit", invoke_selector("message/submitAction"), &block)
73
73
  end
74
74
 
75
+ # application/search invokes - Adaptive Card dynamic typeahead
76
+ # Input.ChoiceSet queries (choices.data / Data.Query).
77
+ def on_card_search(&block)
78
+ register("card.search", invoke_selector("application/search"), &block)
79
+ end
80
+
75
81
  # message/submitAction invokes whose actionName is "feedback" - the
76
82
  # submissions from add_feedback's thumbs up/down UI.
77
83
  def on_message_submit_feedback(&block)
@@ -90,6 +96,57 @@ module Teams
90
96
  register("meeting_end", event_selector("application/vnd.microsoft.meetingEnd"), &block)
91
97
  end
92
98
 
99
+ # Agent 365 agentLifecycle events (type "event", name "agentLifecycle"),
100
+ # discriminated by the activity-level valueType, with the Python method
101
+ # names. The PascalCase valueType literals are service-owned wire values
102
+ # shared by all three SDKs.
103
+ def on_agent_lifecycle(&block)
104
+ register("agent_lifecycle", agent_lifecycle_selector(nil), &block)
105
+ end
106
+
107
+ AGENT_LIFECYCLE_EVENTS = {
108
+ "on_agentic_user_identity_created" => "AgenticUserIdentityCreated",
109
+ "on_agentic_user_identity_updated" => "AgenticUserIdentityUpdated",
110
+ "on_agentic_user_manager_updated" => "AgenticUserManagerUpdated",
111
+ "on_agentic_user_enabled" => "AgenticUserEnabled",
112
+ "on_agentic_user_disabled" => "AgenticUserDisabled",
113
+ "on_agentic_user_deleted" => "AgenticUserDeleted",
114
+ "on_agentic_user_undeleted" => "AgenticUserUndeleted",
115
+ "on_agentic_user_workload_onboarding_updated" => "AgenticUserWorkloadOnboardingUpdated"
116
+ }.freeze
117
+
118
+ AGENT_LIFECYCLE_EVENTS.each do |method_name, value_type|
119
+ define_method(method_name) do |&block|
120
+ register(method_name.delete_prefix("on_"), agent_lifecycle_selector(value_type), &block)
121
+ end
122
+ end
123
+
124
+ # conversationUpdate activities and their channel/team lifecycle
125
+ # sub-events, routed by channelData.eventType with the Python method
126
+ # names (the eventType literals are shared by all three SDKs).
127
+ def on_conversation_update(&block)
128
+ register("conversation_update", ->(activity) { activity.type == "conversationUpdate" }, &block)
129
+ end
130
+
131
+ CONVERSATION_UPDATE_EVENTS = {
132
+ "on_channel_created" => "channelCreated",
133
+ "on_channel_deleted" => "channelDeleted",
134
+ "on_channel_renamed" => "channelRenamed",
135
+ "on_channel_restored" => "channelRestored",
136
+ "on_team_archived" => "teamArchived",
137
+ "on_team_deleted" => "teamDeleted",
138
+ "on_team_hard_deleted" => "teamHardDeleted",
139
+ "on_team_renamed" => "teamRenamed",
140
+ "on_team_restored" => "teamRestored",
141
+ "on_team_unarchived" => "teamUnarchived"
142
+ }.freeze
143
+
144
+ CONVERSATION_UPDATE_EVENTS.each do |method_name, event_type|
145
+ define_method(method_name) do |&block|
146
+ register(event_type, conversation_update_selector(event_type), &block)
147
+ end
148
+ end
149
+
93
150
  # Message extension (compose extension) invoke routes, using the same
94
151
  # route names as the TypeScript and Python SDKs.
95
152
  MESSAGE_EXTENSION_ROUTES = {
@@ -152,6 +209,20 @@ module Teams
152
209
  ->(activity) { activity.type == "event" && activity.name == event_name }
153
210
  end
154
211
 
212
+ def conversation_update_selector(event_type)
213
+ lambda do |activity|
214
+ activity.type == "conversationUpdate" && activity.channel_data.event_type == event_type
215
+ end
216
+ end
217
+
218
+ def agent_lifecycle_selector(value_type)
219
+ lambda do |activity|
220
+ next false unless activity.type == "event" && activity.name == "agentLifecycle"
221
+
222
+ value_type.nil? || activity.value_type == value_type
223
+ end
224
+ end
225
+
155
226
  def dialog_selector(invoke_name, key, expected)
156
227
  lambda do |activity|
157
228
  next false unless activity.invoke? && activity.name == invoke_name
data/lib/teams/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Teams
4
- VERSION = "2.0.1"
4
+ VERSION = "2.0.3"
5
5
  end
data/lib/teams.rb CHANGED
@@ -8,10 +8,12 @@ require_relative "teams/cloud_environment"
8
8
  require_relative "teams/cards"
9
9
  require_relative "teams/cards/generated"
10
10
  require_relative "teams/api/model"
11
+ require_relative "teams/api/agentic_identity"
11
12
  require_relative "teams/api/account"
12
13
  require_relative "teams/api/conversation_account"
13
14
  require_relative "teams/api/conversation_resource"
14
15
  require_relative "teams/api/paged_members_result"
16
+ require_relative "teams/api/app_info"
15
17
  require_relative "teams/api/tenant_info"
16
18
  require_relative "teams/api/team_info"
17
19
  require_relative "teams/api/team_details"
@@ -27,6 +29,7 @@ require_relative "teams/api/sent_activity"
27
29
  require_relative "teams/api/citation_appearance"
28
30
  require_relative "teams/api/task_module"
29
31
  require_relative "teams/api/message_extension"
32
+ require_relative "teams/api/search"
30
33
  require_relative "teams/activity"
31
34
  require_relative "teams/activity_context"
32
35
  require_relative "teams/function_context"