Skip to content

Commit 9ecfc1b

Browse files
MehakBindraMehak Bindra
andauthored
[feat] Add BotBuilder Plugin package, sample and tests (#190)
- Added `BotBuilderPlugin` (overrides http plugin's `on_activity_request`) - Refactored `HttpPlugin` to easily extend to botbuilder without code duplication - Added a test sample - Added unit tests - Added `HttpPluginOptions` to be able to extend easily - Moved jwt validation initialization to on init in http plugin to leverage DI for credentials <img width="843" height="164" alt="Screenshot 2025-10-28 171527" src="https://github.com/user-attachments/assets/b0b8e18f-e0c8-4407-927c-3d4049bf8a8d" /> --------- Co-authored-by: Mehak Bindra <[email protected]>
1 parent 4454bac commit 9ecfc1b

File tree

18 files changed

+700
-58
lines changed

18 files changed

+700
-58
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ A comprehensive SDK for building Microsoft Teams applications, bots, and AI agen
5959
- [`microsoft-teams-devtools`](./packages/devtools/README.md)
6060
- [`microsoft-teams-graph`](./packages/graph/README.md)
6161
- [`microsoft-teams-openai`](./packages/openai/README.md)
62+
- [`microsoft-teams-botbuilder`](./packages/botbuilder/README.md)
6263

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

packages/apps/src/microsoft/teams/apps/app.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,7 @@ def __init__(self, **options: Unpack[AppOptions]):
114114
break
115115

116116
if not http_plugin:
117-
app_id = None
118-
if self.credentials and hasattr(self.credentials, "client_id"):
119-
app_id = self.credentials.client_id
120-
121-
http_plugin = HttpPlugin(app_id, self.log, self.options.skip_auth)
117+
http_plugin = HttpPlugin(logger=self.log, skip_auth=self.options.skip_auth)
122118

123119
plugins.insert(0, http_plugin)
124120
self.http = cast(HttpPlugin, http_plugin)

packages/apps/src/microsoft/teams/apps/http_plugin.py

Lines changed: 83 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,19 @@
99
from logging import Logger
1010
from pathlib import Path
1111
from types import SimpleNamespace
12-
from typing import Annotated, Any, AsyncGenerator, Awaitable, Callable, Dict, Optional, cast
12+
from typing import (
13+
Annotated,
14+
Any,
15+
AsyncGenerator,
16+
Awaitable,
17+
Callable,
18+
Dict,
19+
Optional,
20+
TypedDict,
21+
Union,
22+
Unpack,
23+
cast,
24+
)
1325

1426
import uvicorn
1527
from fastapi import FastAPI, Request, Response
@@ -23,6 +35,7 @@
2335
SentActivity,
2436
TokenProtocol,
2537
)
38+
from microsoft.teams.api.auth.credentials import Credentials
2639
from microsoft.teams.apps.http_stream import HttpStream
2740
from microsoft.teams.common.http import Client, ClientOptions, Token
2841
from microsoft.teams.common.logging import ConsoleLogger
@@ -47,13 +60,22 @@
4760
version = importlib.metadata.version("microsoft-teams-apps")
4861

4962

63+
class HttpPluginOptions(TypedDict, total=False):
64+
"""Options for configuring the HTTP plugin."""
65+
66+
logger: Logger
67+
skip_auth: bool
68+
server_factory: Callable[[FastAPI], uvicorn.Server]
69+
70+
5071
@Plugin(name="http", version=version, description="the default plugin for sending/receiving activities")
5172
class HttpPlugin(Sender):
5273
"""
5374
Basic HTTP plugin that provides a FastAPI server for Teams activities.
5475
"""
5576

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

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

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

67-
def __init__(
68-
self,
69-
app_id: Optional[str],
70-
logger: Optional[Logger] = None,
71-
skip_auth: bool = False,
72-
server_factory: Optional[Callable[[FastAPI], uvicorn.Server]] = None,
73-
):
89+
def __init__(self, **options: Unpack[HttpPluginOptions]):
7490
"""
7591
Args:
76-
app_id: Optional Microsoft App ID.
7792
logger: Optional logger.
7893
skip_auth: Whether to skip JWT validation.
7994
server_factory: Optional function that takes an ASGI app
@@ -88,8 +103,9 @@ def custom_server_factory(app: FastAPI) -> uvicorn.Server:
88103
```
89104
"""
90105
super().__init__()
91-
self.logger = logger or ConsoleLogger().create_logger("@teams/http-plugin")
106+
self.logger = options.get("logger") or ConsoleLogger().create_logger("@teams/http-plugin")
92107
self._port: Optional[int] = None
108+
self._skip_auth: bool = options.get("skip_auth", False)
93109
self._server: Optional[uvicorn.Server] = None
94110
self._on_ready_callback: Optional[Callable[[], Awaitable[None]]] = None
95111
self._on_stopped_callback: Optional[Callable[[], Awaitable[None]]] = None
@@ -122,20 +138,14 @@ async def combined_lifespan(app: Starlette):
122138
self.app = FastAPI(lifespan=combined_lifespan)
123139

124140
# Create uvicorn server if user provides custom factory method
141+
server_factory = options.get("server_factory")
125142
if server_factory:
126143
self._server = server_factory(self.app)
127144
if self._server.config.app is not self.app:
128145
raise ValueError(
129146
"server_factory must return a uvicorn.Server configured with the provided FastAPI app instance."
130147
)
131148

132-
# Add JWT validation middleware
133-
if app_id and not skip_auth:
134-
jwt_middleware = create_jwt_validation_middleware(
135-
app_id=app_id, logger=self.logger, paths=["/api/messages"]
136-
)
137-
self.app.middleware("http")(jwt_middleware)
138-
139149
# Expose FastAPI routing methods (like TypeScript exposes Express methods)
140150
self.get = self.app.get
141151
self.post = self.app.post
@@ -167,6 +177,20 @@ def on_stopped_callback(self, callback: Optional[Callable[[], Awaitable[None]]])
167177
"""Set callback to call when HTTP server is stopped."""
168178
self._on_stopped_callback = callback
169179

180+
async def on_init(self) -> None:
181+
"""
182+
Initialize the HTTP plugin when the app starts.
183+
This adds JWT validation middleware unless `skip_auth` is True.
184+
"""
185+
186+
# Add JWT validation middleware
187+
app_id = getattr(self.credentials, "client_id", None)
188+
if app_id and not self._skip_auth:
189+
jwt_middleware = create_jwt_validation_middleware(
190+
app_id=app_id, logger=self.logger, paths=["/api/messages"]
191+
)
192+
self.app.middleware("http")(jwt_middleware)
193+
170194
async def on_start(self, event: PluginStartEvent) -> None:
171195
"""Start the HTTP server."""
172196
self._port = event.port
@@ -337,38 +361,51 @@ async def _handle_activity_request(self, request: Request) -> Any:
337361

338362
return result
339363

364+
def _handle_activity_response(self, response: Response, result: Any) -> Union[Response, Dict[str, object]]:
365+
"""
366+
Handle the activity response formatting.
367+
368+
Args:
369+
response: The FastAPI response object
370+
result: The result from activity processing
371+
372+
Returns:
373+
The formatted response
374+
"""
375+
status_code: Optional[int] = None
376+
body: Optional[Dict[str, Any]] = None
377+
resp_dict: Optional[Dict[str, Any]] = None
378+
if isinstance(result, dict):
379+
resp_dict = cast(Dict[str, Any], result)
380+
elif isinstance(result, BaseModel):
381+
resp_dict = result.model_dump(exclude_none=True)
382+
383+
# if resp_dict has status set it
384+
if resp_dict and "status" in resp_dict:
385+
status_code = resp_dict.get("status")
386+
387+
if resp_dict and "body" in resp_dict:
388+
body = resp_dict.get("body", None)
389+
390+
if status_code is not None:
391+
response.status_code = status_code
392+
393+
if body is not None:
394+
self.logger.debug(f"Returning body {body}")
395+
return body
396+
self.logger.debug("Returning empty body")
397+
return response
398+
399+
async def on_activity_request(self, request: Request, response: Response) -> Any:
400+
"""Handle incoming Teams activity."""
401+
# Process the activity (token validation handled by middleware)
402+
result = await self._handle_activity_request(request)
403+
return self._handle_activity_response(response, result)
404+
340405
def _setup_routes(self) -> None:
341406
"""Setup FastAPI routes."""
342407

343-
async def on_activity_request(request: Request, response: Response) -> Any:
344-
"""Handle incoming Teams activity."""
345-
# Process the activity (token validation handled by middleware)
346-
result = await self._handle_activity_request(request)
347-
status_code: Optional[int] = None
348-
body: Optional[Dict[str, Any]] = None
349-
resp_dict: Optional[Dict[str, Any]] = None
350-
if isinstance(result, dict):
351-
resp_dict = cast(Dict[str, Any], result)
352-
elif isinstance(result, BaseModel):
353-
resp_dict = result.model_dump(exclude_none=True)
354-
355-
# if resp_dict has status set it
356-
if resp_dict and "status" in resp_dict:
357-
status_code = resp_dict.get("status")
358-
359-
if resp_dict and "body" in resp_dict:
360-
body = resp_dict.get("body", None)
361-
362-
if status_code is not None:
363-
response.status_code = status_code
364-
365-
if body is not None:
366-
self.logger.debug(f"Returning body {body}")
367-
return body
368-
self.logger.debug("Returning empty body")
369-
return response
370-
371-
self.app.post("/api/messages")(on_activity_request)
408+
self.app.post("/api/messages")(self.on_activity_request)
372409

373410
async def health_check() -> Dict[str, Any]:
374411
"""Basic health check endpoint."""

packages/apps/tests/test_http_plugin.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,31 +32,31 @@ def mock_logger(self):
3232
@pytest.fixture
3333
def plugin_with_validator(self, mock_logger):
3434
"""Create HttpPlugin with token validator."""
35-
return HttpPlugin("test-app-id", mock_logger)
35+
return HttpPlugin(app_id="test-app-id", logger=mock_logger)
3636

3737
@pytest.fixture
3838
def plugin_without_validator(self, mock_logger):
3939
"""Create HttpPlugin without token validator."""
40-
return HttpPlugin(None, mock_logger)
40+
return HttpPlugin(app_id=None, logger=mock_logger)
4141

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

4646
assert plugin.logger == mock_logger
4747
assert plugin.app is not None
4848
assert plugin.pending == {}
4949

5050
def test_init_without_app_id(self, mock_logger):
5151
"""Test HttpPlugin initialization without app ID."""
52-
plugin = HttpPlugin(None, mock_logger)
52+
plugin = HttpPlugin(app_id=None, logger=mock_logger)
5353

5454
assert plugin.logger == mock_logger
5555
assert plugin.app is not None
5656

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

6161
assert plugin.logger is not None
6262

@@ -280,7 +280,7 @@ def test_middleware_setup(self, plugin_with_validator, plugin_without_validator)
280280

281281
def test_logger_property(self, mock_logger):
282282
"""Test logger property assignment."""
283-
plugin = HttpPlugin("test-app-id", mock_logger)
283+
plugin = HttpPlugin(app_id="test-app-id", logger=mock_logger)
284284
assert plugin.logger == mock_logger
285285

286286
def test_app_property(self, plugin_with_validator):

packages/botbuilder/README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Microsoft Teams BotBuilder
2+
3+
<p>
4+
<a href="https://pypi.org/project/microsoft-teams-botbuilder/" target="_blank">
5+
<img src="https://img.shields.io/pypi/v/microsoft-teams-botbuilder" />
6+
</a>
7+
<a href="https://pypi.org/project/microsoft-teams-botbuilder" target="_blank">
8+
<img src="https://img.shields.io/pypi/dw/microsoft-teams-botbuilder" />
9+
</a>
10+
</p>
11+
12+
A package used to make the `@microsoft/teams.apps` package backwards compatible with legacy bots built using
13+
`BotBuilder`.
14+
15+
<a href="https://microsoft.github.io/teams-ai" target="_blank">
16+
<img src="https://img.shields.io/badge/📖 Getting Started-blue?style=for-the-badge" />
17+
</a>

packages/botbuilder/pyproject.toml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[project]
2+
name = "microsoft-teams-botbuilder"
3+
version = "2.0.0a5"
4+
description = "BotBuilder plugin for Microsoft Teams"
5+
authors = [{ name = "Microsoft", email = "[email protected]" }]
6+
readme = "README.md"
7+
requires-python = ">=3.12,<3.14"
8+
repository = "https://github.com/microsoft/teams.py"
9+
keywords = ["microsoft", "teams", "ai", "bot", "agents"]
10+
license = "MIT"
11+
dependencies = [
12+
"botbuilder-core>=4.14.0",
13+
"botbuilder-integration-aiohttp>=4.17.0",
14+
"microsoft-teams-apps",
15+
"microsoft-teams-api",
16+
"microsoft-teams-common"
17+
]
18+
19+
[project.urls]
20+
Homepage = "https://github.com/microsoft/teams.py/tree/main/packages/botbuilder/src/microsoft/teams/botbuilder"
21+
22+
23+
[build-system]
24+
requires = ["hatchling"]
25+
build-backend = "hatchling.build"
26+
27+
[tool.hatch.build.targets.wheel]
28+
packages = ["src/microsoft"]
29+
30+
[tool.hatch.build.targets.sdist]
31+
include = ["src"]
32+
33+
[tool.uv.sources]
34+
microsoft-teams-apps = { workspace = true }
35+
microsoft-teams-api = { workspace = true }
36+
microsoft-teams-common = { workspace = true }
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from .botbuilder_plugin import BotBuilderPlugin, BotBuilderPluginOptions
7+
8+
__all__ = ["BotBuilderPlugin", "BotBuilderPluginOptions"]

0 commit comments

Comments
 (0)