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]
ActivityType = typing.Literal['message', 'typing', 'invoke']
class Attachment(botas.core_activity._CamelModel):
 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.

content_type: str = PydanticUndefined
content_url: Optional[str] = None
content: Any = None
name: Optional[str] = None
thumbnail_url: Optional[str] = None
class BotApplication:
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.

BotApplication( options: BotApplicationOptions = BotApplicationOptions(client_id=None, client_secret=None, tenant_id=None, managed_identity_client_id=None, token_factory=None))
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.

version: str = '0.0.0.dev0'
conversation_client
on_activity: Optional[Callable[[TurnContext], Awaitable[NoneType]]]
appid: Optional[str]
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.

def on( self, type: str, handler: Optional[Callable[[TurnContext], Awaitable[NoneType]]] = None) -> Any:
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.

def use( self, middleware: TurnMiddleware) -> BotApplication:
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.

def use_state(self, storage: Any) -> BotApplication:
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())
def on_invoke( self, name: str, handler: Optional[Callable[[TurnContext], Awaitable[InvokeResponse]]] = None) -> Any:
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): ...
async def process_body(self, body: str) -> Optional[InvokeResponse]:
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.

async def send_activity_async( self, service_url: str, conversation_id: str, activity: Union[CoreActivity, dict[str, Any]]) -> Optional[ResourceResponse]:
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.

async def aclose(self) -> None:
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.

@dataclass
class BotApplicationOptions:
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.

BotApplicationOptions( client_id: Optional[str] = None, client_secret: Optional[str] = None, tenant_id: Optional[str] = None, managed_identity_client_id: Optional[str] = None, token_factory: Optional[Callable[[str, str], Awaitable[str]]] = None)
client_id: Optional[str] = None
client_secret: Optional[str] = None
tenant_id: Optional[str] = None
managed_identity_client_id: Optional[str] = None
token_factory: Optional[Callable[[str, str], Awaitable[str]]] = None
class BotAuthError(builtins.Exception):
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.).

class BotHandlerException(builtins.Exception):
 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.

BotHandlerException( message: str, cause: BaseException, activity: CoreActivity)
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.

name
cause
activity
class CardAction(botas.suggested_actions._CamelModel):
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.

type: str = 'imBack'
title: Optional[str] = None
value: Optional[str] = None
text: Optional[str] = None
display_text: Optional[str] = None
image: Optional[str] = None
class ChannelAccount(botas.core_activity._CamelModel):
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").

id: str = PydanticUndefined
name: Optional[str] = None
aad_object_id: Optional[str] = None
role: Optional[str] = None
class ChannelInfo(botas.teams_activity._CamelModel):
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.

id: Optional[str] = None
name: Optional[str] = None
class Conversation(botas.core_activity._CamelModel):
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.

id: str = PydanticUndefined
class ConversationClient:
 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.

ConversationClient(get_token: Optional[Callable[[], Awaitable[Optional[str]]]] = None)
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.

async def send_activity_async( self, service_url: str, conversation_id: str, activity: Union[CoreActivity, dict[str, Any]]) -> Optional[ResourceResponse]:
 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.

async def update_activity_async( self, service_url: str, conversation_id: str, activity_id: str, activity: Union[CoreActivity, dict[str, Any]]) -> Optional[ResourceResponse]:
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.

async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> 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.

async def get_conversation_members_async( self, service_url: str, conversation_id: str) -> list[ChannelAccount]:
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.

async def get_conversation_member_async( self, service_url: str, conversation_id: str, member_id: str) -> Optional[ChannelAccount]:
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.

async def get_conversation_paged_members_async( self, service_url: str, conversation_id: str, page_size: Optional[int] = None, continuation_token: Optional[str] = None) -> botas.core_activity._PagedMembersResult:
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.

async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None:
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.

async def create_conversation_async( self, service_url: str, parameters: botas.core_activity._ConversationParameters) -> Optional[botas.core_activity._ConversationResourceResponse]:
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.

async def get_conversations_async( self, service_url: str, continuation_token: Optional[str] = None) -> botas.core_activity._ConversationsResult:
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.

async def send_conversation_history_async( self, service_url: str, conversation_id: str, _Transcript: botas.core_activity._Transcript) -> Optional[ResourceResponse]:
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.

async def get_conversation_account_async( self, service_url: str, conversation_id: str) -> Optional[Conversation]:
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.

async def aclose(self) -> None:
360    async def aclose(self) -> None:
361        """Close the underlying HTTP client and release resources."""
362        await self._http.aclose()

Close the underlying HTTP client and release resources.

class CoreActivity(botas.core_activity._CamelModel):
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.

