Skip to content
37 changes: 37 additions & 0 deletions examples/history/README.md
Original file line number Diff line number Diff line change
@@ -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 <n>` | Reads the last `<n>` messages from the current context |
| `history chat <chat-id> [n]` | Reads chat history using `app.get_history(n=n, chat_id=chat_id)` |
| `history channel <team-aad-group-id> <channel-id> [n]` | Reads channel history using `app.get_history(n=n, team_aad_group_id=team_aad_group_id, channel_id=channel_id)` |
| `history thread <team-aad-group-id> <channel-id> <thread-id> [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=<your-azure-bot-app-id>
CLIENT_SECRET=<your-azure-bot-app-secret>
TENANT_ID=<your-tenant-id>
```
17 changes: 17 additions & 0 deletions examples/history/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 }
162 changes: 162 additions & 0 deletions examples/history/src/main.py
Original file line number Diff line number Diff line change
@@ -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"<at>.*?</at>", "", 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 <n>` - current context history with a custom count\n\n"
"- `history chat <chat-id> [n]` - app-level chat history\n\n"
"- `history channel <team-aad-group-id> <channel-id> [n]` - app-level channel history\n\n"
"- `history thread <team-aad-group-id> <channel-id> <thread-id> [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())
5 changes: 2 additions & 3 deletions examples/threading/src/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -29,8 +29,7 @@ async def handle_message(ctx: ActivityContext[MessageActivity]):

# When inside a thread, conversation_id contains ;messageid=<rootId>.
# 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
Expand Down
4 changes: 3 additions & 1 deletion packages/apps/src/microsoft_teams/apps/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand All @@ -28,6 +28,8 @@
"FastAPIAdapter",
"HttpStream",
"ActivityContext",
"get_base_conversation_id",
"get_thread_message_id",
"to_threaded_conversation_id",
]
__all__.extend(auth.__all__)
Expand Down
27 changes: 27 additions & 0 deletions packages/apps/src/microsoft_teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
Loading
Loading