Showing posts with label Teams. Show all posts
Showing posts with label Teams. Show all posts

April 14, 2020

Cloning OneNote Tab without /clone REST endpoint

Series

This is series of blog articles showing how to clone Teams Channels and Tabs without using the Clone REST endpoint or direct REST queries. Except we use the Microsoft.Graph (3.1.0). First post discusses things in general, details about different tab types are separated to individual articles.

  1. Cloning Teams Channels and Tabs without /clone REST endpoint
  2. Cloning Planner Tab without /clone REST endpoint
  3. Cloning OneNote Tab without /clone REST endpoint <<YOU ARE HERE>>
  4. Cloning Web Tab without /clone REST endpoint (TBD)
  5. Copying Teams Files tab content using MoveCopyUtil

Solution

In this piece of code, we first determine current source tab is of type OneNote. We must first create new Notebook for the tab, and for this we add simple retry logic as you cannot have Notebooks with duplicate name. This retry logic adds running integer to Notebook name until the creation succeeds.

Now, after we have successfully created the Notebook, it is time to create the Tab. Do note the special format of the EntityId parameter for the tab, as well as rather identical URLs.

Finally, note that you may end up getting ServiceException although tab creation succeeds, thus the scary Exception swallowing.

Using term cloning when it comes to this tab type can be a bit misleading, as we’re not cloning the tab content, but only the tab and creating new content. Perhaps think of this as cloning from the Team perspective, while the individual tabs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
if (sourceTab.TeamsApp.Id.Equals(TeamsAppId.OneNote))
{
logger?.LogInformation($"Creating OneNote. Channel: {sourceChannel.DisplayName}. {newTeamId}");

var newNotebook = await retry.ExecuteAsync(async () =>
{
    var i = 0;
    Notebook nb = null;
    string nbName;

    while (i < 100 && null == nb)
    {
        nbName = $"{TextTools.RemoveSpecialCharactersForNotebook(requestData.title)}{(i > 0 ? $" {i}" : "")} Notebook";

        try
        {
            nb = await graphClient.Groups[newGroup.Id].Onenote.Notebooks.Request().AddAsync(new Notebook()
            {
                DisplayName = nbName
            });

            break;
        }
        catch (ServiceException ex)
        {
            if ((int)ex.StatusCode == 409)
            {
                // this is fine, Notebook with such name already exists, let's find first available by appending integers to name
                logger?.LogWarning($"Notebook with name '{nbName}' already exists, trying next integer. Channel: {sourceChannel.DisplayName}. {newTeamId}");
            }
        }
        finally
        {
            i++;
        }
    }

    return nb;
});

try
{
    logger?.LogInformation($"Adding OneNote tab. Channel: {sourceChannel.DisplayName}. {newTeamId}");

    await retry.ExecuteAsync(async () =>
    {
        await graphClient.Teams[newTeamId].Channels[channelId].Tabs.Request().AddAsync(new TeamsTab()
        {
            ODataType = null,
            DisplayName = newNotebook.DisplayName,
            Configuration = new TeamsTabConfiguration()
            {
                EntityId = $"{Guid.NewGuid()}_{newNotebook.Id}",
                ContentUrl = $"https://www.onenote.com/teams/TabContent?entityid=%7BentityId%7D&subentityid=%7BsubEntityId%7D&auth_upn=%7Bupn%7D&notebookSource=New&notebookSelfUrl=https%3A%2F%2Fwww.onenote.com%2Fapi%2Fv1.0%2FmyOrganization%2Fgroups%2F{{groupId}}%2Fnotes%2Fnotebooks%2F{newNotebook.Id}&oneNoteWebUrl={newNotebook.Links.OneNoteWebUrl.Href}&notebookName={newNotebook.DisplayName}&ui={{locale}}&tenantId={{tid}}",
                RemoveUrl = $"https://www.onenote.com/teams/TabRemove?entityid=%7BentityId%7D&subentityid=%7BsubEntityId%7D&auth_upn=%7Bupn%7D&notebookSource=New&notebookSelfUrl=https%3A%2F%2Fwww.onenote.com%2Fapi%2Fv1.0%2FmyOrganization%2Fgroups%2F{{groupId}}%2Fnotes%2Fnotebooks%2F{newNotebook.Id}&oneNoteWebUrl={newNotebook.Links.OneNoteWebUrl.Href}&notebookName={newNotebook.DisplayName}&ui={{locale}}&tenantId={{tid}}",
                WebsiteUrl = $"https://www.onenote.com/teams/TabRedirect?redirectUrl={newNotebook.Links.OneNoteWebUrl.Href}"
            },
            AdditionalData = new Dictionary<string, object>()
            {
                {
                    "teamsApp@odata.bind", $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{sourceTab.TeamsApp.Id}"
                }
            }
        });
    });
}
catch (ServiceException ex)
{
    HandleTabCreationException(ex, logger, sourceTab, newTeamId);
}
}

