Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ A comprehensive SDK for building Microsoft Teams applications, bots, and AI agen
- [`microsoft-teams-devtools`](./packages/devtools/README.md)
- [`microsoft-teams-graph`](./packages/graph/README.md)
- [`microsoft-teams-openai`](./packages/openai/README.md)
- [`microsoft-teams-botbuilder`](./packages/botbuilder/README.md)

> external packages to integrate with external protocols and microsoft-teams-cards

Expand Down
6 changes: 1 addition & 5 deletions packages/apps/src/microsoft/teams/apps/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,7 @@ def __init__(self, **options: Unpack[AppOptions]):
break

if not http_plugin:
app_id = None
if self.credentials and hasattr(self.credentials, "client_id"):
app_id = self.credentials.client_id

http_plugin = HttpPlugin(app_id, self.log, self.options.skip_auth)
http_plugin = HttpPlugin(logger=self.log, skip_auth=self.options.skip_auth)

plugins.insert(0, http_plugin)
self.http = cast(HttpPlugin, http_plugin)
Expand Down
129 changes: 83 additions & 46 deletions packages/apps/src/microsoft/teams/apps/http_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,19 @@
from logging import Logger
from pathlib import Path
from types import SimpleNamespace
from typing import Annotated, Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, cast
from typing import (
Annotated,
Any,
AsyncGenerator,
Awaitable,
Callable,
Dict,
Optional,
TypedDict,
Union,
Unpack,
cast,
)

import uvicorn
from fastapi import FastAPI, Request, Response
Expand All @@ -23,6 +35,7 @@
SentActivity,
TokenProtocol,
)
from microsoft.teams.api.auth.credentials import Credentials
from microsoft.teams.apps.http_stream import HttpStream
from microsoft.teams.common.http import Client, ClientOptions, Token
from microsoft.teams.common.logging import ConsoleLogger
Expand All @@ -47,13 +60,22 @@
version = importlib.metadata.version("microsoft-teams-apps")


class HttpPluginOptions(TypedDict, total=False):
"""Options for configuring the HTTP plugin."""

logger: Logger
skip_auth: bool
server_factory: Callable[[FastAPI], uvicorn.Server]


@Plugin(name="http", version=version, description="the default plugin for sending/receiving activities")
class HttpPlugin(Sender):
"""
Basic HTTP plugin that provides a FastAPI server for Teams activities.
"""

logger: Annotated[Logger, LoggerDependencyOptions()]
credentials: Annotated[Optional[Credentials], DependencyMetadata(optional=True)]

on_error_event: Annotated[Callable[[ErrorEvent], None], EventMetadata(name="error")]
on_activity_event: Annotated[Callable[[ActivityEvent], None], EventMetadata(name="activity")]
Expand All @@ -64,16 +86,9 @@ class HttpPlugin(Sender):

lifespans: list[Lifespan[Starlette]] = []

def __init__(
self,
app_id: Optional[str],
logger: Optional[Logger] = None,
skip_auth: bool = False,
server_factory: Optional[Callable[[FastAPI], uvicorn.Server]] = None,
):
def __init__(self, **options: Unpack[HttpPluginOptions]):
"""
Args:
app_id: Optional Microsoft App ID.
logger: Optional logger.
skip_auth: Whether to skip JWT validation.
server_factory: Optional function that takes an ASGI app
Expand All @@ -88,8 +103,9 @@ def custom_server_factory(app: FastAPI) -> uvicorn.Server:
```
"""
super().__init__()
self.logger = logger or ConsoleLogger().create_logger("@teams/http-plugin")
self.logger = options.get("logger") or ConsoleLogger().create_logger("@teams/http-plugin")
self._port: Optional[int] = None
self._skip_auth: bool = options.get("skip_auth", False)
self._server: Optional[uvicorn.Server] = None
self._on_ready_callback: Optional[Callable[[], Awaitable[None]]] = None
self._on_stopped_callback: Optional[Callable[[], Awaitable[None]]] = None
Expand Down Expand Up @@ -122,20 +138,14 @@ async def combined_lifespan(app: Starlette):
self.app = FastAPI(lifespan=combined_lifespan)

