8000 Merge pull request #1038 from microsoft/trboehre/expectReplies · ericmicrofocus/botbuilder-python@3b3e7cd · GitHub
[go: up one dir, main page]

Skip to content

Commit 3b3e7cd

Browse files
authored
Merge pull request microsoft#1038 from microsoft/trboehre/expectReplies
Support SSO with skill dialog and expected replies
2 parents 8b18ba4 + ea97656 commit 3b3e7cd

File tree

7 files changed

+450
-62
lines changed

7 files changed

+450
-62
lines changed

libraries/botbuilder-core/botbuilder/core/adapters/test_adapter.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ def __init__(self, key: UserToken = None, magic_code: str = None):
9292

9393
class TestAdapter(BotAdapter, ExtendedUserTokenProvider):
9494
__test__ = False
95+
__EXCEPTION_EXPECTED = "ExceptionExpected"
9596

9697
def __init__(
9798
self,
@@ -446,6 +447,23 @@ def add_exchangeable_token(
446447
)
447448
self.exchangeable_tokens[key.to_key()] = key
448449

450+
def throw_on_exchange_request(
451+
self,
452+
connection_name: str,
453+
channel_id: str,
454+
user_id: str,
455+
exchangeable_item: str,
456+
):
457+
key = ExchangeableToken(
458+
connection_name=connection_name,
459+
channel_id=channel_id,
460+
user_id=user_id,
461+
exchangeable_item=exchangeable_item,
462+
token=TestAdapter.__EXCEPTION_EXPECTED,
463+
)
464+
465+
self.exchangeable_tokens[key.to_key()] = key
466+
449467
async def get_sign_in_resource_from_user(
450468
self,
451469
turn_context: TurnContext,
@@ -504,6 +522,9 @@ async def exchange_token_from_credentials(
504522

505523
token_exchange_response = self.exchangeable_tokens.get(key.to_key())
506524
if token_exchange_response:
525+
if token_exchange_response.token == TestAdapter.__EXCEPTION_EXPECTED:
526+
raise Exception("Exception occurred during exchanging tokens")
527+
507528
return TokenResponse(
508529
channel_id=key.channel_id,
509530
connection_name=key.connection_name,

libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1242,14 +1242,18 @@ async def exchange_token_from_credentials(
12421242
turn_context, oauth_app_credentials
12431243
)
12441244

1245-
return client.user_token.exchange_async(
1245+
result = client.user_token.exchange_async(
12461246
user_id,
12471247
connection_name,
12481248
turn_context.activity.channel_id,
12491249
exchange_request.uri,
12501250
exchange_request.token,
12511251
)
12521252

1253+
if isinstance(result, TokenResponse):
1254+
return result
1255+
raise TypeError(f"exchange_async returned improper result: {type(result)}")
1256+
12531257
@staticmethod
12541258
def key_for_connector_client(service_url: str, app_id: str, scope: str):
12551259
return f"{service_url if service_url else ''}:{app_id if app_id else ''}:{scope if scope else ''}"

libraries/botbuilder-dialogs/botbuilder/dialogs/prompts/oauth_prompt.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -527,13 +527,21 @@ async def _recognize_token(
527527
else:
528528
# No errors. Proceed with token exchange.
529529
extended_user_token_provider: ExtendedUserTokenProvider = context.adapter
530-
token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials(
531-
context,
532-
self._settings.oath_app_credentials,
533-
self._settings.connection_name,
534-
context.activity.from_property.id,
535-
TokenExchangeRequest(token=context.activity.value.token),
536-
)
530+
531+
token_exchange_response = None
532+
try:
533+
token_exchange_response = await extended_user_token_provider.exchange_token_from_credentials(
534+
context,
535+
self._settings.oath_app_credentials,
536+
self._settings.connection_name,
537+
context.activity.from_property.id,
538+
TokenExchangeRequest(token=context.activity.value.token),
539+
)
540+
except:
541+
# Ignore Exceptions
542+
# If token exchange failed for any reason, tokenExchangeResponse above stays null, and
543+
# hence we send back a failure invoke response to the caller.
544+
pass
537545

538546
if not token_exchange_response or not token_exchange_response.token:
539547
await context.send_activity(

libraries/botbuilder-dialogs/botbuilder/dialogs/skills/begin_skill_dialog_options.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,19 @@
55

66

77
class BeginSkillDialogOptions:
8-
def __init__(self, activity: Activity): # pylint: disable=unused-argument
8+
def __init__(
9+
self, activity: Activity, connection_name: str = None
10+
): # pylint: disable=unused-argument
911
self.activity = activity
12+
self.connection_name = connection_name
1013

1114
@staticmethod
1215
def from_object(obj: object) -> "BeginSkillDialogOptions":
1316
if isinstance(obj, dict) and "activity" in obj:
14-
return BeginSkillDialogOptions(obj["activity"])
17+
return BeginSkillDialogOptions(obj["activity"], obj.get("connection_name"))
1518
if hasattr(obj, "activity"):
16-
return BeginSkillDialogOptions(obj.activity)
17-
< 8000 /code>
19+
return BeginSkillDialogOptions(
20+
obj.activity,
21+
obj.connection_name if hasattr(obj, "connection_name") else None,
22+
)
1823
return None

libraries/botbuilder-dialogs/botbuilder/dialogs/skills/skill_dialog.py

Lines changed: 114 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,25 @@
44
from copy import deepcopy
55
from typing import List
66

7-
from botbuilder.schema import Activity, ActivityTypes, ExpectedReplies, DeliveryModes
8-
from botbuilder.core import (
9-
BotAdapter,
10-
TurnContext,
7+
from botbuilder.schema import (
8+
Activity,
9+
ActivityTypes,
10+
ExpectedReplies,
11+
DeliveryModes,
12+
SignInConstants,
13+
TokenExchangeInvokeRequest,
1114
)
15+
from botbuilder.core import BotAdapter, TurnContext, ExtendedUserTokenProvider
16+
from botbuilder.core.card_factory import ContentTypes
1217
from botbuilder.core.skills import SkillConversationIdFactoryOptions
13-
1418
from botbuilder.dialogs import (
1519
Dialog,
1620
DialogContext,
1721
DialogEvents,
1822
DialogReason,
1923
DialogInstance,
2024
)
25+
from botframework.connector.token_api.models import TokenExchangeRequest
2126

2227
from .begin_skill_dialog_options import BeginSkillDialogOptions
2328
from .skill_dialog_options import SkillDialogOptions
@@ -31,6 +36,7 @@ def __init__(self, dialog_options: SkillDialogOptions, dialog_id: str):
3136

3237
self.dialog_options = dialog_options
3338
self._deliver_mode_state_key = "deliverymode"
39+
self._sso_connection_name_key = "SkillDialog.SSOConnectionName"
3440

3541
async def begin_dialog(self, dialog_context: DialogContext, options: object = None):
3642
"""
@@ -59,8 +65,14 @@ async def begin_dialog(self, dialog_context: DialogContext, options: object = No
5965
self._deliver_mode_state_key
6066
] = dialog_args.activity.delivery_mode
6167

68+
dialog_context.active_dialog.state[
69+
self._sso_connection_name_key
70+
] = dialog_args.connection_name
71+
6272
# Send the activity to the skill.
63-
eoc_activity = await self._send_to_skill(dialog_context.context, skill_activity)
73+
eoc_activity = await self._send_to_skill(
74+
dialog_context.context, skill_activity, dialog_args.connection_name
75+
)
6476
if eoc_activity:
6577
return await dialog_context.end_dialog(eoc_activity.value)
6678

@@ -84,23 +96,21 @@ async def continue_dialog(self, dialog_context: DialogContext):
8496
dialog_context.context.activity.value
8597
)
8698

87-
# Forward only Message and Event activities to the skill
88-
if (
89-
dialog_context.context.activity.type == ActivityTypes.message
90-
or dialog_context.context.activity.type == ActivityTypes.event
91-
):
92-
# Create deep clone of the original activity to avoid altering it before forwarding it.
93-
skill_activity = deepcopy(dialog_context.context.activity)
94-
skill_activity.delivery_mode = dialog_context.active_dialog.state[
95-
self._deliver_mode_state_key
96-
]
97-
98-
# Just forward to the remote skill
99-
eoc_activity = await self._send_to_skill(
100-
dialog_context.context, skill_activity
101-
)
102-
if eoc_activity:
103-
return await dialog_context.end_dialog(eoc_activity.value)
99+
# Create deep clone of the original activity to avoid altering it before forwarding it.
100+
skill_activity = deepcopy(dialog_context.context.activity)
101+
skill_activity.delivery_mode = dialog_context.active_dialog.state[
102+
self._deliver_mode_state_key
103+
]
104+
connection_name = dialog_context.active_dialog.state[
105+
self._sso_connection_name_key
106+
]
107+
108+
# Just forward to the remote skill
109+
eoc_activity = await self._send_to_skill(
110+
dialog_context.context, skill_activity, connection_name
111+
)
112+
if eoc_activity:
113+
return await dialog_context.end_dialog(eoc_activity.value)
104114

105115
return self.end_of_turn
106116

@@ -119,6 +129,7 @@ async def reprompt_dialog( # pylint: disable=unused-argument
119129
is_incoming=True,
120130
)
121131

132+
# connection Name is not applicable for a RePrompt, as we don't expect as OAuthCard in response.
122133
await self._send_to_skill(context, reprompt_event)
123134

124135
async def resume_dialog( # pylint: disable=unused-argument
@@ -147,6 +158,7 @@ async def end_dialog(
147158
activity.channel_data = context.activity.channel_data
148159
activity.additional_properties = context.activity.additional_properties
149160

161+
# connection Name is not applicable for an EndDialog, as we don't expect as OAuthCard in response.
150162
await self._send_to_skill(context, activity)
151163

152164
await super().end_dialog(context, instance, reason)
@@ -168,20 +180,10 @@ def _validate_begin_dialog_args(options: object) -> BeginSkillDialogOptions:
168180
"SkillDialog: activity object in options as BeginSkillDialogOptions cannot be None."
169181
)
170182

171-
# Only accept Message or Event activities
172-
if (
173-
dialog_args.activity.type != ActivityTypes.message
174-
and dialog_args.activity.type != ActivityTypes.event
175-
):
176-
raise TypeError(
177-
f"Only {ActivityTypes.message} and {ActivityTypes.event} activities are supported."
178-
f" Received activity of type {dialog_args.activity.type}."
179-
)
180-
181183
return dialog_args
182184

183185
async def _send_to_skill(
184-
self, context: TurnContext, activity: Activity,
186+
self, context: TurnContext, activity: Activity, connection_name: str = None
185187
) -> Activity:
186188
# Create a conversationId to interact with the skill and send the activity
187189
conversation_id_factory_options = SkillConversationIdFactoryOptions(
@@ -226,8 +228,86 @@ async def _send_to_skill(
226228
if from_skill_activity.type == ActivityTypes.end_of_conversation:
227229
# Capture the EndOfConversation activity if it was sent from skill
228230
eoc_activity = from_skill_activity
231+
elif await self._intercept_oauth_cards(
232+
context, from_skill_activity, connection_name
233+
):
234+
# do nothing. Token exchange succeeded, so no oauthcard needs to be shown to the user
235+
pass
229236
else:
230237
# Send the response back to the channel.
231238
await context.send_activity(from_skill_activity)
232239

233240
return eoc_activity
241+
242+
async def _intercept_oauth_cards(
243+
self, context: TurnContext, activity: Activity, connection_name: str
244+
):
245+
"""
246+
Tells is if we should intercept the OAuthCard message.
247+
"""
248+
if not connection_name or not isinstance(
249+
context.adapter, ExtendedUserTokenProvider
250+
):
251+
return False
252+
253+
oauth_card_attachment = next(
254+
attachment
255+
for attachment in activity.attachments
256+
if attachment.content_type == ContentTypes.oauth_card
257+
)
258+
if oauth_card_attachment:
259+
oauth_card = oauth_card_attachment.content
260+
if (
261+
oauth_card
262+
and oauth_card.token_exchange_resource
263+
and oauth_card.token_exchange_resource.uri
264+
):
265+
try:
266+
result = await context.adapter.exchange_token(
267+
turn_context=context,
268+
connection_name=connection_name,
269+
user_id=context.activity.from_property.id,
270+
exchange_request=TokenExchangeRequest(
271+
uri=oauth_card.token_exchange_resource.uri
272+
),
273+
)
274+
275+
if result and result.token:
276+
return await self._send_token_exchange_invoke_to_skill(
277+
activity,
278+
oauth_card.token_exchange_resource.id,
279+
oauth_card.connection_name,
280+
result.token,
281+
)
282+
except:
283+
return False
284+
285+
return False
286+
287+
async def _send_token_exchange_invoke_to_skill(
288+
self,
289+
incoming_activity: Activity,
290+
request_id: str,
291+
connection_name: str,
292+
token: str,
293+
):
294+
activity = incoming_activity.create_reply()
295+
activity.type = ActivityTypes.invoke
296+
activity.name = SignInConstants.token_exchange_operation_name
297+
activity.value = TokenExchangeInvokeRequest(
298+
id=request_id, token=token, connection_name=connection_name,
299+
)
300+
301+
# route the activity to the skill
302+
skill_info = self.dialog_options.skill
303+
response = await self.dialog_options.skill_client.post_activity(
304+
self.dialog_options.bot_id,
305+
skill_info.app_id,
306+
skill_info.skill_endpoint,
307+
self.dialog_options.skill_host_endpoint,
308+
incoming_activity.conversation.id,
309+
activity,
310+
)
311+
312+
# Check response status: true if success, false if failure
313+
return response.status / 100 == 2

0 commit comments

Comments
 (0)
0