Skip to content

Teams Providers

Microsoft Teams integration via the Bot Framework SDK. Supports inbound webhook parsing and proactive outbound messaging through stored conversation references.

Quick start

from roomkit import (
    RoomKit,
    BotFrameworkTeamsProvider,
    TeamsConfig,
    parse_teams_webhook,
)
from roomkit.channels import TeamsChannel

config = TeamsConfig(
    app_id="YOUR_APP_ID",
    app_password="YOUR_APP_PASSWORD",
    tenant_id="YOUR_TENANT_ID",  # or "common" for multi-tenant
)
provider = BotFrameworkTeamsProvider(config)

kit = RoomKit()
kit.register_channel(TeamsChannel("teams-main", provider=provider))

Configuration

TeamsConfig holds the Azure Bot registration credentials:

from roomkit import TeamsConfig

config = TeamsConfig(
    app_id="your-azure-app-id",          # Azure AD application (client) ID
    app_password="your-client-secret",    # Azure AD client secret
    tenant_id="common",                   # "common" for multi-tenant (default)
)
Parameter Type Default Description
app_id str required Azure AD application (client) ID
app_password SecretStr required Azure AD client secret
tenant_id str "common" Azure AD tenant ID ("common" for multi-tenant bots)

Single-tenant apps

For single-tenant Azure AD apps, you must set tenant_id to your actual tenant ID (a UUID). When tenant_id is not "common", the provider automatically passes it as channel_auth_tenant to the Bot Framework SDK, which is required for token acquisition to succeed.

Webhook integration

Bot Framework sends one Activity per HTTP POST to your messaging endpoint. Use parse_teams_webhook() to convert message activities into InboundMessage, and parse_teams_activity() / is_bot_added() to handle lifecycle events:

from aiohttp import web
from roomkit import is_bot_added, parse_teams_activity, parse_teams_webhook

async def handle_messages(request: web.Request) -> web.Response:
    payload = await request.json()

    # Always save the conversation reference (needed for proactive sends)
    await provider.save_conversation_reference(payload)

    activity = parse_teams_activity(payload)

    # Handle bot installation
    if is_bot_added(payload):
        print(f"Bot added to {activity['conversation_type']} conversation")
        return web.Response(status=200)

    # Handle regular messages
    if activity["activity_type"] == "message":
        messages = parse_teams_webhook(payload, channel_id="teams-main")
        for inbound in messages:
            result = await kit.process_inbound(inbound)

    return web.Response(status=200)

FastAPI variant:

from fastapi import FastAPI, Request
from roomkit import is_bot_added, parse_teams_activity, parse_teams_webhook

app = FastAPI()

@app.post("/api/messages")
async def handle_messages(request: Request):
    payload = await request.json()
    await provider.save_conversation_reference(payload)

    if is_bot_added(payload):
        return {"status": "bot_added"}

    activity = parse_teams_activity(payload)
    if activity["activity_type"] == "message":
        for inbound in parse_teams_webhook(payload, channel_id="teams-main"):
            await kit.process_inbound(inbound)

    return {"status": "ok"}

Webhook metadata

Each parsed InboundMessage includes metadata extracted from the Activity:

Key Type Description
sender_name str Display name of the sender
conversation_id str Teams conversation ID (used as to for outbound)
conversation_type str "personal", "groupChat", or "channel"
is_group bool Whether the message is from a group/channel chat
bot_mentioned bool Whether the bot was @mentioned (always True in personal chats)
service_url str Bot Framework service URL for this conversation
tenant_id str Azure AD tenant ID of the sender

Mention stripping

In group chats and channels, users @mention the bot to address it. The webhook parser automatically strips <at>BotName</at> tags from the message text, so your hooks receive clean text:

Raw:    "<at>MyBot</at> what's the weather?"
Parsed: "what's the weather?"

Activity parsing helpers

parse_teams_activity() extracts common fields from any Bot Framework Activity (not just messages). Useful for handling lifecycle events like conversationUpdate:

from roomkit import parse_teams_activity

activity = parse_teams_activity(payload)
# Returns: {
#   "activity_type": "message" | "conversationUpdate" | ...,
#   "conversation_id": "19:abc123@thread.v2",
#   "conversation_type": "personal" | "groupChat" | "channel",
#   "is_group": True/False,
#   "service_url": "https://smba.trafficmanager.net/amer/",
#   "tenant_id": "your-tenant-id",
#   "sender_id": "...",
#   "sender_name": "...",
#   "bot_id": "...",
#   "members_added": [...],
#   "members_removed": [...],
# }

Bot installation detection

is_bot_added() checks if a conversationUpdate Activity indicates the bot was added to a conversation:

from roomkit import is_bot_added

if is_bot_added(payload):
    conv_type = parse_teams_activity(payload)["conversation_type"]
    print(f"Bot installed in {conv_type} chat")

You can optionally pass a bot_id parameter. If not provided, it uses payload["recipient"]["id"].

Conversation references

