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

205 lines
8.8 KiB
C#

// Copyright (c) 2026 Duplicati Inc. All rights reserved.
using System.Globalization;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace Duplicati.Proprietary.Office365;
public partial class RestoreProvider
{
internal ChannelApiImpl ChannelApi => new ChannelApiImpl(_apiHelper);
internal class ChannelApiImpl(APIHelper provider)
{
internal async Task<GraphChannel> CreateChannelInMigrationModeAsync(
string teamId,
string displayName,
string? description,
DateTimeOffset createdDateTimeUtc,
CancellationToken ct)
{
// Requires:
// - Application permission: Teamwork.Migrate.All
// - Channel.Create (or equivalent per tenant configuration)
// Notes:
// - Migration-mode channels are standard channels.
// - createdDateTime must be in the past (not in the future).
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(teamId);
var url = $"{baseUrl}/v1.0/teams/{team}/channels";
// Need to emit the instance annotation property name exactly:
// "@microsoft.graph.channelCreationMode": "migration"
var payload = new Dictionary<string, object?>
{
["displayName"] = displayName,
["description"] = description,
["createdDateTime"] = createdDateTimeUtc.UtcDateTime.ToString("o", CultureInfo.InvariantCulture),
["@microsoft.graph.channelCreationMode"] = "migration"
};
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.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<GraphChannel>(stream, cancellationToken: ct).ConfigureAwait(false);
if (created is null || string.IsNullOrWhiteSpace(created.Id))
throw new InvalidOperationException("Graph did not return the created channel id.");
return created;
}
internal async Task StartChannelMigrationAsync(string teamId, string channelId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(teamId);
var channel = Uri.EscapeDataString(channelId);
// Beta-only
var url = $"{baseUrl}/beta/teams/{team}/channels/{channel}/startMigration";
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.Content = new StringContent("{}", Encoding.UTF8, "application/json");
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
if (resp.IsSuccessStatusCode)
return;
// Idempotency: treat "already in migration mode" as success.
if (resp.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
string bodyText = "";
try { bodyText = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); } catch { /* ignore */ }
if (bodyText.IndexOf("already in migration mode", StringComparison.OrdinalIgnoreCase) >= 0)
return;
}
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
internal async Task CompleteChannelMigrationAsync(string teamId, string channelId, CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(teamId);
var channel = Uri.EscapeDataString(channelId);
// v1.0 supported action
var url = $"{baseUrl}/v1.0/teams/{team}/channels/{channel}/completeMigration";
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.Content = new StringContent("{}", Encoding.UTF8, "application/json");
return req;
}
using var resp = await provider.SendWithRetryShortAsync(requestFactory, ct).ConfigureAwait(false);
await APIHelper.EnsureOfficeApiSuccessAsync(resp, ct).ConfigureAwait(false);
}
internal async Task<GraphChannel?> GetChannelByNameAsync(
string groupId,
string channelName,
CancellationToken ct)
{
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(groupId);
// Channels do NOT support $filter reliably on displayName,
// so enumerate and match client-side.
var url = $"{baseUrl}/v1.0/teams/{team}/channels";
await foreach (var channel in provider.GetAllGraphItemsAsync<GraphChannel>(url, ct))
{
if (string.Equals(channel.DisplayName, channelName, StringComparison.OrdinalIgnoreCase))
return channel;
}
return null;
}
internal async Task<GraphChatMessage> ImportChannelMessageAsync(
string teamId,
string channelId,
string senderUserId,
string senderDisplayName,
DateTimeOffset createdDateTimeUtc,
string htmlContent,
string? replyToId,
CancellationToken ct)
{
// Requires:
// - Application permission: Teamwork.Migrate.All
// - The channel must be in migration mode (new channel created with @microsoft.graph.channelCreationMode="migration"
// OR existing channel put into migration mode via /beta/.../startMigration)
var baseUrl = provider.GraphBaseUrl.TrimEnd('/');
var team = Uri.EscapeDataString(teamId);
var channel = Uri.EscapeDataString(channelId);
// Import uses the same endpoint as normal posting, but allows createdDateTime/from when Teamwork.Migrate.All is present.
var url = $"{baseUrl}/v1.0/teams/{team}/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.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;
}
}
}