8000 Merge pull request #812 from microsoft/trboehre/bufferedReplies · itsmokha/botbuilder-python@07692fc · GitHub
[go: up one dir, main page]

Skip to content

Commit 07692fc

Browse files
authored
Merge pull request microsoft#812 from microsoft/trboehre/bufferedReplies
Added DeliveryMode bufferedReplies
2 parents f00bce1 + 867eab7 commit 07692fc

File tree

17 files changed

+493
-28
lines changed

17 files changed

+493
-28
lines changed

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
ConversationReference,
4040
TokenResponse,
4141
ResourceResponse,
42+
DeliveryModes,
4243
)
4344

4445
from . import __version__
@@ -456,6 +457,15 @@ async def process_activity_with_identity(
456457
return InvokeResponse(status=501)
457458
return invoke_response.value
458459

460+
# Return the buffered activities in the response. In this case, the invoker
461+
# should deserialize accordingly:
462+
# activities = [Activity().deserialize(activity) for activity in response.body]
463+
if context.activity.delivery_mode == DeliveryModes.buffered_replies:
464+
serialized_activities = [
465+
activity.serialize() for activity in context.buffered_replies
466+
]
467+
return InvokeResponse(status=200, body=serialized_activities)
468+
459469
return None
460470

461471
async def _authenticate_request(

libraries/botbuilder-core/botbuilder/core/bot_framework_http_client.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,34 @@ async def post_activity(
9191
content = json.loads(data) if data else None
9292

9393
if content:
94-
return InvokeResponse(status=resp.status_code, body=content)
94+
return InvokeResponse(status=resp.status, body=content)
9595

9696
finally:
9797
# Restore activity properties.
9898
activity.conversation.id = original_conversation_id
9999
activity.service_url = original_service_url
100100
activity.caller_id = original_caller_id
101101

102+
async def post_buffered_activity(
103+
self,
104+
from_bot_id: str,
105+
to_bot_id: str,
106+
to_url: str,
107+
service_url: str,
108+
conversation_id: str,
109+
activity: Activity,
110+
) -> [Activity]:
111+
"""
112+
Helper method to return a list of activities when an Activity is being
113+
sent with DeliveryMode == bufferedReplies.
114+
"""
115+
response = await self.post_activity(
116+
from_bot_id, to_bot_id, to_url, service_url, conversation_id, activity
117+
)
118+
if not response or (response.status / 100) != 2:
119+
return []
120+
return [Activity().deserialize(activity) for activity in response.body]
121+
102122
async def _get_app_credentials(
103123
self, app_id: str, oauth_scope: str
104124
) -> MicrosoftAppCredentials:
Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
1-
# Copyright (c) Microsoft Corporation. All rights reserved.
2-
# Licensed under the MIT License.
3-
4-
5-
class InvokeResponse:
6-
"""
7-
Tuple class containing an HTTP Status Code and a JSON Serializable
8-
object. The HTTP Status code is, in the invoke activity scenario, what will
9-
be set in the resulting POST. The Body of the resulting POST will be
10-
the JSON Serialized content from the Body property.
11-
"""
12-
13-
def __init__(self, status: int = None, body: object = None):
14-
"""
15-
Gets or sets the HTTP status and/or body code for the response
16-
:param status: The HTTP status code.
17-
:param body: The body content for the response.
18-
"""
19-
self.status = status
20-
self.body = body
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
5+
class InvokeResponse:
6+
"""
7+
Tuple class containing an HTTP Status Code and a JSON serializable
8+
object. The HTTP Status code is, in the invoke activity scenario, what will
9+
be set in the resulting POST. The Body of the resulting POST will be
10+
JSON serialized content.
11+
12+
The body content is defined by the producer. The caller must know what
13+
the content is and deserialize as needed.
14+
"""
15+
16+
def __init__(self, status: int = None, body: object = None):
17+
"""
18+
Gets or sets the HTTP status and/or body code for the response
19+
:param status: The HTTP status code.
20+
:param body: The JSON serializable body content for the response. This object
21+
must be serializable by the core Python json routines. The caller is responsible
22+
for serializing more complex/nested objects into native classes (lists and
23+
dictionaries of strings are acceptable).
24+
"""
25+
self.status = status
26+
self.body = body

libraries/botbuilder-core/botbuilder/core/turn_context.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
InputHints,
1313
Mention,
1414
ResourceResponse,
15+
DeliveryModes,
1516
)
1617
from .re_escape import escape
1718

@@ -50,6 +51,9 @@ def __init__(self, adapter_or_context, request: Activity = None):
5051

5152
self._turn_state = {}
5253

54+
# A list of activities to send when `context.Activity.DeliveryMode == 'bufferedReplies'`
55+
self.buffered_replies = []
56+
5357
@property
5458
def turn_state(self) -> Dict[str, object]:
5559
return self._turn_state
@@ -190,7 +194,21 @@ def activity_validator(activity: Activity) -> Activity:
190194
for act in activities
191195
]
192196

197+
# send activities through adapter
193198
async def logic():
199+
nonlocal sent_non_trace_activity
200+
201+
if self.activity.delivery_mode == DeliveryModes.buffered_replies:
202+
responses = []
203+
for activity in output:
204+
self.buffered_replies.append(activity)
205+
responses.append(ResourceResponse())
206+
207+
if sent_non_trace_activity:
208+
self.responded = True
209+
210+
return responses
211+
194212
responses = await self.adapter.send_activities(self, output)
195213
if sent_non_trace_activity:
196214
self.responded = True

libraries/botbuilder-core/tests/test_bot_framework_adapter.py

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
ConversationReference,
2020
ConversationResourceResponse,
2121
ChannelAccount,
22+
DeliveryModes,
2223
)
2324
from botframework.connector.aio import ConnectorClient
2425
from botframework.connector.auth import (
@@ -58,6 +59,7 @@ def __init__(self, settings=None):
5859
self.fail_operation = False
5960
self.expect_auth_header = ""
6061
self.new_service_url = None
62+
self.connector_client_mock = None
6163

6264
def aux_test_authenticate_request(self, request: Activity, auth_header: str):
6365
return super()._authenticate_request(request, auth_header)
@@ -102,7 +104,10 @@ def _get_or_create_connector_client(
102104
self.tester.assertIsNotNone(
103105
service_url, "create_connector_client() not passed service_url."
104106
)
105-
connector_client_mock = Mock()
107+
108+
if self.connector_client_mock:
109+
return self.connector_client_mock
110+
self.connector_client_mock = Mock()
106111

107112
async def mock_reply_to_activity(conversation_id, activity_id, activity):
108113
nonlocal self
@@ -160,23 +165,23 @@ async def mock_create_conversation(parameters):
160165
)
161166
return response
162167

163-
connector_client_mock.conversations.reply_to_activity.side_effect = (
168+
self.connector_client_mock.conversations.reply_to_activity.side_effect = (
164169
mock_reply_to_activity
165170
)
166-
connector_client_mock.conversations.send_to_conversation.side_effect = (
171+
self.connector_client_mock.conversations.send_to_conversation.side_effect = (
167172
mock_send_to_conversation
168173
)
169-
connector_client_mock.conversations.update_activity.side_effect = (
174+
self.connector_client_mock.conversations.update_activity.side_effect = (
170175
mock_update_activity
171176
)
172-
connector_client_mock.conversations.delete_activity.side_effect = (
177+
self.connector_client_mock.conversations.delete_activity.side_effect = (
173178
mock_delete_activity
174179
)
175-
connector_client_mock.conversations.create_conversation.side_effect = (
180+
self.connector_client_mock.conversations.create_conversation.side_effect = (
176181
mock_create_conversation
177182
)
178183

179-
return connector_client_mock
184+
return self.connector_client_mock
180185

181186

182187
async def process_activity(
@@ -572,3 +577,88 @@ async def callback(context: TurnContext):
572577
await adapter.continue_conversation(
573578
refs, callback, claims_identity=skills_identity, audience=skill_2_app_id
574579
)
580+
581+
async def test_delivery_mode_buffered_replies(self):
582+
mock_credential_provider = unittest.mock.create_autospec(CredentialProvider)
583+
584+
settings = BotFrameworkAdapterSettings(
585+
app_id="bot_id", credential_provider=mock_credential_provider
586+
)
587+
adapter = AdapterUnderTest(settings)
588+
589+
async def callback(context: TurnContext):
590+
await context.send_activity("activity 1")
591+
await context.send_activity("activity 2")
592+
await context.send_activity("activity 3")
593+
594+
inbound_activity = Activity(
595+
type=ActivityTypes.message,
596+
channel_id="emulator",
597+
service_url="http://tempuri.org/whatever",
598+
delivery_mode=DeliveryModes.buffered_replies,
599+
text="hello world",
600+
)
601+
602+
identity = ClaimsIdentity(
603+
claims={
604+
AuthenticationConstants.AUDIENCE_CLAIM: "bot_id",
605+
AuthenticationConstants.APP_ID_CLAIM: "bot_id",
606+
AuthenticationConstants.VERSION_CLAIM: "1.0",
607+
},
608+
is_authenticated=True,
609+
)
610+
611+
invoke_response = await adapter.process_activity_with_identity(
612+
inbound_activity, identity, callback
613+
)
614+
assert invoke_response
615+
assert invoke_response.status == 200
616+
activities = invoke_response.body
617+
assert len(activities) == 3
618+
assert activities[0]["text"] == "activity 1"
619+
assert activities[1]["text"] == "activity 2"
620+
assert activities[2]["text"] == "activity 3"
621+
assert (
622+
adapter.connector_client_mock.conversations.send_to_conversation.call_count
623+
== 0
624+
)
625+
626+
async def test_delivery_mode_normal(self):
627+
mock_credential_provider = unittest.mock.create_autospec(CredentialProvider)
628+
629+
settings = BotFrameworkAdapterSettings(
630+
app_id="bot_id", credential_provider=mock_credential_provider
631+
)
632+
adapter = AdapterUnderTest(settings)
633+
634+
async def callback(context: TurnContext):
635+
await context.send_activity("activity 1")
636+
await context.send_activity("activity 2")
637+
await context.send_activity("activity 3")
638+
639+
inbound_activity = Activity(
640+
type=ActivityTypes.message,
641+
channel_id="emulator",
642+
service_url="http://tempuri.org/whatever",
643+
delivery_mode=DeliveryModes.normal,
644+
text="hello world",
645+
conversation=ConversationAccount(id="conversationId"),
646+
)
647+
648+
identity = ClaimsIdentity(
649+
claims={
650+
AuthenticationConstants.AUDIENCE_CLAIM: "bot_id",
651+
AuthenticationConstants.APP_ID_CLAIM: "bot_id",
652+
AuthenticationConstants.VERSION_CLAIM: "1.0",
653+
},
654+
is_authenticated=True,
655+
)
656+
657+
invoke_response = await adapter.process_activity_with_identity(
658+
inbound_activity, identity, callback
659+
)
660+
assert not invoke_response
661+
assert (
662+
adapter.connector_client_mock.conversations.send_to_conversation.call_count
663+
== 3
664+
)

libraries/botbuilder-schema/botbuilder/schema/_connector_client_enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class DeliveryModes(str, Enum):
9999

100100
normal = "normal"
101101
notification = "notification"
102+
buffered_replies = "bufferedReplies"
102103

103104

104105
class ContactRelationUpdateActionTypes(str, Enum):
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import sys
5+
import traceback
6+
7+
from aiohttp import web
8+
from aiohttp.web import Request, Response
9+
from aiohttp.web_response import json_response
10+
from botbuilder.core import (
11+
BotFrameworkAdapterSettings,
12+
TurnContext,
13+
BotFrameworkAdapter,
14+
)
15+
from botbuilder.schema import Activity
16+
17+
from bots import ChildBot
18+
from config import DefaultConfig
19+
20+
CONFIG = DefaultConfig()
21+
22+
# Create adapter.
23+
# See https://aka.ms/about-bot-adapter to learn more about how bots work.
24+
SETTINGS = BotFrameworkAdapterSettings(
25+
app_id=CONFIG.APP_ID, app_password=CONFIG.APP_PASSWORD,
26+
)
27+
ADAPTER = BotFrameworkAdapter(SETTINGS)
28+
29+
30+
# Catch-all for errors.
31+
async def on_error(context: TurnContext, error: Exception):
32+
# This check writes out errors to console log .vs. app insights.
33+
# NOTE: In production environment, you should consider logging this to Azure
34+
# application insights.
35+
print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr)
36+
traceback.print_exc()
37+
38+
# Send a message to the user
39+
await context.send_activity("The bot encountered an error or bug.")
40+
await context.send_activity(
41+
"To continue to run this bot, please fix the bot source code."
42+
)
43+
44+
45+
ADAPTER.on_turn_error = on_error
46+
47+
# Create the Bot
48+
BOT = ChildBot()
49+
50+
51+
# Listen for incoming requests on /api/messages
52+
async def messages(req: Request) -> Response:
53+
# Main bot message handler.
54+
if "application/json" in req.headers["Content-Type"]:
55+
body = await req.json()
56+
else:
57+
return Response(status=415)
58+
59+
activity = Activity().deserialize(body)
60+
auth_header = req.headers["Authorization"] if "Authorization" in req.headers else ""
61+
62+
try:
63+
response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
64+
if response:
65+
return json_response(data=response.body, status=response.status)
66+
return Response(status=201)
67+
except Exception as exception:
68+
raise exception
69+
70+
71+
APP = web.Application()
72+
APP.router.add_post("/api/messages", messages)
73+
74+
if __name__ == "__main__":
75+
try:
76+
web.run_app(APP, host="localhost", port=CONFIG.PORT)
77+
except Exception as error:
78+
raise error
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .child_bot import ChildBot
5+
6+
__all__ = ["ChildBot"]

0 commit comments

Comments
 (0)
0