Teams requires conversation references for proactive (bot-initiated) messaging. When a user messages the bot, you capture the reference from the inbound Activity. Later, you use that reference to send messages back.

BotFrameworkTeamsProvider manages this automatically with a pluggable store:

# Capture reference from inbound webhook
await provider.save_conversation_reference(activity_dict)

# Later: send proactively using the conversation ID as `to`
result = await provider.send(event, to="conversation-id-here")

Custom conversation store

The default InMemoryConversationReferenceStore works for single-process bots. For production, implement ConversationReferenceStore with a persistent backend:

from roomkit import ConversationReferenceStore

class RedisConversationStore(ConversationReferenceStore):
    async def save(self, conversation_id, reference):
        await self.redis.set(f"teams:ref:{conversation_id}", json.dumps(reference))

    async def get(self, conversation_id):
        data = await self.redis.get(f"teams:ref:{conversation_id}")
        return json.loads(data) if data else None

    async def delete(self, conversation_id):
        await self.redis.delete(f"teams:ref:{conversation_id}")

    async def list_all(self):
        # Return all stored references
        ...

provider = BotFrameworkTeamsProvider(
    config,
    conversation_store=RedisConversationStore(redis),
)

Proactive channel messaging

To message a Teams channel the bot has been installed in — even if no user has messaged the bot in that channel yet — use create_channel_conversation():

# Create a conversation in a Teams channel
conv_id = await provider.create_channel_conversation(
    service_url="https://smba.trafficmanager.net/amer/",
    channel_id="19:abc123@thread.tacv2",
    tenant_id="your-tenant-id",  # optional, falls back to config
)

# Now you can send messages using the returned conversation ID
result = await provider.send(event, to=conv_id)

The service_url and channel_id are available from a prior conversationUpdate Activity (captured via parse_teams_activity()).

Channel capabilities

Feature Supported
Text messages Yes
Rich text (HTML) Yes
Threading Yes
Reactions Yes
Read receipts Yes
Max message length 28,000 characters
Media attachments Not yet
Adaptive Cards Not yet

Installation

pip install roomkit[teams]

This installs botbuilder-core>=4.14. The BotFrameworkTeamsProvider constructor raises a helpful ImportError if the dependency is missing.

Testing with Bot Framework Emulator

You can test locally without Azure credentials using the Bot Framework Emulator:

  1. Leave app_id and app_password empty for local testing
  2. Run your bot on http://localhost:3978/api/messages
  3. Connect the Emulator to that URL
# Local testing config (no auth)
config = TeamsConfig(app_id="", app_password="")
provider = BotFrameworkTeamsProvider(config)

Testing with MockTeamsProvider

For unit tests, use MockTeamsProvider which records all sent messages:

from roomkit import MockTeamsProvider

mock = MockTeamsProvider()
result = await mock.send(event, to="conv-123")

assert result.success
assert len(mock.sent) == 1
assert mock.sent[0]["to"] == "conv-123"

API reference

TeamsProvider

Bases: ABC

Microsoft Teams delivery provider.

name property

name

Provider name.

send abstractmethod async

send(event, to)

Send a Microsoft Teams message.

Parameters:

Name Type Description Default
event RoomEvent

The room event containing the message content.

required
to str

Recipient Teams conversation ID.

required

Returns:

Type Description
ProviderResult

Result with provider-specific delivery metadata.

verify_signature

verify_signature(payload, signature)

Verify that a webhook payload was signed by the Bot Framework.

Parameters:

Name Type Description Default
payload bytes

Raw request body bytes.

required
signature str

Value of the Authorization header (Bearer <token>).

required

Returns:

Type Description
bool

True if the signature is valid, False otherwise.

Raises:

Type Description
NotImplementedError

If the provider does not support signature verification.

close async

close()

Release resources. Override in subclasses that hold connections.

BotFrameworkTeamsProvider

BotFrameworkTeamsProvider(config, *, conversation_store=None)

Bases: TeamsProvider

Send messages via the Microsoft Bot Framework SDK.

adapter property

adapter

The underlying Bot Framework adapter.

conversation_store property

conversation_store

The conversation reference store.

save_conversation_reference async

save_conversation_reference(activity_dict)

Extract and store a ConversationReference from an inbound Activity dict.

create_personal_conversation async

create_personal_conversation(service_url, user_id, *, tenant_id=None)

Create a 1:1 personal conversation with a user and store its reference.

Use this to proactively message a Teams user by their AAD/Teams ID, even if the user has never messaged the bot. The bot must be installed in the user's tenant for this to succeed.

Parameters:

Name Type Description Default
service_url str

The Bot Framework service URL for the tenant (e.g. "https://smba.trafficmanager.net/amer/"). Available from a prior conversationUpdate Activity or from :func:parse_teams_activity.

required
user_id str

The AAD object ID or Teams user ID of the target user (e.g. "29:1abc-user-aad-id").

required
tenant_id str | None

