Django: build a Microsoft Teams bot

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:
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”.
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.
The framework’s reference documentation contains zero information, just automatically generated function stubs.
Inside the framework code, many docstrings have examples written in C#.
Sample apps are split across two repositories: Microsoft/BotBuilder-Samples and OfficeDev/Microsoft-Teams-Samples.
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.
None of the sample apps include unit tests. I needed to reverse-engineer some bits from function signatures and captured HTTP traffic.
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.
The rich text formatting standard, Adaptive Cards, crops message text by default:
Microsoft Teams clients have no way of expanding to see the cropped text. You need to add
"wrap": trueto every text box to allow wrapping. I found this behaviour completely baffling.-
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
msteamsproperty in the card JSON. This detail does not appear in the Adaptive Card designer or documentation, but in this Teams-specific documentation. 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.
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.
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.
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:
Install Django (5.1.1), botbuilder-core (4.16.1), and aiohttp (3.10.5). (Later versions will probably work.)
Save the code in a file called
example.py.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.
Install and open Bot Framework Emulator.
Connect the emulator to
http://localhost:8000/bot/:
Write a message and see the bot respond:

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 🤖"
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts:
- Django: rotate your secret key, fast or slow
- Django: create sub-commands within a management command
- Django: Pinpoint upstream changes with Git
Tags: django