8000 add UserState, ConversationState, MemoryStorage, BotState; update Ada… · rsliang/botbuilder-python@684c62f · GitHub
[go: up one dir, main page]

Skip to content

Commit 684c62f

Browse files
committed
add UserState, ConversationState, MemoryStorage, BotState; update Adapter sample
1 parent 9cb287f commit 684c62f

File tree

16 files changed

+335
-42
lines changed

16 files changed

+335
-42
lines changed

libraries/botbuilder-core/botbuilder/core/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,29 @@
1010
from .bot_adapter import BotAdapter
1111
from .bot_framework_adapter import BotFrameworkAdapter, BotFrameworkAdapterSettings
1212
from .bot_context import BotContext
13+
from .bot_state import BotState, CachedBotState
14+
from .conversation_state import ConversationState
1315
from .memory_storage import MemoryStorage
1416
from .middleware_set import AnonymousReceiveMiddleware, Middleware, MiddlewareSet
15-
from .storage import Storage, StoreItem
17+
from .storage import Storage, StoreItem, StorageKeyFactory, calculate_change_hash
1618
from .test_adapter import TestAdapter
19+
from .user_state import UserState
1720

1821
__all__ = ['AnonymousReceiveMiddleware',
1922
'BotAdapter',
2023
'BotContext',
2124
'BotFrameworkAdapter',
2225
'BotFrameworkAdapterSettings',
26+
'BotState',
27+
'CachedBotState',
28+
'calculate_change_hash',
29+
'ConversationState',
2330
'MemoryStorage',
2431
'Middleware',
2532
'MiddlewareSet',
2633
'Storage',
34+
'StorageKeyFactory',
2735
'StoreItem',
2836
'TestAdapter',
37+
'UserState',
2938
'__version__',]

libraries/botbuilder-core/botbuilder/core/bot_context.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,14 @@ def responded(self, value):
7878
else:
7979
self._responded['responded'] = True
8080

