Month: February 2023

PDF document conversion in a Teams Tab with Microsoft Graph and Teams Toolkit for Visual Studio (C#)

PDF document conversion in a Teams Tab with Microsoft Graph and Teams Toolkit for Visual Studio (C#)

Recently I started to create Microsoft Teams App samples with the Teams Toolkit and Visual Studio using C# as backend programming language for instance. Here is my second rebuilt sample, a PDF conversion on Drag&Drop upload in a Teams Tab.

Content

Setup

First you need to setup your Teams application. You should have Visual studio 2022 in the version 17.3 or above installed. This enables you to install Teams Toolkit in GA version. Then a Teams App, here a Tab, can be created. This was already described in my last post so I won’t repeat it here again.

After the Teams App Dependencies were prepared a basic app registration was created for you. Next there is a need to slightly adjust permissions according to this solution.

Azure App registration

The app registration created is already pre-configured to support the Teams SSO process. The following steps can already be found after “Prepare Teams App Dependencies” (see above):

  • A redirect uri https://localhost/blank-auth-end.html
  • Multi-tenant enabled
  • A client secret
  • An exposed Api “access_as_user” and App ID Uri api://localhost/
  • With the client IDs for Teams App and Teams Web App 1fec8e78-bce4-4aaf-ab1b-5451cc387264 and 5e3ce6c0-2b1f-4285-8d4b-75ee78787346

Additionally for the upload and download operations ReadWrite permissions to OneDrive and SharePoint are needed.

Add necessary Graph delegated app permissions Files.ReadWrite and Sites.ReadWrite.All
Add necessary Graph delegated app permissions

Client-Side

Drag&Drop

The drag&drop handling needs to be enabled for a specific component and also visualized by highlighting the “landing zone”. Therefore, different event receivers are set and implemented in JavaScript.

<div class="dropZone" 
          ondragenter="TabPDF.Drag.enableHighlight(event)"
          ondragleave="TabPDF.Drag.disableHighlight(event)"
          ondragover="TabPDF.Drag.allowDrop(event)"
          ondrop="TabPDF.executeUpload(event)">
      ...
    </div>
TabPDF.Drag = {};
{
    TabPDF.Drag.allowDrop = function (event) {
      event.preventDefault();
      event.stopPropagation();
      event.dataTransfer.dropEffect = 'copy';
    }
    TabPDF.Drag.enableHighlight = function (event) {
      TabPDF.Drag.allowDrop(event);
      const bgDIV = document.getElementsByClassName('dropZone');
      bgDIV[0].classList.add('dropZoneHighlight');
    }
    TabPDF.Drag.disableHighlight = function (event) {
      TabPDF.Drag.allowDrop(event);
      const bgDIV = document.getElementsByClassName('dropZone');
      bgDIV[0].classList.remove('dropZoneHighlight');
    }
}

Progress Indicator

As the conversion process consists of 3 major up-/download operations and may take a while showing a progress indicator to the user is a good and polite idea. Therefore, usage of a FluentUI Blazor component is a suitable idea:

<div style="display: none;" class="loader">
    <FluentProgressRing />
</div>

This is enabled by the following <script /> tag in the root HTML page:

<script type="module" src="https://unpkg.com/@@fluentui/web-components"></script>

In the JavaScript code at the beginning of the upload operation the whole <div/> containing the progress indicator is made visible while it’s turned off again at the very end.

TabPDF.executeUpload = function (event) {
    TabPDF.Drag.allowDrop(event);
    const loaderDIV = document.getElementsByClassName('loader')[0];
    loaderDIV.style.display = 'flex';
    const dt = event.dataTransfer;
    const files = Array.prototype.slice.call(dt.files); 
    files.forEach(fileToUpload => {
      const extensions = fileToUpload.name.split('.');
      const fileExtension = extensions[extensions.length - 1];
      TabPDF.Drag.disableHighlight(event);
      if (TabPDF.Utilities.isFileTypeSupported(fileExtension, 'PDF')) {
        const formData = new FormData();
        formData.append('file', fileToUpload);
        formData.append('Name', fileToUpload.name);
        formData.append('SiteUrl', TabPDF.siteUrl);
        fetch("/api/Upload", {
          method: "post",
          headers: {
            "Authorization": "Bearer " + TabPDF.ssoToken
          },
          body: formData
        })
        .then((response) => {
          response.text().then(resp => {
            TabPDF.addConvertedFile(resp);
            loaderDIV.style.display = 'none';
          });
        });
      }
      else {
        alert('File type not supported!')
      }
    });
}

In between the file and several parameters are collected in a form and with it the backend is called. Authentication is done with a bootstrap token (generation see next).

SSO

(function (TabPDF) {
  ssoToken = "";
  siteUrl = "";
  TabPDF.getSSOToken = function () {
    if (microsoftTeams) {
      microsoftTeams.initialize();
      microsoftTeams.authentication.getAuthToken({
        successCallback: (token, event) => {
          TabPDF.ssoToken = token;
        },
        failureCallback: (error) => {
          renderError(error);
        }
      });
    }
  }
}
}(window.TabPDF = window.TabPDF || {}));

This is a self invoking function that stores itself in a global variable. The TabPDF.getSSOToken function is executed onload of the whole script

<script onload="(function () { TabPDF.getSSOToken(); TabPDF.getContext();}).call(this)" src="/js/TabPDF.js">

The function uses the TeamsJS SDK 2.0 to get an AuthToken. That is, the so called ID or bootstrap token which needs to be exchanged to an access token for getting access to Microsoft Graph. But this happens later server-side. For the moment it is stored in a class variable where it’s taken from “on action” when a file is dropped.

Additionally TeamsJS SDK 2.0 provides either the SharePoint siteUrl for the current Teams’ site (if Tab is running as a Configurable inside a team) or the SharePoint host url (if Tab is running as static or personal one). That siteUrl will later be used as the target siteUrl for final upload of the converted PDF.

TabPDF.getContext = function () {
    if (microsoftTeams) {
      microsoftTeams.app.getContext()
        .then(context => {
          if (context.sharePointSite.teamSiteUrl !== "") {
            TabPDF.siteUrl = context.sharePointSite.teamSiteUrl;
          }
          else {
            TabPDF.siteUrl = "https://" + context.sharePointSite.teamSiteDomain;
          }
        });
    }
  }

Alternatively, a dedicated site url could be stored with the configuration option. This is another story and for Teams Toolkit and Visual Studio I will handle later.

Backend Graph Controller

Authentication / SSO

In the call above the bootstrap token was used for authentication with “Bearer “. Any backend controller in our solution now simply gets added UI authentication and additionally a Microsoft Graph client established by automatic use of the on-behalf flow. This is done in the program.cs

builder.Services
    // Use Web API authentication (default JWT bearer token scheme)
    .AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
    // Enable token acquisition via on-behalf-of flow
    .EnableTokenAcquisitionToCallDownstreamApi()
    // Add authenticated Graph client via dependency injection
    .AddMicrosoftGraph(builder.Configuration.GetSection("Graph"))
    // Use in-memory token cache
    // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization
    .AddInMemoryTokenCaches();

Token acquisition 8not needed here, but…) but also the Microsoft Graph SDK client they can then directly be used inside our controller:

