diff --git a/README.md b/README.md index 7dd43e5c..73ca41cb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/packages/apps/src/microsoft/teams/apps/app.py b/packages/apps/src/microsoft/teams/apps/app.py index f8b858f0..5e2ee419 100644 --- a/packages/apps/src/microsoft/teams/apps/app.py +++ b/packages/apps/src/microsoft/teams/apps/app.py @@ -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) diff --git a/packages/apps/src/microsoft/teams/apps/http_plugin.py b/packages/apps/src/microsoft/teams/apps/http_plugin.py index 7dcf5fa3..d4f18625 100644 --- a/packages/apps/src/microsoft/teams/apps/http_plugin.py +++ b/packages/apps/src/microsoft/teams/apps/http_plugin.py @@ -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 @@ -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 @@ -47,6 +60,14 @@ 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): """ @@ -54,6 +75,7 @@ class HttpPlugin(Sender): """ 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")] @@ -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 @@ -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 @@ -122,6 +138,7 @@ 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: @@ -129,13 +146,6 @@ async def combined_lifespan(app: Starlette): "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 @@ -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 @@ -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.""" diff --git a/packages/apps/tests/test_http_plugin.py b/packages/apps/tests/test_http_plugin.py index 8eec2101..f1bf6586 100644 --- a/packages/apps/tests/test_http_plugin.py +++ b/packages/apps/tests/test_http_plugin.py @@ -32,16 +32,16 @@ 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 @@ -49,14 +49,14 @@ def test_init_with_app_id(self, mock_logger): 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 @@ -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): diff --git a/packages/botbuilder/README.md b/packages/botbuilder/README.md new file mode 100644 index 00000000..998bb25a --- /dev/null +++ b/packages/botbuilder/README.md @@ -0,0 +1,17 @@ +# Microsoft Teams BotBuilder + +

+ + + + + + +

+ +A package used to make the `@microsoft/teams.apps` package backwards compatible with legacy bots built using +`BotBuilder`. + + + + diff --git a/packages/botbuilder/pyproject.toml b/packages/botbuilder/pyproject.toml new file mode 100644 index 00000000..039c89be --- /dev/null +++ b/packages/botbuilder/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "microsoft-teams-botbuilder" +version = "2.0.0a5" +description = "BotBuilder plugin for Microsoft Teams" +authors = [{ name = "Microsoft", email = "TeamsAISDKFeedback@microsoft.com" }] +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 } \ No newline at end of file diff --git a/packages/botbuilder/src/microsoft/teams/botbuilder/__init__.py b/packages/botbuilder/src/microsoft/teams/botbuilder/__init__.py new file mode 100644 index 00000000..73a28a2d --- /dev/null +++ b/packages/botbuilder/src/microsoft/teams/botbuilder/__init__.py @@ -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"] diff --git a/packages/botbuilder/src/microsoft/teams/botbuilder/botbuilder_plugin.py b/packages/botbuilder/src/microsoft/teams/botbuilder/botbuilder_plugin.py new file mode 100644 index 00000000..5045c4ef --- /dev/null +++ b/packages/botbuilder/src/microsoft/teams/botbuilder/botbuilder_plugin.py @@ -0,0 +1,138 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import importlib.metadata +from logging import Logger +from types import SimpleNamespace +from typing import Annotated, Any, Optional, Unpack, cast + +from fastapi import HTTPException, Request, Response +from microsoft.teams.api import Credentials +from microsoft.teams.apps import ( + DependencyMetadata, + HttpPlugin, + LoggerDependencyOptions, + Plugin, +) +from microsoft.teams.apps.http_plugin import HttpPluginOptions + +from botbuilder.core import ( + ActivityHandler, + TurnContext, +) +from botbuilder.integration.aiohttp import ( + CloudAdapter, + ConfigurationBotFrameworkAuthentication, +) +from botbuilder.schema import Activity + +version = importlib.metadata.version("microsoft-teams-botbuilder") + +# Constants for app types +SINGLE_TENANT = "singletenant" +MULTI_TENANT = "multitenant" + + +class BotBuilderPluginOptions(HttpPluginOptions, total=False): + """Options for configuring the BotBuilder plugin.""" + + handler: ActivityHandler + adapter: CloudAdapter + + +@Plugin(name="http", version=version, description="BotBuilder plugin for Microsoft Bot Framework integration") +class BotBuilderPlugin(HttpPlugin): + """ + BotBuilder plugin that provides Microsoft Bot Framework integration. + """ + + # Dependency injections + logger: Annotated[Logger, LoggerDependencyOptions()] + credentials: Annotated[Optional[Credentials], DependencyMetadata(optional=True)] + + def __init__(self, **options: Unpack[BotBuilderPluginOptions]): + """ + Initialize the BotBuilder plugin. + + Args: + options: Configuration options for the plugin + """ + + self.handler: Optional[ActivityHandler] = options.get("handler") + self.adapter: Optional[CloudAdapter] = options.get("adapter") + + super().__init__(**options) + + async def on_init(self) -> None: + """Initialize the plugin when the app starts.""" + await super().on_init() + + if not self.adapter: + # Extract credentials for Bot Framework authentication + client_id: Optional[str] = None + client_secret: Optional[str] = None + tenant_id: Optional[str] = None + + if self.credentials: + client_id = getattr(self.credentials, "client_id", None) + client_secret = getattr(self.credentials, "client_secret", None) + tenant_id = getattr(self.credentials, "tenant_id", None) + + config = SimpleNamespace( + APP_TYPE=SINGLE_TENANT if tenant_id else MULTI_TENANT, + APP_ID=client_id, + APP_PASSWORD=client_secret, + APP_TENANTID=tenant_id, + ) + + bot_framework_auth = ConfigurationBotFrameworkAuthentication(configuration=config) + self.adapter = CloudAdapter(bot_framework_auth) + + self.logger.debug("BotBuilder plugin initialized successfully") + + async def on_activity_request(self, request: Request, response: Response) -> Any: + """ + Handles an incoming activity. + + Overrides the base HTTP plugin behavior to: + 1. Process the activity using the Bot Framework adapter/handler. + 2. Then pass the request to the Teams plugin pipeline (_handle_activity_request). + + Returns the final HTTP response. + """ + if not self.adapter: + raise RuntimeError("plugin not registered") + + try: + # Parse activity data + body = await request.json() + activity_bf = cast(Activity, Activity().deserialize(body)) + + # A POST request must contain an Activity + if not activity_bf.type: + raise HTTPException(status_code=400, detail="Missing activity type") + + async def logic(turn_context: TurnContext): + if not turn_context.activity.id: + return + + # Handle activity with botframework handler + if self.handler: + await self.handler.on_turn(turn_context) + + # Grab the auth header from the inbound request + auth_header = request.headers["Authorization"] if "Authorization" in request.headers else "" + await self.adapter.process_activity(auth_header, activity_bf, logic) + + # Call HTTP plugin to handle activity request + result = await self._handle_activity_request(request) + return self._handle_activity_response(response, result) + + except HTTPException as http_err: + self.logger.error(f"HTTP error processing activity: {http_err}", exc_info=True) + raise + except Exception as err: + self.logger.error(f"Error processing activity: {err}", exc_info=True) + raise HTTPException(status_code=500, detail=str(err)) from err diff --git a/packages/botbuilder/tests/test_botbuilder_plugin.py b/packages/botbuilder/tests/test_botbuilder_plugin.py new file mode 100644 index 00000000..33d117d8 --- /dev/null +++ b/packages/botbuilder/tests/test_botbuilder_plugin.py @@ -0,0 +1,113 @@ +# pyright: reportMissingTypeStubs=false +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from botbuilder.core import ActivityHandler, TurnContext +from botbuilder.integration.aiohttp import CloudAdapter +from botbuilder.schema import Activity +from fastapi import HTTPException, Request, Response +from microsoft.teams.api import Credentials +from microsoft.teams.botbuilder import BotBuilderPlugin + + +class TestBotBuilderPlugin: + """Tests for BotBuilderPlugin.""" + + @pytest.fixture + def mock_logger(self): + return MagicMock() + + @pytest.fixture + def plugin_without_adapter(self): + plugin = BotBuilderPlugin(skip_auth=True) + plugin.credentials = MagicMock(spec=Credentials) + plugin.credentials.client_id = "abc" + plugin.credentials.client_secret = "secret" + plugin.credentials.tenant_id = "tenant-123" + return plugin + + @pytest.fixture + def plugin_with_adapter(self) -> BotBuilderPlugin: + adapter = MagicMock(spec=CloudAdapter) + plugin = BotBuilderPlugin(adapter=adapter, skip_auth=True) + plugin._handle_activity_request = AsyncMock(return_value="fake_result") # pyright: ignore[reportPrivateUsage] + handler = AsyncMock(spec=ActivityHandler) + plugin.handler = handler + return plugin + + @pytest.mark.asyncio + async def test_on_init_creates_adapter_when_missing(self, plugin_without_adapter: BotBuilderPlugin): + assert plugin_without_adapter.adapter is None + + with ( + patch("microsoft.teams.botbuilder.botbuilder_plugin.CloudAdapter") as mock_adapter_class, + patch( + "microsoft.teams.botbuilder.botbuilder_plugin.ConfigurationBotFrameworkAuthentication" + ) as mock_config_class, + ): + mock_adapter_class.return_value = "mock_adapter" + await plugin_without_adapter.on_init() + + mock_config_class.assert_called_once() + mock_adapter_class.assert_called_once() + assert plugin_without_adapter.adapter == "mock_adapter" + + @pytest.mark.asyncio + async def test_on_activity_request_calls_adapter_and_handler(self, plugin_with_adapter: BotBuilderPlugin): + # Mock request and response + activity_data = { + "type": "message", + "id": "activity-id", + "from": {"id": "user1", "name": "Test User"}, + "recipient": {"id": "bot1", "name": "Test Bot"}, + "conversation": {"id": "conv1"}, + "serviceUrl": "https://service.url", + } + request = AsyncMock(spec=Request) + request.json.return_value = activity_data + request.headers = {"Authorization": "Bearer token"} + + response = MagicMock(spec=Response) + + # Mock adapter.process_activity to call logic with a mock TurnContext + async def fake_process_activity(auth_header, activity, logic): # type: ignore + print("Inside fake_process_activity") + await logic(MagicMock(spec=TurnContext)) + + assert plugin_with_adapter.adapter is not None + + plugin_with_adapter.adapter.process_activity = AsyncMock(side_effect=fake_process_activity) + + await plugin_with_adapter.on_activity_request(request, response) + + # Ensure adapter.process_activity called with correct auth and activity + plugin_with_adapter.adapter.process_activity.assert_called_once() + called_auth, called_activity, _ = plugin_with_adapter.adapter.process_activity.call_args[0] + assert called_auth == "Bearer token" + assert isinstance(called_activity, Activity) + + # Ensure handler called via TurnContext + plugin_with_adapter.handler.on_turn.assert_awaited() # type: ignore + + @pytest.mark.asyncio + async def test_on_activity_request_raises_http_exception_on_adapter_error( + self, plugin_with_adapter: BotBuilderPlugin + ): + activity_data = {"type": "message", "id": "activity-id"} + request = AsyncMock(spec=Request) + request.json.return_value = activity_data + request.headers = {} + + response = MagicMock(spec=Response) + assert plugin_with_adapter.adapter is not None + + plugin_with_adapter.adapter.process_activity = AsyncMock(side_effect=Exception("fail")) + + with pytest.raises(HTTPException) as exc: + await plugin_with_adapter.on_activity_request(request, response) + assert exc.value.status_code == 500 diff --git a/pyproject.toml b/pyproject.toml index 74287ac6..46d54ac3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ "microsoft-teams-openai" = { workspace = true } "microsoft-teams-mcpplugin" = { workspace = true } "microsoft-teams-a2a" = { workspace = true } +"microsoft-teams-botbuilder" = { workspace = true } [tool.uv.workspace] members = ["packages/*", "tests/*"] diff --git a/pyrightconfig.json b/pyrightconfig.json index 1490796f..a9ac9f08 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -16,7 +16,8 @@ "packages/ai/src", "packages/openai/src", "packages/mcpplugin/src", - "packages/a2aprotocol/src" + "packages/a2aprotocol/src", + "packages/botbuilder/src" ], "typeCheckingMode": "strict", "executionEnvironments": [ @@ -24,6 +25,16 @@ "root": "packages/api/src", "reportIncompatibleVariableOverride": "none", "reportIncompatibleMethodOverride": "none" + }, + { + "root": "packages/botbuilder/src", + "reportMissingTypeStubs": "none", + "reportUnknownMemberType": "none" + }, + { + "root": "tests/botbuilder/src", + "reportMissingTypeStubs": "none", + "reportUnknownMemberType": "none" } ] } \ No newline at end of file diff --git a/tests/botbuilder/README.md b/tests/botbuilder/README.md new file mode 100644 index 00000000..e69de29b diff --git a/tests/botbuilder/pyproject.toml b/tests/botbuilder/pyproject.toml new file mode 100644 index 00000000..f21529ac --- /dev/null +++ b/tests/botbuilder/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "botbuilder" +version = "0.1.0" +description = "Botbuilder sample" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "dotenv>=0.9.9", + "botbuilder-core>=4.14.0", + "microsoft-teams-apps", + "microsoft-teams-devtools", + "microsoft-teams-botbuilder" +] + +[tool.uv.sources] +microsoft-teams-apps = { workspace = true } diff --git a/tests/botbuilder/src/bots/__init__.py b/tests/botbuilder/src/bots/__init__.py new file mode 100644 index 00000000..e3a12730 --- /dev/null +++ b/tests/botbuilder/src/bots/__init__.py @@ -0,0 +1,11 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .echo_bot import EchoBot + +__all__ = ["EchoBot"] diff --git a/tests/botbuilder/src/bots/echo_bot.py b/tests/botbuilder/src/bots/echo_bot.py new file mode 100644 index 00000000..6fb03dd7 --- /dev/null +++ b/tests/botbuilder/src/bots/echo_bot.py @@ -0,0 +1,12 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext + + +class EchoBot(ActivityHandler): + async def on_message_activity(self, turn_context: TurnContext): + print("Message activity received.") + await turn_context.send_activity(MessageFactory.text(f"Echo: {turn_context.activity.text}")) diff --git a/tests/botbuilder/src/config.py b/tests/botbuilder/src/config.py new file mode 100644 index 00000000..abf71a0b --- /dev/null +++ b/tests/botbuilder/src/config.py @@ -0,0 +1,24 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import os + +from dotenv import find_dotenv, load_dotenv + +# Constants for app types +SINGLE_TENANT = "singletenant" +MULTI_TENANT = "multitenant" + + +class DefaultConfig: + """Bot Configuration""" + + def __init__(self): + load_dotenv(find_dotenv(usecwd=True)) + self.PORT = os.getenv("PORT", "") + self.APP_ID = os.getenv("CLIENT_ID", "") + self.APP_PASSWORD = os.getenv("CLIENT_SECRET", "") + self.APP_TENANTID = os.getenv("TENANT_ID", "") + self.APP_TYPE = SINGLE_TENANT if self.APP_TENANTID else MULTI_TENANT diff --git a/tests/botbuilder/src/main.py b/tests/botbuilder/src/main.py new file mode 100644 index 00000000..f39b094d --- /dev/null +++ b/tests/botbuilder/src/main.py @@ -0,0 +1,63 @@ +""" +Copyright (c) Microsoft Corporation. All rights reserved. +Licensed under the MIT License. +""" + +import asyncio +import datetime +import sys +import traceback + +from botbuilder.core import TurnContext +from botbuilder.integration.aiohttp import ( + CloudAdapter, + ConfigurationBotFrameworkAuthentication, +) +from botbuilder.schema import Activity, ActivityTypes +from bots.echo_bot import EchoBot +from config import DefaultConfig +from microsoft.teams.api import MessageActivity +from microsoft.teams.apps import ActivityContext, App +from microsoft.teams.botbuilder import BotBuilderPlugin +from microsoft.teams.devtools import DevToolsPlugin + +config = DefaultConfig() +adapter = CloudAdapter(ConfigurationBotFrameworkAuthentication(config)) + + +# Catch-all for errors. +async def on_error(context: TurnContext, error: Exception): + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.datetime.now(), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + await context.send_activity(trace_activity) + + +adapter.on_turn_error = on_error + +# Provide the Bot Framework's adapter and activity handler to `BotBuilderPlugin` +# BotBuilderPlugin will route incoming Teams messages through the adapter +# and invoke the handler to process and respond to each activity. +app = App(plugins=[BotBuilderPlugin(adapter=adapter, handler=EchoBot()), DevToolsPlugin()]) + + +@app.on_message +async def handle_message(ctx: ActivityContext[MessageActivity]): + print("Handling message in app...") + await ctx.send("hi from teams...") + + +if __name__ == "__main__": + asyncio.run(app.start()) diff --git a/uv.lock b/uv.lock index f1c9be73..3fef0db9 100644 --- a/uv.lock +++ b/uv.lock @@ -10,6 +10,7 @@ resolution-markers = [ members = [ "a2a", "ai-test", + "botbuilder", "cards", "dialogs", "echo", @@ -21,6 +22,7 @@ members = [ "microsoft-teams-ai", "microsoft-teams-api", "microsoft-teams-apps", + "microsoft-teams-botbuilder", "microsoft-teams-cards", "microsoft-teams-common", "microsoft-teams-devtools", @@ -277,6 +279,94 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006, upload-time = "2017-08-03T15:55:31.23Z" }, ] +[[package]] +name = "botbuilder" +version = "0.1.0" +source = { virtual = "tests/botbuilder" } +dependencies = [ + { name = "botbuilder-core" }, + { name = "dotenv" }, + { name = "microsoft-teams-apps" }, + { name = "microsoft-teams-botbuilder" }, + { name = "microsoft-teams-devtools" }, +] + +[package.metadata] +requires-dist = [ + { name = "botbuilder-core", specifier = ">=4.14.0" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "microsoft-teams-botbuilder", editable = "packages/botbuilder" }, + { name = "microsoft-teams-devtools", editable = "packages/devtools" }, +] + +[[package]] +name = "botbuilder-core" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botbuilder-schema" }, + { name = "botframework-connector" }, + { name = "botframework-streaming" }, + { name = "jsonpickle" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/51/0e4d0ba1fc25d57977e17d7bfd55c7bd06a9d8403d5efb23e6fc512356fa/botbuilder_core-4.17.0-py3-none-any.whl", hash = "sha256:56828b11d9af663a200fba0fae4e0b9ad6f815a679c3e90e9b6425b9cbe16d53", size = 116148, upload-time = "2025-05-29T15:10:00.474Z" }, +] + +[[package]] +name = "botbuilder-integration-aiohttp" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "botbuilder-core" }, + { name = "botbuilder-schema" }, + { name = "botframework-connector" }, + { name = "yarl" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/e2/78d9ed5beeeaf9dcdcfd8b7dd0fe4116c8b3ed55b81eb9b01b28037a2b7c/botbuilder_integration_aiohttp-4.17.0-py3-none-any.whl", hash = "sha256:30cd8de3eeec132463bf1834fbd6cfbc6c4a9a361bf478627b6b2f0e7ee211b6", size = 19030, upload-time = "2025-05-29T15:10:02.884Z" }, +] + +[[package]] +name = "botbuilder-schema" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msrest" }, + { name = "urllib3" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/10/db5e16bf91fe60b78c88bf42b46e87af7f75097303cece71478a5ae46ccf/botbuilder_schema-4.17.0-py2.py3-none-any.whl", hash = "sha256:97219f8361a91bfa1529ba1231100b527adbe7c4c249d6b23fe1bffaf012b31b", size = 38197, upload-time = "2025-05-29T15:10:05.111Z" }, +] + +[[package]] +name = "botframework-connector" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botbuilder-schema" }, + { name = "msal" }, + { name = "msrest" }, + { name = "pyjwt" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/df/8daa548a5c73d673e8464cbd156dd8235d6efc35296ad45e89ff9f97c109/botframework_connector-4.17.0-py2.py3-none-any.whl", hash = "sha256:51b8702cc348c63efbdb571f6a4da868d1660efb80ed77d6e04f081720105a87", size = 100886, upload-time = "2025-05-29T15:10:07.271Z" }, +] + +[[package]] +name = "botframework-streaming" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botbuilder-schema" }, + { name = "botframework-connector" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/d2/c55470c918c403ebd76b3c1b54e312021bf7edacaf61e4b1c3b132216490/botframework_streaming-4.17.0-py3-none-any.whl", hash = "sha256:ed64fd2a9f56a3d32dd252bf378d0ce145a9bbc9c9a9a0e770889c7d140df435", size = 41953, upload-time = "2025-05-29T15:10:08.499Z" }, +] + [[package]] name = "cachetools" version = "6.2.1" @@ -1038,6 +1128,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/87/fc632776344e7aabbab05a95a0075476f418c5d29ab0f2eec672b7a1f0ac/jiter-0.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a4d71d7ea6ea8786291423fe209acf6f8d398a0759d03e7f24094acb8ab686ba", size = 204225, upload-time = "2025-09-15T09:20:03.102Z" }, ] +[[package]] +name = "jsonpickle" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/df/8072fb98c12d78dd29b4a52c50af7ab548f84166b8a3d363c1c754c14af0/jsonpickle-1.4.2.tar.gz", hash = "sha256:c9b99b28a9e6a3043ec993552db79f4389da11afcb1d0246d93c79f4b5e64062", size = 104745, upload-time = "2020-11-30T03:21:56.571Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/d5/1cc282dc23346a43aab461bf2e8c36593aacd34242bee1a13fa750db0cfe/jsonpickle-1.4.2-py2.py3-none-any.whl", hash = "sha256:2ac5863099864c63d7f0c367af5e512c94f3384977dd367f2eae5f2303f7b92c", size = 36529, upload-time = "2020-11-30T03:21:53.857Z" }, +] + [[package]] name = "jsonschema" version = "4.25.1" @@ -1453,6 +1552,27 @@ test = [ { name = "pytest-asyncio", specifier = ">=0.24.0" }, ] +[[package]] +name = "microsoft-teams-botbuilder" +version = "2.0.0a5" +source = { editable = "packages/botbuilder" } +dependencies = [ + { name = "botbuilder-core" }, + { name = "botbuilder-integration-aiohttp" }, + { name = "microsoft-teams-api" }, + { name = "microsoft-teams-apps" }, + { name = "microsoft-teams-common" }, +] + +[package.metadata] +requires-dist = [ + { name = "botbuilder-core", specifier = ">=4.14.0" }, + { name = "botbuilder-integration-aiohttp", specifier = ">=4.17.0" }, + { name = "microsoft-teams-api", editable = "packages/api" }, + { name = "microsoft-teams-apps", editable = "packages/apps" }, + { name = "microsoft-teams-common", editable = "packages/common" }, +] + [[package]] name = "microsoft-teams-cards" version = "2.0.0a5" @@ -1662,6 +1782,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/c6/6300f0a7759d3546f8a0a69f417f7f9f8def7f43753ae525869abd31f29c/msgraph_sdk-1.46.0-py3-none-any.whl", hash = "sha256:9367551a61edb08dcab96bcadb3e7441401cddf2d2f9d63acca2beeeffb38487", size = 24775723, upload-time = "2025-10-06T21:44:43.188Z" }, ] +[[package]] +name = "msrest" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-core" }, + { name = "certifi" }, + { name = "isodate" }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" }, +] + [[package]] name = "multidict" version = "6.7.0" @@ -1749,6 +1885,15 @@ requires-dist = [ { name = "microsoft-teams-apps", editable = "packages/apps" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "openai" version = "2.4.0" @@ -2331,6 +2476,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4"