botas

Botas — a lightweight, multi-language Bot Service library for Python.

Provides the core building blocks for receiving, processing, and sending Bot Service activities over HTTP. Typical usage::

from botas import BotApplication, TurnContext

bot = BotApplication()

@bot.on("message")
async def echo(ctx: TurnContext):
    await ctx.send(f"You said: {ctx.activity.text}")

See the specs/ directory for protocol details and the README for quickstart guides.

 1"""Botas — a lightweight, multi-language Bot Service library for Python.
 2
 3Provides the core building blocks for receiving, processing, and sending
 4Bot Service activities over HTTP.  Typical usage::
 5
 6    from botas import BotApplication, TurnContext
 7
 8    bot = BotApplication()
 9
10    @bot.on("message")
11    async def echo(ctx: TurnContext):
12        await ctx.send(f"You said: {ctx.activity.text}")
13
14See the `specs/` directory for protocol details and the README for quickstart guides.
15"""
16
17from botas._version import __version__
18from botas.bot_application import BotApplication, BotHandlerException, InvokeResponse
19from botas.bot_auth import BotAuthError, validate_bot_token
20from botas.conversation_client import ConversationClient
21from botas.core_activity import (
22    ActivityType,
23    Attachment,
24    ChannelAccount,
25    Conversation,
26    CoreActivity,
27    CoreActivityBuilder,
28    Entity,
29    ResourceResponse,
30    TeamsActivityType,
31    TeamsChannelAccount,
32)
33from botas.i_turn_middleware import ITurnMiddleware, TurnMiddleware
34from botas.remove_mention_middleware import RemoveMentionMiddleware
35from botas.suggested_actions import CardAction, SuggestedActions
36from botas.teams_activity import (
37    ChannelInfo,
38    MeetingInfo,
39    NotificationInfo,
40    TeamInfo,
41    TeamsActivity,
42    TeamsActivityBuilder,
43    TeamsChannelData,
44    TeamsConversation,
45    TenantInfo,
46)
47from botas.token_manager import BotApplicationOptions, TokenManager
48from botas.turn_context import TurnContext
49
50__all__ = [
51    "ActivityType",
52    "Attachment",
53    "BotApplication",
54    "BotApplicationOptions",
55    "BotAuthError",
56    "BotHandlerException",
57    "CardAction",
58    "ChannelAccount",
59    "ChannelInfo",
60    "Conversation",
61    "ConversationClient",
62    "CoreActivity",
63    "CoreActivityBuilder",
64    "Entity",
65    "InvokeResponse",
66    "ITurnMiddleware",
67    "TurnMiddleware",
68    "MeetingInfo",
69    "NotificationInfo",
70    "RemoveMentionMiddleware",
71    "ResourceResponse",
72    "SuggestedActions",
73    "TeamsActivity",
74    "TeamsActivityBuilder",
75    "TeamsActivityType",
76    "TeamsChannelAccount",
77    "TeamsChannelData",
78    "TeamsConversation",
79    "TeamInfo",
80    "TenantInfo",
81    "TokenManager",
82    "TurnContext",
83    "__version__",
84    "validate_bot_token",
85]
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: str | None = None
103    content: Any = None
104    name: str | None = None
105    thumbnail_url: str | None = None

Bot Service attachment (images, cards, files, etc.).

Attributes: content_type: MIME type (e.g. "application/vnd.microsoft.card.adaptive"). content_url: URL to download the attachment content. content: Inline attachment content (e.g. an Adaptive Card JSON object). name: Display name / filename. thumbnail_url: URL to a thumbnail image.

content_type: str = PydanticUndefined
content_url: str | None = None
content: Any = None
name: str | None = None
thumbnail_url: str | None = None
class BotApplication:
 96class BotApplication:
 97    """Central entry point for building a bot with the Bot Service.
 98
 99    Manages the middleware pipeline, activity handler dispatch, outbound
100    messaging via :class:`ConversationClient`, and OAuth2 token lifecycle
101    via :class:`TokenManager`.
102
103    Supports async context-manager usage for automatic resource cleanup::
104
105        async with BotApplication(options) as bot:
106            bot.on("message", my_handler)
107            ...
108
109    Attributes:
110        version: Library version string.
111        conversation_client: Client for sending outbound activities.
112        on_activity: Optional catch-all handler invoked for every activity type.
113    """
114
115    version: str = __import__("botas._version", fromlist=["__version__"]).__version__
116
117    def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None:
118        """Initialise the bot application.
119
120        Args:
121            options: Configuration for authentication credentials and token
122                acquisition.  Defaults to reading from environment variables.
123        """
124        self._token_manager = TokenManager(options)
125        token_provider = self._token_manager.get_bot_token
126        self.conversation_client = ConversationClient(token_provider)
127        self._middlewares: list[TurnMiddleware] = []
128        self._handlers: dict[str, ActivityHandler] = {}
129        self._invoke_handlers: dict[str, InvokeActivityHandler] = {}
130        self.on_activity: ActivityHandler | None = None
131
132    @property
133    def appid(self) -> str | None:
134        """The bot application/client ID exposed from the token manager."""
135        return self._token_manager.client_id
136
137    def on(
138        self,
139        type: str,
140        handler: ActivityHandler | None = None,
141    ) -> Any:
142        """Register a handler for an activity type.
143
144        Only one handler is stored per type; re-registering the same type
145        replaces the previous handler.
146
147        Can be used as a two-argument call or as a decorator::
148
149            bot.on('message', my_handler)
150
151            @bot.on('message')
152            async def my_handler(ctx: TurnContext):
153                await ctx.send("hello")
154
155        Args:
156            type: The activity type to handle (e.g. ``"message"``, ``"typing"``).
157            handler: Async handler function.  If omitted, returns a decorator.
158
159        Returns:
160            The ``BotApplication`` instance when called with a handler, or a
161            decorator function when called without one.
162        """
163        if handler is None:
164
165            def decorator(fn: ActivityHandler) -> ActivityHandler:
166                self._handlers[type.lower()] = fn
167                return fn
168
169            return decorator
170        self._handlers[type.lower()] = handler
171        return self
172
173    def use(self, middleware: TurnMiddleware) -> "BotApplication":
174        """Register a middleware in the turn pipeline.
175
176        Middleware executes in registration order before handler dispatch.
177        Each middleware receives ``(context, next)`` and must call ``next()``
178        to continue the pipeline, or skip it to short-circuit processing.
179
180        Args:
181            middleware: An object implementing :class:`TurnMiddleware`.
182
183        Returns:
184            The ``BotApplication`` instance for chaining.
185        """
186        self._middlewares.append(middleware)
187        return self
188
189    def on_invoke(
190        self,
191        name: str,
192        handler: InvokeActivityHandler | None = None,
193    ) -> Any:
194        """Register a handler for an invoke activity by its ``activity.name`` sub-type.
195
196        The handler must return an :class:`InvokeResponse`.  Only one handler
197        per name is supported; re-registering the same name replaces the
198        previous handler.
199
200        Can be used as a two-argument call or as a decorator::
201
202            bot.on_invoke("adaptiveCard/action", my_handler)
203
204            @bot.on_invoke("adaptiveCard/action")
205            async def my_handler(ctx): ...
206        """
207        if handler is None:
208
209            def decorator(fn: InvokeActivityHandler) -> InvokeActivityHandler:
210                self._invoke_handlers[name.lower()] = fn
211                return fn
212
213            return decorator
214        self._invoke_handlers[name.lower()] = handler
215        return self
216
217    async def process_body(self, body: str) -> InvokeResponse | None:
218        """Parse and process a raw JSON activity body.
219
220        Deserializes the JSON string into a :class:`CoreActivity`, validates
221        required fields and the ``serviceUrl``, then runs the full middleware
222        pipeline followed by handler dispatch.
223
224        For ``invoke`` activities, returns the :class:`InvokeResponse` produced
225        by the registered handler, a 200 response if no invoke handlers are
226        registered, or a 501 response if handlers exist but none match.
227        Returns ``None`` for all other activity types.
228
229        Args:
230            body: Raw JSON string representing a Bot Service activity.
231
232        Returns:
233            An :class:`InvokeResponse` for invoke activities, or ``None``.
234
235        Raises:
236            ValueError: If the JSON is malformed or required activity fields
237                are missing.
238            BotHandlerException: If the matched handler raises an exception.
239        """
240        try:
241            activity = CoreActivity.model_validate_json(body)
242        except json.JSONDecodeError as exc:
243            raise ValueError("Invalid JSON in request body") from exc
244        _assert_activity(activity)
245        _validate_service_url(activity.service_url)
246        return await self._run_pipeline(activity)
247
248    async def send_activity_async(
249        self,
250        service_url: str,
251        conversation_id: str,
252        activity: CoreActivity | dict[str, Any],
253    ) -> ResourceResponse | None:
254        """Proactively send an activity to a conversation.
255
256        Use this to push messages outside of the normal turn pipeline (e.g.
257        notifications or proactive messages).
258
259        Args:
260            service_url: The channel's service URL.
261            conversation_id: Target conversation identifier.
262            activity: The activity payload to send.
263
264        Returns:
265            A :class:`ResourceResponse` with the new activity ID, or ``None``
266            if the channel does not return one.
267        """
268        return await self.conversation_client.send_activity_async(service_url, conversation_id, activity)
269
270    async def aclose(self) -> None:
271        """Close the underlying HTTP client and release resources.
272
273        Should be called during application shutdown.  Alternatively, use the
274        bot as an async context manager to ensure automatic cleanup.
275        """
276        await self.conversation_client.aclose()
277
278    async def __aenter__(self) -> "BotApplication":
279        """Enter the async context manager."""
280        return self
281
282    async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
283        """Exit the async context manager, ensuring resources are closed."""
284        await self.aclose()
285
286    async def _handle_activity_async(self, context: TurnContext) -> InvokeResponse | None:
287        if context.activity.type == "invoke":
288            return await self._dispatch_invoke_async(context)
289        handler = self.on_activity or self._handlers.get(context.activity.type.lower())
290        if handler is None:
291            return None
292        try:
293            await handler(context)
294        except Exception as exc:
295            raise BotHandlerException(
296                f'Handler for "{context.activity.type}" threw an error',
297                exc,
298                context.activity,
299            ) from exc
300        return None
301
302    async def _dispatch_invoke_async(self, context: TurnContext) -> InvokeResponse:
303        if not self._invoke_handlers:
304            return InvokeResponse(status=200, body={})
305        name = context.activity.name
306        handler = self._invoke_handlers.get(name.lower()) if name else None
307        if handler is None:
308            return InvokeResponse(status=501)
309        try:
310            return await handler(context)
311        except Exception as exc:
312            raise BotHandlerException(
313                f'Invoke handler for "{name}" threw an error',
314                exc,
315                context.activity,
316            ) from exc
317
318    async def _run_pipeline(self, activity: CoreActivity) -> InvokeResponse | None:
319        context = TurnContext(self, activity)
320        index = 0
321        invoke_response: InvokeResponse | None = None
322
323        async def next_fn() -> None:
324            nonlocal index, invoke_response
325            if index < len(self._middlewares):
326                mw = self._middlewares[index]
327                index += 1
328                await mw.on_turn(context, next_fn)
329            else:
330                invoke_response = await self._handle_activity_async(context)
331
332        await next_fn()
333        return invoke_response

Central entry point for building a bot with the Bot Service.

Manages the middleware pipeline, activity handler dispatch, outbound messaging via ConversationClient, and OAuth2 token lifecycle via TokenManager.

