Skip to content

Added DeliveryMode bufferedReplies #812

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Mar 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions libraries/botbuilder-core/botbuilder/core/bot_framework_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
ConversationReference,
TokenResponse,
ResourceResponse,
DeliveryModes,
)

from . import __version__
Expand Down Expand Up @@ -456,6 +457,15 @@ async def process_activity_with_identity(
return InvokeResponse(status=501)
return invoke_response.value

# Return the buffered activities in the response. In this case, the invoker
# should deserialize accordingly:
# activities = [Activity().deserialize(activity) for activity in response.body]
if context.activity.delivery_mode == DeliveryModes.buffered_replies:
serialized_activities = [
activity.serialize() for activity in context.buffered_replies
]
return InvokeResponse(status=200, body=serialized_activities)

return None

async def _authenticate_request(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,14 +91,34 @@ async def post_activity(
content = json.loads(data) if data else None

if content:
return InvokeResponse(status=resp.status_code, body=content)
return InvokeResponse(status=resp.status, body=content)

finally:
# Restore activity properties.
activity.conversation.id = original_conversation_id
activity.service_url = original_service_url
activity.caller_id = original_caller_id

async def post_buffered_activity(
self,
from_bot_id: str,
to_bot_id: str,
to_url: str,
service_url: str,
conversation_id: str,
activity: Activity,
) -> [Activity]:
"""
Helper method to return a list of activities when an Activity is being
sent with DeliveryMode == bufferedReplies.
"""
response = await self.post_activity(
from_bot_id, to_bot_id, to_url, service_url, conversation_id, activity
)
if not response or (response.status / 100) != 2:
return []
return [Activity().deserialize(activity) for activity in response.body]

async def _get_app_credentials(
self, app_id: str, oauth_scope: str
) -> MicrosoftAppCredentials:
Expand Down
46 changes: 26 additions & 20 deletions libraries/botbuilder-core/botbuilder/core/invoke_response.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.


class InvokeResponse:
"""
Tuple class containing an HTTP Status Code and a JSON Serializable
object. The HTTP Status code is, in the invoke activity scenario, what will
be set in the resulting POST. The Body of the resulting POST will be
the JSON Serialized content from the Body property.
"""

def __init__(self, status: int = None, body: object = None):
"""
Gets or sets the HTTP status and/or body code for the response
:param status: The HTTP status code.
:param body: The body content for the response.
"""
self.status = status
self.body = body
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.


class InvokeResponse:
"""
Tuple class containing an HTTP Status Code and a JSON serializable
object. The HTTP Status code is, in the invoke activity scenario, what will
be set in the resulting POST. The Body of the resulting POST will be
JSON serialized content.

The body content is defined by the producer. The caller must know what
the content is and deserialize as needed.
"""

def __init__(self, status: int = None, body: object = None):
"""
Gets or sets the HTTP status and/or body code for the response
:param status: The HTTP status code.
:param body: The JSON serializable body content for the response. This object
must be serializable by the core Python json routines. The caller is responsible
for serializing more complex/nested objects into native classes (lists and
dictionaries of strings are acceptable).
"""
self.status = status
self.body = body
18 changes: 18 additions & 0 deletions libraries/botbuilder-core/botbuilder/core/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
InputHints,
Mention,
ResourceResponse,
DeliveryModes,
)
from .re_escape import escape

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

self._turn_state = {}

# A list of activities to send when `context.Activity.DeliveryMode == 'bufferedReplies'`
self.buffered_replies = []

@property
def turn_state(self) -> Dict[str, object]:
return self._turn_state
Expand Down Expand Up @@ -190,7 +194,21 @@ def activity_validator(activity: Activity) -> Activity:
for act in activities
]

# send activities through adapter
async def logic():
nonlocal sent_non_trace_activity

if self.activity.delivery_mode == DeliveryModes.buffered_replies:
responses = []
for activity in output:
self.buffered_replies.append(activity)
responses.append(ResourceResponse())

if sent_non_trace_activity:
self.responded = True

return responses

responses = await self.adapter.send_activities(self, output)
if sent_non_trace_activity:
self.responded = True
Expand Down
104 changes: 97 additions & 7 deletions libraries/botbuilder-core/tests/test_bot_framework_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
ConversationReference,
ConversationResourceResponse,
ChannelAccount,
DeliveryModes,
)
from botframework.connector.aio import ConnectorClient
from botframework.connector.auth import (
Expand Down Expand Up @@ -58,6 +59,7 @@ def __init__(self, settings=None):
self.fail_operation = False
self.expect_auth_header = ""
self.new_service_url = None
self.connector_client_mock = None

def aux_test_authenticate_request(self, request: Activity, auth_header: str):
return super()._authenticate_request(request, auth_header)
Expand Down Expand Up @@ -102,7 +104,10 @@ def _get_or_create_connector_client(
self.tester.assertIsNotNone(
service_url, "create_connector_client() not passed service_url."
)
connector_client_mock = Mock()

if self.connector_client_mock:
return self.connector_client_mock
self.connector_client_mock = Mock()

async def mock_reply_to_activity(conversation_id, activity_id, activity):
nonlocal self
Expand Down Expand Up @@ -160,23 +165,23 @@ async def mock_create_conversation(parameters):
)
return response

connector_client_mock.conversations.reply_to_activity.side_effect = (
self.connector_client_mock.conversations.reply_to_activity.side_effect = (
mock_reply_to_activity
)
connector_client_mock.conversations.send_to_conversation.side_effect = (
self.connector_client_mock.conversations.send_to_conversation.side_effect = (
mock_send_to_conversation
)
connector_client_mock.conversations.update_activity.side_effect = (
self.connector_client_mock.conversations.update_activity.side_effect = (
mock_update_activity
)
connector_client_mock.conversations.delete_activity.side_effect = (
self.connector_client_mock.conversations.delete_activity.side_effect = (
mock_delete_activity
)
connector_client_mock.conversations.create_conversation.side_effect = (
self.connector_client_mock.conversations.create_conversation.side_effect = (
mock_create_conversation
)

return connector_client_mock
return self.connector_client_mock


async def process_activity(
Expand Down Expand Up @@ -572,3 +577,88 @@ async def callback(context: TurnContext):
await adapter.continue_conversation(
refs, callback, claims_identity=skills_identity, audience=skill_2_app_id
)

async def test_delivery_mode_buffered_replies(self):
mock_credential_provider = unittest.mock.create_autospec(CredentialProvider)

settings = BotFrameworkAdapterSettings(
app_id="bot_id", credential_provider=mock_credential_provider
)
adapter = AdapterUnderTest(settings)

async def callback(context: TurnContext):
await context.send_activity("activity 1")
await context.send_activity("activity 2")
await context.send_activity("activity 3")

inbound_activity = Activity(
type=ActivityTypes.message,
channel_id="emulator",
service_url="http://tempuri.org/whatever",
delivery_mode=DeliveryModes.buffered_replies,
text="hello world",
)

identity = ClaimsIdentity(
claims={
AuthenticationConstants.AUDIENCE_CLAIM: "bot_id",
AuthenticationConstants.APP_ID_CLAIM: "bot_id",
AuthenticationConstants.VERSION_CLAIM: "1.0",
},
is_authenticated=True,
)

invoke_response = await adapter.process_activity_with_identity(
inbound_activity, identity, callback
)
assert invoke_response
assert invoke_response.status == 200
activities = invoke_response.body
assert len(activities) == 3
assert activities[0]["text"] == "activity 1"
assert activities[1]["text"] == "activity 2"
assert activities[2]["text"] == "activity 3"
assert (
adapter.connector_client_mock.conversations.send_to_conversation.call_count
== 0
)

async def test_delivery_mode_normal(self):
mock_credential_provider = unittest.mock.create_autospec(CredentialProvider)

settings = BotFrameworkAdapterSettings(
app_id="bot_id", credential_provider=mock_credential_provider
)
adapter = AdapterUnderTest(settings)

async def callback(context: TurnContext):
await context.send_activity("activity 1")
await context.send_activity("activity 2")
await context.send_activity("activity 3")

inbound_activity = Activity(
type=ActivityTypes.message,
channel_id="emulator",
service_url="http://tempuri.org/whatever",
delivery_mode=DeliveryModes.normal,
text="hello world",
conversation=ConversationAccount(id="conversationId"),
)

identity = ClaimsIdentity(
claims={
AuthenticationConstants.AUDIENCE_CLAIM: "bot_id",
AuthenticationConstants.APP_ID_CLAIM: "bot_id",
AuthenticationConstants.VERSION_CLAIM: "1.0",
},
is_authenticated=True,
)

invoke_response = await adapter.process_activity_with_identity(
inbound_activity, identity, callback
)
assert not invoke_response
assert (
adapter.connector_client_mock.conversations.send_to_conversation.call_count
== 3
)
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ class DeliveryModes(str, Enum):

normal = "normal"
notification = "notification"
buffered_replies = "bufferedReplies"


class ContactRelationUpdateActionTypes(str, Enum):
Expand Down
78 changes: 78 additions & 0 deletions samples/experimental/skills-buffered/child/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import sys
import traceback

from aiohttp import web
from aiohttp.web import Request, Response
from aiohttp.web_response import json_response
from botbuilder.core import (
BotFrameworkAdapterSettings,
TurnContext,
BotFrameworkAdapter,
)
from botbuilder.schema import Activity

from bots import ChildBot
from config import DefaultConfig

CONFIG = DefaultConfig()

# Create adapter.
# See https://aka.ms/about-bot-adapter to learn more about how bots work.
SETTINGS = BotFrameworkAdapterSettings(
app_id=CONFIG.APP_ID, app_password=CONFIG.APP_PASSWORD,
)
ADAPTER = BotFrameworkAdapter(SETTINGS)


# Catch-all for errors.
async def on_error(context: TurnContext, error: Exception):
# This check writes out errors to console log .vs. app insights.
# NOTE: In production environment, you should consider logging this to Azure
# application insights.
print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr)
traceback.print_exc()

# Send a message to the user
await context.send_activity("The bot encountered an error or bug.")
await context.send_activity(
"To continue to run this bot, please fix the bot source code."
)


ADAPTER.on_turn_error = on_error

# Create the Bot
BOT = ChildBot()


# Listen for incoming requests on /api/messages
async def messages(req: Request) -> Response:
# Main bot message handler.
if "application/json" in req.headers["Content-Type"]:
body = await req.json()
else:
return Response(status=415)

activity = Activity().deserialize(body)
auth_header = req.headers["Authorization"] if "Authorization" in req.headers else ""

try:
response = await ADAPTER.process_activity(activity, auth_header, BOT.on_turn)
if response:
return json_response(data=response.body, status=response.status)
return Response(status=201)
except Exception as exception:
raise exception


APP = web.Application()
APP.router.add_post("/api/messages", messages)

if __name__ == "__main__":
try:
web.run_app(APP, host="localhost", port=CONFIG.PORT)
except Exception as error:
raise error
6 changes: 6 additions & 0 deletions samples/experimental/skills-buffered/child/bots/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from .child_bot import ChildBot

__all__ = ["ChildBot"]
Loading