April 9, 2020

Cloning Planner Tab without /clone REST endpoint

Series

This is series of blog articles showing how to clone Teams Channels and Tabs without using the Clone REST endpoint or direct REST queries. Except we use the Microsoft.Graph (3.1.0). First post discusses things in general, details about different tab types are separated to individual articles.

  1. Cloning Teams Channels and Tabs without /clone REST endpoint
  2. Cloning Planner Tab without /clone REST endpoint <<YOU ARE HERE>>
  3. Cloning OneNote Tab without /clone REST endpoint
  4. Cloning Web Tab without /clone REST endpoint (TBD)
  5. Copying Teams Files tab content using MoveCopyUtil

Solution

In this piece of code, we first determine current source tab is of type Planner. Then comes the tricky part: you cannot use Application permissions to create the Planner Plan itself, but you must use Delegated permissions, so basically what you need is a dedicated user account, and use a GraphServiceClient that uses UsernamePasswordProvider as IAuthenticationProvider for the Graph API calls. Yes, you really gotta put username and password of that user in Key Vault and fetch those here.

Now, after we have successfully created the Planner Plan, we will do a small trick and do not create the contentUrl property for the new tab manually, but take the ContentUrl from the source Tab and just replace the old Planner Id with the Id of the newly created Planner. Lovely. Just note that the WebsiteUrl needs to be done separately, otherwise the “Go to website” link on top right of the Planner tab is broken and will be stuck loading and give error “Error loading user settings. Please try again. If this continues please contact customer support.” after few minutes.

For creating the new Tab, there’s not much to say. These are the parameters you need to define based on a bit of trial and error. As Graph API evolves and improves, this is the part that will most probably need updating, but at least with Microsoft.Graph v. 3.1.0 it works – which is already old version as 3.2.0 was released few hours ago.