Supports async context-manager usage for automatic resource cleanup::

async with BotApplication(options) as bot:
    bot.on("message", my_handler)
    ...

Attributes: version: Library version string. conversation_client: Client for sending outbound activities. on_activity: Optional catch-all handler invoked for every activity type.

BotApplication( options: BotApplicationOptions = BotApplicationOptions(client_id=None, client_secret=None, tenant_id=None, managed_identity_client_id=None, token_factory=None))
117    def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None:
118        """Initialise the bot application.
119
120        Args:
121            options: Configuration for authentication credentials and token
122                acquisition.  Defaults to reading from environment variables.
123        """
124        self._token_manager = TokenManager(options)
125        token_provider = self._token_manager.get_bot_token
126        self.conversation_client = ConversationClient(token_provider)
127        self._middlewares: list[TurnMiddleware] = []
128        self._handlers: dict[str, ActivityHandler] = {}
129        self._invoke_handlers: dict[str, InvokeActivityHandler] = {}
130        self.on_activity: ActivityHandler | None = None

Initialise the bot application.

Args: options: Configuration for authentication credentials and token acquisition. Defaults to reading from environment variables.

version: str = '0.0.0.dev0'
conversation_client
on_activity: Optional[Callable[[TurnContext], Awaitable[NoneType]]]
appid: str | None
132    @property
133    def appid(self) -> str | None:
134        """The bot application/client ID exposed from the token manager."""
135        return self._token_manager.client_id

The bot application/client ID exposed from the token manager.

def on( self, type: str, handler: Optional[Callable[[TurnContext], Awaitable[NoneType]]] = None) -> Any:
137    def on(
138        self,
139        type: str,
140        handler: ActivityHandler | None = None,
141    ) -> Any:
142        """Register a handler for an activity type.
143
144        Only one handler is stored per type; re-registering the same type
145        replaces the previous handler.
146
147        Can be used as a two-argument call or as a decorator::
148
149            bot.on('message', my_handler)
150
151            @bot.on('message')
152            async def my_handler(ctx: TurnContext):
153                await ctx.send("hello")
154
155        Args:
156            type: The activity type to handle (e.g. ``"message"``, ``"typing"``).
157            handler: Async handler function.  If omitted, returns a decorator.
158
159        Returns:
160            The ``BotApplication`` instance when called with a handler, or a
161            decorator function when called without one.
162        """
163        if handler is None:
164
165            def decorator(fn: ActivityHandler) -> ActivityHandler:
166                self._handlers[type.lower()] = fn
167                return fn
168
169            return decorator
170        self._handlers[type.lower()] = handler
171        return self

Register a handler for an activity type.

Only one handler is stored per type; re-registering the same type replaces the previous handler.

Can be used as a two-argument call or as a decorator::

bot.on('message', my_handler)

@bot.on('message')
async def my_handler(ctx: TurnContext):
    await ctx.send("hello")

Args: type: The activity type to handle (e.g. "message", "typing"). handler: Async handler function. If omitted, returns a decorator.

Returns: The BotApplication instance when called with a handler, or a decorator function when called without one.

def use( self, middleware: TurnMiddleware) -> BotApplication:
173    def use(self, middleware: TurnMiddleware) -> "BotApplication":
174        """Register a middleware in the turn pipeline.
175
176        Middleware executes in registration order before handler dispatch.
177        Each middleware receives ``(context, next)`` and must call ``next()``
178        to continue the pipeline, or skip it to short-circuit processing.
179
180        Args:
181            middleware: An object implementing :class:`TurnMiddleware`.
182
183        Returns:
184            The ``BotApplication`` instance for chaining.
185        """
186        self._middlewares.append(middleware)
187        return self

Register a middleware in the turn pipeline.

Middleware executes in registration order before handler dispatch. Each middleware receives (context, next) and must call next() to continue the pipeline, or skip it to short-circuit processing.

Args: middleware: An object implementing TurnMiddleware.

Returns: The BotApplication instance for chaining.

def on_invoke( self, name: str, handler: Optional[Callable[[TurnContext], Awaitable[InvokeResponse]]] = None) -> Any:
189    def on_invoke(
190        self,
191        name: str,
192        handler: InvokeActivityHandler | None = None,
193    ) -> Any:
194        """Register a handler for an invoke activity by its ``activity.name`` sub-type.
195
196        The handler must return an :class:`InvokeResponse`.  Only one handler
197        per name is supported; re-registering the same name replaces the
198        previous handler.
199
200        Can be used as a two-argument call or as a decorator::
201
202            bot.on_invoke("adaptiveCard/action", my_handler)
203
204            @bot.on_invoke("adaptiveCard/action")
205            async def my_handler(ctx): ...
206        """
207        if handler is None:
208
209            def decorator(fn: InvokeActivityHandler) -> InvokeActivityHandler:
210                self._invoke_handlers[name.lower()] = fn
211                return fn
212
213            return decorator
214        self._invoke_handlers[name.lower()] = handler
215        return self

Register a handler for an invoke activity by its activity.name sub-type.

The handler must return an InvokeResponse. Only one handler per name is supported; re-registering the same name replaces the previous handler.

Can be used as a two-argument call or as a decorator::

bot.on_invoke("adaptiveCard/action", my_handler)

@bot.on_invoke("adaptiveCard/action")
async def my_handler(ctx): ...
async def process_body(self, body: str) -> InvokeResponse | None:
217    async def process_body(self, body: str) -> InvokeResponse | None:
218        """Parse and process a raw JSON activity body.
219
220        Deserializes the JSON string into a :class:`CoreActivity`, validates
221        required fields and the ``serviceUrl``, then runs the full middleware
222        pipeline followed by handler dispatch.
223
224        For ``invoke`` activities, returns the :class:`InvokeResponse` produced
225        by the registered handler, a 200 response if no invoke handlers are
226        registered, or a 501 response if handlers exist but none match.
227        Returns ``None`` for all other activity types.
228
229        Args:
230            body: Raw JSON string representing a Bot Service activity.
231
232        Returns:
233            An :class:`InvokeResponse` for invoke activities, or ``None``.
234
235        Raises:
236            ValueError: If the JSON is malformed or required activity fields
237                are missing.
238            BotHandlerException: If the matched handler raises an exception.
239        """
240        try:
241            activity = CoreActivity.model_validate_json(body)
242        except json.JSONDecodeError as exc:
243            raise ValueError("Invalid JSON in request body") from exc
244        _assert_activity(activity)
245        _validate_service_url(activity.service_url)
246        return await self._run_pipeline(activity)

Parse and process a raw JSON activity body.

Deserializes the JSON string into a CoreActivity, validates required fields and the serviceUrl, then runs the full middleware pipeline followed by handler dispatch.

For invoke activities, returns the InvokeResponse produced by the registered handler, a 200 response if no invoke handlers are registered, or a 501 response if handlers exist but none match. Returns None for all other activity types.

Args: body: Raw JSON string representing a Bot Service activity.

Returns: An InvokeResponse for invoke activities, or None.

Raises: ValueError: If the JSON is malformed or required activity fields are missing. BotHandlerException: If the matched handler raises an exception.

async def send_activity_async( self, service_url: str, conversation_id: str, activity: CoreActivity | dict[str, typing.Any]) -> ResourceResponse | None:
248    async def send_activity_async(
249        self,
250        service_url: str,
251        conversation_id: str,
252        activity: CoreActivity | dict[str, Any],
253    ) -> ResourceResponse | None:
254        """Proactively send an activity to a conversation.
255
256        Use this to push messages outside of the normal turn pipeline (e.g.
257        notifications or proactive messages).
258
259        Args:
260            service_url: The channel's service URL.
261            conversation_id: Target conversation identifier.
262            activity: The activity payload to send.
263
264        Returns:
265            A :class:`ResourceResponse` with the new activity ID, or ``None``
266            if the channel does not return one.
267        """
268        return await self.conversation_client.send_activity_async(service_url, conversation_id, activity)

Proactively send an activity to a conversation.

Use this to push messages outside of the normal turn pipeline (e.g. notifications or proactive messages).

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. activity: The activity payload to send.

Returns: A ResourceResponse with the new activity ID, or None if the channel does not return one.

async def aclose(self) -> None:
270    async def aclose(self) -> None:
271        """Close the underlying HTTP client and release resources.
272
273        Should be called during application shutdown.  Alternatively, use the
274        bot as an async context manager to ensure automatic cleanup.
275        """
276        await self.conversation_client.aclose()

Close the underlying HTTP client and release resources.

Should be called during application shutdown. Alternatively, use the bot as an async context manager to ensure automatic cleanup.

@dataclass
class BotApplicationOptions:
16@dataclass
17class BotApplicationOptions:
18    """Configuration options for :class:`BotApplication` authentication.
19
20    All fields are optional; when ``None``, values are read from environment
21    variables (``CLIENT_ID``, ``CLIENT_SECRET``, ``TENANT_ID``,
22    ``MANAGED_IDENTITY_CLIENT_ID``).
23
24    Attributes:
25        client_id: Azure AD application (bot) ID.
26        client_secret: Azure AD client secret.
27        tenant_id: Azure AD tenant ID (defaults to ``"common"``).
28        managed_identity_client_id: Client ID for managed identity auth.
29        token_factory: Custom async callable ``(scope, tenant) -> token``
30            that bypasses MSAL entirely.
31    """
32
33    client_id: str | None = None
34    client_secret: str | None = None
35    tenant_id: str | None = None
36    managed_identity_client_id: str | None = None
37    token_factory: Callable[[str, str], Awaitable[str]] | None = None

Configuration options for BotApplication authentication.

All fields are optional; when None, values are read from environment variables (CLIENT_ID, CLIENT_SECRET, TENANT_ID, MANAGED_IDENTITY_CLIENT_ID).

Attributes: client_id: Azure AD application (bot) ID. client_secret: Azure AD client secret. tenant_id: Azure AD tenant ID (defaults to "common"). managed_identity_client_id: Client ID for managed identity auth. token_factory: Custom async callable (scope, tenant) -> token that bypasses MSAL entirely.

BotApplicationOptions( client_id: str | None = None, client_secret: str | None = None, tenant_id: str | None = None, managed_identity_client_id: str | None = None, token_factory: Optional[Callable[[str, str], Awaitable[str]]] = None)
client_id: str | None = None
client_secret: str | None = None
tenant_id: str | None = None
managed_identity_client_id: str | None = None
token_factory: Optional[Callable[[str, str], Awaitable[str]]] = None
class BotAuthError(builtins.Exception):
31class BotAuthError(Exception):
32    """Raised when inbound JWT validation fails.
33
34    Inspect the message for the specific reason (expired, bad audience, etc.).
35    """
36
37    pass

Raised when inbound JWT validation fails.

Inspect the message for the specific reason (expired, bad audience, etc.).

class BotHandlerException(builtins.Exception):
68class BotHandlerException(Exception):
69    """Wraps an exception thrown inside an activity handler.
70
71    When an activity handler or invoke handler raises, the exception is
72    caught by the pipeline and re-raised as a ``BotHandlerException`` with
73    the original exception attached as ``cause`` and ``__cause__``.
74
75    Attributes:
76        name: Always ``"BotHandlerException"``.
77        cause: The original exception raised by the handler.
78        activity: The activity being processed when the error occurred.
79    """
80
81    def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None:
82        """Initialise a BotHandlerException.
83
84        Args:
85            message: Human-readable description of the failure.
86            cause: The original exception raised by the handler.
87            activity: The activity that was being processed.
88        """
89        super().__init__(message)
90        self.name = "BotHandlerException"
91        self.cause = cause
92        self.activity = activity
93        self.__cause__ = cause