[Route("api/[controller]")]
[ApiController]
public class UploadController : ControllerBase
{
    private readonly GraphServiceClient _graphClient;
    private readonly ITokenAcquisition _tokenAcquisition;
    private readonly ILogger<UploadController> _logger;
    public UploadController(ITokenAcquisition tokenAcquisition, GraphServiceClient graphClient, ILogger<UploadController> logger)
    {
        _tokenAcquisition = tokenAcquisition;
        _graphClient = graphClient;
        _logger = logger;
    }
...

Temporary upload to OneDrive

The backend operation first takes the file and the other parameters, and uploads the file directly to OneDrive for a temporary purpos.

public async Task<ActionResult<string>> Post([FromForm] UploadRequest fileUpload)
{
    string userID = User.GetObjectId(); 
    DriveItem uploadResult = await this._graphClient.Users[userID]
                                            .Drive.Root
                                            .ItemWithPath(fileUpload.file.FileName)
                                            .Content.Request()
                                            .PutAsync<DriveItem>(fileUpload.file.OpenReadStream());

Download with PDF conversion

Having a file uploaded to OneDrive, and received the driveItemID, it is quite simple to download it once again, but this time with the format conversion to PDF. Therefore, “?format=PDF” is attached to the request, here with the Microsoft Graph SDK as QueryOption

private async Task<Stream> GetPDF(string userID, string itemID)
{
    var queryOptions = new List<QueryOption>()
    {
        new QueryOption("format", "PDF")
    };
    Stream pdfResult = await this._graphClient.Users[userID]
                                            .Drive.Items[itemID]
                                            .Content
                                            .Request(queryOptions)
                                            .GetAsync();
    return pdfResult;
}

Final upload to target

Finally, the PDF converted Stream needs to be uploaded to the target siteUrl (definition depending on current context, see above). First, a new pdfFileName needs to be constructed by exchanging former file extension with .pdf

The upload itself is easy (omitting UploadSession and big file sizes here). After evaluating the siteId based on the given siteUrl the upload itself can be executed and the webUrl of the new DriveItem can be returned for display.

private async Task<string> UploadPDF(string userID, string orgFileName, Stream fileStream, string siteUrl)
{
    string pdfFileName = Path.GetFileNameWithoutExtension(orgFileName);
    pdfFileName += ".pdf";
    string siteId = await EvaluateSiteID(siteUrl);
    DriveItem uploadResult = await this._graphClient.Sites[siteId]
                                                    .Drive.Root
                                                    .ItemWithPath(pdfFileName)
                                                    .Content.Request()
                                                    .PutAsync<DriveItem>(fileStream);
    return uploadResult.WebUrl;
}
private async Task<string> EvaluateSiteID(string siteUrl)
{
    Uri siteUri = new Uri(siteUrl);
    Site site = await this._graphClient.Sites.GetByPath(siteUri.PathAndQuery, siteUri.Host).Request().GetAsync();
    return site.Id;
}

Delete temporary file

Finally to clean up resources the temporary first upload file to OneDrive can be deleted. That’s quite easy because we still have the driveItem ID.

private async Task DeleteTempFile(string userID, string itemID)
{
       await this._graphClient.Users[userID]
                    .Drive.Items[itemID]
                    .Request()
                    .DeleteAsync();
}

Finally, the app in action looks like the following:

 The whole upload and PDF conversion process running​
The whole upload and PDF conversion process running

I hope I explained the essential topics drag&drop in HTML5, Teams SSO, Microsoft Graph file upload and PDF conversion in an understandable way. Also, you understood once again, the benefit of such a backend solution, when having the need for a chain of upload and download operations. In SPFx I once upon a time realized a similar solution, but that is not the best idea. Nevertheless for your reference, the whole solution is available in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
Use Teams Toolkit and Visual Studio (C#) to create a Teams Tab using Microsoft Graph Toolkit

Use Teams Toolkit and Visual Studio (C#) to create a Teams Tab using Microsoft Graph Toolkit

Although I’m a great fan of the Yeoman generator for teams (and developing NodeJS based) I now started to dig in the usage of Visual Studio and the Teams toolkit. My first attempt is recreating a simple Teams Tab that made usage of the Microsoft Graph toolkit and SSO with the most recent Teams MSAL2 Provider.

As a good starter I used Build a productivity dashboard with Microsoft Teams Toolkit for Visual Studio from the wonderful Ayca Bas. But against that I made use of the more modern Teams MSAL2 Provider. This additionally led me to the need to generate a backend API controller (which will be useful for further SSO scenarios as well, stay tuned) for access token generation.

Content

Setup

First you need to setup your Teams application. You should have Visual studio 2022 in the version 17.3 or above installed. This enables you to install Teams Toolkit in GA version.

Having that, it’s time to create a new project:

Create new Visual Studio project
Create new Visual Studio project
Pick Microsoft Teams App template

As application type a Tab including SSO is the matter of choice:

Tab picked as application type
Tab picked as application type

Next the Teams App Dependencies should be prepared.

Prepare Teams App Dependencies
Prepare Teams App Dependencies

First there is a need to enter M365 tenant App credentials. Then Visual Studio already prepares an app registration and configures this accordingly. Especially in Dev environments this can be a big timesaver. But for staging and production scenarios more manual or enterprise automated processes are needed of course.

App registration and permissions

As said above Visual Studio already prepared an app registration for us. The only thing that cannot be guessed upfront are the solution specific app permissions. Here the following need to be added. But first seek for the generated app registration which is derived from the solution’s name.

Seek the solution’s app registration
Seek the solution’s app registration
Add necessary Graph delegated app permissions
Add necessary Graph delegated app permissions

TeamsMsal2Provider for SSO

Using Microsoft Graph Toolkit components in a solution is quite easy. Only place <mgt-... /> components in your HTML source files like any other HTML standard components. Additionally you only need to establish an authentication provider that handles authentication and authorization for the components’ requests. In case of a Microsoft Teams app the most recent provider Teams MSAL2 is the best choice. Therefore the following JS code should be placed in the root HTML (_Host.cshtml):

@using Microsoft.Extensions.Configuration
@inject IConfiguration Configuration
 <script>
    mgt.Providers.globalProvider = new mgt.TeamsMsal2Provider({
        clientId: "@Configuration["TeamsFx:Authentication:ClientId"]",
        authPopupUrl: '/auth-teams-sso.html',
        scopes: ["User.Read", "User.ReadBasic.All", "People.Read"],
        ssoUrl: "/api/Token",
        httpMethod: "POST"
    });
</script>

Client-side that’s all we need. Most important are the clientId retrieved from configuration and the ssoUrl that points to the backend service responsible to generate the access token.

Implement backend Controller for on-behalf flow

To implement the backend controller handling authentication tokens at first the following lines need to be added to the initial Program.cs

builder.Services
    // Use Web API authentication (default JWT bearer token scheme)
    .AddMicrosoftIdentityWebApiAuthenticationbuilder.Configuration, "TeamsFx:Authentication")
    // Enable token acquisition via on-behalf-of flow
    .EnableTokenAcquisitionToCallDownstreamApi()
    // Add authenticated Graph client via dependency injection
    .AddMicrosoftGraph(builder.Configuration.GetSection("Graph"))
    // Use in-memory token cache
    // See https://github.com/AzureAD/microsoft-identity-web/wiki/token-cache-serialization
    .AddInMemoryTokenCaches();

This adds authentication behavior for the UI requests as well as for Microsoft Graph for instance. Next is the TokenController itself:

[Route("api/[controller]")]
[ApiController]
public class TokenController : ControllerBase
{
        private readonly ITokenAcquisition _tokenAcquisition;
        private readonly ILogger<TokenController> _logger;
        public TokenController(ITokenAcquisition tokenAcquisition, ILogger<TokenController> logger)
        {
            _tokenAcquisition = tokenAcquisition;
            _logger = logger;
        }
        [HttpPost]
        public async Task<ActionResult<string>> Post()
        {           
            try
            {
                // Get a Graph token via O-B-O flow
                var token = await _tokenAcquisition
                    .GetAccessTokenForUserAsync(new[]{
                        "User.Read", "User.ReadBasic.All", "People.Read" });
                var response = new { access_token = token };
                return new JsonResult(response);
            }
            catch (MicrosoftIdentityWebChallengeUserException ex)
            {
                _logger.LogError(ex, "Consent required");
                ...
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error occurred");
                throw;
            }
        }
}

With that simple setup there is a need to add some more lines to the appsettings.json

{
  "Graph": {
    "Scopes": "https://graph.microsoft.com/.default"
  },
....
  "TeamsFx": {
    "Authentication": {
      "ClientId": "$clientId$",
      "ClientSecret": "$client-secret$",
      "OAuthAuthority": "$oauthAuthority$",
      "TenantId": "common",
      "OAuthAuthority": "$oauthAuthority$"
    }
  }
}

While the “TeamsFx” part is there from project setup, the “Graph” part is added because of the backend service. “TeamsFx:Authentication” is partially re-used but partially needs to be extended as per program.cs (see above). Especially in enterprise or production scenarios IDs or at least secrets (!!!) should be stored and handled by Azure Key Vault or similar.

SSO user consent

In case you do not grant user permissions tenant-wide in the TokenController an exception is thrown and sent back to the client the following way a user has not yet consented (to simulate again and again look here).

catch (MicrosoftIdentityWebChallengeUserException ex)
{
    _logger.LogError(ex, "Consent required");
    // This exception indicates consent is required.
    var response = new { error = "consent_required" };
    return new JsonResult(response)
    {
          StatusCode = (int)HttpStatusCode.Forbidden                    
    }; 
}

In that case TeamsMsal2Provider opens authPopupUrl: '/auth-teams-sso.html' which will request the user to give consent by the following code inside the html:

<html>
<head>
  ...
  <script src="https://unpkg.com/@microsoft/mgt@2/dist/bundle/mgt-loader.js"></script>
  <script>
    mgt.TeamsMsal2Provider.handleAuth();
  </script>
</head>
<body>
</body>
</html>

And that will enforce following popup (ensure to ALLOW popups ☝️) and after consenting the mgt components should successfully render.

Request to consent user permissions
Request to consent user permissions

Client-side

Client-side the Tab component finally looks quite simple as promised above.

@page "/"
@page "/tab"
@using TabMGTPerson.Components;
<div>
   <div class="mgtDiv">
    <mgt-person person-query="me" view="twoLines"></mgt-person>
  </div>
  <div class="mgtDiv">
    <mgt-people show-max="5"></mgt-people>
  </div>
</div>

Two mgt components with simple attributes surrounded by some <div> elements, nothing else. And the best is, it works:

The result: A Person followed by a People Microsoft Graph Toolkit component
The result: A Person followed by a People component

As always, the whole solution for your reference is available in my GitHub repository.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.
SharePoint document review in Outlook or Teams

SharePoint document review in Outlook or Teams

Recently I already wrote about Microsoft 365 across applications that can be run in either Teams, Office (Microsoft 365) or Outlook. Since version 1.16 now it is also possible to develop and host those applications in SharePoint with SharePoint Framework (SPFx). So let’s dive into another sample application and see how this can be realized with either a Teams dev application, or based on SharePoint Framework (SPFx). Document generation is done with both technologies. Now let’s add some further lifecycle steps such as review and publish with search-based messaging extensions realized within the existing Teams dev application.

At the time of writing this feature is in Developer preview only. So it’s not supported for productional use, might be subject to change AND needs the Teams client to be enabled in “Developer preview” (even or especially to use in Outlook)

Search-based messaging extension to pick a document and post it as adaptive card to current context
Search-based messaging extension to pick a document and post it as adaptive card to current context

Series

Content

Setup

I already wrote about the setup of a search-based messaging extension and SSO but let’s repeat it here again as it’s slightly different, especially targeting Microsoft 365 and not only Teams now.

First there is a need to create an Azure Bot. The Bot needs three channels enabled: Teams, Outlook and Microsoft 365 Extensions:

Bot with three channels enabled: Teams, Outlook and Microsoft 365 Extensions
Bot with three channels enabled: Teams, Outlook and Microsoft 365 Extensions

Next in “Configuration” the messaging endpoint needs to be constructed with the app host / ngrok url and /api/messages.

Azure Bot configuration with messaging endpoint and OAuth connection
Azure Bot configuration with messaging endpoint and OAuth connection

Next in the Azure Bot configuration the Microsoft App registration needs to be configured. “Manage password” (takes you to the Secrets tab of the app registration) needs to be clicked to get there.

The App configuration needs a specific redirect url to cooperate with the bot framework on authentication:

Add redirect URI to app registration
Add redirect URI to app registration

A secret needs to be generated and noted down for two following steps. Under “Expose API” an App URI together with scope and several client IDs needs to be configured:

Expose API with scope and client IDs​
Expose API with scope and client IDs

The exact client IDs are:

Teams desktop, mobile1fec8e78-bce4-4aaf-ab1b-5451cc387264
Teams web5e3ce6c0-2b1f-4285-8d4b-75ee78787346
Microsoft 365 web4765445b-32c6-49b0-83e6-1d93765276ca
Microsoft 365 desktop0ec893e0-5785-4de6-99da-4ed124e5296c
Microsoft 365 mobiled3590ed6-52b3-4102-aeff-aad2292ab01c
Outlook desktop, mobiled3590ed6-52b3-4102-aeff-aad2292ab01c
Outlook webbc59ab01-8403-45c6-8796-ac3ef710b3e3
Outlook web00000002-0000-0ff1-ce00-000000000000
Microsoft 365 client IDs

For search-based messaging extensions Microsoft 365 app client IDs are not necessary to configure as this is not a valid option to call messaging extensions.

For access with Microsoft Graph permissions need to be granted. Against my previous posts in this series I am using Microsoft Graph here. It’s another app registration and the specific scenario (search and update list items) can be perfectly achieved with Microsoft Graph. There is one small exception only but will mention this later.

Graph permissions needed (Sites.ReadWrite.All)
Graph permissions needed

Having that, the OAuth Configuration of the Bot needs to be adjusted finally. Therefore, the client ID and secret, the app uri as Token Exchange URL, the tenant ID and the default scope of Microsoft Graph are needed. As service provider chose Azure Active Directory V2.

OAuth connection configuration for Bot’s access to Microsoft Graph​
OAuth connection configuration for Bot’s access to Microsoft Graph

Last not least the following configuration values need to be added to the env configuration (while of course I recommend to put sensitive things like an app secret to more secure and robust resources such as Azure Key Vault ☝🏻)

# App Id and App Password for the Bot Framework bot

MICROSOFT_APP_ID=
MICROSOFT_APP_PASSWORD=
# OAuth Connection name in Bot configuration
ConnectionName=GraphConnection
# Site where your offerings are stored
SiteUrl=https://your-tenant.sharepoint.com/sites/Offerings

Manifest

Last not least in the manifest two messaging extensions need to be referenced. So user can switch between selecting documents for “Review” or to “Publish” (see screenshot at the top of the post):

"composeExtensions": [
    {
      "botId": "{{MICROSOFT_APP_ID}}",
      "canUpdateConfiguration": true,
      "commands": [
        {
          "id": "offerReviewYoteamsMessageExtension",
          "title": "Offer Review (yoteams)",
          "description": "Reviews an offer",
          "initialRun": true,
          "parameters": [
            {
              "name": "parameter",
              "description": "Search for Offer documents",
              "title": "Parameter"
            }
          ],
          "type": "query"
        },
        {
          "id": "offerPublishYoteamsMessageExtension",
          "title": "Offer Publish (yoteams)",
          "description": "Publishes an offer",
          "initialRun": true,
          "parameters": [
            {
              "name": "parameter",
              "description": "Search for Offer documents",
              "title": "Parameter"
            }
          ],
          "type": "query"
        }
      ]
    }
  ]

Most important: Both refer to the same botId and the bot can detect from the different commandIDs from where the request is coming.

SSO implementation

Implementing SSO for search-based messaging extensions I already illustrated a while ago. Since then, nothing fundamentally changed. Only one point in the past I was struggling with and now works. Basically, a token needs to be generated on behalf of the current user with the help of the configured Bot’s OAuth (see above) authentication.

When the Bot is reached, for instance with a search query request, an attempt is made to generate an on-behalf user token. Three things are needed: The Bot’s OAuth connection name, the context and a “so called” magicCode from the request. If the user did not sign in to the Bot, yet, a sign-in request url is sent back to the user instead (now the type=auth works pretty well). Otherwise an access token is available and the Graph API request(s) can begin.

public async onQuery(context: TurnContext, query: MessagingExtensionQuery): Promise<MessagingExtensionResult> {
    const adapter: any = context.adapter;
    const magicCode = (query.state && Number.isInteger(Number(query.state))) ? query.state : '';        
    const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);
    if (!tokenResponse || !tokenResponse.token) {
      // There is no token, so the user has not signed in yet.
      // Retrieve the OAuth Sign in Link to use in the MessagingExtensionResult Suggested Actions
      const signInLink = await adapter.getSignInLink(context, this.connectionName);
      let composeExtension: MessagingExtensionResult = {
        type: 'auth',
        suggestedActions: {
          actions: [{
            title: 'Sign in as user',
            value: signInLink,
            type: ActionTypes.OpenUrl
          }]
        }
      };
      return Promise.resolve(composeExtension);
    }
// If this point is reached, there is a token and the access to Microsoft Graph can start (see next)
Request to Sign-In for the user by the Bot
Request to Sign-In for the user (in case no token could be generated)

The files are retrieved with a Microsoft Graph search request for a driveItem and our specific ContentTypeID. Following is the code that happens in the query function after a token was retrieved successfully (see above).

public async onQuery(context: TurnContext, query: MessagingExtensionQuery): Promise<MessagingExtensionResult> {
    const attachments: MessagingExtensionAttachment[] = [];
    ...
    const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);
   ...
    let memberIDs: string[] = [];
    const memberResponse = await TeamsInfo.getPagedMembers(context, 60, '');      
    memberResponse.members.forEach((m) => {
      memberIDs.push(m.id!);
    });
    if (query.commandId === 'offerReviewYoteamsMessageExtension') {
      let documents: IOfferDocument[] = [];
      if (query.parameters && query.parameters[0] && query.parameters[0].name === "initialRun") {
        const graphService = new GraphSearchService();
        documents = await graphService.getFiles(tokenResponse.token);        
      }
      documents.forEach((doc) => {
        const card = CardFactory.adaptiveCard(CardService.reviewCardUA(doc, memberIDs));
        const preview = {
          contentType: "application/vnd.microsoft.card.thumbnail",
          content: {
            title: doc.name,
            text: doc.description,
            images: [
              {
                url: `https://${process.env.PUBLIC_HOSTNAME}/assets/icon.png`
              }
            ]             
          }
        };
        attachments.push({ contentType: card.contentType, content: card.content, preview: preview });
      });
    }
   ...
    return Promise.resolve({
      type: "result",
      attachmentLayout: "list",
      attachments: attachments
    } as MessagingExtensionResult);
  }

After having the token and some memberIDs are grabbed (see later) two checks are done. First the check for the right commandID and 2nd the check if it’s the initialRun (it’s also possible to search for specific documents but we will skip this here as this is only a different search query. The rest of the functionality stays the same). Now the implemented GraphSearchService can retrieve the files. The result is then iterated and transformed to preview and AdaptiveCards. Both are finally returned as a MessagingExtensionResult. Next let’s have a look how to get the files with Microsoft Graph.

public async getFiles(token: string, query: string): Promise<IOfferDocument[]> {
    let queryString = 'ContentTypeID:0x0101003656A003937692408E62ADAA56A5AEEF*';
    if (query !== "")  {
      queryString += ` AND ${query}`;
    }
    const searchResponse = {
      requests: [
        { entityTypes: ['driveItem'],
          query: {
            queryString: queryString
          }
        }
      ]};
    const requestUrl: string = `https://graph.microsoft.com/v1.0/search/microsoft.graph.query`;
    return Axios.post(requestUrl,
      searchResponse,
      {
        headers: {          
          Authorization: `Bearer ${token}`
      }})
      .then(response => {
        let docs: IOfferDocument[] = [];
        response.data.value[0].hitsContainers[0].hits.forEach(element => {
          docs.push({
            name: element.resource.name,
            description: element.summary,
            author: element.resource.createdBy.user.displayName,
            url: element.resource.webUrl,
            id: element.resource.parentReference.sharepointIds.listItemId,
            modified: new Date(element.resource.lastModifiedDateTime)
          });
        });
        return docs;
      })
      .catch(err => {
        log(err);
        return [];
      });
}

With a valid access token it is possible to search for a driveItem item with a given ContentTypeID. The request is done as a POST request against the Microsoft graph search endpoint. Having a response it can be iterated and transformed to our given model and finally returned to the requestor. Before an eventual custom search query is added to the request (if not the initialRun)

Update Item (Graph)

On click on the Adaptive Card’s “Reviewed” button an Action.Execute towards the Bot is fired. As there might be several ones, the context.activity.value.action.verb helps to decide what needs to be done. After another SSO token generation and extracting the corresponding doc another Microsoft Graph operation needs to be called that simply updates the doc’s listItem with the current user as reviewer and the current date as review date. Last not least depending on the result a new adaptive card is created and returned.

public async onActionExecute(context: TurnContext): Promise<AdaptiveCardResponseBody> {
    const doc: IOfferDocument = context.activity.value.action.data.doc as IOfferDocument;
    ...
    const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);
    ...
    // Get user's Email from the token (as the context.activity only offers display name)
    const decoded: { [key: string]: any; } = jwtDecode(tokenResponse.token) as { [key: string]: any; };
    const graphService = new GraphSearchService();
    switch (context.activity.value.action.verb) {
      case 'review':
       doc = await graphService.reviewItem(tokenResponse.token, doc.id, decoded.upn!, context.activity.from.name);        
        if (doc.reviewer !== "") {
          card = CardService.reviewedCardUA(doc);
        }
        else {
          card = CardService.reviewCardUA(doc, context.activity.value.action.data.userIds);
        }
        break;
    ....
}

The Microsoft Graph operation itself first needs to detect the corresponding list item of the given file. Next there is a need for the user lookup ID in the site’s user information list (see below). Having that the review date and reviewer can be updated in the given list item with the PATCH operation. Finally the updated listItem is returned as an object for further processing in updating the adaptive card.

public async reviewItem(token: string, itemID: string, user: string): Promise<IOfferDocument> {
    const currentItem = await this.getItem(token, itemID);
    if (currentItem.reviewer !== '') {
      let requestUrl: string = await this.getSiteAndListByPath(token, process.env.SiteUrl!);
      // Get user LookupID
      const userInfoListID = await this.getUserInfoListID(token, requestUrl);
      const userLookupID = await this.getUserLookupID(token, requestUrl, userInfoListID, user);
      requestUrl += `/${itemID}/fields`;
      const config: AxiosRequestConfig = {  headers: {      
        Authorization: `Bearer ${token}`,
        'Content-Type': 'application/json'
      }};
      const fieldValueSet = {
        OfferingReviewedDate: new Date().toISOString(),
        OfferingReviewerLookupId: userLookupID
      };  
      try {
        const response = await Axios.patch(requestUrl, 
          fieldValueSet,
          config
        );
        const reviewedDoc: IOfferDocument = {
          name: response.data.Title,
          author: currentItem.author,
          description: response.data.OfferingDescription,
          id: response.data.id,
          modified: new Date(response.data.Modified),
          url: currentItem.url,
          reviewedOn: new Date(response.data.OfferingReviewedDate),
          reviewer: userDisplayName
        }
        return reviewedDoc;
      }
      catch(error) {
        log(error);
        return currentItem;
      }
    }
    else {
      return currentItem;
    }
}

Get User LookupID

A problem known from one of my basic articles about SharePoint and Microsoft Graph is that to update a People column the local Site’s lookup ID of the user in the local Site’s hidden User Information List is needed. Email, UPN, AADObjectID and so on directly doesn’t help.

First there is the need to exactly identify the hidden User Information List. This can be done with a request to the /lists endpoint and $select for system to also retrieve hidden system lists. Before only the general siteUrl including the siteID was taken as “baseline”.

Unfortunately I was not successful with a $filter in the /lists request. This can be the case in some situations with Microsoft Graph and the solution is to iterate the result set and pick the correct item client-side. Luckily usually not much lists can be expected within one site.

private async getUserInfoListID (accessToken: string, requestUrl: string): Promise<string> {
    let listRequestUrl = requestUrl.split('/lists')[0];
    listRequestUrl += "/lists?$select=name,webUrl,displayName,Id,system";
    try {
      const response = await Axios.get(listRequestUrl, {
        headers: {
          Authorization: `Bearer ${accessToken}`
        }
      });
      const lists: any[] = response.data.value;
      let listID = "";
      lists.forEach((l) => {
        if (l.webUrl.endsWith('/_catalogs/users')) {
          listID = l.id;
        }
      });
      return listID;
    }
    catch (error) {
      ...
    }
}

Having the listID it can be queried for the given user (mail, login). Unfortunately there is no guarantee every user is already available within that list and this is the little exception where Microsoft Graph cannot really help (mentioned above). Let’s skip this here and assume the user always exists as all site members should exist and otherwise there would be a user permission problem as well. In a previous sample I illustrated the ensureuser endpoint of the SP Rest API which would help here.

Also worth to note that the list is not indexed (and I would not recommend to do so on a system list ☝🏻). So to $filter for the username

 'Prefer': 'HonorNonIndexedQueriesWarningMayFailRandomly'// No chance to index User Information List

needs to be added to the header of the request. Finally although querying for a unique username Microsoft Graph returns an array here so pick the first item [0] and it’s ID.

private async getUserLookupID (accessToken: string, requestUrl: string, listID: string, userName: string): Promise<string> {
    let listRequestUrl = requestUrl.split('/lists')[0];
    listRequestUrl += `/lists/${listID}/items?$expand=fields&$filter=fields/UserName eq '${userName}'`;
    try {
      const response = await Axios.get(listRequestUrl, {
        headers: {
          Authorization: `Bearer ${accessToken}`,
          'Prefer': 'HonorNonIndexedQueriesWarningMayFailRandomly' // No chance to index User Information List
        }
      });
      return response.data.value[0].id;
    }
    catch (error) {
      ...
    }
}

Update the Adaptive Card

With the new universal action model (UAM) it is possible to update the Adaptive Card. For this a refresh part needs to exist inside an adaptive card v1.4 or above.

{
    type: "AdaptiveCard",
    $schema: "http://adaptivecards.io/schemas/adaptive-card.json",
    version: "1.4",
    refresh: {
        action: {
            type: "Action.Execute",
            title: "Refresh",
            verb: "alreadyreviewed",
            data: {
              doc: doc,
              userIds: userIds
            }
        },
        userIds: userIds
    },

Two things to note here. At first the verb. This is to identify from where the Action.Execute is coming later in the Bot. Second the userIds because only users which are listed here will get the effect of automatic refresh when displaying the card. As userIds all members of the current chat were evaluated (see above). Last not least also the data such as the doc can be transported here.

Once the refresh action is executed, either automatically or manually the following happens inside the Bot:

public async onActionExecute(context: TurnContext): Promise<AdaptiveCardResponseBody> {
    const doc: IOfferDocument = context.activity.value.action.data.doc as IOfferDocument;
    ...
    const tokenResponse = await adapter.getUserToken(context, this.connectionName, magicCode);
    ...
    const graphService = new GraphSearchService();
    let card;
    switch (context.activity.value.action.verb) {
      case 'alreadyreviewed':
        let currentDoc: IOfferDocument;
        currentDoc = await graphService.getItem(tokenResponse.token, doc.id)
          .catch(e => { 
            return doc; // Use card's doc instead
        });
        if (typeof currentDoc.reviewer !== 'undefined') {
          card = CardService.reviewedCardUA(currentDoc);
        }
        else {
          card = CardService.reviewCardUA(currentDoc, context.activity.value.action.data.userIds);
        }
        break;
  }
  return Promise.resolve({
      statusCode: StatusCodes.OK,
      type: 'application/vnd.microsoft.card.adaptive',
      value: card
  });

After the switch for the right verb the given doc is retrieved from the server in the latest state. Depending on review a new card without the “Reviewed” button is returned or the old one still consisting the option to click “Reviewed”.

Same as the “Review” process there is also a “Publish” process. It works quite the same: Pick a document, send as adaptive card, click the button, update metadata in the backend and the card in the frontend. So far so good. The only difference I implemented is the option that additionally the document is converted as a PDF, too, and the card as well as the document get the url to the PDF. How to convert an Office document to PDF with Microsoft Graph was already described here.

In short once again here. A PDF can be generated from any supported file type (note a lot more src and target types coming with the current beta endpoint) by putting /content?format=PDF to the driveItemID request.

private async downloadTmpFileAsPDF (fileID: string, driveRequestUrl: string, fileName: string, accessToken: string): Promise<any> {
    driveRequestUrl += `/items/${fileID}/content?format=PDF`;
    return Axios.get(driveRequestUrl, {
                    responseType: 'arraybuffer', // no 'blob' as 'blob' only works in browser
                    headers: {          
                        Authorization: `Bearer ${accessToken}`
                    }})
                    .then(response => {
                      const respFile = { data: response.data, name: `${fileName}.pdf`, size: response.data.length };
                      return respFile;
                    }).catch(err => {
                      log(err);
                      return null;
                    });
  }

Having the PDF as an ArrayBuffer together with a name and size it can simply be uploaded to the site. In this sample a “Published” subfolder is taken.

private async uploadFileToTargetSite (file: File, accessToken: string, driveUrl: string): Promise<string> {
    driveUrl += `/root:/Published/${file.name}:/content`;
    if (file.size <(4 * 1024 * 1024)) {
      const fileBuffer = file as any; 
      return Axios.put(driveUrl, fileBuffer.data, {
                  headers: {          
                      Authorization: `Bearer ${accessToken}`
                  }})
                  .then(response => {
                    const webUrl = response.data.webUrl;
                    return webUrl;
                  }).catch(err => {
                    log(err);
                    return null;
                  });
    }
    else {
      // File.size>4MB, refer to https://mmsharepoint.wordpress.com/2020/01/12/an-outlook-add-in-with-sharepoint-framework-spfx-storing-mail-with-microsoftgraph/
      return "";
    }
}

All in all the process looks like this from the beginning (picking the adaptive card) in Outlook:

Of course this sample has some room for improvement but I tried to keep it as rich as possible on the one hand but as simple as possible to establish on the other hand. So I always search for all documents having the content type and not filtering if already reviewed/published. You can do that by simply adding a managed property and change the search query. Also the document template has room for improvement but the functionality is there. Also theoretically the Adaptive Cards can be shared with users having no or only read access to the document(‘s site). A productional solution should handle this, of course.

As always you can find the whole solution for your reference or building a really cool solution out of it in my GitHub repository. Sharing is caring.

Markus is a SharePoint architect and technical consultant with focus on latest technology stack in Microsoft 365 Development. He loves SharePoint Framework but also has a passion for Microsoft Graph and Teams Development.
He works for Avanade as an expert for Microsoft 365 Dev and is based in Munich.
In 2021 he received his first Microsoft MVP award in M365 Development for his continuous community contributions.
Although if partially inspired by his daily work opinions are always personal.