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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE +21 -0
- data/README.md +500 -0
- data/docs/README.md +40 -0
- data/docs/essentials/README.md +13 -0
- data/docs/essentials/api-client.md +62 -0
- data/docs/essentials/app-authentication.md +27 -0
- data/docs/essentials/app-basics.md +50 -0
- data/docs/essentials/graph.md +41 -0
- data/docs/essentials/on-activity.md +72 -0
- data/docs/essentials/on-event.md +29 -0
- data/docs/essentials/proactive-messaging.md +44 -0
- data/docs/essentials/sending-messages.md +76 -0
- data/docs/essentials/sovereign-cloud.md +26 -0
- data/docs/getting-started/README.md +7 -0
- data/docs/getting-started/code-basics.md +61 -0
- data/docs/getting-started/quickstart.md +54 -0
- data/docs/getting-started/running-in-teams.md +44 -0
- data/docs/in-depth-guides/README.md +14 -0
- data/docs/in-depth-guides/adaptive-cards.md +62 -0
- data/docs/in-depth-guides/dialogs.md +77 -0
- data/docs/in-depth-guides/feedback.md +29 -0
- data/docs/in-depth-guides/meeting-events.md +27 -0
- data/docs/in-depth-guides/message-extensions.md +77 -0
- data/docs/in-depth-guides/message-reactions.md +29 -0
- data/docs/in-depth-guides/observability.md +28 -0
- data/docs/in-depth-guides/streaming.md +52 -0
- data/docs/in-depth-guides/tabs.md +59 -0
- data/docs/in-depth-guides/user-authentication.md +60 -0
- data/lib/teams/activity.rb +160 -0
- data/lib/teams/activity_context.rb +278 -0
- data/lib/teams/api/account.rb +90 -0
- data/lib/teams/api/activity_value.rb +49 -0
- data/lib/teams/api/bot_sign_in_client.rb +54 -0
- data/lib/teams/api/channel_data.rb +86 -0
- data/lib/teams/api/channel_info.rb +19 -0
- data/lib/teams/api/citation_appearance.rb +83 -0
- data/lib/teams/api/client.rb +28 -0
- data/lib/teams/api/conversation_account.rb +40 -0
- data/lib/teams/api/conversation_client.rb +156 -0
- data/lib/teams/api/conversation_reference.rb +83 -0
- data/lib/teams/api/conversation_resource.rb +19 -0
- data/lib/teams/api/meeting_client.rb +57 -0
- data/lib/teams/api/meeting_info.rb +26 -0
- data/lib/teams/api/meeting_notification_response.rb +29 -0
- data/lib/teams/api/meeting_participant.rb +33 -0
- data/lib/teams/api/message_activity.rb +201 -0
- data/lib/teams/api/message_extension.rb +110 -0
- data/lib/teams/api/model.rb +49 -0
- data/lib/teams/api/notification_info.rb +28 -0
- data/lib/teams/api/paged_members_result.rb +16 -0
- data/lib/teams/api/quoted_reply_entity.rb +65 -0
- data/lib/teams/api/reaction_client.rb +34 -0
- data/lib/teams/api/sent_activity.rb +48 -0
- data/lib/teams/api/task_module.rb +83 -0
- data/lib/teams/api/team_client.rb +45 -0
- data/lib/teams/api/team_details.rb +36 -0
- data/lib/teams/api/team_info.rb +44 -0
- data/lib/teams/api/tenant_info.rb +11 -0
- data/lib/teams/api/token.rb +81 -0
- data/lib/teams/api/typing_activity.rb +19 -0
- data/lib/teams/api/user_client.rb +83 -0
- data/lib/teams/app.rb +602 -0
- data/lib/teams/auth/client_secret_credentials.rb +7 -0
- data/lib/teams/auth/jwt_validator.rb +148 -0
- data/lib/teams/auth/token.rb +39 -0
- data/lib/teams/auth/token_manager.rb +68 -0
- data/lib/teams/cards/generated.rb +1764 -0
- data/lib/teams/cards/generated_base.rb +105 -0
- data/lib/teams/cards.rb +81 -0
- data/lib/teams/cloud_environment.rb +41 -0
- data/lib/teams/common/hashes.rb +20 -0
- data/lib/teams/common/http_client.rb +111 -0
- data/lib/teams/common/retry.rb +31 -0
- data/lib/teams/errors.rb +50 -0
- data/lib/teams/function_context.rb +82 -0
- data/lib/teams/graph/client.rb +73 -0
- data/lib/teams/http_stream.rb +460 -0
- data/lib/teams/rack_app.rb +62 -0
- data/lib/teams/response.rb +9 -0
- data/lib/teams/router.rb +171 -0
- data/lib/teams/storage/memory_store.rb +27 -0
- data/lib/teams/version.rb +5 -0
- data/lib/teams.rb +70 -0
- 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
|