Finally, note that you may end up getting ServiceException although tab creation succeeds, thus the scary Exception swallowing.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// Planner
if (sourceTab.TeamsApp.Id.Equals(TeamsAppId.Planner))
{
    logger?.LogInformation($"Creating Planner. Channel: {sourceChannel.DisplayName}. {newGroup.Id}");

    var newPlannerPlan = await retry.ExecuteAsync(async () =>
    {
        return await Authentication.GetGraphApiClientClientUsernamePasswordProvider(keyvault).Planner.Plans.Request()
            .WithUsernamePassword(keyvault.GetSecret("teamsClone--SPOUser"), new System.Net.NetworkCredential("", keyvault.GetSecret("teamsClone--SPOPassword")).SecurePassword)
            .AddAsync(new PlannerPlan()
            {
                Owner = newGroup.Id,
                Title = $"{TextTools.RemoveSpecialCharactersForNotebook(requestData.title)} Planner"
            });
    });

    try
    {
        string contentUrl = sourceTab.Configuration.ContentUrl.Replace(sourceTab.Configuration.EntityId, newPlannerPlan.Id);
        string websiteUrl = $"https://tasks.office.com/YOURTENANT.onmicrosoft.com/Home/Planner#/plantaskboard?groupId={newTeamId}&planId={newPlannerPlan.Id}";

        logger?.LogInformation($"Adding Planner tab. Channel: {sourceChannel.DisplayName}, ContentUrl: {contentUrl}. {newGroup.Id}");

        await retry.ExecuteAsync(async () =>
        {
            await graphClient.Teams[newTeamId].Channels[channelId].Tabs.Request().AddAsync(new TeamsTab()
            {
                ODataType = null,
                DisplayName = sourceTab.DisplayName,
                TeamsApp = sourceTab.TeamsApp,
                Configuration = new TeamsTabConfiguration()
                {
                    EntityId = newPlannerPlan.Id,
                    ContentUrl = contentUrl,
                    RemoveUrl = contentUrl,
                    WebsiteUrl = webSiteUrl
                },
                AdditionalData = new Dictionary<string, object>()
    {
        {
            "teamsApp@odata.bind", $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{sourceTab.TeamsApp.Id}"
        }
    }
            });
        });
    }
    catch (ServiceException ex)
    {
        HandleTabCreationException(ex, logger, sourceTab);
    }
}

Cloning Teams Channels and Tabs without /clone REST endpoint

Series

This is series of blog articles showing how to clone Teams Channels and Tabs without using the Clone REST endpoint or direct REST queries. Except we use the Microsoft.Graph (3.1.0). First post discusses things in general, details about different tab types are separated to individual articles.

  1. Cloning Teams Channels and Tabs without /clone REST endpoint <<YOU ARE HERE>>
  2. Cloning Planner Tab without /clone REST endpoint
  3. Cloning OneNote Tab without /clone REST endpoint
  4. Cloning Web Tab without /clone REST endpoint (TBD)
  5. Copying Teams Files tab content using MoveCopyUtil

Task

As cloning a Microsoft Teams Team using the https://graph.microsoft.com/v1.0/teams/{sourceeamId}/clone has few things that might surprise you (such as described here), you will anyway need go through all cloned tabs to verify they’re working. Why not instead create a fresh Channel based on the source Team Channel, then enumerate source Team tabs and recreate new tabs in the destination Channel based on the properties of the source Tab?

Solution

Enough chit-chat, lets dive in!

What happens in the code is that we first get source Team, Channels, and their Tabs. Having already created new Group and related Team (out of scope of this specific article), we start creating Channels in the new Team based on source Team Channels.

Reason why I didn’t include details of creating new Group and related Team here is because you could apply this same logic on existing Teams as well. Maybe if you’d like to push new Channel or Tab to existing Teams? Welcome, this code will work just nice.

After creating each Channel, we loop through the source Channel Tabs. Based on the source Tab type, and also how it is eventually been configured, we recreate identical Tab.

Good thing in this way of doing this is that you have full control of the Tab cloning operation.Then again it also means that you will have to implement that full control logic and program code.

Depending on the tab type, term cloning might be a bit misleading, as in all cases we’re not cloning tab content but creating new background resource (such as Planner Plan or OneNote Notebook) and just binding to it from the tab. Maybe think of cloning here as cloning the Team structure including channels and tabs.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
logger?.LogInformation("Getting source team channels and tabs");

var sourceTeamChannels = await retry.ExecuteAsync(async () =>
{
    return await graphClient.Teams[sourceTeamId].Channels.Request().GetAsync();
});

foreach (var chan in sourceTeamChannels)
{
    chan.Tabs = await retry.ExecuteAsync(async () =>
    {
        return await graphClient.Teams[sourceTeamId].Channels[chan.Id].Tabs.Request().Expand("TeamsApp").GetAsync();
    });
}