id: Optional[str] = None
channel_id: Optional[str] = None
type: str = PydanticUndefined
service_url: str = ''
from_account: Optional[ChannelAccount] = None
recipient: Optional[ChannelAccount] = None
conversation: Optional[Conversation] = None
text: Optional[str] = None
name: Optional[str] = None
value: Any = None
entities: Optional[list[Entity]] = None
attachments: Optional[list[Attachment]] = None
class CoreActivityBuilder:
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()
)
CoreActivityBuilder()
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").

def with_conversation_reference( self, source: CoreActivity) -> CoreActivityBuilder:
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.

def with_type(self, activity_type: str) -> CoreActivityBuilder:
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.

def with_service_url(self, service_url: str) -> CoreActivityBuilder:
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.

def with_conversation( self, conversation: Conversation) -> CoreActivityBuilder:
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.

def with_from( self, from_account: ChannelAccount) -> CoreActivityBuilder:
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.

def with_recipient( self, recipient: ChannelAccount) -> CoreActivityBuilder:
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.

def with_text(self, text: str) -> CoreActivityBuilder:
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.

def with_entities( self, entities: list[Entity]) -> CoreActivityBuilder:
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.

def with_attachments( self, attachments: list[Attachment]) -> CoreActivityBuilder:
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.

def build(self) -> CoreActivity:
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.

class Entity(botas.core_activity._CamelModel):
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.

type: str = PydanticUndefined
class FileStorage:
 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".

