Add MAX contacts and direct chat creation
This commit is contained in:
@@ -52,3 +52,11 @@ public sealed record CreateDirectChatRequest(string ExternalChatId, string Title
|
||||
public sealed record ChatBulkActionRequest(IReadOnlyList<Guid> ChatIds);
|
||||
|
||||
public sealed record ChatPresenceDto(bool IsTyping, string? StatusText, DateTimeOffset UpdatedAt);
|
||||
|
||||
public sealed record ContactDto(
|
||||
string UserId,
|
||||
string ExternalChatId,
|
||||
string DisplayName,
|
||||
string? AvatarUrl,
|
||||
string? PhoneNumber,
|
||||
string? Status);
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using QMax.Api.Contracts;
|
||||
using QMax.Api.Infrastructure.Max;
|
||||
|
||||
namespace QMax.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/contacts")]
|
||||
public sealed class ContactsController(IMaxBridgeClient maxBridge) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<IReadOnlyList<ContactDto>>> GetContacts(CancellationToken cancellationToken)
|
||||
{
|
||||
var contacts = await maxBridge.FetchContactsAsync(cancellationToken);
|
||||
return contacts
|
||||
.Select(contact => new ContactDto(
|
||||
contact.UserId,
|
||||
contact.ExternalChatId,
|
||||
contact.DisplayName,
|
||||
contact.AvatarUrl,
|
||||
contact.PhoneNumber,
|
||||
contact.Status))
|
||||
.OrderBy(contact => contact.DisplayName, StringComparer.CurrentCultureIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ public interface IMaxBridgeClient
|
||||
Task<MaxBridgeStatus> SubmitLoginCodeAsync(string code, CancellationToken cancellationToken);
|
||||
Task<MaxBrowserSnapshot> GetSnapshotAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MaxChatUpdate>> FetchUpdatesAsync(CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>(Array.Empty<MaxContact>());
|
||||
}
|
||||
Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxChatPresence?> FetchChatPresenceAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken);
|
||||
Task<MaxChatUrlResult> ResolveChatUrlAsync(string externalChatId, CancellationToken cancellationToken);
|
||||
|
||||
@@ -73,3 +73,11 @@ public sealed record MaxChannelSearchResult(
|
||||
string? ChatUrl,
|
||||
bool IsSubscribed,
|
||||
string? Description = null);
|
||||
|
||||
public sealed record MaxContact(
|
||||
string UserId,
|
||||
string ExternalChatId,
|
||||
string DisplayName,
|
||||
string? AvatarUrl,
|
||||
string? PhoneNumber,
|
||||
string? Status);
|
||||
|
||||
@@ -45,6 +45,13 @@ public sealed class MockMaxBridgeClient : IMaxBridgeClient
|
||||
return Task.FromResult<IReadOnlyList<MaxChatUpdate>>(_updates);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MaxContact>>([
|
||||
new MaxContact("mock-user", "mock-direct", "Mock contact", null, "+70000000000", "online")
|
||||
]);
|
||||
}
|
||||
|
||||
public Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
var update = _updates.FirstOrDefault(x => x.ExternalId == externalChatId);
|
||||
|
||||
@@ -43,6 +43,12 @@ public sealed class WorkerMaxBridgeClient(
|
||||
?? throw new InvalidOperationException("MAX worker returned an empty updates response.");
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MaxContact>> FetchContactsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<IReadOnlyList<MaxContact>>(HttpMethod.Get, "/contacts", null, cancellationToken)
|
||||
?? Array.Empty<MaxContact>();
|
||||
}
|
||||
|
||||
public async Task<MaxChatUpdate?> FetchChatHistoryAsync(string externalChatId, string? chatUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
return await SendAsync<MaxChatUpdate>(HttpMethod.Post, "/chat/history", new { externalChatId, chatUrl }, cancellationToken);
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.Extensions.Options;
|
||||
using QMax.Api.Configuration;
|
||||
using QMax.Api.Data.Entities;
|
||||
|
||||
namespace QMax.Api.Infrastructure.Storage;
|
||||
|
||||
public sealed record StoredAttachment(
|
||||
string OriginalFileName,
|
||||
string StorageFileName,
|
||||
string ContentType,
|
||||
long FileSizeBytes,
|
||||
string Sha256,
|
||||
AttachmentKind Kind);
|
||||
|
||||
public interface IAttachmentStorageService
|
||||
{
|
||||
Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken);
|
||||
Task<StoredAttachment> SaveRemoteAsync(
|
||||
string fileName,
|
||||
string? contentType,
|
||||
Stream stream,
|
||||
long? expectedLength,
|
||||
AttachmentKind? preferredKind,
|
||||
CancellationToken cancellationToken);
|
||||
string GetPath(string storageFileName);
|
||||
}
|
||||
|
||||
public sealed class AttachmentStorageService(IOptions<QMaxOptions> options) : IAttachmentStorageService
|
||||
{
|
||||
private readonly QMaxOptions _options = options.Value;
|
||||
|
||||
public async Task<StoredAttachment> SaveAsync(IFormFile file, CancellationToken cancellationToken)
|
||||
{
|
||||
if (file.Length <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Empty files are not allowed.");
|
||||
}
|
||||
|
||||
if (file.Length > _options.MaxUploadBytes)
|
||||
{
|
||||
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_options.StoragePath);
|
||||
|
||||
var extension = Path.GetExtension(file.FileName);
|
||||
if (extension.Length > 16)
|
||||
{
|
||||
extension = "";
|
||||
}
|
||||
|
||||
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||
var finalPath = GetPath(storageName);
|
||||
var partPath = finalPath + ".part";
|
||||
|
||||
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
||||
{
|
||||
await file.CopyToAsync(target, cancellationToken);
|
||||
}
|
||||
|
||||
var info = new FileInfo(partPath);
|
||||
if (info.Length != file.Length)
|
||||
{
|
||||
File.Delete(partPath);
|
||||
throw new IOException("Uploaded file size verification failed.");
|
||||
}
|
||||
|
||||
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||
File.Move(partPath, finalPath, false);
|
||||
|
||||
return new StoredAttachment(
|
||||
Path.GetFileName(file.FileName),
|
||||
storageName,
|
||||
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
file.Length,
|
||||
hash,
|
||||
GuessKind(file.ContentType, extension));
|
||||
}
|
||||
|
||||
public async Task<StoredAttachment> SaveRemoteAsync(
|
||||
string fileName,
|
||||
string? contentType,
|
||||
Stream stream,
|
||||
long? expectedLength,
|
||||
AttachmentKind? preferredKind,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (expectedLength is > 0 && expectedLength > _options.MaxUploadBytes)
|
||||
{
|
||||
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(_options.StoragePath);
|
||||
|
||||
var safeOriginalName = NormalizeRemoteFileName(fileName, contentType, preferredKind);
|
||||
var extension = Path.GetExtension(safeOriginalName);
|
||||
if (extension.Length > 16)
|
||||
{
|
||||
extension = "";
|
||||
}
|
||||
|
||||
var storageName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension.ToLowerInvariant()}";
|
||||
var finalPath = GetPath(storageName);
|
||||
var partPath = finalPath + ".part";
|
||||
|
||||
await using (var target = new FileStream(partPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 128 * 1024, true))
|
||||
{
|
||||
await stream.CopyToAsync(target, cancellationToken);
|
||||
}
|
||||
|
||||
var info = new FileInfo(partPath);
|
||||
if (info.Length <= 0)
|
||||
{
|
||||
File.Delete(partPath);
|
||||
throw new IOException("Downloaded MAX media is empty.");
|
||||
}
|
||||
|
||||
if (expectedLength is > 0 && info.Length != expectedLength)
|
||||
{
|
||||
File.Delete(partPath);
|
||||
throw new IOException("Downloaded MAX media size verification failed.");
|
||||
}
|
||||
|
||||
if (info.Length > _options.MaxUploadBytes)
|
||||
{
|
||||
File.Delete(partPath);
|
||||
throw new InvalidOperationException($"File is larger than {_options.MaxUploadBytes} bytes.");
|
||||
}
|
||||
|
||||
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||
var resolvedKind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
||||
var hash = await ComputeSha256Async(partPath, cancellationToken);
|
||||
File.Move(partPath, finalPath, false);
|
||||
|
||||
return new StoredAttachment(
|
||||
safeOriginalName,
|
||||
storageName,
|
||||
resolvedContentType,
|
||||
info.Length,
|
||||
hash,
|
||||
resolvedKind);
|
||||
}
|
||||
|
||||
public string GetPath(string storageFileName)
|
||||
{
|
||||
var safeName = Path.GetFileName(storageFileName);
|
||||
return Path.Combine(_options.StoragePath, safeName);
|
||||
}
|
||||
|
||||
private static async Task<string> ComputeSha256Async(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
var hash = await SHA256.HashDataAsync(stream, cancellationToken);
|
||||
return Convert.ToHexString(hash).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public static AttachmentKind GuessKind(string? contentType, string extension)
|
||||
{
|
||||
var content = contentType?.ToLowerInvariant() ?? "";
|
||||
var ext = extension.ToLowerInvariant();
|
||||
if (content.Contains("vcard", StringComparison.Ordinal) || ext == ".vcf") return AttachmentKind.Contact;
|
||||
if (content.StartsWith("image/gif") || ext == ".gif") return AttachmentKind.Gif;
|
||||
if (content.StartsWith("image/")) return AttachmentKind.Image;
|
||||
if (content.StartsWith("video/")) return AttachmentKind.Video;
|
||||
if (content.StartsWith("audio/") || ext is ".ogg" or ".opus" or ".m4a" or ".aac" or ".mp3" or ".wav" or ".flac") return AttachmentKind.VoiceNote;
|
||||
return AttachmentKind.File;
|
||||
}
|
||||
|
||||
public static string NormalizeRemoteFileName(string? fileName, string? contentType, AttachmentKind? preferredKind)
|
||||
{
|
||||
var original = string.IsNullOrWhiteSpace(fileName) ? "" : Path.GetFileName(fileName.Trim());
|
||||
var extension = Path.GetExtension(original);
|
||||
if (extension.Length > 16)
|
||||
{
|
||||
extension = "";
|
||||
}
|
||||
|
||||
var resolvedContentType = string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType;
|
||||
var kind = preferredKind ?? GuessKind(resolvedContentType, extension);
|
||||
var fallbackExtension = string.IsNullOrWhiteSpace(extension)
|
||||
? DefaultExtension(resolvedContentType, kind)
|
||||
: extension.ToLowerInvariant();
|
||||
|
||||
return IsUnhelpfulRemoteFileName(original, kind, extension)
|
||||
? $"max-{KindSlug(kind)}{fallbackExtension}"
|
||||
: original;
|
||||
}
|
||||
|
||||
private static bool IsUnhelpfulRemoteFileName(string value, AttachmentKind kind, string extension)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var normalized = value.Trim().ToLowerInvariant();
|
||||
var stem = Path.GetFileNameWithoutExtension(normalized).Trim();
|
||||
if (stem is
|
||||
"photo" or
|
||||
"image" or
|
||||
"video" or
|
||||
"audio" or
|
||||
"voice" or
|
||||
"gif" or
|
||||
"media" or
|
||||
"contact" or
|
||||
"\u0444\u043e\u0442\u043e" or
|
||||
"\u0432\u0438\u0434\u0435\u043e" or
|
||||
"\u0430\u0443\u0434\u0438\u043e" or
|
||||
"\u0433\u043e\u043b\u043e\u0441" or
|
||||
"\u043a\u043e\u043d\u0442\u0430\u043a\u0442")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("\u0431\u0440\u0430\u0443\u0437\u0435\u0440", StringComparison.Ordinal) &&
|
||||
normalized.Contains("\u043d\u0435", StringComparison.Ordinal) &&
|
||||
normalized.Contains("\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (normalized.Contains("your browser", StringComparison.Ordinal) ||
|
||||
normalized.Contains("not supported", StringComparison.Ordinal) ||
|
||||
normalized.Contains("[object object]", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(extension) &&
|
||||
kind is AttachmentKind.Image or AttachmentKind.Gif or AttachmentKind.Video or AttachmentKind.VoiceNote or AttachmentKind.Sticker;
|
||||
}
|
||||
|
||||
private static string DefaultExtension(string contentType, AttachmentKind kind)
|
||||
{
|
||||
var content = contentType.ToLowerInvariant();
|
||||
if (content.Contains("webp", StringComparison.Ordinal)) return ".webp";
|
||||
if (content.Contains("jpeg", StringComparison.Ordinal) || content.Contains("jpg", StringComparison.Ordinal)) return ".jpg";
|
||||
if (content.Contains("png", StringComparison.Ordinal)) return ".png";
|
||||
if (content.Contains("gif", StringComparison.Ordinal)) return ".gif";
|
||||
if (content.Contains("mp4", StringComparison.Ordinal)) return kind == AttachmentKind.VoiceNote ? ".m4a" : ".mp4";
|
||||
if (content.Contains("webm", StringComparison.Ordinal)) return ".webm";
|
||||
if (content.Contains("ogg", StringComparison.Ordinal) || content.Contains("opus", StringComparison.Ordinal)) return ".ogg";
|
||||
if (content.Contains("mpeg", StringComparison.Ordinal)) return ".mp3";
|
||||
if (content.Contains("wav", StringComparison.Ordinal)) return ".wav";
|
||||
if (content.Contains("pdf", StringComparison.Ordinal)) return ".pdf";
|
||||
if (content.Contains("vcard", StringComparison.Ordinal)) return ".vcf";
|
||||
|
||||
return kind switch
|
||||
{
|
||||
AttachmentKind.Image => ".jpg",
|
||||
AttachmentKind.Gif => ".gif",
|
||||
AttachmentKind.Video => ".mp4",
|
||||
AttachmentKind.VoiceNote => ".ogg",
|
||||
AttachmentKind.Sticker => ".webp",
|
||||
AttachmentKind.Contact => ".vcf",
|
||||
_ => ""
|
||||
};
|
||||
}
|
||||
|
||||
private static string KindSlug(AttachmentKind kind)
|
||||
{
|
||||
return kind switch
|
||||
{
|
||||
AttachmentKind.Image => "image",
|
||||
AttachmentKind.Gif => "gif",
|
||||
AttachmentKind.Video => "video",
|
||||
AttachmentKind.VoiceNote => "voice",
|
||||
AttachmentKind.Sticker => "sticker",
|
||||
AttachmentKind.Contact => "contact",
|
||||
AttachmentKind.File => "file",
|
||||
_ => "media"
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user