botas
Botas — a lightweight, multi-language Bot Service library for Python.
Provides the core building blocks for receiving, processing, and sending Bot Service activities over HTTP. Typical usage::
from botas import BotApplication, TurnContext
bot = BotApplication()
@bot.on("message")
async def echo(ctx: TurnContext):
await ctx.send(f"You said: {ctx.activity.text}")
See the specs/ directory for protocol details and the README for quickstart guides.
1"""Botas — a lightweight, multi-language Bot Service library for Python. 2 3Provides the core building blocks for receiving, processing, and sending 4Bot Service activities over HTTP. Typical usage:: 5 6 from botas import BotApplication, TurnContext 7 8 bot = BotApplication() 9 10 @bot.on("message") 11 async def echo(ctx: TurnContext): 12 await ctx.send(f"You said: {ctx.activity.text}") 13 14See the `specs/` directory for protocol details and the README for quickstart guides. 15""" 16 17from botas._version import __version__ 18from botas.bot_application import BotApplication, BotHandlerException, InvokeResponse 19from botas.bot_auth import BotAuthError, validate_bot_token 20from botas.conversation_client import ConversationClient 21from botas.core_activity import ( 22 ActivityType, 23 Attachment, 24 ChannelAccount, 25 Conversation, 26 CoreActivity, 27 CoreActivityBuilder, 28 Entity, 29 ResourceResponse, 30 TeamsActivityType, 31 TeamsChannelAccount, 32) 33from botas.i_turn_middleware import ITurnMiddleware, TurnMiddleware 34from botas.remove_mention_middleware import RemoveMentionMiddleware 35from botas.suggested_actions import CardAction, SuggestedActions 36from botas.teams_activity import ( 37 ChannelInfo, 38 MeetingInfo, 39 NotificationInfo, 40 TeamInfo, 41 TeamsActivity, 42 TeamsActivityBuilder, 43 TeamsChannelData, 44 TeamsConversation, 45 TenantInfo, 46) 47from botas.token_manager import BotApplicationOptions, TokenManager 48from botas.turn_context import TurnContext 49 50__all__ = [ 51 "ActivityType", 52 "Attachment", 53 "BotApplication", 54 "BotApplicationOptions", 55 "BotAuthError", 56 "BotHandlerException", 57 "CardAction", 58 "ChannelAccount", 59 "ChannelInfo", 60 "Conversation", 61 "ConversationClient", 62 "CoreActivity", 63 "CoreActivityBuilder", 64 "Entity", 65 "InvokeResponse", 66 "ITurnMiddleware", 67 "TurnMiddleware", 68 "MeetingInfo", 69 "NotificationInfo", 70 "RemoveMentionMiddleware", 71 "ResourceResponse", 72 "SuggestedActions", 73 "TeamsActivity", 74 "TeamsActivityBuilder", 75 "TeamsActivityType", 76 "TeamsChannelAccount", 77 "TeamsChannelData", 78 "TeamsConversation", 79 "TeamInfo", 80 "TenantInfo", 81 "TokenManager", 82 "TurnContext", 83 "__version__", 84 "validate_bot_token", 85]
90class Attachment(_CamelModel): 91 """Bot Service attachment (images, cards, files, etc.). 92 93 Attributes: 94 content_type: MIME type (e.g. ``"application/vnd.microsoft.card.adaptive"``). 95 content_url: URL to download the attachment content. 96 content: Inline attachment content (e.g. an Adaptive Card JSON object). 97 name: Display name / filename. 98 thumbnail_url: URL to a thumbnail image. 99 """ 100 101 content_type: str 102 content_url: str | None = None 103 content: Any = None 104 name: str | None = None 105 thumbnail_url: str | None = None
Bot Service attachment (images, cards, files, etc.).
Attributes:
content_type: MIME type (e.g. "application/vnd.microsoft.card.adaptive").
content_url: URL to download the attachment content.
content: Inline attachment content (e.g. an Adaptive Card JSON object).
name: Display name / filename.
thumbnail_url: URL to a thumbnail image.
96class BotApplication: 97 """Central entry point for building a bot with the Bot Service. 98 99 Manages the middleware pipeline, activity handler dispatch, outbound 100 messaging via :class:`ConversationClient`, and OAuth2 token lifecycle 101 via :class:`TokenManager`. 102 103 Supports async context-manager usage for automatic resource cleanup:: 104 105 async with BotApplication(options) as bot: 106 bot.on("message", my_handler) 107 ... 108 109 Attributes: 110 version: Library version string. 111 conversation_client: Client for sending outbound activities. 112 on_activity: Optional catch-all handler invoked for every activity type. 113 """ 114 115 version: str = __import__("botas._version", fromlist=["__version__"]).__version__ 116 117 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 118 """Initialise the bot application. 119 120 Args: 121 options: Configuration for authentication credentials and token 122 acquisition. Defaults to reading from environment variables. 123 """ 124 self._token_manager = TokenManager(options) 125 token_provider = self._token_manager.get_bot_token 126 self.conversation_client = ConversationClient(token_provider) 127 self._middlewares: list[TurnMiddleware] = [] 128 self._handlers: dict[str, ActivityHandler] = {} 129 self._invoke_handlers: dict[str, InvokeActivityHandler] = {} 130 self.on_activity: ActivityHandler | None = None 131 132 @property 133 def appid(self) -> str | None: 134 """The bot application/client ID exposed from the token manager.""" 135 return self._token_manager.client_id 136 137 def on( 138 self, 139 type: str, 140 handler: ActivityHandler | None = None, 141 ) -> Any: 142 """Register a handler for an activity type. 143 144 Only one handler is stored per type; re-registering the same type 145 replaces the previous handler. 146 147 Can be used as a two-argument call or as a decorator:: 148 149 bot.on('message', my_handler) 150 151 @bot.on('message') 152 async def my_handler(ctx: TurnContext): 153 await ctx.send("hello") 154 155 Args: 156 type: The activity type to handle (e.g. ``"message"``, ``"typing"``). 157 handler: Async handler function. If omitted, returns a decorator. 158 159 Returns: 160 The ``BotApplication`` instance when called with a handler, or a 161 decorator function when called without one. 162 """ 163 if handler is None: 164 165 def decorator(fn: ActivityHandler) -> ActivityHandler: 166 self._handlers[type.lower()] = fn 167 return fn 168 169 return decorator 170 self._handlers[type.lower()] = handler 171 return self 172 173 def use(self, middleware: TurnMiddleware) -> "BotApplication": 174 """Register a middleware in the turn pipeline. 175 176 Middleware executes in registration order before handler dispatch. 177 Each middleware receives ``(context, next)`` and must call ``next()`` 178 to continue the pipeline, or skip it to short-circuit processing. 179 180 Args: 181 middleware: An object implementing :class:`TurnMiddleware`. 182 183 Returns: 184 The ``BotApplication`` instance for chaining. 185 """ 186 self._middlewares.append(middleware) 187 return self 188 189 def on_invoke( 190 self, 191 name: str, 192 handler: InvokeActivityHandler | None = None, 193 ) -> Any: 194 """Register a handler for an invoke activity by its ``activity.name`` sub-type. 195 196 The handler must return an :class:`InvokeResponse`. Only one handler 197 per name is supported; re-registering the same name replaces the 198 previous handler. 199 200 Can be used as a two-argument call or as a decorator:: 201 202 bot.on_invoke("adaptiveCard/action", my_handler) 203 204 @bot.on_invoke("adaptiveCard/action") 205 async def my_handler(ctx): ... 206 """ 207 if handler is None: 208 209 def decorator(fn: InvokeActivityHandler) -> InvokeActivityHandler: 210 self._invoke_handlers[name.lower()] = fn 211 return fn 212 213 return decorator 214 self._invoke_handlers[name.lower()] = handler 215 return self 216 217 async def process_body(self, body: str) -> InvokeResponse | None: 218 """Parse and process a raw JSON activity body. 219 220 Deserializes the JSON string into a :class:`CoreActivity`, validates 221 required fields and the ``serviceUrl``, then runs the full middleware 222 pipeline followed by handler dispatch. 223 224 For ``invoke`` activities, returns the :class:`InvokeResponse` produced 225 by the registered handler, a 200 response if no invoke handlers are 226 registered, or a 501 response if handlers exist but none match. 227 Returns ``None`` for all other activity types. 228 229 Args: 230 body: Raw JSON string representing a Bot Service activity. 231 232 Returns: 233 An :class:`InvokeResponse` for invoke activities, or ``None``. 234 235 Raises: 236 ValueError: If the JSON is malformed or required activity fields 237 are missing. 238 BotHandlerException: If the matched handler raises an exception. 239 """ 240 try: 241 activity = CoreActivity.model_validate_json(body) 242 except json.JSONDecodeError as exc: 243 raise ValueError("Invalid JSON in request body") from exc 244 _assert_activity(activity) 245 _validate_service_url(activity.service_url) 246 return await self._run_pipeline(activity) 247 248 async def send_activity_async( 249 self, 250 service_url: str, 251 conversation_id: str, 252 activity: CoreActivity | dict[str, Any], 253 ) -> ResourceResponse | None: 254 """Proactively send an activity to a conversation. 255 256 Use this to push messages outside of the normal turn pipeline (e.g. 257 notifications or proactive messages). 258 259 Args: 260 service_url: The channel's service URL. 261 conversation_id: Target conversation identifier. 262 activity: The activity payload to send. 263 264 Returns: 265 A :class:`ResourceResponse` with the new activity ID, or ``None`` 266 if the channel does not return one. 267 """ 268 return await self.conversation_client.send_activity_async(service_url, conversation_id, activity) 269 270 async def aclose(self) -> None: 271 """Close the underlying HTTP client and release resources. 272 273 Should be called during application shutdown. Alternatively, use the 274 bot as an async context manager to ensure automatic cleanup. 275 """ 276 await self.conversation_client.aclose() 277 278 async def __aenter__(self) -> "BotApplication": 279 """Enter the async context manager.""" 280 return self 281 282 async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 283 """Exit the async context manager, ensuring resources are closed.""" 284 await self.aclose() 285 286 async def _handle_activity_async(self, context: TurnContext) -> InvokeResponse | None: 287 if context.activity.type == "invoke": 288 return await self._dispatch_invoke_async(context) 289 handler = self.on_activity or self._handlers.get(context.activity.type.lower()) 290 if handler is None: 291 return None 292 try: 293 await handler(context) 294 except Exception as exc: 295 raise BotHandlerException( 296 f'Handler for "{context.activity.type}" threw an error', 297 exc, 298 context.activity, 299 ) from exc 300 return None 301 302 async def _dispatch_invoke_async(self, context: TurnContext) -> InvokeResponse: 303 if not self._invoke_handlers: 304 return InvokeResponse(status=200, body={}) 305 name = context.activity.name 306 handler = self._invoke_handlers.get(name.lower()) if name else None 307 if handler is None: 308 return InvokeResponse(status=501) 309 try: 310 return await handler(context) 311 except Exception as exc: 312 raise BotHandlerException( 313 f'Invoke handler for "{name}" threw an error', 314 exc, 315 context.activity, 316 ) from exc 317 318 async def _run_pipeline(self, activity: CoreActivity) -> InvokeResponse | None: 319 context = TurnContext(self, activity) 320 index = 0 321 invoke_response: InvokeResponse | None = None 322 323 async def next_fn() -> None: 324 nonlocal index, invoke_response 325 if index < len(self._middlewares): 326 mw = self._middlewares[index] 327 index += 1 328 await mw.on_turn(context, next_fn) 329 else: 330 invoke_response = await self._handle_activity_async(context) 331 332 await next_fn() 333 return invoke_response
Central entry point for building a bot with the Bot Service.
Manages the middleware pipeline, activity handler dispatch, outbound
messaging via ConversationClient, and OAuth2 token lifecycle
via TokenManager.
Supports async context-manager usage for automatic resource cleanup::
async with BotApplication(options) as bot:
bot.on("message", my_handler)
...
Attributes: version: Library version string. conversation_client: Client for sending outbound activities. on_activity: Optional catch-all handler invoked for every activity type.
117 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 118 """Initialise the bot application. 119 120 Args: 121 options: Configuration for authentication credentials and token 122 acquisition. Defaults to reading from environment variables. 123 """ 124 self._token_manager = TokenManager(options) 125 token_provider = self._token_manager.get_bot_token 126 self.conversation_client = ConversationClient(token_provider) 127 self._middlewares: list[TurnMiddleware] = [] 128 self._handlers: dict[str, ActivityHandler] = {} 129 self._invoke_handlers: dict[str, InvokeActivityHandler] = {} 130 self.on_activity: ActivityHandler | None = None
Initialise the bot application.
Args: options: Configuration for authentication credentials and token acquisition. Defaults to reading from environment variables.
132 @property 133 def appid(self) -> str | None: 134 """The bot application/client ID exposed from the token manager.""" 135 return self._token_manager.client_id
The bot application/client ID exposed from the token manager.
137 def on( 138 self, 139 type: str, 140 handler: ActivityHandler | None = None, 141 ) -> Any: 142 """Register a handler for an activity type. 143 144 Only one handler is stored per type; re-registering the same type 145 replaces the previous handler. 146 147 Can be used as a two-argument call or as a decorator:: 148 149 bot.on('message', my_handler) 150 151 @bot.on('message') 152 async def my_handler(ctx: TurnContext): 153 await ctx.send("hello") 154 155 Args: 156 type: The activity type to handle (e.g. ``"message"``, ``"typing"``). 157 handler: Async handler function. If omitted, returns a decorator. 158 159 Returns: 160 The ``BotApplication`` instance when called with a handler, or a 161 decorator function when called without one. 162 """ 163 if handler is None: 164 165 def decorator(fn: ActivityHandler) -> ActivityHandler: 166 self._handlers[type.lower()] = fn 167 return fn 168 169 return decorator 170 self._handlers[type.lower()] = handler 171 return self
Register a handler for an activity type.
Only one handler is stored per type; re-registering the same type replaces the previous handler.
Can be used as a two-argument call or as a decorator::
bot.on('message', my_handler)
@bot.on('message')
async def my_handler(ctx: TurnContext):
await ctx.send("hello")
Args:
type: The activity type to handle (e.g. "message", "typing").
handler: Async handler function. If omitted, returns a decorator.
Returns:
The BotApplication instance when called with a handler, or a
decorator function when called without one.
173 def use(self, middleware: TurnMiddleware) -> "BotApplication": 174 """Register a middleware in the turn pipeline. 175 176 Middleware executes in registration order before handler dispatch. 177 Each middleware receives ``(context, next)`` and must call ``next()`` 178 to continue the pipeline, or skip it to short-circuit processing. 179 180 Args: 181 middleware: An object implementing :class:`TurnMiddleware`. 182 183 Returns: 184 The ``BotApplication`` instance for chaining. 185 """ 186 self._middlewares.append(middleware) 187 return self
Register a middleware in the turn pipeline.
Middleware executes in registration order before handler dispatch.
Each middleware receives (context, next) and must call next()
to continue the pipeline, or skip it to short-circuit processing.
Args:
middleware: An object implementing TurnMiddleware.
Returns:
The BotApplication instance for chaining.
189 def on_invoke( 190 self, 191 name: str, 192 handler: InvokeActivityHandler | None = None, 193 ) -> Any: 194 """Register a handler for an invoke activity by its ``activity.name`` sub-type. 195 196 The handler must return an :class:`InvokeResponse`. Only one handler 197 per name is supported; re-registering the same name replaces the 198 previous handler. 199 200 Can be used as a two-argument call or as a decorator:: 201 202 bot.on_invoke("adaptiveCard/action", my_handler) 203 204 @bot.on_invoke("adaptiveCard/action") 205 async def my_handler(ctx): ... 206 """ 207 if handler is None: 208 209 def decorator(fn: InvokeActivityHandler) -> InvokeActivityHandler: 210 self._invoke_handlers[name.lower()] = fn 211 return fn 212 213 return decorator 214 self._invoke_handlers[name.lower()] = handler 215 return self
Register a handler for an invoke activity by its activity.name sub-type.
The handler must return an InvokeResponse. Only one handler
per name is supported; re-registering the same name replaces the
previous handler.
Can be used as a two-argument call or as a decorator::
bot.on_invoke("adaptiveCard/action", my_handler)
@bot.on_invoke("adaptiveCard/action")
async def my_handler(ctx): ...
217 async def process_body(self, body: str) -> InvokeResponse | None: 218 """Parse and process a raw JSON activity body. 219 220 Deserializes the JSON string into a :class:`CoreActivity`, validates 221 required fields and the ``serviceUrl``, then runs the full middleware 222 pipeline followed by handler dispatch. 223 224 For ``invoke`` activities, returns the :class:`InvokeResponse` produced 225 by the registered handler, a 200 response if no invoke handlers are 226 registered, or a 501 response if handlers exist but none match. 227 Returns ``None`` for all other activity types. 228 229 Args: 230 body: Raw JSON string representing a Bot Service activity. 231 232 Returns: 233 An :class:`InvokeResponse` for invoke activities, or ``None``. 234 235 Raises: 236 ValueError: If the JSON is malformed or required activity fields 237 are missing. 238 BotHandlerException: If the matched handler raises an exception. 239 """ 240 try: 241 activity = CoreActivity.model_validate_json(body) 242 except json.JSONDecodeError as exc: 243 raise ValueError("Invalid JSON in request body") from exc 244 _assert_activity(activity) 245 _validate_service_url(activity.service_url) 246 return await self._run_pipeline(activity)
Parse and process a raw JSON activity body.
Deserializes the JSON string into a CoreActivity, validates
required fields and the serviceUrl, then runs the full middleware
pipeline followed by handler dispatch.
For invoke activities, returns the InvokeResponse produced
by the registered handler, a 200 response if no invoke handlers are
registered, or a 501 response if handlers exist but none match.
Returns None for all other activity types.
Args: body: Raw JSON string representing a Bot Service activity.
Returns:
An InvokeResponse for invoke activities, or None.
Raises: ValueError: If the JSON is malformed or required activity fields are missing. BotHandlerException: If the matched handler raises an exception.
248 async def send_activity_async( 249 self, 250 service_url: str, 251 conversation_id: str, 252 activity: CoreActivity | dict[str, Any], 253 ) -> ResourceResponse | None: 254 """Proactively send an activity to a conversation. 255 256 Use this to push messages outside of the normal turn pipeline (e.g. 257 notifications or proactive messages). 258 259 Args: 260 service_url: The channel's service URL. 261 conversation_id: Target conversation identifier. 262 activity: The activity payload to send. 263 264 Returns: 265 A :class:`ResourceResponse` with the new activity ID, or ``None`` 266 if the channel does not return one. 267 """ 268 return await self.conversation_client.send_activity_async(service_url, conversation_id, activity)
Proactively send an activity to a conversation.
Use this to push messages outside of the normal turn pipeline (e.g. notifications or proactive messages).
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. activity: The activity payload to send.
Returns:
A ResourceResponse with the new activity ID, or None
if the channel does not return one.
270 async def aclose(self) -> None: 271 """Close the underlying HTTP client and release resources. 272 273 Should be called during application shutdown. Alternatively, use the 274 bot as an async context manager to ensure automatic cleanup. 275 """ 276 await self.conversation_client.aclose()
Close the underlying HTTP client and release resources.
Should be called during application shutdown. Alternatively, use the bot as an async context manager to ensure automatic cleanup.
16@dataclass 17class BotApplicationOptions: 18 """Configuration options for :class:`BotApplication` authentication. 19 20 All fields are optional; when ``None``, values are read from environment 21 variables (``CLIENT_ID``, ``CLIENT_SECRET``, ``TENANT_ID``, 22 ``MANAGED_IDENTITY_CLIENT_ID``). 23 24 Attributes: 25 client_id: Azure AD application (bot) ID. 26 client_secret: Azure AD client secret. 27 tenant_id: Azure AD tenant ID (defaults to ``"common"``). 28 managed_identity_client_id: Client ID for managed identity auth. 29 token_factory: Custom async callable ``(scope, tenant) -> token`` 30 that bypasses MSAL entirely. 31 """ 32 33 client_id: str | None = None 34 client_secret: str | None = None 35 tenant_id: str | None = None 36 managed_identity_client_id: str | None = None 37 token_factory: Callable[[str, str], Awaitable[str]] | None = None
Configuration options for BotApplication authentication.
All fields are optional; when None, values are read from environment
variables (CLIENT_ID, CLIENT_SECRET, TENANT_ID,
MANAGED_IDENTITY_CLIENT_ID).
Attributes:
client_id: Azure AD application (bot) ID.
client_secret: Azure AD client secret.
tenant_id: Azure AD tenant ID (defaults to "common").
managed_identity_client_id: Client ID for managed identity auth.
token_factory: Custom async callable (scope, tenant) -> token
that bypasses MSAL entirely.
31class BotAuthError(Exception): 32 """Raised when inbound JWT validation fails. 33 34 Inspect the message for the specific reason (expired, bad audience, etc.). 35 """ 36 37 pass
Raised when inbound JWT validation fails.
Inspect the message for the specific reason (expired, bad audience, etc.).
68class BotHandlerException(Exception): 69 """Wraps an exception thrown inside an activity handler. 70 71 When an activity handler or invoke handler raises, the exception is 72 caught by the pipeline and re-raised as a ``BotHandlerException`` with 73 the original exception attached as ``cause`` and ``__cause__``. 74 75 Attributes: 76 name: Always ``"BotHandlerException"``. 77 cause: The original exception raised by the handler. 78 activity: The activity being processed when the error occurred. 79 """ 80 81 def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None: 82 """Initialise a BotHandlerException. 83 84 Args: 85 message: Human-readable description of the failure. 86 cause: The original exception raised by the handler. 87 activity: The activity that was being processed. 88 """ 89 super().__init__(message) 90 self.name = "BotHandlerException" 91 self.cause = cause 92 self.activity = activity 93 self.__cause__ = cause
Wraps an exception thrown inside an activity handler.
When an activity handler or invoke handler raises, the exception is
caught by the pipeline and re-raised as a BotHandlerException with
the original exception attached as cause and __cause__.
Attributes:
name: Always "BotHandlerException".
cause: The original exception raised by the handler.
activity: The activity being processed when the error occurred.
81 def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None: 82 """Initialise a BotHandlerException. 83 84 Args: 85 message: Human-readable description of the failure. 86 cause: The original exception raised by the handler. 87 activity: The activity that was being processed. 88 """ 89 super().__init__(message) 90 self.name = "BotHandlerException" 91 self.cause = cause 92 self.activity = activity 93 self.__cause__ = cause
Initialise a BotHandlerException.
Args: message: Human-readable description of the failure. cause: The original exception raised by the handler. activity: The activity that was being processed.
18class CardAction(_CamelModel): 19 """A clickable action button presented to the user. 20 21 Attributes: 22 type: Action type (``"imBack"``, ``"postBack"``, ``"openUrl"``, etc.). 23 title: Button label displayed to the user. 24 value: Value sent back to the bot when the button is clicked. 25 text: Text sent to the bot (for ``imBack`` actions). 26 display_text: Text displayed in the chat when the button is clicked. 27 image: URL of an icon image for the button. 28 """ 29 30 type: str = "imBack" 31 title: str | None = None 32 value: str | None = None 33 text: str | None = None 34 display_text: str | None = None 35 image: str | None = None
A clickable action button presented to the user.
Attributes:
type: Action type ("imBack", "postBack", "openUrl", etc.).
title: Button label displayed to the user.
value: Value sent back to the bot when the button is clicked.
text: Text sent to the bot (for imBack actions).
display_text: Text displayed in the chat when the button is clicked.
image: URL of an icon image for the button.
41class ChannelAccount(_CamelModel): 42 """Represents a user or bot account on a channel. 43 44 Used for the ``from`` and ``recipient`` fields of an activity. 45 46 Attributes: 47 id: Unique account identifier on the channel. 48 name: Display name of the account. 49 aad_object_id: Azure AD object ID (when available). 50 role: Account role (``"bot"`` or ``"user"``). 51 """ 52 53 id: str 54 name: str | None = None 55 aad_object_id: str | None = None 56 role: str | None = None
Represents a user or bot account on a channel.
Used for the from and recipient fields of an activity.
Attributes:
id: Unique account identifier on the channel.
name: Display name of the account.
aad_object_id: Azure AD object ID (when available).
role: Account role ("bot" or "user").
46class ChannelInfo(_CamelModel): 47 """Teams channel information. 48 49 Attributes: 50 id: Unique channel identifier. 51 name: Display name of the channel. 52 """ 53 54 id: str | None = None 55 name: str | None = None
Teams channel information.
Attributes: id: Unique channel identifier. name: Display name of the channel.
71class Conversation(_CamelModel): 72 """Represents a conversation (chat, channel, or group) on a channel. 73 74 Attributes: 75 id: Unique conversation identifier on the channel. 76 """ 77 78 id: str
Represents a conversation (chat, channel, or group) on a channel.
Attributes: id: Unique conversation identifier on the channel.
45class ConversationClient: 46 """Typed client for Bot Service Conversation REST API operations. 47 48 All methods accept a ``service_url`` and ``conversation_id`` to target 49 the correct channel endpoint. Authentication is handled automatically 50 via the injected :class:`TokenProvider`. 51 """ 52 53 def __init__(self, get_token: TokenProvider | None = None) -> None: 54 """Initialise the conversation client. 55 56 Args: 57 get_token: Async callable that supplies a bearer token. 58 When ``None``, requests are unauthenticated. 59 """ 60 self._http = BotHttpClient(get_token) 61 62 async def send_activity_async( 63 self, 64 service_url: str, 65 conversation_id: str, 66 activity: CoreActivity | dict[str, Any], 67 ) -> ResourceResponse | None: 68 """Send an activity to a conversation. 69 70 Args: 71 service_url: The channel's service URL. 72 conversation_id: Target conversation identifier. 73 activity: Activity payload (model or dict). 74 75 Returns: 76 A :class:`ResourceResponse` with the new activity ID, or ``None``. 77 """ 78 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities" 79 data = await self._http.post( 80 service_url, 81 endpoint, 82 _serialize(activity), 83 BotRequestOptions(operation_description="send activity"), 84 ) 85 return ResourceResponse.model_validate(data) if data else None 86 87 async def update_activity_async( 88 self, 89 service_url: str, 90 conversation_id: str, 91 activity_id: str, 92 activity: CoreActivity | dict[str, Any], 93 ) -> ResourceResponse | None: 94 """Update an existing activity in a conversation. 95 96 Args: 97 service_url: The channel's service URL. 98 conversation_id: Conversation containing the activity. 99 activity_id: ID of the activity to update. 100 activity: Replacement activity payload. 101 102 Returns: 103 A :class:`ResourceResponse`, or ``None``. 104 """ 105 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 106 data = await self._http.put( 107 service_url, 108 endpoint, 109 _serialize(activity), 110 BotRequestOptions(operation_description="update activity"), 111 ) 112 return ResourceResponse.model_validate(data) if data else None 113 114 async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None: 115 """Delete an activity from a conversation. 116 117 Args: 118 service_url: The channel's service URL. 119 conversation_id: Conversation containing the activity. 120 activity_id: ID of the activity to delete. 121 """ 122 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 123 await self._http.delete( 124 service_url, 125 endpoint, 126 BotRequestOptions(operation_description="delete activity"), 127 ) 128 129 async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]: 130 """Retrieve all members of a conversation. 131 132 Args: 133 service_url: The channel's service URL. 134 conversation_id: Target conversation identifier. 135 136 Returns: 137 List of :class:`ChannelAccount` objects for each member. 138 """ 139 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members" 140 data = await self._http.get( 141 service_url, 142 endpoint, 143 options=BotRequestOptions(operation_description="get conversation members"), 144 ) 145 return [ChannelAccount.model_validate(m) for m in (data or [])] 146 147 async def get_conversation_member_async( 148 self, service_url: str, conversation_id: str, member_id: str 149 ) -> ChannelAccount | None: 150 """Retrieve a single conversation member by ID. 151 152 Args: 153 service_url: The channel's service URL. 154 conversation_id: Target conversation identifier. 155 member_id: The member's account ID. 156 157 Returns: 158 A :class:`ChannelAccount`, or ``None`` if the member is not found. 159 """ 160 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 161 data = await self._http.get( 162 service_url, 163 endpoint, 164 options=BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True), 165 ) 166 return ChannelAccount.model_validate(data) if data else None 167 168 async def get_conversation_paged_members_async( 169 self, 170 service_url: str, 171 conversation_id: str, 172 page_size: int | None = None, 173 continuation_token: str | None = None, 174 ) -> PagedMembersResult: 175 """Retrieve conversation members with server-side pagination. 176 177 Args: 178 service_url: The channel's service URL. 179 conversation_id: Target conversation identifier. 180 page_size: Maximum members per page (channel may enforce its own limit). 181 continuation_token: Opaque token from a previous page to fetch the next. 182 183 Returns: 184 A :class:`PagedMembersResult` containing members and an optional 185 continuation token for the next page. 186 """ 187 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers" 188 params = { 189 "pageSize": str(page_size) if page_size else None, 190 "continuationToken": continuation_token, 191 } 192 data = await self._http.get( 193 service_url, 194 endpoint, 195 params=params, 196 options=BotRequestOptions(operation_description="get paged members"), 197 ) 198 return PagedMembersResult.model_validate(data) if data else PagedMembersResult() 199 200 async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None: 201 """Remove a member from a conversation. 202 203 Args: 204 service_url: The channel's service URL. 205 conversation_id: Target conversation identifier. 206 member_id: The member's account ID. 207 """ 208 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 209 await self._http.delete( 210 service_url, 211 endpoint, 212 BotRequestOptions(operation_description="delete conversation member"), 213 ) 214 215 async def create_conversation_async( 216 self, service_url: str, parameters: ConversationParameters 217 ) -> ConversationResourceResponse | None: 218 """Create a new conversation on the channel. 219 220 Args: 221 service_url: The channel's service URL. 222 parameters: Conversation creation parameters (members, topic, etc.). 223 224 Returns: 225 A :class:`ConversationResourceResponse` with the new conversation 226 ID and service URL, or ``None``. 227 """ 228 data = await self._http.post( 229 service_url, 230 "/v3/conversations", 231 _serialize(parameters), 232 BotRequestOptions(operation_description="create conversation"), 233 ) 234 return ConversationResourceResponse.model_validate(data) if data else None 235 236 async def get_conversations_async( 237 self, service_url: str, continuation_token: str | None = None 238 ) -> ConversationsResult: 239 """List conversations the bot has participated in. 240 241 Args: 242 service_url: The channel's service URL. 243 continuation_token: Opaque token from a previous page. 244 245 Returns: 246 A :class:`ConversationsResult` with conversations and an optional 247 continuation token. 248 """ 249 params = {"continuationToken": continuation_token} 250 data = await self._http.get( 251 service_url, 252 "/v3/conversations", 253 params=params, 254 options=BotRequestOptions(operation_description="get conversations"), 255 ) 256 return ConversationsResult.model_validate(data) if data else ConversationsResult() 257 258 async def send_conversation_history_async( 259 self, service_url: str, conversation_id: str, transcript: Transcript 260 ) -> ResourceResponse | None: 261 """Upload a transcript of activities to a conversation's history. 262 263 Args: 264 service_url: The channel's service URL. 265 conversation_id: Target conversation identifier. 266 transcript: A :class:`Transcript` containing activities to upload. 267 268 Returns: 269 A :class:`ResourceResponse`, or ``None``. 270 """ 271 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history" 272 data = await self._http.post( 273 service_url, 274 endpoint, 275 _serialize(transcript), 276 BotRequestOptions(operation_description="send conversation history"), 277 ) 278 return ResourceResponse.model_validate(data) if data else None 279 280 async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Conversation | None: 281 """Retrieve the conversation account details. 282 283 Args: 284 service_url: The channel's service URL. 285 conversation_id: Target conversation identifier. 286 287 Returns: 288 A :class:`Conversation` object, or ``None`` if not found. 289 """ 290 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}" 291 data = await self._http.get( 292 service_url, 293 endpoint, 294 options=BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True), 295 ) 296 return Conversation.model_validate(data) if data else None 297 298 async def aclose(self) -> None: 299 """Close the underlying HTTP client and release resources.""" 300 await self._http.aclose()
Typed client for Bot Service Conversation REST API operations.
All methods accept a service_url and conversation_id to target
the correct channel endpoint. Authentication is handled automatically
via the injected TokenProvider.
53 def __init__(self, get_token: TokenProvider | None = None) -> None: 54 """Initialise the conversation client. 55 56 Args: 57 get_token: Async callable that supplies a bearer token. 58 When ``None``, requests are unauthenticated. 59 """ 60 self._http = BotHttpClient(get_token)
Initialise the conversation client.
Args:
get_token: Async callable that supplies a bearer token.
When None, requests are unauthenticated.
62 async def send_activity_async( 63 self, 64 service_url: str, 65 conversation_id: str, 66 activity: CoreActivity | dict[str, Any], 67 ) -> ResourceResponse | None: 68 """Send an activity to a conversation. 69 70 Args: 71 service_url: The channel's service URL. 72 conversation_id: Target conversation identifier. 73 activity: Activity payload (model or dict). 74 75 Returns: 76 A :class:`ResourceResponse` with the new activity ID, or ``None``. 77 """ 78 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities" 79 data = await self._http.post( 80 service_url, 81 endpoint, 82 _serialize(activity), 83 BotRequestOptions(operation_description="send activity"), 84 ) 85 return ResourceResponse.model_validate(data) if data else None
Send an activity to a conversation.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. activity: Activity payload (model or dict).
Returns:
A ResourceResponse with the new activity ID, or None.
87 async def update_activity_async( 88 self, 89 service_url: str, 90 conversation_id: str, 91 activity_id: str, 92 activity: CoreActivity | dict[str, Any], 93 ) -> ResourceResponse | None: 94 """Update an existing activity in a conversation. 95 96 Args: 97 service_url: The channel's service URL. 98 conversation_id: Conversation containing the activity. 99 activity_id: ID of the activity to update. 100 activity: Replacement activity payload. 101 102 Returns: 103 A :class:`ResourceResponse`, or ``None``. 104 """ 105 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 106 data = await self._http.put( 107 service_url, 108 endpoint, 109 _serialize(activity), 110 BotRequestOptions(operation_description="update activity"), 111 ) 112 return ResourceResponse.model_validate(data) if data else None
Update an existing activity in a conversation.
Args: service_url: The channel's service URL. conversation_id: Conversation containing the activity. activity_id: ID of the activity to update. activity: Replacement activity payload.
Returns:
A ResourceResponse, or None.
114 async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None: 115 """Delete an activity from a conversation. 116 117 Args: 118 service_url: The channel's service URL. 119 conversation_id: Conversation containing the activity. 120 activity_id: ID of the activity to delete. 121 """ 122 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 123 await self._http.delete( 124 service_url, 125 endpoint, 126 BotRequestOptions(operation_description="delete activity"), 127 )
Delete an activity from a conversation.
Args: service_url: The channel's service URL. conversation_id: Conversation containing the activity. activity_id: ID of the activity to delete.
129 async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]: 130 """Retrieve all members of a conversation. 131 132 Args: 133 service_url: The channel's service URL. 134 conversation_id: Target conversation identifier. 135 136 Returns: 137 List of :class:`ChannelAccount` objects for each member. 138 """ 139 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members" 140 data = await self._http.get( 141 service_url, 142 endpoint, 143 options=BotRequestOptions(operation_description="get conversation members"), 144 ) 145 return [ChannelAccount.model_validate(m) for m in (data or [])]
Retrieve all members of a conversation.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier.
Returns:
List of ChannelAccount objects for each member.
147 async def get_conversation_member_async( 148 self, service_url: str, conversation_id: str, member_id: str 149 ) -> ChannelAccount | None: 150 """Retrieve a single conversation member by ID. 151 152 Args: 153 service_url: The channel's service URL. 154 conversation_id: Target conversation identifier. 155 member_id: The member's account ID. 156 157 Returns: 158 A :class:`ChannelAccount`, or ``None`` if the member is not found. 159 """ 160 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 161 data = await self._http.get( 162 service_url, 163 endpoint, 164 options=BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True), 165 ) 166 return ChannelAccount.model_validate(data) if data else None
Retrieve a single conversation member by ID.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. member_id: The member's account ID.
Returns:
A ChannelAccount, or None if the member is not found.
168 async def get_conversation_paged_members_async( 169 self, 170 service_url: str, 171 conversation_id: str, 172 page_size: int | None = None, 173 continuation_token: str | None = None, 174 ) -> PagedMembersResult: 175 """Retrieve conversation members with server-side pagination. 176 177 Args: 178 service_url: The channel's service URL. 179 conversation_id: Target conversation identifier. 180 page_size: Maximum members per page (channel may enforce its own limit). 181 continuation_token: Opaque token from a previous page to fetch the next. 182 183 Returns: 184 A :class:`PagedMembersResult` containing members and an optional 185 continuation token for the next page. 186 """ 187 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers" 188 params = { 189 "pageSize": str(page_size) if page_size else None, 190 "continuationToken": continuation_token, 191 } 192 data = await self._http.get( 193 service_url, 194 endpoint, 195 params=params, 196 options=BotRequestOptions(operation_description="get paged members"), 197 ) 198 return PagedMembersResult.model_validate(data) if data else PagedMembersResult()
Retrieve conversation members with server-side pagination.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. page_size: Maximum members per page (channel may enforce its own limit). continuation_token: Opaque token from a previous page to fetch the next.
Returns:
A PagedMembersResult containing members and an optional
continuation token for the next page.
200 async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None: 201 """Remove a member from a conversation. 202 203 Args: 204 service_url: The channel's service URL. 205 conversation_id: Target conversation identifier. 206 member_id: The member's account ID. 207 """ 208 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 209 await self._http.delete( 210 service_url, 211 endpoint, 212 BotRequestOptions(operation_description="delete conversation member"), 213 )
Remove a member from a conversation.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. member_id: The member's account ID.
215 async def create_conversation_async( 216 self, service_url: str, parameters: ConversationParameters 217 ) -> ConversationResourceResponse | None: 218 """Create a new conversation on the channel. 219 220 Args: 221 service_url: The channel's service URL. 222 parameters: Conversation creation parameters (members, topic, etc.). 223 224 Returns: 225 A :class:`ConversationResourceResponse` with the new conversation 226 ID and service URL, or ``None``. 227 """ 228 data = await self._http.post( 229 service_url, 230 "/v3/conversations", 231 _serialize(parameters), 232 BotRequestOptions(operation_description="create conversation"), 233 ) 234 return ConversationResourceResponse.model_validate(data) if data else None
Create a new conversation on the channel.
Args: service_url: The channel's service URL. parameters: Conversation creation parameters (members, topic, etc.).
Returns:
A ConversationResourceResponse with the new conversation
ID and service URL, or None.
236 async def get_conversations_async( 237 self, service_url: str, continuation_token: str | None = None 238 ) -> ConversationsResult: 239 """List conversations the bot has participated in. 240 241 Args: 242 service_url: The channel's service URL. 243 continuation_token: Opaque token from a previous page. 244 245 Returns: 246 A :class:`ConversationsResult` with conversations and an optional 247 continuation token. 248 """ 249 params = {"continuationToken": continuation_token} 250 data = await self._http.get( 251 service_url, 252 "/v3/conversations", 253 params=params, 254 options=BotRequestOptions(operation_description="get conversations"), 255 ) 256 return ConversationsResult.model_validate(data) if data else ConversationsResult()
List conversations the bot has participated in.
Args: service_url: The channel's service URL. continuation_token: Opaque token from a previous page.
Returns:
A ConversationsResult with conversations and an optional
continuation token.
258 async def send_conversation_history_async( 259 self, service_url: str, conversation_id: str, transcript: Transcript 260 ) -> ResourceResponse | None: 261 """Upload a transcript of activities to a conversation's history. 262 263 Args: 264 service_url: The channel's service URL. 265 conversation_id: Target conversation identifier. 266 transcript: A :class:`Transcript` containing activities to upload. 267 268 Returns: 269 A :class:`ResourceResponse`, or ``None``. 270 """ 271 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history" 272 data = await self._http.post( 273 service_url, 274 endpoint, 275 _serialize(transcript), 276 BotRequestOptions(operation_description="send conversation history"), 277 ) 278 return ResourceResponse.model_validate(data) if data else None
Upload a transcript of activities to a conversation's history.
Args:
service_url: The channel's service URL.
conversation_id: Target conversation identifier.
transcript: A Transcript containing activities to upload.
Returns:
A ResourceResponse, or None.
280 async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Conversation | None: 281 """Retrieve the conversation account details. 282 283 Args: 284 service_url: The channel's service URL. 285 conversation_id: Target conversation identifier. 286 287 Returns: 288 A :class:`Conversation` object, or ``None`` if not found. 289 """ 290 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}" 291 data = await self._http.get( 292 service_url, 293 endpoint, 294 options=BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True), 295 ) 296 return Conversation.model_validate(data) if data else None
Retrieve the conversation account details.
Args: service_url: The channel's service URL. conversation_id: Target conversation identifier.
Returns:
A Conversation object, or None if not found.
108class CoreActivity(_CamelModel): 109 """Bot Service activity payload. 110 111 Represents an incoming or outgoing message, typing indicator, or other event. 112 Routing fields (``from``, ``recipient``, ``conversation``, ``serviceUrl``) are 113 automatically populated for outbound messages. Unknown JSON properties are 114 preserved via Pydantic's ``extra="allow"`` config (e.g. ``channelData``, 115 ``membersAdded``). 116 117 Note: 118 The ``from`` JSON field is mapped to ``from_account`` because ``from`` 119 is a Python reserved keyword. Serialization via :meth:`model_dump` 120 restores the original ``from`` key. 121 122 Attributes: 123 type: Activity type (``"message"``, ``"typing"``, ``"invoke"``, etc.). 124 service_url: Channel service endpoint URL. 125 from_account: Sender's channel account (mapped from JSON ``from``). 126 recipient: Recipient's channel account. 127 conversation: Conversation reference. 128 text: Message text content. 129 name: Sub-type name (used by ``invoke`` activities). 130 value: Payload for ``invoke`` or ``messageReaction`` activities. 131 entities: List of entity metadata (mentions, places, etc.). 132 attachments: List of file or card attachments. 133 """ 134 135 type: str 136 service_url: str = "" 137 from_account: ChannelAccount | None = None 138 recipient: ChannelAccount | None = None 139 conversation: Conversation | None = None 140 text: str | None = None 141 name: str | None = None 142 value: Any = None 143 entities: list[Entity] | None = None 144 attachments: list[Attachment] | None = None 145 146 model_config = ConfigDict( 147 alias_generator=to_camel, 148 populate_by_name=True, 149 extra="allow", 150 # 'from' is a Python keyword — remapped via model_validator below 151 ) 152 153 @model_validator(mode="before") 154 @classmethod 155 def _remap_from(cls, data: Any) -> Any: 156 if isinstance(data, dict) and "from" in data: 157 data = dict(data) 158 data["from_account"] = data.pop("from") 159 return data 160 161 @classmethod 162 def model_validate_json(cls, json_data: str | bytes, **kwargs: Any) -> "CoreActivity": # type: ignore[override] 163 """Deserialize a JSON string or bytes into a CoreActivity. 164 165 Handles the ``from`` → ``from_account`` remapping automatically. 166 167 Args: 168 json_data: Raw JSON string or bytes. 169 **kwargs: Additional keyword arguments passed to ``model_validate``. 170 171 Returns: 172 A validated :class:`CoreActivity` instance. 173 """ 174 import json 175 176 data = json.loads(json_data) 177 return cls.model_validate(data, **kwargs) 178 179 def model_dump(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] 180 """Serialize to a dict, restoring ``from_account`` back to ``from``. 181 182 Args: 183 **kwargs: Keyword arguments forwarded to Pydantic's ``model_dump``. 184 185 Returns: 186 A JSON-compatible dict with camelCase keys when ``by_alias=True``. 187 """ 188 d = super().model_dump(**kwargs) 189 # remap 'from_account' → 'from' in output 190 if "from_account" in d: 191 d["from"] = d.pop("from_account") 192 elif "fromAccount" in d: 193 d["from"] = d.pop("fromAccount") 194 return d
Bot Service activity payload.
Represents an incoming or outgoing message, typing indicator, or other event.
Routing fields (from, recipient, conversation, serviceUrl) are
automatically populated for outbound messages. Unknown JSON properties are
preserved via Pydantic's extra="allow" config (e.g. channelData,
membersAdded).
Note:
The from JSON field is mapped to from_account because from
is a Python reserved keyword. Serialization via model_dump()
restores the original from key.
Attributes:
type: Activity type ("message", "typing", "invoke", etc.).
service_url: Channel service endpoint URL.
from_account: Sender's channel account (mapped from JSON from).
recipient: Recipient's channel account.
conversation: Conversation reference.
text: Message text content.
name: Sub-type name (used by invoke activities).
value: Payload for invoke or messageReaction activities.
entities: List of entity metadata (mentions, places, etc.).
attachments: List of file or card attachments.
276class CoreActivityBuilder: 277 """Fluent builder for constructing outbound CoreActivity instances. 278 279 Example:: 280 281 activity = ( 282 CoreActivityBuilder() 283 .with_conversation_reference(incoming) 284 .with_text("Hello!") 285 .build() 286 ) 287 """ 288 289 def __init__(self) -> None: 290 """Initialise the builder with default values (type ``"message"``).""" 291 self._type: str = "message" 292 self._service_url: str = "" 293 self._conversation: Conversation | None = None 294 self._from_account: ChannelAccount | None = None 295 self._recipient: ChannelAccount | None = None 296 self._text: str = "" 297 self._entities: list[Entity] | None = None 298 self._attachments: list[Attachment] | None = None 299 300 def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder": 301 """Copy routing fields from an incoming activity and swap from/recipient. 302 303 Args: 304 source: The incoming activity to extract routing from. 305 306 Returns: 307 The builder instance for chaining. 308 """ 309 self._service_url = source.service_url 310 self._conversation = source.conversation 311 self._from_account = source.recipient 312 self._recipient = source.from_account 313 return self 314 315 def with_type(self, activity_type: str) -> "CoreActivityBuilder": 316 """Set the activity type (default is ``"message"``). 317 318 Args: 319 activity_type: Activity type string. 320 321 Returns: 322 The builder instance for chaining. 323 """ 324 self._type = activity_type 325 return self 326 327 def with_service_url(self, service_url: str) -> "CoreActivityBuilder": 328 """Set the service URL for the channel. 329 330 Args: 331 service_url: Channel service endpoint URL. 332 333 Returns: 334 The builder instance for chaining. 335 """ 336 self._service_url = service_url 337 return self 338 339 def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder": 340 """Set the conversation reference. 341 342 Args: 343 conversation: Target conversation. 344 345 Returns: 346 The builder instance for chaining. 347 """ 348 self._conversation = conversation 349 return self 350 351 def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder": 352 """Set the sender account. 353 354 Args: 355 from_account: The sender's channel account. 356 357 Returns: 358 The builder instance for chaining. 359 """ 360 self._from_account = from_account 361 return self 362 363 def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder": 364 """Set the recipient account. 365 366 Args: 367 recipient: The recipient's channel account. 368 369 Returns: 370 The builder instance for chaining. 371 """ 372 self._recipient = recipient 373 return self 374 375 def with_text(self, text: str) -> "CoreActivityBuilder": 376 """Set the text content of the activity. 377 378 Args: 379 text: Message text. 380 381 Returns: 382 The builder instance for chaining. 383 """ 384 self._text = text 385 return self 386 387 def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder": 388 """Set the entities list. 389 390 Args: 391 entities: Entity metadata objects. 392 393 Returns: 394 The builder instance for chaining. 395 """ 396 self._entities = entities 397 return self 398 399 def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder": 400 """Set the attachments list. 401 402 Args: 403 attachments: Attachment objects. 404 405 Returns: 406 The builder instance for chaining. 407 """ 408 self._attachments = attachments 409 return self 410 411 def build(self) -> CoreActivity: 412 """Build a new CoreActivity from the current builder state. 413 414 Returns: 415 A fully constructed :class:`CoreActivity`. 416 """ 417 return CoreActivity( 418 type=self._type, 419 service_url=self._service_url, 420 conversation=self._conversation, 421 from_account=self._from_account, 422 recipient=self._recipient, 423 text=self._text, 424 entities=self._entities, 425 attachments=self._attachments, 426 )
Fluent builder for constructing outbound CoreActivity instances.
Example::
activity = (
CoreActivityBuilder()
.with_conversation_reference(incoming)
.with_text("Hello!")
.build()
)
289 def __init__(self) -> None: 290 """Initialise the builder with default values (type ``"message"``).""" 291 self._type: str = "message" 292 self._service_url: str = "" 293 self._conversation: Conversation | None = None 294 self._from_account: ChannelAccount | None = None 295 self._recipient: ChannelAccount | None = None 296 self._text: str = "" 297 self._entities: list[Entity] | None = None 298 self._attachments: list[Attachment] | None = None
Initialise the builder with default values (type "message").
300 def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder": 301 """Copy routing fields from an incoming activity and swap from/recipient. 302 303 Args: 304 source: The incoming activity to extract routing from. 305 306 Returns: 307 The builder instance for chaining. 308 """ 309 self._service_url = source.service_url 310 self._conversation = source.conversation 311 self._from_account = source.recipient 312 self._recipient = source.from_account 313 return self
Copy routing fields from an incoming activity and swap from/recipient.
Args: source: The incoming activity to extract routing from.
Returns: The builder instance for chaining.
315 def with_type(self, activity_type: str) -> "CoreActivityBuilder": 316 """Set the activity type (default is ``"message"``). 317 318 Args: 319 activity_type: Activity type string. 320 321 Returns: 322 The builder instance for chaining. 323 """ 324 self._type = activity_type 325 return self
Set the activity type (default is "message").
Args: activity_type: Activity type string.
Returns: The builder instance for chaining.
327 def with_service_url(self, service_url: str) -> "CoreActivityBuilder": 328 """Set the service URL for the channel. 329 330 Args: 331 service_url: Channel service endpoint URL. 332 333 Returns: 334 The builder instance for chaining. 335 """ 336 self._service_url = service_url 337 return self
Set the service URL for the channel.
Args: service_url: Channel service endpoint URL.
Returns: The builder instance for chaining.
339 def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder": 340 """Set the conversation reference. 341 342 Args: 343 conversation: Target conversation. 344 345 Returns: 346 The builder instance for chaining. 347 """ 348 self._conversation = conversation 349 return self
Set the conversation reference.
Args: conversation: Target conversation.
Returns: The builder instance for chaining.
351 def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder": 352 """Set the sender account. 353 354 Args: 355 from_account: The sender's channel account. 356 357 Returns: 358 The builder instance for chaining. 359 """ 360 self._from_account = from_account 361 return self
Set the sender account.
Args: from_account: The sender's channel account.
Returns: The builder instance for chaining.
363 def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder": 364 """Set the recipient account. 365 366 Args: 367 recipient: The recipient's channel account. 368 369 Returns: 370 The builder instance for chaining. 371 """ 372 self._recipient = recipient 373 return self
Set the recipient account.
Args: recipient: The recipient's channel account.
Returns: The builder instance for chaining.
375 def with_text(self, text: str) -> "CoreActivityBuilder": 376 """Set the text content of the activity. 377 378 Args: 379 text: Message text. 380 381 Returns: 382 The builder instance for chaining. 383 """ 384 self._text = text 385 return self
Set the text content of the activity.
Args: text: Message text.
Returns: The builder instance for chaining.
387 def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder": 388 """Set the entities list. 389 390 Args: 391 entities: Entity metadata objects. 392 393 Returns: 394 The builder instance for chaining. 395 """ 396 self._entities = entities 397 return self
Set the entities list.
Args: entities: Entity metadata objects.
Returns: The builder instance for chaining.
399 def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder": 400 """Set the attachments list. 401 402 Args: 403 attachments: Attachment objects. 404 405 Returns: 406 The builder instance for chaining. 407 """ 408 self._attachments = attachments 409 return self
Set the attachments list.
Args: attachments: Attachment objects.
Returns: The builder instance for chaining.
411 def build(self) -> CoreActivity: 412 """Build a new CoreActivity from the current builder state. 413 414 Returns: 415 A fully constructed :class:`CoreActivity`. 416 """ 417 return CoreActivity( 418 type=self._type, 419 service_url=self._service_url, 420 conversation=self._conversation, 421 from_account=self._from_account, 422 recipient=self._recipient, 423 text=self._text, 424 entities=self._entities, 425 attachments=self._attachments, 426 )
Build a new CoreActivity from the current builder state.
Returns:
A fully constructed CoreActivity.
81class Entity(_CamelModel): 82 """Bot Service entity metadata (mentions, places, etc.). 83 84 Extra fields are preserved via Pydantic's ``extra="allow"`` config. 85 """ 86 87 type: str
Bot Service entity metadata (mentions, places, etc.).
Extra fields are preserved via Pydantic's extra="allow" config.
51@dataclass 52class InvokeResponse: 53 """Response returned by an invoke activity handler. 54 55 The ``status`` is written as the HTTP status code; ``body`` is serialized 56 as JSON and included in the response body. 57 """ 58 59 status: int 60 """HTTP status code to return to the channel (e.g. 200, 400, 501).""" 61 body: Any = field(default=None) 62 """Optional response body serialized as JSON. Omitted when ``None``."""
23@runtime_checkable 24class TurnMiddleware(Protocol): 25 """Protocol that all turn middleware must satisfy. 26 27 Implement :meth:`on_turn` to intercept activities before they reach 28 the registered handler. Call ``await next()`` to pass control to the 29 next middleware (or the handler if this is the last middleware). 30 """ 31 32 async def on_turn( 33 self, 34 context: "TurnContext", 35 next: NextTurn, 36 ) -> None: 37 """Process a turn and optionally delegate to the next middleware. 38 39 Args: 40 context: The current turn context with the incoming activity. 41 next: Async callback to invoke the next middleware or handler. 42 """ 43 ...
Protocol that all turn middleware must satisfy.
Implement on_turn() to intercept activities before they reach
the registered handler. Call await next() to pass control to the
next middleware (or the handler if this is the last middleware).
1771def _no_init_or_replace_init(self, *args, **kwargs): 1772 cls = type(self) 1773 1774 if cls._is_protocol: 1775 raise TypeError('Protocols cannot be instantiated') 1776 1777 # Already using a custom `__init__`. No need to calculate correct 1778 # `__init__` to call. This can lead to RecursionError. See bpo-45121. 1779 if cls.__init__ is not _no_init_or_replace_init: 1780 return 1781 1782 # Initially, `__init__` of a protocol subclass is set to `_no_init_or_replace_init`. 1783 # The first instantiation of the subclass will call `_no_init_or_replace_init` which 1784 # searches for a proper new `__init__` in the MRO. The new `__init__` 1785 # replaces the subclass' old `__init__` (ie `_no_init_or_replace_init`). Subsequent 1786 # instantiation of the protocol subclass will thus use the new 1787 # `__init__` and no longer call `_no_init_or_replace_init`. 1788 for base in cls.__mro__: 1789 init = base.__dict__.get('__init__', _no_init_or_replace_init) 1790 if init is not _no_init_or_replace_init: 1791 cls.__init__ = init 1792 break 1793 else: 1794 # should not happen 1795 cls.__init__ = object.__init__ 1796 1797 cls.__init__(self, *args, **kwargs)
32 async def on_turn( 33 self, 34 context: "TurnContext", 35 next: NextTurn, 36 ) -> None: 37 """Process a turn and optionally delegate to the next middleware. 38 39 Args: 40 context: The current turn context with the incoming activity. 41 next: Async callback to invoke the next middleware or handler. 42 """ 43 ...
Process a turn and optionally delegate to the next middleware.
Args: context: The current turn context with the incoming activity. next: Async callback to invoke the next middleware or handler.
72class MeetingInfo(_CamelModel): 73 """Teams meeting information. 74 75 Attributes: 76 id: Unique meeting identifier. 77 """ 78 79 id: str | None = None
Teams meeting information.
Attributes: id: Unique meeting identifier.
82class NotificationInfo(_CamelModel): 83 """Teams notification settings (e.g., alert flag for mobile push). 84 85 Attributes: 86 alert: When ``True``, triggers a mobile push notification. 87 """ 88 89 alert: bool | None = None
Teams notification settings (e.g., alert flag for mobile push).
Attributes:
alert: When True, triggers a mobile push notification.
13class RemoveMentionMiddleware: 14 """Strips @mention text from the activity when the mention targets the bot. 15 16 Useful in Teams where every message to a bot is prefixed with 17 ``<at>BotName</at>``. After this middleware runs, handlers receive 18 clean, human-typed text. 19 20 Usage:: 21 22 from botas import BotApplication 23 from botas.remove_mention_middleware import RemoveMentionMiddleware 24 25 bot = BotApplication() 26 bot.use(RemoveMentionMiddleware()) 27 """ 28 29 async def on_turn(self, context: TurnContext, next: NextTurn) -> None: 30 """Remove bot @mention text from the activity, then continue the pipeline. 31 32 Iterates over ``activity.entities`` looking for ``mention`` entities 33 whose ``mentioned.id`` matches the bot's ID (case-insensitive). 34 Matching mention text is stripped from ``activity.text``. 35 36 Args: 37 context: The current turn context. 38 next: Async callback to invoke the next middleware or handler. 39 """ 40 activity = context.activity 41 if activity.text and activity.entities: 42 bot_id = context.app.appid or (activity.recipient.id if activity.recipient else None) 43 if bot_id: 44 for entity in activity.entities: 45 raw = entity.model_dump(by_alias=True) 46 if raw.get("type") != "mention": 47 continue 48 49 mentioned = raw.get("mentioned", {}) 50 mentioned_id = mentioned.get("id", "") 51 mention_text = raw.get("text", "") 52 53 if mentioned_id.casefold() == bot_id.casefold() and mention_text: 54 activity.text = re.sub(re.escape(mention_text), "", activity.text, flags=re.IGNORECASE).strip() 55 56 await next()
Strips @mention text from the activity when the mention targets the bot.
Useful in Teams where every message to a bot is prefixed with
<at>BotName</at>. After this middleware runs, handlers receive
clean, human-typed text.
Usage::
from botas import BotApplication
from botas.remove_mention_middleware import RemoveMentionMiddleware
bot = BotApplication()
bot.use(RemoveMentionMiddleware())
29 async def on_turn(self, context: TurnContext, next: NextTurn) -> None: 30 """Remove bot @mention text from the activity, then continue the pipeline. 31 32 Iterates over ``activity.entities`` looking for ``mention`` entities 33 whose ``mentioned.id`` matches the bot's ID (case-insensitive). 34 Matching mention text is stripped from ``activity.text``. 35 36 Args: 37 context: The current turn context. 38 next: Async callback to invoke the next middleware or handler. 39 """ 40 activity = context.activity 41 if activity.text and activity.entities: 42 bot_id = context.app.appid or (activity.recipient.id if activity.recipient else None) 43 if bot_id: 44 for entity in activity.entities: 45 raw = entity.model_dump(by_alias=True) 46 if raw.get("type") != "mention": 47 continue 48 49 mentioned = raw.get("mentioned", {}) 50 mentioned_id = mentioned.get("id", "") 51 mention_text = raw.get("text", "") 52 53 if mentioned_id.casefold() == bot_id.casefold() and mention_text: 54 activity.text = re.sub(re.escape(mention_text), "", activity.text, flags=re.IGNORECASE).strip() 55 56 await next()
Remove bot @mention text from the activity, then continue the pipeline.
Iterates over activity.entities looking for mention entities
whose mentioned.id matches the bot's ID (case-insensitive).
Matching mention text is stripped from activity.text.
Args: context: The current turn context. next: Async callback to invoke the next middleware or handler.
197class ResourceResponse(_CamelModel): 198 """Response from the channel after sending or updating an activity. 199 200 Attributes: 201 id: The channel-assigned activity identifier. 202 """ 203 204 id: str
Response from the channel after sending or updating an activity.
Attributes: id: The channel-assigned activity identifier.
38class SuggestedActions(_CamelModel): 39 """A set of suggested action buttons presented alongside a message. 40 41 Attributes: 42 to: List of channel account IDs the suggestions are targeted at. 43 When ``None``, suggestions are shown to all participants. 44 actions: List of :class:`CardAction` buttons. 45 """ 46 47 to: list[str] | None = None 48 actions: list[CardAction] = []
A set of suggested action buttons presented alongside a message.
Attributes:
to: List of channel account IDs the suggestions are targeted at.
When None, suggestions are shown to all participants.
actions: List of CardAction buttons.
134class TeamsActivity(CoreActivity): 135 """Teams-specific activity with strongly-typed channel data and helpers. 136 137 Extends :class:`CoreActivity` with Teams-specific fields like 138 ``channel_data``, ``suggested_actions``, and timestamp fields. 139 140 Attributes: 141 channel_data: Teams-specific channel data. 142 timestamp: Server-side UTC timestamp. 143 local_timestamp: Client-side local timestamp. 144 locale: User's locale string (e.g. ``"en-US"``). 145 local_timezone: User's timezone identifier. 146 suggested_actions: Quick-reply buttons. 147 """ 148 149 model_config = ConfigDict( 150 alias_generator=to_camel, 151 populate_by_name=True, 152 extra="allow", 153 ) 154 155 channel_data: TeamsChannelData | None = None 156 timestamp: str | None = None 157 local_timestamp: str | None = None 158 locale: str | None = None 159 local_timezone: str | None = None 160 suggested_actions: SuggestedActions | None = None 161 162 @model_validator(mode="before") 163 @classmethod 164 def _remap_from(cls, data: Any) -> Any: 165 if isinstance(data, dict) and "from" in data: 166 data = dict(data) 167 data["from_account"] = data.pop("from") 168 return data 169 170 @staticmethod 171 def from_activity(activity: CoreActivity) -> "TeamsActivity": 172 """Create a TeamsActivity from a generic CoreActivity. 173 174 Validates and re-parses the activity data to populate Teams-specific 175 fields like ``channel_data``. 176 177 Args: 178 activity: A generic :class:`CoreActivity` to convert. 179 180 Returns: 181 A :class:`TeamsActivity` with Teams fields populated. 182 183 Raises: 184 ValueError: If ``activity`` is ``None``. 185 """ 186 if activity is None: 187 raise ValueError("activity is required") 188 data = activity.model_dump() 189 return TeamsActivity.model_validate(data) 190 191 def add_entity(self, entity: Entity) -> None: 192 """Append an entity to the activity's entities collection. 193 194 Args: 195 entity: The entity to add. 196 """ 197 if self.entities is None: 198 self.entities = [] 199 self.entities.append(entity) 200 201 @staticmethod 202 def create_builder() -> "TeamsActivityBuilder": 203 """Return a new :class:`TeamsActivityBuilder` for fluent construction.""" 204 return TeamsActivityBuilder()
Teams-specific activity with strongly-typed channel data and helpers.
Extends CoreActivity with Teams-specific fields like
channel_data, suggested_actions, and timestamp fields.
Attributes:
channel_data: Teams-specific channel data.
timestamp: Server-side UTC timestamp.
local_timestamp: Client-side local timestamp.
locale: User's locale string (e.g. "en-US").
local_timezone: User's timezone identifier.
suggested_actions: Quick-reply buttons.
170 @staticmethod 171 def from_activity(activity: CoreActivity) -> "TeamsActivity": 172 """Create a TeamsActivity from a generic CoreActivity. 173 174 Validates and re-parses the activity data to populate Teams-specific 175 fields like ``channel_data``. 176 177 Args: 178 activity: A generic :class:`CoreActivity` to convert. 179 180 Returns: 181 A :class:`TeamsActivity` with Teams fields populated. 182 183 Raises: 184 ValueError: If ``activity`` is ``None``. 185 """ 186 if activity is None: 187 raise ValueError("activity is required") 188 data = activity.model_dump() 189 return TeamsActivity.model_validate(data)
Create a TeamsActivity from a generic CoreActivity.
Validates and re-parses the activity data to populate Teams-specific
fields like channel_data.
Args:
activity: A generic CoreActivity to convert.
Returns:
A TeamsActivity with Teams fields populated.
Raises:
ValueError: If activity is None.
191 def add_entity(self, entity: Entity) -> None: 192 """Append an entity to the activity's entities collection. 193 194 Args: 195 entity: The entity to add. 196 """ 197 if self.entities is None: 198 self.entities = [] 199 self.entities.append(entity)
Append an entity to the activity's entities collection.
Args: entity: The entity to add.
201 @staticmethod 202 def create_builder() -> "TeamsActivityBuilder": 203 """Return a new :class:`TeamsActivityBuilder` for fluent construction.""" 204 return TeamsActivityBuilder()
Return a new TeamsActivityBuilder for fluent construction.
207class TeamsActivityBuilder: 208 """Fluent builder for constructing outbound TeamsActivity instances. 209 210 Example:: 211 212 activity = ( 213 TeamsActivity.create_builder() 214 .with_conversation_reference(incoming) 215 .with_text("Hello!") 216 .add_mention(user_account) 217 .build() 218 ) 219 """ 220 221 def __init__(self) -> None: 222 """Initialise the builder with default values (type ``"message"``).""" 223 self._type: str = "message" 224 self._service_url: str = "" 225 self._conversation: Conversation | None = None 226 self._from_account: ChannelAccount | None = None 227 self._recipient: ChannelAccount | None = None 228 self._text: str = "" 229 self._channel_data: TeamsChannelData | None = None 230 self._suggested_actions: SuggestedActions | None = None 231 self._entities: list[Entity] | None = None 232 self._attachments: list[Attachment] | None = None 233 234 def with_conversation_reference(self, source: CoreActivity) -> "TeamsActivityBuilder": 235 """Copy routing fields from an incoming activity and swap from/recipient. 236 237 Args: 238 source: The incoming activity to extract routing from. 239 240 Returns: 241 The builder instance for chaining. 242 """ 243 self._service_url = source.service_url 244 self._conversation = source.conversation 245 self._from_account = source.recipient 246 self._recipient = source.from_account 247 return self 248 249 def with_type(self, activity_type: str) -> "TeamsActivityBuilder": 250 """Set the activity type. 251 252 Args: 253 activity_type: Activity type string. 254 255 Returns: 256 The builder instance for chaining. 257 """ 258 self._type = activity_type 259 return self 260 261 def with_service_url(self, service_url: str) -> "TeamsActivityBuilder": 262 """Set the service URL. 263 264 Args: 265 service_url: Channel service endpoint URL. 266 267 Returns: 268 The builder instance for chaining. 269 """ 270 self._service_url = service_url 271 return self 272 273 def with_conversation(self, conversation: Conversation) -> "TeamsActivityBuilder": 274 """Set the conversation. 275 276 Args: 277 conversation: Target conversation. 278 279 Returns: 280 The builder instance for chaining. 281 """ 282 self._conversation = conversation 283 return self 284 285 def with_from(self, from_account: ChannelAccount) -> "TeamsActivityBuilder": 286 """Set the sender account. 287 288 Args: 289 from_account: The sender's channel account. 290 291 Returns: 292 The builder instance for chaining. 293 """ 294 self._from_account = from_account 295 return self 296 297 def with_recipient(self, recipient: ChannelAccount) -> "TeamsActivityBuilder": 298 """Set the recipient account. 299 300 Args: 301 recipient: The recipient's channel account. 302 303 Returns: 304 The builder instance for chaining. 305 """ 306 self._recipient = recipient 307 return self 308 309 def with_text(self, text: str) -> "TeamsActivityBuilder": 310 """Set the text content. 311 312 Args: 313 text: Message text. 314 315 Returns: 316 The builder instance for chaining. 317 """ 318 self._text = text 319 return self 320 321 def with_channel_data(self, channel_data: TeamsChannelData | None) -> "TeamsActivityBuilder": 322 """Set the Teams-specific channel data. 323 324 Args: 325 channel_data: Channel data payload, or ``None`` to clear. 326 327 Returns: 328 The builder instance for chaining. 329 """ 330 self._channel_data = channel_data 331 return self 332 333 def with_suggested_actions(self, suggested_actions: SuggestedActions | None) -> "TeamsActivityBuilder": 334 """Set the suggested actions. 335 336 Args: 337 suggested_actions: Quick-reply buttons, or ``None`` to clear. 338 339 Returns: 340 The builder instance for chaining. 341 """ 342 self._suggested_actions = suggested_actions 343 return self 344 345 def with_entities(self, entities: list[Entity] | None) -> "TeamsActivityBuilder": 346 """Replace the entities list. 347 348 Args: 349 entities: Entity metadata objects, or ``None`` to clear. 350 351 Returns: 352 The builder instance for chaining. 353 """ 354 self._entities = entities 355 return self 356 357 def with_attachments(self, attachments: list[Attachment] | None) -> "TeamsActivityBuilder": 358 """Replace the attachments list. 359 360 Args: 361 attachments: Attachment objects, or ``None`` to clear. 362 363 Returns: 364 The builder instance for chaining. 365 """ 366 self._attachments = attachments 367 return self 368 369 def with_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder": 370 """Set a single attachment (replaces the entire collection). 371 372 Args: 373 attachment: The sole attachment. 374 375 Returns: 376 The builder instance for chaining. 377 """ 378 self._attachments = [attachment] 379 return self 380 381 def add_entity(self, entity: Entity) -> "TeamsActivityBuilder": 382 """Append an entity to the collection. 383 384 Args: 385 entity: The entity to add. 386 387 Returns: 388 The builder instance for chaining. 389 """ 390 if self._entities is None: 391 self._entities = [] 392 self._entities.append(entity) 393 return self 394 395 def add_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder": 396 """Append an attachment to the collection. 397 398 Args: 399 attachment: The attachment to add. 400 401 Returns: 402 The builder instance for chaining. 403 """ 404 if self._attachments is None: 405 self._attachments = [] 406 self._attachments.append(attachment) 407 return self 408 409 def add_mention(self, account: ChannelAccount, mention_text: str | None = None) -> "TeamsActivityBuilder": 410 """Create a mention entity for a user. Does NOT modify the activity text. 411 412 You must manually include the mention text in the activity's ``text`` 413 field to make it visible in the chat. 414 415 Args: 416 account: The channel account to mention. 417 mention_text: Custom mention markup. Defaults to 418 ``<at>{account.name}</at>``. 419 420 Returns: 421 The builder instance for chaining. 422 423 Raises: 424 ValueError: If ``account`` is ``None``. 425 """ 426 if account is None: 427 raise ValueError("account is required") 428 text = mention_text or f"<at>{account.name}</at>" 429 entity = Entity(type="mention", mentioned=account.model_dump(), text=text) 430 return self.add_entity(entity) 431 432 def add_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder": 433 """Parse and append an Adaptive Card as an attachment. 434 435 Args: 436 card: A JSON string or pre-parsed dict representing the card. 437 438 Returns: 439 The builder instance for chaining. 440 """ 441 content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card) 442 attachment = Attachment( 443 content_type="application/vnd.microsoft.card.adaptive", 444 content=content, 445 ) 446 return self.add_attachment(attachment) 447 448 def with_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder": 449 """Parse and set an Adaptive Card as the sole attachment. 450 451 Args: 452 card: A JSON string or pre-parsed dict representing the card. 453 454 Returns: 455 The builder instance for chaining. 456 """ 457 content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card) 458 attachment = Attachment( 459 content_type="application/vnd.microsoft.card.adaptive", 460 content=content, 461 ) 462 return self.with_attachment(attachment) 463 464 def build(self) -> TeamsActivity: 465 """Build a new TeamsActivity from the current builder state. 466 467 Returns: 468 A fully constructed :class:`TeamsActivity`. 469 """ 470 return TeamsActivity( 471 type=self._type, 472 service_url=self._service_url, 473 conversation=self._conversation, 474 from_account=self._from_account, 475 recipient=self._recipient, 476 text=self._text, 477 channel_data=self._channel_data, 478 suggested_actions=self._suggested_actions, 479 entities=self._entities, 480 attachments=self._attachments, 481 )
Fluent builder for constructing outbound TeamsActivity instances.
Example::
activity = (
TeamsActivity.create_builder()
.with_conversation_reference(incoming)
.with_text("Hello!")
.add_mention(user_account)
.build()
)
221 def __init__(self) -> None: 222 """Initialise the builder with default values (type ``"message"``).""" 223 self._type: str = "message" 224 self._service_url: str = "" 225 self._conversation: Conversation | None = None 226 self._from_account: ChannelAccount | None = None 227 self._recipient: ChannelAccount | None = None 228 self._text: str = "" 229 self._channel_data: TeamsChannelData | None = None 230 self._suggested_actions: SuggestedActions | None = None 231 self._entities: list[Entity] | None = None 232 self._attachments: list[Attachment] | None = None
Initialise the builder with default values (type "message").
234 def with_conversation_reference(self, source: CoreActivity) -> "TeamsActivityBuilder": 235 """Copy routing fields from an incoming activity and swap from/recipient. 236 237 Args: 238 source: The incoming activity to extract routing from. 239 240 Returns: 241 The builder instance for chaining. 242 """ 243 self._service_url = source.service_url 244 self._conversation = source.conversation 245 self._from_account = source.recipient 246 self._recipient = source.from_account 247 return self
Copy routing fields from an incoming activity and swap from/recipient.
Args: source: The incoming activity to extract routing from.
Returns: The builder instance for chaining.
249 def with_type(self, activity_type: str) -> "TeamsActivityBuilder": 250 """Set the activity type. 251 252 Args: 253 activity_type: Activity type string. 254 255 Returns: 256 The builder instance for chaining. 257 """ 258 self._type = activity_type 259 return self
Set the activity type.
Args: activity_type: Activity type string.
Returns: The builder instance for chaining.
261 def with_service_url(self, service_url: str) -> "TeamsActivityBuilder": 262 """Set the service URL. 263 264 Args: 265 service_url: Channel service endpoint URL. 266 267 Returns: 268 The builder instance for chaining. 269 """ 270 self._service_url = service_url 271 return self
Set the service URL.
Args: service_url: Channel service endpoint URL.
Returns: The builder instance for chaining.
273 def with_conversation(self, conversation: Conversation) -> "TeamsActivityBuilder": 274 """Set the conversation. 275 276 Args: 277 conversation: Target conversation. 278 279 Returns: 280 The builder instance for chaining. 281 """ 282 self._conversation = conversation 283 return self
Set the conversation.
Args: conversation: Target conversation.
Returns: The builder instance for chaining.
285 def with_from(self, from_account: ChannelAccount) -> "TeamsActivityBuilder": 286 """Set the sender account. 287 288 Args: 289 from_account: The sender's channel account. 290 291 Returns: 292 The builder instance for chaining. 293 """ 294 self._from_account = from_account 295 return self
Set the sender account.
Args: from_account: The sender's channel account.
Returns: The builder instance for chaining.
297 def with_recipient(self, recipient: ChannelAccount) -> "TeamsActivityBuilder": 298 """Set the recipient account. 299 300 Args: 301 recipient: The recipient's channel account. 302 303 Returns: 304 The builder instance for chaining. 305 """ 306 self._recipient = recipient 307 return self
Set the recipient account.
Args: recipient: The recipient's channel account.
Returns: The builder instance for chaining.
309 def with_text(self, text: str) -> "TeamsActivityBuilder": 310 """Set the text content. 311 312 Args: 313 text: Message text. 314 315 Returns: 316 The builder instance for chaining. 317 """ 318 self._text = text 319 return self
Set the text content.
Args: text: Message text.
Returns: The builder instance for chaining.
321 def with_channel_data(self, channel_data: TeamsChannelData | None) -> "TeamsActivityBuilder": 322 """Set the Teams-specific channel data. 323 324 Args: 325 channel_data: Channel data payload, or ``None`` to clear. 326 327 Returns: 328 The builder instance for chaining. 329 """ 330 self._channel_data = channel_data 331 return self
Set the Teams-specific channel data.
Args:
channel_data: Channel data payload, or None to clear.
Returns: The builder instance for chaining.
333 def with_suggested_actions(self, suggested_actions: SuggestedActions | None) -> "TeamsActivityBuilder": 334 """Set the suggested actions. 335 336 Args: 337 suggested_actions: Quick-reply buttons, or ``None`` to clear. 338 339 Returns: 340 The builder instance for chaining. 341 """ 342 self._suggested_actions = suggested_actions 343 return self
Set the suggested actions.
Args:
suggested_actions: Quick-reply buttons, or None to clear.
Returns: The builder instance for chaining.
345 def with_entities(self, entities: list[Entity] | None) -> "TeamsActivityBuilder": 346 """Replace the entities list. 347 348 Args: 349 entities: Entity metadata objects, or ``None`` to clear. 350 351 Returns: 352 The builder instance for chaining. 353 """ 354 self._entities = entities 355 return self
Replace the entities list.
Args:
entities: Entity metadata objects, or None to clear.
Returns: The builder instance for chaining.
357 def with_attachments(self, attachments: list[Attachment] | None) -> "TeamsActivityBuilder": 358 """Replace the attachments list. 359 360 Args: 361 attachments: Attachment objects, or ``None`` to clear. 362 363 Returns: 364 The builder instance for chaining. 365 """ 366 self._attachments = attachments 367 return self
Replace the attachments list.
Args:
attachments: Attachment objects, or None to clear.
Returns: The builder instance for chaining.
369 def with_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder": 370 """Set a single attachment (replaces the entire collection). 371 372 Args: 373 attachment: The sole attachment. 374 375 Returns: 376 The builder instance for chaining. 377 """ 378 self._attachments = [attachment] 379 return self
Set a single attachment (replaces the entire collection).
Args: attachment: The sole attachment.
Returns: The builder instance for chaining.
381 def add_entity(self, entity: Entity) -> "TeamsActivityBuilder": 382 """Append an entity to the collection. 383 384 Args: 385 entity: The entity to add. 386 387 Returns: 388 The builder instance for chaining. 389 """ 390 if self._entities is None: 391 self._entities = [] 392 self._entities.append(entity) 393 return self
Append an entity to the collection.
Args: entity: The entity to add.
Returns: The builder instance for chaining.
395 def add_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder": 396 """Append an attachment to the collection. 397 398 Args: 399 attachment: The attachment to add. 400 401 Returns: 402 The builder instance for chaining. 403 """ 404 if self._attachments is None: 405 self._attachments = [] 406 self._attachments.append(attachment) 407 return self
Append an attachment to the collection.
Args: attachment: The attachment to add.
Returns: The builder instance for chaining.
409 def add_mention(self, account: ChannelAccount, mention_text: str | None = None) -> "TeamsActivityBuilder": 410 """Create a mention entity for a user. Does NOT modify the activity text. 411 412 You must manually include the mention text in the activity's ``text`` 413 field to make it visible in the chat. 414 415 Args: 416 account: The channel account to mention. 417 mention_text: Custom mention markup. Defaults to 418 ``<at>{account.name}</at>``. 419 420 Returns: 421 The builder instance for chaining. 422 423 Raises: 424 ValueError: If ``account`` is ``None``. 425 """ 426 if account is None: 427 raise ValueError("account is required") 428 text = mention_text or f"<at>{account.name}</at>" 429 entity = Entity(type="mention", mentioned=account.model_dump(), text=text) 430 return self.add_entity(entity)
Create a mention entity for a user. Does NOT modify the activity text.
You must manually include the mention text in the activity's text
field to make it visible in the chat.
Args:
account: The channel account to mention.
mention_text: Custom mention markup. Defaults to
<at>{account.name}</at>.
Returns: The builder instance for chaining.
Raises:
ValueError: If account is None.
432 def add_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder": 433 """Parse and append an Adaptive Card as an attachment. 434 435 Args: 436 card: A JSON string or pre-parsed dict representing the card. 437 438 Returns: 439 The builder instance for chaining. 440 """ 441 content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card) 442 attachment = Attachment( 443 content_type="application/vnd.microsoft.card.adaptive", 444 content=content, 445 ) 446 return self.add_attachment(attachment)
Parse and append an Adaptive Card as an attachment.
Args: card: A JSON string or pre-parsed dict representing the card.
Returns: The builder instance for chaining.
448 def with_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder": 449 """Parse and set an Adaptive Card as the sole attachment. 450 451 Args: 452 card: A JSON string or pre-parsed dict representing the card. 453 454 Returns: 455 The builder instance for chaining. 456 """ 457 content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card) 458 attachment = Attachment( 459 content_type="application/vnd.microsoft.card.adaptive", 460 content=content, 461 ) 462 return self.with_attachment(attachment)
Parse and set an Adaptive Card as the sole attachment.
Args: card: A JSON string or pre-parsed dict representing the card.
Returns: The builder instance for chaining.
464 def build(self) -> TeamsActivity: 465 """Build a new TeamsActivity from the current builder state. 466 467 Returns: 468 A fully constructed :class:`TeamsActivity`. 469 """ 470 return TeamsActivity( 471 type=self._type, 472 service_url=self._service_url, 473 conversation=self._conversation, 474 from_account=self._from_account, 475 recipient=self._recipient, 476 text=self._text, 477 channel_data=self._channel_data, 478 suggested_actions=self._suggested_actions, 479 entities=self._entities, 480 attachments=self._attachments, 481 )
Build a new TeamsActivity from the current builder state.
Returns:
A fully constructed TeamsActivity.
59class TeamsChannelAccount(ChannelAccount): 60 """Teams-specific channel account with extended user properties. 61 62 Attributes: 63 user_principal_name: The user's UPN (e.g. ``user@contoso.com``). 64 email: The user's email address. 65 """ 66 67 user_principal_name: str | None = None 68 email: str | None = None
Teams-specific channel account with extended user properties.
Attributes:
user_principal_name: The user's UPN (e.g. user@contoso.com).
email: The user's email address.
92class TeamsChannelData(_CamelModel): 93 """Teams-specific channel data payload. 94 95 Populated in the ``channelData`` field of a Teams activity. 96 97 Attributes: 98 tenant: Microsoft 365 tenant information. 99 channel: Teams channel details. 100 team: Teams team details. 101 meeting: Meeting metadata (for meeting-scoped activities). 102 notification: Notification settings (e.g. alert flag). 103 """ 104 105 tenant: TenantInfo | None = None 106 channel: ChannelInfo | None = None 107 team: TeamInfo | None = None 108 meeting: MeetingInfo | None = None 109 notification: NotificationInfo | None = None
Teams-specific channel data payload.
Populated in the channelData field of a Teams activity.
Attributes: tenant: Microsoft 365 tenant information. channel: Teams channel details. team: Teams team details. meeting: Meeting metadata (for meeting-scoped activities). notification: Notification settings (e.g. alert flag).
112class TeamsConversation(Conversation): 113 """Teams-specific conversation with extended metadata. 114 115 Attributes: 116 conversation_type: Type of conversation (``"personal"``, ``"channel"``, ``"groupChat"``). 117 tenant_id: Microsoft 365 tenant ID. 118 is_group: Whether this is a group conversation. 119 name: Display name of the conversation. 120 """ 121 122 model_config = ConfigDict( 123 alias_generator=to_camel, 124 populate_by_name=True, 125 extra="allow", 126 ) 127 128 conversation_type: str | None = None 129 tenant_id: str | None = None 130 is_group: bool | None = None 131 name: str | None = None
Teams-specific conversation with extended metadata.
Attributes:
conversation_type: Type of conversation ("personal", "channel", "groupChat").
tenant_id: Microsoft 365 tenant ID.
is_group: Whether this is a group conversation.
name: Display name of the conversation.
58class TeamInfo(_CamelModel): 59 """Teams team information. 60 61 Attributes: 62 id: Unique team identifier. 63 name: Display name of the team. 64 aad_group_id: Azure AD group ID for the team. 65 """ 66 67 id: str | None = None 68 name: str | None = None 69 aad_group_id: str | None = None
Teams team information.
Attributes: id: Unique team identifier. name: Display name of the team. aad_group_id: Azure AD group ID for the team.
36class TenantInfo(_CamelModel): 37 """Microsoft 365 tenant information. 38 39 Attributes: 40 id: The tenant's unique identifier (GUID). 41 """ 42 43 id: str | None = None
Microsoft 365 tenant information.
Attributes: id: The tenant's unique identifier (GUID).
43class TokenManager: 44 """Acquires and caches OAuth2 tokens for outbound Bot Service API calls. 45 46 Uses MSAL's ``ConfidentialClientApplication`` for client-credentials flow, 47 or delegates to a custom ``token_factory`` if provided. 48 """ 49 50 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 51 """Initialise the token manager. 52 53 Args: 54 options: Authentication configuration. Falls back to environment 55 variables when individual fields are ``None``. 56 """ 57 self._client_id = options.client_id or os.environ.get("CLIENT_ID") 58 self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET") 59 self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID") 60 self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get( 61 "MANAGED_IDENTITY_CLIENT_ID" 62 ) 63 self._token_factory = options.token_factory 64 self._msal_app: object | None = None 65 66 @property 67 def client_id(self) -> str | None: 68 """Returns the configured bot application/client ID.""" 69 return self._client_id 70 71 async def get_bot_token(self) -> str | None: 72 """Acquire a token for the Bot Service API scope. 73 74 Returns: 75 A bearer token string, or ``None`` if credentials are not configured. 76 """ 77 return await self._get_token(_BOT_FRAMEWORK_SCOPE) 78 79 async def _get_token(self, scope: str) -> str | None: 80 if self._token_factory: 81 return await self._token_factory(scope, self._tenant_id or "common") 82 83 if self._client_id and self._client_secret and self._tenant_id: 84 # MSAL is synchronous; offload to thread pool to avoid blocking the event loop 85 return await asyncio.to_thread(self._acquire_client_credentials, scope) 86 87 return None 88 89 def _acquire_client_credentials(self, scope: str) -> str | None: 90 import msal # type: ignore[import-untyped] 91 92 if self._msal_app is None: 93 authority = f"https://login.microsoftonline.com/{self._tenant_id}" 94 self._msal_app = msal.ConfidentialClientApplication( 95 self._client_id, 96 authority=authority, 97 client_credential=self._client_secret, 98 ) 99 100 result = self._msal_app.acquire_token_for_client(scopes=[scope]) # type: ignore[union-attr] 101 if result and "access_token" in result: 102 return result["access_token"] 103 return None 104 105 async def aclose(self) -> None: 106 """Close the token manager and reset internal MSAL state. 107 108 Call during application shutdown to release cached credentials. 109 """ 110 self._msal_app = None
Acquires and caches OAuth2 tokens for outbound Bot Service API calls.
Uses MSAL's ConfidentialClientApplication for client-credentials flow,
or delegates to a custom token_factory if provided.
50 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 51 """Initialise the token manager. 52 53 Args: 54 options: Authentication configuration. Falls back to environment 55 variables when individual fields are ``None``. 56 """ 57 self._client_id = options.client_id or os.environ.get("CLIENT_ID") 58 self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET") 59 self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID") 60 self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get( 61 "MANAGED_IDENTITY_CLIENT_ID" 62 ) 63 self._token_factory = options.token_factory 64 self._msal_app: object | None = None
Initialise the token manager.
Args:
options: Authentication configuration. Falls back to environment
variables when individual fields are None.
66 @property 67 def client_id(self) -> str | None: 68 """Returns the configured bot application/client ID.""" 69 return self._client_id
Returns the configured bot application/client ID.
71 async def get_bot_token(self) -> str | None: 72 """Acquire a token for the Bot Service API scope. 73 74 Returns: 75 A bearer token string, or ``None`` if credentials are not configured. 76 """ 77 return await self._get_token(_BOT_FRAMEWORK_SCOPE)
Acquire a token for the Bot Service API scope.
Returns:
A bearer token string, or None if credentials are not configured.
105 async def aclose(self) -> None: 106 """Close the token manager and reset internal MSAL state. 107 108 Call during application shutdown to release cached credentials. 109 """ 110 self._msal_app = None
Close the token manager and reset internal MSAL state.
Call during application shutdown to release cached credentials.
12class TurnContext: 13 """Context for a single activity turn, passed to handlers and middleware. 14 15 Provides the incoming activity, a reference to the bot application, 16 and a scoped :meth:`send` method that automatically routes replies 17 back to the originating conversation. 18 19 Example:: 20 21 @bot.on("message") 22 async def on_message(ctx: TurnContext): 23 await ctx.send(f"You said: {ctx.activity.text}") 24 """ 25 26 __slots__ = ("activity", "app") 27 28 def __init__(self, app: BotApplication, activity: CoreActivity) -> None: 29 """Initialise the turn context. 30 31 Args: 32 app: The bot application instance processing this turn. 33 activity: The incoming activity for this turn. 34 """ 35 self.activity = activity 36 self.app = app 37 38 async def send( 39 self, 40 activity_or_text: str | CoreActivity | dict[str, Any], 41 ) -> ResourceResponse | None: 42 """Send a reply to the conversation that originated this turn. 43 44 Accepts a plain text string (sent as a message activity), a 45 :class:`CoreActivity`, or a dict for full control over the reply. 46 Routing fields are automatically populated from the incoming activity. 47 48 When passing a dict, you must provide at minimum a ``type`` field. 49 Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc. 50 are optional and depend on the activity type. Routing fields 51 (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``) 52 are auto-populated but can be overridden. 53 54 Args: 55 activity_or_text: A plain string, a :class:`CoreActivity`, or a dict. 56 57 Returns: 58 A :class:`ResourceResponse` with the sent activity ID, or ``None``. 59 60 Example:: 61 62 # Simple text reply 63 await ctx.send("Hello!") 64 65 # Dict with custom fields 66 await ctx.send({ 67 "type": "message", 68 "text": "Hello!", 69 "attachments": [...] 70 }) 71 """ 72 if isinstance(activity_or_text, str): 73 reply: CoreActivity | dict[str, Any] = ( 74 CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build() 75 ) 76 elif isinstance(activity_or_text, CoreActivity): 77 reply = CoreActivityBuilder().with_conversation_reference(self.activity).build() 78 # Merge: caller fields take precedence 79 merged = reply.model_dump(by_alias=True, exclude_none=True) 80 merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True)) 81 reply = merged 82 else: 83 base = ( 84 CoreActivityBuilder() 85 .with_conversation_reference(self.activity) 86 .build() 87 .model_dump(by_alias=True, exclude_none=True) 88 ) 89 base.update(activity_or_text) 90 reply = base 91 92 return await self.app.send_activity_async( 93 self.activity.service_url, 94 self.activity.conversation.id, 95 reply, 96 ) 97 98 async def send_typing(self) -> None: 99 """Send a typing indicator to the conversation. 100 101 Creates a typing activity with routing fields populated from the 102 incoming activity. Typing activities are ephemeral and do not 103 return a ResourceResponse. 104 105 Example:: 106 107 @bot.on("message") 108 async def on_message(ctx: TurnContext): 109 await ctx.send_typing() 110 # ... do some work ... 111 await ctx.send("Done!") 112 """ 113 typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build() 114 await self.app.send_activity_async( 115 self.activity.service_url, 116 self.activity.conversation.id, 117 typing_activity, 118 )
Context for a single activity turn, passed to handlers and middleware.
Provides the incoming activity, a reference to the bot application,
and a scoped send() method that automatically routes replies
back to the originating conversation.
Example::
@bot.on("message")
async def on_message(ctx: TurnContext):
await ctx.send(f"You said: {ctx.activity.text}")
28 def __init__(self, app: BotApplication, activity: CoreActivity) -> None: 29 """Initialise the turn context. 30 31 Args: 32 app: The bot application instance processing this turn. 33 activity: The incoming activity for this turn. 34 """ 35 self.activity = activity 36 self.app = app
Initialise the turn context.
Args: app: The bot application instance processing this turn. activity: The incoming activity for this turn.
38 async def send( 39 self, 40 activity_or_text: str | CoreActivity | dict[str, Any], 41 ) -> ResourceResponse | None: 42 """Send a reply to the conversation that originated this turn. 43 44 Accepts a plain text string (sent as a message activity), a 45 :class:`CoreActivity`, or a dict for full control over the reply. 46 Routing fields are automatically populated from the incoming activity. 47 48 When passing a dict, you must provide at minimum a ``type`` field. 49 Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc. 50 are optional and depend on the activity type. Routing fields 51 (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``) 52 are auto-populated but can be overridden. 53 54 Args: 55 activity_or_text: A plain string, a :class:`CoreActivity`, or a dict. 56 57 Returns: 58 A :class:`ResourceResponse` with the sent activity ID, or ``None``. 59 60 Example:: 61 62 # Simple text reply 63 await ctx.send("Hello!") 64 65 # Dict with custom fields 66 await ctx.send({ 67 "type": "message", 68 "text": "Hello!", 69 "attachments": [...] 70 }) 71 """ 72 if isinstance(activity_or_text, str): 73 reply: CoreActivity | dict[str, Any] = ( 74 CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build() 75 ) 76 elif isinstance(activity_or_text, CoreActivity): 77 reply = CoreActivityBuilder().with_conversation_reference(self.activity).build() 78 # Merge: caller fields take precedence 79 merged = reply.model_dump(by_alias=True, exclude_none=True) 80 merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True)) 81 reply = merged 82 else: 83 base = ( 84 CoreActivityBuilder() 85 .with_conversation_reference(self.activity) 86 .build() 87 .model_dump(by_alias=True, exclude_none=True) 88 ) 89 base.update(activity_or_text) 90 reply = base 91 92 return await self.app.send_activity_async( 93 self.activity.service_url, 94 self.activity.conversation.id, 95 reply, 96 )
Send a reply to the conversation that originated this turn.
Accepts a plain text string (sent as a message activity), a
CoreActivity, or a dict for full control over the reply.
Routing fields are automatically populated from the incoming activity.
When passing a dict, you must provide at minimum a type field.
Other fields such as text, attachments, suggestedActions, etc.
are optional and depend on the activity type. Routing fields
(from, recipient, conversation, serviceUrl, channelId)
are auto-populated but can be overridden.
Args:
activity_or_text: A plain string, a CoreActivity, or a dict.
Returns:
A ResourceResponse with the sent activity ID, or None.
Example::
# Simple text reply
await ctx.send("Hello!")
# Dict with custom fields
await ctx.send({
"type": "message",
"text": "Hello!",
"attachments": [...]
})
98 async def send_typing(self) -> None: 99 """Send a typing indicator to the conversation. 100 101 Creates a typing activity with routing fields populated from the 102 incoming activity. Typing activities are ephemeral and do not 103 return a ResourceResponse. 104 105 Example:: 106 107 @bot.on("message") 108 async def on_message(ctx: TurnContext): 109 await ctx.send_typing() 110 # ... do some work ... 111 await ctx.send("Done!") 112 """ 113 typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build() 114 await self.app.send_activity_async( 115 self.activity.service_url, 116 self.activity.conversation.id, 117 typing_activity, 118 )
Send a typing indicator to the conversation.
Creates a typing activity with routing fields populated from the incoming activity. Typing activities are ephemeral and do not return a ResourceResponse.
Example::
@bot.on("message")
async def on_message(ctx: TurnContext):
await ctx.send_typing()
# ... do some work ...
await ctx.send("Done!")
117async def validate_bot_token(auth_header: str | None, app_id: str | None = None) -> None: 118 """Validate a Bot Service or Entra ID JWT bearer token. 119 120 Supports tokens from both the Bot Service channel service and Azure 121 AD / Entra ID. The correct OpenID configuration is selected dynamically 122 by inspecting the token's issuer claim (see ``specs/inbound-auth.md``). 123 124 Args: 125 auth_header: The full ``Authorization`` header value 126 (e.g. ``"Bearer eyJ..."``). 127 app_id: Expected audience (bot application / client ID). Falls back 128 to the ``CLIENT_ID`` environment variable when ``None``. 129 130 Raises: 131 BotAuthError: On any validation failure — missing header, expired 132 token, bad audience, untrusted issuer, or missing JWKS key. 133 """ 134 resolved_app_id = app_id or os.environ.get("CLIENT_ID") 135 if not resolved_app_id: 136 raise BotAuthError("CLIENT_ID not configured") 137 138 if not auth_header or not auth_header.startswith("Bearer "): 139 raise BotAuthError("Missing or malformed Authorization header") 140 141 token = auth_header[len("Bearer ") :] 142 143 try: 144 unverified = jwt.get_unverified_header(token) 145 except jwt.exceptions.DecodeError as exc: 146 raise BotAuthError("Invalid token header") from exc 147 148 kid = unverified.get("kid") 149 150 # Peek at claims to determine issuer and select correct JWKS source 151 peeked = _peek_claims(token) 152 iss = peeked["iss"] 153 tid = peeked["tid"] 154 _logger.debug("Token issuer=%s tid=%s aud=%s", iss, tid, peeked["aud"]) 155 156 allowed_issuers = _valid_issuers(tid) 157 if not iss or iss not in allowed_issuers: 158 _logger.warning("Token rejected: untrusted issuer %r", iss) 159 raise BotAuthError("Untrusted token issuer") 160 161 metadata_url = _resolve_metadata_url(iss, tid) 162 _logger.debug("Using OpenID metadata: %s", metadata_url) 163 164 if not _is_allowed_metadata_url(metadata_url): 165 _logger.warning("Rejected disallowed metadata URL: %s", metadata_url) 166 raise BotAuthError("Untrusted token issuer") 167 168 keys = await _get_jwks(metadata_url) 169 matching = next((k for k in keys if k.get("kid") == kid), None) 170 171 if matching is None: 172 # Try refreshing JWKS once (key rollover) 173 keys = await _get_jwks(metadata_url, force_refresh=True) 174 matching = next((k for k in keys if k.get("kid") == kid), None) 175 176 if matching is None: 177 raise BotAuthError(f"No JWKS key found for kid={kid!r}") 178 179 public_key = RSAAlgorithm.from_jwk(json.dumps(matching)) 180 181 allowed_audiences = [resolved_app_id, f"api://{resolved_app_id}", _BOT_FRAMEWORK_ISSUER] 182 183 try: 184 jwt.decode( 185 token, 186 public_key, # type: ignore[arg-type] 187 algorithms=["RS256"], 188 audience=allowed_audiences, 189 issuer=allowed_issuers, 190 ) 191 except jwt.ExpiredSignatureError as exc: 192 raise BotAuthError("Token has expired") from exc 193 except jwt.InvalidAudienceError as exc: 194 raise BotAuthError("Invalid audience") from exc 195 except jwt.InvalidIssuerError as exc: 196 raise BotAuthError("Invalid issuer") from exc 197 except jwt.PyJWTError as exc: 198 raise BotAuthError(f"Token validation failed: {exc}") from exc 199 200 _logger.debug("Token validated successfully")
Validate a Bot Service or Entra ID JWT bearer token.
Supports tokens from both the Bot Service channel service and Azure
AD / Entra ID. The correct OpenID configuration is selected dynamically
by inspecting the token's issuer claim (see specs/inbound-auth.md).
Args:
auth_header: The full Authorization header value
(e.g. "Bearer eyJ...").
app_id: Expected audience (bot application / client ID). Falls back
to the CLIENT_ID environment variable when None.
Raises: BotAuthError: On any validation failure — missing header, expired token, bad audience, untrusted issuer, or missing JWKS key.