81+
@property
82+
def services(self):
83+
"""
84+
Map of services and other values cached for the lifetime of the turn.
85+
:return:
86+
"""
87+
return self._services
88+
8189
def get(self, key: str) -> object:
8290
if not key or not isinstance(key, str):
8391
raise TypeError('"key" must be a valid string.')
@@ -136,12 +144,17 @@ async def update_activity(self, activity: Activity):
136144
"""
137145
return await self._emit(self._on_update_activity, activity, self.adapter.update_activity(self, activity))
138146

139-
async def delete_activity(self, reference: Union[str, ConversationReference]):
147+
async def delete_activity(self, id_or_reference: Union[str, ConversationReference]):
140148
"""
141149
Deletes an existing activity.
142-
:param reference:
150+
:param id_or_reference:
143151
:return:
144152
"""
153+
if type(id_or_reference) == str:
154+
reference = BotContext.get_conversation_reference(self.activity)
155+
reference.activity_id = id_or_reference
156+
else:
157+
reference = id_or_reference
145158
return await self._emit(self._on_delete_activity, reference, self.adapter.delete_activity(self, reference))
146159

147160
def on_send_activities(self, handler) -> 'BotContext':
@@ -178,11 +191,15 @@ async def emit_next(i: int):
178191
context = self
179192
try:
180193
if i < len(handlers):
181-
await handlers[i](context, arg, emit_next(i + 1))
182-
asyncio.ensure_future(logic)
194+
async def next_handler():
195+
await emit_next(i + 1)
196+
await handlers[i](context, arg, next_handler)
197+
183198
except Exception as e:
184199
raise e
185200
await emit_next(0)
201+
# This should be changed to `return await logic()`
202+
return await logic
186203

187204
@staticmethod
188205
def get_conversation_reference(activity: Activity) -> ConversationReference:
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .bot_context import BotContext
5+
from .middleware_set import Middleware
6+
from .storage import calculate_change_hash, StoreItem, StorageKeyFactory, Storage
7+
8+
9+
class CachedBotState(StoreItem):
10+
def __init__(self):
11+
super(CachedBotState, self).__init__()
12+
self.state = None
13+
self.hash: str = None
14+
15+
16+
class BotState(Middleware):
17+
def __init__(self, storage: Storage, storage_key: StorageKeyFactory):
18+
self.state_key = 'state'
19+
self.storage = storage
20+
self.storage_key = storage_key
21+
pass
22+
23+
async def on_process_request(self, context, next_middleware):
24+
"""
25+
Reads and writes state for your bot to storage.
26+
:param context:
27+
:param next_middleware:
28+
:return:
29+
"""
30+
await self.read(context, True)
31+
# For libraries like aiohttp, the web.Response need to be bubbled up from the process_activity logic, which is
32+
# why we store the value from next_middleware()
33+
logic_results = await next_middleware()
34+
35+
# print('Printing after log ran')
36+
# print(context.services['state']) # This is appearing as a dict which doesn't seem right
37+
# print(context.services['state']['state'])
38+
39+
await self.write(context, True) # Both of these probably shouldn't be True
40+
return logic_results
41+
42+
async def read(self, context: BotContext, force: bool=False):
43+
"""
44+
Reads in and caches the current state object for a turn.
45+
:param context:
46+
:param force:
47+
:return:
48+
"""
49+
cached = context.services.get(self.state_key)
50+
51+
if force or cached is None or ('state' in cached and cached['state'] is None):
52+
key = self.storage_key(context)
53+
items = await self.storage.read([key])
54+
55+
# state = items.get(key, {}) # This is a problem, we need to decide how the data is going to be handled:
56+
# Is it going to be serialized the entire way down or only partially serialized?
57+
# The current code is a bad implementation...
58+
59+
state = items.get(key, StoreItem())
60+
hash_state = calculate_change_hash(state)
61+
# context.services.set(self.state_key, {'state': state, 'hash': hash_state}) # <-- dict.set() doesn't exist
62+
context.services[self.state_key] = {'state': state, 'hash': hash_state}
63+
return state
64+
65+
return cached['state']
66+
67+
async def write(self, context: BotContext, force: bool=False):
68+
"""
69+
Saves the cached state object if it's been changed.
70+
:param context:
71+
:param force:
72+
:return:
73+
"""
74+
cached = context.services.get(self.state_key)
75+
76+
if force or (cached is not None and cached.get('hash', None) != calculate_change_hash(cached['state'])):
77+
key = self.storage_key(context)
78+
79+
if cached is None:
80+
cached = {'state': StoreItem(e_tag='*'), 'hash': ''}
81+
changes = {key: cached['state']} # this doesn't seem right
82+
# changes.key = cached['state'] # Wtf is this going to be used for?
83+
await self.storage.write(changes)
84+
85+
cached['hash'] = calculate_change_hash(cached['state'])
86+
context.services[self.state_key] = cached # instead of `cached` shouldn't this be
87+
# context.services[self.state_key][key] = cached
88+
89+
async def clear(self, context: BotContext):
90+
"""
91+
Clears the current state object for a turn.
92+
:param context:
93+
:return:
94+
"""
95+
cached = context.services.get(self.state_key)
96+
if cached is not None:
97+
cached['state'] = {}
98+
context.services[self.state_key] = cached
99+
100+
async def get(self, context: BotContext):
101+
"""
102+
Returns a cached state object or undefined if not cached.
103+
:param context:
104+
:return:
105+
"""
106+
cached = context.services.get(self.state_key)
107+
state = None
108+
if isinstance(cached, dict) and isinstance(cached['state'], StoreItem):
109+
state = cached['state']
110+
return state
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .bot_context import BotContext
5+
from .bot_state import BotState
6+
from .storage import Storage
7+
8+
9+
class ConversationState(BotState):
10+
"""
11+
Reads and writes conversation state for your bot to storage.
12+
"""
13+
def __init__(self, storage: Storage, namespace: str=''):
14+
"""
15+
Creates a new ConversationState instance.
16+
:param storage:
17+
:param namespace:
18+
"""
19+
super(ConversationState, self).__init__(storage, self.get_storage_key)
20+
self.namespace = namespace
21+
22+
def get_storage_key(self, context: BotContext):
23+
activity = context.activity
24+
channel_id = activity.channel_id
25+
conversation_id = (activity.conversation.id if
26+
(activity and hasattr(activity, 'conversation') and hasattr(activity.conversation, 'id')) else
27+
None)
28+
return f"conversation/{channel_id}/{conversation_id}" if channel_id and conversation_id else None

libraries/botbuilder-core/botbuilder/core/memory_storage.py

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,27 +25,23 @@ async def read(self, keys: List[str]):
2525
try:
2626
for key in keys:
2727
if key in self.memory:
28-
if self.memory:
29-
data[key] = deepcopy(self.memory[key])
30-
else:
31-
data[key] = None
28+
data[key] = self.memory[key]
3229
except Exception as e:
3330
raise e
3431

3532
return data
3633

37-
async def write(self, changes: Dict[str, StoreItem]): # This needs to be reworked
34+
async def write(self, changes: Dict[str, StoreItem]):
3835
try:
39-
4036
# iterate over the changes
41-
for (key, change_key) in enumerate(changes):
42-
new_value = changes[change_key]
37+
for (key, change) in changes.items():
38+
new_value = change
4339
old_value = None
4440

4541
# Check if the a matching key already exists in self.memory
4642
# If it exists then we want to cache its original value from memory
47-
if change_key in self.memory:
48-
old_value = self.memory[change_key]
43+
if key in self.memory:
44+
old_value = self.memory[key]
4945

5046
write_changes = self.__should_write_changes(old_value, new_value)
5147

@@ -54,7 +50,7 @@ async def write(self, changes: Dict[str, StoreItem]): # This needs to be rework
5450
if new_store_item is not None:
5551
self._e_tag += 1
5652
new_store_item.e_tag = str(self._e_tag)
57-
self.memory[change_key] = new_store_item
53+
self.memory[key] = new_store_item
5854
else:
5955
raise KeyError("MemoryStorage.write(): `e_tag` conflict or changes do not implement ABC"
6056
" `StoreItem`.")
@@ -63,8 +59,8 @@ async def write(self, changes: Dict[str, StoreItem]): # This needs to be rework
6359

6460
def __should_write_changes(self, old_value: StoreItem, new_value: StoreItem) -> bool:
6561
"""
66-
Compares two StoreItems and their e_tags and returns True if the new_value should overwrite the old_value.
67-
Otherwise returns False.
62+
Helper method that compares two StoreItems and their e_tags and returns True if the new_value should overwrite
63+
the old_value. Otherwise returns False.
6864
:param old_value:
6965
:param new_value:
7066
:return:
@@ -75,6 +71,8 @@ def __should_write_changes(self, old_value: StoreItem, new_value: StoreItem) ->
7571
return True
7672
# If none of the above cases, we verify that e_tags exist on both arguments
7773
elif hasattr(new_value, 'e_tag') and hasattr(old_value, 'e_tag'):
74+
if new_value.e_tag is not None and old_value.e_tag is None:
75+
return True
7876
# And then we do a comparing between the old and new e_tag values to decide if the new data will be written
7977
if old_value.e_tag == new_value.e_tag or int(old_value.e_tag) <= int(new_value.e_tag):
8078
return True