foreach (var sourceChannel in sourceTeamChannels)
{
    var newChannel = await retry.ExecuteAsync(async () =>
    {
        logger?.LogInformation($"Adding channel {sourceChannel.DisplayName}. {newGroup.Id}");
        return await graphClient.Teams[newTeamId].Channels.Request().AddAsync(new Microsoft.Graph.Channel()
        {
            ODataType = null,
            DisplayName = sourceChannel.DisplayName,
            Description = sourceChannel.Description
        });
    });

    channelId = newChannel.Id;

    // here you should remove the Wiki tab (see separate article)

    foreach (var sourceTab in sourceChannel.Tabs)
    {
        // here you will have switch or if/else construct to determine source tab type
        if (sourceTab.TeamsApp.Id.Equals(TeamsAppId./*TAB TYPE*/))
        {
            // and actual tab type specific creation and configuration logic
        }
    }
}

By the way, you’ll be needing this Exception handler later, as tab creation will throw specific ServiceExceptions even though creation succeeds.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
private static void HandleTabCreationException(ServiceException ex, ILogger logger, TeamsTab sourceTab)
{
    if (ex.StatusCode.ToString() == "BadRequest" && ex.Error.Message == "Value cannot be null.\r\nParameter name: entity")
    {
        // this is fine: https://stackoverflow.com/questions/59784200/programmatically-creating-teamtab-used-to-work-but-now-gives-error-after-updati
        logger?.LogInformation($"Tab {sourceTab.TeamsApp.Id} created: {sourceTab.DisplayName}");
    }
    else
    {
        logger?.LogInformation($"Error creating tab: {sourceTab.TeamsApp.Id}: {sourceTab.DisplayName}");
    }
}

Also, extending Laura’s TeamsAppId class a bit will come handy, thank you Laura!

1
2
3
4
5
6
7
8
public static class TeamsAppId
{
    public static readonly string OneNote = "0d820ecd-def2-4297-adad-78056cde7c78";
    public static readonly string Planner = "com.microsoft.teamspace.tab.planner";
    public static readonly string SharePoint = "2a527703-1f6f-4559-a332-d8a7d288cd88";
    public static readonly string Web = "com.microsoft.teamspace.tab.web";
    public static readonly string Wiki = "com.microsoft.teamspace.tab.wiki";
}

April 8, 2020

Copying Teams Files tab content using MoveCopyUtil

Series

This is series of blog articles showing how to clone Teams Channels and Tabs without using the Clone REST endpoint or direct REST queries. Except we use the Microsoft.Graph (3.1.0). First post discusses things in general, details about different tab types are separated to individual articles.

  1. Cloning Teams Channels and Tabs without /clone REST endpoint
  2. Cloning Planner Tab without /clone REST endpoint
  3. Cloning OneNote Tab without /clone REST endpoint
  4. Cloning Web Tab without /clone REST endpoint (TBD)
  5. Copying Teams Files tab content using MoveCopyUtil <<YOU ARE HERE>>

Task

As part of Teams provisioning, content of Files tab needed to be copied from source Team to the destination Team. As you might know, in practice it just means copying folders and files across SharePoint site collections.

Solution

Microsoft.SharePoint.Client.MoveCopyUtil will do the trick UNLESS a folder name contains special characters, see here for example. This has been an issue for long in CSOM, and if at all possible, I recommend not having special characters in folder names, and not in file names either for that matter.

If you must support special character scenarios, it is doable, but will require carefully splitting and constructing the query parameters and effectively means you need to loop and create/copy folders and files one by one. I’ve spent some time creating SPFx solution that manages to do this, and I recommend thinking not twice, but ten times before agreeing to support special characters in this case. Good thing is that in this Teams provisioning scenario you will have control over the source Team and should be able to work around any needs for special characters.

Explanation of the code

What happens in this code can probably be read quite well by looking at the LogInformation entries, but the idea is that we get hold of the Drive item of the source and destination Group. Drive.WebUrl is then absolute URL to the document library where Teams File tab contents are in their own sub-folders.