Wraps an exception thrown inside an activity handler.

When an activity handler or invoke handler raises, the exception is caught by the pipeline and re-raised as a BotHandlerException with the original exception attached as cause and __cause__.

Attributes: name: Always "BotHandlerException". cause: The original exception raised by the handler. activity: The activity being processed when the error occurred.

BotHandlerException( message: str, cause: BaseException, activity: CoreActivity)
81    def __init__(self, message: str, cause: BaseException, activity: CoreActivity) -> None:
82        """Initialise a BotHandlerException.
83
84        Args:
85            message: Human-readable description of the failure.
86            cause: The original exception raised by the handler.
87            activity: The activity that was being processed.
88        """
89        super().__init__(message)
90        self.name = "BotHandlerException"
91        self.cause = cause
92        self.activity = activity
93        self.__cause__ = cause

Initialise a BotHandlerException.

Args: message: Human-readable description of the failure. cause: The original exception raised by the handler. activity: The activity that was being processed.

name
cause
activity
class CardAction(botas.suggested_actions._CamelModel):
18class CardAction(_CamelModel):
19    """A clickable action button presented to the user.
20
21    Attributes:
22        type: Action type (``"imBack"``, ``"postBack"``, ``"openUrl"``, etc.).
23        title: Button label displayed to the user.
24        value: Value sent back to the bot when the button is clicked.
25        text: Text sent to the bot (for ``imBack`` actions).
26        display_text: Text displayed in the chat when the button is clicked.
27        image: URL of an icon image for the button.
28    """
29
30    type: str = "imBack"
31    title: str | None = None
32    value: str | None = None
33    text: str | None = None
34    display_text: str | None = None
35    image: str | None = None

A clickable action button presented to the user.

Attributes: type: Action type ("imBack", "postBack", "openUrl", etc.). title: Button label displayed to the user. value: Value sent back to the bot when the button is clicked. text: Text sent to the bot (for imBack actions). display_text: Text displayed in the chat when the button is clicked. image: URL of an icon image for the button.

type: str = 'imBack'
title: str | None = None
value: str | None = None
text: str | None = None
display_text: str | None = None
image: str | None = 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: str | None = None
55    aad_object_id: str | None = None
56    role: str | None = None

Represents a user or bot account on a channel.

Used for the from and recipient fields of an activity.

Attributes: id: Unique account identifier on the channel. name: Display name of the account. aad_object_id: Azure AD object ID (when available). role: Account role ("bot" or "user").

id: str = PydanticUndefined
name: str | None = None
aad_object_id: str | None = None
role: str | None = 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: str | None = None
55    name: str | None = None

Teams channel information.

Attributes: id: Unique channel identifier. name: Display name of the channel.

id: str | None = None
name: str | None = 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:
 45class ConversationClient:
 46    """Typed client for Bot Service Conversation REST API operations.
 47
 48    All methods accept a ``service_url`` and ``conversation_id`` to target
 49    the correct channel endpoint.  Authentication is handled automatically
 50    via the injected :class:`TokenProvider`.
 51    """
 52
 53    def __init__(self, get_token: TokenProvider | None = None) -> None:
 54        """Initialise the conversation client.
 55
 56        Args:
 57            get_token: Async callable that supplies a bearer token.
 58                When ``None``, requests are unauthenticated.
 59        """
 60        self._http = BotHttpClient(get_token)
 61
 62    async def send_activity_async(
 63        self,
 64        service_url: str,
 65        conversation_id: str,
 66        activity: CoreActivity | dict[str, Any],
 67    ) -> ResourceResponse | None:
 68        """Send an activity to a conversation.
 69
 70        Args:
 71            service_url: The channel's service URL.
 72            conversation_id: Target conversation identifier.
 73            activity: Activity payload (model or dict).
 74
 75        Returns:
 76            A :class:`ResourceResponse` with the new activity ID, or ``None``.
 77        """
 78        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities"
 79        data = await self._http.post(
 80            service_url,
 81            endpoint,
 82            _serialize(activity),
 83            BotRequestOptions(operation_description="send activity"),
 84        )
 85        return ResourceResponse.model_validate(data) if data else None
 86
 87    async def update_activity_async(
 88        self,
 89        service_url: str,
 90        conversation_id: str,
 91        activity_id: str,
 92        activity: CoreActivity | dict[str, Any],
 93    ) -> ResourceResponse | None:
 94        """Update an existing activity in a conversation.
 95
 96        Args:
 97            service_url: The channel's service URL.
 98            conversation_id: Conversation containing the activity.
 99            activity_id: ID of the activity to update.
100            activity: Replacement activity payload.
101
102        Returns:
103            A :class:`ResourceResponse`, or ``None``.
104        """
105        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}"
106        data = await self._http.put(
107            service_url,
108            endpoint,
109            _serialize(activity),
110            BotRequestOptions(operation_description="update activity"),
111        )
112        return ResourceResponse.model_validate(data) if data else None
113
114    async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None:
115        """Delete an activity from a conversation.
116
117        Args:
118            service_url: The channel's service URL.
119            conversation_id: Conversation containing the activity.
120            activity_id: ID of the activity to delete.
121        """
122        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}"
123        await self._http.delete(
124            service_url,
125            endpoint,
126            BotRequestOptions(operation_description="delete activity"),
127        )
128
129    async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]:
130        """Retrieve all members of a conversation.
131
132        Args:
133            service_url: The channel's service URL.
134            conversation_id: Target conversation identifier.
135
136        Returns:
137            List of :class:`ChannelAccount` objects for each member.
138        """
139        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members"
140        data = await self._http.get(
141            service_url,
142            endpoint,
143            options=BotRequestOptions(operation_description="get conversation members"),
144        )
145        return [ChannelAccount.model_validate(m) for m in (data or [])]
146
147    async def get_conversation_member_async(
148        self, service_url: str, conversation_id: str, member_id: str
149    ) -> ChannelAccount | None:
150        """Retrieve a single conversation member by ID.
151
152        Args:
153            service_url: The channel's service URL.
154            conversation_id: Target conversation identifier.
155            member_id: The member's account ID.
156
157        Returns:
158            A :class:`ChannelAccount`, or ``None`` if the member is not found.
159        """
160        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}"
161        data = await self._http.get(
162            service_url,
163            endpoint,
164            options=BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True),
165        )
166        return ChannelAccount.model_validate(data) if data else None
167
168    async def get_conversation_paged_members_async(
169        self,
170        service_url: str,
171        conversation_id: str,
172        page_size: int | None = None,
173        continuation_token: str | None = None,
174    ) -> PagedMembersResult:
175        """Retrieve conversation members with server-side pagination.
176
177        Args:
178            service_url: The channel's service URL.
179            conversation_id: Target conversation identifier.
180            page_size: Maximum members per page (channel may enforce its own limit).
181            continuation_token: Opaque token from a previous page to fetch the next.
182
183        Returns:
184            A :class:`PagedMembersResult` containing members and an optional
185            continuation token for the next page.
186        """
187        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers"
188        params = {
189            "pageSize": str(page_size) if page_size else None,
190            "continuationToken": continuation_token,
191        }
192        data = await self._http.get(
193            service_url,
194            endpoint,
195            params=params,
196            options=BotRequestOptions(operation_description="get paged members"),
197        )
198        return PagedMembersResult.model_validate(data) if data else PagedMembersResult()
199
200    async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None:
201        """Remove a member from a conversation.
202
203        Args:
204            service_url: The channel's service URL.
205            conversation_id: Target conversation identifier.
206            member_id: The member's account ID.
207        """
208        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}"
209        await self._http.delete(
210            service_url,
211            endpoint,
212            BotRequestOptions(operation_description="delete conversation member"),
213        )
214
215    async def create_conversation_async(
216        self, service_url: str, parameters: ConversationParameters
217    ) -> ConversationResourceResponse | None:
218        """Create a new conversation on the channel.
219
220        Args:
221            service_url: The channel's service URL.
222            parameters: Conversation creation parameters (members, topic, etc.).
223
224        Returns:
225            A :class:`ConversationResourceResponse` with the new conversation
226            ID and service URL, or ``None``.
227        """
228        data = await self._http.post(
229            service_url,
230            "/v3/conversations",
231            _serialize(parameters),
232            BotRequestOptions(operation_description="create conversation"),
233        )
234        return ConversationResourceResponse.model_validate(data) if data else None
235
236    async def get_conversations_async(
237        self, service_url: str, continuation_token: str | None = None
238    ) -> ConversationsResult:
239        """List conversations the bot has participated in.
240
241        Args:
242            service_url: The channel's service URL.
243            continuation_token: Opaque token from a previous page.
244
245        Returns:
246            A :class:`ConversationsResult` with conversations and an optional
247            continuation token.
248        """
249        params = {"continuationToken": continuation_token}
250        data = await self._http.get(
251            service_url,
252            "/v3/conversations",
253            params=params,
254            options=BotRequestOptions(operation_description="get conversations"),
255        )
256        return ConversationsResult.model_validate(data) if data else ConversationsResult()
257
258    async def send_conversation_history_async(
259        self, service_url: str, conversation_id: str, transcript: Transcript
260    ) -> ResourceResponse | None:
261        """Upload a transcript of activities to a conversation's history.
262
263        Args:
264            service_url: The channel's service URL.
265            conversation_id: Target conversation identifier.
266            transcript: A :class:`Transcript` containing activities to upload.
267
268        Returns:
269            A :class:`ResourceResponse`, or ``None``.
270        """
271        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history"
272        data = await self._http.post(
273            service_url,
274            endpoint,
275            _serialize(transcript),
276            BotRequestOptions(operation_description="send conversation history"),
277        )
278        return ResourceResponse.model_validate(data) if data else None
279
280    async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Conversation | None:
281        """Retrieve the conversation account details.
282
283        Args:
284            service_url: The channel's service URL.
285            conversation_id: Target conversation identifier.
286
287        Returns:
288            A :class:`Conversation` object, or ``None`` if not found.
289        """
290        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}"
291        data = await self._http.get(
292            service_url,
293            endpoint,
294            options=BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True),
295        )
296        return Conversation.model_validate(data) if data else None
297
298    async def aclose(self) -> None:
299        """Close the underlying HTTP client and release resources."""
300        await self._http.aclose()

Typed client for Bot Service Conversation REST API operations.

All methods accept a service_url and conversation_id to target the correct channel endpoint. Authentication is handled automatically via the injected TokenProvider.

ConversationClient(get_token: Optional[Callable[[], Awaitable[str | None]]] = None)
53    def __init__(self, get_token: TokenProvider | None = None) -> None:
54        """Initialise the conversation client.
55
56        Args:
57            get_token: Async callable that supplies a bearer token.
58                When ``None``, requests are unauthenticated.
59        """
60        self._http = BotHttpClient(get_token)

Initialise the conversation client.

Args: get_token: Async callable that supplies a bearer token. When None, requests are unauthenticated.