# Create uvicorn server if user provides custom factory method
server_factory = options.get("server_factory")
if server_factory:
self._server = server_factory(self.app)
if self._server.config.app is not self.app:
raise ValueError(
"server_factory must return a uvicorn.Server configured with the provided FastAPI app instance."
)

# Add JWT validation middleware
if app_id and not skip_auth:
jwt_middleware = create_jwt_validation_middleware(
app_id=app_id, logger=self.logger, paths=["/api/messages"]
)
self.app.middleware("http")(jwt_middleware)

# Expose FastAPI routing methods (like TypeScript exposes Express methods)
self.get = self.app.get
self.post = self.app.post
Expand Down Expand Up @@ -167,6 +177,20 @@ def on_stopped_callback(self, callback: Optional[Callable[[], Awaitable[None]]])
"""Set callback to call when HTTP server is stopped."""
self._on_stopped_callback = callback

async def on_init(self) -> None:
"""
Initialize the HTTP plugin when the app starts.
This adds JWT validation middleware unless `skip_auth` is True.
"""

# Add JWT validation middleware
app_id = getattr(self.credentials, "client_id", None)
if app_id and not self._skip_auth:
jwt_middleware = create_jwt_validation_middleware(
app_id=app_id, logger=self.logger, paths=["/api/messages"]
)
self.app.middleware("http")(jwt_middleware)

async def on_start(self, event: PluginStartEvent) -> None:
"""Start the HTTP server."""
self._port = event.port
Expand Down Expand Up @@ -337,38 +361,51 @@ async def _handle_activity_request(self, request: Request) -> Any:

return result

def _handle_activity_response(self, response: Response, result: Any) -> Union[Response, Dict[str, object]]:
"""
Handle the activity response formatting.

Args:
response: The FastAPI response object
result: The result from activity processing

Returns:
The formatted response
"""
status_code: Optional[int] = None
body: Optional[Dict[str, Any]] = None
resp_dict: Optional[Dict[str, Any]] = None
if isinstance(result, dict):
resp_dict = cast(Dict[str, Any], result)
elif isinstance(result, BaseModel):
resp_dict = result.model_dump(exclude_none=True)

# if resp_dict has status set it
if resp_dict and "status" in resp_dict:
status_code = resp_dict.get("status")

if resp_dict and "body" in resp_dict:
body = resp_dict.get("body", None)

if status_code is not None:
response.status_code = status_code

if body is not None:
self.logger.debug(f"Returning body {body}")
return body
self.logger.debug("Returning empty body")
return response

async def on_activity_request(self, request: Request, response: Response) -> Any:
"""Handle incoming Teams activity."""
# Process the activity (token validation handled by middleware)
result = await self._handle_activity_request(request)
return self._handle_activity_response(response, result)

def _setup_routes(self) -> None:
"""Setup FastAPI routes."""

async def on_activity_request(request: Request, response: Response) -> Any:
"""Handle incoming Teams activity."""
# Process the activity (token validation handled by middleware)
result = await self._handle_activity_request(request)
status_code: Optional[int] = None
body: Optional[Dict[str, Any]] = None
resp_dict: Optional[Dict[str, Any]] = None
if isinstance(result, dict):
resp_dict = cast(Dict[str, Any], result)
elif isinstance(result, BaseModel):
resp_dict = result.model_dump(exclude_none=True)

# if resp_dict has status set it
if resp_dict and "status" in resp_dict:
status_code = resp_dict.get("status")

if resp_dict and "body" in resp_dict:
body = resp_dict.get("body", None)

if status_code is not None:
response.status_code = status_code

if body is not None:
self.logger.debug(f"Returning body {body}")
return body
self.logger.debug("Returning empty body")
return response

self.app.post("/api/messages")(on_activity_request)
self.app.post("/api/messages")(self.on_activity_request)