FileStorage(root_path: Union[str, pathlib.Path] = './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".

async def read(self, keys: list[str]) -> dict[str, object]:
 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.

async def write(self, changes: dict[str, object]) -> None:
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.

async def delete(self, keys: list[str]) -> None:
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.

@dataclass
class InvokeResponse:
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``."""

Response returned by an invoke activity handler.

The status is written as the HTTP status code; body is serialized as JSON and included in the response body.

InvokeResponse(status: int, body: Any = None)
status: int

HTTP status code to return to the channel (e.g. 200, 400, 501).

body: Any = None

Optional response body serialized as JSON. Omitted when None.

ITurnMiddleware = <class 'TurnMiddleware'>
@runtime_checkable
class TurnMiddleware(typing.Protocol):
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).

TurnMiddleware(*args, **kwargs)
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)
async def on_turn( self, context: TurnContext, next: Callable[[], Awaitable[NoneType]]) -> None:
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.

class MeetingInfo(botas.teams_activity._CamelModel):
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.

id: Optional[str] = None
class MemoryStorage:
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}}
MemoryStorage()
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.

async def read(self, keys: list[str]) -> dict[str, object]:
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.

async def write(self, changes: dict[str, object]) -> None:
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.

async def delete(self, keys: list[str]) -> None:
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.

class NotificationInfo(botas.teams_activity._CamelModel):
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.

alert: Optional[bool] = None
class RemoveMentionMiddleware:
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())
async def on_turn( self, context: TurnContext, next: Callable[[], Awaitable[NoneType]]) -> None:
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.

class ResourceResponse(botas.core_activity._CamelModel):
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.

id: str = PydanticUndefined
class StateScope:
 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
StateScope(data: Optional[dict[str, Any]] = 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.

def get(self, key: str, type_: type[~T] = <class 'object'>) -> Optional[~T]:
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.

def set(self, key: str, value: Any) -> 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.

def has(self, key: str) -> bool:
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.

def delete(self, key: str) -> None:
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.

def clear(self) -> None:
77    def clear(self) -> None:
78        """Clear all keys from the scope."""
79        self._data.clear()

Clear all keys from the scope.

def is_dirty(self) -> bool:
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.

def is_deleted(self) -> bool:
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.

def to_dict(self) -> dict[str, typing.Any]:
 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.

class Storage(typing.Protocol):
 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}}
Storage(*args, **kwargs)
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)
async def read(self, keys: list[str]) -> dict[str, object]:
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.

async def write(self, changes: dict[str, object]) -> None:
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.

async def delete(self, keys: list[str]) -> None:
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.

class SuggestedActions(botas.suggested_actions._CamelModel):
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.

to: Optional[list[str]] = None
actions: list[CardAction] = []
class TeamsActivity(botas.CoreActivity):
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.

channel_data: Optional[TeamsChannelData] = None
timestamp: Optional[str] = None
local_timestamp: Optional[str] = None
locale: Optional[str] = None
local_timezone: Optional[str] = None
suggested_actions: Optional[SuggestedActions] = None
@staticmethod
def from_activity( activity: CoreActivity) -> TeamsActivity:
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.

def add_entity(self, entity: Entity) -> 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.

@staticmethod
def create_builder() -> TeamsActivityBuilder:
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.

class TeamsActivityBuilder:
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()
)
TeamsActivityBuilder()
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").

def with_conversation_reference( self, source: CoreActivity) -> TeamsActivityBuilder:
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.

def with_type(self, activity_type: str) -> TeamsActivityBuilder:
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.

def with_service_url(self, service_url: str) -> TeamsActivityBuilder:
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.

def with_conversation( self, conversation: Conversation) -> TeamsActivityBuilder:
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.

def with_from( self, from_account: ChannelAccount) -> TeamsActivityBuilder:
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.

def with_recipient( self, recipient: ChannelAccount) -> TeamsActivityBuilder:
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.

def with_text(self, text: str) -> TeamsActivityBuilder:
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.

def with_channel_data( self, channel_data: Optional[TeamsChannelData]) -> TeamsActivityBuilder:
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.

def with_suggested_actions( self, suggested_actions: Optional[SuggestedActions]) -> TeamsActivityBuilder:
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.

def with_entities( self, entities: Optional[list[Entity]]) -> TeamsActivityBuilder:
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.

def with_attachments( self, attachments: Optional[list[Attachment]]) -> TeamsActivityBuilder:
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.

def with_attachment( self, attachment: Attachment) -> TeamsActivityBuilder:
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.

def add_entity( self, entity: Entity) -> TeamsActivityBuilder:
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.

def add_attachment( self, attachment: Attachment) -> TeamsActivityBuilder:
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.

def add_mention( self, account: ChannelAccount, mention_text: Optional[str] = None) -> TeamsActivityBuilder:
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.

def add_adaptive_card_attachment( self, card: Union[str, dict]) -> TeamsActivityBuilder:
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.

def with_adaptive_card_attachment( self, card: Union[str, dict]) -> TeamsActivityBuilder:
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.

def build(self) -> TeamsActivity:
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.

TeamsActivityType = typing.Literal['message', 'typing', 'invoke', 'event', 'conversationUpdate', 'messageUpdate', 'messageDelete', 'messageReaction', 'installationUpdate']
class TeamsChannelAccount(botas.ChannelAccount):
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.

user_principal_name: Optional[str] = None
email: Optional[str] = None
class TeamsChannelData(botas.teams_activity._CamelModel):
 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).

tenant: Optional[TenantInfo] = None
channel: Optional[ChannelInfo] = None
team: Optional[TeamInfo] = None
meeting: Optional[MeetingInfo] = None
notification: Optional[NotificationInfo] = None
class TeamsConversation(botas.Conversation):
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.

conversation_type: Optional[str] = None
tenant_id: Optional[str] = None
is_group: Optional[bool] = None
name: Optional[str] = None
class TeamInfo(botas.teams_activity._CamelModel):
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.

id: Optional[str] = None
name: Optional[str] = None
aad_group_id: Optional[str] = None
class TenantInfo(botas.teams_activity._CamelModel):
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).

id: Optional[str] = None
class TokenManager:
 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.

TokenManager( options: BotApplicationOptions = BotApplicationOptions(client_id=None, client_secret=None, tenant_id=None, managed_identity_client_id=None, token_factory=None))
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.

client_id: Optional[str]
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.

async def get_bot_token(self) -> Optional[str]:
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.

async def aclose(self) -> None:
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).

class TurnContext:
 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}")
TurnContext( app: BotApplication, activity: CoreActivity)
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.

activity
app
state: Optional[TurnState]
async def send( self, activity_or_text: Union[str, CoreActivity, dict[str, Any]]) -> Optional[ResourceResponse]:
 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": [...]
})
async def send_typing(self) -> None:
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!")
class TurnState:
 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)
TurnState( activity: CoreActivity, conversation_data: Optional[dict[str, Any]] = None, user_data: Optional[dict[str, Any]] = None)
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.

conversation: StateScope
51    @property
52    def conversation(self) -> StateScope:
53        """Conversation-scoped state (persisted per conversation)."""
54        return self._conversation

Conversation-scoped state (persisted per conversation).

user: StateScope
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).

temp: StateScope
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).

def get_value(self, path: str, type_: type[~T] = <class 'object'>) -> Optional[~T]:
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.

def set_value(self, path: str, value: Any) -> None:
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.

def has_value(self, path: str) -> bool:
 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.

def delete_value(self, path: str) -> None:
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.

def delete_conversation_state(self) -> None:
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.

def delete_user_state(self) -> None:
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.

def delete_temp_state(self) -> None:
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.

def get_conversation_key(self) -> str:
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.

def get_user_key(self) -> str:
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.

@staticmethod
def derive_conversation_key(activity: CoreActivity) -> str:
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.

@staticmethod
def derive_user_key(activity: CoreActivity) -> str:
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.

def get_metrics() -> Optional[botas.meter_provider.BotasMetrics]:
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.

def get_tracer() -> opentelemetry.trace.Tracer | None:
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.

__version__ = '0.0.0.dev0'
async def validate_bot_token(auth_header: Optional[str], app_id: Optional[str] = None) -> None:
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.