async def send_activity_async( self, service_url: str, conversation_id: str, activity: CoreActivity | dict[str, typing.Any]) -> ResourceResponse | None:
62    async def send_activity_async(
63        self,
64        service_url: str,
65        conversation_id: str,
66        activity: CoreActivity | dict[str, Any],
67    ) -> ResourceResponse | None:
68        """Send an activity to a conversation.
69
70        Args:
71            service_url: The channel's service URL.
72            conversation_id: Target conversation identifier.
73            activity: Activity payload (model or dict).
74
75        Returns:
76            A :class:`ResourceResponse` with the new activity ID, or ``None``.
77        """
78        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities"
79        data = await self._http.post(
80            service_url,
81            endpoint,
82            _serialize(activity),
83            BotRequestOptions(operation_description="send activity"),
84        )
85        return ResourceResponse.model_validate(data) if data else None

Send an activity to a conversation.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. activity: Activity payload (model or dict).

Returns: A ResourceResponse with the new activity ID, or None.

async def update_activity_async( self, service_url: str, conversation_id: str, activity_id: str, activity: CoreActivity | dict[str, typing.Any]) -> ResourceResponse | None:
 87    async def update_activity_async(
 88        self,
 89        service_url: str,
 90        conversation_id: str,
 91        activity_id: str,
 92        activity: CoreActivity | dict[str, Any],
 93    ) -> ResourceResponse | None:
 94        """Update an existing activity in a conversation.
 95
 96        Args:
 97            service_url: The channel's service URL.
 98            conversation_id: Conversation containing the activity.
 99            activity_id: ID of the activity to update.
100            activity: Replacement activity payload.
101
102        Returns:
103            A :class:`ResourceResponse`, or ``None``.
104        """
105        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}"
106        data = await self._http.put(
107            service_url,
108            endpoint,
109            _serialize(activity),
110            BotRequestOptions(operation_description="update activity"),
111        )
112        return ResourceResponse.model_validate(data) if data else None

Update an existing activity in a conversation.

Args: service_url: The channel's service URL. conversation_id: Conversation containing the activity. activity_id: ID of the activity to update. activity: Replacement activity payload.

Returns: A ResourceResponse, or None.

async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None:
114    async def delete_activity_async(self, service_url: str, conversation_id: str, activity_id: str) -> None:
115        """Delete an activity from a conversation.
116
117        Args:
118            service_url: The channel's service URL.
119            conversation_id: Conversation containing the activity.
120            activity_id: ID of the activity to delete.
121        """
122        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/{_encode_id(activity_id)}"
123        await self._http.delete(
124            service_url,
125            endpoint,
126            BotRequestOptions(operation_description="delete activity"),
127        )

Delete an activity from a conversation.

Args: service_url: The channel's service URL. conversation_id: Conversation containing the activity. activity_id: ID of the activity to delete.

async def get_conversation_members_async( self, service_url: str, conversation_id: str) -> list[ChannelAccount]:
129    async def get_conversation_members_async(self, service_url: str, conversation_id: str) -> list[ChannelAccount]:
130        """Retrieve all members of a conversation.
131
132        Args:
133            service_url: The channel's service URL.
134            conversation_id: Target conversation identifier.
135
136        Returns:
137            List of :class:`ChannelAccount` objects for each member.
138        """
139        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members"
140        data = await self._http.get(
141            service_url,
142            endpoint,
143            options=BotRequestOptions(operation_description="get conversation members"),
144        )
145        return [ChannelAccount.model_validate(m) for m in (data or [])]

Retrieve all members of a conversation.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier.

Returns: List of ChannelAccount objects for each member.

async def get_conversation_member_async( self, service_url: str, conversation_id: str, member_id: str) -> ChannelAccount | None:
147    async def get_conversation_member_async(
148        self, service_url: str, conversation_id: str, member_id: str
149    ) -> ChannelAccount | None:
150        """Retrieve a single conversation member by ID.
151
152        Args:
153            service_url: The channel's service URL.
154            conversation_id: Target conversation identifier.
155            member_id: The member's account ID.
156
157        Returns:
158            A :class:`ChannelAccount`, or ``None`` if the member is not found.
159        """
160        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}"
161        data = await self._http.get(
162            service_url,
163            endpoint,
164            options=BotRequestOptions(operation_description="get conversation member", return_none_on_not_found=True),
165        )
166        return ChannelAccount.model_validate(data) if data else None

Retrieve a single conversation member by ID.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. member_id: The member's account ID.

Returns: A ChannelAccount, or None if the member is not found.

async def get_conversation_paged_members_async( self, service_url: str, conversation_id: str, page_size: int | None = None, continuation_token: str | None = None) -> botas.core_activity.PagedMembersResult:
168    async def get_conversation_paged_members_async(
169        self,
170        service_url: str,
171        conversation_id: str,
172        page_size: int | None = None,
173        continuation_token: str | None = None,
174    ) -> PagedMembersResult:
175        """Retrieve conversation members with server-side pagination.
176
177        Args:
178            service_url: The channel's service URL.
179            conversation_id: Target conversation identifier.
180            page_size: Maximum members per page (channel may enforce its own limit).
181            continuation_token: Opaque token from a previous page to fetch the next.
182
183        Returns:
184            A :class:`PagedMembersResult` containing members and an optional
185            continuation token for the next page.
186        """
187        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/pagedmembers"
188        params = {
189            "pageSize": str(page_size) if page_size else None,
190            "continuationToken": continuation_token,
191        }
192        data = await self._http.get(
193            service_url,
194            endpoint,
195            params=params,
196            options=BotRequestOptions(operation_description="get paged members"),
197        )
198        return PagedMembersResult.model_validate(data) if data else PagedMembersResult()

Retrieve conversation members with server-side pagination.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. page_size: Maximum members per page (channel may enforce its own limit). continuation_token: Opaque token from a previous page to fetch the next.

Returns: A PagedMembersResult containing members and an optional continuation token for the next page.

async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None:
200    async def delete_conversation_member_async(self, service_url: str, conversation_id: str, member_id: str) -> None:
201        """Remove a member from a conversation.
202
203        Args:
204            service_url: The channel's service URL.
205            conversation_id: Target conversation identifier.
206            member_id: The member's account ID.
207        """
208        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/members/{_encode_id(member_id)}"
209        await self._http.delete(
210            service_url,
211            endpoint,
212            BotRequestOptions(operation_description="delete conversation member"),
213        )

Remove a member from a conversation.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. member_id: The member's account ID.

async def create_conversation_async( self, service_url: str, parameters: botas.core_activity.ConversationParameters) -> botas.core_activity.ConversationResourceResponse | None:
215    async def create_conversation_async(
216        self, service_url: str, parameters: ConversationParameters
217    ) -> ConversationResourceResponse | None:
218        """Create a new conversation on the channel.
219
220        Args:
221            service_url: The channel's service URL.
222            parameters: Conversation creation parameters (members, topic, etc.).
223
224        Returns:
225            A :class:`ConversationResourceResponse` with the new conversation
226            ID and service URL, or ``None``.
227        """
228        data = await self._http.post(
229            service_url,
230            "/v3/conversations",
231            _serialize(parameters),
232            BotRequestOptions(operation_description="create conversation"),
233        )
234        return ConversationResourceResponse.model_validate(data) if data else None

Create a new conversation on the channel.

Args: service_url: The channel's service URL. parameters: Conversation creation parameters (members, topic, etc.).

Returns: A ConversationResourceResponse with the new conversation ID and service URL, or None.

async def get_conversations_async( self, service_url: str, continuation_token: str | None = None) -> botas.core_activity.ConversationsResult:
236    async def get_conversations_async(
237        self, service_url: str, continuation_token: str | None = None
238    ) -> ConversationsResult:
239        """List conversations the bot has participated in.
240
241        Args:
242            service_url: The channel's service URL.
243            continuation_token: Opaque token from a previous page.
244
245        Returns:
246            A :class:`ConversationsResult` with conversations and an optional
247            continuation token.
248        """
249        params = {"continuationToken": continuation_token}
250        data = await self._http.get(
251            service_url,
252            "/v3/conversations",
253            params=params,
254            options=BotRequestOptions(operation_description="get conversations"),
255        )
256        return ConversationsResult.model_validate(data) if data else ConversationsResult()

List conversations the bot has participated in.

Args: service_url: The channel's service URL. continuation_token: Opaque token from a previous page.

Returns: A ConversationsResult with conversations and an optional continuation token.

async def send_conversation_history_async( self, service_url: str, conversation_id: str, transcript: botas.core_activity.Transcript) -> ResourceResponse | None:
258    async def send_conversation_history_async(
259        self, service_url: str, conversation_id: str, transcript: Transcript
260    ) -> ResourceResponse | None:
261        """Upload a transcript of activities to a conversation's history.
262
263        Args:
264            service_url: The channel's service URL.
265            conversation_id: Target conversation identifier.
266            transcript: A :class:`Transcript` containing activities to upload.
267
268        Returns:
269            A :class:`ResourceResponse`, or ``None``.
270        """
271        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}/activities/history"
272        data = await self._http.post(
273            service_url,
274            endpoint,
275            _serialize(transcript),
276            BotRequestOptions(operation_description="send conversation history"),
277        )
278        return ResourceResponse.model_validate(data) if data else None

Upload a transcript of activities to a conversation's history.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier. transcript: A Transcript containing activities to upload.

Returns: A ResourceResponse, or None.

async def get_conversation_account_async( self, service_url: str, conversation_id: str) -> Conversation | None:
280    async def get_conversation_account_async(self, service_url: str, conversation_id: str) -> Conversation | None:
281        """Retrieve the conversation account details.
282
283        Args:
284            service_url: The channel's service URL.
285            conversation_id: Target conversation identifier.
286
287        Returns:
288            A :class:`Conversation` object, or ``None`` if not found.
289        """
290        endpoint = f"/v3/conversations/{_encode_conversation_id(conversation_id)}"
291        data = await self._http.get(
292            service_url,
293            endpoint,
294            options=BotRequestOptions(operation_description="get conversation", return_none_on_not_found=True),
295        )
296        return Conversation.model_validate(data) if data else None

Retrieve the conversation account details.

Args: service_url: The channel's service URL. conversation_id: Target conversation identifier.

Returns: A Conversation object, or None if not found.

