Django: build a Microsoft Teams bot

Early farm robot.

Recently, I built a Microsoft Teams bot for a client, inside their Django project. It wasn’t fun or easy, but the experience did increase my resiliency as a developer. I also went into this forewarned by my wife, a product manager also known as “the integration queen”, who has experienced the difficulties of the Teams API first-hand.

I’m writing this post to leave some breadcrumbs for future adventurers braving this path.

Issues that I encountered

At the core, a Teams bot is a straightforward affair. Microsoft Teams sends HTTP requests to your webhook, to which your code should respond appropriately. Your bot can also send extra requests outside this cycle, to send messages or perform other actions.

Unfortunately, there are a lot of complications to getting this process working. Here are some of the issues that I encountered:

  1. The documentation is a rat’s nest of competing terms, deprecations, and broken links. This seems to have been driven by rebranding the bot “product” and sub-products as AI and pitching for LLM-driven chatbots.

    For example, the Python package is referred to all of: “Bot Framework”, “Bot Framework SDK”, and “Bot Builder SDK”. And the Azure service for configuring a bot is called both “Azure AI Bot Service” and “Azure Bot Service”.

  2. The Bot Framework Python package has been needlessly split into sub-packages. They’re all released together, and presumably, their versions need keeping in sync. Installing the base package (botbuilder-core) pulls in three other inconsistently named ones (botbuilder-schema, botframework-connector, botframework-streaming), and there are eight others you may want to add.

  3. The framework’s reference documentation contains zero information, just automatically generated function stubs.

  4. Inside the framework code, many docstrings have examples written in C#.

  5. Sample apps are split across two repositories: Microsoft/BotBuilder-Samples and OfficeDev/Microsoft-Teams-Samples.

  6. All the sample apps are limited to aiohttp. I have nothing against this, but it’s not very helpful for integrating with existing projects. It seems the only use case they have in mind is deploying your bot on Azure Functions as a microservice, needless complexity for a small bot that integrates with existing data.

  7. None of the sample apps include unit tests. I needed to reverse-engineer some bits from function signatures and captured HTTP traffic.

  8. I couldn’t find a sample that covered sending a message in the background, not in response to a webhook request. I only managed to figure out how to do this by grepping through the framework code until I found methods that enabled me to do that.

  9. The rich text formatting standard, Adaptive Cards, crops message text by default:

    Cropped Adaptive Card example.

    Microsoft Teams clients have no way of expanding to see the cropped text. You need to add "wrap": true to every text box to allow wrapping. I found this behaviour completely baffling.

  10. Update (2024-09-28): Added this point after another team member found it.

    Microsoft Teams limits the width of Adaptive Cards to mobile-sized, making the messages quite ugly. You can fix this by asking for full width in a special msteams property in the card JSON. This detail does not appear in the Adaptive Card designer or documentation, but in this Teams-specific documentation.

  11. The latest version of Adaptive Cards is 1.6, but Microsoft Teams silently drops messages sent with this version. After some iteration, I found that version 1.4 was the latest one to be accepted at the time of this writing.

  12. Documentation is focused on using the Azure Bot Service to configure your bot. After struggling through permission issues with the client’s IT team, I created a bot there but couldn’t get it to work correctly in Teams. But in the process, I found a link to the Teams Developer Portal, which provides an alternative UI for configuring bots. This worked swimmingly.

    I swear this portal wasn’t mentioned in any tutorial that I read. I think it is soft-deprecated in favour of Azure Bot Service, but that’s a shame because it’s so much easier.

  13. The bot testing tool, Bot Framework Emulator, is limited to private chats. Most of the work I needed was with messages sent to a channel, so the emulator didn’t help past initial testing. I resorted to using production credentials locally and posting to a “testing” channel.

  14. I needed channel names to route messages correctly. But the Bot Framework provides channel IDs but not channel names. Fetching the names requires the Microsoft Graph API, with a whole extra package and extra permissions.

    I punted on setting all that up and instead opted for a manually configured mapping in a database model. This works fine for the handful of channels in my project.

Despite these setbacks, I got the bot running and the messages flowing. At least the integration seems to be reliable once it is working.

Example Django project

Below is a Microsoft Teams bot in a single-file Django project. I hope it serves you well as a starting point.

The complete code in my client project stores incoming webhook messages, per my webhook receiver post, and extracts channel data for sending background messages. You may want to add that functionality, too.

To test this code:

  1. Install Django (5.1.1), botbuilder-core (4.16.1), and aiohttp (3.10.5). (Later versions will probably work.)

  2. Save the code in a file called example.py.

  3. Run Django’s development server with:

    $ python example.py runserver
    Performing system checks...
    
    System check identified no issues (0 silenced).
    September 04, 2024 - 12:31:58
    Django version 5.1.1, using settings None
    Starting development server at http://127.0.0.1:8000/
    Quit the server with CONTROL-C.
    
  4. Install and open Bot Framework Emulator.

  5. Connect the emulator to http://localhost:8000/bot/:

    The “Open a bot” screen of Microsoft Bot Framework Emulator.
  6. Write a message and see the bot respond:

    The chat screen of Microsoft Bot Framework Emulator.

Here’s the code:

import json
import os
import sys
from http import HTTPStatus

from asgiref.sync import async_to_sync
from azure.core.exceptions import DeserializationError
from botbuilder.core import (
    BotFrameworkAdapter,
    BotFrameworkAdapterSettings,
    TurnContext,
)
from botbuilder.core.teams import TeamsActivityHandler
from botbuilder.schema import Activity, ActivityTypes, InvokeResponse
from django.conf import settings
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.core.wsgi import get_wsgi_application
from django.urls import path