libraries/botbuilder-core/botbuilder/core/storage.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33

4+
from copy import copy
45
from abc import ABC, abstractmethod
5-
from typing import List
6+
from typing import Callable, List
7+
8+
from .bot_context import BotContext
69

710

811
class Storage(ABC):
@@ -38,5 +41,29 @@ class StoreItem(ABC):
3841
"""
3942
Object which is stored in Storage with an optional eTag.
4043
"""
41-
def __init__(self):
42-
self.e_tag = None
44+
def __init__(self, **kwargs):
45+
# If e_tag is passed in as a kwarg use that value, otherwise assign the wildcard value to the new e_tag
46+
self.e_tag = kwargs.get('e_tag', '*')
47+
for key, value in kwargs.items():
48+
setattr(self, key, value)
49+
50+
def __str__(self):
51+
non_magic_attributes = [attr for attr in dir(self) if not attr.startswith('_')]
52+
output = '{' + ','.join(
53+
[f" '{attr}': '{getattr(self, attr)}'" for attr in non_magic_attributes]) + ' }'
54+
return output
55+
56+
57+
StorageKeyFactory = Callable[[BotContext], str]
58+
59+
60+
def calculate_change_hash(item: StoreItem) -> str:
61+
"""
62+
Utility function to calculate a change hash for a `StoreItem`.
63+
:param item:
64+
:return:
65+
"""
66+
cpy = copy(item)
67+
if cpy.e_tag is not None:
68+
del cpy.e_tag
69+
return str(cpy)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .bot_context import BotContext
5+
from .bot_state import BotState
6+
from .storage import Storage
7+
8+
9+
class UserState(BotState):
10+
"""
11+
Reads and writes user state for your bot to storage.
12+
"""
13+
def __init__(self, storage: Storage, namespace=''):
14+
"""
15+
Creates a new UserState instance.
16+
:param storage:
17+
:param namespace:
18+
"""
19+
self.namespace = namespace
20+
super(UserState, self).__init__(storage, self.get_storage_key)
21+
22+
def get_storage_key(self, context: BotContext) -> str:
23+
"""
24+
Returns the storage key for the current user state.
25+
:param context:
26+
:return:
27+
"""
28+
activity = context.activity
29+
channel_id = activity.channel_id or None
30+
user_id = activity.from_property.id or None
31+
storage_key = None
32+
if channel_id and user_id:
33+
storage_key = f"user/{channel_id}/{user_id}/{self.namespace}"
34+
return storage_key

0 commit comments

Comments
 (0)
0