using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using JiDangJia.Contracts;
namespace JiDangJia.Messaging;
public sealed record ChatSyncResult(int Messages,int Downloaded,int Unavailable,bool MoreMessages,bool Legacy=false);
public sealed class ChatSyncClient(LocalChatStore local,HttpClient http,string token,string deviceId,Func<bool> current)
{
public const int MaxMediaBytes=128*1024*1024;
private static readonly JsonSerializerOptions Json=new(JsonSerializerDefaults.Web);
private void Guard(){if(!current())throw new OperationCanceledException("聊天账户已切换");}
private HttpRequestMessage Request(string path,object? body=null)
{
Guard();var request=new HttpRequestMessage(body is null?HttpMethod.Get:HttpMethod.Post,local.Server+"/api/community"+path);
request.Headers.Authorization=new("Bearer",token);request.Headers.Add("X-Device-Id",deviceId);if(body is not null)request.Content=JsonContent.Create(body);return request;
}
private static void Success(HttpResponseMessage response)
{
if(response.StatusCode==HttpStatusCode.Unauthorized)throw new HttpRequestException("登录已过期,请重新登录后继续同步",null,response.StatusCode);
if(!response.IsSuccessStatusCode)throw new HttpRequestException("暂时无法同步私信,本机记录仍可查看",null,response.StatusCode);
}
public async Task<ChatSyncResult> Synchronize(CancellationToken ct=default,int maxPages=20,int maxMedia=10)
{
Guard();int received=0,downloaded=0,unavailable=0;bool more=false,legacy=false;
foreach(var read in local.PendingReads())
{
using var request=Request("/chat/read",read);using var response=await http.SendAsync(request,ct);Guard();
// An offline read may refer to a message already purged from the server.
if(response.IsSuccessStatusCode||response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound)local.ConfirmRead(read);else Success(response);
}
for(int page=0;page<Math.Clamp(maxPages,1,100);page++)
{
long cursor=local.Cursor;using var request=Request("/chat/sync?after="+cursor);using var response=await http.SendAsync(request,ct);Guard();
if(response.StatusCode==HttpStatusCode.NotFound){(received,more)=await LegacySync(maxPages,ct);legacy=true;break;}
Success(response);
var batch=await response.Content.ReadFromJsonAsync<ChatSyncPage>(Json,ct)??throw new IOException("私信同步结果无效");Guard();
if(batch.HasMore&&batch.Cursor<=cursor)throw new IOException("私信同步没有取得进展");
local.Merge(batch);received+=batch.Items.Count;more=batch.HasMore;if(!more)break;
}
if(!legacy)
{
using var profilesRequest=Request("/chat/conversations");using var profilesResponse=await http.SendAsync(profilesRequest,ct);Guard();Success(profilesResponse);
var profiles=await profilesResponse.Content.ReadFromJsonAsync<List<Conversation>>(Json,ct)??[];Guard();local.UpdateProfiles(profiles);
}
// Download all received/sent attachments, even if their conversation was never opened.
foreach(var media in local.PendingMedia(maxMedia))
{
ct.ThrowIfCancellationRequested();Guard();if(local.ReadMedia(media.Id) is not null)continue;
try
{
using var request=Request("/media/"+media.Id);using var response=await http.SendAsync(request,HttpCompletionOption.ResponseHeadersRead,ct);Guard();
if(response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound){local.MediaUnavailable(media.Id);unavailable++;continue;}
Success(response);if(response.Content.Headers.ContentLength>MaxMediaBytes)throw new IOException("私信附件过大");
await using var stream=await response.Content.ReadAsStreamAsync(ct);using var data=new MemoryStream();var buffer=new byte[65536];int read;
while((read=await stream.ReadAsync(buffer,ct))>0){if(data.Length+read>MaxMediaBytes)throw new IOException("私信附件过大");await data.WriteAsync(buffer.AsMemory(0,read),ct);}
Guard();local.SaveMedia(media.Id,data.ToArray());downloaded++;
}
catch(HttpRequestException ex)when(ex.StatusCode!=HttpStatusCode.Unauthorized){Guard();local.RetryMediaLater(media.Id);}
catch(IOException){Guard();local.RetryMediaLater(media.Id);}
}
return new ChatSyncResult(received,downloaded,unavailable,more,legacy);
}
private async Task<(int Messages,bool More)> LegacySync(int maxPages,CancellationToken ct)
{
using var request=Request("/chat/conversations");using var response=await http.SendAsync(request,ct);Guard();Success(response);
var conversations=await response.Content.ReadFromJsonAsync<List<Conversation>>(Json,ct)??[];Guard();local.UpdateProfiles(conversations);if(conversations.Count==0)return (0,false);
int first=conversations.FindIndex(c=>c.PeerId==local.LegacyNextPeer);if(first<0)first=0;
int remaining=Math.Clamp(maxPages,1,100),received=0;bool more=false;
for(int i=0;i<conversations.Count&&remaining>0;i++)
{
var peer=conversations[(first+i)%conversations.Count];var position=local.LegacyPosition(peer.PeerId);bool newerMore;
do
{
using var pageRequest=Request("/chat/messages?peerId="+Uri.EscapeDataString(peer.PeerId)+"&after="+position.After);
using var pageResponse=await http.SendAsync(pageRequest,ct);Guard();Success(pageResponse);
var batch=await pageResponse.Content.ReadFromJsonAsync<MessagePage>(Json,ct)??throw new IOException("旧版私信数据无效");Guard();
newerMore=position.After>0&&batch.HasMore;
if(newerMore&&(batch.Items.Count==0||batch.Items.Max(m=>m.Sequence)<=position.After))throw new IOException("旧版消息同步没有取得进展");
local.SaveLegacy(batch,peer,false);received+=batch.Items.Count;remaining--;position=local.LegacyPosition(peer.PeerId);
}while(newerMore&&remaining>0);
while(!position.Complete&&position.Before>0&&remaining>0)
{
using var olderRequest=Request("/chat/messages?peerId="+Uri.EscapeDataString(peer.PeerId)+"&before="+position.Before);
using var olderResponse=await http.SendAsync(olderRequest,ct);Guard();Success(olderResponse);
var batch=await olderResponse.Content.ReadFromJsonAsync<MessagePage>(Json,ct)??throw new IOException("旧版私信数据无效");Guard();
if(batch.HasMore&&(batch.Items.Count==0||batch.Items.Min(m=>m.Sequence)>=position.Before))throw new IOException("旧版历史同步没有取得进展");
local.SaveLegacy(batch,peer,true);received+=batch.Items.Count;remaining--;position=local.LegacyPosition(peer.PeerId);
}
more|=newerMore||!position.Complete;local.LegacyNextPeer=conversations[(first+i+1)%conversations.Count].PeerId;
if(remaining==0&&i+1<conversations.Count)more=true;
}
return (received,more);
}
}
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using JiDangJia.Contracts;
namespace JiDangJia.Messaging;
public sealed record ChatSyncResult(int Messages,int Downloaded,int Unavailable,bool MoreMessages,bool Legacy=false);
public sealed class ChatSyncClient(LocalChatStore local,HttpClient http,string token,string deviceId,Func<bool> current)
{
public const int MaxMediaBytes=128*1024*1024;
private static readonly JsonSerializerOptions Json=new(JsonSerializerDefaults.Web);
private void Guard(){if(!current())throw new OperationCanceledException("聊天账户已切换");}
private HttpRequestMessage Request(string path,object? body=null)
{
Guard();var request=new HttpRequestMessage(body is null?HttpMethod.Get:HttpMethod.Post,local.Server+"/api/community"+path);
request.Headers.Authorization=new("Bearer",token);request.Headers.Add("X-Device-Id",deviceId);if(body is not null)request.Content=JsonContent.Create(body);return request;
}
private static void Success(HttpResponseMessage response)
{
if(response.StatusCode==HttpStatusCode.Unauthorized)throw new HttpRequestException("登录已过期,请重新登录后继续同步",null,response.StatusCode);
if(!response.IsSuccessStatusCode)throw new HttpRequestException("暂时无法同步私信,本机记录仍可查看",null,response.StatusCode);
}
public async Task<ChatSyncResult> Synchronize(CancellationToken ct=default,int maxPages=20,int maxMedia=10)
{
Guard();int received=0,downloaded=0,unavailable=0;bool more=false,legacy=false;
foreach(var read in local.PendingReads())
{
using var request=Request("/chat/read",read);using var response=await http.SendAsync(request,ct);Guard();
// An offline read may refer to a message already purged from the server.
if(response.IsSuccessStatusCode||response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.NotFound)local.ConfirmRead(read);else Success(response);
}
for(int page=0;page<Math.Clamp(maxPages,1,100);page++)
{
long cursor=local.Cursor;using var request=Request("/chat/sync?after="+cursor);using var response=await http.SendAsync(request,ct);Guard();
if(response.StatusCode==HttpStatusCode.NotFound){(received,more)=await LegacySync(maxPages,ct);legacy=true;break;}
Success(response);
var batch=await response.Content.ReadFromJsonAsync<ChatSyncPage>(Json,ct)??throw new IOException("私信同步结果无效");Guard();
if(batch.HasMore&&batch.Cursor<=cursor)throw new IOException("私信同步没有取得进展");
local.Merge(batch);received+=batch.Items.Count;more=batch.HasMore;if(!more)break;
}
if(!legacy)
{
using var profilesRequest=Request("/chat/conversations");using var profilesResponse=await http.SendAsync(profilesRequest,ct);Guard();Success(profilesResponse);
var profiles=await profilesResponse.Content.ReadFromJsonAsync<List<Conversation>>(Json,ct)??[];Guard();local.UpdateProfiles(profiles);
}
// Download all received/sent attachments, even if their conversation was never opened.
foreach(var media in local.PendingMedia(maxMedia))
{
ct.ThrowIfCancellationRequested();Guard();if(local.ReadMedia(media.Id) is not null)continue;
try
{
using var request=Request("/media/"+media.Id);using var response=await http.SendAsync(request,HttpCompletionOption.ResponseHeadersRead,ct);Guard();
if(response.StatusCode is HttpStatusCode.Gone or HttpStatusCode.NotFound){local.MediaUnavailable(media.Id);unavailable++;continue;}
Success(response);if(response.Content.Headers.ContentLength>MaxMediaBytes)throw new IOException("私信附件过大");
await using var stream=await response.Content.ReadAsStreamAsync(ct);using var data=new MemoryStream();var buffer=new byte[65536];int read;
while((read=await stream.ReadAsync(buffer,ct))>0){if(data.Length+read>MaxMediaBytes)throw new IOException("私信附件过大");await data.WriteAsync(buffer.AsMemory(0,read),ct);}
Guard();local.SaveMedia(media.Id,data.ToArray());downloaded++;
}
catch(HttpRequestException ex)when(ex.StatusCode!=HttpStatusCode.Unauthorized){Guard();local.RetryMediaLater(media.Id);}
catch(IOException){Guard();local.RetryMediaLater(media.Id);}
}
return new ChatSyncResult(received,downloaded,unavailable,more,legacy);
}
private async Task<(int Messages,bool More)> LegacySync(int maxPages,CancellationToken ct)
{
using var request=Request("/chat/conversations");using var response=await http.SendAsync(request,ct);Guard();Success(response);
var conversations=await response.Content.ReadFromJsonAsync<List<Conversation>>(Json,ct)??[];Guard();local.UpdateProfiles(conversations);if(conversations.Count==0)return (0,false);
int first=conversations.FindIndex(c=>c.PeerId==local.LegacyNextPeer);if(first<0)first=0;
int remaining=Math.Clamp(maxPages,1,100),received=0;bool more=false;
for(int i=0;i<conversations.Count&&remaining>0;i++)
{
var peer=conversations[(first+i)%conversations.Count];var position=local.LegacyPosition(peer.PeerId);bool newerMore;
do
{
using var pageRequest=Request("/chat/messages?peerId="+Uri.EscapeDataString(peer.PeerId)+"&after="+position.After);
using var pageResponse=await http.SendAsync(pageRequest,ct);Guard();Success(pageResponse);
var batch=await pageResponse.Content.ReadFromJsonAsync<MessagePage>(Json,ct)??throw new IOException("旧版私信数据无效");Guard();
newerMore=position.After>0&&batch.HasMore;
if(newerMore&&(batch.Items.Count==0||batch.Items.Max(m=>m.Sequence)<=position.After))throw new IOException("旧版消息同步没有取得进展");
local.SaveLegacy(batch,peer,false);received+=batch.Items.Count;remaining--;position=local.LegacyPosition(peer.PeerId);
}while(newerMore&&remaining>0);
while(!position.Complete&&position.Before>0&&remaining>0)
{
using var olderRequest=Request("/chat/messages?peerId="+Uri.EscapeDataString(peer.PeerId)+"&before="+position.Before);
using var olderResponse=await http.SendAsync(olderRequest,ct);Guard();Success(olderResponse);
var batch=await olderResponse.Content.ReadFromJsonAsync<MessagePage>(Json,ct)??throw new IOException("旧版私信数据无效");Guard();
if(batch.HasMore&&(batch.Items.Count==0||batch.Items.Min(m=>m.Sequence)>=position.Before))throw new IOException("旧版历史同步没有取得进展");
local.SaveLegacy(batch,peer,true);received+=batch.Items.Count;remaining--;position=local.LegacyPosition(peer.PeerId);
}
more|=newerMore||!position.Complete;local.LegacyNextPeer=conversations[(first+i+1)%conversations.Count].PeerId;
if(remaining==0&&i+1<conversations.Count)more=true;
}
return (received,more);
}
}