Implemented chat restore

This commit is contained in:
Kenneth Skovhede
2026-02-16 11:31:19 +01:00
parent 42bf8f217c
commit 61dcb52863
7 changed files with 284 additions and 28 deletions
+6
View File
@@ -225,6 +225,12 @@ public class APIHelper
return new HangoutsChatService(GetServiceInitializer(userId, scopes));
}
public HangoutsChatService GetChatCreateSpaceService(string userId)
{
var scopes = new[] { HangoutsChatService.Scope.ChatSpaces };
return new HangoutsChatService(GetServiceInitializer(userId, scopes));
}
private class CompositeHttpClientInitializer : IConfigurableHttpClientInitializer
{
private readonly IConfigurableHttpClientInitializer[] _initializers;
@@ -1,13 +1,70 @@
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
using System.Text.Json.Serialization.Metadata;
using Duplicati.Library.Common.IO;
using Duplicati.Library.Logging;
using Duplicati.Proprietary.GoogleWorkspace.SourceItems;
using Google.Apis.HangoutsChat.v1.Data;
using Google.Apis.Upload;
namespace Duplicati.Proprietary.GoogleWorkspace;
/// <summary>
/// Helper class for JSON serialization that respects Newtonsoft.Json ignore attributes
/// when using System.Text.Json.
/// </summary>
internal static class GoogleChatJsonHelper
{
private static readonly JsonSerializerOptions _options;
static GoogleChatJsonHelper()
{
_options = new JsonSerializerOptions
{
PropertyNamingPolicy = null, // Use exact C# member names (PascalCase)
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { RespectNewtonsoftJsonIgnore }
}
};
}
/// <summary>
/// Gets the JSON serializer options that respect Newtonsoft.Json ignore attributes.
/// </summary>
public static JsonSerializerOptions Options => _options;
/// <summary>
/// Modifier that ignores properties marked with Newtonsoft.Json.JsonIgnoreAttribute.
/// </summary>
private static void RespectNewtonsoftJsonIgnore(JsonTypeInfo typeInfo)
{
// Collect properties to remove
var propertiesToRemove = new List<JsonPropertyInfo>();
foreach (var property in typeInfo.Properties)
{
// Check if the property has Newtonsoft.Json.JsonIgnoreAttribute
var propertyInfo = property.AttributeProvider as System.Reflection.PropertyInfo;
if (propertyInfo != null)
{
var hasJsonIgnore = propertyInfo.GetCustomAttributes(typeof(Newtonsoft.Json.JsonIgnoreAttribute), inherit: true).Any();
if (hasJsonIgnore)
{
propertiesToRemove.Add(property);
}
}
}
// Remove the ignored properties from the type info
foreach (var property in propertiesToRemove)
{
typeInfo.Properties.Remove(property);
}
}
}
partial class RestoreProvider
{
private ChatRestoreHelper? _chatRestoreHelper = null;
@@ -58,10 +115,13 @@ partial class RestoreProvider
public async Task<string?> CreateSpace(string userId, Space space, CancellationToken cancel)
{
var chatService = Provider._apiHelper.GetChatService(userId);
var chatService = Provider._apiHelper.GetChatCreateSpaceService(userId);
if (string.IsNullOrWhiteSpace(space.DisplayName))
space.DisplayName = "Restored Space";
// Check for duplicates by display name if available
if (!Provider._ignoreExisting && !string.IsNullOrWhiteSpace(space.DisplayName))
if (!Provider._ignoreExisting)
{
try
{
@@ -81,11 +141,41 @@ partial class RestoreProvider
}
}
// Store the original space history state to set after creation
var originalHistoryState = space.SpaceHistoryState;
// Clean up properties that shouldn't be sent on creation
space.Name = null;
#pragma warning disable CS0618 // Type or member is obsolete
space.CreateTime = null;
#pragma warning restore CS0618 // Type or member is obsolete
space.CreateTimeDateTimeOffset = null;
space.SpaceHistoryState = null;
space.Type = null;
space.SpaceType = "SPACE";
space.SingleUserBotDm = null;
var createdSpace = await chatService.Spaces.Create(space).ExecuteAsync(cancel);
// If there was a history state, update the space to set it
if (!string.IsNullOrEmpty(originalHistoryState) && !string.IsNullOrEmpty(createdSpace.Name))
{
try
{
var updateSpace = new Space
{
SpaceHistoryState = originalHistoryState
};
var updateRequest = chatService.Spaces.Patch(updateSpace, createdSpace.Name);
updateRequest.UpdateMask = "spaceHistoryState";
await updateRequest.ExecuteAsync(cancel);
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "UpdateSpaceHistoryStateFailed", ex, $"Failed to update space history state for {createdSpace.Name}");
}
}
return createdSpace.Name;
}
@@ -101,6 +191,7 @@ partial class RestoreProvider
#pragma warning restore CS0618 // Type or member is obsolete
message.CreateTimeDateTimeOffset = null;
message.LastUpdateTimeDateTimeOffset = null;
message.AccessoryWidgets = null;
// Note: Creating messages in Chat API has limitations
// Messages can typically only be created by the user who is sending them
@@ -116,6 +207,75 @@ partial class RestoreProvider
return null;
}
}
public async Task<string?> CreateMessageWithAttachment(string userId, string spaceName, Message message, string attachmentFileName, Stream attachmentStream, string? contentType, CancellationToken cancel)
{
var chatService = Provider._apiHelper.GetChatService(userId);
// Clean up properties that shouldn't be sent on creation
message.Name = null;
#pragma warning disable CS0618 // Type or member is obsolete
message.CreateTime = null;
message.LastUpdateTime = null;
#pragma warning restore CS0618 // Type or member is obsolete
message.CreateTimeDateTimeOffset = null;
message.LastUpdateTimeDateTimeOffset = null;
try
{
// For Chat API, attachments need to be handled differently
// We upload the attachment to Drive first, then reference it in the message
// This is a workaround since the Chat API doesn't support direct binary uploads
// in the same way as other Google APIs
var driveService = Provider._apiHelper.GetDriveService(userId);
// Upload attachment to Drive
var fileMetadata = new Google.Apis.Drive.v3.Data.File
{
Name = attachmentFileName
};
var uploadRequest = driveService.Files.Create(fileMetadata, attachmentStream, contentType ?? "application/octet-stream");
var uploadResult = await uploadRequest.UploadAsync(cancel);
if (uploadResult.Status == UploadStatus.Failed)
{
Log.WriteWarningMessage(LOGTAG, "CreateMessageAttachmentUploadFailed", uploadResult.Exception, $"Failed to upload attachment to Drive for message in space {spaceName}.");
// Fall back to creating message without attachment
return await CreateMessage(userId, spaceName, message, cancel);
}
var fileId = uploadRequest.ResponseBody?.Id;
if (string.IsNullOrEmpty(fileId))
{
Log.WriteWarningMessage(LOGTAG, "CreateMessageAttachmentNoFileId", null, $"No file ID returned after uploading attachment for message in space {spaceName}.");
return await CreateMessage(userId, spaceName, message, cancel);
}
// Set up attachment in the message
message.Attachment = new List<Attachment>
{
new Attachment
{
DriveDataRef = new DriveDataRef
{
DriveFileId = fileId
},
ContentName = attachmentFileName,
ContentType = contentType
}
};
// Create the message with the Drive attachment reference
var createdMessage = await chatService.Spaces.Messages.Create(message, spaceName).ExecuteAsync(cancel);
return createdMessage.Name;
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "CreateMessageWithAttachmentFailed", ex, $"Failed to create message with attachment in space {spaceName}. Note: Chat API has limitations on message creation.");
return null;
}
}
}
private async Task RestoreChatMessages(CancellationToken cancel)
@@ -124,8 +284,8 @@ partial class RestoreProvider
throw new InvalidOperationException("Restore target entry is not set");
// Note: Google Chat API has significant limitations for restoring messages
// Messages can typically only be created by the user who authored them
// This implementation attempts to restore spaces but may not be able to restore messages
// Messages will appear as being created by the impersonated user, not the original sender
// To fix this, the Import API would need to be used
var spaces = GetMetadataByType(SourceItemType.ChatSpace);
var messages = GetMetadataByType(SourceItemType.ChatMessage);
@@ -137,7 +297,8 @@ partial class RestoreProvider
if (string.IsNullOrWhiteSpace(userId))
return;
// First, restore spaces
// First, restore spaces and build a map of original space paths to created space names
var spacePathToNameMap = new Dictionary<string, string>();
foreach (var space in spaces)
{
if (cancel.IsCancellationRequested)
@@ -146,7 +307,8 @@ partial class RestoreProvider
try
{
var originalPath = space.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
var contentJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(contentJsonPath);
if (contentEntry == null)
{
@@ -157,7 +319,7 @@ partial class RestoreProvider
Space? spaceData;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
spaceData = await JsonSerializer.DeserializeAsync<Space>(contentStream, cancellationToken: cancel);
spaceData = await JsonSerializer.DeserializeAsync<Space>(contentStream, GoogleChatJsonHelper.Options, cancellationToken: cancel);
}
if (spaceData == null)
@@ -166,10 +328,15 @@ partial class RestoreProvider
continue;
}
await ChatRestore.CreateSpace(userId, spaceData, cancel);
var createdSpaceName = await ChatRestore.CreateSpace(userId, spaceData, cancel);
if (!string.IsNullOrEmpty(createdSpaceName))
{
spacePathToNameMap[originalPath] = createdSpaceName;
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
_metadata.TryRemove(contentJsonPath, out _);
_temporaryFiles.TryRemove(contentJsonPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
@@ -178,11 +345,10 @@ partial class RestoreProvider
}
}
// Log warning about message restoration limitations
if (messages.Count > 0)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesLimitation", null, $"Found {messages.Count} chat messages to restore. Note: Google Chat API has limitations on message creation - messages can typically only be created by the original author. These messages may not be restorable.");
}
// Group attachments by message path
var attachments = GetMetadataByType(SourceItemType.ChatAttachment)
.GroupBy(k => Util.AppendDirSeparator(Path.GetDirectoryName(k.Key.TrimEnd(Path.DirectorySeparatorChar)) ?? ""))
.ToDictionary(g => g.Key, g => g.ToList());
// Attempt to restore messages (will likely fail due to API limitations)
foreach (var message in messages)
@@ -193,7 +359,8 @@ partial class RestoreProvider
try
{
var originalPath = message.Key;
var contentEntry = _temporaryFiles.GetValueOrDefault(originalPath);
var messageJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "message.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(messageJsonPath);
if (contentEntry == null)
{
@@ -201,9 +368,14 @@ partial class RestoreProvider
continue;
}
// Get space name from parent path
// Get space name from the restored spaces map
// The message's parent path corresponds to the space path
var parentPath = Util.AppendDirSeparator(Path.GetDirectoryName(originalPath.TrimEnd(Path.DirectorySeparatorChar)) ?? "");
var spaceName = message.Value.GetValueOrDefault("gsuite:SpaceName");
if (!spacePathToNameMap.TryGetValue(parentPath, out var spaceName) || string.IsNullOrWhiteSpace(spaceName))
{
// Fallback to metadata if available
spaceName = message.Value.GetValueOrDefault("gsuite:SpaceName");
}
if (string.IsNullOrWhiteSpace(spaceName))
{
@@ -214,7 +386,7 @@ partial class RestoreProvider
Message? messageData;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
messageData = await JsonSerializer.DeserializeAsync<Message>(contentStream, cancellationToken: cancel);
messageData = await JsonSerializer.DeserializeAsync<Message>(contentStream, GoogleChatJsonHelper.Options, cancellationToken: cancel);
}
if (messageData == null)
@@ -223,10 +395,55 @@ partial class RestoreProvider
continue;
}
await ChatRestore.CreateMessage(userId, spaceName, messageData, cancel);
// Check for attachments
var messagePathWithSep = Util.AppendDirSeparator(originalPath);
if (attachments.TryGetValue(messagePathWithSep, out var messageAttachments) && messageAttachments.Count > 0)
{
// For now, we only support one attachment per message via media upload
// Additional attachments would need to be handled separately
var att = messageAttachments.First();
var attPath = att.Key;
var attMetadata = att.Value;
var attFileName = attMetadata.GetValueOrDefault("gsuite:Name") ?? "attachment";
var attContentType = attMetadata.GetValueOrDefault("gsuite:ContentType") ?? "application/octet-stream";
if (_temporaryFiles.TryRemove(attPath, out var attContent))
{
try
{
using var attStream = SystemIO.IO_OS.FileOpenRead(attContent);
await ChatRestore.CreateMessageWithAttachment(userId, spaceName, messageData, attFileName, attStream, attContentType, cancel);
// Clean up all attachments for this message
foreach (var attachment in messageAttachments)
{
_metadata.TryRemove(attachment.Key, out _);
if (_temporaryFiles.TryRemove(attachment.Key, out var attFile))
{
attFile?.Dispose();
}
}
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessageAttachmentFailed", ex, $"Failed to restore attachment for message {originalPath}");
// Fall back to creating message without attachment
await ChatRestore.CreateMessage(userId, spaceName, messageData, cancel);
}
}
else
{
await ChatRestore.CreateMessage(userId, spaceName, messageData, cancel);
}
}
else
{
await ChatRestore.CreateMessage(userId, spaceName, messageData, cancel);
}
_metadata.TryRemove(originalPath, out _);
_temporaryFiles.TryRemove(originalPath, out var contentFile);
_metadata.TryRemove(messageJsonPath, out _);
_temporaryFiles.TryRemove(messageJsonPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
@@ -6,7 +6,7 @@ using System.Text.Json;
namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
internal class ChatMessageFileSourceEntry(string parentPath, Message message)
internal class ChatMessageContentSourceEntry(string parentPath, Message message)
: StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "message.json"),
message.CreateTimeDateTimeOffset.HasValue ? message.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
DateTime.UnixEpoch)
@@ -14,17 +14,14 @@ internal class ChatMessageFileSourceEntry(string parentPath, Message message)
public override long Size => -1;
public override Task<Stream> OpenRead(CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = true });
return Task.FromResult<Stream>(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
}
=> Task.FromResult<Stream>(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message))));
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
{
return Task.FromResult(new Dictionary<string, string?>
{
{ "gsuite:v", "1" },
{ "gsuite:Type", SourceItemType.ChatMessage.ToString() },
{ "gsuite:Type", SourceItemType.ChatMessageContent.ToString() },
{ "gsuite:Name", "message.json" },
{ "gsuite:Id", message.Name }
}
@@ -17,7 +17,7 @@ internal class ChatMessageSourceEntry(string parentPath, Message message, Hangou
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested) yield break;
yield return new ChatMessageFileSourceEntry(this.Path, message);
yield return new ChatMessageContentSourceEntry(this.Path, message);
if (message.Attachment != null)
{
@@ -36,6 +36,7 @@ internal class ChatMessageSourceEntry(string parentPath, Message message, Hangou
{ "gsuite:v", "1" },
{ "gsuite:Type", SourceItemType.ChatMessage.ToString() },
{ "gsuite:Name", CapMessage(message.ArgumentText ?? message.Name) },
{ "gsuite:SpaceName", message.Space.Name },
{ "gsuite:Id", message.Name }
}
.Where(kv => !string.IsNullOrEmpty(kv.Value))
@@ -0,0 +1,31 @@
// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
using Duplicati.Library.Common.IO;
using Google.Apis.HangoutsChat.v1.Data;
namespace Duplicati.Proprietary.GoogleWorkspace.SourceItems;
internal class ChatSpaceContentSourceEntry(string parentPath, Space space)
: StreamResourceEntryBase(SystemIO.IO_OS.PathCombine(parentPath, "content.json"),
space.CreateTimeDateTimeOffset.HasValue ? space.CreateTimeDateTimeOffset.Value.UtcDateTime : DateTime.UnixEpoch,
DateTime.UnixEpoch)
{
public override long Size => -1;
public override Task<Stream> OpenRead(CancellationToken cancellationToken)
=> Task.FromResult<Stream>(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(space))));
public override Task<Dictionary<string, string?>> GetMinorMetadata(CancellationToken cancellationToken)
{
return Task.FromResult(new Dictionary<string, string?>
{
{ "gsuite:v", "1" },
{ "gsuite:Type", SourceItemType.ChatSpaceContent.ToString() },
{ "gsuite:Name", "message.json" },
{ "gsuite:Id", space.Name }
}
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
}
}
@@ -14,6 +14,8 @@ internal class ChatSpaceSourceEntry(string parentPath, Space space, HangoutsChat
{
public override async IAsyncEnumerable<ISourceProviderEntry> Enumerate([EnumeratorCancellation] CancellationToken cancellationToken)
{
yield return new ChatSpaceContentSourceEntry(this.Path, space);
var request = chatService.Spaces.Messages.List(space.Name);
string? nextPageToken = null;
@@ -43,7 +45,7 @@ internal class ChatSpaceSourceEntry(string parentPath, Space space, HangoutsChat
{ "gsuite:Type", SourceItemType.ChatSpace.ToString() },
{ "gsuite:Name", space.DisplayName ?? space.Name },
{ "gsuite:Id", space.Name },
{ "gsuite:SpaceType", space.Type }
{ "gsuite:SpaceType", space.Type },
}
.Where(kv => !string.IsNullOrEmpty(kv.Value))
.ToDictionary(kv => kv.Key, kv => kv.Value));
@@ -55,6 +55,8 @@ public enum SourceItemType
Site,
ChatSpaceType,
ChatSpace,
ChatSpaceContent,
ChatMessage,
ChatMessageContent,
ChatAttachment
}