Class: Teams::App

Inherits:
Object
  • Object
show all
Defined in:
lib/teams/app.rb

Constant Summary collapse

DEFAULT_MESSAGING_ENDPOINT =
"/api/messages"
DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS_ENV_VAR =
"DANGEROUSLY_ALLOW_UNAUTHENTICATED_REQUESTS"
TRUE_ENV_VALUES =
%w[1 true yes on].freeze
FALSE_ENV_VALUES =
%w[0 false no off].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client_id: ENV["CLIENT_ID"], client_secret: ENV["CLIENT_SECRET"], tenant_id: ENV["TENANT_ID"], service_url: ENV.fetch("SERVICE_URL", "https://smba.trafficmanager.net/teams"), cloud: CloudEnvironments.public, logger: Logger.new($stdout), storage: Storage::MemoryStore.new, api: nil, token_manager: nil, dangerously_allow_unauthenticated_requests: nil, skip_auth: nil, messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT, default_connection_name: "graph") ⇒ App

Returns a new instance of App.



18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
# File 'lib/teams/app.rb', line 18

def initialize(
  client_id: ENV["CLIENT_ID"],
  client_secret: ENV["CLIENT_SECRET"],
  tenant_id: ENV["TENANT_ID"],
  service_url: ENV.fetch("SERVICE_URL", "https://smba.trafficmanager.net/teams"),
  cloud: CloudEnvironments.public,
  logger: Logger.new($stdout),
  storage: Storage::MemoryStore.new,
  api: nil,
  token_manager: nil,
  dangerously_allow_unauthenticated_requests: nil,
  skip_auth: nil,
  messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT,
  default_connection_name: "graph"
)
  @default_connection_name = default_connection_name
  @sign_in_handlers = []
  @error_handlers = []
  @functions = {}
  @logger = logger
  @storage = storage
  @cloud = cloud
  @dangerously_allow_unauthenticated_requests = resolve_unauthenticated_requests_option(
    dangerously_allow_unauthenticated_requests, skip_auth
  )
  @messaging_endpoint = normalize_messaging_endpoint(messaging_endpoint)
  @router = Router.new

  @token_manager = token_manager || Auth::TokenManager.from_env(client_id:, client_secret:, tenant_id:, cloud:)
  @api = api || Api::Client.new(
    service_url:,
    http: Common::HttpClient.new(token: -> { @token_manager.bot_token }),
    logger:,
    oauth_url: cloud.token_service_url
  )
  @jwt_validator = Auth::JwtValidator.new(
    client_id: @token_manager.client_id,
    tenant_id: @token_manager.credentials&.tenant_id,
    cloud:
  ) if @token_manager.client_id

  # Graph base URL derives from the cloud's graph scope host (sovereign
  # clouds), like the TypeScript SDK; the app-identity client requests
  # app-only tokens through the client-credentials flow.
  graph_root = cloud.graph_scope.to_s[%r{\Ahttps?://[^/]+}]
  @graph = Graph::Client.new(
    token: -> { @token_manager.token_for(cloud.graph_scope, @token_manager.credentials&.tenant_id || cloud.) },
    base_url_root: graph_root
  )

  register_default_oauth_handlers
  warn_missing_credentials
end

Instance Attribute Details

#apiObject (readonly)

Returns the value of attribute api.



12
13
14
# File 'lib/teams/app.rb', line 12

def api
  @api
end

#default_connection_nameObject (readonly)

Returns the value of attribute default_connection_name.



12
13
14
# File 'lib/teams/app.rb', line 12

def default_connection_name
  @default_connection_name
end

#graphObject (readonly)

Returns the value of attribute graph.



12
13
14
# File 'lib/teams/app.rb', line 12

def graph
  @graph
end

#loggerObject (readonly)

Returns the value of attribute logger.



12
13
14
# File 'lib/teams/app.rb', line 12

def logger
  @logger
end

#messaging_endpointObject (readonly)

Returns the value of attribute messaging_endpoint.



12
13
14
# File 'lib/teams/app.rb', line 12

def messaging_endpoint
  @messaging_endpoint
end

#storageObject (readonly)

Returns the value of attribute storage.



12
13
14
# File 'lib/teams/app.rb', line 12

def storage
  @storage
end

Instance Method Details

#client_idObject



14
15
16
# File 'lib/teams/app.rb', line 14

def client_id
  @token_manager.client_id
end

#emit_error(error, activity: nil) ⇒ Object



175
176
177
# File 'lib/teams/app.rb', line 175

def emit_error(error, activity: nil)
  @error_handlers.each { |handler| handler.call(error, activity) }
end

#emit_sign_in(context, token_response) ⇒ Object



171
172
173
# File 'lib/teams/app.rb', line 171

def (context, token_response)
  @sign_in_handlers.each { |handler| handler.call(context, token_response) }
end

#initialize!Object



76
77
78
79
# File 'lib/teams/app.rb', line 76

def initialize!
  @token_manager.bot_token
  true
end

#on(name, &block) ⇒ Object



86
87
88
89
# File 'lib/teams/app.rb', line 86

def on(name, &block)
  @router.on(name, &block)
  self
end

#on_card_search(&block) ⇒ Object

application/search invokes from Adaptive Card dynamic typeahead Input.ChoiceSet queries; the handler's return (Api::SearchResponse or hash) becomes the invoke response body.



152
153
154
155
# File 'lib/teams/app.rb', line 152

def on_card_search(&block)
  @router.on_card_search(&block)
  self
end

#on_conversation_update(&block) ⇒ Object

conversationUpdate activities plus their channel/team lifecycle sub-events (on_channel_created, on_team_renamed, ...), routed by channelData.eventType with the Python method names.



226
227
228
229
# File 'lib/teams/app.rb', line 226

def on_conversation_update(&block)
  @router.on_conversation_update(&block)
  self
end

#on_dialog_open(dialog_id = nil, &block) ⇒ Object

Registers a dialog (task module) open handler for task/fetch invokes. With a dialog_id, only invokes whose card action data carries that "dialog_id" value match. The handler's return value (a Api::TaskModuleResponse or hash) becomes the invoke response body.



105
106
107
108
# File 'lib/teams/app.rb', line 105

def on_dialog_open(dialog_id = nil, &block)
  @router.on_dialog_open(dialog_id, &block)
  self
end

#on_dialog_submit(action = nil, &block) ⇒ Object

Registers a dialog (task module) submit handler for task/submit invokes, optionally filtered by the "action" value in the submit data.



112
113
114
115
# File 'lib/teams/app.rb', line 112

def on_dialog_submit(action = nil, &block)
  @router.on_dialog_submit(action, &block)
  self
end

#on_edit_message(&block) ⇒ Object



254
255
256
257
# File 'lib/teams/app.rb', line 254

def on_edit_message(&block)
  @router.on_edit_message(&block)
  self
end

#on_error(&block) ⇒ Object

Called with (error, activity) when a default OAuth handler hits an unexpected failure or the Teams client reports a sign-in failure.



166
167
168
169
# File 'lib/teams/app.rb', line 166

def on_error(&block)
  @error_handlers << block
  self
end

#on_function(name, &block) ⇒ Object

Registers a remote function callable from tabs via POST /api/functions/name. Requests carry an Entra token for the tab user, validated against the app's client id and tenant; the handler receives a FunctionContext and its return value becomes the JSON response body.

Raises:

  • (ArgumentError)


184
185
186
187
188
189
# File 'lib/teams/app.rb', line 184

def on_function(name, &block)
  raise ArgumentError, "handler block is required" unless block

  @functions[name.to_s] = block
  self
end

#on_meeting_end(&block) ⇒ Object



218
219
220
221
# File 'lib/teams/app.rb', line 218

def on_meeting_end(&block)
  @router.on_meeting_end(&block)
  self
end

#on_meeting_start(&block) ⇒ Object

Meeting start/end events (Teams posts them to bots installed in the meeting chat when the meeting begins and ends).



213
214
215
216
# File 'lib/teams/app.rb', line 213

def on_meeting_start(&block)
  @router.on_meeting_start(&block)
  self
end

#on_message(pattern = nil, &block) ⇒ Object



91
92
93
94
# File 'lib/teams/app.rb', line 91

def on_message(pattern = nil, &block)
  @router.on_message(pattern, &block)
  self
end

#on_message_submit(&block) ⇒ Object

message/submitAction invokes; on_message_submit_feedback filters to feedback-loop submissions (thumbs up/down from add_feedback).



139
140
141
142
# File 'lib/teams/app.rb', line 139

def on_message_submit(&block)
  @router.on_message_submit(&block)
  self
end

#on_message_submit_feedback(&block) ⇒ Object



144
145
146
147
# File 'lib/teams/app.rb', line 144

def on_message_submit_feedback(&block)
  @router.on_message_submit_feedback(&block)
  self
end

#on_message_update(&block) ⇒ Object



249
250
251
252
# File 'lib/teams/app.rb', line 249

def on_message_update(&block)
  @router.on_message_update(&block)
  self
end

#on_sign_in(&block) ⇒ Object

Called with (ctx, token_response) whenever a sign-in completes through the default token-exchange or verify-state handlers.



159
160
161
162
# File 'lib/teams/app.rb', line 159

def (&block)
  @sign_in_handlers << block
  self
end

#on_signin_failure(&block) ⇒ Object



132
133
134
135
# File 'lib/teams/app.rb', line 132

def (&block)
  @router.(&block)
  self
end

#on_signin_token_exchange(&block) ⇒ Object

Sign-in invokes: signin/tokenExchange arrives for silent SSO token exchange, signin/verifyState after interactive OAuth card sign-in, signin/failure when the Teams client reports a failed SSO attempt. Default handlers registered at construction complete these flows and fire on_sign_in / on_error; handlers registered here run after them.



122
123
124
125
# File 'lib/teams/app.rb', line 122

def (&block)
  @router.(&block)
  self
end

#on_signin_verify_state(&block) ⇒ Object



127
128
129
130
# File 'lib/teams/app.rb', line 127

def (&block)
  @router.(&block)
  self
end

#on_suggested_action_submit(&block) ⇒ Object



96
97
98
99
# File 'lib/teams/app.rb', line 96

def on_suggested_action_submit(&block)
  @router.on("suggested-action.submit", &block)
  self
end

#on_undelete_message(&block) ⇒ Object



259
260
261
262
# File 'lib/teams/app.rb', line 259

def on_undelete_message(&block)
  @router.on_undelete_message(&block)
  self
end

#post(conversation_id, activity_or_text, service_url: nil) ⇒ Object

The TypeScript, Python, and .NET SDKs call this operation send. Ruby already defines Object#send for dynamic dispatch, so the public Ruby API uses post to avoid shadowing a core language method.



289
290
291
292
293
294
295
296
# File 'lib/teams/app.rb', line 289

def post(conversation_id, activity_or_text, service_url: nil)
  assert_string!(conversation_id, "conversation_id")

  send_activity(
    proactive_reference(conversation_id, service_url:),
    activity_or_text
  )
end

#process_function(name, data, env: {}) ⇒ Object



191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/teams/app.rb', line 191

def process_function(name, data, env: {})
  handler = @functions[name.to_s]
  unless handler
    return Response.new(status: 404, body: { "detail" => "function #{name.inspect} is not registered" })
  end

  context, error = build_function_context(name.to_s, data, env)
  unless context
    logger&.warn("Rejected function call #{name.inspect}: #{error}")
    return Response.new(status: 401, body: { "detail" => error })
  end

  result = handler.call(context)
  body = result.respond_to?(:to_h) ? result.to_h : result
  # Function responses always carry valid JSON: callers fetch and parse
  # them, so a nil handler return becomes an empty object rather than an
  # empty body with a JSON content type.
  Response.new(status: 200, body: body.nil? ? {} : body)
end

#process_inbound(payload, env: {}) ⇒ Object



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
# File 'lib/teams/app.rb', line 264

def process_inbound(payload, env: {})
  activity = Activity.new(payload)
  validate_inbound!(env, activity)

  conversation_reference = Api::ConversationReference.from_activity(activity)
  context = ActivityContext.new(
    app: self,
    activity:,
    conversation_reference:,
    stream: HttpStream.new(app: self, conversation_reference:)
  )
  result = run_handlers(context)
  context.stream.close

  return result if result.is_a?(Response)

  body = activity.invoke? ? invoke_response_body(result) : nil
  Response.new(status: 200, body:)
rescue StreamCancelledError
  Response.new(status: 200)
end

#reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil) ⇒ Object

Proactive threaded replies use a ";messageid=" conversation ID like the TypeScript and Python SDKs; the service decides whether threading applies.



300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/teams/app.rb', line 300

def reply(conversation_id, activity_id_or_activity, activity_or_text = nil, service_url: nil)
  assert_string!(conversation_id, "conversation_id")

  if activity_or_text
    assert_string!(activity_id_or_activity, "activity_id")

    post(
      Teams.to_threaded_conversation_id(conversation_id, activity_id_or_activity),
      activity_or_text,
      service_url:
    )
  else
    post(conversation_id, activity_id_or_activity, service_url:)
  end
end

#send_activity(conversation_reference, activity_or_text) ⇒ Object



325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'lib/teams/app.rb', line 325

def send_activity(conversation_reference, activity_or_text)
  activity = activity_for_reference(conversation_reference, activity_or_text)
  targeted = targeted_activity?(activity)

  if targeted && conversation_reference.conversation.conversation_type == "personal"
    raise ArgumentError, "Targeted messages are not supported in 1:1 (personal) chats."
  end

  id = activity_id(activity)
  conversation_id = conversation_reference.conversation_id
  service_url = conversation_reference.service_url

  response = if id && targeted
    api.conversations.update_targeted_activity(conversation_id, id, activity, service_url:)
  elsif id
    api.conversations.update_activity(conversation_id, id, activity, service_url:)
  elsif targeted
    api.conversations.create_targeted_activity(conversation_id, activity, service_url:)
  else
    api.conversations.create_activity(conversation_id, activity, service_url:)
  end

  Api::SentActivity.merge(activity, response)
end

#to_rackObject



72
73
74
# File 'lib/teams/app.rb', line 72

def to_rack
  RackApp.new(self)
end

#update(conversation_id, activity_id, activity_or_text, service_url: nil) ⇒ Object

Sugar over post: the SDKs update by sending an activity that already carries an id, and post does exactly that. See AGENTS.md.



318
319
320
321
322
323
# File 'lib/teams/app.rb', line 318

def update(conversation_id, activity_id, activity_or_text, service_url: nil)
  assert_string!(conversation_id, "conversation_id")
  assert_string!(activity_id, "activity_id")

  post(conversation_id, activity_with_id(activity_id, activity_or_text), service_url:)
end

#use(&block) ⇒ Object



81
82
83
84
# File 'lib/teams/app.rb', line 81

def use(&block)
  @router.use(&block)
  self
end