async def aclose(self) -> None:
298    async def aclose(self) -> None:
299        """Close the underlying HTTP client and release resources."""
300        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        type: Activity type (``"message"``, ``"typing"``, ``"invoke"``, etc.).
124        service_url: Channel service endpoint URL.
125        from_account: Sender's channel account (mapped from JSON ``from``).
126        recipient: Recipient's channel account.
127        conversation: Conversation reference.
128        text: Message text content.
129        name: Sub-type name (used by ``invoke`` activities).
130        value: Payload for ``invoke`` or ``messageReaction`` activities.
131        entities: List of entity metadata (mentions, places, etc.).
132        attachments: List of file or card attachments.
133    """
134
135    type: str
136    service_url: str = ""
137    from_account: ChannelAccount | None = None
138    recipient: ChannelAccount | None = None
139    conversation: Conversation | None = None
140    text: str | None = None
141    name: str | None = None
142    value: Any = None
143    entities: list[Entity] | None = None
144    attachments: list[Attachment] | None = None
145
146    model_config = ConfigDict(
147        alias_generator=to_camel,
148        populate_by_name=True,
149        extra="allow",
150        # 'from' is a Python keyword — remapped via model_validator below
151    )
152
153    @model_validator(mode="before")
154    @classmethod
155    def _remap_from(cls, data: Any) -> Any:
156        if isinstance(data, dict) and "from" in data:
157            data = dict(data)
158            data["from_account"] = data.pop("from")
159        return data
160
161    @classmethod
162    def model_validate_json(cls, json_data: str | bytes, **kwargs: Any) -> "CoreActivity":  # type: ignore[override]
163        """Deserialize a JSON string or bytes into a CoreActivity.
164
165        Handles the ``from`` → ``from_account`` remapping automatically.
166
167        Args:
168            json_data: Raw JSON string or bytes.
169            **kwargs: Additional keyword arguments passed to ``model_validate``.
170
171        Returns:
172            A validated :class:`CoreActivity` instance.
173        """
174        import json
175
176        data = json.loads(json_data)
177        return cls.model_validate(data, **kwargs)
178
179    def model_dump(self, **kwargs: Any) -> dict[str, Any]:  # type: ignore[override]
180        """Serialize to a dict, restoring ``from_account`` back to ``from``.
181
182        Args:
183            **kwargs: Keyword arguments forwarded to Pydantic's ``model_dump``.
184
185        Returns:
186            A JSON-compatible dict with camelCase keys when ``by_alias=True``.
187        """
188        d = super().model_dump(**kwargs)
189        # remap 'from_account' → 'from' in output
190        if "from_account" in d:
191            d["from"] = d.pop("from_account")
192        elif "fromAccount" in d:
193            d["from"] = d.pop("fromAccount")
194        return d

Bot Service activity payload.

Represents an incoming or outgoing message, typing indicator, or other event. Routing fields (from, recipient, conversation, serviceUrl) are automatically populated for outbound messages. Unknown JSON properties are preserved via Pydantic's extra="allow" config (e.g. channelData, membersAdded).

Note: The from JSON field is mapped to from_account because from is a Python reserved keyword. Serialization via model_dump() restores the original from key.

Attributes: type: Activity type ("message", "typing", "invoke", etc.). service_url: Channel service endpoint URL. from_account: Sender's channel account (mapped from JSON from). recipient: Recipient's channel account. conversation: Conversation reference. text: Message text content. name: Sub-type name (used by invoke activities). value: Payload for invoke or messageReaction activities. entities: List of entity metadata (mentions, places, etc.). attachments: List of file or card attachments.

type: str = PydanticUndefined
service_url: str = ''
from_account: ChannelAccount | None = None
recipient: ChannelAccount | None = None
conversation: Conversation | None = None
text: str | None = None
name: str | None = None
value: Any = None
entities: list[Entity] | None = None
attachments: list[Attachment] | None = None
class CoreActivityBuilder:
276class CoreActivityBuilder:
277    """Fluent builder for constructing outbound CoreActivity instances.
278
279    Example::
280
281        activity = (
282            CoreActivityBuilder()
283            .with_conversation_reference(incoming)
284            .with_text("Hello!")
285            .build()
286        )
287    """
288
289    def __init__(self) -> None:
290        """Initialise the builder with default values (type ``"message"``)."""
291        self._type: str = "message"
292        self._service_url: str = ""
293        self._conversation: Conversation | None = None
294        self._from_account: ChannelAccount | None = None
295        self._recipient: ChannelAccount | None = None
296        self._text: str = ""
297        self._entities: list[Entity] | None = None
298        self._attachments: list[Attachment] | None = None
299
300    def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder":
301        """Copy routing fields from an incoming activity and swap from/recipient.
302
303        Args:
304            source: The incoming activity to extract routing from.
305
306        Returns:
307            The builder instance for chaining.
308        """
309        self._service_url = source.service_url
310        self._conversation = source.conversation
311        self._from_account = source.recipient
312        self._recipient = source.from_account
313        return self
314
315    def with_type(self, activity_type: str) -> "CoreActivityBuilder":
316        """Set the activity type (default is ``"message"``).
317
318        Args:
319            activity_type: Activity type string.
320
321        Returns:
322            The builder instance for chaining.
323        """
324        self._type = activity_type
325        return self
326
327    def with_service_url(self, service_url: str) -> "CoreActivityBuilder":
328        """Set the service URL for the channel.
329
330        Args:
331            service_url: Channel service endpoint URL.
332
333        Returns:
334            The builder instance for chaining.
335        """
336        self._service_url = service_url
337        return self
338
339    def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder":
340        """Set the conversation reference.
341
342        Args:
343            conversation: Target conversation.
344
345        Returns:
346            The builder instance for chaining.
347        """
348        self._conversation = conversation
349        return self
350
351    def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder":
352        """Set the sender account.
353
354        Args:
355            from_account: The sender's channel account.
356
357        Returns:
358            The builder instance for chaining.
359        """
360        self._from_account = from_account
361        return self
362
363    def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder":
364        """Set the recipient account.
365
366        Args:
367            recipient: The recipient's channel account.
368
369        Returns:
370            The builder instance for chaining.
371        """
372        self._recipient = recipient
373        return self
374
375    def with_text(self, text: str) -> "CoreActivityBuilder":
376        """Set the text content of the activity.
377
378        Args:
379            text: Message text.
380
381        Returns:
382            The builder instance for chaining.
383        """
384        self._text = text
385        return self
386
387    def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder":
388        """Set the entities list.
389
390        Args:
391            entities: Entity metadata objects.
392
393        Returns:
394            The builder instance for chaining.
395        """
396        self._entities = entities
397        return self
398
399    def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder":
400        """Set the attachments list.
401
402        Args:
403            attachments: Attachment objects.
404
405        Returns:
406            The builder instance for chaining.
407        """
408        self._attachments = attachments
409        return self
410
411    def build(self) -> CoreActivity:
412        """Build a new CoreActivity from the current builder state.
413
414        Returns:
415            A fully constructed :class:`CoreActivity`.
416        """
417        return CoreActivity(
418            type=self._type,
419            service_url=self._service_url,
420            conversation=self._conversation,
421            from_account=self._from_account,
422            recipient=self._recipient,
423            text=self._text,
424            entities=self._entities,
425            attachments=self._attachments,
426        )

Fluent builder for constructing outbound CoreActivity instances.

Example::

activity = (
    CoreActivityBuilder()
    .with_conversation_reference(incoming)
    .with_text("Hello!")
    .build()
)
CoreActivityBuilder()
289    def __init__(self) -> None:
290        """Initialise the builder with default values (type ``"message"``)."""
291        self._type: str = "message"
292        self._service_url: str = ""
293        self._conversation: Conversation | None = None
294        self._from_account: ChannelAccount | None = None
295        self._recipient: ChannelAccount | None = None
296        self._text: str = ""
297        self._entities: list[Entity] | None = None
298        self._attachments: list[Attachment] | None = None

Initialise the builder with default values (type "message").

def with_conversation_reference( self, source: CoreActivity) -> CoreActivityBuilder:
300    def with_conversation_reference(self, source: CoreActivity) -> "CoreActivityBuilder":
301        """Copy routing fields from an incoming activity and swap from/recipient.
302
303        Args:
304            source: The incoming activity to extract routing from.
305
306        Returns:
307            The builder instance for chaining.
308        """
309        self._service_url = source.service_url
310        self._conversation = source.conversation
311        self._from_account = source.recipient
312        self._recipient = source.from_account
313        return self

Copy routing fields from an incoming activity and swap from/recipient.

Args: source: The incoming activity to extract routing from.

Returns: The builder instance for chaining.

def with_type(self, activity_type: str) -> CoreActivityBuilder:
315    def with_type(self, activity_type: str) -> "CoreActivityBuilder":
316        """Set the activity type (default is ``"message"``).
317
318        Args:
319            activity_type: Activity type string.
320
321        Returns:
322            The builder instance for chaining.
323        """
324        self._type = activity_type
325        return self

Set the activity type (default is "message").

Args: activity_type: Activity type string.

Returns: The builder instance for chaining.

def with_service_url(self, service_url: str) -> CoreActivityBuilder:
327    def with_service_url(self, service_url: str) -> "CoreActivityBuilder":
328        """Set the service URL for the channel.
329
330        Args:
331            service_url: Channel service endpoint URL.
332
333        Returns:
334            The builder instance for chaining.
335        """
336        self._service_url = service_url
337        return self

Set the service URL for the channel.

Args: service_url: Channel service endpoint URL.

Returns: The builder instance for chaining.

def with_conversation( self, conversation: Conversation) -> CoreActivityBuilder:
339    def with_conversation(self, conversation: Conversation) -> "CoreActivityBuilder":
340        """Set the conversation reference.
341
342        Args:
343            conversation: Target conversation.
344
345        Returns:
346            The builder instance for chaining.
347        """
348        self._conversation = conversation
349        return self

Set the conversation reference.

Args: conversation: Target conversation.

Returns: The builder instance for chaining.

def with_from( self, from_account: ChannelAccount) -> CoreActivityBuilder:
351    def with_from(self, from_account: ChannelAccount) -> "CoreActivityBuilder":
352        """Set the sender account.
353
354        Args:
355            from_account: The sender's channel account.
356
357        Returns:
358            The builder instance for chaining.
359        """
360        self._from_account = from_account
361        return self

Set the sender account.

Args: from_account: The sender's channel account.

Returns: The builder instance for chaining.

def with_recipient( self, recipient: ChannelAccount) -> CoreActivityBuilder:
363    def with_recipient(self, recipient: ChannelAccount) -> "CoreActivityBuilder":
364        """Set the recipient account.
365
366        Args:
367            recipient: The recipient's channel account.
368
369        Returns:
370            The builder instance for chaining.
371        """
372        self._recipient = recipient
373        return self

Set the recipient account.

Args: recipient: The recipient's channel account.

Returns: The builder instance for chaining.

def with_text(self, text: str) -> CoreActivityBuilder:
375    def with_text(self, text: str) -> "CoreActivityBuilder":
376        """Set the text content of the activity.
377
378        Args:
379            text: Message text.
380
381        Returns:
382            The builder instance for chaining.
383        """
384        self._text = text
385        return self

Set the text content of the activity.

Args: text: Message text.

Returns: The builder instance for chaining.

def with_entities( self, entities: list[Entity]) -> CoreActivityBuilder:
387    def with_entities(self, entities: list[Entity]) -> "CoreActivityBuilder":
388        """Set the entities list.
389
390        Args:
391            entities: Entity metadata objects.
392
393        Returns:
394            The builder instance for chaining.
395        """
396        self._entities = entities
397        return self

Set the entities list.

Args: entities: Entity metadata objects.

Returns: The builder instance for chaining.

def with_attachments( self, attachments: list[Attachment]) -> CoreActivityBuilder:
399    def with_attachments(self, attachments: list[Attachment]) -> "CoreActivityBuilder":
400        """Set the attachments list.
401
402        Args:
403            attachments: Attachment objects.
404
405        Returns:
406            The builder instance for chaining.
407        """
408        self._attachments = attachments
409        return self

Set the attachments list.

Args: attachments: Attachment objects.

Returns: The builder instance for chaining.

def build(self) -> CoreActivity:
411    def build(self) -> CoreActivity:
412        """Build a new CoreActivity from the current builder state.
413
414        Returns:
415            A fully constructed :class:`CoreActivity`.
416        """
417        return CoreActivity(
418            type=self._type,
419            service_url=self._service_url,
420            conversation=self._conversation,
421            from_account=self._from_account,
422            recipient=self._recipient,
423            text=self._text,
424            entities=self._entities,
425            attachments=self._attachments,
426        )

Build a new CoreActivity from the current builder state.

Returns: A fully constructed CoreActivity.

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
@dataclass
class InvokeResponse:
51@dataclass
52class InvokeResponse:
53    """Response returned by an invoke activity handler.
54
55    The ``status`` is written as the HTTP status code; ``body`` is serialized
56    as JSON and included in the response body.
57    """
58
59    status: int
60    """HTTP status code to return to the channel (e.g. 200, 400, 501)."""
61    body: Any = field(default=None)
62    """Optional response body serialized as JSON. Omitted when ``None``."""

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: str | None = None

Teams meeting information.

Attributes: id: Unique meeting identifier.

id: str | None = None
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: bool | None = None

Teams notification settings (e.g., alert flag for mobile push).

Attributes: alert: When True, triggers a mobile push notification.

alert: bool | None = 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):
197class ResourceResponse(_CamelModel):
198    """Response from the channel after sending or updating an activity.
199
200    Attributes:
201        id: The channel-assigned activity identifier.
202    """
203
204    id: str

Response from the channel after sending or updating an activity.

Attributes: id: The channel-assigned activity identifier.

id: str = PydanticUndefined
class SuggestedActions(botas.suggested_actions._CamelModel):
38class SuggestedActions(_CamelModel):
39    """A set of suggested action buttons presented alongside a message.
40
41    Attributes:
42        to: List of channel account IDs the suggestions are targeted at.
43            When ``None``, suggestions are shown to all participants.
44        actions: List of :class:`CardAction` buttons.
45    """
46
47    to: list[str] | None = None
48    actions: list[CardAction] = []

A set of suggested action buttons presented alongside a message.

Attributes: to: List of channel account IDs the suggestions are targeted at. When None, suggestions are shown to all participants. actions: List of CardAction buttons.

to: list[str] | None = 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: TeamsChannelData | None = None
156    timestamp: str | None = None
157    local_timestamp: str | None = None
158    locale: str | None = None
159    local_timezone: str | None = None
160    suggested_actions: SuggestedActions | None = None
161
162    @model_validator(mode="before")
163    @classmethod
164    def _remap_from(cls, data: Any) -> Any:
165        if isinstance(data, dict) and "from" in data:
166            data = dict(data)
167            data["from_account"] = data.pop("from")
168        return data
169
170    @staticmethod
171    def from_activity(activity: CoreActivity) -> "TeamsActivity":
172        """Create a TeamsActivity from a generic CoreActivity.
173
174        Validates and re-parses the activity data to populate Teams-specific
175        fields like ``channel_data``.
176
177        Args:
178            activity: A generic :class:`CoreActivity` to convert.
179
180        Returns:
181            A :class:`TeamsActivity` with Teams fields populated.
182
183        Raises:
184            ValueError: If ``activity`` is ``None``.
185        """
186        if activity is None:
187            raise ValueError("activity is required")
188        data = activity.model_dump()
189        return TeamsActivity.model_validate(data)
190
191    def add_entity(self, entity: Entity) -> None:
192        """Append an entity to the activity's entities collection.
193
194        Args:
195            entity: The entity to add.
196        """
197        if self.entities is None:
198            self.entities = []
199        self.entities.append(entity)
200
201    @staticmethod
202    def create_builder() -> "TeamsActivityBuilder":
203        """Return a new :class:`TeamsActivityBuilder` for fluent construction."""
204        return TeamsActivityBuilder()

Teams-specific activity with strongly-typed channel data and helpers.

Extends CoreActivity with Teams-specific fields like channel_data, suggested_actions, and timestamp fields.

Attributes: channel_data: Teams-specific channel data. timestamp: Server-side UTC timestamp. local_timestamp: Client-side local timestamp. locale: User's locale string (e.g. "en-US"). local_timezone: User's timezone identifier. suggested_actions: Quick-reply buttons.

channel_data: TeamsChannelData | None = None
timestamp: str | None = None
local_timestamp: str | None = None
locale: str | None = None
local_timezone: str | None = None
suggested_actions: SuggestedActions | None = 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: Conversation | None = None
226        self._from_account: ChannelAccount | None = None
227        self._recipient: ChannelAccount | None = None
228        self._text: str = ""
229        self._channel_data: TeamsChannelData | None = None
230        self._suggested_actions: SuggestedActions | None = None
231        self._entities: list[Entity] | None = None
232        self._attachments: list[Attachment] | None = None
233
234    def with_conversation_reference(self, source: CoreActivity) -> "TeamsActivityBuilder":
235        """Copy routing fields from an incoming activity and swap from/recipient.
236
237        Args:
238            source: The incoming activity to extract routing from.
239
240        Returns:
241            The builder instance for chaining.
242        """
243        self._service_url = source.service_url
244        self._conversation = source.conversation
245        self._from_account = source.recipient
246        self._recipient = source.from_account
247        return self
248
249    def with_type(self, activity_type: str) -> "TeamsActivityBuilder":
250        """Set the activity type.
251
252        Args:
253            activity_type: Activity type string.
254
255        Returns:
256            The builder instance for chaining.
257        """
258        self._type = activity_type
259        return self
260
261    def with_service_url(self, service_url: str) -> "TeamsActivityBuilder":
262        """Set the service URL.
263
264        Args:
265            service_url: Channel service endpoint URL.
266
267        Returns:
268            The builder instance for chaining.
269        """
270        self._service_url = service_url
271        return self
272
273    def with_conversation(self, conversation: Conversation) -> "TeamsActivityBuilder":
274        """Set the conversation.
275
276        Args:
277            conversation: Target conversation.
278
279        Returns:
280            The builder instance for chaining.
281        """
282        self._conversation = conversation
283        return self
284
285    def with_from(self, from_account: ChannelAccount) -> "TeamsActivityBuilder":
286        """Set the sender account.
287
288        Args:
289            from_account: The sender's channel account.
290
291        Returns:
292            The builder instance for chaining.
293        """
294        self._from_account = from_account
295        return self
296
297    def with_recipient(self, recipient: ChannelAccount) -> "TeamsActivityBuilder":
298        """Set the recipient account.
299
300        Args:
301            recipient: The recipient's channel account.
302
303        Returns:
304            The builder instance for chaining.
305        """
306        self._recipient = recipient
307        return self
308
309    def with_text(self, text: str) -> "TeamsActivityBuilder":
310        """Set the text content.
311
312        Args:
313            text: Message text.
314
315        Returns:
316            The builder instance for chaining.
317        """
318        self._text = text
319        return self
320
321    def with_channel_data(self, channel_data: TeamsChannelData | None) -> "TeamsActivityBuilder":
322        """Set the Teams-specific channel data.
323
324        Args:
325            channel_data: Channel data payload, or ``None`` to clear.
326
327        Returns:
328            The builder instance for chaining.
329        """
330        self._channel_data = channel_data
331        return self
332
333    def with_suggested_actions(self, suggested_actions: SuggestedActions | None) -> "TeamsActivityBuilder":
334        """Set the suggested actions.
335
336        Args:
337            suggested_actions: Quick-reply buttons, or ``None`` to clear.
338
339        Returns:
340            The builder instance for chaining.
341        """
342        self._suggested_actions = suggested_actions
343        return self
344
345    def with_entities(self, entities: list[Entity] | None) -> "TeamsActivityBuilder":
346        """Replace the entities list.
347
348        Args:
349            entities: Entity metadata objects, or ``None`` to clear.
350
351        Returns:
352            The builder instance for chaining.
353        """
354        self._entities = entities
355        return self
356
357    def with_attachments(self, attachments: list[Attachment] | None) -> "TeamsActivityBuilder":
358        """Replace the attachments list.
359
360        Args:
361            attachments: Attachment objects, or ``None`` to clear.
362
363        Returns:
364            The builder instance for chaining.
365        """
366        self._attachments = attachments
367        return self
368
369    def with_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder":
370        """Set a single attachment (replaces the entire collection).
371
372        Args:
373            attachment: The sole attachment.
374
375        Returns:
376            The builder instance for chaining.
377        """
378        self._attachments = [attachment]
379        return self
380
381    def add_entity(self, entity: Entity) -> "TeamsActivityBuilder":
382        """Append an entity to the collection.
383
384        Args:
385            entity: The entity to add.
386
387        Returns:
388            The builder instance for chaining.
389        """
390        if self._entities is None:
391            self._entities = []
392        self._entities.append(entity)
393        return self
394
395    def add_attachment(self, attachment: Attachment) -> "TeamsActivityBuilder":
396        """Append an attachment to the collection.
397
398        Args:
399            attachment: The attachment to add.
400
401        Returns:
402            The builder instance for chaining.
403        """
404        if self._attachments is None:
405            self._attachments = []
406        self._attachments.append(attachment)
407        return self
408
409    def add_mention(self, account: ChannelAccount, mention_text: str | None = None) -> "TeamsActivityBuilder":
410        """Create a mention entity for a user.  Does NOT modify the activity text.
411
412        You must manually include the mention text in the activity's ``text``
413        field to make it visible in the chat.
414
415        Args:
416            account: The channel account to mention.
417            mention_text: Custom mention markup.  Defaults to
418                ``<at>{account.name}</at>``.
419
420        Returns:
421            The builder instance for chaining.
422
423        Raises:
424            ValueError: If ``account`` is ``None``.
425        """
426        if account is None:
427            raise ValueError("account is required")
428        text = mention_text or f"<at>{account.name}</at>"
429        entity = Entity(type="mention", mentioned=account.model_dump(), text=text)
430        return self.add_entity(entity)
431
432    def add_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder":
433        """Parse and append an Adaptive Card as an attachment.
434
435        Args:
436            card: A JSON string or pre-parsed dict representing the card.
437
438        Returns:
439            The builder instance for chaining.
440        """
441        content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card)
442        attachment = Attachment(
443            content_type="application/vnd.microsoft.card.adaptive",
444            content=content,
445        )
446        return self.add_attachment(attachment)
447
448    def with_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder":
449        """Parse and set an Adaptive Card as the sole attachment.
450
451        Args:
452            card: A JSON string or pre-parsed dict representing the card.
453
454        Returns:
455            The builder instance for chaining.
456        """
457        content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card)
458        attachment = Attachment(
459            content_type="application/vnd.microsoft.card.adaptive",
460            content=content,
461        )
462        return self.with_attachment(attachment)
463
464    def build(self) -> TeamsActivity:
465        """Build a new TeamsActivity from the current builder state.
466
467        Returns:
468            A fully constructed :class:`TeamsActivity`.
469        """
470        return TeamsActivity(
471            type=self._type,
472            service_url=self._service_url,
473            conversation=self._conversation,
474            from_account=self._from_account,
475            recipient=self._recipient,
476            text=self._text,
477            channel_data=self._channel_data,
478            suggested_actions=self._suggested_actions,
479            entities=self._entities,
480            attachments=self._attachments,
481        )

Fluent builder for constructing outbound TeamsActivity instances.

Example::

activity = (
    TeamsActivity.create_builder()
    .with_conversation_reference(incoming)
    .with_text("Hello!")
    .add_mention(user_account)
    .build()
)
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: Conversation | None = None
226        self._from_account: ChannelAccount | None = None
227        self._recipient: ChannelAccount | None = None
228        self._text: str = ""
229        self._channel_data: TeamsChannelData | None = None
230        self._suggested_actions: SuggestedActions | None = None
231        self._entities: list[Entity] | None = None
232        self._attachments: list[Attachment] | None = None

Initialise the builder with default values (type "message").

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: TeamsChannelData | None) -> TeamsActivityBuilder:
321    def with_channel_data(self, channel_data: TeamsChannelData | None) -> "TeamsActivityBuilder":
322        """Set the Teams-specific channel data.
323
324        Args:
325            channel_data: Channel data payload, or ``None`` to clear.
326
327        Returns:
328            The builder instance for chaining.
329        """
330        self._channel_data = channel_data
331        return self

Set the Teams-specific channel data.

Args: channel_data: Channel data payload, or None to clear.

Returns: The builder instance for chaining.

def with_suggested_actions( self, suggested_actions: SuggestedActions | None) -> TeamsActivityBuilder:
333    def with_suggested_actions(self, suggested_actions: SuggestedActions | None) -> "TeamsActivityBuilder":
334        """Set the suggested actions.
335
336        Args:
337            suggested_actions: Quick-reply buttons, or ``None`` to clear.
338
339        Returns:
340            The builder instance for chaining.
341        """
342        self._suggested_actions = suggested_actions
343        return self

Set the suggested actions.

Args: suggested_actions: Quick-reply buttons, or None to clear.

Returns: The builder instance for chaining.

def with_entities( self, entities: list[Entity] | None) -> TeamsActivityBuilder:
345    def with_entities(self, entities: list[Entity] | None) -> "TeamsActivityBuilder":
346        """Replace the entities list.
347
348        Args:
349            entities: Entity metadata objects, or ``None`` to clear.
350
351        Returns:
352            The builder instance for chaining.
353        """
354        self._entities = entities
355        return self

Replace the entities list.

Args: entities: Entity metadata objects, or None to clear.

Returns: The builder instance for chaining.

def with_attachments( self, attachments: list[Attachment] | None) -> TeamsActivityBuilder:
357    def with_attachments(self, attachments: list[Attachment] | None) -> "TeamsActivityBuilder":
358        """Replace the attachments list.
359
360        Args:
361            attachments: Attachment objects, or ``None`` to clear.
362
363        Returns:
364            The builder instance for chaining.
365        """
366        self._attachments = attachments
367        return self

Replace the attachments list.

Args: attachments: Attachment objects, or None to clear.

Returns: The builder instance for chaining.

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: str | None = None) -> TeamsActivityBuilder:
409    def add_mention(self, account: ChannelAccount, mention_text: str | None = None) -> "TeamsActivityBuilder":
410        """Create a mention entity for a user.  Does NOT modify the activity text.
411
412        You must manually include the mention text in the activity's ``text``
413        field to make it visible in the chat.
414
415        Args:
416            account: The channel account to mention.
417            mention_text: Custom mention markup.  Defaults to
418                ``<at>{account.name}</at>``.
419
420        Returns:
421            The builder instance for chaining.
422
423        Raises:
424            ValueError: If ``account`` is ``None``.
425        """
426        if account is None:
427            raise ValueError("account is required")
428        text = mention_text or f"<at>{account.name}</at>"
429        entity = Entity(type="mention", mentioned=account.model_dump(), text=text)
430        return self.add_entity(entity)

Create a mention entity for a user. Does NOT modify the activity text.

You must manually include the mention text in the activity's text field to make it visible in the chat.

Args: account: The channel account to mention. mention_text: Custom mention markup. Defaults to <at>{account.name}</at>.

Returns: The builder instance for chaining.

Raises: ValueError: If account is None.

def add_adaptive_card_attachment(self, card: str | dict) -> TeamsActivityBuilder:
432    def add_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder":
433        """Parse and append an Adaptive Card as an attachment.
434
435        Args:
436            card: A JSON string or pre-parsed dict representing the card.
437
438        Returns:
439            The builder instance for chaining.
440        """
441        content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card)
442        attachment = Attachment(
443            content_type="application/vnd.microsoft.card.adaptive",
444            content=content,
445        )
446        return self.add_attachment(attachment)

Parse and append an Adaptive Card as an attachment.

Args: card: A JSON string or pre-parsed dict representing the card.

Returns: The builder instance for chaining.

def with_adaptive_card_attachment(self, card: str | dict) -> TeamsActivityBuilder:
448    def with_adaptive_card_attachment(self, card: str | dict) -> "TeamsActivityBuilder":
449        """Parse and set an Adaptive Card as the sole attachment.
450
451        Args:
452            card: A JSON string or pre-parsed dict representing the card.
453
454        Returns:
455            The builder instance for chaining.
456        """
457        content = json.loads(card) if isinstance(card, str) else copy.deepcopy(card)
458        attachment = Attachment(
459            content_type="application/vnd.microsoft.card.adaptive",
460            content=content,
461        )
462        return self.with_attachment(attachment)

Parse and set an Adaptive Card as the sole attachment.

Args: card: A JSON string or pre-parsed dict representing the card.

Returns: The builder instance for chaining.

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: str | None = None
68    email: str | None = None

Teams-specific channel account with extended user properties.

Attributes: user_principal_name: The user's UPN (e.g. user@contoso.com). email: The user's email address.

user_principal_name: str | None = None
email: str | None = 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: TenantInfo | None = None
106    channel: ChannelInfo | None = None
107    team: TeamInfo | None = None
108    meeting: MeetingInfo | None = None
109    notification: NotificationInfo | None = None

Teams-specific channel data payload.

Populated in the channelData field of a Teams activity.

Attributes: tenant: Microsoft 365 tenant information. channel: Teams channel details. team: Teams team details. meeting: Meeting metadata (for meeting-scoped activities). notification: Notification settings (e.g. alert flag).

tenant: TenantInfo | None = None
channel: ChannelInfo | None = None
team: TeamInfo | None = None
meeting: MeetingInfo | None = None
notification: NotificationInfo | None = 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: str | None = None
129    tenant_id: str | None = None
130    is_group: bool | None = None
131    name: str | None = None

Teams-specific conversation with extended metadata.

Attributes: conversation_type: Type of conversation ("personal", "channel", "groupChat"). tenant_id: Microsoft 365 tenant ID. is_group: Whether this is a group conversation. name: Display name of the conversation.

conversation_type: str | None = None
tenant_id: str | None = None
is_group: bool | None = None
name: str | None = 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: str | None = None
68    name: str | None = None
69    aad_group_id: str | None = None

Teams team information.

Attributes: id: Unique team identifier. name: Display name of the team. aad_group_id: Azure AD group ID for the team.

id: str | None = None
name: str | None = None
aad_group_id: str | None = 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: str | None = None

Microsoft 365 tenant information.

Attributes: id: The tenant's unique identifier (GUID).

id: str | None = None
class TokenManager:
 43class TokenManager:
 44    """Acquires and caches OAuth2 tokens for outbound Bot Service API calls.
 45
 46    Uses MSAL's ``ConfidentialClientApplication`` for client-credentials flow,
 47    or delegates to a custom ``token_factory`` if provided.
 48    """
 49
 50    def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None:
 51        """Initialise the token manager.
 52
 53        Args:
 54            options: Authentication configuration.  Falls back to environment
 55                variables when individual fields are ``None``.
 56        """
 57        self._client_id = options.client_id or os.environ.get("CLIENT_ID")
 58        self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET")
 59        self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID")
 60        self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get(
 61            "MANAGED_IDENTITY_CLIENT_ID"
 62        )
 63        self._token_factory = options.token_factory
 64        self._msal_app: object | None = None
 65
 66    @property
 67    def client_id(self) -> str | None:
 68        """Returns the configured bot application/client ID."""
 69        return self._client_id
 70
 71    async def get_bot_token(self) -> str | None:
 72        """Acquire a token for the Bot Service API scope.
 73
 74        Returns:
 75            A bearer token string, or ``None`` if credentials are not configured.
 76        """
 77        return await self._get_token(_BOT_FRAMEWORK_SCOPE)
 78
 79    async def _get_token(self, scope: str) -> str | None:
 80        if self._token_factory:
 81            return await self._token_factory(scope, self._tenant_id or "common")
 82
 83        if self._client_id and self._client_secret and self._tenant_id:
 84            # MSAL is synchronous; offload to thread pool to avoid blocking the event loop
 85            return await asyncio.to_thread(self._acquire_client_credentials, scope)
 86
 87        return None
 88
 89    def _acquire_client_credentials(self, scope: str) -> str | None:
 90        import msal  # type: ignore[import-untyped]
 91
 92        if self._msal_app is None:
 93            authority = f"https://login.microsoftonline.com/{self._tenant_id}"
 94            self._msal_app = msal.ConfidentialClientApplication(
 95                self._client_id,
 96                authority=authority,
 97                client_credential=self._client_secret,
 98            )
 99
100        result = self._msal_app.acquire_token_for_client(scopes=[scope])  # type: ignore[union-attr]
101        if result and "access_token" in result:
102            return result["access_token"]
103        return None
104
105    async def aclose(self) -> None:
106        """Close the token manager and reset internal MSAL state.
107
108        Call during application shutdown to release cached credentials.
109        """
110        self._msal_app = None

Acquires and caches OAuth2 tokens for outbound Bot Service API calls.

Uses MSAL's ConfidentialClientApplication for client-credentials flow, or delegates to a custom token_factory if provided.

TokenManager( options: BotApplicationOptions = BotApplicationOptions(client_id=None, client_secret=None, tenant_id=None, managed_identity_client_id=None, token_factory=None))
50    def __init__(self, options: BotApplicationOptions = BotApplicationOptions()) -> None:
51        """Initialise the token manager.
52
53        Args:
54            options: Authentication configuration.  Falls back to environment
55                variables when individual fields are ``None``.
56        """
57        self._client_id = options.client_id or os.environ.get("CLIENT_ID")
58        self._client_secret = options.client_secret or os.environ.get("CLIENT_SECRET")
59        self._tenant_id = options.tenant_id or os.environ.get("TENANT_ID")
60        self._managed_identity_client_id = options.managed_identity_client_id or os.environ.get(
61            "MANAGED_IDENTITY_CLIENT_ID"
62        )
63        self._token_factory = options.token_factory
64        self._msal_app: object | None = None

Initialise the token manager.

Args: options: Authentication configuration. Falls back to environment variables when individual fields are None.

client_id: str | None
66    @property
67    def client_id(self) -> str | None:
68        """Returns the configured bot application/client ID."""
69        return self._client_id

Returns the configured bot application/client ID.

async def get_bot_token(self) -> str | None:
71    async def get_bot_token(self) -> str | None:
72        """Acquire a token for the Bot Service API scope.
73
74        Returns:
75            A bearer token string, or ``None`` if credentials are not configured.
76        """
77        return await self._get_token(_BOT_FRAMEWORK_SCOPE)

Acquire a token for the Bot Service API scope.

Returns: A bearer token string, or None if credentials are not configured.

async def aclose(self) -> None:
105    async def aclose(self) -> None:
106        """Close the token manager and reset internal MSAL state.
107
108        Call during application shutdown to release cached credentials.
109        """
110        self._msal_app = None

Close the token manager and reset internal MSAL state.

Call during application shutdown to release cached credentials.

class TurnContext:
 12class TurnContext:
 13    """Context for a single activity turn, passed to handlers and middleware.
 14
 15    Provides the incoming activity, a reference to the bot application,
 16    and a scoped :meth:`send` method that automatically routes replies
 17    back to the originating conversation.
 18
 19    Example::
 20
 21        @bot.on("message")
 22        async def on_message(ctx: TurnContext):
 23            await ctx.send(f"You said: {ctx.activity.text}")
 24    """
 25
 26    __slots__ = ("activity", "app")
 27
 28    def __init__(self, app: BotApplication, activity: CoreActivity) -> None:
 29        """Initialise the turn context.
 30
 31        Args:
 32            app: The bot application instance processing this turn.
 33            activity: The incoming activity for this turn.
 34        """
 35        self.activity = activity
 36        self.app = app
 37
 38    async def send(
 39        self,
 40        activity_or_text: str | CoreActivity | dict[str, Any],
 41    ) -> ResourceResponse | None:
 42        """Send a reply to the conversation that originated this turn.
 43
 44        Accepts a plain text string (sent as a message activity), a
 45        :class:`CoreActivity`, or a dict for full control over the reply.
 46        Routing fields are automatically populated from the incoming activity.
 47
 48        When passing a dict, you must provide at minimum a ``type`` field.
 49        Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc.
 50        are optional and depend on the activity type.  Routing fields
 51        (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``)
 52        are auto-populated but can be overridden.
 53
 54        Args:
 55            activity_or_text: A plain string, a :class:`CoreActivity`, or a dict.
 56
 57        Returns:
 58            A :class:`ResourceResponse` with the sent activity ID, or ``None``.
 59
 60        Example::
 61
 62            # Simple text reply
 63            await ctx.send("Hello!")
 64
 65            # Dict with custom fields
 66            await ctx.send({
 67                "type": "message",
 68                "text": "Hello!",
 69                "attachments": [...]
 70            })
 71        """
 72        if isinstance(activity_or_text, str):
 73            reply: CoreActivity | dict[str, Any] = (
 74                CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build()
 75            )
 76        elif isinstance(activity_or_text, CoreActivity):
 77            reply = CoreActivityBuilder().with_conversation_reference(self.activity).build()
 78            # Merge: caller fields take precedence
 79            merged = reply.model_dump(by_alias=True, exclude_none=True)
 80            merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True))
 81            reply = merged
 82        else:
 83            base = (
 84                CoreActivityBuilder()
 85                .with_conversation_reference(self.activity)
 86                .build()
 87                .model_dump(by_alias=True, exclude_none=True)
 88            )
 89            base.update(activity_or_text)
 90            reply = base
 91
 92        return await self.app.send_activity_async(
 93            self.activity.service_url,
 94            self.activity.conversation.id,
 95            reply,
 96        )
 97
 98    async def send_typing(self) -> None:
 99        """Send a typing indicator to the conversation.
