diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py index 1d54743e..8e2f956a 100644 --- a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/__init__.py @@ -1,5 +1,24 @@ from .store_item import StoreItem from .storage import Storage, AsyncStorageBase from .memory_storage import MemoryStorage +from .transcript_info import TranscriptInfo +from .transcript_logger import ( + TranscriptLogger, + ConsoleTranscriptLogger, + TranscriptLoggerMiddleware, + FileTranscriptLogger, +) +from .transcript_store import TranscriptStore -__all__ = ["StoreItem", "Storage", "AsyncStorageBase", "MemoryStorage"] +__all__ = [ + "StoreItem", + "Storage", + "AsyncStorageBase", + "MemoryStorage", + "TranscriptInfo", + "TranscriptLogger", + "ConsoleTranscriptLogger", + "TranscriptLoggerMiddleware", + "TranscriptStore", + "FileTranscriptLogger", +] diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_info.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_info.py new file mode 100644 index 00000000..4bb5bd12 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_info.py @@ -0,0 +1,12 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timezone +from dataclasses import dataclass + + +@dataclass +class TranscriptInfo: + channel_id: str = "" + conversation_id: str = "" + created_on: datetime = datetime.min.replace(tzinfo=timezone.utc) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py new file mode 100644 index 00000000..6fba5b77 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_logger.py @@ -0,0 +1,209 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import copy +import random +import string +import json + +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from queue import Queue +from typing import Awaitable, Callable, List, Optional + +from microsoft_agents.activity import Activity, ChannelAccount +from microsoft_agents.activity.activity import ConversationReference +from microsoft_agents.activity.activity_types import ActivityTypes +from microsoft_agents.activity.conversation_reference import ActivityEventNames +from microsoft_agents.hosting.core.middleware_set import Middleware, TurnContext + + +class TranscriptLogger(ABC): + @abstractmethod + async def log_activity(self, activity: Activity) -> None: + """ + Asynchronously logs an activity. + + :param activity: The activity to log. + """ + pass + + +class ConsoleTranscriptLogger(TranscriptLogger): + """ + ConsoleTranscriptLogger writes activities to Console output. This is a DEBUG class, intended for testing + and log tailing + """ + + async def log_activity(self, activity: Activity) -> None: + """Log an activity to the transcript. + :param activity:Activity being logged. + """ + if not activity: + raise TypeError("Activity is required") + + json_data = activity.model_dump_json() + parsed = json.loads(json_data) + print(json.dumps(parsed, indent=4)) + + +class FileTranscriptLogger(TranscriptLogger): + """ + A TranscriptLogger implementation that appends each activity as JSON to a file. This class appends + each activity to the given file using basic formatting. This is a DEBUG class, intended for testing + and log tailing. + """ + + def __init__(self, file_path: str, encoding: Optional[str] = "utf-8"): + """ + Initializes the FileTranscriptLogger and opens the file for appending. + + :param file_path: Path to the transcript log file. + :param encoding: File encoding (default: utf-8). + """ + self.file_path = file_path + self.encoding = encoding + + # Open file in append mode to ensure it exists + self._file = open(self.file_path, "a", encoding=self.encoding) + + async def log_activity(self, activity: Activity) -> None: + """ + Appends the given activity as a JSON line to the file. This method pretty-prints the JSON for readability, which makes + it non-performant. For production scenarios, consider a more efficient logging mechanism. + + :param activity: The Activity object to log. + """ + if not activity: + raise TypeError("Activity is required") + + json_data = activity.model_dump_json() + parsed = json.loads(json_data) + + self._file.write(json.dumps(parsed, indent=4)) + + # As this is a logging / debugging class, we want to ensure the data is written out immediately. This is another + # consideration that makes this class non-performant for production scenarios. + self._file.flush() + + def __del__(self): + if hasattr(self, "_file"): + self._file.close() + + +class TranscriptLoggerMiddleware(Middleware): + """Logs incoming and outgoing activities to a TranscriptLogger.""" + + def __init__(self, logger: TranscriptLogger): + if not logger: + raise TypeError( + "TranscriptLoggerMiddleware requires a TranscriptLogger instance." + ) + + self.logger = logger + + async def on_turn( + self, context: TurnContext, logic: Callable[[TurnContext], Awaitable] + ): + """Initialization for middleware. + :param context: Context for the current turn of conversation with the user. + :param logic: Function to call at the end of the middleware chain. + """ + transcript = Queue() + activity = context.activity + # Log incoming activity at beginning of turn + if activity: + if not activity.from_property: + activity.from_property = ChannelAccount() + if not activity.from_property.role: + activity.from_property.role = "user" + + # We should not log ContinueConversation events used by skills to initialize the middleware. + if not ( + context.activity.type == ActivityTypes.event + and context.activity.name == ActivityEventNames.continue_conversation + ): + await self._queue_activity(transcript, copy.copy(activity)) + + # hook up onSend pipeline + # pylint: disable=unused-argument + async def send_activities_handler( + ctx: TurnContext, + activities: List[Activity], + next_send: Callable[[], Awaitable[None]], + ): + # Run full pipeline + responses = await next_send() + for index, activity in enumerate(activities): + cloned_activity = copy.copy(activity) + if responses and index < len(responses): + cloned_activity.id = responses[index].id + + # For certain channels, a ResourceResponse with an id is not always sent to the bot. + # This fix uses the timestamp on the activity to populate its id for logging the transcript + # If there is no outgoing timestamp, the current time for the bot is used for the activity.id + if not cloned_activity.id: + alphanumeric = string.ascii_lowercase + string.digits + prefix = "g_" + "".join( + random.choice(alphanumeric) for i in range(5) + ) + epoch = datetime.fromtimestamp(0, timezone.utc) + if cloned_activity.timestamp: + reference = cloned_activity.timestamp + else: + reference = datetime.now(timezone.utc) + delta = (reference - epoch).total_seconds() * 1000 + cloned_activity.id = f"{prefix}{delta}" + await self._queue_activity(transcript, cloned_activity) + return responses + + context.on_send_activities(send_activities_handler) + + # hook up update activity pipeline + async def update_activity_handler( + ctx: TurnContext, activity: Activity, next_update: Callable[[], Awaitable] + ): + # Run full pipeline + response = await next_update() + update_activity = copy.copy(activity) + update_activity.type = ActivityTypes.message_update + await self._queue_activity(transcript, update_activity) + return response + + context.on_update_activity(update_activity_handler) + + # hook up delete activity pipeline + async def delete_activity_handler( + ctx: TurnContext, + reference: ConversationReference, + next_delete: Callable[[], Awaitable], + ): + # Run full pipeline + await next_delete() + + delete_msg = Activity( + type=ActivityTypes.message_delete, id=reference.activity_id + ) + deleted_activity: Activity = TurnContext.apply_conversation_reference( + delete_msg, reference, False + ) + await self._queue_activity(transcript, deleted_activity) + + context.on_delete_activity(delete_activity_handler) + + if logic: + await logic() + + # Flush transcript at end of turn + while not transcript.empty(): + activity = transcript.get() + if activity is None: + break + await self.logger.log_activity(activity) + transcript.task_done() + + async def _queue_activity(self, transcript: Queue, activity: Activity) -> None: + """Logs the activity. + :param transcript: transcript. + :param activity: Activity to log. + """ + transcript.put(activity) diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py new file mode 100644 index 00000000..5c8d0764 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_memory_store.py @@ -0,0 +1,154 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from threading import Lock +from datetime import datetime, timezone +from typing import List +from .transcript_logger import TranscriptLogger +from .transcript_info import TranscriptInfo +from microsoft_agents.activity import Activity + + +class TranscriptMemoryStore(TranscriptLogger): + """ + An in-memory implementation of the TranscriptLogger for storing and retrieving activities. + + This class is thread-safe and stores all activities in a list. It supports logging activities, + retrieving activities for a specific channel and conversation, and filtering by timestamp. + Activities with a None timestamp are treated as the earliest possible datetime. + + Note: This class is intended for testing and prototyping purposes only. It does not persist + data and is not suitable for production use. This store will also grow without bound over + time, making it especially unsuited for production use. + """ + + def __init__(self): + """ + Initializes the TranscriptMemoryStore. + """ + self._transcript = [] + self.lock = Lock() + + async def log_activity(self, activity: Activity) -> None: + """ + Asynchronously logs an activity to the in-memory transcript. + + :param activity: The Activity object to log. Must have a valid conversation and conversation id. + :raises ValueError: If activity, activity.conversation, or activity.conversation.id is None. + """ + if not activity: + raise ValueError("Activity cannot be None") + if not activity.conversation: + raise ValueError("Activity.Conversation cannot be None") + if not activity.conversation.id: + raise ValueError("Activity.Conversation.id cannot be None") + + with self.lock: + self._transcript.append(activity) + + async def get_transcript_activities( + self, + channel_id: str, + conversation_id: str, + continuation_token: str = None, + start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), + ) -> tuple[list[Activity], str]: + """ + Retrieves activities for a given channel and conversation, optionally filtered by start_date. + + :param channel_id: The channel ID to filter activities. + :param conversation_id: The conversation ID to filter activities. + :param continuation_token: (Unused) Token for pagination. + :param start_date: Only activities with timestamp >= start_date are returned. None timestamps are treated as datetime.min. + :return: A tuple containing the filtered list of Activity objects and a continuation token (always None). + :raises ValueError: If channel_id or conversation_id is None. + """ + if not channel_id: + raise ValueError("channel_id cannot be None") + if not conversation_id: + raise ValueError("conversation_id cannot be None") + + with self.lock: + # Get the activities that match on channel and conversation id + relevant_activities = [ + a + for a in self._transcript + if a.channel_id == channel_id + and a.conversation + and a.conversation.id == conversation_id + ] + # sort these by timestamp, treating None as datetime.min + sorted_relevant_activities = sorted( + relevant_activities, + key=lambda a: ( + a.timestamp + if a.timestamp is not None + else datetime.min.replace(tzinfo=timezone.utc) + ), + ) + # grab the ones bigger than the requested start date, treating None as datetime.min + filtered_sorted_activities = [ + a + for a in sorted_relevant_activities + if ( + a.timestamp + if a.timestamp is not None + else datetime.min.replace(tzinfo=timezone.utc) + ) + >= start_date + ] + + return filtered_sorted_activities, None + + async def delete_transcript(self, channel_id: str, conversation_id: str) -> None: + """ + Deletes all activities for a given channel and conversation from the in-memory transcript. + + :param channel_id: The channel ID whose transcript should be deleted. + :param conversation_id: The conversation ID whose transcript should be deleted. + :raises ValueError: If channel_id or conversation_id is None. + """ + if not channel_id: + raise ValueError("channel_id cannot be None") + if not conversation_id: + raise ValueError("conversation_id cannot be None") + + with self.lock: + self._transcript = [ + a + for a in self._transcript + if not ( + a.channel_id == channel_id + and a.conversation + and a.conversation.id == conversation_id + ) + ] + + async def list_transcripts( + self, channel_id: str, continuation_token: str = None + ) -> tuple[list[TranscriptInfo], str]: + """ + Lists all transcripts (unique conversation IDs) for a given channel. + + :param channel_id: The channel ID to list transcripts for. + :param continuation_token: (Unused) Token for pagination. + :return: A tuple containing a list of TranscriptInfo objects and a continuation token (always None). + :raises ValueError: If channel_id is None. + """ + if not channel_id: + raise ValueError("channel_id cannot be None") + + with self.lock: + relevant_activities = [ + a for a in self._transcript if a.channel_id == channel_id + ] + conversations = set( + a.conversation.id + for a in relevant_activities + if a.conversation and a.conversation.id + ) + transcript_infos = [ + TranscriptInfo(channel_id=channel_id, conversation_id=conversation_id) + for conversation_id in conversations + ] + return transcript_infos, None diff --git a/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py new file mode 100644 index 00000000..8e660bf8 --- /dev/null +++ b/libraries/microsoft-agents-hosting-core/microsoft_agents/hosting/core/storage/transcript_store.py @@ -0,0 +1,52 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from abc import ABC, abstractmethod +from datetime import datetime, timezone +from microsoft_agents.activity import Activity +from .transcript_info import TranscriptInfo +from .transcript_logger import TranscriptLogger + + +class TranscriptStore(TranscriptLogger): + @abstractmethod + async def get_transcript_activities( + self, + channel_id: str, + conversation_id: str, + continuation_token: str = None, + start_date: datetime = datetime.min.replace(tzinfo=timezone.utc), + ) -> tuple[list[Activity], str]: + """ + Asynchronously retrieves activities from a transcript. + + :param channel_id: The channel ID of the conversation. + :param conversation_id: The conversation ID. + :param continuation_token: (Optional) A token to continue retrieving activities from a specific point. + :param start_date: (Optional) The start date to filter activities. + :return: A tuple containing a list of activities and a continuation token. + """ + pass + + @abstractmethod + async def list_transcripts( + self, channel_id: str, continuation_token: str = None + ) -> tuple[list[TranscriptInfo, str]]: + """ + Asynchronously lists transcripts for a given channel. + + :param channel_id: The channel ID to list transcripts for. + :param continuation_token: (Optional) A token to continue listing transcripts from a specific point. + :return: A tuple containing a list of transcripts and a continuation token. + """ + pass + + @abstractmethod + async def delete_transcript(self, channel_id: str, conversation_id: str) -> None: + """ + Asynchronously deletes a transcript. + + :param channel_id: The channel ID of the conversation. + :param conversation_id: The conversation ID. + """ + pass diff --git a/tests/hosting_core/storage/test_transcript_logger_middleware.py b/tests/hosting_core/storage/test_transcript_logger_middleware.py new file mode 100644 index 00000000..d980e3c7 --- /dev/null +++ b/tests/hosting_core/storage/test_transcript_logger_middleware.py @@ -0,0 +1,102 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import os +from microsoft_agents.activity import Activity, ActivityEventNames, ActivityTypes +from microsoft_agents.hosting.core.authorization.claims_identity import ClaimsIdentity +from microsoft_agents.hosting.core.middleware_set import TurnContext +from microsoft_agents.hosting.core.storage.transcript_logger import ConsoleTranscriptLogger, FileTranscriptLogger, TranscriptLoggerMiddleware +from microsoft_agents.hosting.core.storage.transcript_memory_store import TranscriptMemoryStore +import pytest + +from tests._common.testing_objects.adapters.testing_adapter import AgentCallbackHandler, TestingAdapter + +@pytest.mark.asyncio +async def test_should_round_trip_via_middleware(): + transcript_store = TranscriptMemoryStore() + conversation_id = "id.1" + transcript_middleware = TranscriptLoggerMiddleware(transcript_store) + channelName = "Channel1" + + adapter = TestingAdapter(channelName) + adapter.use(transcript_middleware) + id = ClaimsIdentity({}, True) + + async def callback(tc): + print("process callback") + + a1 = adapter.make_activity("some random text") + a1.conversation.id = conversation_id # Make sure the conversation ID is set + + await adapter.process_activity(id, a1, callback) + + transcriptAndContinuationToken = await transcript_store.get_transcript_activities( + channelName, conversation_id + ) + + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 1 + assert transcript[0].channel_id == channelName + assert transcript[0].conversation.id == conversation_id + assert transcript[0].text == a1.text + assert continuationToken is None + +@pytest.mark.asyncio +async def test_should_write_to_file(): + fileName = "test_transcript.log" + + if os.path.exists(fileName): # Check if the file exists + os.remove(fileName) # Delete the file + + assert not os.path.exists(fileName), "file already exists." + + file_store = FileTranscriptLogger(file_path=fileName) + conversation_id = "id.1" + transcript_middleware = TranscriptLoggerMiddleware(file_store) + channelName = "Channel1" + + adapter = TestingAdapter(channelName) + adapter.use(transcript_middleware) + id = ClaimsIdentity({}, True) + + async def callback(tc): + print("process callback") + + textInActivity = "some random text" + a1 = adapter.make_activity(textInActivity) + a1.conversation.id = conversation_id # Make sure the conversation ID is set + + # This round-trips out to the File logger which does the actual write + await adapter.process_activity(id, a1, callback) + + # Check the file was created and has content + assert os.path.exists(fileName), "file was not created" + assert os.path.isfile(fileName), "file is not a file." + assert os.path.getsize(fileName) > 0, "file is empty" + +@pytest.mark.asyncio +async def test_should_write_to_console(): + + store = ConsoleTranscriptLogger() + conversation_id = "id.1" + transcript_middleware = TranscriptLoggerMiddleware(store) + channelName = "Channel1" + + adapter = TestingAdapter(channelName) + adapter.use(transcript_middleware) + id = ClaimsIdentity({}, True) + + async def callback(tc): + print("process callback") + + textInActivity = "some random text" + a1 = adapter.make_activity(textInActivity) + a1.conversation.id = conversation_id # Make sure the conversation ID is set + + # This round-trips out to the console logger which does the actual write + await adapter.process_activity(id, a1, callback) + + #check the console by hand. \ No newline at end of file diff --git a/tests/hosting_core/storage/test_transcript_store_memory.py b/tests/hosting_core/storage/test_transcript_store_memory.py new file mode 100644 index 00000000..2733eb85 --- /dev/null +++ b/tests/hosting_core/storage/test_transcript_store_memory.py @@ -0,0 +1,275 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from datetime import datetime, timezone +import pytest +from microsoft_agents.hosting.core.storage.transcript_memory_store import TranscriptMemoryStore +from microsoft_agents.activity import Activity, ConversationAccount + +@pytest.mark.asyncio +async def test_get_transcript_empty(): + store = TranscriptMemoryStore() + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert transcript == [] + assert continuationToken is None + +@pytest.mark.asyncio +async def test_log_activity_add_one_activity(): + store = TranscriptMemoryStore() + activity = Activity.create_message_activity() + activity.text = "Activity 1" + activity.channel_id = "Channel 1" + activity.conversation = ConversationAccount( id="Conversation 1" ) + + # Add one activity and make sure it's there and comes back + await store.log_activity(activity) + + # Ask for the activity we just added + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 1 + assert transcript[0].channel_id == activity.channel_id + assert transcript[0].conversation.id == activity.conversation.id + assert transcript[0].text == activity.text + assert continuationToken is None + + # Ask for a channel that doesn't exist and make sure we get nothing + transcriptAndContinuationToken = await store.get_transcript_activities("Invalid", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert transcript == [] + assert continuationToken is None + + # Ask for a ConversationID that doesn't exist and make sure we get nothing + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "INVALID") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert transcript == [] + assert continuationToken is None + +@pytest.mark.asyncio +async def test_log_activity_add_two_activity_same_conversation(): + store = TranscriptMemoryStore() + activity1 = Activity.create_message_activity() + activity1.text = "Activity 1" + activity1.channel_id = "Channel 1" + activity1.conversation = ConversationAccount( id="Conversation 1" ) + + activity2 = Activity.create_message_activity() + activity2.text = "Activity 2" + activity2.channel_id = "Channel 1" + activity2.conversation = ConversationAccount( id="Conversation 1" ) + + await store.log_activity(activity1) + await store.log_activity(activity2) + + # Ask for the activity we just added + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 2 + assert transcript[0].channel_id == activity1.channel_id + assert transcript[0].conversation.id == activity1.conversation.id + assert transcript[0].text == activity1.text + + assert transcript[1].channel_id == activity2.channel_id + assert transcript[1].conversation.id == activity2.conversation.id + assert transcript[1].text == activity2.text + + assert continuationToken is None + +@pytest.mark.asyncio +async def test_log_activity_add_two_activity_same_conversation(): + store = TranscriptMemoryStore() + activity1 = Activity.create_message_activity() + activity1.text = "Activity 1" + activity1.channel_id = "Channel 1" + activity1.conversation = ConversationAccount( id="Conversation 1" ) + activity1.timestamp = datetime(2000, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + + activity2 = Activity.create_message_activity() + activity2.text = "Activity 2" + activity2.channel_id = "Channel 1" + activity2.conversation = ConversationAccount( id="Conversation 1" ) + activity2.timestamp = datetime(2010, 1, 1, 12, 0, 1 , tzinfo=timezone.utc) + + activity3 = Activity.create_message_activity() + activity3.text = "Activity 2" + activity3.channel_id = "Channel 1" + activity3.conversation = ConversationAccount( id="Conversation 1" ) + activity3.timestamp = datetime(2020, 1, 1, 12, 0, 1 , tzinfo=timezone.utc) + + await store.log_activity(activity1) # 2000 + await store.log_activity(activity2) # 2010 + await store.log_activity(activity3) # 2020 + + # Ask for the activities we just added + date1 = datetime(1999, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + date2 = datetime(2009, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + date3 = datetime(2019, 1, 1, 12, 0, 0 , tzinfo=timezone.utc) + + # ask for everything after 1999. Should get all 3 activities + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date1) + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 3 + + # ask for everything after 2009. Should get 2 activities - the 2010 and 2020 activities + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date2) + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 2 + + # ask for everything after 2019. Should only get the 2020 activity + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1", None, date3) + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 1 + + +@pytest.mark.asyncio +async def test_log_activity_add_two_activity_two_conversation(): + store = TranscriptMemoryStore() + activity1 = Activity.create_message_activity() + activity1.text = "Activity 1 Channel 1 Conversation 1" + activity1.channel_id = "Channel 1" + activity1.conversation = ConversationAccount( id="Conversation 1" ) + + activity2 = Activity.create_message_activity() + activity2.text = "Activity 1 Channel 1 Conversation 2" + activity2.channel_id = "Channel 1" + activity2.conversation = ConversationAccount( id="Conversation 2" ) + + await store.log_activity(activity1) + await store.log_activity(activity2) + + # Ask for the activity we just added + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 1 + assert transcript[0].channel_id == activity1.channel_id + assert transcript[0].conversation.id == activity1.conversation.id + assert transcript[0].text == activity1.text + assert continuationToken is None + + # Now grab Conversation 2 + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 2") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 1 + assert transcript[0].channel_id == activity2.channel_id + assert transcript[0].conversation.id == activity2.conversation.id + assert transcript[0].text == activity2.text + assert continuationToken is None + +@pytest.mark.asyncio +async def test_delete_one_transcript(): + store = TranscriptMemoryStore() + activity = Activity.create_message_activity() + activity.text = "Activity 1" + activity.channel_id = "Channel 1" + activity.conversation = ConversationAccount( id="Conversation 1" ) + + # Add one activity and make sure it's there and comes back + await store.log_activity(activity) + + # Ask for the activity we just added + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + + assert len(transcript) == 1 + + # Now delete the transcript + await store.delete_transcript("Channel 1", "Conversation 1") + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + assert len(transcript) == 0 + +@pytest.mark.asyncio +async def test_delete_one_transcript_of_two(): + store = TranscriptMemoryStore() + + activity = Activity.create_message_activity() + activity.text = "Activity 1" + activity.channel_id = "Channel 1" + activity.conversation = ConversationAccount( id="Conversation 1" ) + + activity2 = Activity.create_message_activity() + activity2.text = "Activity 2" + activity2.channel_id = "Channel 2" + activity2.conversation = ConversationAccount( id="Conversation 1" ) + + # Add one activity and make sure it's there and comes back + await store.log_activity(activity) + await store.log_activity(activity2) + + # We now have two different transcripts. One for Channel 1 Conversation 1 and one for Channel 2 Conversation 1 + + # Delete one of the transcripts + await store.delete_transcript("Channel 1", "Conversation 1") + + # Make sure the one we deleted is gone + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 1", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + assert len(transcript) == 0 + + # Make sure the other one is still there + transcriptAndContinuationToken = await store.get_transcript_activities("Channel 2", "Conversation 1") + transcript = transcriptAndContinuationToken[0] + assert len(transcript) == 1 + +@pytest.mark.asyncio +async def test_list_transcripts(): + store = TranscriptMemoryStore() + + activity = Activity.create_message_activity() + activity.text = "Activity 1" + activity.channel_id = "Channel 1" + activity.conversation = ConversationAccount( id="Conversation 1" ) + + activity2 = Activity.create_message_activity() + activity2.text = "Activity 2" + activity2.channel_id = "Channel 2" + activity2.conversation = ConversationAccount( id="Conversation 1" ) + + # Make sure a list on an empty store returns an empty set + transcriptAndContinuationToken = await store.list_transcripts("Should Be Empty") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 0 + assert continuationToken is None + + # Add one activity so we can go searching + await store.log_activity(activity) + + transcriptAndContinuationToken = await store.list_transcripts("Channel 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 1 + assert continuationToken is None + + # Add second activity on a different channel, so now we have 2 transcripts + await store.log_activity(activity2) + + # Check again for "Transcript 1" which is on channel 1 + transcriptAndContinuationToken = await store.list_transcripts("Channel 1") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 1 + assert continuationToken is None + + # Check for "Transcript 2" which is on channel 2 + transcriptAndContinuationToken = await store.list_transcripts("Channel 2") + transcript = transcriptAndContinuationToken[0] + continuationToken = transcriptAndContinuationToken[1] + assert len(transcript) == 1 + assert continuationToken is None \ No newline at end of file