diff --git a/proprietary/GoogleWorkspace/APIHelper.cs b/proprietary/GoogleWorkspace/APIHelper.cs
index f59541b26..40fcbce66 100644
--- a/proprietary/GoogleWorkspace/APIHelper.cs
+++ b/proprietary/GoogleWorkspace/APIHelper.cs
@@ -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;
diff --git a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs
index 9488f6387..0fc9c6d51 100644
--- a/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs
+++ b/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs
@@ -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;
+///
+/// Helper class for JSON serialization that respects Newtonsoft.Json ignore attributes
+/// when using System.Text.Json.
+///
+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 }
+ }
+ };
+ }
+
+ ///
+ /// Gets the JSON serializer options that respect Newtonsoft.Json ignore attributes.
+ ///
+ public static JsonSerializerOptions Options => _options;
+
+ ///
+ /// Modifier that ignores properties marked with Newtonsoft.Json.JsonIgnoreAttribute.
+ ///
+ private static void RespectNewtonsoftJsonIgnore(JsonTypeInfo typeInfo)
+ {
+ // Collect properties to remove
+ var propertiesToRemove = new List();
+
+ 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 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 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
+ {
+ 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();
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(contentStream, cancellationToken: cancel);
+ spaceData = await JsonSerializer.DeserializeAsync(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(contentStream, cancellationToken: cancel);
+ messageData = await JsonSerializer.DeserializeAsync(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)
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageContentSourceEntry.cs
similarity index 73%
rename from proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs
rename to proprietary/GoogleWorkspace/SourceItems/ChatMessageContentSourceEntry.cs
index 137e51010..0553274c3 100644
--- a/proprietary/GoogleWorkspace/SourceItems/ChatMessageFileSourceEntry.cs
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageContentSourceEntry.cs
@@ -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 OpenRead(CancellationToken cancellationToken)
- {
- var json = JsonSerializer.Serialize(message, new JsonSerializerOptions { WriteIndented = true });
- return Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(json)));
- }
+ => Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(message))));
public override Task> GetMinorMetadata(CancellationToken cancellationToken)
{
return Task.FromResult(new Dictionary
{
{ "gsuite:v", "1" },
- { "gsuite:Type", SourceItemType.ChatMessage.ToString() },
+ { "gsuite:Type", SourceItemType.ChatMessageContent.ToString() },
{ "gsuite:Name", "message.json" },
{ "gsuite:Id", message.Name }
}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs
index 5e23acfe3..1fcc9538c 100644
--- a/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatMessageSourceEntry.cs
@@ -17,7 +17,7 @@ internal class ChatMessageSourceEntry(string parentPath, Message message, Hangou
public override async IAsyncEnumerable 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))
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSpaceContentSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceContentSourceEntry.cs
new file mode 100644
index 000000000..2c8b83bb2
--- /dev/null
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceContentSourceEntry.cs
@@ -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 OpenRead(CancellationToken cancellationToken)
+ => Task.FromResult(new MemoryStream(System.Text.Encoding.UTF8.GetBytes(JsonSerializer.Serialize(space))));
+
+ public override Task> GetMinorMetadata(CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new Dictionary
+ {
+ { "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));
+ }
+}
diff --git a/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs
index c3de50ed6..4bcbfcaf7 100644
--- a/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs
+++ b/proprietary/GoogleWorkspace/SourceItems/ChatSpaceSourceEntry.cs
@@ -14,6 +14,8 @@ internal class ChatSpaceSourceEntry(string parentPath, Space space, HangoutsChat
{
public override async IAsyncEnumerable 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));
diff --git a/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs
index d013bb3fa..e45855b31 100644
--- a/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs
+++ b/proprietary/GoogleWorkspace/SourceItems/SourceItemType.cs
@@ -55,6 +55,8 @@ public enum SourceItemType
Site,
ChatSpaceType,
ChatSpace,
+ ChatSpaceContent,
ChatMessage,
+ ChatMessageContent,
ChatAttachment
}