100
101        Creates a typing activity with routing fields populated from the
102        incoming activity. Typing activities are ephemeral and do not
103        return a ResourceResponse.
104
105        Example::
106
107            @bot.on("message")
108            async def on_message(ctx: TurnContext):
109                await ctx.send_typing()
110                # ... do some work ...
111                await ctx.send("Done!")
112        """
113        typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build()
114        await self.app.send_activity_async(
115            self.activity.service_url,
116            self.activity.conversation.id,
117            typing_activity,
118        )

Context for a single activity turn, passed to handlers and middleware.

Provides the incoming activity, a reference to the bot application, and a scoped send() method that automatically routes replies back to the originating conversation.

Example::

@bot.on("message")
async def on_message(ctx: TurnContext):
    await ctx.send(f"You said: {ctx.activity.text}")
TurnContext( app: BotApplication, activity: CoreActivity)
28    def __init__(self, app: BotApplication, activity: CoreActivity) -> None:
29        """Initialise the turn context.
30
31        Args:
32            app: The bot application instance processing this turn.
33            activity: The incoming activity for this turn.
34        """
35        self.activity = activity
36        self.app = app

Initialise the turn context.

Args: app: The bot application instance processing this turn. activity: The incoming activity for this turn.

activity
app
async def send( self, activity_or_text: str | CoreActivity | dict[str, typing.Any]) -> ResourceResponse | None:
38    async def send(
39        self,
40        activity_or_text: str | CoreActivity | dict[str, Any],
41    ) -> ResourceResponse | None:
42        """Send a reply to the conversation that originated this turn.
43
44        Accepts a plain text string (sent as a message activity), a
45        :class:`CoreActivity`, or a dict for full control over the reply.
46        Routing fields are automatically populated from the incoming activity.
47
48        When passing a dict, you must provide at minimum a ``type`` field.
49        Other fields such as ``text``, ``attachments``, ``suggestedActions``, etc.
50        are optional and depend on the activity type.  Routing fields
51        (``from``, ``recipient``, ``conversation``, ``serviceUrl``, ``channelId``)
52        are auto-populated but can be overridden.
53
54        Args:
55            activity_or_text: A plain string, a :class:`CoreActivity`, or a dict.
56
57        Returns:
58            A :class:`ResourceResponse` with the sent activity ID, or ``None``.
59
60        Example::
61
62            # Simple text reply
63            await ctx.send("Hello!")
64
65            # Dict with custom fields
66            await ctx.send({
67                "type": "message",
68                "text": "Hello!",
69                "attachments": [...]
70            })
71        """
72        if isinstance(activity_or_text, str):
73            reply: CoreActivity | dict[str, Any] = (
74                CoreActivityBuilder().with_conversation_reference(self.activity).with_text(activity_or_text).build()
75            )
76        elif isinstance(activity_or_text, CoreActivity):
77            reply = CoreActivityBuilder().with_conversation_reference(self.activity).build()
78            # Merge: caller fields take precedence
79            merged = reply.model_dump(by_alias=True, exclude_none=True)
80            merged.update(activity_or_text.model_dump(by_alias=True, exclude_none=True))
81            reply = merged
82        else:
83            base = (
84                CoreActivityBuilder()
85                .with_conversation_reference(self.activity)
86                .build()
87                .model_dump(by_alias=True, exclude_none=True)
88            )
89            base.update(activity_or_text)
90            reply = base
91
92        return await self.app.send_activity_async(
93            self.activity.service_url,
94            self.activity.conversation.id,
95            reply,
96        )

Send a reply to the conversation that originated this turn.

Accepts a plain text string (sent as a message activity), a CoreActivity, or a dict for full control over the reply. Routing fields are automatically populated from the incoming activity.

When passing a dict, you must provide at minimum a type field. Other fields such as text, attachments, suggestedActions, etc. are optional and depend on the activity type. Routing fields (from, recipient, conversation, serviceUrl, channelId) are auto-populated but can be overridden.

Args: activity_or_text: A plain string, a CoreActivity, or a dict.

Returns: A ResourceResponse with the sent activity ID, or None.

Example::

# Simple text reply
await ctx.send("Hello!")

# Dict with custom fields
await ctx.send({
    "type": "message",
    "text": "Hello!",
    "attachments": [...]
})
async def send_typing(self) -> None:
 98    async def send_typing(self) -> None:
 99        """Send a typing indicator to the conversation.