As a reminder, each File tab in a Team Channel maps to one root level folder (Channel name = folder name) in that document library you find at Drive.WebUrl.

Another nice thing about Group.Drive is that whenever we get hold of that, and it is not returning HTTP 404, we know Group provisioning (the process that runs in the background and creates all back-end dependencies, such as the SharePoint site) has been completed.

retry object is Polly’s AsyncRetryPolicy and ensures retry logic in case provisioning would not be yet ready at this point. It will retry the call as configured, in my case every 5 seconds, until the .GetAsync() completes successfully.

Next up there is some ugly parsing of the source document library path, namely we’re removing the document library part from the URL, leaving us with URL of the SPWeb. There are many ways of doing this, and I admit this one is quite verbose way of doing it but doesn’t at least require additional HTTP query.

Then we get to the actual point, initializing SharePoint context using the source SPWeb, and credentials stored in Azure Key Vault.

After getting all the root level folders (each corresponding a Channel, yes, good), we loop through each folder item and use MoveCopyUtil.CopyFolder to copy it to destination document library. Just remember to set the MoveCopyOptions to KeepBoth, indicating this is a Copy operation and not Move operation.

I felt more comfortable doing .ExecuteQuery for each folder, although it means few more HTTP calls, but allows improved error handling in case of those special characters, or some other problems that might occur during the copy operation.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public static async Task CopyDocuments(string srcGroupId, string dstGroupId, AsyncRetryPolicy retry, GraphServiceClient graphClient, KeyVault keyvault, ILogger logger)
{
    var sourceDrive = await retry.ExecuteAsync(async () =>
    {
        return await graphClient.Groups[srcGroupId].Drive.Request().GetAsync();
    });

    var destDrive = await retry.ExecuteAsync(async () =>
    {
        return await graphClient.Groups[dstGroupId].Drive.Request().GetAsync();
    });

    try
    {
        logger?.LogInformation($"Copying SPO files, source drive: {sourceDrive.WebUrl}");
        logger?.LogInformation($"Copying SPO files, destination drive: {destDrive.WebUrl}");

        var sourceUri = new Uri(sourceDrive.WebUrl);
        string sourceSiteUrlWithoutLastSegment = sourceUri.AbsoluteUri.Remove(sourceUri.AbsoluteUri.Length - sourceUri.Segments.Last().Length);

        using (var srcContext = new OfficeDevPnP.Core.AuthenticationManager()
            .GetSharePointOnlineAuthenticatedContextTenant(sourceSiteUrlWithoutLastSegment, keyvault.GetSecret("teamsClone--SPOUser"), keyvault.GetSecret("teamsClone--SPOPassword")))
        {
            logger?.LogInformation($"Copying SPO files, getting root folders");

            // get folders at root                   
            var targetList = srcContext.Web.Lists.GetByTitle(sourceDrive.Name);

            // This method only gets the folders which are on top level of the list/library
            FolderCollection oFolderCollection = targetList.RootFolder.Folders;

            // Load folder collection
            srcContext.Load(oFolderCollection);
            srcContext.ExecuteQuery();

            MoveCopyOptions option = new MoveCopyOptions
            {
                KeepBoth = true
            };

            logger?.LogInformation($"Copying SPO files, looping folders");

            foreach (var folder in oFolderCollection)
            {
                if (folder.Name.Equals("Forms"))
                {
                    continue;
                }

                try
                {
                    logger?.LogInformation($"Copying SPO files, copying folder '{sourceUri.GetLeftPart(UriPartial.Authority) + folder.ServerRelativeUrl}' -> '{destDrive.WebUrl}/{folder.Name}'");

                    MoveCopyUtil.CopyFolder(
                        srcContext,
                        sourceUri.GetLeftPart(UriPartial.Authority) + folder.ServerRelativeUrl,
                        $"{destDrive.WebUrl}/{folder.Name}",
                        option);

                    srcContext.ExecuteQuery();

                    logger?.LogInformation("Copying done");
                }
                catch (Exception ex)
                {
                    logger?.LogError($"Error copying SPO folder {folder.Name}. {ex.Message}");
                }
            }
        }
    }
    catch (Exception ex)
    {
        logger?.LogError($"Error copying SPO content. {ex.Message}");
    }
}

