Skip to content

Commit 5d3732b

Browse files
authored
[PY] feat: TeamsAttachmentDownloader (#1502)
## Linked issues closes: #1331, #1182 ## Details - With reference to JS, added logic to support `TeamsAttachmentDownloader` (incl updates to `Application` , `InputFile` classes) - Added corresponding tests ## Notes - Contents of a file is represented as **bytes** - Our `configuration` object is of type "**Any**" on the BF side (limitation) - Removed `state` property from `InputFileDownloader` (only `context` is needed) - Removed conversion to lowercase for `toChannelFromBotLoginUrl` constant - this ensures the check for US Government Azure can resolve as True - removed unused `AuthenticResult` interface - Associated [BF PR](microsoft/botbuilder-python#2093) that updates the scopes - #1404 may be a bug in Python as well
1 parent 9a55063 commit 5d3732b

File tree

8 files changed

+655
-2
lines changed

8 files changed

+655
-2
lines changed

python/packages/ai/teams/app.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,6 +628,17 @@ async def _on_turn(self, context: TurnContext):
628628
await state.save(context, self._options.storage)
629629
return
630630

631+
# download input files
632+
if (
633+
self._options.file_downloaders is not None
634+
and len(self._options.file_downloaders) > 0
635+
):
636+
input_files = state.temp.input_files if state.temp.input_files is not None else []
637+
for file_downloader in self._options.file_downloaders:
638+
files = await file_downloader.download_files(context)
639+
input_files.append(files)
640+
state.temp.input_files = input_files
641+
631642
# run activity handlers
632643
is_ok, matches = await self._on_activity(context, state)
633644

python/packages/ai/teams/app_options.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77

88
from dataclasses import dataclass, field
99
from logging import Logger
10-
from typing import Optional
10+
from typing import List, Optional
1111

1212
from botbuilder.core import Storage
1313

1414
from .adaptive_cards import AdaptiveCardsOptions
1515
from .ai import AIOptions
16+
from .input_file import InputFileDownloader
1617
from .task_modules import TaskModulesOptions
1718
from .teams_adapter import TeamsAdapter
1819

@@ -77,3 +78,8 @@ class ApplicationOptions:
7778
"""
7879
Optional. Options used to customize the processing of Task Module requests.
7980
"""
81+
82+
file_downloaders: List[InputFileDownloader] = field(default_factory=list)
83+
"""
84+
Optional. Array of input file download plugins to use.
85+
"""

python/packages/ai/teams/input_file.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,11 @@
55

66
from __future__ import annotations
77

8+
from abc import ABC, abstractmethod
89
from dataclasses import dataclass
9-
from typing import Optional
10+
from typing import List, Optional
11+
12+
from botbuilder.core import TurnContext
1013

1114

1215
@dataclass
@@ -22,3 +25,21 @@ class InputFile:
2225
content: bytes
2326
content_type: str
2427
content_url: Optional[str]
28+
29+
30+
class InputFileDownloader(ABC):
31+
"""
32+
A plugin responsible for downloading files relative to the current user's input.
33+
"""
34+
35+
@abstractmethod
36+
async def download_files(self, context: TurnContext) -> List[InputFile]:
37+
"""
38+
Download any files relative to the current user's input.
39+
40+
Args:
41+
context (TurnContext): Context for the current turn of conversation.
42+
43+
Returns:
44+
List[InputFile]: A list of input files.
45+
"""

python/packages/ai/teams/teams_adapter.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from botbuilder.integration.aiohttp import (
1616
CloudAdapter,
1717
ConfigurationBotFrameworkAuthentication,
18+
ConfigurationServiceClientCredentialFactory,
1819
)
1920
from botbuilder.schema import Activity
2021
from botframework.connector import (
@@ -41,6 +42,26 @@ class TeamsAdapter(CloudAdapter, _UserAgent):
4142
and can be hosted in different cloud environments both public and private.
4243
"""
4344

45+
_credentials_factory: ServiceClientCredentialsFactory
46+
"The credentials factory used by the bot adapter to create a [ServiceClientCredentials] object."
47+
_configuration: Any
48+
49+
@property
50+
def credentials_factory(self) -> ServiceClientCredentialsFactory:
51+
"""
52+
The bot's credentials factory.
53+
"""
54+
55+
return self._credentials_factory
56+
57+
@property
58+
def configuration(self) -> Any:
59+
"""
60+
The bot's configuration.
61+
"""
62+
63+
return self._configuration
64+
4465
def __init__(
4566
self,
4667
configuration: Any,
@@ -64,6 +85,13 @@ def __init__(
6485
)
6586
)
6687

88+
self._configuration = configuration
89+
self._credentials_factory = (
90+
cast(ServiceClientCredentialsFactory, credentials_factory)
91+
if credentials_factory
92+
else ConfigurationServiceClientCredentialFactory(configuration)
93+
)
94+
6795
async def process(
6896
self,
6997
request: Request,
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from .teams_attachment_downloader import TeamsAttachmentDownloader
7+
from .teams_attachment_downloader_options import TeamsAttachmentDownloaderOptions
8+
9+
__all__ = ["TeamsAttachmentDownloader", "TeamsAttachmentDownloaderOptions"]
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from typing import List, Optional
9+
10+
import aiohttp
11+
from botbuilder.core import TurnContext
12+
from botbuilder.schema import Attachment
13+
from botframework.connector.auth import AuthenticationConstants, GovernmentConstants
14+
15+
from ..input_file import InputFile, InputFileDownloader
16+
from .teams_attachment_downloader_options import TeamsAttachmentDownloaderOptions
17+
18+
19+
class TeamsAttachmentDownloader(InputFileDownloader):
20+
"""
21+
Downloads attachments from Teams using the bot's access token.
22+
"""
23+
24+
_options: TeamsAttachmentDownloaderOptions
25+
26+
def __init__(self, options: TeamsAttachmentDownloaderOptions):
27+
"""
28+
Creates a new instance of the 'TeamsAttachmentDownloader' class
29+
30+
Args:
31+
options (TeamsAttachmentDownloaderOptions): The options for configuring the class.
32+
"""
33+
self._options = options
34+
35+
async def download_files(self, context: TurnContext) -> List[InputFile]:
36+
"""
37+
Download any files relative to the current user's input.
38+
39+
Args:
40+
context (TurnContext): Context for the current turn of conversation.
41+
42+
Returns:
43+
List[InputFile]: The list of input files
44+
"""
45+
46+
# Filter out HTML attachments
47+
valid_attachments = []
48+
attachments = context.activity.attachments
49+
50+
if attachments is None or len(attachments) == 0:
51+
return []
52+
53+
for attachment in attachments:
54+
if not attachment.content_type.startswith("text/html"):
55+
valid_attachments.append(attachment)
56+
57+
if len(valid_attachments) == 0:
58+
return []
59+
60+
access_token = ""
61+
62+
# If authentication is enabled, get access token
63+
if await self._options.adapter.credentials_factory.is_authentication_disabled() is False:
64+
access_token = await self._get_access_token()
65+
66+
files: List[InputFile] = []
67+
for attachment in valid_attachments:
68+
file = await self._download_file(attachment, access_token)
69+
if file is not None:
70+
files.append(file)
71+
72+
return files
73+
74+
async def _download_file(
75+
self, attachment: Attachment, access_token: str
76+
) -> Optional[InputFile]:
77+
valid_http = attachment.content_url is not None and attachment.content_url.startswith(
78+
"https://"
79+
)
80+
valid_local_host = attachment.content_url is not None and attachment.content_url.startswith(
81+
"http://localhost"
82+
)
83+
84+
if valid_http or valid_local_host:
85+
headers = {}
86+
87+
if len(access_token) > 0:
88+
# Build request for downloading file if access token is available
89+
headers.update({"Authorization": f"Bearer {access_token}"})
90+
91+
async with aiohttp.ClientSession() as session:
92+
async with session.get(attachment.content_url, headers=headers) as response:
93+
content = await response.read()
94+
95+
content_type = attachment.content_type
96+
97+
if content_type == "image/*":
98+
content_type = "image/png"
99+
100+
return InputFile(content, content_type, attachment.content_url)
101+
else:
102+
content = bytes(attachment.content) if attachment.content else bytes()
103+
return InputFile(content, attachment.content_type, attachment.content_url)
104+
105+
async def _get_access_token(self):
106+
# Normalize the ToChannelFromBotLoginUrl (and use a default value when it is undefined).
107+
to_channel_from_bot_login_url_default = (
108+
AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX
109+
+ AuthenticationConstants.DEFAULT_CHANNEL_AUTH_TENANT
110+
)
111+
112+
to_channel_from_bot_login_url = getattr(
113+
self._options.adapter.configuration, "TO_CHANNEL_FROM_BOT_LOGIN_URL", ""
114+
)
115+
116+
if not to_channel_from_bot_login_url:
117+
to_channel_from_bot_login_url = to_channel_from_bot_login_url_default
118+
119+
audience = getattr(
120+
self._options.adapter.configuration, "TO_CHANNEL_FROM_BOT_OAUTH_SCOPE", ""
121+
)
122+
123+
# If there is no loginEndpoint set on the provided
124+
# ConfigurationBotFrameworkAuthenticationOptions, or it starts with
125+
# 'https://login.microsoftonline.com/', the bot is operating in
126+
# Public Azure. So we use the Public Azure audience
127+
# or the specified audience.
128+
if to_channel_from_bot_login_url.startswith(
129+
AuthenticationConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL_PREFIX
130+
):
131+
if not audience:
132+
audience = AuthenticationConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
133+
elif to_channel_from_bot_login_url == GovernmentConstants.TO_CHANNEL_FROM_BOT_LOGIN_URL:
134+
# Or if the bot is operating in US Government Azure, use that audience.
135+
if not audience:
136+
audience = GovernmentConstants.TO_CHANNEL_FROM_BOT_OAUTH_SCOPE
137+
138+
app_creds = await self._options.adapter.credentials_factory.create_credentials(
139+
self._options.bot_app_id, audience, to_channel_from_bot_login_url, True
140+
)
141+
return app_creds.get_access_token()
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""
2+
Copyright (c) Microsoft Corporation. All rights reserved.
3+
Licensed under the MIT License.
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from dataclasses import dataclass
9+
10+
from teams.teams_adapter import TeamsAdapter
11+
12+
13+
@dataclass
14+
class TeamsAttachmentDownloaderOptions:
15+
"""
16+
Options for the `TeamsAttachmentDownloader` class.
17+
"""
18+
19+
bot_app_id: str
20+
"The Microsoft App ID of the bot"
21+
22+
adapter: TeamsAdapter
23+
"ServiceClientCredentialsFactory"

0 commit comments

Comments
 (0)