100
101        Creates a typing activity with routing fields populated from the
102        incoming activity. Typing activities are ephemeral and do not
103        return a ResourceResponse.
104
105        Example::
106
107            @bot.on("message")
108            async def on_message(ctx: TurnContext):
109                await ctx.send_typing()
110                # ... do some work ...
111                await ctx.send("Done!")
112        """
113        typing_activity = CoreActivityBuilder().with_type("typing").with_conversation_reference(self.activity).build()
114        await self.app.send_activity_async(
115            self.activity.service_url,
116            self.activity.conversation.id,
117            typing_activity,
118        )

Send a typing indicator to the conversation.

Creates a typing activity with routing fields populated from the incoming activity. Typing activities are ephemeral and do not return a ResourceResponse.

Example::

@bot.on("message")
async def on_message(ctx: TurnContext):
    await ctx.send_typing()
    # ... do some work ...
    await ctx.send("Done!")
__version__ = '0.0.0.dev0'
async def validate_bot_token(auth_header: str | None, app_id: str | None = None) -> None:
117async def validate_bot_token(auth_header: str | None, app_id: str | None = None) -> None:
118    """Validate a Bot Service or Entra ID JWT bearer token.
119
120    Supports tokens from both the Bot Service channel service and Azure
121    AD / Entra ID.  The correct OpenID configuration is selected dynamically
122    by inspecting the token's issuer claim (see ``specs/inbound-auth.md``).
123
124    Args:
125        auth_header: The full ``Authorization`` header value
126            (e.g. ``"Bearer eyJ..."``).
127        app_id: Expected audience (bot application / client ID).  Falls back
128            to the ``CLIENT_ID`` environment variable when ``None``.
129
130    Raises:
131        BotAuthError: On any validation failure — missing header, expired
132            token, bad audience, untrusted issuer, or missing JWKS key.
133    """
134    resolved_app_id = app_id or os.environ.get("CLIENT_ID")
135    if not resolved_app_id:
136        raise BotAuthError("CLIENT_ID not configured")
137
138    if not auth_header or not auth_header.startswith("Bearer "):
139        raise BotAuthError("Missing or malformed Authorization header")
140
141    token = auth_header[len("Bearer ") :]
142
143    try:
144        unverified = jwt.get_unverified_header(token)
145    except jwt.exceptions.DecodeError as exc:
146        raise BotAuthError("Invalid token header") from exc
147
148    kid = unverified.get("kid")
149
150    # Peek at claims to determine issuer and select correct JWKS source
151    peeked = _peek_claims(token)
152    iss = peeked["iss"]
153    tid = peeked["tid"]
154    _logger.debug("Token issuer=%s tid=%s aud=%s", iss, tid, peeked["aud"])
155
156    allowed_issuers = _valid_issuers(tid)
157    if not iss or iss not in allowed_issuers:
158        _logger.warning("Token rejected: untrusted issuer %r", iss)
159        raise BotAuthError("Untrusted token issuer")
160
161    metadata_url = _resolve_metadata_url(iss, tid)
162    _logger.debug("Using OpenID metadata: %s", metadata_url)
163
164    if not _is_allowed_metadata_url(metadata_url):
165        _logger.warning("Rejected disallowed metadata URL: %s", metadata_url)
166        raise BotAuthError("Untrusted token issuer")
167
168    keys = await _get_jwks(metadata_url)
169    matching = next((k for k in keys if k.get("kid") == kid), None)
170
171    if matching is None:
172        # Try refreshing JWKS once (key rollover)
173        keys = await _get_jwks(metadata_url, force_refresh=True)
174        matching = next((k for k in keys if k.get("kid") == kid), None)
175
176    if matching is None:
177        raise BotAuthError(f"No JWKS key found for kid={kid!r}")
178
179    public_key = RSAAlgorithm.from_jwk(json.dumps(matching))
180
181    allowed_audiences = [resolved_app_id, f"api://{resolved_app_id}", _BOT_FRAMEWORK_ISSUER]
182
183    try:
184        jwt.decode(
185            token,
186            public_key,  # type: ignore[arg-type]
187            algorithms=["RS256"],
188            audience=allowed_audiences,
189            issuer=allowed_issuers,
190        )
191    except jwt.ExpiredSignatureError as exc:
192        raise BotAuthError("Token has expired") from exc
193    except jwt.InvalidAudienceError as exc:
194        raise BotAuthError("Invalid audience") from exc
195    except jwt.InvalidIssuerError as exc:
196        raise BotAuthError("Invalid issuer") from exc
197    except jwt.PyJWTError as exc:
198        raise BotAuthError(f"Token validation failed: {exc}") from exc
199
200    _logger.debug("Token validated successfully")

Validate a Bot Service or Entra ID JWT bearer token.

Supports tokens from both the Bot Service channel service and Azure AD / Entra ID. The correct OpenID configuration is selected dynamically by inspecting the token's issuer claim (see specs/inbound-auth.md).

Args: auth_header: The full Authorization header value (e.g. "Bearer eyJ..."). app_id: Expected audience (bot application / client ID). Falls back to the CLIENT_ID environment variable when None.

Raises: BotAuthError: On any validation failure — missing header, expired token, bad audience, untrusted issuer, or missing JWKS key.