April 3, 2020

Remove Wiki tab during provisioning of Teams channels

Task

Remove Wiki tab during provisioning of Teams channels.

Solution

As I’m not cloning my Team, but instead enumerating channels and tabs of the source Team used as a template, it was quite straightforward to include the Wiki tab remove logic to the stage where I’m anyway fetching Tabs for each source Team Channel.

This one includes Polly retry-logic for the DELETE operation (my first time using Polly so there’s probably lot of room for improvement).

It would also be nice to bake the Polly retry-logic into the GraphServiceClient itself instead of surrounding all calls. If you know it has already been done, please comment below. I’m always looking forward to improve my code.

I’m currently using version 3.21.0 of Microsoft.Graph library.

Code updated to latest version March 30, 2021.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
var team = new Team()
{
    ODataType = null,
    MessagingSettings = sourceTeam.MessagingSettings,
    FunSettings = sourceTeam.FunSettings,
    GuestSettings = sourceTeam.GuestSettings,
    MemberSettings = sourceTeam.MemberSettings
};

team.MessagingSettings.ODataType = null;
team.FunSettings.ODataType = null;
team.GuestSettings.ODataType = null;
team.MemberSettings.ODataType = null;

Team newTeam;

try
{
    newTeam = await retry.ExecuteAsync(async () =>
    {
        return await graphClient.Groups[newGroup.Id].Team
        .Request()
        .PutAsync(team);
    });
}
catch (Exception ex)
{
    logger?.LogError($"Error adding team to group: {ex.Message} {newGroup.Id}");
    return;
}

// get new team channels
var newTeamId = newTeam.Id;

logger?.LogInformation($"Getting new team channels {newTeamId}");

var newTeamChannels = await retry.ExecuteAsync(async () =>
{
    return await graphClient.Teams[newTeamId].Channels.Request().GetAsync();
});

logger?.LogInformation($"Getting new team tabs {newTeamId}");

// and tabs
foreach (var chan in newTeamChannels)
{
    chan.Tabs = await retry.ExecuteAsync(async () =>
    {
        return await graphClient.Teams[newTeamId].Channels[chan.Id].Tabs.Request().Expand("TeamsApp").GetAsync();
    });

    await Content.RemoveWikiTab(newTeamId, chan, retry, graphClient, logger);
}

// Define RemoveWikiTab somewhere else

/// <summary>
/// Removes wiki tab from given channel
/// </summary>
/// <param name="newTeamId"></param>
/// <param name="channel"></param>
/// <param name="retry"></param>
/// <param name="graphClient"></param>
/// <param name="logger"></param>
/// <returns></returns>
public static async Task RemoveWikiTab(string newTeamId, Microsoft.Graph.Channel channel, AsyncRetryPolicy retry, GraphServiceClient graphClient, ILogger logger)
{
    for (int i = channel.Tabs.Count - 1; i >= 0; i--)
    {
        if (channel.Tabs[i].TeamsApp.Id == TeamsAppId.Wiki)
        {
            try
            {
                await retry.ExecuteAsync(async () =>
                {
                    logger?.LogInformation($"Removing wiki on channel {channel.DisplayName}. {newTeamId}");

                    await graphClient.Teams[newTeamId].Channels[channel.Id].Tabs[channel.Tabs[i].Id].Request().DeleteAsync();
                });

                channel.Tabs.RemoveAt(i);
            }
            catch (Exception ex)
            {
                logger?.LogError($"Error removing wiki tab on channel: {channel.DisplayName}. {ex.Message}");
            }
        }
    }
}