class BotHandler(TeamsActivityHandler):
    """
    Determines what to do for incoming events with per-category methods.

    https://learn.microsoft.com/en-us/microsoftteams/platform/bots/bot-basics?tabs=python
    """

    async def on_message_activity(self, turn_context: TurnContext) -> None:
        """
        Handle “message activity” events, which correspond to the bot being
        directly messaged.
        """
        if not turn_context.activity.conversation.is_group:
            # Respond to direct messages only.
            return await turn_context.send_activity(
                Activity(
                    type=ActivityTypes.message,
                    text_format="markdown",
                    text="Beep boop 🤖",
                )
            )


bot = BotHandler()

bot_adapter = BotFrameworkAdapter(
    BotFrameworkAdapterSettings(
        # Replace these with settings from environment variables in a real app.
        # None values allow requests from the Bot Framework Emulator.
        app_id=None,
        app_password=None,
    )
)


@async_to_sync
async def call_bot(activity: Activity, auth_header: str) -> InvokeResponse | None:
    """Call the bot to respond to an incoming activity."""
    return await bot_adapter.process_activity(
        activity,
        auth_header,
        bot.on_turn,
    )


# Single-file Django project per:
# https://adamj.eu/tech/2019/04/03/django-versus-flask-with-single-file-applications/

settings.configure(
    DEBUG=(os.environ.get("DEBUG", "") == "1"),
    # Disable host header validation
    ALLOWED_HOSTS=["*"],
    # Make this module the urlconf
    ROOT_URLCONF=__name__,
    # We aren't using any security features but Django requires a secret key
    SECRET_KEY="django-insecure-whatever",
)


@csrf_exempt
@require_POST
def webhook(request):
    """
    Respond to an event from Microsoft Teams.
    """
    if request.content_type != "application/json":
        return HttpResponse(status=HTTPStatus.UNSUPPORTED_MEDIA_TYPE)

    payload = json.loads(request.body)

    # React to the activity
    try:
        activity = Activity.deserialize(payload)
    except DeserializationError:
        return HttpResponse(status=HTTPStatus.BAD_REQUEST)

    auth_header = request.headers.get("authorization", "")
    try:
        invoke_response = call_bot(activity, auth_header)
    # Note: more more except blocks may be needed, per:
    # https://github.com/microsoft/botbuilder-python/blob/main/libraries/botbuilder-core/botbuilder/core/integration/aiohttp_channel_service_exception_middleware.py#L19
    except TypeError:
        response = HttpResponse(status=HTTPStatus.BAD_REQUEST)
    else:
        if invoke_response:
            response = JsonResponse(
                data=invoke_response.body, status=invoke_response.status
            )
        else:
            response = JsonResponse({})

    return response


urlpatterns = [
    path("bot/", webhook),
]

app = get_wsgi_application()

if __name__ == "__main__":
    from django.core.management import execute_from_command_line

    execute_from_command_line(sys.argv)

Example tests

The code below covers some tests for the bot. They depend on requests-mock to mock the requests sent back by the Bot Framework SDK.

To run the test code, put it in tests.py next to example.py, and run:

$ python example.py test
Found 4 test(s).
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.024s

OK

The test code:

from http import HTTPStatus

import requests_mock
from django.test import SimpleTestCase


class BotTests(SimpleTestCase):
    def setUp(self):
        self.mock_requests = self.enterContext(requests_mock.Mocker())

    def test_incorrect_content_type(self):
        response = self.client.post(
            "/bot/",
            content_type="text/plain",
        )
        assert response.status_code == HTTPStatus.UNSUPPORTED_MEDIA_TYPE

    def test_post_non_dict(self):
        response = self.client.post(
            "/bot/",
            content_type="application/json",
            data=[],
        )
        assert response.status_code == HTTPStatus.BAD_REQUEST

    def test_post_empty_dict(self):
        response = self.client.post(
            "/bot/",
            content_type="application/json",
            data={},
        )
        assert response.status_code == HTTPStatus.BAD_REQUEST

    def test_post_message(self):
        self.mock_requests.post(
            "http://localhost:50096/v3/conversations/19%3Aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa%40thread.tacv2%3Bmessageid%3D1111111111111/activities/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
        )
        # Data based on a payload captured from Bot Framework Emulator
        payload = {
            "text": "Hi!",
            "textFormat": "plain",
            "type": "message",
            "channelId": "msteams",
            "from": {
                "id": "82d12900-783f-496d-9449-43dcd216666a",
                "name": "User",
                "role": "user",
            },
            "localTimestamp": "2024-09-04T12:05:50+01:00",
            "localTimezone": "Europe/London",
            "timestamp": "2024-09-04T12:05:50.281Z",
            "channelData": {
                "channel": {
                    "id": "19:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@thread.tacv2",
                },
            },
            "conversation": {
                "id": "19:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@thread.tacv2;messageid=1111111111111",
            },
            "id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
            "recipient": {
                "id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
                "name": "Bot",
                "role": "bot",
            },
            "serviceUrl": "http://localhost:50096",
        }

        response = self.client.post(
            "/bot/",
            content_type="application/json",
            data=payload,
        )

        assert response.status_code == HTTPStatus.OK
        assert len(self.mock_requests.request_history) == 1
        data = self.mock_requests.request_history[0].json()
        assert data["type"] == "message"
        assert data["text"] == "Beep boop 🤖"

Fin

May you find bots easier to create,

—Adam


😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸


Subscribe via RSS, Twitter, Mastodon, or email:

One summary email a week, no spam, I pinky promise.

Related posts:

Tags: