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
from roomkit.providers.teams.bot_framework import BotFrameworkTeamsProvider
from roomkit.providers.teams.config import TeamsConfig
from roomkit.providers.teams.webhook import 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.providers.teams.config 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.providers.teams.webhook 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.providers.teams.webhook 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:
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.providers.teams.webhook 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.providers.teams.webhook 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.providers.teams.store 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¶
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:
- Leave
app_idandapp_passwordempty for local testing - Run your bot on
http://localhost:3978/api/messages - 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.providers.teams.mock 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 + dispatch provider.
Implementations own every interaction with the Bot Framework transport
so consumers (e.g. Luge) never touch the SDK directly. The contract
covers both outbound delivery (:meth:send) and inbound dispatch
(:meth:process_inbound), plus the Teams-specific roster helpers
needed to resolve sender identity (:meth:get_member,
:meth:list_members).
send
abstractmethod
async
¶
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. |
process_inbound
abstractmethod
async
¶
Validate an inbound activity and dispatch on_turn with a TurnContext.
Concrete implementations are expected to:
- Validate the JWT in
auth_headeragainst Bot Framework / tenant credentials (raising on failure). - Build a provider-specific :class:
TurnContextpopulated with a working connector client for the activity'sserviceUrl. - Invoke
on_turn(turn_context).
Consumers carry their business logic in on_turn and can use
:meth:get_member / :meth:list_members against the provider —
they should never need direct SDK access.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
payload
|
dict[str, Any]
|
Raw Bot Framework Activity dict (as received from the messaging endpoint). |
required |
auth_header
|
str
|
Full |
required |
on_turn
|
Callable[[TurnContext], Awaitable[None]]
|
Awaitable callback invoked once the activity has
been authenticated and a |
required |
Raises:
| Type | Description |
|---|---|
PermissionError
|
If the JWT is invalid or required credentials are missing. |
get_member
abstractmethod
async
¶
Resolve a single member's profile via the Bot Framework roster.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
turn_context
|
TurnContext
|
The active turn for which roster API calls are authorized. |
required |
member_id
|
str
|
Bot-framework user ID ( |
required |
Returns:
| Type | Description |
|---|---|
TeamsMember | None
|
The normalized member record, or |
TeamsMember | None
|
does not have an entry for |
list_members
abstractmethod
async
¶
Return the participant list for the active conversation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
turn_context
|
TurnContext
|
The active turn for which roster API calls are authorized. |
required |
max_count
|
int | None
|
Hard cap on the number of members returned across
paginated calls. |
None
|
Returns:
| Type | Description |
|---|---|
list[TeamsMember]
|
Normalized member records. Empty list when the call fails or |
list[TeamsMember]
|
the conversation has no addressable members. |
verify_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 |
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. |
BotFrameworkTeamsProvider ¶
Bases: TeamsProvider
Send messages via the Microsoft Bot Framework SDK.
process_inbound
async
¶
Validate the activity's JWT and dispatch on_turn with a TurnContext.
Delegates to :meth:BotFrameworkAdapter.process_activity, which
performs JWT validation, constructs a connector client for the
activity's serviceUrl, and invokes the callback inside a
fully-populated turn.
get_member
async
¶
Resolve a single member via the Teams roster API.
Uses :meth:TeamsInfo.get_member, which falls back from the team-scoped
endpoint to the conversation-scoped one — works for personal,
groupChat, and channel conversation types alike. The
team-scoped :meth:TeamsInfo.get_team_member only handles activities
inside a Teams team.
list_members
async
¶
List participants via paginated roster calls.
save_conversation_reference
async
¶
Extract and store a ConversationReference from an inbound Activity dict.
create_personal_conversation
async
¶
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. |
required |
user_id
|
str
|
The AAD object ID or Teams user ID of the target user
(e.g. |
required |
tenant_id
|
str | None
|
Azure AD tenant ID. Falls back to
:attr: |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The conversation ID for the newly created 1:1 conversation. |
str
|
This ID can be used as the |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the conversation could not be created. |
create_channel_conversation
async
¶
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. |
required |
channel_id
|
str
|
The Teams channel ID
(e.g. |
required |
tenant_id
|
str | None
|
Azure AD tenant ID. Falls back to
:attr: |
None
|
Returns:
| Type | Description |
|---|---|
str
|
The conversation ID for the newly created conversation. |
str
|
This ID can be used as the |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If the conversation could not be created. |
verify_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 |
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 ¶
Bases: TeamsProvider
Records sent messages and serves a scripted roster for verification.
Tests can pre-load self.members[conversation_id] = [TeamsMember(...), ...]
and the provider's :meth:get_member / :meth:list_members will return
those records. process_inbound synthesizes a minimal stand-in turn
context — enough for callbacks that only read the activity. Tests that
need richer SDK behaviour should swap in a real BotFrameworkAdapter
via the concrete provider.
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
¶
Return True if configured for certificate-based authentication.
parse_teams_webhook ¶
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.
Validate the Authorization JWT — via
:meth:BotFrameworkTeamsProvider.process_inbound, which runs the Bot
Framework's own check — before calling this.
parse_teams_activity ¶
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 ¶
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
|
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
bool
|
in |
ConversationReferenceStore ¶
Bases: ABC
Abstract store for Bot Framework ConversationReference dicts.
Beyond the bare CRUD surface, implementations should keep enough
identity metadata alongside each reference to answer "who is at the
other end of this conversation?" without re-hitting the roster.
Bot Framework reliably exposes the sender's AAD object ID and Teams
user ID on every activity; resolving an email is harder (guests,
external users, anonymous joins) so any value worth caching is
pre-resolved by the consumer and persisted via
:meth:save_with_user_info.
save
abstractmethod
async
¶
Persist a conversation reference (no identity metadata).
save_with_user_info
abstractmethod
async
¶
save_with_user_info(conversation_id, reference, *, aad_id=None, email=None, conversation_type='personal')
Persist a reference together with the resolved sender identity.
Re-running with the same conversation_id updates the reference
and merges identity fields — None values must NOT overwrite an
already-stored non-null value (so a follow-up activity that lacks
email doesn't blank a known address).
get_email_by_sender
abstractmethod
async
¶
Look up the cached email for a specific sender in a conversation.
Group chats hold multiple senders; the aad_id filter is
required so a reply from Alice isn't attributed to whoever
@mentioned the bot last (Bob).
get_by_email
abstractmethod
async
¶
Find the most recent conversation reference for a given email.
Useful for proactive sends where the caller only knows the
recipient's email. Implementations return a dict with at minimum
conversation_id and reference; additional fields are
implementation-defined.
InMemoryConversationReferenceStore ¶
Bases: ConversationReferenceStore
Dict-backed in-memory store. Suitable for tests and single-process bots.
parse_teams_reactions ¶
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 |
list[dict[str, Any]]
|
|
list[dict[str, Any]]
|
|
list[dict[str, Any]]
|
is not a |