diff --git a/examples/history/README.md b/examples/history/README.md new file mode 100644 index 000000000..1960affad --- /dev/null +++ b/examples/history/README.md @@ -0,0 +1,37 @@ +# Example: Message History + +A bot that demonstrates the `ctx.get_history(...)` and `app.get_history(...)` APIs backed by Microsoft Graph. + +## Commands + +| Command | Behavior | +|---------|----------| +| `history` | Reads the 5 most recent messages for the current chat or channel context using `ctx.get_history(5)` | +| `history ctx ` | Reads the last `` messages from the current context | +| `history chat [n]` | Reads chat history using `app.get_history(n=n, chat_id=chat_id)` | +| `history channel [n]` | Reads channel history using `app.get_history(n=n, team_aad_group_id=team_aad_group_id, channel_id=channel_id)` | +| `history thread [n]` | Reads channel thread replies using `app.get_history(..., thread_id=thread_id)` | +| `help` | Shows available commands | + +## Required Graph permissions + +This example uses app-only Microsoft Graph calls. Grant and admin-consent the app permissions needed for the surfaces you test: + +- Channel history: `ChannelMessage.Read.All` +- Chat history: `Chat.Read.All` or the more specific chat message permission your tenant requires + +## Run + +```bash +uv run python src/main.py +``` + +## Environment Variables + +Create a `.env` file: + +```text +CLIENT_ID= +CLIENT_SECRET= +TENANT_ID= +``` diff --git a/examples/history/pyproject.toml b/examples/history/pyproject.toml new file mode 100644 index 000000000..c3875acec --- /dev/null +++ b/examples/history/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "history" +version = "0.1.0" +description = "Message history test bot" +readme = "README.md" +requires-python = ">=3.11,<4.0" +dependencies = [ + "dotenv>=0.9.9", + "microsoft-teams-apps", + "microsoft-teams-api", + "microsoft-teams-graph", +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } +microsoft-teams-api = { workspace = true } +microsoft-teams-graph = { workspace = true } diff --git a/examples/history/src/main.py b/examples/history/src/main.py new file mode 100644 index 000000000..cb8bd2cbe --- /dev/null +++ b/examples/history/src/main.py @@ -0,0 +1,162 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import logging +import re +from typing import Any + +from microsoft_teams.api import MessageActivity +from microsoft_teams.api.activities.typing import TypingActivityInput +from microsoft_teams.apps import ActivityContext, App + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = App() + + +def _parse_count(raw: str | None, default: int = 5) -> int: + if raw is None: + return default + + count = int(raw) + if count < 1: + raise ValueError("count must be greater than 0") + + return count + + +def _command_parts(text: str) -> list[str]: + without_mentions = re.sub(r".*?", "", text, flags=re.IGNORECASE).strip() + return without_mentions.split() + + +def _read_attr(value: Any, *names: str) -> Any: + current = value + for name in names: + current = getattr(current, name, None) + if current is None: + return None + return current + + +def _message_sender(message: Any) -> str: + sender = ( + _read_attr(message, "from_", "user", "display_name") + or _read_attr(message, "from_", "application", "display_name") + or "Unknown" + ) + if sender == "Unknown": + logger.info( + "Unknown history sender payload: %s", + message.model_dump(by_alias=True, exclude_none=True) if hasattr(message, "model_dump") else message, + ) + return sender + + +def _message_text(message: Any) -> str: + content = _read_attr(message, "body", "content") or "(no content)" + text = re.sub(r"<[^>]+>", "", str(content)).strip() + return re.sub(r"\s+", " ", text) or "(no content)" + + +def _format_history(title: str, messages: list[Any]) -> str: + if not messages: + return f"**{title}**\n\nNo messages returned." + + lines = [f"**{title}**", ""] + for index, message in enumerate(messages, 1): + sender = _message_sender(message) + text = _message_text(message) + created = getattr(message, "created_date_time", None) + timestamp = f" ({created})" if created else "" + lines.append(f"{index}. **{sender}**{timestamp}: {text[:240]}") + + return "\n\n".join(lines) + + +async def _send_history(ctx: ActivityContext[MessageActivity], title: str, messages: list[Any]) -> None: + await ctx.reply(_format_history(title, messages)) + + +def _history_error_guidance(error: Exception) -> str: + error_text = str(error) + if "ChannelMessage.Read.All" in error_text or "Missing role permissions" in error_text: + return ( + "Channel history requires the Microsoft Graph **Application** permission " + "`ChannelMessage.Read.All` with admin consent. After granting it, restart " + "the bot so it gets a fresh app token containing the new role." + ) + + return "Check that Graph app permissions are granted and that the IDs match the command scope." + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]) -> None: + await ctx.reply(TypingActivityInput()) + + text = ctx.activity.text or "" + parts = _command_parts(text) + normalized_text = " ".join(parts).lower() + + if "history" not in normalized_text and "help" not in normalized_text: + await ctx.reply('Say "help" for message history commands.') + return + + if "help" in normalized_text: + await ctx.reply( + "**Message History Test Bot**\n\n" + "**Commands:**\n\n" + "- `history` - current context history with `ctx.get_history(5)`\n\n" + "- `history ctx ` - current context history with a custom count\n\n" + "- `history chat [n]` - app-level chat history\n\n" + "- `history channel [n]` - app-level channel history\n\n" + "- `history thread [n]` - app-level channel thread replies" + ) + return + + try: + history_index = next(index for index, part in enumerate(parts) if part.lower() == "history") + args = parts[history_index + 1 :] + scope = args[0].lower() if args else "ctx" + + if scope == "ctx": + count = _parse_count(args[1] if len(args) > 1 else None) + await _send_history(ctx, "Current context history", await ctx.get_history(count)) + return + + if scope == "chat" and len(args) >= 2: + count = _parse_count(args[2] if len(args) > 2 else None) + messages = await app.get_history(n=count, chat_id=args[1]) + await _send_history(ctx, f"Chat history for {args[1]}", messages) + return + + if scope == "channel" and len(args) >= 3: + count = _parse_count(args[3] if len(args) > 3 else None) + messages = await app.get_history(n=count, team_aad_group_id=args[1], channel_id=args[2]) + await _send_history(ctx, f"Channel history for {args[2]}", messages) + return + + if scope == "thread" and len(args) >= 4: + count = _parse_count(args[4] if len(args) > 4 else None) + messages = await app.get_history( + n=count, + team_aad_group_id=args[1], + channel_id=args[2], + thread_id=args[3], + ) + await _send_history(ctx, f"Thread history for {args[3]}", messages) + return + + await ctx.reply('Invalid command. Say "help" for message history commands.') + + except Exception as e: + logger.exception("Failed to get message history") + await ctx.reply(f"Failed to get message history: {e}\n\n{_history_error_guidance(e)}") + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/examples/threading/src/main.py b/examples/threading/src/main.py index b4ed4e463..da212695e 100644 --- a/examples/threading/src/main.py +++ b/examples/threading/src/main.py @@ -8,7 +8,7 @@ from microsoft_teams.api import MessageActivity from microsoft_teams.api.activities.typing import TypingActivityInput -from microsoft_teams.apps import ActivityContext, App, to_threaded_conversation_id +from microsoft_teams.apps import ActivityContext, App, get_thread_message_id, to_threaded_conversation_id # Surface SDK INFO/WARNING logs (including the anonymous-mode startup warning # emitted when CLIENT_ID / CLIENT_SECRET / TENANT_ID are not configured). @@ -29,8 +29,7 @@ async def handle_message(ctx: ActivityContext[MessageActivity]): # When inside a thread, conversation_id contains ;messageid=. # Extract the root ID for threading; for top-level messages, use activity.id. - parts = conversation_id.split(";messageid=") - thread_root_id = parts[1] if len(parts) > 1 else message_id + thread_root_id = get_thread_message_id(conversation_id) or message_id # ============================================ # context.reply() — reactive threaded reply diff --git a/packages/apps/src/microsoft_teams/apps/__init__.py b/packages/apps/src/microsoft_teams/apps/__init__.py index 54ad5b922..f97c4a72a 100644 --- a/packages/apps/src/microsoft_teams/apps/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/__init__.py @@ -15,7 +15,7 @@ from .options import AppOptions from .plugins import * # noqa: F401, F403 from .routing import ActivityContext -from .utils.thread import to_threaded_conversation_id +from .utils.thread import get_base_conversation_id, get_thread_message_id, to_threaded_conversation_id logging.getLogger(__name__).addHandler(logging.NullHandler()) @@ -28,6 +28,8 @@ "FastAPIAdapter", "HttpStream", "ActivityContext", + "get_base_conversation_id", + "get_thread_message_id", "to_threaded_conversation_id", ] __all__.extend(auth.__all__) diff --git a/packages/apps/src/microsoft_teams/apps/app.py b/packages/apps/src/microsoft_teams/apps/app.py index c8e93be73..31707d7bb 100644 --- a/packages/apps/src/microsoft_teams/apps/app.py +++ b/packages/apps/src/microsoft_teams/apps/app.py @@ -52,6 +52,7 @@ get_event_type_from_signature, is_registered_event, ) +from .history import ChatMessage, get_graph_history from .http import FastAPIAdapter, HttpServer from .http.adapter import HttpRequest, HttpResponse from .options import AppOptions, InternalAppOptions @@ -614,3 +615,29 @@ def get_app_graph(self, tenant_id: Optional[str] = None) -> "GraphServiceClient" """ return create_graph_client(lambda: self._get_graph_token(tenant_id), cloud=self.cloud) + + async def get_history( + self, + *, + n: int, + chat_id: Optional[str] = None, + channel_id: Optional[str] = None, + thread_id: Optional[str] = None, + team_aad_group_id: Optional[str] = None, + tenant_id: Optional[str] = None, + ) -> List[ChatMessage]: + """ + Get Teams message history using Microsoft Graph app credentials. + + For chats, pass ``chat_id``. For channels, pass both + ``team_aad_group_id`` and ``channel_id``. When ``thread_id`` is supplied, + the returned history is the replies for that root message. + """ + return await get_graph_history( + self.get_app_graph(tenant_id=tenant_id), + n, + chat_id=chat_id, + channel_id=channel_id, + thread_id=thread_id, + team_aad_group_id=team_aad_group_id, + ) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py new file mode 100644 index 000000000..19751d701 --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -0,0 +1,107 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from importlib import import_module +from typing import TYPE_CHECKING, Any, List, Optional, cast + +_GRAPH_PAGE_SIZE_LIMIT = 50 + +if TYPE_CHECKING: + from msgraph.generated.models.chat_message import ChatMessage # type: ignore[reportMissingTypeStubs] + from msgraph.graph_service_client import GraphServiceClient +else: + ChatMessage = Any + + +def _validate_history_count(n: object) -> None: + if type(n) is not int: + raise TypeError("n must be an integer") + if n < 1: + raise ValueError("n must be greater than 0") + + +def _get_query_parameters(messages_builder: Any, n: int) -> Any: + builder_type = cast(type[Any], type(messages_builder)) + for name in ("MessagesRequestBuilderGetQueryParameters", "RepliesRequestBuilderGetQueryParameters"): + query_parameters_type = getattr(builder_type, name, None) + if query_parameters_type is not None: + return query_parameters_type(top=n) + + raise TypeError("messages_builder does not support Graph history query parameters") + + +def _get_request_configuration(messages_builder: Any, n: int) -> Any: + try: + from kiota_abstractions.base_request_configuration import RequestConfiguration + except ImportError as e: + raise ImportError( + "Graph functionality not available. Install with 'pip install microsoft-teams-apps[graph]'" + ) from e + + return RequestConfiguration(query_parameters=_get_query_parameters(messages_builder, n)) + + +def _get_error_mapping() -> dict[str, Any]: + ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError + return {"4XX": ODataError, "5XX": ODataError} + + +async def get_graph_history( + graph: "GraphServiceClient", + n: int, + *, + chat_id: Optional[str] = None, + channel_id: Optional[str] = None, + thread_id: Optional[str] = None, + team_aad_group_id: Optional[str] = None, +) -> List[ChatMessage]: + """ + Retrieve Teams message history with Microsoft Graph. + + Provide either ``chat_id`` for ``/chats/{chat-id}/messages`` or both + ``team_aad_group_id`` and ``channel_id`` for + ``/teams/{team-aad-group-id}/channels/{channel-id}/messages``. When + ``thread_id`` is supplied, replies for that root message are returned. + """ + _validate_history_count(n) + + has_chat = bool(chat_id) + has_channel = bool(team_aad_group_id or channel_id) + + if has_chat == has_channel: + raise ValueError("provide either chat_id or both team_aad_group_id and channel_id") + + if has_channel: + if not team_aad_group_id or not channel_id: + raise ValueError("team_aad_group_id and channel_id are required for channel history") + messages_builder = graph.teams.by_team_id(team_aad_group_id).channels.by_channel_id(channel_id).messages + else: + if not chat_id: + raise ValueError("chat_id is required for chat history") + messages_builder = graph.chats.by_chat_id(chat_id).messages + + if thread_id: + messages_builder = messages_builder.by_chat_message_id(thread_id).replies + + messages: list[ChatMessage] = [] + response = await messages_builder.get(_get_request_configuration(messages_builder, min(n, _GRAPH_PAGE_SIZE_LIMIT))) + if response is None or response.value is None: + return [] + + try: + from msgraph_core.tasks.page_iterator import PageIterator # type: ignore[reportMissingTypeStubs] + except ImportError as e: + raise ImportError( + "Graph functionality not available. Install with 'pip install microsoft-teams-apps[graph]'" + ) from e + + def collect(message: ChatMessage) -> bool: + messages.append(message) + return len(messages) < n + + request_adapter: Any = graph.request_adapter # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + iterator: Any = PageIterator(response, request_adapter, error_mapping=_get_error_mapping()) + await iterator.iterate(collect) + return messages diff --git a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index 58365143b..e25fdf4f9 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -8,7 +8,7 @@ import logging import warnings from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, Optional, TypeGuard, TypeVar +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generic, List, Optional, TypeGuard, TypeVar from microsoft_teams.api import ( Account, @@ -42,7 +42,8 @@ from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender -from ..utils import create_graph_client +from ..history import ChatMessage, get_graph_history +from ..utils import create_graph_client, get_base_conversation_id, get_thread_message_id if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient @@ -223,6 +224,30 @@ async def quote(self, message_id: str, input: str | ActivityParams) -> SentActiv activity.prepend_quote(message_id) return await self.send(activity) + async def get_history(self, n: int, *, thread_id: Optional[str] = None) -> List[ChatMessage]: + """ + Get message history for the current Teams conversation using Microsoft Graph. + + In chats, this reads from the current chat. In channels, this reads from the + current team/channel and defaults to the current thread when the incoming + activity is a thread reply. + """ + team_aad_group_id = None + if self.activity.team: + team_aad_group_id = self.activity.team.aad_group_id + channel_id = self.activity.channel.id if self.activity.channel else None + conversation_id = self.activity.conversation.id + resolved_thread_id = thread_id or self.activity.reply_to_id or get_thread_message_id(conversation_id) + + return await get_graph_history( + self.app_graph, + n, + chat_id=None if channel_id else get_base_conversation_id(conversation_id), + channel_id=channel_id, + thread_id=resolved_thread_id, + team_aad_group_id=team_aad_group_id, + ) + async def next(self) -> None: """Call the next middleware in the chain.""" if self._next_handler: diff --git a/packages/apps/src/microsoft_teams/apps/utils/__init__.py b/packages/apps/src/microsoft_teams/apps/utils/__init__.py index 511041a5a..56f7fc7a7 100644 --- a/packages/apps/src/microsoft_teams/apps/utils/__init__.py +++ b/packages/apps/src/microsoft_teams/apps/utils/__init__.py @@ -6,6 +6,14 @@ from .activity_utils import extract_tenant_id from .graph import create_graph_client from .retry import RetryOptions, retry -from .thread import to_threaded_conversation_id +from .thread import get_base_conversation_id, get_thread_message_id, to_threaded_conversation_id -__all__ = ["create_graph_client", "extract_tenant_id", "retry", "RetryOptions", "to_threaded_conversation_id"] +__all__ = [ + "create_graph_client", + "extract_tenant_id", + "get_base_conversation_id", + "get_thread_message_id", + "retry", + "RetryOptions", + "to_threaded_conversation_id", +] diff --git a/packages/apps/src/microsoft_teams/apps/utils/thread.py b/packages/apps/src/microsoft_teams/apps/utils/thread.py index 79c03073f..1512f67aa 100644 --- a/packages/apps/src/microsoft_teams/apps/utils/thread.py +++ b/packages/apps/src/microsoft_teams/apps/utils/thread.py @@ -3,6 +3,21 @@ Licensed under the MIT License. """ +from typing import Optional + +_THREAD_MESSAGE_ID_SEPARATOR = ";messageid=" + + +def get_base_conversation_id(conversation_id: str) -> str: + """Return the conversation ID without the APX thread message suffix.""" + return conversation_id.split(_THREAD_MESSAGE_ID_SEPARATOR, 1)[0] + + +def get_thread_message_id(conversation_id: str) -> Optional[str]: + """Extract the APX thread root message ID from a threaded conversation ID.""" + _, separator, message_id = conversation_id.partition(_THREAD_MESSAGE_ID_SEPARATOR) + return message_id if separator and message_id else None + def to_threaded_conversation_id(conversation_id: str, message_id: str) -> str: """Construct a threaded conversation ID by appending `;messageid={message_id}` @@ -23,5 +38,5 @@ def to_threaded_conversation_id(conversation_id: str, message_id: str) -> str: raise ValueError(f'Invalid message_id "{message_id}": must be a non-zero numeric value') # Strip any existing ;messageid= suffix (mirrors APX's NormalizeConversationId) - base_id = conversation_id.split(";")[0] + base_id = get_base_conversation_id(conversation_id) return f"{base_id};messageid={message_id}" diff --git a/packages/apps/tests/test_history.py b/packages/apps/tests/test_history.py new file mode 100644 index 000000000..871d17560 --- /dev/null +++ b/packages/apps/tests/test_history.py @@ -0,0 +1,297 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# pyright: basic + +from dataclasses import dataclass +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from microsoft_teams.api import ( + Account, + ChannelData, + ChannelInfo, + ConversationAccount, + ConversationReference, + MessageActivity, +) +from microsoft_teams.api.auth.cloud_environment import PUBLIC +from microsoft_teams.apps import App, AppOptions +from microsoft_teams.apps.routing.activity_context import ActivityContext + + +@dataclass +class FakeQueryParameters: + top: int | None = None + + +class FakeRepliesBuilder: + RepliesRequestBuilderGetQueryParameters = FakeQueryParameters + + def __init__(self, value: list[Any]): + self.get = AsyncMock(return_value=SimpleNamespace(value=value)) + self.request_adapter = FakeRequestAdapter([]) + + +class FakeRequestAdapter: + def __init__(self, pages: list[Any]): + self.send_async = AsyncMock(side_effect=pages) + + +class FakeMessagesBuilder: + MessagesRequestBuilderGetQueryParameters = FakeQueryParameters + + def __init__(self, value: list[Any], replies_value: list[Any] | None = None): + self.get = AsyncMock(return_value=SimpleNamespace(value=value)) + self.replies = FakeRepliesBuilder(replies_value or []) + self.request_adapter = FakeRequestAdapter([]) + self.thread_id: str | None = None + + def by_chat_message_id(self, thread_id: str) -> SimpleNamespace: + self.thread_id = thread_id + return SimpleNamespace(replies=self.replies) + + +class FakeChatsBuilder: + def __init__(self, messages: FakeMessagesBuilder): + self.messages = messages + self.chat_id: str | None = None + + def by_chat_id(self, chat_id: str) -> SimpleNamespace: + self.chat_id = chat_id + return SimpleNamespace(messages=self.messages) + + +class FakeChannelsBuilder: + def __init__(self, messages: FakeMessagesBuilder): + self.messages = messages + self.channel_id: str | None = None + + def by_channel_id(self, channel_id: str) -> SimpleNamespace: + self.channel_id = channel_id + return SimpleNamespace(messages=self.messages) + + +class FakeTeamsBuilder: + def __init__(self, channels: FakeChannelsBuilder): + self.channels = channels + self.team_id: str | None = None + + def by_team_id(self, team_id: str) -> SimpleNamespace: + self.team_id = team_id + return SimpleNamespace(channels=self.channels) + + +class FakeGraph: + def __init__(self): + self.request_adapter = FakeRequestAdapter([]) + self.chat_messages = FakeMessagesBuilder(["chat-message"], ["chat-reply"]) + self.channel_messages = FakeMessagesBuilder(["channel-message"], ["channel-reply"]) + self.chats = FakeChatsBuilder(self.chat_messages) + self.channels = FakeChannelsBuilder(self.channel_messages) + self.teams = FakeTeamsBuilder(self.channels) + + +@pytest.mark.asyncio +async def test_app_get_history_reads_chat_messages_with_top() -> None: + app = App(**AppOptions(client_id="test-id", client_secret="test-secret")) + graph = FakeGraph() + + with patch.object(app, "get_app_graph", return_value=graph): + result = await app.get_history(n=3, chat_id="chat-id") + + assert result == ["chat-message"] + assert graph.chats.chat_id == "chat-id" + config = graph.chat_messages.get.call_args.args[0] + assert config.query_parameters.top == 3 + + +@pytest.mark.asyncio +async def test_app_get_history_paginates_when_count_exceeds_graph_page_limit() -> None: + app = App(**AppOptions(client_id="test-id", client_secret="test-secret")) + graph = FakeGraph() + graph.chat_messages.get = AsyncMock( + return_value=SimpleNamespace(value=list(range(50)), odata_next_link="https://graph.example/next") + ) + graph.request_adapter = FakeRequestAdapter([SimpleNamespace(value=list(range(50, 100)), odata_next_link=None)]) + + with patch.object(app, "get_app_graph", return_value=graph): + result = await app.get_history(n=75, chat_id="chat-id") + + assert result == list(range(75)) + config = graph.chat_messages.get.call_args.args[0] + assert config.query_parameters.top == 50 + next_request = graph.request_adapter.send_async.call_args.args[0] + assert next_request.url == "https://graph.example/next" + + +@pytest.mark.asyncio +async def test_app_get_history_reads_channel_thread_replies() -> None: + app = App(**AppOptions(client_id="test-id", client_secret="test-secret")) + graph = FakeGraph() + + with patch.object(app, "get_app_graph", return_value=graph): + result = await app.get_history( + n=5, + team_aad_group_id="team-aad-group-id", + channel_id="channel-id", + thread_id="root-message-id", + ) + + assert result == ["channel-reply"] + assert graph.teams.team_id == "team-aad-group-id" + assert graph.channels.channel_id == "channel-id" + assert graph.channel_messages.thread_id == "root-message-id" + config = graph.channel_messages.replies.get.call_args.args[0] + assert config.query_parameters.top == 5 + + +@pytest.mark.asyncio +async def test_activity_context_get_history_uses_current_channel_thread() -> None: + graph = FakeGraph() + activity = MessageActivity( + id="reply-message-id", + text="hello", + from_=Account(id="user-id"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="conversation-id", conversation_type="channel"), + reply_to_id="root-message-id", + channel_data=ChannelData( + team={"id": "team-thread-id", "aad_group_id": "team-group-id"}, + channel=ChannelInfo(id="channel-id"), + ), + ) + activity_sender = MagicMock() + activity_sender.create_stream = MagicMock(return_value=MagicMock()) + + ctx = ActivityContext( + activity=activity, + app_id="test-app-id", + storage=MagicMock(), + api=MagicMock(), + user_token=None, + conversation_ref=ConversationReference( + bot=Account(id="bot-id"), + conversation=ConversationAccount(id="conversation-id"), + channel_id="msteams", + service_url="https://service.example", + ), + is_signed_in=False, + connection_name="graph", + activity_sender=activity_sender, + app_token=MagicMock(), + cloud=PUBLIC, + ) + ctx._app_graph = graph + + result = await ctx.get_history(2) + + assert result == ["channel-reply"] + assert graph.teams.team_id == "team-group-id" + assert graph.channels.channel_id == "channel-id" + assert graph.channel_messages.thread_id == "root-message-id" + + +@pytest.mark.asyncio +async def test_activity_context_get_history_reads_thread_from_conversation_id() -> None: + graph = FakeGraph() + activity = MessageActivity( + id="reply-message-id", + text="hello", + from_=Account(id="user-id"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount( + id="19:channel-id@thread.tacv2;messageid=root-message-id", + conversation_type="channel", + ), + channel_data=ChannelData( + team={"id": "team-thread-id", "aad_group_id": "team-group-id"}, + channel=ChannelInfo(id="channel-id"), + ), + ) + activity_sender = MagicMock() + activity_sender.create_stream = MagicMock(return_value=MagicMock()) + + ctx = ActivityContext( + activity=activity, + app_id="test-app-id", + storage=MagicMock(), + api=MagicMock(), + user_token=None, + conversation_ref=ConversationReference( + bot=Account(id="bot-id"), + conversation=ConversationAccount(id="19:channel-id@thread.tacv2;messageid=root-message-id"), + channel_id="msteams", + service_url="https://service.example", + ), + is_signed_in=False, + connection_name="graph", + activity_sender=activity_sender, + app_token=MagicMock(), + cloud=PUBLIC, + ) + ctx._app_graph = graph + + result = await ctx.get_history(2) + + assert result == ["channel-reply"] + assert graph.channel_messages.thread_id == "root-message-id" + + +@pytest.mark.asyncio +async def test_get_history_validates_count() -> None: + app = App(**AppOptions(client_id="test-id", client_secret="test-secret")) + + with pytest.raises(ValueError, match="n must be greater than 0"): + await app.get_history(n=0, chat_id="chat-id") + + +@pytest.mark.asyncio +async def test_get_history_requires_complete_channel_target() -> None: + app = App(**AppOptions(client_id="test-id", client_secret="test-secret")) + + with pytest.raises(ValueError, match="team_aad_group_id and channel_id are required"): + await app.get_history(n=1, channel_id="channel-id") + + +@pytest.mark.asyncio +async def test_activity_context_channel_history_requires_team_aad_group_id() -> None: + activity = MessageActivity( + id="reply-message-id", + text="hello", + from_=Account(id="user-id"), + recipient=Account(id="bot-id"), + conversation=ConversationAccount(id="conversation-id", conversation_type="channel"), + channel_data=ChannelData( + team={"id": "team-thread-id"}, + channel=ChannelInfo(id="channel-id"), + ), + ) + activity_sender = MagicMock() + activity_sender.create_stream = MagicMock(return_value=MagicMock()) + ctx = ActivityContext( + activity=activity, + app_id="test-app-id", + storage=MagicMock(), + api=MagicMock(), + user_token=None, + conversation_ref=ConversationReference( + bot=Account(id="bot-id"), + conversation=ConversationAccount(id="conversation-id"), + channel_id="msteams", + service_url="https://service.example", + ), + is_signed_in=False, + connection_name="graph", + activity_sender=activity_sender, + app_token=MagicMock(), + cloud=PUBLIC, + ) + ctx._app_graph = FakeGraph() + + with pytest.raises(ValueError, match="team_aad_group_id and channel_id are required"): + await ctx.get_history(2) diff --git a/packages/apps/tests/test_thread.py b/packages/apps/tests/test_thread.py index fca09dce7..fcbe01435 100644 --- a/packages/apps/tests/test_thread.py +++ b/packages/apps/tests/test_thread.py @@ -4,6 +4,7 @@ """ import pytest +from microsoft_teams.apps import get_base_conversation_id, get_thread_message_id from microsoft_teams.apps.utils.thread import to_threaded_conversation_id @@ -43,3 +44,23 @@ def test_raises_on_decimal_message_id(self): def test_strips_existing_messageid_and_replaces_with_thread_root(self): result = to_threaded_conversation_id("19:abc@thread.skype;messageid=111", "222") assert result == "19:abc@thread.skype;messageid=222" + + +class TestConversationIdThreadHelpers: + def test_get_base_conversation_id_strips_thread_message_id(self): + result = get_base_conversation_id("19:abc@thread.skype;messageid=111") + assert result == "19:abc@thread.skype" + + def test_get_base_conversation_id_returns_unthreaded_id(self): + result = get_base_conversation_id("19:abc@thread.skype") + assert result == "19:abc@thread.skype" + + def test_get_thread_message_id_extracts_thread_root(self): + result = get_thread_message_id("19:abc@thread.skype;messageid=111") + assert result == "111" + + def test_get_thread_message_id_returns_none_for_unthreaded_id(self): + assert get_thread_message_id("19:abc@thread.skype") is None + + def test_get_thread_message_id_returns_none_for_empty_thread_root(self): + assert get_thread_message_id("19:abc@thread.skype;messageid=") is None diff --git a/uv.lock b/uv.lock index 3ef2ba7ac..f7c63d3de 100644 --- a/uv.lock +++ b/uv.lock @@ -14,7 +14,9 @@ members = [ "cards", "dialogs", "echo", + "formatted-messaging", "graph", + "history", "http-adapters", "mcp-server", "meetings", @@ -967,6 +969,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "formatted-messaging" +version = "0.1.0" +source = { virtual = "examples/formatted-messaging" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, +] + [[package]] name = "frozenlist" version = "1.8.0" @@ -1183,6 +1202,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d3/8a/44032265776062a89171285ede55a0bdaadc8ac00f27f0512a71a9e3e1c8/hatchling-1.29.0-py3-none-any.whl", hash = "sha256:50af9343281f34785fab12da82e445ed987a6efb34fd8c2fc0f6e6630dbcc1b0", size = 76356, upload-time = "2026-02-23T19:42:05.197Z" }, ] +[[package]] +name = "history" +version = "0.1.0" +source = { virtual = "examples/history" } +dependencies = [ + { name = "dotenv" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, + { name = "microsoft-teams-graph" }, +] + +[package.metadata] +requires-dist = [ + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "microsoft-teams-graph", editable = "packages/graph" }, +] + [[package]] name = "hpack" version = "4.1.0" @@ -1816,7 +1854,7 @@ test = [ [package.metadata] requires-dist = [ - { name = "cryptography", specifier = ">=48.0.1" }, + { name = "cryptography", specifier = ">=3.4.0" }, { name = "dependency-injector", specifier = ">=4.48.1" }, { name = "fastapi", specifier = ">=0.115.13" }, { name = "microsoft-teams-api", editable = "packages/api" },