Class: Teams::App

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

Constant Summary collapse

DEFAULT_MESSAGING_ENDPOINT =
"/api/messages"

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, skip_auth: false, messaging_endpoint: DEFAULT_MESSAGING_ENDPOINT, default_connection_name: "graph") ⇒ App

Returns a new instance of App.



15
16
17
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
# File 'lib/teams/app.rb', line 15

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,
  skip_auth: false,
  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
  @skip_auth = 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.



9
10
11
# File 'lib/teams/app.rb', line 9

def api
  @api
end

#default_connection_nameObject (readonly)

Returns the value of attribute default_connection_name.



9
10
11
# File 'lib/teams/app.rb', line 9

def default_connection_name
  @default_connection_name
end

#graphObject (readonly)

Returns the value of attribute graph.



9
10
11
# File 'lib/teams/app.rb', line 9

def graph
  @graph
end

#loggerObject (readonly)

Returns the value of attribute logger.



9
10
11
# File 'lib/teams/app.rb', line 9

def logger
  @logger
end

#messaging_endpointObject (readonly)

Returns the value of attribute messaging_endpoint.



9
10
11
# File 'lib/teams/app.rb', line 9

def messaging_endpoint
  @messaging_endpoint
end

#storageObject (readonly)

Returns the value of attribute storage.



9
10
11
# File 'lib/teams/app.rb', line 9

def storage
  @storage
end

Instance Method Details

#client_idObject



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

def client_id
  @token_manager.client_id
end

#emit_error(error, activity: nil) ⇒ Object



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

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

#emit_sign_in(context, token_response) ⇒ Object



157
158
159
# File 'lib/teams/app.rb', line 157

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

#initialize!Object



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

def initialize!
  @token_manager.bot_token
  true
end

#on(name, &block) ⇒ Object



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

def on(name, &block)
  @router.on(name, &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.



99
100
101
102
# File 'lib/teams/app.rb', line 99

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.



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

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

#on_edit_message(&block) ⇒ Object



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

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.



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

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)


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

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



204
205
206
207
# File 'lib/teams/app.rb', line 204

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).



199
200
201
202
# File 'lib/teams/app.rb', line 199

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

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



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

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).



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

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

#on_message_submit_feedback(&block) ⇒ Object



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

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

#on_message_update(&block) ⇒ Object



220
221
222
223
# File 'lib/teams/app.rb', line 220

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.



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

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

#on_signin_failure(&block) ⇒ Object



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

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.



116
117
118
119
# File 'lib/teams/app.rb', line 116

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

#on_signin_verify_state(&block) ⇒ Object



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

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

#on_suggested_action_submit(&block) ⇒ Object



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

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

#on_undelete_message(&block) ⇒ Object



230
231
232
233
# File 'lib/teams/app.rb', line 230

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.



260
261
262
263
264
265
266
267
# File 'lib/teams/app.rb', line 260

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



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/teams/app.rb', line 177

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



235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/teams/app.rb', line 235

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.



271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
# File 'lib/teams/app.rb', line 271

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



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
# File 'lib/teams/app.rb', line 296

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



66
67
68
# File 'lib/teams/app.rb', line 66

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.



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

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



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

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