8000 Inspection middleware with jsonpickle (#340) · guidotorresmx/botbuilder-python@392a5d4 · GitHub
[go: up one dir, main page]

Skip to content

Commit 392a5d4

Browse files
authored
Inspection middleware with jsonpickle (microsoft#340)
* Inspection middleware with jsonpickle * inspection middleware tested * Removing msrest Model * adding inspection to package listing in setup.py
1 parent 4666d1d commit 392a5d4

13 files changed

+774
-22
lines changed

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

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88
import asyncio
99
import inspect
1010
from datetime import datetime
11-
from typing import Coroutine, Dict, List, Callable, Union
11+
from typing import Awaitable, Coroutine, Dict, List, Callable, Union
1212
from copy import copy
13+
from threading import Lock
1314
from botbuilder.schema import (
1415
ActivityTypes,
1516
Activity,
@@ -56,8 +57,8 @@ class TestAdapter(BotAdapter, UserTokenProvider):
5657
def __init__(
5758
self,
5859
logic: Coroutine = None,
59-
conversation: ConversationReference = None,
60-
send_trace_activity: bool = False,
60+
template: Activity = None,
61+
send_trace_activities: bool = False,
6162
): # pylint: disable=unused-argument
6263
"""
6364
Creates a new TestAdapter instance.
@@ -69,21 +70,42 @@ def __init__(
6970
self._next_id: int = 0
7071
self._user_tokens: List[UserToken] = []
7172
self._magic_codes: List[TokenMagicCode] = []
73+
self._conversation_lock = Lock()
7274
self.activity_buffer: List[Activity] = []
7375
self.updated_activities: List[Activity] = []
7476
self.deleted_activities: List[ConversationReference] = []
77+
self.send_trace_activities = send_trace_activities
7578

76-
self.template: Activity = Activity(
79+
self.template = template or Activity(
7780
channel_id="test",
7881
service_url="https://test.com",
7982
from_property=ChannelAccount(id="User1", name="user"),
8083
recipient=ChannelAccount(id="bot", name="Bot"),
8184
conversation=ConversationAccount(id="Convo1"),
8285
)
83-
if self.template is not None:
84-
self.template.service_url = self.template.service_url
85-
self.template.conversation = self.template.conversation
86-
self.template.channel_id = self.template.channel_id
86+
87+
async def process_activity(
88+
self, activity: Activity, logic: Callable[[TurnContext], Awaitable]
89+
):
90+
self._conversation_lock.acquire()
91+
try:
92+
# ready for next reply
93+
if activity.type is None:
94+
activity.type = ActivityTypes.message
95+
96+
activity.channel_id = self.template.channel_id
97+
activity.from_property = self.template.from_property
98+
activity.recipient = self.template.recipient
99+
activity.conversation = self.template.conversation
100+
activity.service_url = self.template.service_url
101+
102+
activity.id = str((self._next_id))
103+
self.< F438 span class=pl-c1>_next_id += 1
104+
finally:
105+
self._conversation_lock.release()
106+
107+
activity.timestamp = activity.timestamp or datetime.utcnow()
108+
await self.run_pipeline(TurnContext(self, activity), logic)
87109

88110
async def send_activities(self, context, activities: List[Activity]):
89111
"""
@@ -99,12 +121,11 @@ def id_mapper(activity):
99121
self._next_id += 1
100122
return ResourceResponse(id=str(self._next_id))
101123

102-
# TODO This if-else code is temporary until the BotAdapter and Bot/TurnContext are revamped.
103-
if isinstance(activities, list):
104-
responses = [id_mapper(activity) for activity in activities]
105-
else:
106-
responses = [id_mapper(activities)]
107-
return responses
124+
return [
125+
id_mapper(activity)
126+
for activity in activities
127+
if self.send_trace_activities or activity.type != "trace"
128+
]
108129

109130
async def delete_activity(self, context, reference: ConversationReference):
110131
"""

libraries/botbuilder-core/botbuilder/core/bot_state.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
# Licensed under the MIT License.
33

44
from abc import abstractmethod
5-
from typing import Callable, Dict
5+
from copy import deepcopy
6+
from typing import Callable, Dict, Union
67
from botbuilder.core.state_property_accessor import StatePropertyAccessor
78
from .turn_context import TurnContext
89
from .storage import Storage
@@ -186,17 +187,23 @@ async def delete(self, turn_context: TurnContext) -> None:
10000
186187
await self._bot_state.delete_property_value(turn_context, self._name)
187188

188189
async def get(
189-
self, turn_context: TurnContext, default_value_factory: Callable = None
190+
self,
191+
turn_context: TurnContext,
192+
default_value_or_factory: Union[Callable, object] = None,
190193
) -> object:
191194
await self._bot_state.load(turn_context, False)
192195
try:
193196
result = await self._bot_state.get_property_value(turn_context, self._name)
194197
return result
195198
except:
196199
# ask for default value from factory
197-
if not default_value_factory:
200+
if not default_value_or_factory:
198201
return None
199-
result = default_value_factory()
202+
result = (
203+
default_value_or_factory()
204+
if callable(default_value_or_factory)
205+
else deepcopy(default_value_or_factory)
206+
)
200207
# save default value for any further calls
201208
await self.set(turn_context, result)
202209
return result
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# coding=utf-8
2+
# --------------------------------------------------------------------------
3+
# Copyright (c) Microsoft Corporation. All rights reserved.
4+
# Licensed under the MIT License. See License.txt in the project root for
5+
# license information.
6+
# --------------------------------------------------------------------------
7+
8+
from .inspection_middleware import InspectionMiddleware
9+
from .inspection_state import InspectionState
10+
11+
__all__ = ["InspectionMiddleware", "InspectionState"]
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from uuid import uuid4
5+
from typing import Any, List
6+
7+
from jsonpickle import Pickler
8+
from botbuilder.core import BotState, ConversationState, TurnContext, UserState
9+
from botbuilder.schema import Activity, ActivityTypes, ConversationReference
10+
from botframework.connector.auth import MicrosoftAppCredentials
11+
12+
from .inspection_session import InspectionSession
13+
from .inspection_sessions_by_status import (
14+
InspectionSessionsByStatus,
15+
DEFAULT_INSPECTION_SESSIONS_BY_STATUS,
16+
)
17+
from .inspection_state import InspectionState
18+
from .interception_middleware import InterceptionMiddleware
19+
from .trace_activity import from_state, make_command_activity
20+
21+
22+
class InspectionMiddleware(InterceptionMiddleware):
23+
_COMMAND = "/INSPECT"
24+
25+
def __init__( # pylint: disable=super-init-not-called
26+
self,
27+
inspection_state: InspectionState,
28+
user_state: UserState = None,
29+
conversation_state: ConversationState = None,
30+
credentials: MicrosoftAppCredentials = None,
31+
):
32+
33+
self.inspection_state = inspection_state
34+
self.inspection_state_accessor = inspection_state.create_property(
35+
"InspectionSessionByStatus"
36+
)
37+
self.user_state = user_state
38+
self.conversation_state = conversation_state
39+
self.credentials = MicrosoftAppCredentials(
40+
credentials.microsoft_app_id if credentials else "",
41+
credentials.microsoft_app_password if credentials else "",
42+
)
43+
44+
async def process_command(self, context: TurnContext) -> Any:
45+
if context.activity.type == ActivityTypes.message and context.activity.text:
46+
47+
original_text = context.activity.text
48+
TurnContext.remove_recipient_mention(context.activity)
49+
50+
command = context.activity.text.strip().split(" ")
51+
if len(command) > 1 and command[0] == InspectionMiddleware._COMMAND:
52+
53+
if len(command) == 2 and command[1] == "open":
54+
await self._process_open_command(context)
55+
return True
56+
57+
if len(command) == 3 and command[1] == "attach":
58+
await self.process_attach_command(context, command[2])
59+
return True
60+
61+
context.activity.text = original_text
62+
63+
return False
64+
65+
async def _inbound(self, context: TurnContext, trace_activity: Activity) -> Any:
66+
if await self.process_command(context):
67+
return False, False
68+
69+
session = await self._find_session(context)
70+
if session:
71+
if await self._invoke_send(context, session, trace_activity):
72+
return True, True
73+
return True, False
74+
75+
async def _outbound(
76+
self, context: TurnContext, trace_activities: List[Activity]
77+
) -> Any:
78+
session = await self._find_session(context)
79+
if session:
80+
for trace_activity in trace_activities:
81+
if not await self._invoke_send(context, session, trace_activity):
82+
break
83+
84+
async def _trace_state(self, context: TurnContext) -> Any:
85+
session = await self._find_session(context)
86+
if session:
87+
if self.user_state:
88+
await self.user_state.load(context, False)
89+
90+
if self.conversation_state:
91+
await self.conversation_state.load(context, False)
92+
93+
bot_state = {}
94+
95+
if self.user_state:
96+
bot_state["user_state"] = InspectionMiddleware._get_serialized_context(
97+
self.user_state, context
98+
)
99+
100+
if self.conversation_state:
101+
bot_state[
102+
"conversation_state"
103+
] = InspectionMiddleware._get_serialized_context(
104+
self.conversation_state, context
105+
)
106+
107+
await self._invoke_send(context, session, from_state(bot_state))
108+
109+
async def _process_open_command(self, context: TurnContext) -> Any:
110+
sessions = await self.inspection_state_accessor.get(
111+
context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS
112+
)
113+
session_id = self._open_command(
114+
sessions, TurnContext.get_conversation_reference(context.activity)
115+
)
116+
await context.send_activity(
117+
make_command_activity(
118+
f"{InspectionMiddleware._COMMAND} attach {session_id}"
119+
)
120+
)
121+
await self.inspection_state.save_changes(context, False)
122+
123+
async def process_attach_command(
124+
self, context: TurnContext, session_id: str
125+
) -> None:
126+
sessions = await self.inspection_state_accessor.get(
127+
context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS
128+
)
129+
130+
if self._attach_comamnd(context.activity.conversation.id, sessions, session_id):
131+
await context.send_activity(
132+
"Attached to session, all traffic is being replicated for inspection."
133+
)
134+
else:
135+
await context.send_activity(
136+
f"Open session with id {session_id} does not exist."
137+
)
138+
139+
await self.inspection_state.save_changes(context, False)
140+
141+
def _open_command(
142+
self,
143+
sessions: InspectionSessionsByStatus,
144+
conversation_reference: ConversationReference,
145+
) -> str:
146+
session_id = str(uuid4())
147+
sessions.opened_sessions[session_id] = conversation_reference
148+
return session_id
149+
150+
def _attach_comamnd(
151+
self,
152+
conversation_id: str,
153+
sessions: InspectionSessionsByStatus,
154+
session_id: str,
155+
) -> bool:
156+
inspection_session_state = sessions.opened_sessions.get(session_id)
157+
if inspection_session_state:
158+
sessions.attached_sessions[conversation_id] = inspection_session_state
159+
del sessions.opened_sessions[session_id]
160+
return True
161+
162+
return False
163+
164+
@staticmethod
165+
def _get_serialized_context(state: BotState, context: TurnContext):
166+
ctx = state.get(context)
167+
return Pickler(unpicklable=False).flatten(ctx)
168+
169+
async def _find_session(self, context: TurnContext) -> Any:
170+
sessions = await self.inspection_state_accessor.get(
171+
context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS
172+
)
173+
174+
conversation_reference = sessions.attached_sessions.get(
175+
context.activity.conversation.id
176+
)
177+
if conversation_reference:
178+
return InspectionSession(conversation_reference, self.credentials)
179+
180+
return None
181+
182+
async def _invoke_send(
183+
self, context: TurnContext, session: InspectionSession, activity: Activity
184+
) -> bool:
185+
if await session.send(activity):
186+
return True
187+
188+
await self._clean_up_session(context)
189+
return False
190+
191+
async def _clean_up_session(self, context: TurnContext) -> None:
192+
sessions = await self.inspection_state_accessor.get(
193+
context, DEFAULT_INSPECTION_SESSIONS_BY_STATUS
194+
)
195+
196+
del sessions.attached_sessions[context.activity.conversation.id]
197+
await self.inspection_state.save_changes(context, False)
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Any
5+
6+
from botbuilder.core import TurnContext
7+
from botbuilder.schema import Activity, ConversationReference
8+
from botframework.connector.aio import ConnectorClient
9+
from botframework.connector.auth import MicrosoftAppCredentials
10+
11+
12+
class InspectionSession:
13+
def __init__(
14+
self,
15+
conversation_reference: ConversationReference,
16+
credentials: MicrosoftAppCredentials,
17+
):
18+
self._conversation_reference = conversation_reference
19+
self._connector_client = ConnectorClient(
20+
credentials, base_url=conversation_reference.service_url
21+
)
22+
23+
async def send(self, activity: Activity) -> Any:
24+
TurnContext.apply_conversation_reference(activity, self._conversation_reference)
25+
26+
try:
27+
await self._connector_client.conversations.send_to_conversation(
28+
activity.conversation.id, activity
29+
)
30+
except Exception:
31+
return False
32+
33+
return True
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from typing import Dict
5+
6+
from botbuilder.schema import ConversationReference
7+
8+
9+
class InspectionSessionsByStatus:
10+
def __init__(
11+
self,
12+
opened_sessions: Dict[str, ConversationReference] = None,
13+
attached_sessions: Dict[str, ConversationReference] = None,
14+
):
15+
self.opened_sessions: Dict[str, ConversationReference] = opened_sessions or {}
16+
self.attached_sessions: Dict[
17+
str, ConversationReference
18+
] = attached_sessions or {}
19+
20+
21+
DEFAULT_INSPECTION_SESSIONS_BY_STATUS = InspectionSessionsByStatus()

0 commit comments

Comments
 (0)
0