From 474c60d88737832c3169456060033a793ea4a7c8 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 16:25:56 -0700 Subject: [PATCH 01/11] Add Teams message history API Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/history/README.md | 37 +++ examples/history/pyproject.toml | 17 ++ examples/history/src/main.py | 156 ++++++++++ packages/apps/src/microsoft_teams/apps/app.py | 27 ++ .../apps/src/microsoft_teams/apps/history.py | 84 ++++++ .../apps/routing/activity_context.py | 36 ++- packages/apps/tests/test_history.py | 270 ++++++++++++++++++ uv.lock | 40 ++- 8 files changed, 665 insertions(+), 2 deletions(-) create mode 100644 examples/history/README.md create mode 100644 examples/history/pyproject.toml create mode 100644 examples/history/src/main.py create mode 100644 packages/apps/src/microsoft_teams/apps/history.py create mode 100644 packages/apps/tests/test_history.py 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..1db461225 --- /dev/null +++ b/examples/history/src/main.py @@ -0,0 +1,156 @@ +""" +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: + return ( + _read_attr(message, "from_", "user", "display_name") + or _read_attr(message, "from_", "application", "display_name") + or "Unknown" + ) + + +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/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..1ff85e1ed --- /dev/null +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -0,0 +1,84 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from typing import TYPE_CHECKING, Any, List, Optional, cast + +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)) + + +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 + + response = await messages_builder.get(_get_request_configuration(messages_builder, n)) + if response is None or response.value is None: + return [] + + return list(response.value) 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..b574d6f45 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,6 +42,7 @@ from microsoft_teams.common.http.client_token import Token from ..activity_sender import ActivitySender +from ..history import ChatMessage, get_graph_history from ..utils import create_graph_client if TYPE_CHECKING: @@ -73,6 +74,15 @@ class SignInOptions: DEFAULT_SIGNIN_OPTIONS = SignInOptions() +def _thread_id_from_conversation_id(conversation_id: str) -> Optional[str]: + _, separator, thread_id = conversation_id.partition(";messageid=") + return thread_id if separator and thread_id else None + + +def _base_conversation_id(conversation_id: str) -> str: + return conversation_id.split(";messageid=", 1)[0] + + class ActivityContext(Generic[T]): """Context object passed to activity handlers with middleware support.""" @@ -223,6 +233,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 _thread_id_from_conversation_id(conversation_id) + + return await get_graph_history( + self.app_graph, + n, + chat_id=None if channel_id else _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/tests/test_history.py b/packages/apps/tests/test_history.py new file mode 100644 index 000000000..3a7676963 --- /dev/null +++ b/packages/apps/tests/test_history.py @@ -0,0 +1,270 @@ +""" +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)) + + +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.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.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_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/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" }, From 8cf0e62e09e96a35b24cc0de3272f9c9863cd76e Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:22:10 -0700 Subject: [PATCH 02/11] Handle paged Graph history results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../apps/src/microsoft_teams/apps/history.py | 45 +++++++++++++++++-- packages/apps/tests/test_history.py | 28 ++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 1ff85e1ed..fa7e1e7ef 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -3,8 +3,11 @@ 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 @@ -77,8 +80,42 @@ async def get_graph_history( if thread_id: messages_builder = messages_builder.by_chat_message_id(thread_id).replies - response = await messages_builder.get(_get_request_configuration(messages_builder, n)) - if response is None or response.value is None: - return [] + messages: list[ChatMessage] = [] + response = await messages_builder.get(_get_request_configuration(messages_builder, min(n, _GRAPH_PAGE_SIZE_LIMIT))) + + while response is not None: + if response.value: + messages.extend(response.value[: n - len(messages)]) + + if len(messages) >= n: + break + + next_link = getattr(response, "odata_next_link", None) + if not next_link: + break + + response = await _get_next_page(messages_builder, next_link) + + return messages + + +async def _get_next_page(messages_builder: Any, next_link: str) -> Any: + try: + from kiota_abstractions.method import Method + from kiota_abstractions.request_information import RequestInformation + from msgraph.generated.models.chat_message_collection_response import ( # type: ignore[reportMissingTypeStubs] + ChatMessageCollectionResponse, + ) + except ImportError as e: + raise ImportError( + "Graph functionality not available. Install with 'pip install microsoft-teams-apps[graph]'" + ) from e - return list(response.value) + ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError + request_info = RequestInformation(method=Method.GET) + request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link + return await messages_builder.request_adapter.send_async( + request_info, + ChatMessageCollectionResponse, + {"XXX": ODataError}, + ) diff --git a/packages/apps/tests/test_history.py b/packages/apps/tests/test_history.py index 3a7676963..3e6dcc2e1 100644 --- a/packages/apps/tests/test_history.py +++ b/packages/apps/tests/test_history.py @@ -34,6 +34,12 @@ class FakeRepliesBuilder: 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: @@ -42,6 +48,7 @@ class FakeMessagesBuilder: 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: @@ -102,6 +109,27 @@ async def test_app_get_history_reads_chat_messages_with_top() -> None: 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.chat_messages.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.chat_messages.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")) From 610b4266b6f8950cbb9fbcb1f50ea4b46a18b187 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:24:53 -0700 Subject: [PATCH 03/11] Use explicit Graph error mapping Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index fa7e1e7ef..de01a56c5 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -117,5 +117,5 @@ async def _get_next_page(messages_builder: Any, next_link: str) -> Any: return await messages_builder.request_adapter.send_async( request_info, ChatMessageCollectionResponse, - {"XXX": ODataError}, + {"4XX": ODataError, "5XX": ODataError}, ) From 687142eec361564a41b86c63bc850579ed6406ef Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:26:28 -0700 Subject: [PATCH 04/11] Use Graph client for history paging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/history.py | 7 ++++--- packages/apps/tests/test_history.py | 7 +++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index de01a56c5..663be17e0 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -94,12 +94,12 @@ async def get_graph_history( if not next_link: break - response = await _get_next_page(messages_builder, next_link) + response = await _get_next_page(graph, next_link) return messages -async def _get_next_page(messages_builder: Any, next_link: str) -> Any: +async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: try: from kiota_abstractions.method import Method from kiota_abstractions.request_information import RequestInformation @@ -114,7 +114,8 @@ async def _get_next_page(messages_builder: Any, next_link: str) -> Any: ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError request_info = RequestInformation(method=Method.GET) request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link - return await messages_builder.request_adapter.send_async( + request_adapter = cast(Any, graph).request_adapter + return await request_adapter.send_async( request_info, ChatMessageCollectionResponse, {"4XX": ODataError, "5XX": ODataError}, diff --git a/packages/apps/tests/test_history.py b/packages/apps/tests/test_history.py index 3e6dcc2e1..871d17560 100644 --- a/packages/apps/tests/test_history.py +++ b/packages/apps/tests/test_history.py @@ -88,6 +88,7 @@ def by_team_id(self, team_id: str) -> SimpleNamespace: 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) @@ -116,9 +117,7 @@ async def test_app_get_history_paginates_when_count_exceeds_graph_page_limit() - graph.chat_messages.get = AsyncMock( return_value=SimpleNamespace(value=list(range(50)), odata_next_link="https://graph.example/next") ) - graph.chat_messages.request_adapter = FakeRequestAdapter( - [SimpleNamespace(value=list(range(50, 100)), odata_next_link=None)] - ) + 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") @@ -126,7 +125,7 @@ async def test_app_get_history_paginates_when_count_exceeds_graph_page_limit() - assert result == list(range(75)) config = graph.chat_messages.get.call_args.args[0] assert config.query_parameters.top == 50 - next_request = graph.chat_messages.request_adapter.send_async.call_args.args[0] + next_request = graph.request_adapter.send_async.call_args.args[0] assert next_request.url == "https://graph.example/next" From bf324521d22da29c0ebc6a29608c67ac0b9b0cdb Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:32:24 -0700 Subject: [PATCH 05/11] Tighten history paging adapter typing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/history.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 663be17e0..8ec0ddef4 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -4,7 +4,7 @@ """ from importlib import import_module -from typing import TYPE_CHECKING, Any, List, Optional, cast +from typing import TYPE_CHECKING, Any, List, Optional, Protocol, cast _GRAPH_PAGE_SIZE_LIMIT = 50 @@ -15,6 +15,10 @@ ChatMessage = Any +class _NextPageRequestAdapter(Protocol): + async def send_async(self, request_info: Any, parsable_factory: Any, error_map: dict[str, Any]) -> Any: ... + + def _validate_history_count(n: object) -> None: if type(n) is not int: raise TypeError("n must be an integer") @@ -94,12 +98,15 @@ async def get_graph_history( if not next_link: break - response = await _get_next_page(graph, next_link) + response = await _get_next_page( + graph.request_adapter, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + next_link, + ) return messages -async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: +async def _get_next_page(request_adapter: _NextPageRequestAdapter, next_link: str) -> Any: try: from kiota_abstractions.method import Method from kiota_abstractions.request_information import RequestInformation @@ -114,7 +121,6 @@ async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError request_info = RequestInformation(method=Method.GET) request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link - request_adapter = cast(Any, graph).request_adapter return await request_adapter.send_async( request_info, ChatMessageCollectionResponse, From ac97b842db6a0bb49ebad647ce0f2e1c4e56f104 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:36:52 -0700 Subject: [PATCH 06/11] Remove history paging type ignore Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../apps/src/microsoft_teams/apps/history.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 8ec0ddef4..481989468 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -10,13 +10,14 @@ if TYPE_CHECKING: from msgraph.generated.models.chat_message import ChatMessage # type: ignore[reportMissingTypeStubs] - from msgraph.graph_service_client import GraphServiceClient else: ChatMessage = Any -class _NextPageRequestAdapter(Protocol): - async def send_async(self, request_info: Any, parsable_factory: Any, error_map: dict[str, Any]) -> Any: ... +class _GraphClient(Protocol): + request_adapter: Any + chats: Any + teams: Any def _validate_history_count(n: object) -> None: @@ -48,7 +49,7 @@ def _get_request_configuration(messages_builder: Any, n: int) -> Any: async def get_graph_history( - graph: "GraphServiceClient", + graph: _GraphClient, n: int, *, chat_id: Optional[str] = None, @@ -98,15 +99,12 @@ async def get_graph_history( if not next_link: break - response = await _get_next_page( - graph.request_adapter, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] - next_link, - ) + response = await _get_next_page(graph.request_adapter, next_link) return messages -async def _get_next_page(request_adapter: _NextPageRequestAdapter, next_link: str) -> Any: +async def _get_next_page(request_adapter: Any, next_link: str) -> Any: try: from kiota_abstractions.method import Method from kiota_abstractions.request_information import RequestInformation From b2515911150002d8c28a4f5547fb99645e3f93e6 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:39:05 -0700 Subject: [PATCH 07/11] Use generated Graph client for history paging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../apps/src/microsoft_teams/apps/history.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 481989468..6a6975033 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -4,22 +4,17 @@ """ from importlib import import_module -from typing import TYPE_CHECKING, Any, List, Optional, Protocol, cast +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 -class _GraphClient(Protocol): - request_adapter: Any - chats: Any - teams: Any - - def _validate_history_count(n: object) -> None: if type(n) is not int: raise TypeError("n must be an integer") @@ -49,7 +44,7 @@ def _get_request_configuration(messages_builder: Any, n: int) -> Any: async def get_graph_history( - graph: _GraphClient, + graph: "GraphServiceClient", n: int, *, chat_id: Optional[str] = None, @@ -99,12 +94,12 @@ async def get_graph_history( if not next_link: break - response = await _get_next_page(graph.request_adapter, next_link) + response = await _get_next_page(graph, next_link) return messages -async def _get_next_page(request_adapter: Any, next_link: str) -> Any: +async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: try: from kiota_abstractions.method import Method from kiota_abstractions.request_information import RequestInformation @@ -119,6 +114,7 @@ async def _get_next_page(request_adapter: Any, next_link: str) -> Any: ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError request_info = RequestInformation(method=Method.GET) request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link + request_adapter: Any = object.__getattribute__(graph, "request_adapter") return await request_adapter.send_async( request_info, ChatMessageCollectionResponse, From 1a4afbe1b78a5866f8ba86e3638ca6bea08c6476 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:40:54 -0700 Subject: [PATCH 08/11] Use public Graph request adapter directly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/apps/src/microsoft_teams/apps/history.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 6a6975033..0212a0e74 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -114,8 +114,7 @@ async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError request_info = RequestInformation(method=Method.GET) request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link - request_adapter: Any = object.__getattribute__(graph, "request_adapter") - return await request_adapter.send_async( + return await graph.request_adapter.send_async( # pyright: ignore[reportUnknownMemberType] request_info, ChatMessageCollectionResponse, {"4XX": ODataError, "5XX": ODataError}, From 9e94d2abd2dfe176171b5756940cd91ddc600dc5 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 17:45:34 -0700 Subject: [PATCH 09/11] Use Graph page iterator for history paging Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../apps/src/microsoft_teams/apps/history.py | 46 +++++++------------ 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/apps/src/microsoft_teams/apps/history.py b/packages/apps/src/microsoft_teams/apps/history.py index 0212a0e74..19751d701 100644 --- a/packages/apps/src/microsoft_teams/apps/history.py +++ b/packages/apps/src/microsoft_teams/apps/history.py @@ -43,6 +43,11 @@ def _get_request_configuration(messages_builder: Any, n: int) -> Any: 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, @@ -82,40 +87,21 @@ async def get_graph_history( 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 [] - while response is not None: - if response.value: - messages.extend(response.value[: n - len(messages)]) - - if len(messages) >= n: - break - - next_link = getattr(response, "odata_next_link", None) - if not next_link: - break - - response = await _get_next_page(graph, next_link) - - return messages - - -async def _get_next_page(graph: "GraphServiceClient", next_link: str) -> Any: try: - from kiota_abstractions.method import Method - from kiota_abstractions.request_information import RequestInformation - from msgraph.generated.models.chat_message_collection_response import ( # type: ignore[reportMissingTypeStubs] - ChatMessageCollectionResponse, - ) + 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 - ODataError = import_module("msgraph.generated.models.o_data_errors.o_data_error").ODataError - request_info = RequestInformation(method=Method.GET) - request_info.path_parameters[RequestInformation.RAW_URL_KEY] = next_link - return await graph.request_adapter.send_async( # pyright: ignore[reportUnknownMemberType] - request_info, - ChatMessageCollectionResponse, - {"4XX": ODataError, "5XX": ODataError}, - ) + 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 From 53f8c88136a1752ca3b57b40ae9a53e6f35718b1 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 21:46:39 -0700 Subject: [PATCH 10/11] Log unknown history sender payloads Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/history/src/main.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/examples/history/src/main.py b/examples/history/src/main.py index 1db461225..cb8bd2cbe 100644 --- a/examples/history/src/main.py +++ b/examples/history/src/main.py @@ -44,11 +44,17 @@ def _read_attr(value: Any, *names: str) -> Any: def _message_sender(message: Any) -> str: - return ( + 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: From 02376c55652c519edd0fd7b9bd3dede7d5dc4ef5 Mon Sep 17 00:00:00 2001 From: heyitsaamir Date: Tue, 30 Jun 2026 21:56:13 -0700 Subject: [PATCH 11/11] Extract threaded conversation ID helpers Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- examples/threading/src/main.py | 5 ++--- .../apps/src/microsoft_teams/apps/__init__.py | 4 +++- .../apps/routing/activity_context.py | 15 +++---------- .../microsoft_teams/apps/utils/__init__.py | 12 +++++++++-- .../src/microsoft_teams/apps/utils/thread.py | 17 ++++++++++++++- packages/apps/tests/test_thread.py | 21 +++++++++++++++++++ 6 files changed, 55 insertions(+), 19 deletions(-) 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/routing/activity_context.py b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py index b574d6f45..e25fdf4f9 100644 --- a/packages/apps/src/microsoft_teams/apps/routing/activity_context.py +++ b/packages/apps/src/microsoft_teams/apps/routing/activity_context.py @@ -43,7 +43,7 @@ from ..activity_sender import ActivitySender from ..history import ChatMessage, get_graph_history -from ..utils import create_graph_client +from ..utils import create_graph_client, get_base_conversation_id, get_thread_message_id if TYPE_CHECKING: from msgraph.graph_service_client import GraphServiceClient @@ -74,15 +74,6 @@ class SignInOptions: DEFAULT_SIGNIN_OPTIONS = SignInOptions() -def _thread_id_from_conversation_id(conversation_id: str) -> Optional[str]: - _, separator, thread_id = conversation_id.partition(";messageid=") - return thread_id if separator and thread_id else None - - -def _base_conversation_id(conversation_id: str) -> str: - return conversation_id.split(";messageid=", 1)[0] - - class ActivityContext(Generic[T]): """Context object passed to activity handlers with middleware support.""" @@ -246,12 +237,12 @@ async def get_history(self, n: int, *, thread_id: Optional[str] = None) -> List[ 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 _thread_id_from_conversation_id(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 _base_conversation_id(conversation_id), + 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, 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_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