async def health_check() -> Dict[str, Any]:
"""Basic health check endpoint."""
Expand Down
12 changes: 6 additions & 6 deletions packages/apps/tests/test_http_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,31 +32,31 @@ def mock_logger(self):
@pytest.fixture
def plugin_with_validator(self, mock_logger):
"""Create HttpPlugin with token validator."""
return HttpPlugin("test-app-id", mock_logger)
return HttpPlugin(app_id="test-app-id", logger=mock_logger)

@pytest.fixture
def plugin_without_validator(self, mock_logger):
"""Create HttpPlugin without token validator."""
return HttpPlugin(None, mock_logger)
return HttpPlugin(app_id=None, logger=mock_logger)

def test_init_with_app_id(self, mock_logger):
"""Test HttpPlugin initialization with app ID."""
plugin = HttpPlugin("test-app-id", mock_logger)
plugin = HttpPlugin(app_id="test-app-id", logger=mock_logger)

assert plugin.logger == mock_logger
assert plugin.app is not None
assert plugin.pending == {}

def test_init_without_app_id(self, mock_logger):
"""Test HttpPlugin initialization without app ID."""
plugin = HttpPlugin(None, mock_logger)
plugin = HttpPlugin(app_id=None, logger=mock_logger)

assert plugin.logger == mock_logger
assert plugin.app is not None

def test_init_with_default_logger(self):
"""Test HttpPlugin initialization with default logger."""
plugin = HttpPlugin("test-app-id", None)
plugin = HttpPlugin(app_id="test-app-id")

assert plugin.logger is not None

Expand Down Expand Up @@ -280,7 +280,7 @@ def test_middleware_setup(self, plugin_with_validator, plugin_without_validator)

def test_logger_property(self, mock_logger):
"""Test logger property assignment."""
plugin = HttpPlugin("test-app-id", mock_logger)
plugin = HttpPlugin(app_id="test-app-id", logger=mock_logger)
assert plugin.logger == mock_logger

def test_app_property(self, plugin_with_validator):
Expand Down
17 changes: 17 additions & 0 deletions packages/botbuilder/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Microsoft Teams BotBuilder

<p>
<a href="https://pypi.org/project/microsoft-teams-botbuilder/" target="_blank">
<img src="https://img.shields.io/pypi/v/microsoft-teams-botbuilder" />
</a>
<a href="https://pypi.org/project/microsoft-teams-botbuilder" target="_blank">
<img src="https://img.shields.io/pypi/dw/microsoft-teams-botbuilder" />
</a>
</p>

A package used to make the `@microsoft/teams.apps` package backwards compatible with legacy bots built using
`BotBuilder`.

<a href="https://microsoft.github.io/teams-ai" target="_blank">
<img src="https://img.shields.io/badge/📖 Getting Started-blue?style=for-the-badge" />
</a>
36 changes: 36 additions & 0 deletions packages/botbuilder/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[project]
name = "microsoft-teams-botbuilder"
version = "2.0.0a5"
description = "BotBuilder plugin for Microsoft Teams"
authors = [{ name = "Microsoft", email = "[email protected]" }]
readme = "README.md"
requires-python = ">=3.12,<3.14"
repository = "https://github.com/microsoft/teams.py"
keywords = ["microsoft", "teams", "ai", "bot", "agents"]
license = "MIT"
dependencies = [
"botbuilder-core>=4.14.0",
"botbuilder-integration-aiohttp>=4.17.0",
"microsoft-teams-apps",
"microsoft-teams-api",
"microsoft-teams-common"
]

[project.urls]
Homepage = "https://github.com/microsoft/teams.py/tree/main/packages/botbuilder/src/microsoft/teams/botbuilder"


[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/microsoft"]

[tool.hatch.build.targets.sdist]
include = ["src"]

[tool.uv.sources]
microsoft-teams-apps = { workspace = true }
microsoft-teams-api = { workspace = true }
microsoft-teams-common = { workspace = true }
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""
Copyright (c) Microsoft Corporation. All rights reserved.
Licensed under the MIT License.
"""

from .botbuilder_plugin import BotBuilderPlugin, BotBuilderPluginOptions

__all__ = ["BotBuilderPlugin", "BotBuilderPluginOptions"]
Loading