1403 lines
58 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|