Files
duplicati/proprietary/GoogleWorkspace/RestoreProvider/RestoreProvider.Chat.cs
T

389 lines
17 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Text.Json;
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;
partial class RestoreProvider
{
private ChatRestoreHelper? _chatRestoreHelper = null;
internal ChatRestoreHelper ChatRestore => _chatRestoreHelper ??= new ChatRestoreHelper(this);
internal class ChatRestoreHelper(RestoreProvider Provider)
{
private string? _targetUserId = null;
private bool _hasLoadedTargetInfo = false;
public async Task<string?> GetUserIdAndChatTarget(CancellationToken cancel)
{
if (_hasLoadedTargetInfo)
return _targetUserId;
var target = Provider.RestoreTarget;
if (target == null)
throw new InvalidOperationException("Restore target is not set");
// Only restore Chat messages if the target is a User or UserChat
if (target.Type == SourceItemType.User)
{
_targetUserId = target.Metadata.GetValueOrDefault("gsuite:Id");
}
else if (target.Type == SourceItemType.UserChat)
{
_targetUserId = target.Path.TrimStart(Path.DirectorySeparatorChar).Split(Path.DirectorySeparatorChar).Skip(1).FirstOrDefault();
}
else if (target.Type == SourceItemType.ChatSpace)
{
_targetUserId = target.Metadata.GetValueOrDefault("gsuite:UserId");
}
else
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatInvalidTargetType", null, $"Restore target type {target.Type} is not valid for restoring chat messages. Chat messages can only be restored to a User, UserChat, or ChatSpace target.");
}
_hasLoadedTargetInfo = true;
if (string.IsNullOrWhiteSpace(_targetUserId))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMissingUserId", null, $"Missing target userId for restoring chat messages.");
return null;
}
return _targetUserId;
}
public async Task<string?> CreateSpace(string userId, Space space, CancellationToken cancel)
{
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)
{
try
{
var existingSpaces = await chatService.Spaces.List().ExecuteAsync(cancel);
var duplicate = existingSpaces.Spaces?.FirstOrDefault(s =>
s.DisplayName?.Equals(space.DisplayName, StringComparison.OrdinalIgnoreCase) == true);
if (duplicate != null)
{
Log.WriteInformationMessage(LOGTAG, "CreateSpaceSkipDuplicate", $"Space with name '{space.DisplayName}' already exists, skipping.");
return duplicate.Name;
}
}
catch
{
// List might fail, continue with creation
}
}
// 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;
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;
}
public async Task<string?> CreateMessage(string userId, string spaceName, Message message, CancellationToken cancel)
{
var chatService = Provider._apiHelper.GetChatService(userId);
// Clean up properties that shouldn't be sent on creation
message.Name = null;
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
// For restore purposes, we attempt to create the message
try
{
var createdMessage = await chatService.Spaces.Messages.Create(message, spaceName).ExecuteAsync(cancel);
return createdMessage.Name;
}
catch (Exception ex)
{
Log.WriteWarningMessage(LOGTAG, "CreateMessageFailed", ex, $"Failed to create message in space {spaceName}. Note: Chat API has limitations on message creation.");
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;
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)
{
if (RestoreTarget == null)
throw new InvalidOperationException("Restore target entry is not set");
// Note: Google Chat API has significant limitations for restoring 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);
if (spaces.Count == 0 && messages.Count == 0)
return;
var userId = await ChatRestore.GetUserIdAndChatTarget(cancel);
if (string.IsNullOrWhiteSpace(userId))
return;
// 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)
break;
try
{
var originalPath = space.Key;
var contentJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "content.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(contentJsonPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatSpacesMissingContent", null, $"Missing content for space {originalPath}, skipping.");
continue;
}
Space? spaceData;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
spaceData = await JsonSerializer.DeserializeAsync<Space>(contentStream, GoogleApiJsonDeserializer.Options, cancellationToken: cancel);
}
if (spaceData == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatSpacesInvalidContent", null, $"Invalid content for space {originalPath}, skipping.");
continue;
}
var createdSpaceName = await ChatRestore.CreateSpace(userId, spaceData, cancel);
if (!string.IsNullOrEmpty(createdSpaceName))
{
spacePathToNameMap[originalPath] = createdSpaceName;
}
_metadata.TryRemove(originalPath, out _);
_metadata.TryRemove(contentJsonPath, out _);
_temporaryFiles.TryRemove(contentJsonPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreChatSpacesFailed", ex, $"Failed to restore Chat space {space.Key}");
}
}
// 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)
{
if (cancel.IsCancellationRequested)
break;
try
{
var originalPath = message.Key;
var messageJsonPath = SystemIO.IO_OS.PathCombine(originalPath, "message.json");
var contentEntry = _temporaryFiles.GetValueOrDefault(messageJsonPath);
if (contentEntry == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingContent", null, $"Missing content for message {originalPath}, skipping.");
continue;
}
// 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)) ?? "");
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))
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesMissingSpace", null, $"Missing space name for message {originalPath}, skipping.");
continue;
}
Message? messageData;
using (var contentStream = SystemIO.IO_OS.FileOpenRead(contentEntry))
{
messageData = await JsonSerializer.DeserializeAsync<Message>(contentStream, GoogleApiJsonDeserializer.Options, cancellationToken: cancel);
}
if (messageData == null)
{
Log.WriteWarningMessage(LOGTAG, "RestoreChatMessagesInvalidContent", null, $"Invalid content for message {originalPath}, skipping.");
continue;
}
// 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 _);
_metadata.TryRemove(messageJsonPath, out _);
_temporaryFiles.TryRemove(messageJsonPath, out var contentFile);
contentFile?.Dispose();
}
catch (Exception ex)
{
Log.WriteErrorMessage(LOGTAG, "RestoreChatMessagesFailed", ex, $"Failed to restore Chat message {message.Key}");
}
}
}
}