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.meter_provider import get_metrics 35from botas.remove_mention_middleware import RemoveMentionMiddleware 36from botas.state import FileStorage, MemoryStorage, StateScope, Storage, TurnState 37from botas.suggested_actions import CardAction, SuggestedActions 38from botas.teams_activity import ( 39 ChannelInfo, 40 MeetingInfo, 41 NotificationInfo, 42 TeamInfo, 43 TeamsActivity, 44 TeamsActivityBuilder, 45 TeamsChannelData, 46 TeamsConversation, 47 TenantInfo, 48) 49from botas.token_manager import BotApplicationOptions, TokenManager 50from botas.tracer_provider import get_tracer 51from botas.turn_context import TurnContext 52 53__all__ = [ 54 "ActivityType", 55 "Attachment", 56 "BotApplication", 57 "BotApplicationOptions", 58 "BotAuthError", 59 "BotHandlerException", 60 "CardAction", 61 "ChannelAccount", 62 "ChannelInfo", 63 "Conversation", 64 "ConversationClient", 65 "CoreActivity", 66 "CoreActivityBuilder", 67 "Entity", 68 "FileStorage", 69 "InvokeResponse", 70 "ITurnMiddleware", 71 "TurnMiddleware", 72 "MeetingInfo", 73 "MemoryStorage", 74 "NotificationInfo", 75 "RemoveMentionMiddleware", 76 "ResourceResponse", 77 "StateScope", 78 "Storage", 79 "SuggestedActions", 80 "TeamsActivity", 81 "TeamsActivityBuilder", 82 "TeamsActivityType", 83 "TeamsChannelAccount", 84 "TeamsChannelData", 85 "TeamsConversation", 86 "TeamInfo", 87 "TenantInfo", 88 "TokenManager", 89 "TurnContext", 90 "TurnState", 91 "get_metrics", 92 "get_tracer", 93 "__version__", 94 "validate_bot_token", 95]
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: Optional[str] = None 103 content: Any = None 104 name: Optional[str] = None 105 thumbnail_url: Optional[str] = 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.
117class BotApplication: 118 """Central entry point for building a bot with the Bot Service. 119 120 Manages the middleware pipeline, activity handler dispatch, outbound 121 messaging via :class:`ConversationClient`, and OAuth2 token lifecycle 122 via :class:`TokenManager`. 123 124 Supports async context-manager usage for automatic resource cleanup:: 125 126 async with BotApplication(options) as bot: 127 bot.on("message", my_handler) 128 ... 129 130 Attributes: 131 version: Library version string. 132 conversation_client: Client for sending outbound activities. 133 on_activity: Optional catch-all handler invoked for every activity type. 134 """ 135 136 version: str = __import__("botas._version", fromlist=["__version__"]).__version__ 137 138 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 139 """Initialise the bot application. 140 141 Args: 142 options: Configuration for authentication credentials and token 143 acquisition. Defaults to reading from environment variables. 144 """ 145 self._token_manager = TokenManager(options) 146 token_provider = self._token_manager.get_bot_token 147 self.conversation_client = ConversationClient(token_provider) 148 self._middlewares: list[TurnMiddleware] = [] 149 self._handlers: dict[str, _ActivityHandler] = {} 150 self._invoke_handlers: dict[str, _InvokeActivityHandler] = {} 151 self.on_activity: Optional[_ActivityHandler] = None 152 153 @property 154 def appid(self) -> Optional[str]: 155 """The bot application/client ID exposed from the token manager.""" 156 return self._token_manager.client_id 157 158 def on( 159 self, 160 type: str, 161 handler: Optional[_ActivityHandler] = None, 162 ) -> Any: 163 """Register a handler for an activity type. 164 165 Only one handler is stored per type; re-registering the same type 166 replaces the previous handler. 167 168 Can be used as a two-argument call or as a decorator:: 169 170 bot.on('message', my_handler) 171 172 @bot.on('message') 173 async def my_handler(ctx: TurnContext): 174 await ctx.send("hello") 175 176 Args: 177 type: The activity type to handle (e.g. ``"message"``, ``"typing"``). 178 handler: Async handler function. If omitted, returns a decorator. 179 180 Returns: 181 The ``BotApplication`` instance when called with a handler, or a 182 decorator function when called without one. 183 """ 184 if handler is None: 185 186 def decorator(fn: _ActivityHandler) -> _ActivityHandler: 187 self._handlers[type.lower()] = fn 188 return fn 189 190 return decorator 191 self._handlers[type.lower()] = handler 192 return self 193 194 def use(self, middleware: TurnMiddleware) -> "BotApplication": 195 """Register a middleware in the turn pipeline. 196 197 Middleware executes in registration order before handler dispatch. 198 Each middleware receives ``(context, next)`` and must call ``next()`` 199 to continue the pipeline, or skip it to short-circuit processing. 200 201 Args: 202 middleware: An object implementing :class:`TurnMiddleware`. 203 204 Returns: 205 The ``BotApplication`` instance for chaining. 206 """ 207 self._middlewares.append(middleware) 208 return self 209 210 def use_state(self, storage: Any) -> "BotApplication": 211 """Enable turn state management with the given storage backend. 212 213 Registers state middleware that loads state at turn start and saves 214 dirty state at turn end (only if the turn completes successfully). 215 216 Args: 217 storage: A storage implementation (MemoryStorage, FileStorage, etc.). 218 219 Returns: 220 The ``BotApplication`` instance for chaining. 221 222 Example:: 223 224 from botas.state import MemoryStorage 225 226 bot = BotApplication() 227 bot.use_state(MemoryStorage()) 228 """ 229 import asyncio 230 231 from botas.state import TurnState 232 233 # Per (conversation_key, user_key) lock so concurrent turns for the SAME 234 # user/conversation serialize their load -> handler -> save sequence and 235 # avoid lost updates (#365). Different users/conversations do not block 236 # each other. Locks are created lazily under `pair_locks_guard`. 237 pair_locks: dict[tuple[str, str], asyncio.Lock] = {} 238 pair_locks_guard = asyncio.Lock() 239 240 async def get_pair_lock(key_pair: tuple[str, str]) -> asyncio.Lock: 241 async with pair_locks_guard: 242 lock = pair_locks.get(key_pair) 243 if lock is None: 244 lock = asyncio.Lock() 245 pair_locks[key_pair] = lock 246 return lock 247 248 async def state_middleware(context: TurnContext, next: Callable[[], Awaitable[None]]) -> None: 249 # Load state at turn start 250 conversation_key = TurnState.derive_conversation_key(context.activity) 251 user_key = TurnState.derive_user_key(context.activity) 252 keys = [conversation_key, user_key] 253 254 # Acquire per (conv, user) lock so the load -> handler -> save sequence 255 # is atomic against concurrent turns for the same key pair. 256 pair_lock = await get_pair_lock((conversation_key, user_key)) 257 async with pair_lock: 258 loaded = await storage.read(keys) 259 conversation_data = loaded.get(conversation_key) 260 user_data = loaded.get(user_key) 261 262 # Initialize TurnState and attach to context 263 context.state = TurnState( 264 context.activity, 265 conversation_data, # type: ignore 266 user_data, # type: ignore 267 ) 268 269 # Call next and save state ONLY if no exception 270 exception_raised = False 271 try: 272 await next() 273 except Exception: 274 exception_raised = True 275 raise 276 finally: 277 if not exception_raised: 278 # Save dirty state 279 changes = {} 280 deletions = [] 281 282 if context.state.conversation.is_deleted(): 283 deletions.append(conversation_key) 284 elif context.state.conversation.is_dirty(): 285 changes[conversation_key] = context.state.conversation.to_dict() 286 287 if context.state.user.is_deleted(): 288 deletions.append(user_key) 289 elif context.state.user.is_dirty(): 290 changes[user_key] = context.state.user.to_dict() 291 292 if changes: 293 await storage.write(changes) 294 if deletions: 295 await storage.delete(deletions) 296 297 # Create a middleware object from the function 298 class StateMiddleware: 299 async def on_turn(self, context: TurnContext, next: Callable[[], Awaitable[None]]) -> None: # noqa: A003 300 await state_middleware(context, next) 301 302 self._middlewares.append(StateMiddleware()) 303 return self 304 305 def on_invoke( 306 self, 307 name: str, 308 handler: Optional[_InvokeActivityHandler] = None, 309 ) -> Any: 310 """Register a handler for an invoke activity by its ``activity.name`` sub-type. 311 312 The handler must return an :class:`InvokeResponse`. Only one handler 313 per name is supported; re-registering the same name replaces the 314 previous handler. 315 316 Can be used as a two-argument call or as a decorator:: 317 318 bot.on_invoke("adaptiveCard/action", my_handler) 319 320 @bot.on_invoke("adaptiveCard/action") 321 async def my_handler(ctx): ... 322 """ 323 if handler is None: 324 325 def decorator(fn: _InvokeActivityHandler) -> _InvokeActivityHandler: 326 self._invoke_handlers[name.lower()] = fn 327 return fn 328 329 return decorator 330 self._invoke_handlers[name.lower()] = handler 331 return self 332 333 async def process_body(self, body: str) -> Optional[InvokeResponse]: 334 """Parse and process a raw JSON activity body. 335 336 Deserializes the JSON string into a :class:`CoreActivity`, validates 337 required fields and the ``serviceUrl``, then runs the full middleware 338 pipeline followed by handler dispatch. 339 340 For ``invoke`` activities, returns the :class:`InvokeResponse` produced 341 by the registered handler, a 200 response if no invoke handlers are 342 registered, or a 501 response if handlers exist but none match. 343 Returns ``None`` for all other activity types. 344 345 Args: 346 body: Raw JSON string representing a Bot Service activity. 347 348 Returns: 349 An :class:`InvokeResponse` for invoke activities, or ``None``. 350 351 Raises: 352 ValueError: If the JSON is malformed or required activity fields 353 are missing. 354 BotHandlerException: If the matched handler raises an exception. 355 """ 356 try: 357 activity = CoreActivity.model_validate_json(body) 358 except json.JSONDecodeError as exc: 359 raise ValueError("Invalid JSON in request body") from exc 360 _assert_activity(activity) 361 _validate_service_url(activity.service_url) 362 363 metrics = get_metrics() 364 if metrics: 365 metrics.activities_received.add(1, {"activity.type": activity.type or ""}) 366 start_time = time.perf_counter() 367 368 with _span( 369 "botas.turn", 370 **{ 371 "activity.type": activity.type or "", 372 "activity.id": activity.id or "", 373 "conversation.id": activity.conversation.id if activity.conversation else "", 374 "channel.id": activity.channel_id or "", 375 "bot.id": self._token_manager.client_id or "", 376 }, 377 ): 378 try: 379 return await self._run_pipeline(activity) 380 finally: 381 if metrics: 382 elapsed_ms = (time.perf_counter() - start_time) * 1000 383 metrics.turn_duration.record(elapsed_ms, {"activity.type": activity.type or ""}) 384 385 async def send_activity_async( 386 self, 387 service_url: str, 388 conversation_id: str, 389 activity: Union[ 390 CoreActivity, 391 dict[str, Any], 392 ], 393 ) -> Optional[ResourceResponse]: 394 """Proactively send an activity to a conversation. 395 396 Use this to push messages outside of the normal turn pipeline (e.g. 397 notifications or proactive messages). 398 399 Args: 400 service_url: The channel's service URL. 401 conversation_id: Target conversation identifier. 402 activity: The activity payload to send. 403 404 Returns: 405 A :class:`ResourceResponse` with the new activity ID, or ``None`` 406 if the channel does not return one. 407 """ 408 return await self.conversation_client.send_activity_async(service_url, conversation_id, activity) 409 410 async def aclose(self) -> None: 411 """Close the underlying HTTP client and release resources. 412 413 Should be called during application shutdown. Alternatively, use the 414 bot as an async context manager to ensure automatic cleanup. 415 """ 416 await self.conversation_client.aclose() 417 418 async def __aenter__(self) -> "BotApplication": 419 """Enter the async context manager.""" 420 return self 421 422 async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: 423 """Exit the async context manager, ensuring resources are closed.""" 424 await self.aclose() 425 426 async def _handle_activity_async(self, context: TurnContext) -> Optional[InvokeResponse]: 427 if context.activity.type == "invoke": 428 return await self._dispatch_invoke_async(context) 429 handler = self.on_activity or self._handlers.get(context.activity.type.lower()) 430 if handler is None: 431 return None 432 dispatch_mode = "on_activity" if self.on_activity else "typed" 433 with _span( 434 "botas.handler", 435 **{"handler.type": context.activity.type, "handler.dispatch": dispatch_mode}, 436 ): 437 try: 438 await handler(context) 439 except Exception as exc: 440 metrics = get_metrics() 441 if metrics: 442 metrics.handler_errors.add(1, {"activity.type": context.activity.type}) 443 raise BotHandlerException( 444 f'Handler for "{context.activity.type}" threw an error', 445 exc, 446 context.activity, 447 ) from exc 448 return None 449 450 async def _dispatch_invoke_async(self, context: TurnContext) -> InvokeResponse: 451 if not self._invoke_handlers: 452 return InvokeResponse(status=200, body={}) 453 name = context.activity.name 454 handler = self._invoke_handlers.get(name.lower()) if name else None 455 if handler is None: 456 return InvokeResponse(status=501) 457 with _span( 458 "botas.handler", 459 **{"handler.type": "invoke", "handler.dispatch": "invoke"}, 460 ): 461 try: 462 return await handler(context) 463 except Exception as exc: 464 metrics = get_metrics() 465 if metrics: 466 metrics.handler_errors.add(1, {"activity.type": "invoke"}) 467 raise BotHandlerException( 468 f'Invoke handler for "{name}" threw an error', 469 exc, 470 context.activity, 471 ) from exc 472 473 async def _run_pipeline(self, activity: CoreActivity) -> Optional[InvokeResponse]: 474 context = TurnContext(self, activity) 475 index = 0 476 invoke_response: Optional[InvokeResponse] = None 477 478 async def next_fn() -> None: 479 nonlocal index, invoke_response 480 if index < len(self._middlewares): 481 mw = self._middlewares[index] 482 mw_index = index 483 index += 1 484 mw_name = type(mw).__name__ 485 mw_start = time.perf_counter() 486 with _span( 487 "botas.middleware", 488 **{"middleware.name": mw_name, "middleware.index": mw_index}, 489 ): 490 try: 491 await mw.on_turn(context, next_fn) 492 finally: 493 metrics = get_metrics() 494 if metrics: 495 elapsed_ms = (time.perf_counter() - mw_start) * 1000 496 metrics.middleware_duration.record(elapsed_ms, {"middleware.name": mw_name}) 497 else: 498 invoke_response = await self._handle_activity_async(context) 499 500 await next_fn() 501 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.
138 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 139 """Initialise the bot application. 140 141 Args: 142 options: Configuration for authentication credentials and token 143 acquisition. Defaults to reading from environment variables. 144 """ 145 self._token_manager = TokenManager(options) 146 token_provider = self._token_manager.get_bot_token 147 self.conversation_client = ConversationClient(token_provider) 148 self._middlewares: list[TurnMiddleware] = [] 149 self._handlers: dict[str, _ActivityHandler] = {} 150 self._invoke_handlers: dict[str, _InvokeActivityHandler] = {} 151 self.on_activity: Optional[_ActivityHandler] = None
Initialise the bot application.
Args: options: Configuration for authentication credentials and token acquisition. Defaults to reading from environment variables.
153 @property 154 def appid(self) -> Optional[str]: 155 """The bot application/client ID exposed from the token manager.""" 156 return self._token_manager.client_id
The bot application/client ID exposed from the token manager.
158 def on( 159 self, 160 type: str, 161 handler: Optional[_ActivityHandler] = None, 162 ) -> Any: 163 """Register a handler for an activity type. 164 165 Only one handler is stored per type; re-registering the same type 166 replaces the previous handler. 167 168 Can be used as a two-argument call or as a decorator:: 169 170 bot.on('message', my_handler) 171 172 @bot.on('message') 173 async def my_handler(ctx: TurnContext): 174 await ctx.send("hello") 175 176 Args: 177 type: The activity type to handle (e.g. ``"message"``, ``"typing"``). 178 handler: Async handler function. If omitted, returns a decorator. 179 180 Returns: 181 The ``BotApplication`` instance when called with a handler, or a 182 decorator function when called without one. 183 """ 184 if handler is None: 185 186 def decorator(fn: _ActivityHandler) -> _ActivityHandler: 187 self._handlers[type.lower()] = fn 188 return fn 189 190 return decorator 191 self._handlers[type.lower()] = handler 192 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.
194 def use(self, middleware: TurnMiddleware) -> "BotApplication": 195 """Register a middleware in the turn pipeline. 196 197 Middleware executes in registration order before handler dispatch. 198 Each middleware receives ``(context, next)`` and must call ``next()`` 199 to continue the pipeline, or skip it to short-circuit processing. 200 201 Args: 202 middleware: An object implementing :class:`TurnMiddleware`. 203 204 Returns: 205 The ``BotApplication`` instance for chaining. 206 """ 207 self._middlewares.append(middleware) 208 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.
210 def use_state(self, storage: Any) -> "BotApplication": 211 """Enable turn state management with the given storage backend. 212 213 Registers state middleware that loads state at turn start and saves 214 dirty state at turn end (only if the turn completes successfully). 215 216 Args: 217 storage: A storage implementation (MemoryStorage, FileStorage, etc.). 218 219 Returns: 220 The ``BotApplication`` instance for chaining. 221 222 Example:: 223 224 from botas.state import MemoryStorage 225 226 bot = BotApplication() 227 bot.use_state(MemoryStorage()) 228 """ 229 import asyncio 230 231 from botas.state import TurnState 232 233 # Per (conversation_key, user_key) lock so concurrent turns for the SAME 234 # user/conversation serialize their load -> handler -> save sequence and 235 # avoid lost updates (#365). Different users/conversations do not block 236 # each other. Locks are created lazily under `pair_locks_guard`. 237 pair_locks: dict[tuple[str, str], asyncio.Lock] = {} 238 pair_locks_guard = asyncio.Lock() 239 240 async def get_pair_lock(key_pair: tuple[str, str]) -> asyncio.Lock: 241 async with pair_locks_guard: 242 lock = pair_locks.get(key_pair) 243 if lock is None: 244 lock = asyncio.Lock() 245 pair_locks[key_pair] = lock 246 return lock 247 248 async def state_middleware(context: TurnContext, next: Callable[[], Awaitable[None]]) -> None: 249 # Load state at turn start 250 conversation_key = TurnState.derive_conversation_key(context.activity) 251 user_key = TurnState.derive_user_key(context.activity) 252 keys = [conversation_key, user_key] 253 254 # Acquire per (conv, user) lock so the load -> handler -> save sequence 255 # is atomic against concurrent turns for the same key pair. 256 pair_lock = await get_pair_lock((conversation_key, user_key)) 257 async with pair_lock: 258 loaded = await storage.read(keys) 259 conversation_data = loaded.get(conversation_key) 260 user_data = loaded.get(user_key) 261 262 # Initialize TurnState and attach to context 263 context.state = TurnState( 264 context.activity, 265 conversation_data, # type: ignore 266 user_data, # type: ignore 267 ) 268 269 # Call next and save state ONLY if no exception 270 exception_raised = False 271 try: 272 await next() 273 except Exception: 274 exception_raised = True 275 raise 276 finally: 277 if not exception_raised: 278 # Save dirty state 279 changes = {} 280 deletions = [] 281 282 if context.state.conversation.is_deleted(): 283 deletions.append(conversation_key) 284 elif context.state.conversation.is_dirty(): 285 changes[conversation_key] = context.state.conversation.to_dict() 286 287 if context.state.user.is_deleted(): 288 deletions.append(user_key) 289 elif context.state.user.is_dirty(): 290 changes[user_key] = context.state.user.to_dict() 291 292 if changes: 293 await storage.write(changes) 294 if deletions: 295 await storage.delete(deletions) 296 297 # Create a middleware object from the function 298 class StateMiddleware: 299 async def on_turn(self, context: TurnContext, next: Callable[[], Awaitable[None]]) -> None: # noqa: A003 300 await state_middleware(context, next) 301 302 self._middlewares.append(StateMiddleware()) 303 return self
Enable turn state management with the given storage backend.
Registers state middleware that loads state at turn start and saves dirty state at turn end (only if the turn completes successfully).
Args: storage: A storage implementation (MemoryStorage, FileStorage, etc.).
Returns:
The BotApplication instance for chaining.
Example::
from botas.state import MemoryStorage
bot = BotApplication()
bot.use_state(MemoryStorage())
305 def on_invoke( 306 self, 307 name: str, 308 handler: Optional[_InvokeActivityHandler] = None, 309 ) -> Any: 310 """Register a handler for an invoke activity by its ``activity.name`` sub-type. 311 312 The handler must return an :class:`InvokeResponse`. Only one handler 313 per name is supported; re-registering the same name replaces the 314 previous handler. 315 316 Can be used as a two-argument call or as a decorator:: 317 318 bot.on_invoke("adaptiveCard/action", my_handler) 319 320 @bot.on_invoke("adaptiveCard/action") 321 async def my_handler(ctx): ... 322 """ 323 if handler is None: 324 325 def decorator(fn: _InvokeActivityHandler) -> _InvokeActivityHandler: 326 self._invoke_handlers[name.lower()] = fn 327 return fn 328 329 return decorator 330 self._invoke_handlers[name.lower()] = handler 331 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): ...
333 async def process_body(self, body: str) -> Optional[InvokeResponse]: 334 """Parse and process a raw JSON activity body. 335 336 Deserializes the JSON string into a :class:`CoreActivity`, validates 337 required fields and the ``serviceUrl``, then runs the full middleware 338 pipeline followed by handler dispatch. 339 340 For ``invoke`` activities, returns the :class:`InvokeResponse` produced 341 by the registered handler, a 200 response if no invoke handlers are 342 registered, or a 501 response if handlers exist but none match. 343 Returns ``None`` for all other activity types. 344 345 Args: 346 body: Raw JSON string representing a Bot Service activity. 347 348 Returns: 349 An :class:`InvokeResponse` for invoke activities, or ``None``. 350 351 Raises: 352 ValueError: If the JSON is malformed or required activity fields 353 are missing. 354 BotHandlerException: If the matched handler raises an exception. 355 """ 356 try: 357 activity = CoreActivity.model_validate_json(body) 358 except json.JSONDecodeError as exc: 359 raise ValueError("Invalid JSON in request body") from exc 360 _assert_activity(activity) 361 _validate_service_url(activity.service_url) 362 363 metrics = get_metrics() 364 if metrics: 365 metrics.activities_received.add(1, {"activity.type": activity.type or ""}) 366 start_time = time.perf_counter() 367 368 with _span( 369 "botas.turn", 370 **{ 371 "activity.type": activity.type or "", 372 "activity.id": activity.id or "", 373 "conversation.id": activity.conversation.id if activity.conversation else "", 374 "channel.id": activity.channel_id or "", 375 "bot.id": self._token_manager.client_id or "", 376 }, 377 ): 378 try: 379 return await self._run_pipeline(activity) 380 finally: 381 if metrics: 382 elapsed_ms = (time.perf_counter() - start_time) * 1000 383 metrics.turn_duration.record(elapsed_ms, {"activity.type": activity.type or ""})
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.
385 async def send_activity_async( 386 self, 387 service_url: str, 388 conversation_id: str, 389 activity: Union[ 390 CoreActivity, 391 dict[str, Any], 392 ], 393 ) -> Optional[ResourceResponse]: 394 """Proactively send an activity to a conversation. 395 396 Use this to push messages outside of the normal turn pipeline (e.g. 397 notifications or proactive messages). 398 399 Args: 400 service_url: The channel's service URL. 401 conversation_id: Target conversation identifier. 402 activity: The activity payload to send. 403 404 Returns: 405 A :class:`ResourceResponse` with the new activity ID, or ``None`` 406 if the channel does not return one. 407 """ 408 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.
410 async def aclose(self) -> None: 411 """Close the underlying HTTP client and release resources. 412 413 Should be called during application shutdown. Alternatively, use the 414 bot as an async context manager to ensure automatic cleanup. 415 """ 416 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.
22@dataclass 23class BotApplicationOptions: 24 """Configuration options for :class:`BotApplication` authentication. 25 26 All fields are optional; when ``None``, values are read from environment 27 variables (``CLIENT_ID``, ``CLIENT_SECRET``, ``TENANT_ID``, 28 ``MANAGED_IDENTITY_CLIENT_ID``). 29 30 Attributes: 31 client_id: Azure AD application (bot) ID. 32 client_secret: Azure AD client secret. 33 tenant_id: Azure AD tenant ID (defaults to ``"common"``). 34 managed_identity_client_id: Client ID for managed identity auth. 35 token_factory: Custom async callable ``(scope, tenant) -> token`` 36 that bypasses MSAL entirely. 37 """ 38 39 client_id: Optional[str] = None 40 client_secret: Optional[str] = None 41 tenant_id: Optional[str] = None 42 managed_identity_client_id: Optional[str] = None 43 token_factory: Optional[Callable[[str, str], Awaitable[str]]] = 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.
36class BotAuthError(Exception): 37 """Raised when inbound JWT validation fails. 38 39 Inspect the message for the specific reason (expired, bad audience, etc.). 40 """ 41 42 pass
Raised when inbound JWT validation fails.
Inspect the message for the specific reason (expired, bad audience, etc.).
89class BotHandlerException(Exception): 90 """Wraps an exception thrown inside an activity handler. 91 92 When an activity handler or invoke handler raises, the exception is 93 caught by the pipeline and re-raised as a ``BotHandlerException`` with 94 the original exception attached as ``cause`` and ``__cause__``. 95 96 Attributes: 97 name: Always ``"BotHandlerException"``. 98 cause: The original exception raised by the handler. 99 activity: The activity being processed when the error occurred. 100 """ 101 102 def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None: 103 """Initialise a BotHandlerException. 104 105 Args: 106 message: Human-readable description of the failure. 107 cause: The original exception raised by the handler. 108 activity: The activity that was being processed. 109 """ 110 super().__init__(message) 111 self.name = "BotHandlerException" 112 self.cause = cause 113 self.activity = activity 114 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.
102 def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None: 103 """Initialise a BotHandlerException. 104 105 Args: 106 message: Human-readable description of the failure. 107 cause: The original exception raised by the handler. 108 activity: The activity that was being processed. 109 """ 110 super().__init__(message) 111 self.name = "BotHandlerException" 112 self.cause = cause 113 self.activity = activity 114 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.
20class CardAction(_CamelModel): 21 """A clickable action button presented to the user. 22 23 Attributes: 24 type: Action type (``"imBack"``, ``"postBack"``, ``"openUrl"``, etc.). 25 title: Button label displayed to the user. 26 value: Value sent back to the bot when the button is clicked. 27 text: Text sent to the bot (for ``imBack`` actions). 28 display_text: Text displayed in the chat when the button is clicked. 29 image: URL of an icon image for the button. 30 """ 31 32 type: str = "imBack" 33 title: Optional[str] = None 34 value: Optional[str] = None 35 text: Optional[str] = None 36 display_text: Optional[str] = None 37 image: Optional[str] = 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: Optional[str] = None 55 aad_object_id: Optional[str] = None 56 role: Optional[str] = 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: Optional[str] = None 55 name: Optional[str] = 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.
46class ConversationClient: 47 """Typed client for Bot Service Conversation REST API operations. 48 49 All methods accept a ``service_url`` and ``conversation_id`` to target 50 the correct channel endpoint. Authentication is handled automatically 51 via the injected :class:`TokenProvider`. 52 """ 53 54 def __init__(self, get_token: Optional[TokenProvider] = None) -> None: 55 """Initialise the conversation client. 56 57 Args: 58 get_token: Async callable that supplies a bearer token. 59 When ``None``, requests are unauthenticated. 60 """ 61 self._http = _BotHttpClient(get_token) 62 63 async def send_activity_async( 64 self, 65 service_url: str, 66 conversation_id: str, 67 activity: Union[ 68 CoreActivity, 69 dict[str, Any], 70 ], 71 ) -> Optional[ResourceResponse]: 72 """Send an activity to a conversation. 73 74 Args: 75 service_url: The channel's service URL. 76 conversation_id: Target conversation identifier. 77 activity: Activity payload (model or dict). 78 79 Returns: 80 A :class:`ResourceResponse` with the new activity ID, or ``None``. 81 """ 82 tracer = get_tracer() 83 metrics = get_metrics() 84 if metrics: 85 metrics.outbound_calls.add(1, {"operation": "sendActivity"}) 86 if tracer: 87 with tracer.start_as_current_span("botas.conversation_client") as span: 88 span.set_attribute("conversation.id", conversation_id) 89 span.set_attribute("activity.type", getattr(activity, "type", "") or "") 90 span.set_attribute("service.url", service_url) 91 try: 92 result = await self._do_send(service_url, conversation_id, activity) 93 if result: 94 span.set_attribute("activity.id", result.id or "") 95 return result 96 except Exception: 97 if metrics: 98 metrics.outbound_errors.add(1, {"operation": "sendActivity"}) 99 raise 100 try: 101 return await self._do_send(service_url, conversation_id, activity) 102 except Exception: 103 if metrics: 104 metrics.outbound_errors.add(1, {"operation": "sendActivity"}) 105 raise 106 107 async def _do_send( 108 self, 109 service_url: str, 110 conversation_id: str, 111 activity: Union[ 112 CoreActivity, 113 dict[str, Any], 114 ], 115 ) -> Optional[ResourceResponse]: 116 """Execute the actual HTTP POST for sending an activity.""" 117 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities" 118 data = await self._http.post( 119 service_url, 120 endpoint, 121 _serialize(activity), 122 _BotRequestOptions(operation_description="send activity"), 123 ) 124 return ResourceResponse.model_validate(data) if data else None 125 126 async def update_activity_async( 127 self, 128 service_url: str, 129 conversation_id: str, 130 activity_id: str, 131 activity: Union[ 132 CoreActivity, 133 dict[str, Any], 134 ], 135 ) -> Optional[ResourceResponse]: 136 """Update an existing activity in a conversation. 137 138 Args: 139 service_url: The channel's service URL. 140 conversation_id: Conversation containing the activity. 141 activity_id: ID of the activity to update. 142 activity: Replacement activity payload. 143 144 Returns: 145 A :class:`ResourceResponse`, or ``None``. 146 """ 147 metrics = get_metrics() 148 if metrics: 149 metrics.outbound_calls.add(1, {"operation": "updateActivity"}) 150 try: 151 endpoint = ( 152 f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 153 ) 154 data = await self._http.put( 155 service_url, 156 endpoint, 157 _serialize(activity), 158 _BotRequestOptions(operation_description="update activity"), 159 ) 160 return ResourceResponse.model_validate(data) if data else None 161 except Exception: 162 if metrics: 163 metrics.outbound_errors.add(1, {"operation": "updateActivity"}) 164 raise 165 166 async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None: 167 """Delete an activity from a conversation. 168 169 Args: 170 service_url: The channel's service URL. 171 conversation_id: Conversation containing the activity. 172 activity_id: ID of the activity to delete. 173 """ 174 metrics = get_metrics() 175 if metrics: 176 metrics.outbound_calls.add(1, {"operation": "deleteActivity"}) 177 try: 178 endpoint = ( 179 f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 180 ) 181 await self._http.delete( 182 service_url, 183 endpoint, 184 _BotRequestOptions(operation_description="delete activity"), 185 ) 186 except Exception: 187 if metrics: 188 metrics.outbound_errors.add(1, {"operation": "deleteActivity"}) 189 raise 190 191 async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]: 192 """Retrieve all members of a conversation. 193 194 Args: 195 service_url: The channel's service URL. 196 conversation_id: Target conversation identifier. 197 198 Returns: 199 List of :class:`ChannelAccount` objects for each member. 200 """ 201 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members" 202 data = await self._http.get( 203 service_url, 204 endpoint, 205 options=_BotRequestOptions(operation_description="get conversation members"), 206 ) 207 return [ChannelAccount.model_validate(m) for m in (data or [])] 208 209 async def get_conversation_member_async( 210 self, service_url: str, conversation_id: str, member_id: str 211 ) -> Optional[ChannelAccount]: 212 """Retrieve a single conversation member by ID. 213 214 Args: 215 service_url: The channel's service URL. 216 conversation_id: Target conversation identifier. 217 member_id: The member's account ID. 218 219 Returns: 220 A :class:`ChannelAccount`, or ``None`` if the member is not found. 221 """ 222 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 223 data = await self._http.get( 224 service_url, 225 endpoint, 226 options=_BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True), 227 ) 228 return ChannelAccount.model_validate(data) if data else None 229 230 async def get_conversation_paged_members_async( 231 self, 232 service_url: str, 233 conversation_id: str, 234 page_size: Optional[int] = None, 235 continuation_token: Optional[str] = None, 236 ) -> _PagedMembersResult: 237 """Retrieve conversation members with server-side pagination. 238 239 Args: 240 service_url: The channel's service URL. 241 conversation_id: Target conversation identifier. 242 page_size: Maximum members per page (channel may enforce its own limit). 243 continuation_token: Opaque token from a previous page to fetch the next. 244 245 Returns: 246 A :class:`_PagedMembersResult` containing members and an optional 247 continuation token for the next page. 248 """ 249 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers" 250 params = { 251 "pageSize": str(page_size) if page_size else None, 252 "continuationToken": continuation_token, 253 } 254 data = await self._http.get( 255 service_url, 256 endpoint, 257 params=params, 258 options=_BotRequestOptions(operation_description="get paged members"), 259 ) 260 return _PagedMembersResult.model_validate(data) if data else _PagedMembersResult() 261 262 async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None: 263 """Remove a member from a conversation. 264 265 Args: 266 service_url: The channel's service URL. 267 conversation_id: Target conversation identifier. 268 member_id: The member's account ID. 269 """ 270 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 271 await self._http.delete( 272 service_url, 273 endpoint, 274 _BotRequestOptions(operation_description="delete conversation member"), 275 ) 276 277 async def create_conversation_async( 278 self, service_url: str, parameters: _ConversationParameters 279 ) -> Optional[_ConversationResourceResponse]: 280 """Create a new conversation on the channel. 281 282 Args: 283 service_url: The channel's service URL. 284 parameters: Conversation creation parameters (members, topic, etc.). 285 286 Returns: 287 A :class:`_ConversationResourceResponse` with the new conversation 288 ID and service URL, or ``None``. 289 """ 290 data = await self._http.post( 291 service_url, 292 "/v3/conversations", 293 _serialize(parameters), 294 _BotRequestOptions(operation_description="create conversation"), 295 ) 296 return _ConversationResourceResponse.model_validate(data) if data else None 297 298 async def get_conversations_async( 299 self, service_url: str, continuation_token: Optional[str] = None 300 ) -> _ConversationsResult: 301 """List conversations the bot has participated in. 302 303 Args: 304 service_url: The channel's service URL. 305 continuation_token: Opaque token from a previous page. 306 307 Returns: 308 A :class:`_ConversationsResult` with conversations and an optional 309 continuation token. 310 """ 311 params = {"continuationToken": continuation_token} 312 data = await self._http.get( 313 service_url, 314 "/v3/conversations", 315 params=params, 316 options=_BotRequestOptions(operation_description="get conversations"), 317 ) 318 return _ConversationsResult.model_validate(data) if data else _ConversationsResult() 319 320 async def send_conversation_history_async( 321 self, service_url: str, conversation_id: str, _Transcript: _Transcript 322 ) -> Optional[ResourceResponse]: 323 """Upload a _Transcript of activities to a conversation's history. 324 325 Args: 326 service_url: The channel's service URL. 327 conversation_id: Target conversation identifier. 328 _Transcript: A :class:`_Transcript` containing activities to upload. 329 330 Returns: 331 A :class:`ResourceResponse`, or ``None``. 332 """ 333 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history" 334 data = await self._http.post( 335 service_url, 336 endpoint, 337 _serialize(_Transcript), 338 _BotRequestOptions(operation_description="send conversation history"), 339 ) 340 return ResourceResponse.model_validate(data) if data else None 341 342 async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Optional[Conversation]: 343 """Retrieve the conversation account details. 344 345 Args: 346 service_url: The channel's service URL. 347 conversation_id: Target conversation identifier. 348 349 Returns: 350 A :class:`Conversation` object, or ``None`` if not found. 351 """ 352 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}" 353 data = await self._http.get( 354 service_url, 355 endpoint, 356 options=_BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True), 357 ) 358 return Conversation.model_validate(data) if data else None 359 360 async def aclose(self) -> None: 361 """Close the underlying HTTP client and release resources.""" 362 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.
54 def __init__(self, get_token: Optional[TokenProvider] = None) -> None: 55 """Initialise the conversation client. 56 57 Args: 58 get_token: Async callable that supplies a bearer token. 59 When ``None``, requests are unauthenticated. 60 """ 61 self._http = _BotHttpClient(get_token)
Initialise the conversation client.
Args:
get_token: Async callable that supplies a bearer token.
When None, requests are unauthenticated.
63 async def send_activity_async( 64 self, 65 service_url: str, 66 conversation_id: str, 67 activity: Union[ 68 CoreActivity, 69 dict[str, Any], 70 ], 71 ) -> Optional[ResourceResponse]: 72 """Send an activity to a conversation. 73 74 Args: 75 service_url: The channel's service URL. 76 conversation_id: Target conversation identifier. 77 activity: Activity payload (model or dict). 78 79 Returns: 80 A :class:`ResourceResponse` with the new activity ID, or ``None``. 81 """ 82 tracer = get_tracer() 83 metrics = get_metrics() 84 if metrics: 85 metrics.outbound_calls.add(1, {"operation": "sendActivity"}) 86 if tracer: 87 with tracer.start_as_current_span("botas.conversation_client") as span: 88 span.set_attribute("conversation.id", conversation_id) 89 span.set_attribute("activity.type", getattr(activity, "type", "") or "") 90 span.set_attribute("service.url", service_url) 91 try: 92 result = await self._do_send(service_url, conversation_id, activity) 93 if result: 94 span.set_attribute("activity.id", result.id or "") 95 return result 96 except Exception: 97 if metrics: 98 metrics.outbound_errors.add(1, {"operation": "sendActivity"}) 99 raise 100 try: 101 return await self._do_send(service_url, conversation_id, activity) 102 except Exception: 103 if metrics: 104 metrics.outbound_errors.add(1, {"operation": "sendActivity"}) 105 raise
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.
126 async def update_activity_async( 127 self, 128 service_url: str, 129 conversation_id: str, 130 activity_id: str, 131 activity: Union[ 132 CoreActivity, 133 dict[str, Any], 134 ], 135 ) -> Optional[ResourceResponse]: 136 """Update an existing activity in a conversation. 137 138 Args: 139 service_url: The channel's service URL. 140 conversation_id: Conversation containing the activity. 141 activity_id: ID of the activity to update. 142 activity: Replacement activity payload. 143 144 Returns: 145 A :class:`ResourceResponse`, or ``None``. 146 """ 147 metrics = get_metrics() 148 if metrics: 149 metrics.outbound_calls.add(1, {"operation": "updateActivity"}) 150 try: 151 endpoint = ( 152 f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 153 ) 154 data = await self._http.put( 155 service_url, 156 endpoint, 157 _serialize(activity), 158 _BotRequestOptions(operation_description="update activity"), 159 ) 160 return ResourceResponse.model_validate(data) if data else None 161 except Exception: 162 if metrics: 163 metrics.outbound_errors.add(1, {"operation": "updateActivity"}) 164 raise
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.
166 async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None: 167 """Delete an activity from a conversation. 168 169 Args: 170 service_url: The channel's service URL. 171 conversation_id: Conversation containing the activity. 172 activity_id: ID of the activity to delete. 173 """ 174 metrics = get_metrics() 175 if metrics: 176 metrics.outbound_calls.add(1, {"operation": "deleteActivity"}) 177 try: 178 endpoint = ( 179 f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}" 180 ) 181 await self._http.delete( 182 service_url, 183 endpoint, 184 _BotRequestOptions(operation_description="delete activity"), 185 ) 186 except Exception: 187 if metrics: 188 metrics.outbound_errors.add(1, {"operation": "deleteActivity"}) 189 raise
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.
191 async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]: 192 """Retrieve all members of a conversation. 193 194 Args: 195 service_url: The channel's service URL. 196 conversation_id: Target conversation identifier. 197 198 Returns: 199 List of :class:`ChannelAccount` objects for each member. 200 """ 201 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members" 202 data = await self._http.get( 203 service_url, 204 endpoint, 205 options=_BotRequestOptions(operation_description="get conversation members"), 206 ) 207 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.
209 async def get_conversation_member_async( 210 self, service_url: str, conversation_id: str, member_id: str 211 ) -> Optional[ChannelAccount]: 212 """Retrieve a single conversation member by ID. 213 214 Args: 215 service_url: The channel's service URL. 216 conversation_id: Target conversation identifier. 217 member_id: The member's account ID. 218 219 Returns: 220 A :class:`ChannelAccount`, or ``None`` if the member is not found. 221 """ 222 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 223 data = await self._http.get( 224 service_url, 225 endpoint, 226 options=_BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True), 227 ) 228 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.
230 async def get_conversation_paged_members_async( 231 self, 232 service_url: str, 233 conversation_id: str, 234 page_size: Optional[int] = None, 235 continuation_token: Optional[str] = None, 236 ) -> _PagedMembersResult: 237 """Retrieve conversation members with server-side pagination. 238 239 Args: 240 service_url: The channel's service URL. 241 conversation_id: Target conversation identifier. 242 page_size: Maximum members per page (channel may enforce its own limit). 243 continuation_token: Opaque token from a previous page to fetch the next. 244 245 Returns: 246 A :class:`_PagedMembersResult` containing members and an optional 247 continuation token for the next page. 248 """ 249 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers" 250 params = { 251 "pageSize": str(page_size) if page_size else None, 252 "continuationToken": continuation_token, 253 } 254 data = await self._http.get( 255 service_url, 256 endpoint, 257 params=params, 258 options=_BotRequestOptions(operation_description="get paged members"), 259 ) 260 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.
262 async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None: 263 """Remove a member from a conversation. 264 265 Args: 266 service_url: The channel's service URL. 267 conversation_id: Target conversation identifier. 268 member_id: The member's account ID. 269 """ 270 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}" 271 await self._http.delete( 272 service_url, 273 endpoint, 274 _BotRequestOptions(operation_description="delete conversation member"), 275 )
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.
277 async def create_conversation_async( 278 self, service_url: str, parameters: _ConversationParameters 279 ) -> Optional[_ConversationResourceResponse]: 280 """Create a new conversation on the channel. 281 282 Args: 283 service_url: The channel's service URL. 284 parameters: Conversation creation parameters (members, topic, etc.). 285 286 Returns: 287 A :class:`_ConversationResourceResponse` with the new conversation 288 ID and service URL, or ``None``. 289 """ 290 data = await self._http.post( 291 service_url, 292 "/v3/conversations", 293 _serialize(parameters), 294 _BotRequestOptions(operation_description="create conversation"), 295 ) 296 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.
298 async def get_conversations_async( 299 self, service_url: str, continuation_token: Optional[str] = None 300 ) -> _ConversationsResult: 301 """List conversations the bot has participated in. 302 303 Args: 304 service_url: The channel's service URL. 305 continuation_token: Opaque token from a previous page. 306 307 Returns: 308 A :class:`_ConversationsResult` with conversations and an optional 309 continuation token. 310 """ 311 params = {"continuationToken": continuation_token} 312 data = await self._http.get( 313 service_url, 314 "/v3/conversations", 315 params=params, 316 options=_BotRequestOptions(operation_description="get conversations"), 317 ) 318 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.
320 async def send_conversation_history_async( 321 self, service_url: str, conversation_id: str, _Transcript: _Transcript 322 ) -> Optional[ResourceResponse]: 323 """Upload a _Transcript of activities to a conversation's history. 324 325 Args: 326 service_url: The channel's service URL. 327 conversation_id: Target conversation identifier. 328 _Transcript: A :class:`_Transcript` containing activities to upload. 329 330 Returns: 331 A :class:`ResourceResponse`, or ``None``. 332 """ 333 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history" 334 data = await self._http.post( 335 service_url, 336 endpoint, 337 _serialize(_Transcript), 338 _BotRequestOptions(operation_description="send conversation history"), 339 ) 340 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.
342 async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Optional[Conversation]: 343 """Retrieve the conversation account details. 344 345 Args: 346 service_url: The channel's service URL. 347 conversation_id: Target conversation identifier. 348 349 Returns: 350 A :class:`Conversation` object, or ``None`` if not found. 351 """ 352 endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}" 353 data = await self._http.get( 354 service_url, 355 endpoint, 356 options=_BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True), 357 ) 358 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 id: Channel-assigned activity identifier. 124 channel_id: Channel identifier (e.g. ``"msteams"``, ``"webchat"``). 125 type: Activity type (``"message"``, ``"typing"``, ``"invoke"``, etc.). 126 service_url: Channel service endpoint URL. 127 from_account: Sender's channel account (mapped from JSON ``from``). 128 recipient: Recipient's channel account. 129 conversation: Conversation reference. 130 text: Message text content. 131 name: Sub-type name (used by ``invoke`` activities). 132 value: Payload for ``invoke`` or ``messageReaction`` activities. 133 entities: List of entity metadata (mentions, places, etc.). 134 attachments: List of file or card attachments. 135 """ 136 137 id: Optional[str] = None 138 channel_id: Optional[str] = None 139 type: str 140 service_url: str = "" 141 from_account: Optional[ChannelAccount] = None 142 recipient: Optional[ChannelAccount] = None 143 conversation: Optional[Conversation] = None 144 text: Optional[str] = None 145 name: Optional[str] = None 146 value: Any = None 147 entities: Optional[list[Entity]] = None 148 attachments: Optional[list[Attachment]] = None 149 150 model_config = ConfigDict( 151 alias_generator=to_camel, 152 populate_by_name=True, 153 extra="allow", 154 # 'from' is a Python keyword — remapped via model_validator below 155 ) 156 157 @model_validator(mode="before") 158 @classmethod 159 def _remap_from(cls, data: Any) -> Any: 160 if isinstance(data, dict) and "from" in data: 161 data = dict(data) 162 data["from_account"] = data.pop("from") 163 return data 164 165 @classmethod 166 def model_validate_json(cls, json_data: Union[str, bytes], **kwargs: Any) -> "CoreActivity": # type: ignore[override] 167 """Deserialize a JSON string or bytes into a CoreActivity. 168 169 Handles the ``from`` → ``from_account`` remapping automatically. 170 171 Args: 172 json_data: Raw JSON string or bytes. 173 **kwargs: Additional keyword arguments passed to ``model_validate``. 174 175 Returns: 176 A validated :class:`CoreActivity` instance. 177 """ 178 import json 179 180 data = json.loads(json_data) 181 return cls.model_validate(data, **kwargs) 182 183 def model_dump(self, **kwargs: Any) -> dict[str, Any]: # type: ignore[override] 184 """Serialize to a dict, restoring ``from_account`` back to ``from``. 185 186 Args: 187 **kwargs: Keyword arguments forwarded to Pydantic's ``model_dump``. 188 189 Returns: 190 A JSON-compatible dict with camelCase keys when ``by_alias=True``. 191 """ 192 d = super().model_dump(**kwargs) 193 # remap 'from_account' → 'from' in output 194 if "from_account" in d: 195 d["from"] = d.pop("from_account") 196 elif "fromAccount" in d: 197 d["from"] = d.pop("fromAccount") 198 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:
id: Channel-assigned activity identifier.
channel_id: Channel identifier (e.g. "msteams", "webchat").
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.
280class CoreActivityBuilder: 281 """Fluent builder for constructing outbound CoreActivity instances. 282 283 Example:: 284 285 activity = ( 286 CoreActivityBuilder() 287 .with_conversation_reference(incoming) 288 .with_text("Hello!") 289 .build() 290 ) 291 """ 292 293 def __init__(self) -> None: 294 """Initialise the builder with default values (type ``"message"``).""" 295 self._type: str = "message" 296 self._service_url: str = "" 297 self._conversation: Optional[Conversation] = None 298 self._from_account: Optional[ChannelAccount] = None 299 self._recipient: Optional[ChannelAccount] = None 300 self._text: str = "" 301 self._entities: Optional[list[Entity]] = None 302 self._attachments: Optional[list[Attachment]] = None 303 304 def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder": 305 """Copy routing fields from an incoming activity and swap from/recipient. 306 307 Args: 308 source: The incoming activity to extract routing from. 309 310 Returns: 311 The builder instance for chaining. 312 """ 313 self._service_url = source.service_url 314 self._conversation = source.conversation 315 self._from_account = source.recipient 316 self._recipient = source.from_account 317 return self 318 319 def with_type(self, activity_type: str) -> "CoreActivityBuilder": 320 """Set the activity type (default is ``"message"``). 321 322 Args: 323 activity_type: Activity type string. 324 325 Returns: 326 The builder instance for chaining. 327 """ 328 self._type = activity_type 329 return self 330 331 def with_service_url(self, service_url: str) -> "CoreActivityBuilder": 332 """Set the service URL for the channel. 333 334 Args: 335 service_url: Channel service endpoint URL. 336 337 Returns: 338 The builder instance for chaining. 339 """ 340 self._service_url = service_url 341 return self 342 343 def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder": 344 """Set the conversation reference. 345 346 Args: 347 conversation: Target conversation. 348 349 Returns: 350 The builder instance for chaining. 351 """ 352 self._conversation = conversation 353 return self 354 355 def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder": 356 """Set the sender account. 357 358 Args: 359 from_account: The sender's channel account. 360 361 Returns: 362 The builder instance for chaining. 363 """ 364 self._from_account = from_account 365 return self 366 367 def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder": 368 """Set the recipient account. 369 370 Args: 371 recipient: The recipient's channel account. 372 373 Returns: 374 The builder instance for chaining. 375 """ 376 self._recipient = recipient 377 return self 378 379 def with_text(self, text: str) -> "CoreActivityBuilder": 380 """Set the text content of the activity. 381 382 Args: 383 text: Message text. 384 385 Returns: 386 The builder instance for chaining. 387 """ 388 self._text = text 389 return self 390 391 def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder": 392 """Set the entities list. 393 394 Args: 395 entities: Entity metadata objects. 396 397 Returns: 398 The builder instance for chaining. 399 """ 400 self._entities = entities 401 return self 402 403 def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder": 404 """Set the attachments list. 405 406 Args: 407 attachments: Attachment objects. 408 409 Returns: 410 The builder instance for chaining. 411 """ 412 self._attachments = attachments 413 return self 414 415 def build(self) -> CoreActivity: 416 """Build a new CoreActivity from the current builder state. 417 418 Returns: 419 A fully constructed :class:`CoreActivity`. 420 """ 421 return CoreActivity( 422 type=self._type, 423 service_url=self._service_url, 424 conversation=self._conversation, 425 from_account=self._from_account, 426 recipient=self._recipient, 427 text=self._text, 428 entities=self._entities, 429 attachments=self._attachments, 430 )
Fluent builder for constructing outbound CoreActivity instances.
Example::
activity = (
CoreActivityBuilder()
.with_conversation_reference(incoming)
.with_text("Hello!")
.build()
)
293 def __init__(self) -> None: 294 """Initialise the builder with default values (type ``"message"``).""" 295 self._type: str = "message" 296 self._service_url: str = "" 297 self._conversation: Optional[Conversation] = None 298 self._from_account: Optional[ChannelAccount] = None 299 self._recipient: Optional[ChannelAccount] = None 300 self._text: str = "" 301 self._entities: Optional[list[Entity]] = None 302 self._attachments: Optional[list[Attachment]] = None
Initialise the builder with default values (type "message").
304 def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder": 305 """Copy routing fields from an incoming activity and swap from/recipient. 306 307 Args: 308 source: The incoming activity to extract routing from. 309 310 Returns: 311 The builder instance for chaining. 312 """ 313 self._service_url = source.service_url 314 self._conversation = source.conversation 315 self._from_account = source.recipient 316 self._recipient = source.from_account 317 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.
319 def with_type(self, activity_type: str) -> "CoreActivityBuilder": 320 """Set the activity type (default is ``"message"``). 321 322 Args: 323 activity_type: Activity type string. 324 325 Returns: 326 The builder instance for chaining. 327 """ 328 self._type = activity_type 329 return self
Set the activity type (default is "message").
Args: activity_type: Activity type string.
Returns: The builder instance for chaining.
331 def with_service_url(self, service_url: str) -> "CoreActivityBuilder": 332 """Set the service URL for the channel. 333 334 Args: 335 service_url: Channel service endpoint URL. 336 337 Returns: 338 The builder instance for chaining. 339 """ 340 self._service_url = service_url 341 return self
Set the service URL for the channel.
Args: service_url: Channel service endpoint URL.
Returns: The builder instance for chaining.
343 def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder": 344 """Set the conversation reference. 345 346 Args: 347 conversation: Target conversation. 348 349 Returns: 350 The builder instance for chaining. 351 """ 352 self._conversation = conversation 353 return self
Set the conversation reference.
Args: conversation: Target conversation.
Returns: The builder instance for chaining.
355 def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder": 356 """Set the sender account. 357 358 Args: 359 from_account: The sender's channel account. 360 361 Returns: 362 The builder instance for chaining. 363 """ 364 self._from_account = from_account 365 return self
Set the sender account.
Args: from_account: The sender's channel account.
Returns: The builder instance for chaining.
367 def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder": 368 """Set the recipient account. 369 370 Args: 371 recipient: The recipient's channel account. 372 373 Returns: 374 The builder instance for chaining. 375 """ 376 self._recipient = recipient 377 return self
Set the recipient account.
Args: recipient: The recipient's channel account.
Returns: The builder instance for chaining.
379 def with_text(self, text: str) -> "CoreActivityBuilder": 380 """Set the text content of the activity. 381 382 Args: 383 text: Message text. 384 385 Returns: 386 The builder instance for chaining. 387 """ 388 self._text = text 389 return self
Set the text content of the activity.
Args: text: Message text.
Returns: The builder instance for chaining.
391 def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder": 392 """Set the entities list. 393 394 Args: 395 entities: Entity metadata objects. 396 397 Returns: 398 The builder instance for chaining. 399 """ 400 self._entities = entities 401 return self
Set the entities list.
Args: entities: Entity metadata objects.
Returns: The builder instance for chaining.
403 def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder": 404 """Set the attachments list. 405 406 Args: 407 attachments: Attachment objects. 408 409 Returns: 410 The builder instance for chaining. 411 """ 412 self._attachments = attachments 413 return self
Set the attachments list.
Args: attachments: Attachment objects.
Returns: The builder instance for chaining.
415 def build(self) -> CoreActivity: 416 """Build a new CoreActivity from the current builder state. 417 418 Returns: 419 A fully constructed :class:`CoreActivity`. 420 """ 421 return CoreActivity( 422 type=self._type, 423 service_url=self._service_url, 424 conversation=self._conversation, 425 from_account=self._from_account, 426 recipient=self._recipient, 427 text=self._text, 428 entities=self._entities, 429 attachments=self._attachments, 430 )
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.
14class FileStorage: 15 """JSON file-based storage for bot state. 16 17 Stores each state key as a separate JSON file in a configurable directory. 18 Keys are sanitized for filesystem safety. Suitable for single-instance 19 deployments where simple persistence is needed. 20 21 **Not suitable for multi-instance deployments** — no locking or concurrency control. 22 23 Example:: 24 25 from botas.state import FileStorage 26 27 storage = FileStorage("./bot-state") 28 await storage.write({"conversation/123": {"count": 5}}) 29 data = await storage.read(["conversation/123"]) 30 # data = {"conversation/123": {"count": 5}} 31 32 Args: 33 root_path: Root directory for state files. Defaults to ``"./bot-state"``. 34 """ 35 36 def __init__(self, root_path: Union[str, Path] = "./bot-state") -> None: 37 """Initialize file storage with a root directory. 38 39 Args: 40 root_path: Root directory for state files. Defaults to ``"./bot-state"``. 41 """ 42 self._root = Path(root_path) 43 44 def _sanitize_key(self, key: str) -> str: 45 """Sanitize a storage key for filesystem safety. 46 47 Uses URL percent-encoding with no safe characters, ensuring 48 cross-platform filesystem compatibility. 49 50 Args: 51 key: Raw storage key (e.g., "msteams/bot-id/conversations/conv-id"). 52 53 Returns: 54 Filesystem-safe encoded key. 55 """ 56 return quote(key, safe="") 57 58 def _key_to_path(self, key: str) -> Path: 59 """Convert a storage key to a file path. 60 61 On Windows, if the absolute path would exceed 240 characters, 62 automatically applies the \\\\?\\ prefix for extended-length path support. 63 64 Args: 65 key: Storage key. 66 67 Returns: 68 Absolute path to the JSON file for this key. 69 """ 70 sanitized = self._sanitize_key(key) 71 path = self._root / f"{sanitized}.json" 72 73 # On Windows, use extended-length path prefix for long paths 74 # This prevents FileNotFoundError when path > 260 chars (MAX_PATH) 75 if sys.platform == "win32": 76 abs_path = path.resolve() 77 abs_path_str = str(abs_path) 78 # Use \\?\ prefix if path exceeds safe threshold (240 chars) 79 # Threshold chosen to leave buffer before MAX_PATH (260) 80 if len(abs_path_str) > 240: 81 # Add extended-length path prefix if not already present 82 if not abs_path_str.startswith("\\\\?\\"): 83 # Convert C:\path to \\?\C:\path 84 extended_path = f"\\\\?\\{abs_path_str}" 85 return Path(extended_path) 86 return path 87 88 async def read(self, keys: list[str]) -> dict[str, object]: 89 """Read items from storage. 90 91 Args: 92 keys: Keys to read. 93 94 Returns: 95 Dictionary of key-value pairs that exist in storage. 96 Missing keys are omitted from the result. 97 """ 98 result: dict[str, object] = {} 99 for key in keys: 100 path = self._key_to_path(key) 101 try: 102 # Use asyncio.to_thread to avoid blocking the event loop 103 content = await asyncio.to_thread(path.read_text, encoding="utf-8") 104 result[key] = json.loads(content) 105 except FileNotFoundError: 106 # Missing file is not an error — just omit from result 107 pass 108 return result 109 110 async def write(self, changes: dict[str, object]) -> None: 111 """Write items to storage. 112 113 Creates parent directories if they don't exist. 114 115 Args: 116 changes: Dictionary of key-value pairs to write. 117 """ 118 # Ensure root directory exists 119 await asyncio.to_thread(self._root.mkdir, parents=True, exist_ok=True) 120 121 for key, value in changes.items(): 122 path = self._key_to_path(key) 123 content = json.dumps(value, ensure_ascii=False, indent=2) 124 await asyncio.to_thread(path.write_text, content, encoding="utf-8") 125 126 async def delete(self, keys: list[str]) -> None: 127 """Delete items from storage. 128 129 Args: 130 keys: Keys to delete. Idempotent — no error if key doesn't exist. 131 """ 132 for key in keys: 133 path = self._key_to_path(key) 134 try: 135 await asyncio.to_thread(path.unlink) 136 except FileNotFoundError: 137 # Idempotent — no error if file doesn't exist 138 pass
JSON file-based storage for bot state.
Stores each state key as a separate JSON file in a configurable directory. Keys are sanitized for filesystem safety. Suitable for single-instance deployments where simple persistence is needed.
Not suitable for multi-instance deployments — no locking or concurrency control.
Example::
from botas.state import FileStorage
storage = FileStorage("./bot-state")
await storage.write({"conversation/123": {"count": 5}})
data = await storage.read(["conversation/123"])
# data = {"conversation/123": {"count": 5}}
Args:
root_path: Root directory for state files. Defaults to "./bot-state".
36 def __init__(self, root_path: Union[str, Path] = "./bot-state") -> None: 37 """Initialize file storage with a root directory. 38 39 Args: 40 root_path: Root directory for state files. Defaults to ``"./bot-state"``. 41 """ 42 self._root = Path(root_path)
Initialize file storage with a root directory.
Args:
root_path: Root directory for state files. Defaults to "./bot-state".
88 async def read(self, keys: list[str]) -> dict[str, object]: 89 """Read items from storage. 90 91 Args: 92 keys: Keys to read. 93 94 Returns: 95 Dictionary of key-value pairs that exist in storage. 96 Missing keys are omitted from the result. 97 """ 98 result: dict[str, object] = {} 99 for key in keys: 100 path = self._key_to_path(key) 101 try: 102 # Use asyncio.to_thread to avoid blocking the event loop 103 content = await asyncio.to_thread(path.read_text, encoding="utf-8") 104 result[key] = json.loads(content) 105 except FileNotFoundError: 106 # Missing file is not an error — just omit from result 107 pass 108 return result
Read items from storage.
Args: keys: Keys to read.
Returns: Dictionary of key-value pairs that exist in storage. Missing keys are omitted from the result.
110 async def write(self, changes: dict[str, object]) -> None: 111 """Write items to storage. 112 113 Creates parent directories if they don't exist. 114 115 Args: 116 changes: Dictionary of key-value pairs to write. 117 """ 118 # Ensure root directory exists 119 await asyncio.to_thread(self._root.mkdir, parents=True, exist_ok=True) 120 121 for key, value in changes.items(): 122 path = self._key_to_path(key) 123 content = json.dumps(value, ensure_ascii=False, indent=2) 124 await asyncio.to_thread(path.write_text, content, encoding="utf-8")
Write items to storage.
Creates parent directories if they don't exist.
Args: changes: Dictionary of key-value pairs to write.
126 async def delete(self, keys: list[str]) -> None: 127 """Delete items from storage. 128 129 Args: 130 keys: Keys to delete. Idempotent — no error if key doesn't exist. 131 """ 132 for key in keys: 133 path = self._key_to_path(key) 134 try: 135 await asyncio.to_thread(path.unlink) 136 except FileNotFoundError: 137 # Idempotent — no error if file doesn't exist 138 pass
Delete items from storage.
Args: keys: Keys to delete. Idempotent — no error if key doesn't exist.
72@dataclass 73class InvokeResponse: 74 """Response returned by an invoke activity handler. 75 76 The ``status`` is written as the HTTP status code; ``body`` is serialized 77 as JSON and included in the response body. 78 """ 79 80 status: int 81 """HTTP status code to return to the channel (e.g. 200, 400, 501).""" 82 body: Any = field(default=None) 83 """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: Optional[str] = None
Teams meeting information.
Attributes: id: Unique meeting identifier.
11class MemoryStorage: 12 """In-process dictionary-backed storage for bot state. 13 14 Thread-safe for single-process use. Data is lost when the process exits. 15 Suitable for development, testing, and single-instance bots. 16 17 Example:: 18 19 from botas.state import MemoryStorage 20 21 storage = MemoryStorage() 22 await storage.write({"key1": {"count": 5}}) 23 data = await storage.read(["key1"]) 24 # data = {"key1": {"count": 5}} 25 """ 26 27 def __init__(self) -> None: 28 """Initialize an empty in-memory storage.""" 29 self._store: dict[str, object] = {} 30 self._lock: Optional[asyncio.Lock] = None 31 32 def _get_lock(self) -> asyncio.Lock: 33 """Lazy-init lock to avoid event loop issues during import.""" 34 if self._lock is None: 35 self._lock = asyncio.Lock() 36 return self._lock 37 38 async def read(self, keys: list[str]) -> dict[str, object]: 39 """Read items from storage. 40 41 Args: 42 keys: Keys to read. 43 44 Returns: 45 Dictionary of key-value pairs that exist in storage. 46 Missing keys are omitted from the result. 47 Values are deep-cloned to isolate per-turn mutations. 48 """ 49 async with self._get_lock(): 50 return {k: copy.deepcopy(self._store[k]) for k in keys if k in self._store} 51 52 async def write(self, changes: dict[str, object]) -> None: 53 """Write items to storage. 54 55 Args: 56 changes: Dictionary of key-value pairs to write. 57 Values are deep-cloned to isolate per-turn mutations. 58 """ 59 async with self._get_lock(): 60 self._store.update({k: copy.deepcopy(v) for k, v in changes.items()}) 61 62 async def delete(self, keys: list[str]) -> None: 63 """Delete items from storage. 64 65 Args: 66 keys: Keys to delete. Idempotent — no error if key doesn't exist. 67 """ 68 async with self._get_lock(): 69 for key in keys: 70 self._store.pop(key, None)
In-process dictionary-backed storage for bot state.
Thread-safe for single-process use. Data is lost when the process exits. Suitable for development, testing, and single-instance bots.
Example::
from botas.state import MemoryStorage
storage = MemoryStorage()
await storage.write({"key1": {"count": 5}})
data = await storage.read(["key1"])
# data = {"key1": {"count": 5}}
27 def __init__(self) -> None: 28 """Initialize an empty in-memory storage.""" 29 self._store: dict[str, object] = {} 30 self._lock: Optional[asyncio.Lock] = None
Initialize an empty in-memory storage.
38 async def read(self, keys: list[str]) -> dict[str, object]: 39 """Read items from storage. 40 41 Args: 42 keys: Keys to read. 43 44 Returns: 45 Dictionary of key-value pairs that exist in storage. 46 Missing keys are omitted from the result. 47 Values are deep-cloned to isolate per-turn mutations. 48 """ 49 async with self._get_lock(): 50 return {k: copy.deepcopy(self._store[k]) for k in keys if k in self._store}
Read items from storage.
Args: keys: Keys to read.
Returns: Dictionary of key-value pairs that exist in storage. Missing keys are omitted from the result. Values are deep-cloned to isolate per-turn mutations.
52 async def write(self, changes: dict[str, object]) -> None: 53 """Write items to storage. 54 55 Args: 56 changes: Dictionary of key-value pairs to write. 57 Values are deep-cloned to isolate per-turn mutations. 58 """ 59 async with self._get_lock(): 60 self._store.update({k: copy.deepcopy(v) for k, v in changes.items()})
Write items to storage.
Args: changes: Dictionary of key-value pairs to write. Values are deep-cloned to isolate per-turn mutations.
62 async def delete(self, keys: list[str]) -> None: 63 """Delete items from storage. 64 65 Args: 66 keys: Keys to delete. Idempotent — no error if key doesn't exist. 67 """ 68 async with self._get_lock(): 69 for key in keys: 70 self._store.pop(key, None)
Delete items from storage.
Args: keys: Keys to delete. Idempotent — no error if key doesn't exist.
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: Optional[bool] = 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.
201class ResourceResponse(_CamelModel): 202 """Response from the channel after sending or updating an activity. 203 204 Attributes: 205 id: The channel-assigned activity identifier. 206 """ 207 208 id: str
Response from the channel after sending or updating an activity.
Attributes: id: The channel-assigned activity identifier.
12class StateScope: 13 """Key-value store for a single state scope (conversation, user, or temp). 14 15 Each scope holds a dictionary of string keys to arbitrary values. 16 Values are serialized to JSON for storage persistence. 17 18 Example:: 19 20 scope = StateScope() 21 scope.set("count", 5) 22 count = scope.get("count", int) # 5 23 scope.has("count") # True 24 scope.delete("count") 25 scope.get("count", int) # None 26 """ 27 28 def __init__(self, data: Optional[dict[str, Any]] = None) -> None: 29 """Initialize a state scope. 30 31 Args: 32 data: Initial data dictionary. Defaults to empty dict. 33 """ 34 self._data = data if data is not None else {} 35 self._snapshot = json.dumps(self._data, sort_keys=True, ensure_ascii=False) 36 37 def get(self, key: str, type_: type[T] = object) -> Optional[T]: # noqa: ARG002 38 """Get a value by key. 39 40 Args: 41 key: Key to retrieve. 42 type_: Type hint for return value (not enforced at runtime). 43 44 Returns: 45 The value if it exists, else None. 46 """ 47 return self._data.get(key) # type: ignore 48 49 def set(self, key: str, value: Any) -> None: 50 """Set a value by key. 51 52 Args: 53 key: Key to set. 54 value: Value to store. 55 """ 56 self._data[key] = value 57 58 def has(self, key: str) -> bool: 59 """Check if a key exists. 60 61 Args: 62 key: Key to check. 63 64 Returns: 65 True if the key exists, False otherwise. 66 """ 67 return key in self._data 68 69 def delete(self, key: str) -> None: 70 """Delete a key from the scope. 71 72 Args: 73 key: Key to delete. No error if key doesn't exist. 74 """ 75 self._data.pop(key, None) 76 77 def clear(self) -> None: 78 """Clear all keys from the scope.""" 79 self._data.clear() 80 81 def is_dirty(self) -> bool: 82 """Check if the scope has been modified since load. 83 84 Returns: 85 True if the scope data has changed, False otherwise. 86 """ 87 current = json.dumps(self._data, sort_keys=True, ensure_ascii=False) 88 return current != self._snapshot 89 90 def is_deleted(self) -> bool: 91 """Check if the scope has been cleared (all keys removed). 92 93 Returns: 94 True if the scope is now empty and was non-empty at load. 95 """ 96 return len(self._data) == 0 and self._snapshot != "{}" 97 98 def to_dict(self) -> dict[str, Any]: 99 """Export the scope data as a dictionary. 100 101 Returns: 102 Copy of the internal data dictionary. 103 """ 104 return dict(self._data)
Key-value store for a single state scope (conversation, user, or temp).
Each scope holds a dictionary of string keys to arbitrary values. Values are serialized to JSON for storage persistence.
Example::
scope = StateScope()
scope.set("count", 5)
count = scope.get("count", int) # 5
scope.has("count") # True
scope.delete("count")
scope.get("count", int) # None
28 def __init__(self, data: Optional[dict[str, Any]] = None) -> None: 29 """Initialize a state scope. 30 31 Args: 32 data: Initial data dictionary. Defaults to empty dict. 33 """ 34 self._data = data if data is not None else {} 35 self._snapshot = json.dumps(self._data, sort_keys=True, ensure_ascii=False)
Initialize a state scope.
Args: data: Initial data dictionary. Defaults to empty dict.
37 def get(self, key: str, type_: type[T] = object) -> Optional[T]: # noqa: ARG002 38 """Get a value by key. 39 40 Args: 41 key: Key to retrieve. 42 type_: Type hint for return value (not enforced at runtime). 43 44 Returns: 45 The value if it exists, else None. 46 """ 47 return self._data.get(key) # type: ignore
Get a value by key.
Args: key: Key to retrieve. type_: Type hint for return value (not enforced at runtime).
Returns: The value if it exists, else None.
49 def set(self, key: str, value: Any) -> None: 50 """Set a value by key. 51 52 Args: 53 key: Key to set. 54 value: Value to store. 55 """ 56 self._data[key] = value
Set a value by key.
Args: key: Key to set. value: Value to store.
58 def has(self, key: str) -> bool: 59 """Check if a key exists. 60 61 Args: 62 key: Key to check. 63 64 Returns: 65 True if the key exists, False otherwise. 66 """ 67 return key in self._data
Check if a key exists.
Args: key: Key to check.
Returns: True if the key exists, False otherwise.
69 def delete(self, key: str) -> None: 70 """Delete a key from the scope. 71 72 Args: 73 key: Key to delete. No error if key doesn't exist. 74 """ 75 self._data.pop(key, None)
Delete a key from the scope.
Args: key: Key to delete. No error if key doesn't exist.
81 def is_dirty(self) -> bool: 82 """Check if the scope has been modified since load. 83 84 Returns: 85 True if the scope data has changed, False otherwise. 86 """ 87 current = json.dumps(self._data, sort_keys=True, ensure_ascii=False) 88 return current != self._snapshot
Check if the scope has been modified since load.
Returns: True if the scope data has changed, False otherwise.
90 def is_deleted(self) -> bool: 91 """Check if the scope has been cleared (all keys removed). 92 93 Returns: 94 True if the scope is now empty and was non-empty at load. 95 """ 96 return len(self._data) == 0 and self._snapshot != "{}"
Check if the scope has been cleared (all keys removed).
Returns: True if the scope is now empty and was non-empty at load.
98 def to_dict(self) -> dict[str, Any]: 99 """Export the scope data as a dictionary. 100 101 Returns: 102 Copy of the internal data dictionary. 103 """ 104 return dict(self._data)
Export the scope data as a dictionary.
Returns: Copy of the internal data dictionary.
9class Storage(Protocol): 10 """Storage provider for reading/writing bot state. 11 12 Implementations provide pluggable backends (in-memory, file, cloud, etc.) 13 for persisting bot state across turns. 14 15 Example:: 16 17 from botas.state import MemoryStorage 18 19 storage = MemoryStorage() 20 await storage.write({"conversation/123": {"count": 5}}) 21 data = await storage.read(["conversation/123"]) 22 # data = {"conversation/123": {"count": 5}} 23 """ 24 25 async def read(self, keys: list[str]) -> dict[str, object]: 26 """Read items from storage. 27 28 Args: 29 keys: Keys to read. 30 31 Returns: 32 Dictionary of key-value pairs that exist in storage. 33 Missing keys are omitted from the result. 34 """ 35 ... 36 37 async def write(self, changes: dict[str, object]) -> None: 38 """Write items to storage. 39 40 Args: 41 changes: Dictionary of key-value pairs to write. 42 """ 43 ... 44 45 async def delete(self, keys: list[str]) -> None: 46 """Delete items from storage. 47 48 Args: 49 keys: Keys to delete. Idempotent — no error if key doesn't exist. 50 """ 51 ...
Storage provider for reading/writing bot state.
Implementations provide pluggable backends (in-memory, file, cloud, etc.) for persisting bot state across turns.
Example::
from botas.state import MemoryStorage
storage = MemoryStorage()
await storage.write({"conversation/123": {"count": 5}})
data = await storage.read(["conversation/123"])
# data = {"conversation/123": {"count": 5}}
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)
25 async def read(self, keys: list[str]) -> dict[str, object]: 26 """Read items from storage. 27 28 Args: 29 keys: Keys to read. 30 31 Returns: 32 Dictionary of key-value pairs that exist in storage. 33 Missing keys are omitted from the result. 34 """ 35 ...
Read items from storage.
Args: keys: Keys to read.
Returns: Dictionary of key-value pairs that exist in storage. Missing keys are omitted from the result.
37 async def write(self, changes: dict[str, object]) -> None: 38 """Write items to storage. 39 40 Args: 41 changes: Dictionary of key-value pairs to write. 42 """ 43 ...
Write items to storage.
Args: changes: Dictionary of key-value pairs to write.
45 async def delete(self, keys: list[str]) -> None: 46 """Delete items from storage. 47 48 Args: 49 keys: Keys to delete. Idempotent — no error if key doesn't exist. 50 """ 51 ...
Delete items from storage.
Args: keys: Keys to delete. Idempotent — no error if key doesn't exist.
40class SuggestedActions(_CamelModel): 41 """A set of suggested action buttons presented alongside a message. 42 43 Attributes: 44 to: List of channel account IDs the suggestions are targeted at. 45 When ``None``, suggestions are shown to all participants. 46 actions: List of :class:`CardAction` buttons. 47 """ 48 49 to: Optional[list[str]] = None 50 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: Optional[TeamsChannelData] = None 156 timestamp: Optional[str] = None 157 local_timestamp: Optional[str] = None 158 locale: Optional[str] = None 159 local_timezone: Optional[str] = None 160 suggested_actions: Optional[SuggestedActions] = 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: Optional[Conversation] = None 226 self._from_account: Optional[ChannelAccount] = None 227 self._recipient: Optional[ChannelAccount] = None 228 self._text: str = "" 229 self._channel_data: Optional[TeamsChannelData] = None 230 self._suggested_actions: Optional[SuggestedActions] = None 231 self._entities: Optional[list[Entity]] = None 232 self._attachments: Optional[list[Attachment]] = 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: Optional[TeamsChannelData]) -> "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: Optional[SuggestedActions]) -> "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: Optional[list[Entity]]) -> "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: Optional[list[Attachment]]) -> "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: Optional[str] = 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: Union[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: Union[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: Optional[Conversation] = None 226 self._from_account: Optional[ChannelAccount] = None 227 self._recipient: Optional[ChannelAccount] = None 228 self._text: str = "" 229 self._channel_data: Optional[TeamsChannelData] = None 230 self._suggested_actions: Optional[SuggestedActions] = None 231 self._entities: Optional[list[Entity]] = None 232 self._attachments: Optional[list[Attachment]] = 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: Optional[TeamsChannelData]) -> "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: Optional[SuggestedActions]) -> "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: Optional[list[Entity]]) -> "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: Optional[list[Attachment]]) -> "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: Optional[str] = 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: Union[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: Union[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: Optional[str] = None 68 email: Optional[str] = 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: Optional[TenantInfo] = None 106 channel: Optional[ChannelInfo] = None 107 team: Optional[TeamInfo] = None 108 meeting: Optional[MeetingInfo] = None 109 notification: Optional[NotificationInfo] = 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: Optional[str] = None 129 tenant_id: Optional[str] = None 130 is_group: Optional[bool] = None 131 name: Optional[str] = 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: Optional[str] = None 68 name: Optional[str] = None 69 aad_group_id: Optional[str] = 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: Optional[str] = None
Microsoft 365 tenant information.
Attributes: id: The tenant's unique identifier (GUID).
49class TokenManager: 50 """Acquires and caches OAuth2 tokens for outbound Bot Service API calls. 51 52 Uses MSAL's ``ConfidentialClientApplication`` for client-credentials flow, 53 or delegates to a custom ``token_factory`` if provided. 54 """ 55 56 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 57 """Initialise the token manager. 58 59 Args: 60 options: Authentication configuration. Falls back to environment 61 variables when individual fields are ``None``. 62 """ 63 self._client_id = options.client_id or os.environ.get("CLIENT_ID") 64 self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET") 65 self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID") 66 self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get( 67 "MANAGED_IDENTITY_CLIENT_ID" 68 ) 69 self._token_factory = options.token_factory 70 self._msal_app: Optional[object] = None 71 self._mi_credential: Optional[Any] = None 72 73 @property 74 def client_id(self) -> Optional[str]: 75 """Returns the configured bot application/client ID.""" 76 return self._client_id 77 78 async def get_bot_token(self) -> Optional[str]: 79 """Acquire a token for the Bot Service API scope. 80 81 Returns: 82 A bearer token string, or ``None`` if credentials are not configured. 83 """ 84 return await self._get_token(_BOT_FRAMEWORK_SCOPE) 85 86 async def _get_token(self, scope: str) -> Optional[str]: 87 tracer = get_tracer() 88 if tracer: 89 with tracer.start_as_current_span("botas.auth.outbound") as span: 90 span.set_attribute("auth.scope", scope) 91 span.set_attribute( 92 "auth.token_endpoint", 93 f"https://login.microsoftonline.com/{self._tenant_id or 'common'}/oauth2/v2.0/token", 94 ) 95 if self._token_factory: 96 span.set_attribute("auth.flow", "custom_factory") 97 elif self._client_id and self._client_secret: 98 span.set_attribute("auth.flow", "client_credentials") 99 elif self._managed_identity_client_id or self._client_id: 100 span.set_attribute("auth.flow", "managed_identity") 101 span.set_attribute("auth.cache_hit", False) 102 return await self._do_get_token(scope) 103 return await self._do_get_token(scope) 104 105 async def _do_get_token(self, scope: str) -> Optional[str]: 106 if self._token_factory: 107 result = await self._token_factory(scope, self._tenant_id or "common") 108 if not result: 109 raise ValueError("Custom token factory returned an invalid token (None or empty)") 110 return result 111 112 if self._client_id and self._client_secret and self._tenant_id: 113 # MSAL is synchronous; offload to thread pool to avoid blocking the event loop 114 return await asyncio.to_thread(self._acquire_client_credentials, scope) 115 116 return None 117 118 def _acquire_client_credentials(self, scope: str) -> Optional[str]: 119 import msal # type: ignore[import-untyped] 120 121 if self._msal_app is None: 122 authority = f"https://login.microsoftonline.com/{self._tenant_id}" 123 self._msal_app = msal.ConfidentialClientApplication( 124 self._client_id, 125 authority=authority, 126 client_credential=self._client_secret, 127 ) 128 129 result = self._msal_app.acquire_token_for_client(scopes=[scope]) # type: ignore[union-attr] 130 if result and "access_token" in result: 131 return result["access_token"] 132 return None 133 134 async def _acquire_managed_identity(self, scope: str, client_id: str) -> Optional[str]: 135 """Acquire a token using a user-assigned managed identity. 136 137 Uses :class:`azure.identity.aio.ManagedIdentityCredential` under the 138 hood. Returns ``None`` and logs a warning when ``azure-identity`` is 139 not installed or when token acquisition fails (e.g., when running 140 outside an Azure environment that exposes the IMDS endpoint). 141 """ 142 try: 143 from azure.identity.aio import ManagedIdentityCredential 144 except ImportError: 145 _logger.error( 146 "azure-identity is required for managed identity authentication; " 147 "install with `pip install azure-identity`" 148 ) 149 return None 150 151 try: 152 if self._mi_credential is None: 153 self._mi_credential = ManagedIdentityCredential(client_id=client_id) 154 access_token = await self._mi_credential.get_token(scope) 155 return access_token.token 156 except Exception as exc: # noqa: BLE001 — surface a clean log + None to caller 157 _logger.warning("Managed identity token acquisition failed: %s", exc) 158 return None 159 160 async def aclose(self) -> None: 161 """Close the token manager and reset internal credential state. 162 163 Call during application shutdown to release cached credentials and 164 close the underlying ``azure-identity`` HTTP client (if any). 165 """ 166 self._msal_app = None 167 if self._mi_credential is not None: 168 close = getattr(self._mi_credential, "close", None) 169 if close is not None: 170 try: 171 result = close() 172 if asyncio.iscoroutine(result): 173 await result 174 except Exception as exc: # noqa: BLE001 175 _logger.debug("Error closing managed identity credential: %s", exc) 176 self._mi_credential = 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.
56 def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None: 57 """Initialise the token manager. 58 59 Args: 60 options: Authentication configuration. Falls back to environment 61 variables when individual fields are ``None``. 62 """ 63 self._client_id = options.client_id or os.environ.get("CLIENT_ID") 64 self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET") 65 self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID") 66 self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get( 67 "MANAGED_IDENTITY_CLIENT_ID" 68 ) 69 self._token_factory = options.token_factory 70 self._msal_app: Optional[object] = None 71 self._mi_credential: Optional[Any] = None
Initialise the token manager.
Args:
options: Authentication configuration. Falls back to environment
variables when individual fields are None.
73 @property 74 def client_id(self) -> Optional[str]: 75 """Returns the configured bot application/client ID.""" 76 return self._client_id
Returns the configured bot application/client ID.
78 async def get_bot_token(self) -> Optional[str]: 79 """Acquire a token for the Bot Service API scope. 80 81 Returns: 82 A bearer token string, or ``None`` if credentials are not configured. 83 """ 84 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.
160 async def aclose(self) -> None: 161 """Close the token manager and reset internal credential state. 162 163 Call during application shutdown to release cached credentials and 164 close the underlying ``azure-identity`` HTTP client (if any). 165 """ 166 self._msal_app = None 167 if self._mi_credential is not None: 168 close = getattr(self._mi_credential, "close", None) 169 if close is not None: 170 try: 171 result = close() 172 if asyncio.iscoroutine(result): 173 await result 174 except Exception as exc: # noqa: BLE001 175 _logger.debug("Error closing managed identity credential: %s", exc) 176 self._mi_credential = None
Close the token manager and reset internal credential state.
Call during application shutdown to release cached credentials and
close the underlying azure-identity HTTP client (if any).
13class TurnContext: 14 """Context for a single activity turn, passed to handlers and middleware. 15 16 Provides the incoming activity, a reference to the bot application, 17 and a scoped :meth:`send` method that automatically routes replies 18 back to the originating conversation. 19 20 Example:: 21 22 @bot.on("message") 23 async def on_message(ctx: TurnContext): 24 await ctx.send(f"You said: {ctx.activity.text}") 25 """ 26 27 __slots__ = ("activity", "app", "state") 28 29 def __init__(self, app: BotApplication, activity: CoreActivity) -> None: 30 """Initialise the turn context. 31 32 Args: 33 app: The bot application instance processing this turn. 34 activity: The incoming activity for this turn. 35 """ 36 self.activity = activity 37 self.app = app 38 self.state: Optional[TurnState] = None 39 40 async def send( 41 self, 42 activity_or_text: Union[ 43 str, 44 CoreActivity, 45 dict[str, Any], 46 ], 47 ) -> Optional[ResourceResponse]: 48 """Send a reply to the conversation that originated this turn. 49 50 Accepts a plain text string (sent as a message activity), a 51 :class:`CoreActivity`, or a dict for full control over the reply. 52 Routing fields are automatically populated from the incoming activity. 53 54 When passing a dict, you must provide at minimum a ``type`` field. 55 Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc. 56 are optional and depend on the activity type. Routing fields 57 (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``) 58 are auto-populated but can be overridden. 59 60 Args: 61 activity_or_text: A plain string, a :class:`CoreActivity`, or a dict. 62 63 Returns: 64 A :class:`ResourceResponse` with the sent activity ID, or ``None``. 65 66 Example:: 67 68 # Simple text reply 69 await ctx.send("Hello!") 70 71 # Dict with custom fields 72 await ctx.send({ 73 "type": "message", 74 "text": "Hello!", 75 "attachments": [...] 76 }) 77 """ 78 if isinstance(activity_or_text, str): 79 reply: Union[CoreActivity, dict[str, Any]] = ( 80 CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build() 81 ) 82 elif isinstance(activity_or_text, CoreActivity): 83 reply = CoreActivityBuilder().with_conversation_reference(self.activity).build() 84 # Merge: caller fields take precedence 85 merged = reply.model_dump(by_alias=True, exclude_none=True) 86 merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True)) 87 reply = merged 88 else: 89 base = ( 90 CoreActivityBuilder() 91 .with_conversation_reference(self.activity) 92 .build() 93 .model_dump(by_alias=True, exclude_none=True) 94 ) 95 base.update(activity_or_text) 96 reply = base 97 98 return await self.app.send_activity_async( 99 self.activity.service_url, 100 self.activity.conversation.id, 101 reply, 102 ) 103 104 async def send_typing(self) -> None: 105 """Send a typing indicator to the conversation. 106 107 Creates a typing activity with routing fields populated from the 108 incoming activity. Typing activities are ephemeral and do not 109 return a ResourceResponse. 110 111 Example:: 112 113 @bot.on("message") 114 async def on_message(ctx: TurnContext): 115 await ctx.send_typing() 116 # ... do some work ... 117 await ctx.send("Done!") 118 """ 119 typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build() 120 await self.app.send_activity_async( 121 self.activity.service_url, 122 self.activity.conversation.id, 123 typing_activity, 124 )
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}")
29 def __init__(self, app: BotApplication, activity: CoreActivity) -> None: 30 """Initialise the turn context. 31 32 Args: 33 app: The bot application instance processing this turn. 34 activity: The incoming activity for this turn. 35 """ 36 self.activity = activity 37 self.app = app 38 self.state: Optional[TurnState] = None
Initialise the turn context.
Args: app: The bot application instance processing this turn. activity: The incoming activity for this turn.
40 async def send( 41 self, 42 activity_or_text: Union[ 43 str, 44 CoreActivity, 45 dict[str, Any], 46 ], 47 ) -> Optional[ResourceResponse]: 48 """Send a reply to the conversation that originated this turn. 49 50 Accepts a plain text string (sent as a message activity), a 51 :class:`CoreActivity`, or a dict for full control over the reply. 52 Routing fields are automatically populated from the incoming activity. 53 54 When passing a dict, you must provide at minimum a ``type`` field. 55 Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc. 56 are optional and depend on the activity type. Routing fields 57 (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``) 58 are auto-populated but can be overridden. 59 60 Args: 61 activity_or_text: A plain string, a :class:`CoreActivity`, or a dict. 62 63 Returns: 64 A :class:`ResourceResponse` with the sent activity ID, or ``None``. 65 66 Example:: 67 68 # Simple text reply 69 await ctx.send("Hello!") 70 71 # Dict with custom fields 72 await ctx.send({ 73 "type": "message", 74 "text": "Hello!", 75 "attachments": [...] 76 }) 77 """ 78 if isinstance(activity_or_text, str): 79 reply: Union[CoreActivity, dict[str, Any]] = ( 80 CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build() 81 ) 82 elif isinstance(activity_or_text, CoreActivity): 83 reply = CoreActivityBuilder().with_conversation_reference(self.activity).build() 84 # Merge: caller fields take precedence 85 merged = reply.model_dump(by_alias=True, exclude_none=True) 86 merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True)) 87 reply = merged 88 else: 89 base = ( 90 CoreActivityBuilder() 91 .with_conversation_reference(self.activity) 92 .build() 93 .model_dump(by_alias=True, exclude_none=True) 94 ) 95 base.update(activity_or_text) 96 reply = base 97 98 return await self.app.send_activity_async( 99 self.activity.service_url, 100 self.activity.conversation.id, 101 reply, 102 )
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": [...]
})
104 async def send_typing(self) -> None: 105 """Send a typing indicator to the conversation. 106 107 Creates a typing activity with routing fields populated from the 108 incoming activity. Typing activities are ephemeral and do not 109 return a ResourceResponse. 110 111 Example:: 112 113 @bot.on("message") 114 async def on_message(ctx: TurnContext): 115 await ctx.send_typing() 116 # ... do some work ... 117 await ctx.send("Done!") 118 """ 119 typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build() 120 await self.app.send_activity_async( 121 self.activity.service_url, 122 self.activity.conversation.id, 123 typing_activity, 124 )
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!")
16class TurnState: 17 """State container for a single turn with three scopes. 18 19 Provides scoped key-value storage for conversation, user, and temporary state. 20 State is loaded at turn start and saved at turn end (if the turn succeeds). 21 22 Example:: 23 24 # Access via scopes 25 context.state.conversation.set("turnCount", 5) 26 count = context.state.conversation.get("turnCount", int) 27 28 # Or via path syntax 29 context.state.set_value("conversation.turnCount", 5) 30 count = context.state.get_value("conversation.turnCount", int) 31 """ 32 33 def __init__( 34 self, 35 activity: "CoreActivity", 36 conversation_data: Optional[dict[str, Any]] = None, 37 user_data: Optional[dict[str, Any]] = None, 38 ) -> None: 39 """Initialize turn state with scoped data. 40 41 Args: 42 activity: The activity for this turn (used for key derivation). 43 conversation_data: Initial conversation scope data. 44 user_data: Initial user scope data. 45 """ 46 self._activity = activity 47 self._conversation = StateScope(conversation_data) 48 self._user = StateScope(user_data) 49 self._temp = StateScope() 50 51 @property 52 def conversation(self) -> StateScope: 53 """Conversation-scoped state (persisted per conversation).""" 54 return self._conversation 55 56 @property 57 def user(self) -> StateScope: 58 """User-scoped state (persisted per user across conversations).""" 59 return self._user 60 61 @property 62 def temp(self) -> StateScope: 63 """Temporary state for the current turn (not persisted).""" 64 return self._temp 65 66 def get_value(self, path: str, type_: type[T] = object) -> Optional[T]: 67 """Get a value by path. 68 69 Path format: "[scope].property" or "property" (defaults to temp). 70 71 Args: 72 path: Dot-separated path (e.g., "conversation.count" or "input"). 73 type_: Type hint for return value (not enforced at runtime). 74 75 Returns: 76 The value if it exists, else None. 77 78 Raises: 79 ValueError: If path has more than one dot. 80 """ 81 scope, key = self._parse_path(path) 82 return scope.get(key, type_) 83 84 def set_value(self, path: str, value: Any) -> None: 85 """Set a value by path. 86 87 Path format: "[scope].property" or "property" (defaults to temp). 88 89 Args: 90 path: Dot-separated path (e.g., "conversation.count" or "input"). 91 value: Value to store. 92 93 Raises: 94 ValueError: If path has more than one dot. 95 """ 96 scope, key = self._parse_path(path) 97 scope.set(key, value) 98 99 def has_value(self, path: str) -> bool: 100 """Check if a value exists at path. 101 102 Args: 103 path: Dot-separated path (e.g., "conversation.count" or "input"). 104 105 Returns: 106 True if the value exists, False otherwise. 107 108 Raises: 109 ValueError: If path has more than one dot. 110 """ 111 scope, key = self._parse_path(path) 112 return scope.has(key) 113 114 def delete_value(self, path: str) -> None: 115 """Delete a value at path. 116 117 Args: 118 path: Dot-separated path (e.g., "conversation.count" or "input"). 119 120 Raises: 121 ValueError: If path has more than one dot. 122 """ 123 scope, key = self._parse_path(path) 124 scope.delete(key) 125 126 def delete_conversation_state(self) -> None: 127 """Delete all state in the conversation scope.""" 128 self._conversation.clear() 129 130 def delete_user_state(self) -> None: 131 """Delete all state in the user scope.""" 132 self._user.clear() 133 134 def delete_temp_state(self) -> None: 135 """Delete all state in the temp scope.""" 136 self._temp.clear() 137 138 def _parse_path(self, path: str) -> tuple[StateScope, str]: 139 """Parse a path string into (scope, key). 140 141 Args: 142 path: Dot-separated path (e.g., "conversation.count" or "input"). 143 144 Returns: 145 Tuple of (scope object, key string). 146 147 Raises: 148 ValueError: If path has more than one dot. 149 """ 150 parts = path.split(".") 151 if len(parts) > 2: 152 raise ValueError(f"Invalid path: {path} (too many dots)") 153 if len(parts) == 1: 154 # Unqualified path defaults to temp 155 return self._temp, parts[0] 156 # Qualified path: "scope.key" 157 scope_name, key = parts 158 if scope_name == "conversation": 159 return self._conversation, key 160 if scope_name == "user": 161 return self._user, key 162 if scope_name == "temp": 163 return self._temp, key 164 raise ValueError(f"Unknown scope: {scope_name}") 165 166 def get_conversation_key(self) -> str: 167 """Derive the storage key for conversation scope from the activity. 168 169 Returns: 170 Storage key for conversation state. 171 """ 172 channel_id = self._activity.channel_id or "" 173 bot_id = self._activity.recipient.id if self._activity.recipient else "" 174 conversation_id = self._activity.conversation.id if self._activity.conversation else "" 175 return f"{channel_id}/{bot_id}/conversations/{conversation_id}" 176 177 def get_user_key(self) -> str: 178 """Derive the storage key for user scope from the activity. 179 180 Returns: 181 Storage key for user state. 182 """ 183 channel_id = self._activity.channel_id or "" 184 bot_id = self._activity.recipient.id if self._activity.recipient else "" 185 user_id = self._activity.from_account.id if self._activity.from_account else "" 186 return f"{channel_id}/{bot_id}/users/{user_id}" 187 188 @staticmethod 189 def derive_conversation_key(activity: "CoreActivity") -> str: 190 """Derive the storage key for conversation scope from an activity. 191 192 Args: 193 activity: The activity to derive the key from. 194 195 Returns: 196 Storage key for conversation state. 197 """ 198 channel_id = activity.channel_id or "" 199 bot_id = activity.recipient.id if activity.recipient else "" 200 conversation_id = activity.conversation.id if activity.conversation else "" 201 return f"{channel_id}/{bot_id}/conversations/{conversation_id}" 202 203 @staticmethod 204 def derive_user_key(activity: "CoreActivity") -> str: 205 """Derive the storage key for user scope from an activity. 206 207 Args: 208 activity: The activity to derive the key from. 209 210 Returns: 211 Storage key for user state. 212 """ 213 channel_id = activity.channel_id or "" 214 bot_id = activity.recipient.id if activity.recipient else "" 215 user_id = activity.from_account.id if activity.from_account else "" 216 return f"{channel_id}/{bot_id}/users/{user_id}"
State container for a single turn with three scopes.
Provides scoped key-value storage for conversation, user, and temporary state. State is loaded at turn start and saved at turn end (if the turn succeeds).
Example::
# Access via scopes
context.state.conversation.set("turnCount", 5)
count = context.state.conversation.get("turnCount", int)
# Or via path syntax
context.state.set_value("conversation.turnCount", 5)
count = context.state.get_value("conversation.turnCount", int)
33 def __init__( 34 self, 35 activity: "CoreActivity", 36 conversation_data: Optional[dict[str, Any]] = None, 37 user_data: Optional[dict[str, Any]] = None, 38 ) -> None: 39 """Initialize turn state with scoped data. 40 41 Args: 42 activity: The activity for this turn (used for key derivation). 43 conversation_data: Initial conversation scope data. 44 user_data: Initial user scope data. 45 """ 46 self._activity = activity 47 self._conversation = StateScope(conversation_data) 48 self._user = StateScope(user_data) 49 self._temp = StateScope()
Initialize turn state with scoped data.
Args: activity: The activity for this turn (used for key derivation). conversation_data: Initial conversation scope data. user_data: Initial user scope data.
51 @property 52 def conversation(self) -> StateScope: 53 """Conversation-scoped state (persisted per conversation).""" 54 return self._conversation
Conversation-scoped state (persisted per conversation).
56 @property 57 def user(self) -> StateScope: 58 """User-scoped state (persisted per user across conversations).""" 59 return self._user
User-scoped state (persisted per user across conversations).
61 @property 62 def temp(self) -> StateScope: 63 """Temporary state for the current turn (not persisted).""" 64 return self._temp
Temporary state for the current turn (not persisted).
66 def get_value(self, path: str, type_: type[T] = object) -> Optional[T]: 67 """Get a value by path. 68 69 Path format: "[scope].property" or "property" (defaults to temp). 70 71 Args: 72 path: Dot-separated path (e.g., "conversation.count" or "input"). 73 type_: Type hint for return value (not enforced at runtime). 74 75 Returns: 76 The value if it exists, else None. 77 78 Raises: 79 ValueError: If path has more than one dot. 80 """ 81 scope, key = self._parse_path(path) 82 return scope.get(key, type_)
Get a value by path.
Path format: "[scope].property" or "property" (defaults to temp).
Args: path: Dot-separated path (e.g., "conversation.count" or "input"). type_: Type hint for return value (not enforced at runtime).
Returns: The value if it exists, else None.
Raises: ValueError: If path has more than one dot.
84 def set_value(self, path: str, value: Any) -> None: 85 """Set a value by path. 86 87 Path format: "[scope].property" or "property" (defaults to temp). 88 89 Args: 90 path: Dot-separated path (e.g., "conversation.count" or "input"). 91 value: Value to store. 92 93 Raises: 94 ValueError: If path has more than one dot. 95 """ 96 scope, key = self._parse_path(path) 97 scope.set(key, value)
Set a value by path.
Path format: "[scope].property" or "property" (defaults to temp).
Args: path: Dot-separated path (e.g., "conversation.count" or "input"). value: Value to store.
Raises: ValueError: If path has more than one dot.
99 def has_value(self, path: str) -> bool: 100 """Check if a value exists at path. 101 102 Args: 103 path: Dot-separated path (e.g., "conversation.count" or "input"). 104 105 Returns: 106 True if the value exists, False otherwise. 107 108 Raises: 109 ValueError: If path has more than one dot. 110 """ 111 scope, key = self._parse_path(path) 112 return scope.has(key)
Check if a value exists at path.
Args: path: Dot-separated path (e.g., "conversation.count" or "input").
Returns: True if the value exists, False otherwise.
Raises: ValueError: If path has more than one dot.
114 def delete_value(self, path: str) -> None: 115 """Delete a value at path. 116 117 Args: 118 path: Dot-separated path (e.g., "conversation.count" or "input"). 119 120 Raises: 121 ValueError: If path has more than one dot. 122 """ 123 scope, key = self._parse_path(path) 124 scope.delete(key)
Delete a value at path.
Args: path: Dot-separated path (e.g., "conversation.count" or "input").
Raises: ValueError: If path has more than one dot.
126 def delete_conversation_state(self) -> None: 127 """Delete all state in the conversation scope.""" 128 self._conversation.clear()
Delete all state in the conversation scope.
130 def delete_user_state(self) -> None: 131 """Delete all state in the user scope.""" 132 self._user.clear()
Delete all state in the user scope.
134 def delete_temp_state(self) -> None: 135 """Delete all state in the temp scope.""" 136 self._temp.clear()
Delete all state in the temp scope.
166 def get_conversation_key(self) -> str: 167 """Derive the storage key for conversation scope from the activity. 168 169 Returns: 170 Storage key for conversation state. 171 """ 172 channel_id = self._activity.channel_id or "" 173 bot_id = self._activity.recipient.id if self._activity.recipient else "" 174 conversation_id = self._activity.conversation.id if self._activity.conversation else "" 175 return f"{channel_id}/{bot_id}/conversations/{conversation_id}"
Derive the storage key for conversation scope from the activity.
Returns: Storage key for conversation state.
177 def get_user_key(self) -> str: 178 """Derive the storage key for user scope from the activity. 179 180 Returns: 181 Storage key for user state. 182 """ 183 channel_id = self._activity.channel_id or "" 184 bot_id = self._activity.recipient.id if self._activity.recipient else "" 185 user_id = self._activity.from_account.id if self._activity.from_account else "" 186 return f"{channel_id}/{bot_id}/users/{user_id}"
Derive the storage key for user scope from the activity.
Returns: Storage key for user state.
188 @staticmethod 189 def derive_conversation_key(activity: "CoreActivity") -> str: 190 """Derive the storage key for conversation scope from an activity. 191 192 Args: 193 activity: The activity to derive the key from. 194 195 Returns: 196 Storage key for conversation state. 197 """ 198 channel_id = activity.channel_id or "" 199 bot_id = activity.recipient.id if activity.recipient else "" 200 conversation_id = activity.conversation.id if activity.conversation else "" 201 return f"{channel_id}/{bot_id}/conversations/{conversation_id}"
Derive the storage key for conversation scope from an activity.
Args: activity: The activity to derive the key from.
Returns: Storage key for conversation state.
203 @staticmethod 204 def derive_user_key(activity: "CoreActivity") -> str: 205 """Derive the storage key for user scope from an activity. 206 207 Args: 208 activity: The activity to derive the key from. 209 210 Returns: 211 Storage key for user state. 212 """ 213 channel_id = activity.channel_id or "" 214 bot_id = activity.recipient.id if activity.recipient else "" 215 user_id = activity.from_account.id if activity.from_account else "" 216 return f"{channel_id}/{bot_id}/users/{user_id}"
Derive the storage key for user scope from an activity.
Args: activity: The activity to derive the key from.
Returns: Storage key for user state.
56def get_metrics() -> Optional[BotasMetrics]: 57 """Return pre-created metric instruments, or ``None`` if unavailable.""" 58 global _metrics, _initialized 59 if _initialized: 60 return _metrics 61 _initialized = True 62 try: 63 from opentelemetry import metrics 64 65 from botas._version import __version__ 66 67 meter = metrics.get_meter("botas", __version__) 68 _metrics = BotasMetrics(meter) 69 except ImportError: 70 _metrics = None 71 return _metrics
Return pre-created metric instruments, or None if unavailable.
19def get_tracer() -> Tracer | None: 20 """Return the shared OpenTelemetry tracer, or ``None`` if unavailable. 21 22 If ``_tracer`` is set (e.g. for testing), returns it directly. 23 When ``_initialized`` is True and ``_tracer`` is None, returns None (no-op). 24 Otherwise calls ``trace.get_tracer()`` each time to pick up the 25 currently configured global TracerProvider. 26 """ 27 if _initialized: 28 return _tracer 29 try: 30 from opentelemetry import trace 31 32 from botas._version import __version__ 33 34 return trace.get_tracer("botas", __version__) 35 except ImportError: 36 return None
Return the shared OpenTelemetry tracer, or None if unavailable.
If _tracer is set (e.g. for testing), returns it directly.
When _initialized is True and _tracer is None, returns None (no-op).
Otherwise calls trace.get_tracer() each time to pick up the
currently configured global TracerProvider.
122async def validate_bot_token(auth_header: Optional[str], app_id: Optional[str] = None) -> None: 123 """Validate a Bot Service or Entra ID JWT bearer token. 124 125 Supports tokens from both the Bot Service channel service and Azure 126 AD / Entra ID. The correct OpenID configuration is selected dynamically 127 by inspecting the token's issuer claim (see ``specs/inbound-auth.md``). 128 129 Args: 130 auth_header: The full ``Authorization`` header value 131 (e.g. ``"Bearer eyJ..."``). 132 app_id: Expected audience (bot application / client ID). Falls back 133 to the ``CLIENT_ID`` environment variable when ``None``. 134 135 Raises: 136 BotAuthError: On any validation failure — missing header, expired 137 token, bad audience, untrusted issuer, or missing JWKS key. 138 """ 139 resolved_app_id = app_id or os.environ.get("CLIENT_ID") 140 if not resolved_app_id: 141 raise BotAuthError("CLIENT_ID not configured") 142 143 if not auth_header or not auth_header.startswith("Bearer "): 144 raise BotAuthError("Missing or malformed Authorization header") 145 146 token = auth_header[len("Bearer ") :] 147 148 tracer = get_tracer() 149 if tracer: 150 with tracer.start_as_current_span("botas.auth.inbound") as span: 151 await _do_validate_token(token, resolved_app_id, span) 152 else: 153 await _do_validate_token(token, resolved_app_id) 154 155 _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.