Azure AD tenant ID. Falls back to :attr:TeamsConfig.tenant_id if not provided.

None

Returns:

Type Description
str

The conversation ID for the newly created 1:1 conversation.

str

This ID can be used as the to parameter in :meth:send.

Raises:

Type Description
RuntimeError

If the conversation could not be created.

create_channel_conversation async

create_channel_conversation(service_url, channel_id, *, tenant_id=None)

Create a conversation in a Teams channel and store its reference.

Use this to proactively message a Teams channel the bot has been installed in, even if no user has messaged the bot in that channel yet.

Parameters:

Name Type Description Default
service_url str

The Bot Framework service URL for the team (e.g. "https://smba.trafficmanager.net/amer/"). Available from a prior conversationUpdate Activity or from :func:parse_teams_activity.

required
channel_id str

The Teams channel ID (e.g. "19:abc123@thread.tacv2").

required
tenant_id str | None

Azure AD tenant ID. Falls back to :attr:TeamsConfig.tenant_id if not provided.

None

Returns:

Type Description
str

The conversation ID for the newly created conversation.

str

This ID can be used as the to parameter in :meth:send.

Raises:

Type Description
RuntimeError

If the conversation could not be created.

verify_signature

verify_signature(payload, signature)

Verify a Bot Framework JWT bearer token.

Validates the JWT from the Authorization: Bearer <token> header using Microsoft's OpenID Connect metadata and signing keys.

Requires the PyJWT and cryptography packages::

pip install PyJWT cryptography

Parameters:

Name Type Description Default
payload bytes

Raw request body bytes (unused — Bot Framework signs the token, not the payload).

required
signature str

The full Authorization header value, including the Bearer prefix, OR just the raw JWT token.

required

Returns:

Type Description
bool

True if the JWT is valid (signature, issuer, audience, expiry).

Raises:

Type Description
ValueError

If required dependencies are missing.

MockTeamsProvider

MockTeamsProvider()

Bases: TeamsProvider

Records sent messages for verification in tests.

TeamsConfig

Bases: BaseModel

Microsoft Teams Bot Framework configuration.

Supports two authentication modes:

Client secret (password)::

TeamsConfig(app_id="...", app_password="...")

Certificate-based::

TeamsConfig(
    app_id="...",
    certificate_thumbprint="AB01CD...",
    certificate_private_key="-----BEGIN RSA PRIVATE KEY-----\n...",
)

Exactly one mode must be provided.

uses_certificate_auth property

uses_certificate_auth

Return True if configured for certificate-based authentication.

parse_teams_webhook

parse_teams_webhook(payload, channel_id)

Convert a Bot Framework Activity payload into InboundMessages.

Bot Framework sends one Activity per HTTP POST. Only type="message" activities with non-empty text are converted. <at>BotName</at> mention tags are stripped from group chat messages.

parse_teams_activity

parse_teams_activity(payload)

Extract common fields from a Bot Framework Activity payload.

Returns a dict with activity_type, conversation_id, conversation_type, conversation_name, is_group, service_url, tenant_id, sender_id, sender_name, bot_id, members_added, and members_removed. Useful for handling lifecycle events (conversationUpdate) alongside message parsing.

is_bot_added

is_bot_added(payload, bot_id=None)

Check if a conversationUpdate Activity indicates the bot was added.

Parameters:

Name Type Description Default
payload dict[str, Any]

Raw Bot Framework Activity dict.

required
bot_id str | None

The bot's AAD ID. If None, falls back to payload["recipient"]["id"] (the bot in most Activities).

None

Returns:

Type Description
bool

True if the Activity is a conversationUpdate with the bot

bool

in membersAdded.

ConversationReferenceStore

Bases: ABC

Abstract store for Bot Framework ConversationReference dicts.

save abstractmethod async

save(conversation_id, reference)

Persist a conversation reference.

get abstractmethod async

get(conversation_id)

Retrieve a conversation reference by ID.

delete abstractmethod async

delete(conversation_id)

Remove a conversation reference.

list_all abstractmethod async

list_all()

Return all stored conversation references.

InMemoryConversationReferenceStore

InMemoryConversationReferenceStore()

Bases: ConversationReferenceStore

Dict-backed in-memory conversation reference store.

parse_teams_reactions

parse_teams_reactions(payload)

Parse reaction events from a messageReaction Activity.

Teams sends messageReaction activities when a user adds or removes a reaction (like, heart, laugh, etc.) on a message. This helper normalises both reactionsAdded and reactionsRemoved into a flat list of dicts.

Parameters:

Name Type Description Default
payload dict[str, Any]

Raw Bot Framework Activity dict.

required

Returns:

Type Description
list[dict[str, Any]]

A list of dicts, each with keys action ("add" or

list[dict[str, Any]]

"remove"), emoji, sender_id, sender_name, and

list[dict[str, Any]]

target_activity_id. Returns an empty list if the Activity

list[dict[str, Any]]

is not a messageReaction or has no reactions.