Files
duplicati/proprietary/Office365/RestoreProvider/RestoreProvider.Group.cs
T

1403 lines
58 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Collections.Concurrent;
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Logging;
using Duplicati.Proprietary.Office365.SourceItems;
namespace Duplicati.Proprietary.Office365;
public partial class RestoreProvider
{
private readonly ConcurrentDictionary<string, string> _restoredConversationThreadMap = new();
private readonly ConcurrentDictionary<string, string> _restoredChannelMessageMap = new();
private readonly ConcurrentDictionary<string, string> _restoredChannelMap = new();
internal GroupTeamsApiImpl GroupTeamsApi => new GroupTeamsApiImpl(_apiHelper);
internal GroupConversationApiImpl GroupConversationApi => new GroupConversationApiImpl(_apiHelper);
internal GroupCalendarApiImpl GroupCalendarApi => new GroupCalendarApiImpl(_apiHelper);
internal GroupApiImpl GroupApi => new GroupApiImpl(_apiHelper);
internal class GroupApiImpl(APIHelper provider)
{
internal async Task UpdateGroupAsync(string groupId, Stream contentStream, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}";
var graphGroup = await JsonSerializer.DeserializeAsync<GraphGroup>(contentStream, cancellationToken: ct);
if (graphGroup == null) return;
var payload = new Dictionary<string, object?>
{
["description"] = graphGroup.Description,
["visibility"] = graphGroup.Visibility,
["mailEnabled"] = graphGroup.MailEnabled,
["securityEnabled"] = graphGroup.SecurityEnabled,
// DisplayName is read-only for some group types or might require different permissions, but let's try
["displayName"] = graphGroup.DisplayName
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await provider.PatchGraphItemAsync(url, content, ct);
}
internal async Task AddGroupMemberAsync(string groupId, string userId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}/members/$ref";
var payload = new
{
_odata_id = $"https://graph.microsoft.com/v1.0/directoryObjects/{userId}"
};
// Use a custom serializer option to handle the property name with @
var json = JsonSerializer.Serialize(payload).Replace("_odata_id", "@odata.id");
using var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
await provider.PostGraphItemNoResponseAsync(url, content, ct);
}
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
// Member might already exist or other issue
// Graph API returns 400 if member already exists? Or 409?
// Let's log and continue
throw;
}
}
internal async Task AddGroupOwnerAsync(string groupId, string userId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}/owners/$ref";
var payload = new
{
_odata_id = $"https://graph.microsoft.com/v1.0/directoryObjects/{userId}"
};
var json = JsonSerializer.Serialize(payload).Replace("_odata_id", "@odata.id");
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await provider.PostGraphItemNoResponseAsync(url, content, ct);
}
}
internal class GroupCalendarApiImpl(APIHelper provider)
{
internal async Task<GraphEvent> CreateGroupCalendarEventAsync(string groupId, GraphEvent eventItem, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}/events";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
=> new HttpRequestMessage(HttpMethod.Post, new Uri(url))
{
Headers =
{
Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false)
},
Content = JsonContent.Create(eventItem)
};
using var resp = await provider.SendWithRetryShortAsync(
requestFactory,
ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
using var respStream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<GraphEvent>(respStream, cancellationToken: ct).ConfigureAwait(false)
?? throw new InvalidOperationException("Failed to deserialize created event");
}
}
internal class GroupConversationApiImpl(APIHelper provider)
{
internal async Task<GraphConversationThread> CreateGroupThreadAsync(string groupId, string topic, object body, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/groups/{group}/threads";
var payload = new
{
topic,
posts = new[]
{
new { body }
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await provider.PostGraphItemAsync<GraphConversationThread>(url, content, ct);
}
internal async Task ReplyToThreadAsync(string groupId, string threadId, object body, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var thread = Uri.EscapeDataString(threadId);
var url = $"{baseUrl}/v1.0/groups/{group}/threads/{thread}/reply";
var payload = new
{
post = new
{
body
}
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await provider.PostGraphItemNoResponseAsync(url, content, ct);
}
}
internal class GroupTeamsApiImpl(APIHelper provider)
{
internal async Task<GraphChannel?> GetTeamChannelAsync(string groupId, string displayName, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(groupId);
// First verify that the Team exists (group may exist without Teams).
var teamUrl = $"{baseUrl}/v1.0/teams/{team}";
async Task<HttpRequestMessage> teamReqFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(teamUrl));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
using (var teamResp = await provider.SendWithRetryShortAsync(teamReqFactory, ct).ConfigureAwait(false))
{
if (teamResp.StatusCode == System.Net.HttpStatusCode.NotFound)
return null; // Not a Team (no threadId)
await APIHelper.EnsureOfficeApiSuccessAsync(teamResp, ct).ConfigureAwait(false);
}
var select = "id,displayName,description,membershipType,createdDateTime,isFavoriteByDefault,email,webUrl";
var url = $"{baseUrl}/v1.0/teams/{team}/channels?$select={Uri.EscapeDataString(select)}";
await foreach (var channel in provider.GetAllGraphItemsAsync<GraphChannel>(url, ct))
{
if (string.Equals(channel.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))
return channel;
}
return null;
}
internal async Task<bool> IsGroupTeamsEnabledAsync(string groupId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/teams/{team}";
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Get, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
return false; // Group exists but has no Team
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
return true;
}
internal async Task<GraphChannel> CreateTeamChannelAsync(
string groupId,
string displayName,
string? description,
string? membershipType,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/teams/{team}/channels";
var payload = new Dictionary<string, object?>
{
["displayName"] = displayName,
["description"] = description,
["membershipType"] = membershipType ?? "standard"
};
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await provider.PostGraphItemAsync<GraphChannel>(url, content, ct).ConfigureAwait(false);
}
internal async Task<GraphChannel> CreateTeamChannelInMigrationModeAsync(
string groupId,
string displayName,
string? description,
string? membershipType,
DateTimeOffset? createdDateTimeUtc,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/teams/{team}/channels";
// Graph requires the instance annotation property name exactly.
var payload = new Dictionary<string, object?>
{
["displayName"] = displayName,
["description"] = description,
["membershipType"] = membershipType ?? "standard",
["@microsoft.graph.channelCreationMode"] = "migration"
};
// Optional for migration scenarios; must be UTC and not in the future.
if (createdDateTimeUtc.HasValue)
payload["createdDateTime"] = createdDateTimeUtc.Value.UtcDateTime.ToGraphTimeString();
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await provider.PostGraphItemAsync<GraphChannel>(url, content, ct).ConfigureAwait(false);
}
internal async Task<GraphChatMessage> ImportChannelMessageAsync(
string groupId,
string channelId,
string senderUserId,
string senderDisplayName,
DateTimeOffset createdDateTimeUtc,
string htmlContent,
string? replyToId,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var channel = Uri.EscapeDataString(channelId);
var url = $"{baseUrl}/v1.0/teams/{group}/channels/{channel}/messages";
var payload = new Dictionary<string, object?>
{
["createdDateTime"] = createdDateTimeUtc.UtcDateTime.ToString("o", CultureInfo.InvariantCulture),
["from"] = new
{
user = new
{
id = senderUserId,
displayName = senderDisplayName,
userIdentityType = "aadUser"
}
},
["body"] = new
{
contentType = "html",
content = htmlContent
}
};
if (!string.IsNullOrWhiteSpace(replyToId))
payload["replyToId"] = replyToId;
async Task<HttpRequestMessage> requestFactory(CancellationToken rct)
{
var req = new HttpRequestMessage(HttpMethod.Post, new Uri(url));
req.Headers.Authorization = await provider.GetAuthenticationHeaderAsync(false, rct).ConfigureAwait(false);
req.Headers.Add("Teamwork-Migrate", "true");
req.Content = JsonContent.Create(payload);
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
var created = await JsonSerializer.DeserializeAsync<GraphChatMessage>(stream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the imported message id.");
return created;
}
internal IAsyncEnumerable<GraphChatMessage> ListChannelMessagesAsync(string groupId, string channelId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var channel = Uri.EscapeDataString(channelId);
var url = $"{baseUrl}/v1.0/teams/{group}/channels/{channel}/messages";
return provider.GetAllGraphItemsAsync<GraphChatMessage>(url, ct);
}
internal IAsyncEnumerable<GraphChatMessage> ListChannelMessageRepliesAsync(string groupId, string channelId, string messageId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var channel = Uri.EscapeDataString(channelId);
var message = Uri.EscapeDataString(messageId);
var url = $"{baseUrl}/v1.0/teams/{group}/channels/{channel}/messages/{message}/replies";
return provider.GetAllGraphItemsAsync<GraphChatMessage>(url, ct);
}
internal async Task<GraphTeamsTab> CreateChannelTabAsync(string groupId, string channelId, string displayName, string? teamsAppId, GraphTeamsTabConfiguration? configuration, bool ignoreExisting, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var channel = Uri.EscapeDataString(channelId);
var url = $"{baseUrl}/v1.0/teams/{group}/channels/{channel}/tabs";
// Check if tab already exists
if (!ignoreExisting)
{
var existingTabs = provider.GetAllGraphItemsAsync<GraphTeamsTab>(url, ct);
await foreach (var tab in existingTabs)
{
if (string.Equals(tab.DisplayName, displayName, StringComparison.OrdinalIgnoreCase))
{
// Tab already exists, return it
return tab;
}
}
}
var payload = new Dictionary<string, object?>
{
["displayName"] = displayName,
["configuration"] = configuration
};
if (!string.IsNullOrEmpty(teamsAppId))
{
payload["teamsApp@odata.bind"] = $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{teamsAppId}";
}
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
return await provider.PostGraphItemAsync<GraphTeamsTab>(url, content, ct);
}
internal async Task InstallTeamAppAsync(string groupId, string teamsAppId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var group = Uri.EscapeDataString(groupId);
var url = $"{baseUrl}/v1.0/teams/{group}/installedApps";
var payloadBind = new Dictionary<string, string>
{
["teamsApp@odata.bind"] = $"https://graph.microsoft.com/v1.0/appCatalogs/teamsApps/{teamsAppId}"
};
var json = JsonSerializer.Serialize(payloadBind);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
await provider.PostGraphItemNoResponseAsync(url, content, ct);
}
}
private async Task<string?> GetTargetChannelId(string groupId, string itemPath, CancellationToken cancel)
{
// Check map first
var channelPath = _restoredChannelMap.Keys
.Where(k => itemPath.StartsWith(Util.AppendDirSeparator(k)) || itemPath == k)
.OrderByDescending(k => k.Length)
.FirstOrDefault();
if (channelPath != null)
return _restoredChannelMap[channelPath];
// Walk up to find channel in metadata
var currentPath = itemPath;
while (!string.IsNullOrEmpty(currentPath))
{
if (_metadata.TryGetValue(currentPath, out var metadata) &&
metadata.TryGetValue("o365:Type", out var type) &&
type == SourceItemType.GroupChannel.ToString())
{
var displayName = metadata.GetValueOrDefault("o365:DisplayName") ?? metadata.GetValueOrDefault("o365:Name");
if (string.IsNullOrWhiteSpace(displayName))
return null;
try
{
// Check if exists
var existingChannel = await GroupTeamsApi.GetTeamChannelAsync(groupId, displayName, cancel);
if (existingChannel != null)
{
await ChannelRestore.EnsureChannelInMigrationMode(existingChannel.Id, groupId, cancel);
_restoredChannelMap[currentPath] = existingChannel.Id;
return existingChannel.Id;
}
if (!await GroupTeamsApi.IsGroupTeamsEnabledAsync(groupId, cancel))
{
Log.WriteWarningMessage(LOGTAG, "GroupTeamsNotEnabled", null, $"Group {groupId} does not have Teams enabled, skipping channel create {displayName}");
return null;
}
// Create
var description = metadata.GetValueOrDefault("o365:Description");
var membershipType = metadata.GetValueOrDefault("o365:MembershipType");
var createdDateTimeStr = metadata.GetValueOrDefault("o365:CreatedDateTime");
var newChannel = await GroupTeamsApi.CreateTeamChannelAsync(groupId, displayName, description, membershipType, cancel);
await ChannelRestore.EnsureChannelInMigrationMode(newChannel.Id, groupId, cancel);
_restoredChannelMap[currentPath] = newChannel.Id;
return newChannel.Id;
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "GetTargetChannelIdFailed", ex, $"Failed to get/create channel {displayName}");
return null;
}
}
currentPath = Path.GetDirectoryName(currentPath);
}
// Not found, use default channel
return await ChannelRestore.GetTargetChannelId(groupId, cancel);
}
private async Task RestoreGroupChannels(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var channels = GetMetadataByType(SourceItemType.GroupChannel);
if (channels.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var channel in channels)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = channel.Key;
var metadata = channel.Value;
var displayName = metadata.GetValueOrDefault("o365:DisplayName") ?? metadata.GetValueOrDefault("o365:Name");
if (string.IsNullOrWhiteSpace(displayName))
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupChannelsMissingName", null, $"Missing display name for channel {originalPath}, skipping.");
continue;
}
// Check if channel exists
if (!_ignoreExisting)
{
var existingChannel = await GroupTeamsApi.GetTeamChannelAsync(targetGroupId, displayName, cancel);
if (existingChannel != null)
{
Log.WriteInformationMessage(LOGTAG, "RestoreGroupChannelsSkipExisting", null, $"Channel {displayName} already exists in target group, skipping.");
await ChannelRestore.EnsureChannelInMigrationMode(existingChannel.Id, targetGroupId, cancel);
_restoredChannelMap[originalPath] = existingChannel.Id;
continue;
}
}
if (!await GroupTeamsApi.IsGroupTeamsEnabledAsync(targetGroupId, cancel))
{
Log.WriteWarningMessage(LOGTAG, "GroupTeamsNotEnabled", null, $"Group {targetGroupId} does not have Teams enabled, skipping channel create {displayName}");
continue;
}
var description = metadata.GetValueOrDefault("o365:Description");
var membershipType = metadata.GetValueOrDefault("o365:MembershipType");
var createdDateTimeStr = metadata.GetValueOrDefault("o365:CreatedDateTime");
var newChannel = await GroupTeamsApi.CreateTeamChannelAsync(targetGroupId, displayName, description, membershipType, cancel);
await ChannelRestore.EnsureChannelInMigrationMode(newChannel.Id, targetGroupId, cancel);
_restoredChannelMap[originalPath] = newChannel.Id;
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupChannelsFailed", ex, $"Failed to restore channel {channel.Key}");
}
}
}
private async Task RestoreChannelMessages(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var messages = GetMetadataByType(SourceItemType.GroupChannelMessage);
var replies = GetMetadataByType(SourceItemType.GroupChannelMessageReply);
if (messages.Count == 0 && replies.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
var existingMessagesCache = new Dictionary<string, List<GraphChatMessage>>();
var existingRepliesCache = new Dictionary<string, List<GraphChatMessage>>();
// Restore messages
foreach (var message in messages)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = message.Key;
// Find channel ID
var channelId = await GetTargetChannelId(targetGroupId, originalPath, cancel);
if (string.IsNullOrWhiteSpace(channelId))
return;
if (!existingMessagesCache.ContainsKey(channelId))
existingMessagesCache[channelId] = await GroupTeamsApi.ListChannelMessagesAsync(targetGroupId, channelId, cancel).ToListAsync(cancel);
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesMissingContent", null, $"Missing content for message {originalPath}, skipping.");
continue;
}
GraphChatMessage? graphMessage;
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
graphMessage = await JsonSerializer.DeserializeAsync<GraphChatMessage>(stream, cancellationToken: cancel);
}
if (graphMessage?.Body?.Content == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesMissingBody", null, $"Missing body content for message {originalPath}, skipping.");
continue;
}
var senderUserId = graphMessage.From?.User?.Id;
var senderDisplayName = graphMessage.From?.User?.DisplayName ?? "Unknown User";
var createdDateTime = graphMessage.CreatedDateTime ?? DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(senderUserId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesMissingSender", null, $"Missing sender for message {originalPath}, skipping.");
continue;
}
// Remove the "/content.json" suffix to get the message path so replies can find it
var restoreMessagePath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath) ?? "");
if (!_ignoreExisting)
{
var existing = existingMessagesCache[channelId].FirstOrDefault(m =>
m.CreatedDateTime.HasValue &&
Math.Abs((m.CreatedDateTime.Value - createdDateTime).TotalSeconds) < 2 &&
m.From?.User?.Id == senderUserId
);
if (existing != null)
{
Log.WriteInformationMessage(LOGTAG, "RestoreChannelMessagesSkipExisting", null, $"Message {originalPath} already exists, skipping.");
_restoredChannelMessageMap[restoreMessagePath] = existing.Id;
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFileSkip);
contentFileSkip?.Dispose();
continue;
}
}
var newMessage = await GroupTeamsApi.ImportChannelMessageAsync(targetGroupId, channelId, senderUserId, senderDisplayName, createdDateTime, graphMessage.Body.Content, null, cancel);
_restoredChannelMessageMap[restoreMessagePath] = newMessage.Id;
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreChannelMessagesFailed", ex, $"Failed to restore channel message {message.Key}");
}
}
// Restore replies
foreach (var reply in replies)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = reply.Key;
// Find channel ID
var channelId = await GetTargetChannelId(targetGroupId, originalPath, cancel);
if (string.IsNullOrWhiteSpace(channelId))
return;
// Find parent message ID
var parentMessagePath = _restoredChannelMessageMap.Keys
.Where(k => originalPath.StartsWith(Util.AppendDirSeparator(k)))
.OrderByDescending(k => k.Length)
.FirstOrDefault();
if (parentMessagePath == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesReplyMissingParent", null, $"Could not find restored parent message for reply {originalPath}, skipping.");
continue;
}
var parentMessageId = _restoredChannelMessageMap[parentMessagePath];
if (!existingRepliesCache.ContainsKey(parentMessageId))
existingRepliesCache[parentMessageId] = await GroupTeamsApi.ListChannelMessageRepliesAsync(targetGroupId, channelId, parentMessageId, cancel).ToListAsync(cancel);
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesReplyMissingContent", null, $"Missing content for reply {originalPath}, skipping.");
continue;
}
GraphChatMessage? graphMessage;
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
graphMessage = await JsonSerializer.DeserializeAsync<GraphChatMessage>(stream, cancellationToken: cancel);
}
if (graphMessage?.Body?.Content == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesReplyMissingBody", null, $"Missing body content for reply {originalPath}, skipping.");
continue;
}
var senderUserId = graphMessage.From?.User?.Id;
var senderDisplayName = graphMessage.From?.User?.DisplayName ?? "Unknown User";
var createdDateTime = graphMessage.CreatedDateTime ?? DateTimeOffset.UtcNow;
if (string.IsNullOrWhiteSpace(senderUserId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelMessagesReplyMissingSender", null, $"Missing sender for reply {originalPath}, skipping.");
continue;
}
if (!_ignoreExisting)
{
var existing = existingRepliesCache[parentMessageId].FirstOrDefault(m =>
m.CreatedDateTime.HasValue &&
Math.Abs((m.CreatedDateTime.Value - createdDateTime).TotalSeconds) < 2 &&
m.From?.User?.Id == senderUserId
);
if (existing != null)
{
Log.WriteInformationMessage(LOGTAG, "RestoreChannelMessagesReplySkipExisting", null, $"Reply {originalPath} already exists, skipping.");
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFileSkip);
contentFileSkip?.Dispose();
continue;
}
}
await GroupTeamsApi.ImportChannelMessageAsync(targetGroupId, channelId, senderUserId, senderDisplayName, createdDateTime, graphMessage.Body.Content, parentMessageId, cancel);
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreChannelMessagesReplyFailed", ex, $"Failed to restore channel message reply {reply.Key}");
}
}
}
private async Task RestoreGroupConversations(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var threads = GetMetadataByType(SourceItemType.GroupConversationThread);
var posts = GetMetadataByType(SourceItemType.GroupConversationThreadPost);
if (threads.Count == 0 && posts.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
// Restore threads
foreach (var thread in threads)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = thread.Key;
var metadata = thread.Value;
var topic = metadata.GetValueOrDefault("o365:Topic") ?? "Restored Thread";
// Find the first post for this thread to create it
var threadPosts = posts
.Where(p => p.Key.StartsWith(Util.AppendDirSeparator(originalPath)))
.OrderBy(p => p.Value.GetValueOrDefault("o365:CreatedDateTime"))
.ToList();
if (threadPosts.Count == 0)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsEmptyThread", null, $"Thread {originalPath} has no posts, skipping.");
continue;
}
var firstPost = threadPosts[0];
var firstPostPath = firstPost.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(firstPostPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsMissingContent", null, $"Missing content for first post of thread {originalPath}, skipping.");
continue;
}
object? body = null;
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
var graphPost = await JsonSerializer.DeserializeAsync<GraphPost>(stream, cancellationToken: cancel);
body = graphPost?.Body;
}
if (body == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsMissingBody", null, $"Missing body for first post of thread {originalPath}, skipping.");
continue;
}
var newThread = await GroupConversationApi.CreateGroupThreadAsync(targetGroupId, topic, body, cancel);
_restoredConversationThreadMap[originalPath] = newThread.Id;
// Remove first post from metadata so we don't restore it again as a reply
_metadata.TryRemove(firstPostPath, out _);
_temporaryFiles.TryRemove(firstPostPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupConversationsThreadFailed", ex, $"Failed to restore thread {thread.Key}");
}
}
// Restore remaining posts as replies
// Re-fetch posts because we might have removed some
posts = GetMetadataByType(SourceItemType.GroupConversationThreadPost);
foreach (var post in posts)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = post.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsMissingPostContent", null, $"Missing content for post {originalPath}, skipping.");
continue;
}
// Find thread ID
var threadPath = _restoredConversationThreadMap.Keys
.Where(k => originalPath.StartsWith(Util.AppendDirSeparator(k)))
.OrderByDescending(k => k.Length)
.FirstOrDefault();
if (threadPath == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsMissingThread", null, $"Could not find restored thread for post {originalPath}, skipping.");
continue;
}
var threadId = _restoredConversationThreadMap[threadPath];
object? body = null;
using (var stream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
var graphPost = await JsonSerializer.DeserializeAsync<GraphPost>(stream, cancellationToken: cancel);
body = graphPost?.Body;
}
if (body == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupConversationsMissingPostBody", null, $"Missing body for post {originalPath}, skipping.");
continue;
}
await GroupConversationApi.ReplyToThreadAsync(targetGroupId, threadId, body, cancel);
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupConversationsPostFailed", ex, $"Failed to restore post {post.Key}");
}
}
}
private async Task RestoreGroupCalendarEvents(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var events = GetMetadataByType(SourceItemType.GroupCalendarEvent);
if (events.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var eventItem in events)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = eventItem.Key;
using var stream = await OpenRead(originalPath, cancel);
var graphEvent = await JsonSerializer.DeserializeAsync<GraphEvent>(stream, cancellationToken: cancel);
if (graphEvent == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupCalendarEventsInvalidJson", null, $"Invalid JSON for event {originalPath}, skipping.");
continue;
}
// Clean up properties that shouldn't be sent on creation
graphEvent.Id = "";
graphEvent.CreatedDateTime = null;
graphEvent.LastModifiedDateTime = null;
// Group events don't support all properties that user events do, but Graph API should handle it or ignore extra fields.
// However, we should be careful about attendees if it's a group event.
// Usually group events are just added to the group calendar.
await GroupCalendarApi.CreateGroupCalendarEventAsync(targetGroupId, graphEvent, cancel);
_metadata.TryRemove(originalPath, out _);
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupCalendarEventsFailed", ex, $"Failed to restore group calendar event {eventItem.Key}");
}
}
}
/// <summary>
/// Determines if the tab should be skipped during restore (built-in tabs).
/// </summary>
/// <param name="teamsAppId">The teams app ID to check.</param>
/// <param name="entityId">The entity ID to check.</param>
/// <returns><c>true</c> if the tab should be skipped; otherwise, <c>false</c>.</returns>
internal static bool ShouldSkipTab(string? teamsAppId, string? entityId)
{
if (string.IsNullOrEmpty(teamsAppId))
return true;
if (!string.IsNullOrEmpty(entityId) && entityId.Equals("FileBrowserTabApp", StringComparison.OrdinalIgnoreCase))
return true;
return teamsAppId.Equals("com.microsoft.teamspace.tab.files", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.conversations", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.wiki", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.forms", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.stream", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.powerbi", StringComparison.OrdinalIgnoreCase)
|| teamsAppId.Equals("com.microsoft.teamspace.tab.whiteboard", StringComparison.OrdinalIgnoreCase);
}
private async Task RestoreChannelTabs(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var tabs = GetMetadataByType(SourceItemType.GroupChannelTab);
if (tabs.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var tab in tabs)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = tab.Key;
var metadata = tab.Value;
var displayName = metadata.GetValueOrDefault("o365:DisplayName");
var teamsAppId = metadata.GetValueOrDefault("o365:TeamsAppId");
var entityId = metadata.GetValueOrDefault("o365:EntityId");
if (string.IsNullOrWhiteSpace(displayName))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChannelTabsMissingName", null, $"Missing display name for tab {originalPath}, skipping.");
continue;
}
if (ShouldSkipTab(teamsAppId, entityId))
{
_metadata.TryRemove(originalPath, out _);
if (_temporaryFiles.TryRemove(originalPath, out var cf))
cf?.Dispose();
Log.WriteInformationMessage(LOGTAG, "RestoreChannelTabsSkipBuiltIn", $"Skipping built-in tab {displayName} for channel {originalPath}.");
continue;
}
// Find channel ID
var channelId = await GetTargetChannelId(targetGroupId, originalPath, cancel);
if (string.IsNullOrWhiteSpace(channelId))
return;
// Construct configuration
var configuration = new GraphTeamsTabConfiguration
{
EntityId = metadata.GetValueOrDefault("o365:EntityId"),
ContentUrl = metadata.GetValueOrDefault("o365:ContentUrl"),
RemoveUrl = metadata.GetValueOrDefault("o365:RemoveUrl"),
WebsiteUrl = metadata.GetValueOrDefault("o365:WebsiteUrl")
};
await GroupTeamsApi.CreateChannelTabAsync(targetGroupId, channelId, displayName, teamsAppId, configuration, _ignoreExisting, cancel);
_metadata.TryRemove(originalPath, out _);
if (_temporaryFiles.TryRemove(originalPath, out var contentFile))
{
contentFile.Dispose();
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreChannelTabsFailed", ex, $"Failed to restore channel tab {tab.Key}");
}
}
}
private async Task RestoreTeamApps(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var apps = GetMetadataByType(SourceItemType.GroupInstalledApp);
if (apps.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var app in apps)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = app.Key;
var metadata = app.Value;
var teamsAppId = metadata.GetValueOrDefault("o365:TeamsAppId");
if (string.IsNullOrWhiteSpace(teamsAppId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreTeamAppsMissingAppId", null, $"Missing Teams App ID for app {originalPath}, skipping.");
continue;
}
await GroupTeamsApi.InstallTeamAppAsync(targetGroupId, teamsAppId, cancel);
_metadata.TryRemove(originalPath, out _);
if (_temporaryFiles.TryRemove(originalPath, out var contentFile))
{
contentFile.Dispose();
}
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreTeamAppsFailed", ex, $"Failed to restore team app {app.Key}");
}
}
}
private async Task RestoreGroupSettings(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var settings = GetMetadataByType(SourceItemType.GroupSettings);
if (settings.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var setting in settings)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = setting.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupSettingsMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
await GroupApi.UpdateGroupAsync(targetGroupId, contentStream, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupSettingsFailed", ex, $"Failed to restore group settings {setting.Key}");
}
}
}
private async Task RestoreGroupMembers(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var membersList = GetMetadataByType(SourceItemType.GroupMember);
if (membersList.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var members in membersList)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = members.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupMembersMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
List<GraphDirectoryObject>? memberObjects;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
memberObjects = await JsonSerializer.DeserializeAsync<List<GraphDirectoryObject>>(contentStream, cancellationToken: cancel);
}
if (memberObjects != null)
{
foreach (var member in memberObjects)
{
if (cancel.IsCancellationRequested) break;
try
{
await GroupApi.AddGroupMemberAsync(targetGroupId, member.Id, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupMemberFailed", ex, $"Failed to add member {member.Id} to group");
}
}
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupMembersFailed", ex, $"Failed to restore group members {members.Key}");
}
}
}
private async Task RestoreGroupOwners(CancellationToken cancel)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
var ownersList = GetMetadataByType(SourceItemType.GroupOwner);
if (ownersList.Count == 0)
return;
// If ID is missing we already logged warning
var targetGroupId = await ChannelRestore.GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(targetGroupId))
return;
foreach (var owners in ownersList)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = owners.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupOwnersMissingContent", null, $"Missing content for {originalPath}, skipping.");
continue;
}
List<GraphDirectoryObject>? ownerObjects;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
ownerObjects = await JsonSerializer.DeserializeAsync<List<GraphDirectoryObject>>(contentStream, cancellationToken: cancel);
}
if (ownerObjects != null)
{
foreach (var owner in ownerObjects)
{
if (cancel.IsCancellationRequested) break;
try
{
await GroupApi.AddGroupOwnerAsync(targetGroupId, owner.Id, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreGroupOwnerFailed", ex, $"Failed to add owner {owner.Id} to group");
}
}
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreGroupOwnersFailed", ex, $"Failed to restore group owners {owners.Key}");
}
}
}
private ChannelRestoreHelper? _channelRestoreHelper = null;
internal ChannelRestoreHelper ChannelRestore => _channelRestoreHelper ??= new ChannelRestoreHelper(this);
internal class ChannelRestoreHelper(RestoreProvider Provider)
{
private string? _targetGroupId = null;
private bool _hasLoadedGroupId = false;
private ConcurrentDictionary<string, string> _targetChannelIds = new();
private ConcurrentDictionary<string, string> _channelsInExplicitMigrationMode = new();
public async Task<string?> GetTargetGroupId(CancellationToken cancel)
{
if (_hasLoadedGroupId)
return _targetGroupId;
_hasLoadedGroupId = true;
var target = Provider.RestoreTarget;
if (target == null)
throw new InvalidOperationException("Restore target is not set");
if (target.Type == SourceItemType.Group)
{
_targetGroupId = target.Metadata.GetValueOrDefault("o365:Id");
}
else if (target.Type == SourceItemType.GroupTeams)
{
_targetGroupId = target.Metadata.GetValueOrDefault("o365:Id");
}
else if (target.Type == SourceItemType.GroupChannel)
{
_targetGroupId = target.Metadata.GetValueOrDefault("o365:GroupId");
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring chat items.");
}
if (string.IsNullOrWhiteSpace(_targetGroupId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMissingGroupId", null, $"Missing target groupId for restoring chat items.");
return null;
}
return _targetGroupId;
}
public async Task<string?> GetTargetChannelId(string targetGroupId, CancellationToken cancel)
{
if (_targetChannelIds.TryGetValue(targetGroupId, out var channelId))
return channelId;
_targetChannelIds.TryAdd(targetGroupId, null!);
var target = Provider.RestoreTarget;
if (target == null)
throw new InvalidOperationException("Restore target is not set");
if (target.Type == SourceItemType.Group)
{
var groupId = await GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(groupId))
return null;
_targetChannelIds[targetGroupId] = await GetDefaultRestoreTargetChannel(groupId, cancel);
}
else if (target.Type == SourceItemType.GroupTeams)
{
var groupId = await GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(groupId))
return null;
_targetChannelIds[targetGroupId] = await GetDefaultRestoreTargetChannel(groupId, cancel);
}
else if (target.Type == SourceItemType.GroupChannel)
{
var groupId = await GetTargetGroupId(cancel);
if (string.IsNullOrWhiteSpace(groupId))
return null;
channelId = target.Metadata.GetValueOrDefault("o365:Id");
if (string.IsNullOrWhiteSpace(channelId))
return null;
await EnsureChannelInMigrationMode(channelId, groupId, cancel);
_targetChannelIds[targetGroupId] = channelId;
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring chat items.");
return null;
}
var res = _targetChannelIds.GetValueOrDefault(targetGroupId);
if (string.IsNullOrWhiteSpace(res))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMissingIds", null, $"Missing target userId or chatId for restoring chat items.");
return null;
}
return res;
}
private async Task<string> GetDefaultRestoreTargetChannel(string groupId, CancellationToken cancel)
{
const string RESTORED_CHANNEL_NAME = "Restored";
var existing = await Provider.ChannelApi.GetChannelByNameAsync(groupId, RESTORED_CHANNEL_NAME, cancel);
if (existing != null)
{
await EnsureChannelInMigrationMode(existing.Id, groupId, cancel);
return existing.Id;
}
var created = await Provider.GroupTeamsApi.CreateTeamChannelAsync(groupId, RESTORED_CHANNEL_NAME, "Restored content", null, cancel);
await EnsureChannelInMigrationMode(created.Id, groupId, cancel);
return created.Id;
}
public async Task EnsureChannelInMigrationMode(string channelId, string groupId, CancellationToken cancel)
{
if (_channelsInExplicitMigrationMode.ContainsKey(groupId))
return;
await Provider.ChannelApi.StartChannelMigrationAsync(groupId, channelId, cancel);
_channelsInExplicitMigrationMode[groupId] = channelId;
}
public async Task EndMigrationMode(CancellationToken cancel)
{
foreach (var kvp in _channelsInExplicitMigrationMode)
{
try
{
await Provider.ChannelApi.CompleteChannelMigrationAsync(kvp.Key, kvp.Value, cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "EndMigrationModeFailed", ex, $"Failed to end migration mode for channel {kvp.Value} in group {kvp.Key}");
}
}
_channelsInExplicitMigrationMode.Clear();
}
}
}