Tests / test (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
3267 lines
196 KiB
C#
3267 lines
196 KiB
C#
using System.Collections.Concurrent;
|
|
using System.Net.Mail;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Security.Cryptography;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Ikar.Server.Data;
|
|
using Ikar.Server.Data.Entities;
|
|
using Ikar.Server.Infrastructure;
|
|
using Ikar.Server.Infrastructure.Auth;
|
|
using Ikar.Server.Infrastructure.Bots;
|
|
using Ikar.Server.Infrastructure.Calls;
|
|
using Ikar.Server.Infrastructure.Push;
|
|
using Ikar.Server.Infrastructure.Hubs;
|
|
using Ikar.Server.Infrastructure.Storage;
|
|
using Ikar.Shared;
|
|
using Microsoft.AspNetCore.SignalR;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace Ikar.Server.Controllers;
|
|
|
|
[ApiController]
|
|
[AllowAnonymous]
|
|
[Route("api/ikar/requests")]
|
|
public sealed partial class QlyraCompatibilityController(
|
|
IkarDbContext dbContext,
|
|
TokenService tokenService,
|
|
DeviceAuthChallengeStore deviceAuthChallengeStore,
|
|
PresenceTracker presenceTracker,
|
|
QlyraClientIdMapper clientIdMapper,
|
|
QlyraUploadStore uploadStore,
|
|
IHubContext<MessengerHub> hubContext,
|
|
ILogger<QlyraCompatibilityController> logger,
|
|
IEmailCodeSender? emailCodeSender = null,
|
|
QlyraCallCompatibilityStore? qlyraCallStore = null,
|
|
IPushNotificationSender? pushNotificationSender = null,
|
|
QlyraWebPushSessionStore? webPushSessionStore = null,
|
|
IOptions<QlyraPublicEndpointOptions>? publicEndpointOptions = null,
|
|
BotUpdateService? botUpdateService = null,
|
|
PushDispatchQueue? pushDispatchQueue = null,
|
|
IkarTranscriptionService? transcriptionService = null,
|
|
BotCreatorService? botCreatorService = null,
|
|
VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|
{
|
|
private static readonly ConcurrentDictionary<string, QlyraTwoFactorTrack> TwoFactorTracks = new(StringComparer.Ordinal);
|
|
[HttpPost("{opcode:int}")]
|
|
public async Task<IActionResult> Dispatch(int opcode, QlyraCompatibilityRequest request, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return opcode switch
|
|
{
|
|
1 => await PingAsync(opcode, request.Payload, cancellationToken),
|
|
17 => await RequestPhoneCodeAsync(opcode, request.Payload, cancellationToken),
|
|
18 => await VerifyPhoneCodeAsync(opcode, request.Payload, cancellationToken),
|
|
19 => await LoginAsync(opcode, cancellationToken),
|
|
20 => await LogoutAsync(opcode, cancellationToken),
|
|
21 => await SyncAsync(opcode, cancellationToken),
|
|
22 => await ConfigureAsync(opcode, request.Payload, cancellationToken),
|
|
23 => await CompleteRegistrationAsync(opcode, request.Payload, cancellationToken),
|
|
26 or 27 or 28 or 29 or 259 => await GetAssetsAsync(opcode, request.Payload, cancellationToken),
|
|
8 => await GetContactsAsync(opcode, cancellationToken),
|
|
16 => await UpdateProfileAsync(opcode, request.Payload, cancellationToken),
|
|
32 => await GetContactInfoAsync(opcode, request.Payload, cancellationToken),
|
|
34 => await UpdateContactAsync(opcode, request.Payload, cancellationToken),
|
|
35 => await GetContactPresenceAsync(opcode, request.Payload, cancellationToken),
|
|
36 => await GetBlockedContactsAsync(opcode, cancellationToken),
|
|
39 => await GetContactPhotosAsync(opcode, request.Payload, cancellationToken),
|
|
41 => await AddContactByPhoneAsync(opcode, request.Payload, cancellationToken),
|
|
46 => await GetContactByPhoneAsync(opcode, request.Payload, cancellationToken),
|
|
43 => await RemoveProfilePhotoAsync(opcode, cancellationToken),
|
|
48 => await GetChatInfoAsync(opcode, request.Payload, cancellationToken),
|
|
49 => await GetHistoryAsync(opcode, request.Payload, cancellationToken),
|
|
50 => await MarkReadAsync(opcode, request.Payload, cancellationToken),
|
|
51 => await GetChatMediaAsync(opcode, request.Payload, cancellationToken),
|
|
52 => await DeleteChatAsync(opcode, request.Payload, cancellationToken),
|
|
53 => await GetChatsAsync(opcode, request.Payload, cancellationToken),
|
|
54 => await ClearChatAsync(opcode, request.Payload, cancellationToken),
|
|
55 => await UpdateChatAsync(opcode, request.Payload, cancellationToken),
|
|
57 => await JoinChatAsync(opcode, request.Payload, cancellationToken),
|
|
58 => await LeaveChatAsync(opcode, request.Payload, cancellationToken),
|
|
59 => await GetChatMembersAsync(opcode, request.Payload, cancellationToken),
|
|
60 => await PublicSearchAsync(opcode, request.Payload, cancellationToken),
|
|
64 => await SendMessageAsync(opcode, request.Payload, cancellationToken),
|
|
65 => await SendTypingAsync(opcode, request.Payload, cancellationToken),
|
|
66 => await DeleteMessagesAsync(opcode, request.Payload, cancellationToken),
|
|
67 => await EditMessageAsync(opcode, request.Payload, cancellationToken),
|
|
68 => await SearchMessagesAsync(opcode, request.Payload, false, cancellationToken),
|
|
73 => await SearchMessagesAsync(opcode, request.Payload, true, cancellationToken),
|
|
75 => await SubscribeChatAsync(opcode, request.Payload, cancellationToken),
|
|
76 or 78 or 84 or 166 => await CallFeatureAsync(opcode, request.Payload, cancellationToken),
|
|
77 => await UpdateChatMembersAsync(opcode, request.Payload, cancellationToken),
|
|
79 or 164 => await CallHistoryAsync(opcode, request.Payload, cancellationToken),
|
|
80 or 82 or 87 => await RequestUploadAsync(opcode, request.Payload, cancellationToken),
|
|
83 or 88 => await ResolveMediaAsync(opcode, request.Payload, cancellationToken),
|
|
89 => await GetLinkInfoAsync(opcode, request.Payload, cancellationToken),
|
|
91 => await GetCommentsInfoAsync(opcode, request.Payload, cancellationToken),
|
|
96 => await GetSessionsAsync(opcode, cancellationToken),
|
|
97 => await CloseOtherSessionsAsync(opcode, cancellationToken),
|
|
104 or 107 or 108 or 109 or 110 or 111 or 112 or 113 or 115 => await TwoFactorAsync(opcode, request.Payload, cancellationToken),
|
|
105 or 106 or 160 => await MiniAppAsync(opcode, request.Payload, cancellationToken),
|
|
118 => await BotCallbackAsync(opcode, request.Payload, cancellationToken),
|
|
145 => await GetBotInfoAsync(opcode, request.Payload, cancellationToken),
|
|
161 => await SubmitComplaintAsync(opcode, request.Payload, cancellationToken),
|
|
199 => await DeleteOwnAccountAsync(opcode, request.Payload, cancellationToken),
|
|
218 => await DeleteOwnStoriesAsync(opcode, request.Payload, cancellationToken),
|
|
257 or 258 => await ReactionSettingsAsync(opcode, request.Payload, cancellationToken),
|
|
162 => await GetComplaintReasonsAsync(opcode, cancellationToken),
|
|
178 => await SetReactionAsync(opcode, request.Payload, cancellationToken),
|
|
179 => await CancelReactionAsync(opcode, request.Payload, cancellationToken),
|
|
181 => await GetDetailedReactionsAsync(opcode, request.Payload, cancellationToken),
|
|
198 => await GetCommonChatsAsync(opcode, request.Payload, cancellationToken),
|
|
202 => await GetTranscriptionAsync(opcode, request.Payload, cancellationToken),
|
|
208 or 210 => await GetStoriesAsync(opcode, request.Payload, cancellationToken),
|
|
214 => await MarkStoryAsync(opcode, request.Payload, cancellationToken),
|
|
215 => await SendStoryAsync(opcode, request.Payload, cancellationToken),
|
|
272 or 273 or 274 or 275 or 276 => await HandleFoldersAsync(opcode, request.Payload, cancellationToken),
|
|
290 => await ApproveQrLoginAsync(opcode, request.Payload, cancellationToken),
|
|
302 => await GetBannersAsync(opcode, cancellationToken),
|
|
304 => await SendPollVoteAsync(opcode, request.Payload, cancellationToken),
|
|
306 => await GetPollUpdatesAsync(opcode, request.Payload, cancellationToken),
|
|
_ => StatusCode(
|
|
StatusCodes.Status501NotImplemented,
|
|
QlyraCompatibilityResponse.Error(
|
|
opcode,
|
|
"IKAR_OPCODE_NOT_IMPLEMENTED",
|
|
$"IKAR compatibility endpoint for opcode {opcode} is not implemented."))
|
|
};
|
|
}
|
|
catch (InvalidOperationException exception) when (
|
|
exception.Message == "Authenticated user id claim is missing.")
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", exception.Message));
|
|
}
|
|
}
|
|
|
|
private async Task<IActionResult> RequestPhoneCodeAsync(
|
|
int opcode,
|
|
JsonElement payload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var phone = ReadString(payload, "phone");
|
|
var normalizedPhone = PhoneNumberNormalizer.Normalize(phone);
|
|
if (normalizedPhone is null)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required."));
|
|
}
|
|
|
|
var user = await dbContext.Users.AsNoTracking().SingleOrDefaultAsync(
|
|
x => x.NormalizedPhoneNumber == normalizedPhone,
|
|
cancellationToken);
|
|
var installationId = ReadString(payload, "installationId")?.Trim().ToLowerInvariant();
|
|
var publicKey = ReadString(payload, "devicePublicKey")?.Trim();
|
|
var publicKeyIsValid = !string.IsNullOrWhiteSpace(publicKey) &&
|
|
publicKey.Length <= 2048 &&
|
|
DeviceKeyVerifier.IsValidPublicKey(publicKey);
|
|
if (!publicKeyIsValid)
|
|
{
|
|
logger.LogWarning(
|
|
"Rejected IKAR device key. JsonKind={JsonKind}, EncodedLength={EncodedLength}",
|
|
payload.TryGetProperty("devicePublicKey", out var rawPublicKey) ? rawPublicKey.ValueKind : JsonValueKind.Undefined,
|
|
publicKey?.Length ?? 0);
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_REQUIRED", "A valid IKAR P-256 public key is required."));
|
|
}
|
|
|
|
if (!IsInstallationIdValid(installationId))
|
|
{
|
|
installationId = DeviceKeyVerifier.DeriveInstallationId(publicKey!);
|
|
}
|
|
|
|
if (user?.IsBlocked == true)
|
|
{
|
|
return StatusCode(StatusCodes.Status423Locked, QlyraCompatibilityResponse.Error(opcode, "USER_BLOCKED", "User is blocked."));
|
|
}
|
|
|
|
var credential = await dbContext.DeviceCredentials.AsNoTracking()
|
|
.SingleOrDefaultAsync(device => device.InstallationId == installationId, cancellationToken);
|
|
var bindCredential = false;
|
|
if (user is null)
|
|
{
|
|
if (credential is not null)
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_ALREADY_BOUND", "This IKAR device is already bound to another account."));
|
|
}
|
|
|
|
bindCredential = true;
|
|
}
|
|
else if (credential is null)
|
|
{
|
|
var trustedLegacyDevice = await dbContext.PushDevices.AsNoTracking().AnyAsync(
|
|
device => device.UserId == user.Id && device.InstallationId == installationId,
|
|
cancellationToken);
|
|
if (!trustedLegacyDevice)
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_NOT_TRUSTED", "This device is not linked to the IKAR account."));
|
|
}
|
|
|
|
bindCredential = true;
|
|
}
|
|
else if (credential.UserId != user.Id ||
|
|
!CryptographicOperations.FixedTimeEquals(
|
|
SHA256.HashData(Convert.FromBase64String(credential.PublicKey)),
|
|
SHA256.HashData(Convert.FromBase64String(publicKey!))))
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_MISMATCH", "The device key does not match the registered IKAR device."));
|
|
}
|
|
|
|
var challenge = deviceAuthChallengeStore.Create(
|
|
normalizedPhone,
|
|
installationId!,
|
|
publicKey!,
|
|
isRegistered: user is not null,
|
|
bindCredential);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
token = challenge.Id,
|
|
expiresAt = challenge.ExpiresAt.ToUnixTimeMilliseconds(),
|
|
isRegistered = challenge.IsRegistered,
|
|
signaturePayload = challenge.SignaturePayload,
|
|
authMethod = "DEVICE_KEY"
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> VerifyPhoneCodeAsync(
|
|
int opcode,
|
|
JsonElement payload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var challengeId = ReadString(payload, "token") ?? string.Empty;
|
|
var challenge = deviceAuthChallengeStore.Get(challengeId);
|
|
if (challenge is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "INVALID_DEVICE_CHALLENGE", "The device challenge is invalid or expired."));
|
|
}
|
|
var signature = ReadString(payload, "deviceSignature") ?? string.Empty;
|
|
if (!DeviceKeyVerifier.Verify(challenge.PublicKey, challenge.SignaturePayload, signature))
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "INVALID_DEVICE_SIGNATURE", "The device signature is invalid."));
|
|
}
|
|
if (!deviceAuthChallengeStore.Consume(challengeId, challenge))
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_CHALLENGE_USED", "The device challenge has already been used."));
|
|
}
|
|
|
|
var user = await dbContext.Users.SingleOrDefaultAsync(
|
|
x => x.NormalizedPhoneNumber == challenge.NormalizedPhoneNumber,
|
|
cancellationToken);
|
|
if (!challenge.IsRegistered)
|
|
{
|
|
if (user is not null || await dbContext.DeviceCredentials.AnyAsync(
|
|
device => device.InstallationId == challenge.InstallationId,
|
|
cancellationToken))
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "REGISTRATION_CONFLICT", "The phone number or device is no longer available for registration."));
|
|
}
|
|
|
|
user = await CreatePhoneRegistrationUserAsync(challenge.NormalizedPhoneNumber, cancellationToken);
|
|
dbContext.DeviceCredentials.Add(new DeviceCredential
|
|
{
|
|
UserId = user.Id,
|
|
InstallationId = challenge.InstallationId,
|
|
PublicKey = challenge.PublicKey
|
|
});
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
var registrationSession = await tokenService.CreateSessionAsync(
|
|
user,
|
|
ClientVersionRequestReader.Read(Request),
|
|
cancellationToken);
|
|
var registrationAccountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
tokenAttrs = new Dictionary<string, object> { ["REGISTER"] = new { token = registrationSession.AccessToken, refreshToken = registrationSession.RefreshToken } },
|
|
accountId = registrationAccountId,
|
|
presetAvatars = Array.Empty<object>()
|
|
}));
|
|
}
|
|
if (user is null || user.IsBlocked)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable."));
|
|
}
|
|
|
|
var registeredCredential = await dbContext.DeviceCredentials.SingleOrDefaultAsync(
|
|
device => device.InstallationId == challenge.InstallationId && device.UserId == user.Id,
|
|
cancellationToken);
|
|
if (registeredCredential is null)
|
|
{
|
|
if (!challenge.BindCredential)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_NOT_TRUSTED", "The IKAR device credential is unavailable."));
|
|
}
|
|
|
|
registeredCredential = new DeviceCredential
|
|
{
|
|
UserId = user.Id,
|
|
InstallationId = challenge.InstallationId,
|
|
PublicKey = challenge.PublicKey
|
|
};
|
|
dbContext.DeviceCredentials.Add(registeredCredential);
|
|
}
|
|
else if (!string.Equals(registeredCredential.PublicKey, challenge.PublicKey, StringComparison.Ordinal))
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "DEVICE_KEY_MISMATCH", "The registered IKAR device key has changed."));
|
|
}
|
|
registeredCredential.LastUsedAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
|
|
var session = await tokenService.CreateSessionAsync(
|
|
user,
|
|
ClientVersionRequestReader.Read(Request),
|
|
cancellationToken);
|
|
var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
tokenAttrs = new Dictionary<string, object> { ["LOGIN"] = new { token = session.AccessToken, refreshToken = session.RefreshToken } },
|
|
accountId,
|
|
profile = await BuildProfileAsync(user, cancellationToken)
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> PingAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
if (User.Identity?.IsAuthenticated == true)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable."));
|
|
var interactive = ReadBool(payload, "interactive") ?? true;
|
|
presenceTracker.SetInteractive(user.Id, User.GetRequiredSessionId(), interactive);
|
|
if (interactive)
|
|
{
|
|
user.PublicLastSeenUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { server = "ikar" }));
|
|
}
|
|
|
|
private async Task<IActionResult> LoginAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
if (User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
|
|
var userId = User.GetRequiredUserId();
|
|
var user = await dbContext.Users.SingleOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
|
if (user is null || user.IsBlocked)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "Account is unavailable."));
|
|
}
|
|
|
|
var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
var payload = await BuildLoginPayloadAsync(user, accountId, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, payload));
|
|
}
|
|
|
|
private async Task<IActionResult> CompleteRegistrationAsync(
|
|
int opcode,
|
|
JsonElement payload,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null || user.IsBlocked)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR registration token is required."));
|
|
}
|
|
|
|
var firstName = ReadString(payload, "firstName")?.Trim();
|
|
var lastName = ReadString(payload, "lastName")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(firstName) || firstName.Length > 120 || (lastName?.Length ?? 0) > 120)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_NAME_INVALID", "A valid first name is required."));
|
|
}
|
|
|
|
user.DisplayName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var issued = await tokenService.CreateSessionAsync(user, ClientVersionRequestReader.Read(Request), cancellationToken);
|
|
await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
token = issued.AccessToken,
|
|
refreshToken = issued.RefreshToken,
|
|
tokenType = "LOGIN",
|
|
accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken),
|
|
profile = await BuildProfileAsync(user, cancellationToken)
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> LogoutAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
if (User.Identity?.IsAuthenticated != true)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
|
|
await tokenService.RevokeSessionAsync(User.GetRequiredSessionId(), cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { }));
|
|
}
|
|
|
|
private async Task<IActionResult> SyncAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var accountId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, await BuildLoginPayloadAsync(user, accountId, cancellationToken)));
|
|
}
|
|
|
|
private async Task<IActionResult> ConfigureAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var pushToken = ReadString(payload, "pushToken")?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(pushToken))
|
|
{
|
|
var credential = await dbContext.DeviceCredentials
|
|
.Where(device => device.UserId == user.Id)
|
|
.OrderByDescending(device => device.LastUsedAt)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (credential is null)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "WRONG_DEVICE_TOKEN", "The IKAR device is not registered."));
|
|
}
|
|
|
|
var device = await dbContext.PushDevices.SingleOrDefaultAsync(
|
|
item => item.InstallationId == credential.InstallationId,
|
|
cancellationToken);
|
|
if (device is null)
|
|
{
|
|
device = new PushDevice
|
|
{
|
|
UserId = user.Id,
|
|
Platform = PushPlatform.Android,
|
|
InstallationId = credential.InstallationId,
|
|
DeviceToken = pushToken,
|
|
NotificationsEnabled = true,
|
|
UpdatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
dbContext.PushDevices.Add(device);
|
|
}
|
|
else
|
|
{
|
|
device.UserId = user.Id;
|
|
device.Platform = PushPlatform.Android;
|
|
device.DeviceToken = pushToken;
|
|
device.NotificationsEnabled = true;
|
|
device.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
ClientVersionRequestReader.Apply(device, ClientVersionRequestReader.Read(Request));
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
var privacy = await GetOrCreatePrivacySettingsAsync(user.Id, cancellationToken);
|
|
var userConfig = BuildUserConfig(privacy);
|
|
if (payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty("settings", out var requestedSettings) &&
|
|
requestedSettings.ValueKind == JsonValueKind.Object)
|
|
{
|
|
if (requestedSettings.TryGetProperty("user", out var requestedUser) && requestedUser.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var property in requestedUser.EnumerateObject())
|
|
{
|
|
if (property.Value.ValueKind is JsonValueKind.String or JsonValueKind.Number or JsonValueKind.True or JsonValueKind.False or JsonValueKind.Null)
|
|
{
|
|
userConfig[property.Name] = ReadConfigValue(property.Value);
|
|
}
|
|
}
|
|
ApplyPrivacyConfig(privacy, userConfig);
|
|
privacy.QlyraConfigJson = JsonSerializer.Serialize(userConfig);
|
|
privacy.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
|
|
if (requestedSettings.TryGetProperty("chats", out var requestedChats) && requestedChats.ValueKind == JsonValueKind.Object)
|
|
{
|
|
foreach (var property in requestedChats.EnumerateObject())
|
|
{
|
|
if (!int.TryParse(property.Name, out var clientChatId) || property.Value.ValueKind != JsonValueKind.Object) continue;
|
|
var membershipChat = await ResolveMemberChatAsync(user.Id, clientChatId, cancellationToken);
|
|
if (membershipChat is null) continue;
|
|
var membership = membershipChat.Members.Single(member => member.UserId == user.Id);
|
|
var dontDisturbUntil = ReadLong(property.Value, "dontDisturbUntil");
|
|
if (dontDisturbUntil is null) continue;
|
|
membership.MutedUntil = ToMutedUntil(dontDisturbUntil.Value);
|
|
}
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
hash = "ikar",
|
|
user = userConfig,
|
|
chats = await BuildChatConfigAsync(user.Id, cancellationToken)
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> GetAssetsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
|
|
var settings = await dbContext.UserPrivacySettings.SingleOrDefaultAsync(item => item.UserId == user.Id, cancellationToken);
|
|
var favorites = DeserializeStoredIntList(settings?.QlyraFavoriteStickerSetIdsJson ?? "[]").Where(id => id > 0).Distinct().ToList();
|
|
if (opcode is 29 or 259)
|
|
{
|
|
if (!string.Equals(ReadString(payload, "type"), "FAVORITE_STICKER_SET", StringComparison.OrdinalIgnoreCase))
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "ASSET_TYPE_INVALID", "Only favorite sticker sets can be modified."));
|
|
|
|
var changedIds = opcode == 29
|
|
? (ReadInt(payload, "id") is int requestedSetId && requestedSetId > 0 ? new[] { requestedSetId } : [])
|
|
: ReadIntList(payload, "ids").Where(id => id > 0).Distinct().ToArray();
|
|
if (changedIds.Length == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "ASSET_ID_REQUIRED", "At least one sticker set id is required."));
|
|
|
|
settings ??= new UserPrivacySettings { UserId = user.Id };
|
|
if (dbContext.Entry(settings).State == EntityState.Detached) dbContext.UserPrivacySettings.Add(settings);
|
|
if (opcode == 29)
|
|
{
|
|
foreach (var changedId in changedIds) if (!favorites.Contains(changedId)) favorites.Add(changedId);
|
|
}
|
|
else
|
|
{
|
|
favorites.RemoveAll(changedIds.Contains);
|
|
}
|
|
settings.QlyraFavoriteStickerSetIdsJson = JsonSerializer.Serialize(favorites);
|
|
settings.UpdatedAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
foreach (var changedId in changedIds)
|
|
{
|
|
await PublishPacketAsync([user.Id], 150, new
|
|
{
|
|
type = "FAVORITE_STICKER_SET",
|
|
id = changedId,
|
|
updateType = opcode == 29 ? "ADDED" : "REMOVED"
|
|
}, cancellationToken);
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
var sections = opcode == 27 && string.Equals(ReadString(payload, "type"), "FAVORITE_STICKER", StringComparison.OrdinalIgnoreCase)
|
|
? new object[] { new { id = "FAVORITE_STICKER_SETS", stickerSets = favorites.ToArray(), marker = 0 } }
|
|
: opcode == 27 && string.Equals(ReadString(payload, "type"), "STICKER", StringComparison.OrdinalIgnoreCase)
|
|
? [new { id = "NEW_STICKER_SETS", stickerSets = new[] { 900000 }, marker = 0 }]
|
|
: [];
|
|
var stickerCodes = new[] { "1f600", "1f60a", "1f607", "1f44b", "1f44d", "1f44c", "1f49b", "1f525" };
|
|
var stickerTags = new[] { "😀", "😊", "😇", "👋", "👍", "👌", "💛", "🔥" };
|
|
var requested = ReadIntList(payload, "ids");
|
|
object[] stickerSets = opcode == 28 && ReadString(payload, "type") == "STICKER_SET" && requested.Contains(900000)
|
|
? [new { id = 900000, name = "ИКАР: эмоции", iconUrl = BuildPublicUrl("/stickers/1f600.png"), stickers = Enumerable.Range(900001, 8).ToArray() }]
|
|
: [];
|
|
var stickers = opcode == 28 && ReadString(payload, "type") == "STICKER"
|
|
? Enumerable.Range(0, 8).Where(index => requested.Contains(900001 + index)).Select(index => new {
|
|
id = 900001 + index, setId = 900000, url = BuildPublicUrl($"/stickers/{stickerCodes[index]}.png"), width = 128, height = 128, tags = new[] { stickerTags[index] }
|
|
}).Cast<object>().ToArray()
|
|
: [];
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
sections,
|
|
stickerSets,
|
|
stickers,
|
|
emojis = Array.Empty<object>(),
|
|
marker = 0,
|
|
success = true
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> HandleFoldersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
|
|
var now = DateTimeOffset.UtcNow;
|
|
if (opcode == 274)
|
|
{
|
|
var id = ReadString(payload, "id")?.Trim();
|
|
var title = ReadString(payload, "title")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(id) || id.Length > 64 || string.IsNullOrWhiteSpace(title) || title.Length > 20)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "FOLDER_INVALID", "Folder id and a title up to 20 characters are required."));
|
|
}
|
|
var folder = await dbContext.QlyraChatFolders.SingleOrDefaultAsync(item => item.UserId == user.Id && item.Id == id, cancellationToken);
|
|
if (folder is null)
|
|
{
|
|
var nextOrder = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).Select(item => (int?)item.SortOrder).MaxAsync(cancellationToken) ?? -1;
|
|
folder = new QlyraChatFolder { UserId = user.Id, Id = id, SortOrder = nextOrder + 1 };
|
|
dbContext.QlyraChatFolders.Add(folder);
|
|
}
|
|
folder.Title = title;
|
|
folder.Emoji = ReadString(payload, "emoji")?.Trim();
|
|
folder.IncludeJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "include").Distinct());
|
|
folder.FiltersJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "filters").Distinct());
|
|
folder.OptionsJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "options").Distinct());
|
|
folder.FavoritesJson = JsonSerializer.Serialize(ReadFlexibleIntList(payload, "favorites").Distinct());
|
|
folder.UpdatedAt = now;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var mapped = BuildQlyraFolder(folder);
|
|
var responsePayload = new { folder = mapped, folderSync = now.ToUnixTimeMilliseconds() };
|
|
await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload));
|
|
}
|
|
|
|
if (opcode == 275)
|
|
{
|
|
var requestedOrder = ReadStringList(payload, "foldersOrder");
|
|
var folders = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).ToListAsync(cancellationToken);
|
|
var byId = folders.ToDictionary(item => item.Id, StringComparer.Ordinal);
|
|
var ordered = requestedOrder.Where(byId.ContainsKey).Distinct(StringComparer.Ordinal).ToList();
|
|
ordered.AddRange(folders.OrderBy(item => item.SortOrder).Select(item => item.Id).Where(id => !ordered.Contains(id, StringComparer.Ordinal)));
|
|
for (var index = 0; index < ordered.Count; index++)
|
|
{
|
|
byId[ordered[index]].SortOrder = index;
|
|
byId[ordered[index]].UpdatedAt = now;
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var responsePayload = new { foldersOrder = ordered, folderSync = now.ToUnixTimeMilliseconds() };
|
|
await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload));
|
|
}
|
|
|
|
if (opcode == 276)
|
|
{
|
|
var ids = ReadStringList(payload, "folderIds").ToHashSet(StringComparer.Ordinal);
|
|
var folders = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id && ids.Contains(item.Id)).ToListAsync(cancellationToken);
|
|
dbContext.QlyraChatFolders.RemoveRange(folders);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var remainingOrder = await dbContext.QlyraChatFolders.Where(item => item.UserId == user.Id).OrderBy(item => item.SortOrder).Select(item => item.Id).ToListAsync(cancellationToken);
|
|
var responsePayload = new { foldersOrder = remainingOrder, folderSync = now.ToUnixTimeMilliseconds() };
|
|
await PublishPacketAsync([user.Id], 277, responsePayload, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, responsePayload));
|
|
}
|
|
|
|
var query = dbContext.QlyraChatFolders.AsNoTracking().Where(item => item.UserId == user.Id);
|
|
if (opcode == 273)
|
|
{
|
|
var ids = ReadStringList(payload, "folderIds").ToHashSet(StringComparer.Ordinal);
|
|
query = query.Where(item => ids.Contains(item.Id));
|
|
}
|
|
var stored = await query.OrderBy(item => item.SortOrder).ToListAsync(cancellationToken);
|
|
var allOrder = await dbContext.QlyraChatFolders.AsNoTracking().Where(item => item.UserId == user.Id).OrderBy(item => item.SortOrder).Select(item => item.Id).ToListAsync(cancellationToken);
|
|
var sync = stored.Count == 0 ? 0 : stored.Max(item => item.UpdatedAt.ToUnixTimeMilliseconds());
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { folders = stored.Select(BuildQlyraFolder).ToArray(), foldersOrder = allOrder, folderSync = sync }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetBannersAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
banners = Array.Empty<object>(),
|
|
bannersSync = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> GetStoriesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var peerIds = await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var query = dbContext.Stories
|
|
.AsNoTracking()
|
|
.Include(story => story.Attachments)
|
|
.Include(story => story.Views)
|
|
.Where(story => story.DeletedAt == null && story.ExpiresAt > now)
|
|
.WhereVisibleTo(user.Id, peerIds);
|
|
if (opcode == 210)
|
|
{
|
|
var requestedOwnerIds = new HashSet<Guid>();
|
|
if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("owners", out var owners) && owners.ValueKind == JsonValueKind.Array)
|
|
{
|
|
foreach (var owner in owners.EnumerateArray())
|
|
{
|
|
var clientOwnerId = ReadInt(owner, "ownerId");
|
|
if (clientOwnerId is null) continue;
|
|
var ownerId = await clientIdMapper.FindEntityIdAsync("user", clientOwnerId.Value, cancellationToken);
|
|
if (ownerId is not null) requestedOwnerIds.Add(ownerId.Value);
|
|
}
|
|
}
|
|
if (requestedOwnerIds.Count > 0) query = query.Where(story => requestedOwnerIds.Contains(story.AuthorUserId));
|
|
}
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 100, 1, 100);
|
|
var stories = await query.OrderByDescending(story => story.CreatedAt).Take(count).ToListAsync(cancellationToken);
|
|
var previews = await BuildStoryPreviewsAsync(stories, user.Id, cancellationToken);
|
|
if (opcode == 208)
|
|
{
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreviews = previews, cursor = string.Empty }));
|
|
}
|
|
var peers = new List<object>();
|
|
foreach (var group in stories.GroupBy(story => story.AuthorUserId))
|
|
{
|
|
var owner = await BuildStoryOwnerAsync(group.Key, cancellationToken);
|
|
var mappedStories = new List<object>();
|
|
foreach (var story in group.OrderBy(story => story.CreatedAt)) mappedStories.Add(await BuildStoryAsync(story, cancellationToken));
|
|
peers.Add(new { owner, stories = mappedStories });
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreviews = previews, peerStories = peers }));
|
|
}
|
|
|
|
private async Task<IActionResult> MarkStoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var clientStoryId = ReadInt(payload, "storyId");
|
|
var storyId = clientStoryId is null ? null : await clientIdMapper.FindEntityIdAsync("story", clientStoryId.Value, cancellationToken);
|
|
if (storyId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "STORY_NOT_FOUND", "Story was not found."));
|
|
var peerIds = await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken);
|
|
var now = DateTimeOffset.UtcNow;
|
|
var story = await dbContext.Stories.Include(item => item.Views)
|
|
.Where(item => item.Id == storyId && item.DeletedAt == null && item.ExpiresAt > now)
|
|
.WhereVisibleTo(user.Id, peerIds)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (story is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "STORY_NOT_FOUND", "Story was not found."));
|
|
if (story.AuthorUserId != user.Id && story.Views.All(view => view.ViewerUserId != user.Id))
|
|
{
|
|
story.Views.Add(new StoryView { StoryId = story.Id, ViewerUserId = user.Id, ViewedAt = DateTimeOffset.UtcNow });
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> CallFeatureAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
if (qlyraCallStore is null) return StatusCode(503, QlyraCompatibilityResponse.Error(opcode, "CALL_TRANSPORT_UNAVAILABLE", "Call transport is unavailable."));
|
|
var callerClientId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
var conversationId = ReadString(payload, "conversationId") ?? Guid.NewGuid().ToString("D");
|
|
QlyraCallConversation conversation;
|
|
QlyraCallParticipant participant;
|
|
if (opcode == 78)
|
|
{
|
|
var calleeClientId = ReadIntList(payload, "calleeIds").FirstOrDefault();
|
|
var calleeId = await clientIdMapper.FindEntityIdAsync("user", calleeClientId, cancellationToken);
|
|
if (calleeId is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_USER_NOT_FOUND", "Call recipient was not found."));
|
|
conversation = qlyraCallStore.Create(conversationId, ReadBool(payload, "isVideo") == true, (user.Id, callerClientId), (calleeId.Value, calleeClientId));
|
|
participant = conversation.Participants[user.Id];
|
|
var callee = conversation.Participants[calleeId.Value];
|
|
var vcp = BuildCallVcp(callee.Token, conversation.IsVideo, callee.ClientId);
|
|
await PublishPacketAsync([calleeId.Value], 137, new { vcp, conversationId, callerId = callerClientId, type = conversation.IsVideo ? "VIDEO" : "AUDIO", country = "RU", isContact = true }, cancellationToken);
|
|
if (pushNotificationSender is not null)
|
|
{
|
|
var devices = await dbContext.PushDevices.AsNoTracking()
|
|
.Where(device => device.UserId == calleeId.Value && device.NotificationsEnabled)
|
|
.ToListAsync(cancellationToken);
|
|
await pushNotificationSender.SendQlyraIncomingCallAsync(
|
|
new QlyraIncomingCallPushDto(vcp, conversationId, callerClientId, user.DisplayName, callee.ClientId, conversation.IsVideo),
|
|
devices,
|
|
cancellationToken);
|
|
}
|
|
}
|
|
else if (opcode == 76)
|
|
{
|
|
conversation = qlyraCallStore.Create(conversationId, false, (user.Id, callerClientId));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId, joinLink = conversation.JoinToken, callName = (string?)null }));
|
|
}
|
|
else if (opcode == 84)
|
|
{
|
|
conversation = qlyraCallStore.Find(conversationId)!;
|
|
if (conversation is null || !conversation.Participants.ContainsKey(user.Id)) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_NOT_FOUND", "Call was not found."));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { joinLink = conversation.JoinToken }));
|
|
}
|
|
else
|
|
{
|
|
var joinToken = (ReadString(payload, "joinLink") ?? string.Empty).Trim().TrimEnd('/').Split('/').Last();
|
|
conversation = qlyraCallStore.FindByJoinToken(joinToken)!;
|
|
if (conversation is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CALL_LINK_NOT_FOUND", "Call link was not found."));
|
|
participant = qlyraCallStore.AddParticipant(conversation, user.Id, callerClientId);
|
|
}
|
|
var endpoint = BuildCallEndpoint(participant.Token, conversation.Id, participant.ClientId);
|
|
var internalParams = JsonSerializer.Serialize(new { endpoint, id = new { @internal = participant.ClientId, external = participant.ClientId } });
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { conversationId = conversation.Id, internalCallerParams = internalParams, internalParams, joinLink = conversation.JoinToken }));
|
|
}
|
|
|
|
private async Task<IActionResult> CallHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var memberChatIds = dbContext.ChatMembers.Where(member => member.UserId == user.Id).Select(member => member.ChatId);
|
|
if (opcode == 164)
|
|
{
|
|
var clientIds = ReadIntList(payload, "historyIds");
|
|
var hidden = await dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId).ToListAsync(cancellationToken);
|
|
var hiddenSet = hidden.ToHashSet();
|
|
foreach (var clientId in clientIds)
|
|
{
|
|
var messageId = await clientIdMapper.FindEntityIdAsync("message", clientId, cancellationToken);
|
|
if (messageId is null || !hiddenSet.Add(messageId.Value)) continue;
|
|
var belongs = await dbContext.Messages.AnyAsync(message => message.Id == messageId && memberChatIds.Contains(message.ChatId), cancellationToken);
|
|
if (belongs) dbContext.MessageHiddenForUsers.Add(new MessageHiddenForUser { MessageId = messageId.Value, UserId = user.Id });
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
var hiddenIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId);
|
|
var messages = await dbContext.Messages.AsNoTracking().Include(message => message.Sender).Include(message => message.Attachments)
|
|
.Where(message => memberChatIds.Contains(message.ChatId) && message.DeletedAt == null && !hiddenIds.Contains(message.Id) && message.QlyraAttachesJson != null && message.QlyraAttachesJson.Contains("\"CALL\""))
|
|
.OrderByDescending(message => message.SentAt).Take(100).ToListAsync(cancellationToken);
|
|
var history = new List<object>();
|
|
foreach (var message in messages) history.Add(new { message = await BuildMessageAsync(message, cancellationToken) });
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { history, success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> RequestUploadAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 1, 1, 10);
|
|
var mediaKind = opcode switch
|
|
{
|
|
80 => "photo.jpg",
|
|
82 when ReadInt(payload, "type") == 2 => "audio.ogg",
|
|
82 when ReadInt(payload, "type") == 1 => "video-note.mp4",
|
|
82 => "video.mp4",
|
|
_ => "file"
|
|
};
|
|
var reservations = Enumerable.Range(0, count).Select(_ => uploadStore.Reserve(user.Id, mediaKind)).ToArray();
|
|
string Url(UploadReservation reservation) => BuildPublicUrl($"/api/ikar/uploads/{reservation.Token}");
|
|
if (opcode == 80)
|
|
{
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { url = Url(reservations[0]) }));
|
|
}
|
|
var info = new List<object>(reservations.Length);
|
|
foreach (var reservation in reservations)
|
|
{
|
|
var uploadId = await clientIdMapper.GetOrCreateAsync("upload", reservation.Id, cancellationToken);
|
|
info.Add(opcode == 87
|
|
? new { url = Url(reservation), fileId = uploadId, token = reservation.Token }
|
|
: new { url = Url(reservation), videoId = uploadId, token = reservation.Token });
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { info }));
|
|
}
|
|
|
|
private async Task<IActionResult> ResolveMediaAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
MessageAttachment? attachment = null;
|
|
var clientAttachmentId = ReadInt(payload, opcode == 83 ? "videoId" : "fileId");
|
|
if (clientAttachmentId is not null)
|
|
{
|
|
var attachmentId = await clientIdMapper.FindEntityIdAsync("attachment", clientAttachmentId.Value, cancellationToken);
|
|
if (attachmentId is not null) attachment = await dbContext.MessageAttachments.SingleOrDefaultAsync(item => item.Id == attachmentId && item.Message.ChatId == chat.Id, cancellationToken);
|
|
}
|
|
if (attachment is null && ReadInt(payload, "messageId") is int clientMessageId)
|
|
{
|
|
var message = await ResolveMessageAsync(chat.Id, clientMessageId, cancellationToken);
|
|
if (message is not null) attachment = await dbContext.MessageAttachments.Where(item => item.MessageId == message.Id).OrderBy(item => item.SortOrder).FirstOrDefaultAsync(cancellationToken);
|
|
}
|
|
if (attachment is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MEDIA_NOT_FOUND", "Media was not found."));
|
|
var url = BuildPublicUrl($"/api/chats/{chat.Id}/attachments/{attachment.Id}/content");
|
|
// Dictionary keys retain the protocol's spelling under ASP.NET's camel-case serializer.
|
|
// The original uploaded file is a direct source, not an HLS playlist.
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, opcode == 83
|
|
? new Dictionary<string, string> { ["EXTERNAL"] = url }
|
|
: new Dictionary<string, string> { ["url"] = url }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetLinkInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var rawLink = ReadString(payload, "link")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(rawLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "LINK_REQUIRED", "A link is required."));
|
|
var key = ExtractLinkKey(rawLink);
|
|
var call = qlyraCallStore?.FindByJoinToken(key);
|
|
if (call is not null)
|
|
{
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
videoConference = new
|
|
{
|
|
conferenceId = call.Id,
|
|
callName = (string?)null,
|
|
participantsCount = call.Participants.Count,
|
|
callType = call.IsVideo ? "VIDEO" : "AUDIO"
|
|
}
|
|
}));
|
|
}
|
|
var chat = await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender).Include(item => item.InviteLinks)
|
|
.SingleOrDefaultAsync(item => item.PublicUsername == key || item.InviteLinks.Any(link => link.Token == key && !link.IsRevoked), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "LINK_NOT_FOUND", "Link was not found."));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetCommentsInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var updates = new List<object>();
|
|
foreach (var clientPostId in ReadFlexibleIntList(payload, "postIds"))
|
|
{
|
|
var post = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken);
|
|
updates.Add(new { postId = clientPostId, commentsInfo = new { totalCount = post?.CommentCount ?? 0 } });
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commentsInfoUpdates = updates }));
|
|
}
|
|
|
|
private async Task<IActionResult> TwoFactorAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
RemoveExpiredTwoFactorTracks();
|
|
if (opcode == 112)
|
|
{
|
|
var created = new QlyraTwoFactorTrack(user.Id);
|
|
TwoFactorTracks[created.Id] = created;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { trackId = created.Id }));
|
|
}
|
|
|
|
var trackId = ReadString(payload, "trackId");
|
|
if (string.IsNullOrWhiteSpace(trackId) || !TwoFactorTracks.TryGetValue(trackId, out var track) || track.UserId != user.Id || track.ExpiresAt <= DateTimeOffset.UtcNow)
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TRACK_INVALID", "The two-factor setup track is invalid or expired."));
|
|
var settings = await dbContext.UserTwoFactorSettings.SingleOrDefaultAsync(item => item.UserId == user.Id, cancellationToken);
|
|
if (opcode == 104) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { password = new { enabled = settings is not null, email = settings?.Email, hint = settings?.Hint } }));
|
|
|
|
if (opcode == 107)
|
|
{
|
|
var password = ReadString(payload, "password") ?? string.Empty;
|
|
if (password.Length < 8) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_TOO_SHORT", "Password must contain at least 8 characters."));
|
|
track.CandidatePasswordHash = new PasswordHasher<UserTwoFactorSettings>().HashPassword(settings ?? new UserTwoFactorSettings { UserId = user.Id }, password);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
if (opcode == 108)
|
|
{
|
|
var hint = ReadString(payload, "hint")?.Trim();
|
|
if (hint is { Length: > 100 }) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "HINT_TOO_LONG", "Password hint is too long."));
|
|
track.Hint = hint;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
if (opcode == 109) return await SendTwoFactorEmailCodeAsync(opcode, payload, track, cancellationToken);
|
|
if (opcode == 110)
|
|
{
|
|
var code = ReadString(payload, "verifyCode") ?? string.Empty;
|
|
if (!track.IsValidEmailCode(code)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_INVALID", "The email verification code is invalid or expired."));
|
|
track.EmailVerified = true;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { email = track.PendingEmail }));
|
|
}
|
|
if (opcode is 113 or 115)
|
|
{
|
|
if (settings is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TWO_FACTOR_DISABLED", "Two-factor password is not enabled."));
|
|
var password = ReadString(payload, "password") ?? string.Empty;
|
|
var valid = new PasswordHasher<UserTwoFactorSettings>().VerifyHashedPassword(settings, settings.PasswordHash, password) != PasswordVerificationResult.Failed;
|
|
if (!valid) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "WRONG_PASSWORD", "The password is incorrect."));
|
|
track.PasswordVerified = true;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
if (opcode != 111) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "TWO_FACTOR_REQUEST_INVALID", "Unsupported two-factor operation."));
|
|
|
|
if (ReadBool(payload, "remove2fa") == true)
|
|
{
|
|
if (settings is null || !track.PasswordVerified) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_REQUIRED", "Verify the current password before removing two-factor protection."));
|
|
dbContext.UserTwoFactorSettings.Remove(settings);
|
|
}
|
|
else
|
|
{
|
|
settings ??= new UserTwoFactorSettings { UserId = user.Id };
|
|
if (dbContext.Entry(settings).State == EntityState.Detached) dbContext.UserTwoFactorSettings.Add(settings);
|
|
if (!string.IsNullOrWhiteSpace(track.CandidatePasswordHash)) settings.PasswordHash = track.CandidatePasswordHash;
|
|
if (string.IsNullOrWhiteSpace(settings.PasswordHash)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PASSWORD_REQUIRED", "Validate a password before enabling two-factor protection."));
|
|
settings.Hint = ReadString(payload, "hint")?.Trim() ?? track.Hint ?? settings.Hint;
|
|
if (track.EmailVerified) settings.Email = track.PendingEmail;
|
|
settings.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
TwoFactorTracks.TryRemove(track.Id, out _);
|
|
var profile = await BuildProfileAsync(user, cancellationToken);
|
|
await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile }));
|
|
}
|
|
|
|
private async Task<IActionResult> BotCallbackAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
var callbackId = ReadString(payload, "callbackId")?.Trim();
|
|
var callbackPayload = ReadString(payload, "payload");
|
|
if (string.IsNullOrWhiteSpace(callbackId)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_ID_REQUIRED", "Callback id is required."));
|
|
if (!ContainsInlineKeyboardCallback(message.QlyraAttachesJson, callbackId, callbackPayload))
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_INVALID", "The callback does not belong to this message."));
|
|
|
|
var senderBot = await dbContext.Bots
|
|
.SingleOrDefaultAsync(item => item.UserId == message.SenderId && item.IsEnabled, cancellationToken);
|
|
if (senderBot is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CALLBACK_BOT_UNAVAILABLE", "The message bot is unavailable."));
|
|
|
|
await (botUpdateService ?? new BotUpdateService(dbContext))
|
|
.EnqueueCallbackAsync(senderBot, message, user, callbackId, callbackPayload, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true, callbackId }));
|
|
}
|
|
|
|
private static bool ContainsInlineKeyboardCallback(string? attachesJson, string callbackId, string? callbackPayload)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachesJson)) return false;
|
|
try
|
|
{
|
|
using var document = JsonDocument.Parse(attachesJson);
|
|
if (document.RootElement.ValueKind != JsonValueKind.Array) return false;
|
|
foreach (var attach in document.RootElement.EnumerateArray())
|
|
{
|
|
if (!string.Equals(ReadString(attach, "_type"), "INLINE_KEYBOARD", StringComparison.OrdinalIgnoreCase) ||
|
|
!string.Equals(ReadString(attach, "callbackId"), callbackId, StringComparison.Ordinal)) continue;
|
|
if (!attach.TryGetProperty("keyboard", out var keyboard) ||
|
|
keyboard.ValueKind != JsonValueKind.Object ||
|
|
!keyboard.TryGetProperty("buttons", out var rows) ||
|
|
rows.ValueKind != JsonValueKind.Array) return false;
|
|
foreach (var row in rows.EnumerateArray())
|
|
{
|
|
if (row.ValueKind != JsonValueKind.Array) continue;
|
|
foreach (var button in row.EnumerateArray())
|
|
{
|
|
if (button.ValueKind != JsonValueKind.Object) continue;
|
|
if (callbackPayload is null || string.Equals(ReadString(button, "payload"), callbackPayload, StringComparison.Ordinal)) return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private async Task<IActionResult> SendTwoFactorEmailCodeAsync(int opcode, JsonElement payload, QlyraTwoFactorTrack track, CancellationToken cancellationToken)
|
|
{
|
|
var email = ReadString(payload, "email")?.Trim().ToLowerInvariant();
|
|
if (string.IsNullOrWhiteSpace(email) || !MailAddress.TryCreate(email, out _)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMAIL_INVALID", "A valid email address is required."));
|
|
if (emailCodeSender is null) return Conflict(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_UNAVAILABLE", "Email verification is not configured."));
|
|
var code = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
|
|
track.SetEmailCode(email, code);
|
|
try
|
|
{
|
|
await emailCodeSender.SendLoginCodeAsync(email, code, track.EmailCodeExpiresAt!.Value, cancellationToken);
|
|
}
|
|
catch (InvalidOperationException exception)
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "EMAIL_CODE_UNAVAILABLE", exception.Message));
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { blockingDuration = 60 }));
|
|
}
|
|
|
|
private static void RemoveExpiredTwoFactorTracks()
|
|
{
|
|
var now = DateTimeOffset.UtcNow;
|
|
foreach (var item in TwoFactorTracks)
|
|
if (item.Value.ExpiresAt <= now) TwoFactorTracks.TryRemove(item.Key, out _);
|
|
}
|
|
|
|
private async Task<IActionResult> GetBotInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var botUserId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "botId") ?? 0, cancellationToken);
|
|
var bot = botUserId is null ? null : await dbContext.Bots.Include(item => item.User).Include(item => item.Commands).SingleOrDefaultAsync(item => item.UserId == botUserId && item.IsEnabled, cancellationToken);
|
|
if (bot is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "BOT_NOT_FOUND", "Bot was not found."));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contact = await BuildContactAsync(bot.User, cancellationToken), commands = bot.Commands.OrderBy(item => item.SortOrder).Select(item => new { name = item.Command.TrimStart('/'), description = item.Description }).ToArray() }));
|
|
}
|
|
|
|
private async Task<IActionResult> SubmitComplaintAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var reasonId = ReadInt(payload, "reasonId");
|
|
var typeId = ReadInt(payload, "typeId");
|
|
if (reasonId is null || typeId is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "COMPLAINT_INVALID", "Complaint reason and type are required."));
|
|
dbContext.ModerationLogEntries.Add(new ModerationLogEntry { ActorUserId = user.Id, TargetType = ModerationLogTargetType.User, Action = "complaint.submitted", Reason = JsonSerializer.Serialize(new { reasonId, typeId, ids = ReadFlexibleIntList(payload, "ids"), parentId = ReadInt(payload, "parentId") }) });
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetComplaintReasonsAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var reasons = new[] { new { reasonId = 1, reasonTitle = "Спам" }, new { reasonId = 2, reasonTitle = "Оскорбления" }, new { reasonId = 3, reasonTitle = "Мошенничество" }, new { reasonId = 4, reasonTitle = "Другое" } };
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { complains = new[] { new { typeId = 6, reasons }, new { typeId = 1, reasons }, new { typeId = 2, reasons } } }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetTranscriptionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user=await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat=await ResolveMemberChatAsync(user.Id,ReadInt(payload,"chatId"),cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode,"CHAT_NOT_FOUND","Чат не найден."));
|
|
var message=await ResolveMessageAsync(chat.Id,ReadInt(payload,"messageId"),cancellationToken);
|
|
if (message is null || message.DeletedAt is not null) return NotFound(QlyraCompatibilityResponse.Error(opcode,"MESSAGE_NOT_FOUND","Сообщение не найдено."));
|
|
if (message.IkarTranscriptText is not null) return Ok(QlyraCompatibilityResponse.Ok(opcode,new {transcriptionStatus=1,transcription=message.IkarTranscriptText}));
|
|
var audio=await dbContext.MessageAttachments.Where(x=>x.MessageId==message.Id && (x.Kind==AttachmentKind.VoiceNote || x.ContentType.StartsWith("audio/"))).FirstOrDefaultAsync(cancellationToken);
|
|
var queued=audio is not null && transcriptionService?.Enqueue(message.Id,audio.StoragePath)==true;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode,new {transcriptionStatus=queued?0:-1,transcription=string.Empty}));
|
|
}
|
|
|
|
private async Task<IActionResult> SendStoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty("stories", out var requestedStories) || requestedStories.ValueKind != JsonValueKind.Array || requestedStories.GetArrayLength() == 0)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_REQUIRED", "At least one story is required."));
|
|
}
|
|
var created = new List<Story>();
|
|
var cids = new Dictionary<Guid, int>();
|
|
foreach (var requested in requestedStories.EnumerateArray().Take(10))
|
|
{
|
|
if (!requested.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_MEDIA_REQUIRED", "Story media is required."));
|
|
var token = ReadString(media, "token") ?? ReadString(media, "photoToken");
|
|
if (string.IsNullOrWhiteSpace(token)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "STORY_MEDIA_REQUIRED", "Story media token is required."));
|
|
var ttlMs = Math.Clamp(ReadLong(requested, "expiration") ?? 86_400_000L, 60_000L, 604_800_000L);
|
|
var story = new Story
|
|
{
|
|
AuthorUserId = user.Id,
|
|
Text = ReadString(requested, "text")?.Trim() ?? string.Empty,
|
|
Visibility = ReadInt(requested, "settings") == 2 ? StoryVisibility.Contacts : StoryVisibility.Everyone,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
ExpiresAt = DateTimeOffset.UtcNow.AddMilliseconds(ttlMs)
|
|
};
|
|
var attachment = await uploadStore.MaterializeStoryAttachmentAsync(token, user.Id, story.Id, 0, cancellationToken);
|
|
if (attachment is null) return Conflict(QlyraCompatibilityResponse.Error(opcode, "ATTACHMENT_NOT_READY", "The story upload token is invalid or not ready."));
|
|
story.Attachments.Add(attachment);
|
|
created.Add(story);
|
|
cids[story.Id] = ReadInt(requested, "cid") ?? 0;
|
|
}
|
|
dbContext.Stories.AddRange(created);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var mappedStories = new List<object>();
|
|
foreach (var story in created) mappedStories.Add(await BuildStoryAsync(story, cancellationToken, cids[story.Id]));
|
|
var now = DateTimeOffset.UtcNow;
|
|
var activeOwnStories = await dbContext.Stories.AsNoTracking().Include(story => story.Attachments).Include(story => story.Views)
|
|
.Where(story => story.AuthorUserId == user.Id && story.DeletedAt == null && story.ExpiresAt > now)
|
|
.ToListAsync(cancellationToken);
|
|
var preview = (await BuildStoryPreviewsAsync(activeOwnStories, user.Id, cancellationToken)).Single();
|
|
var publicStories = activeOwnStories.Where(story => story.Visibility == StoryVisibility.Everyone).ToList();
|
|
if (publicStories.Count > 0)
|
|
{
|
|
var publicPreview = (await BuildStoryPreviewsAsync(publicStories, user.Id, cancellationToken)).Single();
|
|
await hubContext.Clients.All.SendAsync("QlyraPacket", new { opcode = 216, payload = new { storiesPreview = publicPreview } }, cancellationToken);
|
|
}
|
|
await PublishPacketAsync((await LoadStoryPeerAuthorIdsAsync(user.Id, cancellationToken)).Append(user.Id), 216, new { storiesPreview = preview }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { storiesPreview = preview, stories = mappedStories }));
|
|
}
|
|
|
|
private async Task<IActionResult> SendPollVoteAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var pollId = await clientIdMapper.FindEntityIdAsync("poll", ReadInt(payload, "pollId") ?? 0, cancellationToken);
|
|
var poll = pollId is null ? null : await dbContext.QlyraPolls.Include(item => item.Answers).Include(item => item.Votes).SingleOrDefaultAsync(item => item.Id == pollId && item.Message.ChatId == chat.Id, cancellationToken);
|
|
if (poll is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "POLL_NOT_FOUND", "Poll was not found."));
|
|
var selected = ReadIntList(payload, "answersIds").Where(id => poll.Answers.Any(answer => answer.AnswerId == id)).Distinct().ToList();
|
|
if ((poll.Settings & 1) == 0 && selected.Count > 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_SINGLE_CHOICE", "Only one answer can be selected."));
|
|
dbContext.QlyraPollVotes.RemoveRange(poll.Votes.Where(vote => vote.UserId == user.Id));
|
|
foreach (var answerId in selected) dbContext.QlyraPollVotes.Add(new QlyraPollVote { PollId = poll.Id, AnswerId = answerId, UserId = user.Id });
|
|
poll.Version++;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
poll = await dbContext.QlyraPolls.AsNoTracking().Include(item => item.Answers).Include(item => item.Votes).SingleAsync(item => item.Id == poll.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { state = await BuildPollStateAsync(poll, user.Id, cancellationToken) }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetPollUpdatesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var requestedIds = new List<int>();
|
|
if (payload.TryGetProperty("polls", out var requested) && requested.ValueKind == JsonValueKind.Array)
|
|
{
|
|
requestedIds.AddRange(requested.EnumerateArray().Select(item => ReadInt(item, "pollId") ?? 0).Where(id => id > 0));
|
|
}
|
|
var entityIds = new List<Guid>();
|
|
foreach (var requestedId in requestedIds.Distinct())
|
|
{
|
|
var id = await clientIdMapper.FindEntityIdAsync("poll", requestedId, cancellationToken);
|
|
if (id is not null) entityIds.Add(id.Value);
|
|
}
|
|
var entities = await dbContext.QlyraPolls.AsNoTracking().Include(item => item.Answers).Include(item => item.Votes).Where(item => entityIds.Contains(item.Id) && item.Message.ChatId == chat.Id).ToListAsync(cancellationToken);
|
|
var polls = new List<object>();
|
|
foreach (var poll in entities)
|
|
{
|
|
polls.Add(new { pollId = await clientIdMapper.GetOrCreateAsync("poll", poll.Id, cancellationToken), title = poll.Title, settings = poll.Settings, version = poll.Version, answers = poll.Answers.OrderBy(answer => answer.AnswerId).Select(answer => new { answerId = answer.AnswerId, text = answer.Text }).ToArray(), state = await BuildPollStateAsync(poll, user.Id, cancellationToken) });
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { polls }));
|
|
}
|
|
|
|
private async Task<object> BuildPollStateAsync(QlyraPoll poll, Guid currentUserId, CancellationToken cancellationToken)
|
|
{
|
|
var totalVoters = poll.Votes.Select(vote => vote.UserId).Distinct().Count();
|
|
var anonymous = (poll.Settings & 4) != 0;
|
|
var voterPreviewIds = new List<int>();
|
|
if (!anonymous)
|
|
{
|
|
foreach (var voterId in poll.Votes.Select(vote => vote.UserId).Distinct().Take(3)) voterPreviewIds.Add(await clientIdMapper.GetOrCreateAsync("user", voterId, cancellationToken));
|
|
}
|
|
var result = new List<object>();
|
|
foreach (var answer in poll.Answers.OrderBy(answer => answer.AnswerId))
|
|
{
|
|
var votes = poll.Votes.Where(vote => vote.AnswerId == answer.AnswerId).ToList();
|
|
var voterIds = new List<int>();
|
|
if (!anonymous)
|
|
{
|
|
foreach (var vote in votes) voterIds.Add(await clientIdMapper.GetOrCreateAsync("user", vote.UserId, cancellationToken));
|
|
}
|
|
result.Add(new { answerId = answer.AnswerId, voteCount = votes.Count, rate = totalVoters == 0 ? 0d : (double)votes.Count / totalVoters, votes = voterIds, options = votes.Any(vote => vote.UserId == currentUserId) ? 1 : 0 });
|
|
}
|
|
return new { total = totalVoters, result, voterPreviewIds };
|
|
}
|
|
|
|
private async Task<IActionResult> FeatureUnavailableAsync(int opcode, string errorKey, string message, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
return StatusCode(StatusCodes.Status409Conflict, QlyraCompatibilityResponse.Error(opcode, errorKey, message));
|
|
}
|
|
|
|
private async Task<IActionResult> ApproveQrLoginAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
if (webPushSessionStore is null) return StatusCode(StatusCodes.Status503ServiceUnavailable, QlyraCompatibilityResponse.Error(opcode, "QR_LOGIN_UNAVAILABLE", "QR login service is unavailable."));
|
|
var qrLink = ReadString(payload, "qrLink");
|
|
if (string.IsNullOrWhiteSpace(qrLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "QR_LINK_REQUIRED", "QR link is required."));
|
|
if (!webPushSessionStore.Approve(qrLink, user.Id)) return NotFound(QlyraCompatibilityResponse.Error(opcode, "QR_TRACK_NOT_FOUND", "QR session was not found or has expired."));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private static Dictionary<string, object?> BuildUserConfig(UserPrivacySettings? settings = null)
|
|
{
|
|
var config = new Dictionary<string, object?>
|
|
{
|
|
["SEARCH_BY_PHONE"] = "ALL",
|
|
["INCOMING_CALL"] = "CONTACTS",
|
|
["PHONE_NUMBER_PRIVACY"] = "ALL",
|
|
["CHATS_INVITE"] = "CONTACTS",
|
|
["CHATS_PUSH_NOTIFICATION"] = "ON",
|
|
["M_CALL_PUSH_NOTIFICATION"] = "ON",
|
|
["PUSH_DETAILS"] = false,
|
|
["PUSH_NEW_CONTACTS"] = false,
|
|
["SHOW_READ_MARK"] = true,
|
|
["AUDIO_TRANSCRIPTION_ENABLED"] = true,
|
|
["hash"] = "ikar"
|
|
};
|
|
if (settings is not null && !string.IsNullOrWhiteSpace(settings.QlyraConfigJson))
|
|
{
|
|
try
|
|
{
|
|
var stored = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(settings.QlyraConfigJson);
|
|
if (stored is not null)
|
|
{
|
|
foreach (var property in stored) config[property.Key] = ReadConfigValue(property.Value);
|
|
}
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
// Keep safe defaults if an older row contains malformed compatibility data.
|
|
}
|
|
config["PHONE_NUMBER_PRIVACY"] = ToQlyraPrivacy(settings.PhoneNumberVisibility);
|
|
config["PUSH_DETAILS"] = settings.ShowMessagePreview;
|
|
}
|
|
return config;
|
|
}
|
|
|
|
private static object? ReadConfigValue(JsonElement value) => value.ValueKind switch
|
|
{
|
|
JsonValueKind.String => value.GetString(),
|
|
JsonValueKind.Number when value.TryGetInt64(out var number) => number,
|
|
JsonValueKind.Number => value.GetDouble(),
|
|
JsonValueKind.True => true,
|
|
JsonValueKind.False => false,
|
|
JsonValueKind.Null => null,
|
|
_ => value.Clone()
|
|
};
|
|
|
|
private static void ApplyPrivacyConfig(UserPrivacySettings settings, IReadOnlyDictionary<string, object?> config)
|
|
{
|
|
if (config.TryGetValue("PHONE_NUMBER_PRIVACY", out var privacy) && privacy is string privacyValue)
|
|
{
|
|
settings.PhoneNumberVisibility = privacyValue.Trim().ToUpperInvariant() switch
|
|
{
|
|
"ALL" => "everyone",
|
|
"CONTACTS" => "contacts",
|
|
"NOBODY" => "nobody",
|
|
_ => settings.PhoneNumberVisibility
|
|
};
|
|
}
|
|
if (config.TryGetValue("PUSH_DETAILS", out var preview) && preview is bool showPreview)
|
|
{
|
|
settings.ShowMessagePreview = showPreview;
|
|
}
|
|
}
|
|
|
|
private static string ToQlyraPrivacy(string value) => value.Trim().ToLowerInvariant() switch
|
|
{
|
|
"everyone" => "ALL",
|
|
"contacts" => "CONTACTS",
|
|
"nobody" => "NOBODY",
|
|
_ => "ALL"
|
|
};
|
|
|
|
private static DateTimeOffset? ToMutedUntil(long value)
|
|
{
|
|
if (value == 0) return null;
|
|
if (value < 0 || value > DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) return DateTimeOffset.MaxValue;
|
|
return DateTimeOffset.FromUnixTimeMilliseconds(value);
|
|
}
|
|
|
|
private async Task<IActionResult> GetContactsAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var contacts = await BuildContactsAsync(user.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts }));
|
|
}
|
|
|
|
private async Task<IActionResult> UpdateProfileAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var firstName = ReadString(payload, "firstName")?.Trim();
|
|
var lastName = ReadString(payload, "lastName")?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(firstName))
|
|
{
|
|
var displayName = string.Join(' ', new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
|
if (displayName.Length > 120) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_NAME_INVALID", "Display name is too long."));
|
|
user.DisplayName = displayName;
|
|
}
|
|
var photoToken = ReadString(payload, "photoToken")?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(photoToken))
|
|
{
|
|
var uploaded = await uploadStore.MaterializeStandaloneAsync(photoToken, user.Id, cancellationToken);
|
|
if (uploaded is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "UPLOAD_TOKEN_INVALID", "Profile photo upload token is invalid or incomplete."));
|
|
if (!uploaded.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try { System.IO.File.Delete(uploaded.StoragePath); } catch (IOException) { }
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "PROFILE_PHOTO_INVALID", "Profile photo must be an image."));
|
|
}
|
|
var previousAvatar = user.AvatarStoragePath;
|
|
user.AvatarStoragePath = uploaded.StoragePath;
|
|
user.AvatarContentType = uploaded.ContentType;
|
|
if (!string.IsNullOrWhiteSpace(previousAvatar) && !string.Equals(previousAvatar, uploaded.StoragePath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try { System.IO.File.Delete(previousAvatar); } catch (IOException) { }
|
|
}
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var profile = await BuildProfileAsync(user, cancellationToken);
|
|
await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetChatsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chats = await LoadMemberChatsAsync(user.Id, cancellationToken);
|
|
var marker = Math.Max(ReadInt(payload, "marker") ?? 0, 0);
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100);
|
|
var page = chats.Skip(marker).Take(count).ToList();
|
|
var nextMarker = marker + page.Count < chats.Count ? marker + page.Count : 0;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
chats = await BuildChatsAsync(page, cancellationToken),
|
|
marker = nextMarker,
|
|
chatMarker = nextMarker
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> GetContactInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var ids = ReadIntList(payload, "contactIds");
|
|
if (ids.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts = Array.Empty<object>() }));
|
|
var users = await dbContext.Users.Join(
|
|
dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "user" && ids.Contains(map.Id)),
|
|
entity => entity.Id,
|
|
map => map.EntityId,
|
|
(entity, _) => entity)
|
|
.Where(entity => !entity.IsBlocked)
|
|
.ToListAsync(cancellationToken);
|
|
var userIds = users.Select(contact => contact.Id).ToList();
|
|
var customNames = await dbContext.UserContacts
|
|
.Where(contact => contact.OwnerUserId == user.Id && userIds.Contains(contact.ContactUserId))
|
|
.ToDictionaryAsync(contact => contact.ContactUserId, cancellationToken);
|
|
var contacts = new List<object>(users.Count);
|
|
foreach (var contact in users)
|
|
{
|
|
customNames.TryGetValue(contact.Id, out var customName);
|
|
contacts.Add(await BuildContactAsync(
|
|
contact,
|
|
cancellationToken,
|
|
customName?.CustomFirstName,
|
|
customName?.CustomLastName));
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetContactPresenceAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var currentUser = await RequireCurrentUserAsync(cancellationToken);
|
|
if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var ids = ReadIntList(payload, "contactIds").Distinct().ToList();
|
|
if (ids.Count == 0)
|
|
{
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { presence = new Dictionary<string, object>() }));
|
|
}
|
|
|
|
var mappings = await dbContext.QlyraClientIdMaps
|
|
.Where(map => map.EntityKind == "user" && ids.Contains(map.Id))
|
|
.ToListAsync(cancellationToken);
|
|
var entityIds = mappings.Select(map => map.EntityId).ToList();
|
|
var users = await dbContext.Users
|
|
.Include(user => user.Sessions)
|
|
.Include(user => user.PrivacySettings)
|
|
.Where(user => entityIds.Contains(user.Id) && !user.IsBlocked)
|
|
.ToDictionaryAsync(user => user.Id, cancellationToken);
|
|
var currentChatIds = dbContext.ChatMembers
|
|
.Where(member => member.UserId == currentUser.Id)
|
|
.Select(member => member.ChatId);
|
|
var sharedUserIds = await dbContext.ChatMembers
|
|
.Where(member => entityIds.Contains(member.UserId) && currentChatIds.Contains(member.ChatId))
|
|
.Select(member => member.UserId)
|
|
.Distinct()
|
|
.ToHashSetAsync(cancellationToken);
|
|
|
|
var presence = new Dictionary<string, object>();
|
|
foreach (var mapping in mappings)
|
|
{
|
|
if (!users.TryGetValue(mapping.EntityId, out var user)) continue;
|
|
var visible = user.ToDtoForViewer(
|
|
presenceTracker,
|
|
currentUser.Id,
|
|
user.Id == currentUser.Id || sharedUserIds.Contains(user.Id));
|
|
var isOnline = user.Id == currentUser.Id || visible.IsOnline;
|
|
presence[mapping.Id.ToString()] = new
|
|
{
|
|
status = isOnline ? 1 : 0,
|
|
seen = visible.LastSeenAt?.ToUnixTimeSeconds() ?? 0
|
|
};
|
|
}
|
|
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { presence }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetContactByPhoneAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var currentUser = await RequireCurrentUserAsync(cancellationToken);
|
|
if (currentUser is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
var normalizedPhone = PhoneNumberNormalizer.Normalize(ReadString(payload, "phone"));
|
|
if (normalizedPhone is null)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required."));
|
|
}
|
|
var contact = await dbContext.Users.SingleOrDefaultAsync(
|
|
user => user.NormalizedPhoneNumber == normalizedPhone && !user.IsBlocked,
|
|
cancellationToken);
|
|
if (contact is null)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден."));
|
|
}
|
|
var relation = await dbContext.UserContacts.SingleOrDefaultAsync(
|
|
item => item.OwnerUserId == currentUser.Id && item.ContactUserId == contact.Id,
|
|
cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
contact = await BuildContactAsync(
|
|
contact,
|
|
cancellationToken,
|
|
relation?.CustomFirstName,
|
|
relation?.CustomLastName)
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> AddContactByPhoneAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var currentUser = await RequireCurrentUserAsync(cancellationToken);
|
|
if (currentUser is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
|
|
var normalizedPhone = PhoneNumberNormalizer.Normalize(ReadString(payload, "phone"));
|
|
if (normalizedPhone is null)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "INVALID_PHONE", "Valid phone number is required."));
|
|
}
|
|
|
|
var target = await dbContext.Users.SingleOrDefaultAsync(
|
|
user => user.NormalizedPhoneNumber == normalizedPhone && !user.IsBlocked,
|
|
cancellationToken);
|
|
if (target is null || target.Id == currentUser.Id)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден."));
|
|
}
|
|
|
|
var firstName = ReadString(payload, "firstName")?.Trim();
|
|
var lastName = ReadString(payload, "lastName")?.Trim();
|
|
if ((firstName?.Length ?? 0) > 120 || (lastName?.Length ?? 0) > 120)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_NAME_INVALID", "Contact name is too long."));
|
|
}
|
|
|
|
var relation = await UpsertContactAsync(
|
|
currentUser.Id,
|
|
target.Id,
|
|
firstName,
|
|
lastName,
|
|
cancellationToken);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
contact = await BuildContactAsync(target, cancellationToken, relation.CustomFirstName, relation.CustomLastName)
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> UpdateContactAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var currentUser = await RequireCurrentUserAsync(cancellationToken);
|
|
if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken);
|
|
if (targetId is null)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден."));
|
|
}
|
|
var target = await dbContext.Users.SingleOrDefaultAsync(user => user.Id == targetId.Value && !user.IsBlocked, cancellationToken);
|
|
if (target is null)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "user.not.found", "Пользователь не найден."));
|
|
}
|
|
var action = ReadString(payload, "action")?.Trim().ToUpperInvariant();
|
|
var relation = await dbContext.UserContacts.SingleOrDefaultAsync(
|
|
contact => contact.OwnerUserId == currentUser.Id && contact.ContactUserId == target.Id,
|
|
cancellationToken);
|
|
if (action is "ADD" or "UPDATE")
|
|
{
|
|
if (action == "UPDATE" && relation is null)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "contact.not.found", "Контакт не найден. Добавьте его в контакты."));
|
|
}
|
|
var firstName = ReadString(payload, "firstName")?.Trim();
|
|
var lastName = ReadString(payload, "lastName")?.Trim();
|
|
if ((firstName?.Length ?? 0) > 120 || (lastName?.Length ?? 0) > 120)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_NAME_INVALID", "Contact name is too long."));
|
|
}
|
|
relation = await UpsertContactAsync(
|
|
currentUser.Id,
|
|
target.Id,
|
|
firstName,
|
|
lastName,
|
|
cancellationToken);
|
|
}
|
|
else if (action == "REMOVE")
|
|
{
|
|
if (relation is not null) dbContext.UserContacts.Remove(relation);
|
|
relation = null;
|
|
}
|
|
if (action is "BLOCK" or "UNBLOCK")
|
|
{
|
|
var settings = await GetOrCreatePrivacySettingsAsync(currentUser.Id, cancellationToken);
|
|
var blocked = ParseUserIds(settings.BlockedUserIds);
|
|
if (action == "BLOCK") blocked.Add(target.Id); else blocked.Remove(target.Id);
|
|
settings.BlockedUserIds = string.Join(',', blocked.OrderBy(id => id).Select(id => id.ToString("D")));
|
|
settings.UpdatedAt = DateTimeOffset.UtcNow;
|
|
}
|
|
else if (action is not ("ADD" or "UPDATE" or "REMOVE"))
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CONTACT_ACTION_INVALID", "A supported contact action is required."));
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new
|
|
{
|
|
contact = await BuildContactAsync(target, cancellationToken, relation?.CustomFirstName, relation?.CustomLastName),
|
|
success = true
|
|
}));
|
|
}
|
|
|
|
private async Task<IActionResult> GetBlockedContactsAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var currentUser = await RequireCurrentUserAsync(cancellationToken);
|
|
if (currentUser is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var settings = await GetOrCreatePrivacySettingsAsync(currentUser.Id, cancellationToken);
|
|
var blockedIds = ParseUserIds(settings.BlockedUserIds);
|
|
var blockedUsers = await dbContext.Users.Where(user => blockedIds.Contains(user.Id)).ToListAsync(cancellationToken);
|
|
var customNames = await dbContext.UserContacts
|
|
.Where(contact => contact.OwnerUserId == currentUser.Id && blockedIds.Contains(contact.ContactUserId))
|
|
.ToDictionaryAsync(contact => contact.ContactUserId, cancellationToken);
|
|
var contacts = new List<object>(blockedUsers.Count);
|
|
foreach (var user in blockedUsers)
|
|
{
|
|
customNames.TryGetValue(user.Id, out var customName);
|
|
contacts.Add(await BuildContactAsync(user, cancellationToken, customName?.CustomFirstName, customName?.CustomLastName));
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { contacts }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetContactPhotosAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
if (await RequireCurrentUserAsync(cancellationToken) is null)
|
|
{
|
|
return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
}
|
|
var targetId = await clientIdMapper.FindEntityIdAsync("user", ReadInt(payload, "contactId") ?? 0, cancellationToken);
|
|
var user = targetId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == targetId.Value, cancellationToken);
|
|
var url = user is null ? null : BuildAvatarUrl(user);
|
|
var photoId = user is null ? null : await BuildAvatarPhotoIdAsync(user, cancellationToken);
|
|
var urls = url is null ? Array.Empty<string>() : new[] { url };
|
|
var ids = photoId is null ? Array.Empty<int>() : new[] { photoId.Value };
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { urls, ids, total = urls.Length }));
|
|
}
|
|
|
|
private async Task<IActionResult> RemoveProfilePhotoAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var previousAvatar = user.AvatarStoragePath;
|
|
user.AvatarStoragePath = null;
|
|
user.AvatarContentType = null;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
if (!string.IsNullOrWhiteSpace(previousAvatar))
|
|
{
|
|
try { System.IO.File.Delete(previousAvatar); } catch (IOException) { }
|
|
}
|
|
var profile = await BuildProfileAsync(user, cancellationToken);
|
|
await PublishPacketAsync([user.Id], 159, new { profile }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { profile }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetSessionsAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var currentSessionId = User.GetRequiredSessionId();
|
|
var entities = await dbContext.UserSessions
|
|
.Where(session => session.UserId == user.Id)
|
|
.OrderByDescending(session => session.LastSeenAt)
|
|
.ToListAsync(cancellationToken);
|
|
var sessions = new List<object>(entities.Count);
|
|
foreach (var session in entities)
|
|
{
|
|
sessions.Add(new
|
|
{
|
|
id = await clientIdMapper.GetOrCreateAsync("session", session.Id, cancellationToken),
|
|
client = session.ClientVersionName ?? session.ClientPlatform ?? "IKAR",
|
|
location = string.Empty,
|
|
current = session.Id == currentSessionId,
|
|
time = session.LastSeenAt.ToUnixTimeSeconds(),
|
|
info = session.Id == currentSessionId ? "Это устройство" : "Сессия IKAR"
|
|
});
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { sessions }));
|
|
}
|
|
|
|
private async Task<IActionResult> CloseOtherSessionsAsync(int opcode, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var currentSessionId = User.GetRequiredSessionId();
|
|
var sessions = await dbContext.UserSessions
|
|
.Where(session => session.UserId == user.Id && session.Id != currentSessionId)
|
|
.ToListAsync(cancellationToken);
|
|
dbContext.UserSessions.RemoveRange(sessions);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetChatInfoAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var ids = ReadIntList(payload, "chatIds");
|
|
if (ids.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chats = Array.Empty<object>() }));
|
|
var chats = new List<Chat>();
|
|
foreach (var id in ids.Distinct())
|
|
{
|
|
var chat = await ResolveMemberChatAsync(user.Id, id, cancellationToken);
|
|
if (chat is not null && chats.All(existing => existing.Id != chat.Id)) chats.Add(chat);
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chats = await BuildChatsAsync(chats, cancellationToken) }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetHistoryAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var requestedChatId = ReadInt(payload, "chatId");
|
|
var chat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken);
|
|
if (chat is null)
|
|
{
|
|
// A contact dialog exists in the client before its first message.
|
|
if (requestedChatId is > 0 && await ResolveDirectPeerAsync(user.Id, requestedChatId.Value, cancellationToken) is not null)
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages = Array.Empty<object>(), hasMore = false }));
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Чат не найден."));
|
|
}
|
|
var backward = Math.Clamp(ReadInt(payload, "backward") ?? 50, 0, 200);
|
|
var forward = Math.Clamp(ReadInt(payload, "forward") ?? 0, 0, 200);
|
|
if (backward == 0 && forward == 0) backward = 50;
|
|
var from = ReadLong(payload, "from") ?? DateTimeOffset.MaxValue.ToUnixTimeMilliseconds();
|
|
DateTimeOffset anchor;
|
|
try
|
|
{
|
|
anchor = DateTimeOffset.FromUnixTimeMilliseconds(from);
|
|
}
|
|
catch (ArgumentOutOfRangeException)
|
|
{
|
|
anchor = from < 0 ? DateTimeOffset.MinValue : DateTimeOffset.MaxValue;
|
|
}
|
|
var hiddenMessageIds = await dbContext.MessageHiddenForUsers.AsNoTracking()
|
|
.Where(item => item.UserId == user.Id && item.Message.ChatId == chat.Id)
|
|
.Select(item => item.MessageId)
|
|
.ToListAsync(cancellationToken);
|
|
var delayed = ReadString(payload, "itemType") == "DELAYED";
|
|
var visibleMessages = chat.Messages
|
|
.Where(message => message.DeletedAt is null && !hiddenMessageIds.Contains(message.Id))
|
|
.Where(message => delayed ? message.ScheduledForUnixMs != null && message.SenderId == user.Id : message.ScheduledForUnixMs == null);
|
|
if (delayed) anchor = DateTimeOffset.MaxValue;
|
|
if (ReadInt(payload, "postId") is int clientPostId)
|
|
{
|
|
var parent = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken);
|
|
if (parent is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "POST_NOT_FOUND", "Comment post was not found."));
|
|
visibleMessages = visibleMessages.Where(message => message.DiscussionMessageId == parent.Id);
|
|
}
|
|
var before = visibleMessages.Where(message => message.SentAt < anchor).OrderByDescending(message => message.SentAt).ToList();
|
|
var after = visibleMessages.Where(message => message.SentAt >= anchor).OrderBy(message => message.SentAt).ToList();
|
|
var hasMore = (backward > 0 && before.Count > backward) || (forward > 0 && after.Count > forward);
|
|
var messages = before.Take(backward)
|
|
.Concat(after.Take(forward))
|
|
.DistinctBy(message => message.Id)
|
|
.OrderBy(message => message.SentAt)
|
|
.ToList();
|
|
var mapped = new List<object>(messages.Count);
|
|
foreach (var message in messages)
|
|
{
|
|
mapped.Add(await BuildMessageAsync(message, cancellationToken));
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages = mapped, hasMore }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetChatMediaAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var count = Math.Clamp((ReadInt(payload, "forward") ?? 0) + (ReadInt(payload, "backward") ?? 60), 1, 100);
|
|
var hiddenMessageIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId);
|
|
var entities = await dbContext.Messages
|
|
.Include(message => message.Sender)
|
|
.Include(message => message.Attachments)
|
|
.Where(message => message.ChatId == chat.Id && message.DeletedAt == null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id) && message.Attachments.Count > 0)
|
|
.OrderByDescending(message => message.SentAt)
|
|
.Take(count)
|
|
.ToListAsync(cancellationToken);
|
|
var messages = new List<object>(entities.Count);
|
|
foreach (var message in entities) messages.Add(await BuildMessageAsync(message, cancellationToken));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { messages, total = entities.Count }));
|
|
}
|
|
|
|
private async Task<IActionResult> DeleteChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var membership = chat.Members.Single(member => member.UserId == user.Id);
|
|
var forAll = ReadBool(payload, "forAll") == true;
|
|
if (forAll && chat.Type != ChatType.Direct && !IsChatManager(membership))
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_DELETE_FORBIDDEN", "Only a chat owner can delete this chat for everyone."));
|
|
}
|
|
if (!forAll && chat.Type is (ChatType.Group or ChatType.Channel) && membership.IsOwner)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "OWNER_CANNOT_LEAVE", "Transfer ownership or delete the chat for everyone."));
|
|
}
|
|
var recipients = chat.Members.Select(member => member.UserId).ToArray();
|
|
if (forAll || chat.Members.Count == 1)
|
|
{
|
|
dbContext.Chats.Remove(chat);
|
|
}
|
|
else
|
|
{
|
|
dbContext.ChatMembers.Remove(membership);
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await PublishPacketAsync(recipients, 131, new { chatId = ReadInt(payload, "chatId") }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> ClearChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
if (ReadBool(payload, "forAll") != true)
|
|
{
|
|
var alreadyHidden = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId);
|
|
var visibleIds = chat.Messages.Where(message => message.DeletedAt == null).Select(message => message.Id).Where(messageId => !alreadyHidden.Contains(messageId)).ToArray();
|
|
dbContext.MessageHiddenForUsers.AddRange(visibleIds.Select(messageId => new MessageHiddenForUser { MessageId = messageId, UserId = user.Id }));
|
|
if (visibleIds.Length > 0) await dbContext.SaveChangesAsync(cancellationToken);
|
|
await PublishPacketAsync([user.Id], 143, new { chatId = ReadInt(payload, "chatId") }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
var membership = chat.Members.Single(member => member.UserId == user.Id);
|
|
if (chat.Type != ChatType.Direct && !IsChatManager(membership))
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_CLEAR_FORBIDDEN", "Only a chat owner can clear history for everyone."));
|
|
}
|
|
foreach (var message in chat.Messages.Where(message => message.DeletedAt is null))
|
|
{
|
|
message.Text = string.Empty;
|
|
message.DeletedAt = DateTimeOffset.UtcNow;
|
|
message.EditedAt = null;
|
|
}
|
|
chat.LastActivityAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await PublishPacketAsync(chat.Members.Select(member => member.UserId), 143, new { chatId = ReadInt(payload, "chatId") }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> UpdateChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var membership = chat.Members.Single(member => member.UserId == user.Id);
|
|
var title = ReadString(payload, "theme") ?? ReadString(payload, "title");
|
|
if (!string.IsNullOrWhiteSpace(title))
|
|
{
|
|
if (!IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_UPDATE_FORBIDDEN", "Only chat administrators can update the title."));
|
|
chat.Title = title.Trim().Length <= 120 ? title.Trim() : title.Trim()[..120];
|
|
}
|
|
var photoToken = ReadString(payload, "photoToken")?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(photoToken))
|
|
{
|
|
if (!IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_UPDATE_FORBIDDEN", "Only chat administrators can update the photo."));
|
|
var uploaded = await uploadStore.MaterializeStandaloneAsync(photoToken, user.Id, cancellationToken);
|
|
if (uploaded is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "UPLOAD_TOKEN_INVALID", "Chat photo upload token is invalid or incomplete."));
|
|
if (!uploaded.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try { System.IO.File.Delete(uploaded.StoragePath); } catch (IOException) { }
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_PHOTO_INVALID", "Chat photo must be an image."));
|
|
}
|
|
var previousAvatar = chat.AvatarStoragePath;
|
|
chat.AvatarStoragePath = uploaded.StoragePath;
|
|
chat.AvatarContentType = uploaded.ContentType;
|
|
if (!string.IsNullOrWhiteSpace(previousAvatar) && !string.Equals(previousAvatar, uploaded.StoragePath, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
try { System.IO.File.Delete(previousAvatar); } catch (IOException) { }
|
|
}
|
|
}
|
|
if (ReadInt(payload, "pinMessageId") is int pinnedClientId)
|
|
{
|
|
if (!membership.CanPinMessages && !IsChatManager(membership)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_PIN_FORBIDDEN", "This member cannot pin messages."));
|
|
foreach (var item in chat.Messages.Where(message => message.PinnedAt != null))
|
|
{
|
|
item.PinnedAt = null;
|
|
item.PinnedByUserId = null;
|
|
}
|
|
if (pinnedClientId != 0)
|
|
{
|
|
var message = await ResolveMessageAsync(chat.Id, pinnedClientId, cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
message.PinnedAt = DateTimeOffset.UtcNow;
|
|
message.PinnedByUserId = user.Id;
|
|
}
|
|
}
|
|
chat.LastActivityAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single();
|
|
await PublishPacketAsync(chat.Members.Select(member => member.UserId), 132, new { chat = mapped }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped }));
|
|
}
|
|
|
|
private async Task<IActionResult> JoinChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var rawLink = ReadString(payload, "link")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(rawLink)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "LINK_REQUIRED", "A chat link is required."));
|
|
var key = ExtractLinkKey(rawLink);
|
|
var chat = await dbContext.Chats
|
|
.Include(item => item.Members).ThenInclude(member => member.User)
|
|
.Include(item => item.Messages).ThenInclude(message => message.Sender)
|
|
.Include(item => item.InviteLinks)
|
|
.SingleOrDefaultAsync(item => item.PublicUsername == key || item.InviteLinks.Any(link => link.Token == key && !link.IsRevoked), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "LINK_NOT_FOUND", "Chat link was not found."));
|
|
var invite = chat.InviteLinks.FirstOrDefault(link => link.Token == key && !link.IsRevoked);
|
|
if (invite is not null && (invite.ExpiresAt <= DateTimeOffset.UtcNow || invite.MaxUses is int maxUses && invite.UsesCount >= maxUses || invite.MemberLimit is int memberLimit && chat.Members.Count >= memberLimit))
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "LINK_EXPIRED", "Chat link is no longer available."));
|
|
}
|
|
if (!chat.Members.Any(member => member.UserId == user.Id))
|
|
{
|
|
if (invite?.CreatesJoinRequest == true) return Conflict(QlyraCompatibilityResponse.Error(opcode, "JOIN_REQUEST_REQUIRED", "This chat requires administrator approval."));
|
|
chat.Members.Add(CreateChatMember(chat, user.Id));
|
|
if (invite is not null) invite.UsesCount += 1;
|
|
chat.LastActivityAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single();
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped }));
|
|
}
|
|
|
|
private async Task<IActionResult> LeaveChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var membership = chat.Members.Single(member => member.UserId == user.Id);
|
|
if (chat.Type is (ChatType.Group or ChatType.Channel) && membership.IsOwner) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "OWNER_CANNOT_LEAVE", "Transfer ownership or delete the chat for everyone."));
|
|
dbContext.ChatMembers.Remove(membership);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await PublishPacketAsync(chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId), 131, new { chatId = ReadInt(payload, "chatId") }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetChatMembersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var marker = Math.Max(ReadInt(payload, "marker") ?? 0, 0);
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100);
|
|
var selected = chat.Members.OrderByDescending(IsChatManager).ThenBy(member => member.JoinedAt).Skip(marker).Take(count).ToList();
|
|
var members = new List<object>(selected.Count);
|
|
foreach (var member in selected)
|
|
{
|
|
var dto = member.User.ToDtoForViewer(presenceTracker, user.Id, true);
|
|
members.Add(new { contact = await BuildContactAsync(member.User, cancellationToken), presence = new { status = dto.IsOnline ? 1 : 0, seen = dto.LastSeenAt?.ToUnixTimeSeconds() ?? 0 } });
|
|
}
|
|
var nextMarker = marker + selected.Count;
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { members, marker = nextMarker >= chat.Members.Count ? 0 : nextMarker }));
|
|
}
|
|
|
|
private async Task<IActionResult> PublicSearchAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var query = ReadString(payload, "query")?.Trim() ?? string.Empty;
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 20, 1, 100);
|
|
var result = new List<object>();
|
|
if (int.TryParse(query, out var clientUserId))
|
|
{
|
|
var entityId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken);
|
|
var exact = entityId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == entityId && !item.IsBlocked, cancellationToken);
|
|
if (exact is not null) result.Add(new { contact = await BuildContactAsync(exact, cancellationToken) });
|
|
}
|
|
if (result.Count < count && query.Length > 0)
|
|
{
|
|
var users = await dbContext.Users.Where(item => item.Id != user.Id && !item.IsBlocked && (item.DisplayName.Contains(query) || item.Username.Contains(query))).Take(count - result.Count).ToListAsync(cancellationToken);
|
|
foreach (var found in users) result.Add(new { contact = await BuildContactAsync(found, cancellationToken) });
|
|
var normalized = query.TrimStart('@');
|
|
var chats = await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender)
|
|
.Where(item => item.PublicUsername != null && (item.PublicUsername.Contains(normalized) || item.Title != null && item.Title.Contains(query))).Take(count - result.Count).ToListAsync(cancellationToken);
|
|
foreach (var found in chats)
|
|
{
|
|
var mapped = (await BuildChatsAsync([found], cancellationToken)).Single();
|
|
result.Add(new { chat = mapped });
|
|
}
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { result = result.Take(count).ToArray() }));
|
|
}
|
|
|
|
private async Task<IActionResult> SearchMessagesAsync(int opcode, JsonElement payload, bool requireChat, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var query = ReadString(payload, "query")?.Trim() ?? string.Empty;
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 50, 1, 100);
|
|
var memberChatIds = dbContext.ChatMembers.Where(member => member.UserId == user.Id).Select(member => member.ChatId);
|
|
var hiddenMessageIds = dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId);
|
|
var messagesQuery = dbContext.Messages.Include(message => message.Sender).Where(message => memberChatIds.Contains(message.ChatId) && message.DeletedAt == null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id) && message.Text.Contains(query));
|
|
if (requireChat)
|
|
{
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
messagesQuery = messagesQuery.Where(message => message.ChatId == chat.Id);
|
|
}
|
|
var entities = await messagesQuery.OrderByDescending(message => message.SentAt).Take(count).ToListAsync(cancellationToken);
|
|
var result = new List<object>(entities.Count);
|
|
foreach (var message in entities)
|
|
{
|
|
result.Add(new { chatId = await GetClientChatIdAsync(message.ChatId, cancellationToken), message = await BuildMessageAsync(message, cancellationToken) });
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { result, messages = result.Select(item => item).ToArray() }));
|
|
}
|
|
|
|
private async Task<IActionResult> SubscribeChatAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
// This opcode subscribes a preview to updates. Joining/leaving a chat is
|
|
// handled by opcodes 57/58; closing a preview must preserve membership.
|
|
if (ReadBool(payload, "subscribe") == false)
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
var requestedChatId = ReadInt(payload, "chatId");
|
|
var memberChat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken);
|
|
if (memberChat is not null)
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([memberChat], cancellationToken)).Single() }));
|
|
if (requestedChatId is > 0 && await ResolveDirectPeerAsync(user.Id, requestedChatId.Value, cancellationToken) is not null)
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { success = true }));
|
|
var chatId = await clientIdMapper.FindEntityIdAsync("chat", requestedChatId ?? 0, cancellationToken);
|
|
var chat = chatId is null ? null : await dbContext.Chats.Include(item => item.Members).ThenInclude(member => member.User).Include(item => item.Messages).ThenInclude(message => message.Sender).SingleOrDefaultAsync(item => item.Id == chatId, cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Чат не найден."));
|
|
if (chat.Type != ChatType.Channel || string.IsNullOrWhiteSpace(chat.PublicUsername)) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_PRIVATE", "Для доступа к приватному чату требуется приглашение."));
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() }));
|
|
}
|
|
|
|
private async Task<IActionResult> UpdateChatMembersAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
if (!IsChatManager(chat.Members.Single(member => member.UserId == user.Id))) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_MEMBERS_FORBIDDEN", "Only chat administrators can update members."));
|
|
var entityIds = new List<Guid>();
|
|
foreach (var clientUserId in ReadIntList(payload, "userIds"))
|
|
{
|
|
var entityId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken);
|
|
if (entityId is not null) entityIds.Add(entityId.Value);
|
|
}
|
|
if (entityIds.Count == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBERS_REQUIRED", "At least one valid member is required."));
|
|
var operation = ReadString(payload, "operation")?.ToLowerInvariant() ?? "add";
|
|
if (operation == "remove")
|
|
{
|
|
dbContext.ChatMembers.RemoveRange(chat.Members.Where(member => entityIds.Contains(member.UserId) && !member.IsOwner));
|
|
}
|
|
else
|
|
{
|
|
var validIds = await dbContext.Users.Where(item => entityIds.Contains(item.Id) && !item.IsBlocked).Select(item => item.Id).ToListAsync(cancellationToken);
|
|
foreach (var entityId in validIds.Where(entityId => chat.Members.All(member => member.UserId != entityId))) chat.Members.Add(CreateChatMember(chat, entityId));
|
|
}
|
|
chat.LastActivityAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = (await BuildChatsAsync([chat], cancellationToken)).Single() }));
|
|
}
|
|
|
|
private async Task<IActionResult> SendMessageAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var attaches = ReadNestedArray(payload, "message", "attaches");
|
|
var firstAttach = attaches.ValueKind == JsonValueKind.Array && attaches.GetArrayLength() > 0 ? attaches[0] : default;
|
|
if (firstAttach.ValueKind == JsonValueKind.Object &&
|
|
string.Equals(ReadString(firstAttach, "_type"), "CONTROL", StringComparison.OrdinalIgnoreCase) &&
|
|
string.Equals(ReadString(firstAttach, "event"), "new", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return await CreateChatFromControlAsync(opcode, user, firstAttach, cancellationToken);
|
|
}
|
|
|
|
var requestedChatId = ReadInt(payload, "chatId");
|
|
var chat = await ResolveMemberChatAsync(user.Id, requestedChatId, cancellationToken);
|
|
if (chat is null && requestedChatId is > 0) chat = await CreateDirectChatFromClientIdAsync(user, requestedChatId.Value, cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
if (chat.Type == ChatType.Channel && !chat.Members.Any(member => member.UserId == user.Id && (member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin || member.CanPostMessages)))
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "CHAT_READ_ONLY", "Chat is read-only."));
|
|
}
|
|
|
|
var clientCid = payload.TryGetProperty("message", out var cidPayload) ? ReadLong(cidPayload, "cid") : null;
|
|
if (clientCid is not null)
|
|
{
|
|
var duplicate = await dbContext.Messages.Include(x => x.Attachments).SingleOrDefaultAsync(x => x.ChatId == chat.Id && x.SenderId == user.Id && x.IkarClientCid == clientCid, cancellationToken);
|
|
if (duplicate is not null) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = await BuildMessageAsync(duplicate, cancellationToken) }));
|
|
}
|
|
|
|
if (chat.Type == ChatType.Direct)
|
|
{
|
|
var peers = chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId).ToList();
|
|
var peerSettings = await dbContext.UserPrivacySettings.Where(settings => peers.Contains(settings.UserId)).ToListAsync(cancellationToken);
|
|
if (peerSettings.Any(settings => ParseUserIds(settings.BlockedUserIds).Contains(user.Id)))
|
|
return StatusCode(403, QlyraCompatibilityResponse.Error(opcode, "USER_BLOCKED_SEND", "Получатель заблокировал сообщения от этого аккаунта."));
|
|
}
|
|
var text = ReadNestedString(payload, "message", "text") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(text) && (attaches.ValueKind != JsonValueKind.Array || attaches.GetArrayLength() == 0))
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMPTY_MESSAGE", "Message text or an attachment is required."));
|
|
}
|
|
var message = new Message
|
|
{
|
|
ChatId = chat.Id,
|
|
SenderId = user.Id,
|
|
Text = text,
|
|
SentAt = DateTimeOffset.UtcNow,
|
|
IkarClientCid = clientCid,
|
|
IkarElementsJson = payload.TryGetProperty("message", out var formatted) && formatted.TryGetProperty("elements", out var elements) && elements.ValueKind == JsonValueKind.Array ? elements.GetRawText() : null,
|
|
QlyraAttachesJson = null
|
|
};
|
|
Message? discussionPost = null;
|
|
if (ReadInt(payload, "postId") is int clientPostId)
|
|
{
|
|
discussionPost = await ResolveMessageAsync(chat.Id, clientPostId, cancellationToken);
|
|
if (discussionPost is null || discussionPost.DeletedAt is not null)
|
|
{
|
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "POST_NOT_FOUND", "Comment post was not found."));
|
|
}
|
|
message.DiscussionMessageId = discussionPost.Id;
|
|
discussionPost.CommentCount += 1;
|
|
}
|
|
if (attaches.ValueKind == JsonValueKind.Array)
|
|
{
|
|
var protocolAttaches = new List<JsonElement>();
|
|
var sortOrder = 0;
|
|
foreach (var attach in attaches.EnumerateArray())
|
|
{
|
|
var type = ReadString(attach, "_type")?.ToUpperInvariant();
|
|
if (type == "POLL")
|
|
{
|
|
if (attaches.GetArrayLength() != 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "A poll must be the only message attachment."));
|
|
var title = ReadString(attach, "title")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(title) || !attach.TryGetProperty("answers", out var requestedAnswers) || requestedAnswers.ValueKind != JsonValueKind.Array)
|
|
{
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "Poll title and answers are required."));
|
|
}
|
|
var answerTexts = requestedAnswers.EnumerateArray().Select(answer => ReadString(answer, "text")?.Trim()).Where(answer => !string.IsNullOrWhiteSpace(answer)).Select(answer => answer!).Take(10).ToArray();
|
|
if (answerTexts.Length < 2) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "POLL_INVALID", "A poll requires at least two answers."));
|
|
var poll = new QlyraPoll { MessageId = message.Id, Title = title.Length <= 255 ? title : title[..255], Settings = ReadInt(attach, "settings") ?? 0 };
|
|
for (var index = 0; index < answerTexts.Length; index++) poll.Answers.Add(new QlyraPollAnswer { PollId = poll.Id, AnswerId = index + 1, Text = answerTexts[index].Length <= 255 ? answerTexts[index] : answerTexts[index][..255] });
|
|
dbContext.Messages.Add(message);
|
|
dbContext.QlyraPolls.Add(poll);
|
|
var pollId = await clientIdMapper.GetOrCreateAsync("poll", poll.Id, cancellationToken);
|
|
protocolAttaches.Add(JsonSerializer.SerializeToElement(new { _type = "POLL", pollId, title = poll.Title, settings = poll.Settings, version = poll.Version, answers = poll.Answers.Select(answer => new { answerId = answer.AnswerId, text = answer.Text }).ToArray() }));
|
|
continue;
|
|
}
|
|
var token = ReadString(attach, "token") ?? ReadString(attach, "photoToken");
|
|
if (type is "PHOTO" or "VIDEO" or "AUDIO" or "FILE" && !string.IsNullOrWhiteSpace(token))
|
|
{
|
|
var materialized = await uploadStore.MaterializeMessageAttachmentAsync(
|
|
token,
|
|
user.Id,
|
|
message.Id,
|
|
type == "AUDIO" ? AttachmentKind.VoiceNote : AttachmentKind.File,
|
|
sortOrder++,
|
|
cancellationToken);
|
|
if (materialized is null)
|
|
{
|
|
return Conflict(QlyraCompatibilityResponse.Error(opcode, "ATTACHMENT_NOT_READY", "The uploaded attachment token is invalid or not ready."));
|
|
}
|
|
message.Attachments.Add(materialized);
|
|
continue;
|
|
}
|
|
protocolAttaches.Add(attach.Clone());
|
|
}
|
|
if (protocolAttaches.Count > 0) message.QlyraAttachesJson = JsonSerializer.Serialize(protocolAttaches);
|
|
}
|
|
if (payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("message", out var messagePayload) && messagePayload.ValueKind == JsonValueKind.Object && messagePayload.TryGetProperty("link", out var link) && link.ValueKind == JsonValueKind.Object && ReadInt(link, "messageId") is int replyClientId)
|
|
{
|
|
message.ReplyToMessageId = (await ResolveMessageAsync(chat.Id, replyClientId, cancellationToken))?.Id;
|
|
}
|
|
var scheduled = ReadScheduledTime(payload, "message");
|
|
if (scheduled is not null && (scheduled <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() || scheduled > DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds()))
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "SCHEDULE_INVALID", "Укажите время отправки в пределах года."));
|
|
message.ScheduledForUnixMs = scheduled;
|
|
if (scheduled is null) chat.LastActivityAt = message.SentAt;
|
|
if (dbContext.Entry(message).State == EntityState.Detached) dbContext.Messages.Add(message);
|
|
if (scheduled is null) pushDispatchQueue?.StageMessages(dbContext, message);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
message.Sender = user;
|
|
var mappedMessage = await BuildMessageAsync(message, cancellationToken);
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
await PublishPacketAsync(
|
|
message.ScheduledForUnixMs is not null ? [user.Id] : chat.Members.Select(member => member.UserId),
|
|
message.ScheduledForUnixMs is not null ? 154 : 128,
|
|
new { chatId = clientChatId, message = mappedMessage, unread = 0 },
|
|
cancellationToken);
|
|
if (message.ScheduledForUnixMs is null && pushDispatchQueue is not null) await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id, cancellationToken);
|
|
if (message.ScheduledForUnixMs is null) await (botUpdateService ?? new BotUpdateService(dbContext)).EnqueueIncomingMessageAsync(message.Id, cancellationToken);
|
|
if (message.ScheduledForUnixMs is null && botCreatorService is not null && await botCreatorService.TryHandleIncomingMessageAsync(message.Id, cancellationToken))
|
|
{
|
|
var replies = await dbContext.Messages.Include(x => x.Attachments).Where(x => x.ChatId == chat.Id && x.SenderId != user.Id && x.SentAt > message.SentAt && x.DeletedAt == null).ToListAsync(cancellationToken);
|
|
foreach (var reply in replies) await PublishPacketAsync(chat.Members.Select(x => x.UserId), 128, new { chatId = clientChatId, message = await BuildMessageAsync(reply, cancellationToken), unread = 0 }, cancellationToken);
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = mappedMessage }));
|
|
}
|
|
|
|
private async Task<IActionResult> CreateChatFromControlAsync(int opcode, User user, JsonElement control, CancellationToken cancellationToken)
|
|
{
|
|
var chatType = ReadString(control, "chatType")?.ToUpperInvariant();
|
|
if (chatType is not ("CHAT" or "CHANNEL")) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_TYPE_INVALID", "Only group chats and channels can be created with this request."));
|
|
var title = ReadString(control, "title")?.Trim();
|
|
if (string.IsNullOrWhiteSpace(title)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "CHAT_TITLE_REQUIRED", "Chat title is required."));
|
|
var requestedClientIds = ReadIntList(control, "userIds");
|
|
var memberIds = new HashSet<Guid> { user.Id };
|
|
foreach (var clientUserId in requestedClientIds)
|
|
{
|
|
var memberId = await clientIdMapper.FindEntityIdAsync("user", clientUserId, cancellationToken);
|
|
if (memberId is not null) memberIds.Add(memberId.Value);
|
|
}
|
|
if (memberIds.Count != requestedClientIds.Distinct().Count() + 1) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBER_NOT_FOUND", "One or more members were not found."));
|
|
var validUsers = await dbContext.Users.Where(item => memberIds.Contains(item.Id) && !item.IsBlocked).Select(item => item.Id).ToListAsync(cancellationToken);
|
|
if (validUsers.Count != memberIds.Count) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MEMBER_NOT_FOUND", "One or more members were not found."));
|
|
var chat = new Chat
|
|
{
|
|
Type = chatType == "CHANNEL" ? ChatType.Channel : ChatType.Group,
|
|
Title = title.Length <= 120 ? title : title[..120],
|
|
CreatedById = user.Id,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
LastActivityAt = DateTimeOffset.UtcNow
|
|
};
|
|
foreach (var memberId in memberIds)
|
|
{
|
|
var isOwner = memberId == user.Id;
|
|
chat.Members.Add(new ChatMember
|
|
{
|
|
Chat = chat,
|
|
UserId = memberId,
|
|
IsOwner = isOwner,
|
|
Role = isOwner ? ChatMemberRole.Owner : ChatMemberRole.Member,
|
|
CanPinMessages = isOwner,
|
|
CanDeleteMessages = isOwner,
|
|
CanInviteUsers = isOwner || chat.Type == ChatType.Group,
|
|
CanPostMessages = isOwner || chat.Type == ChatType.Group,
|
|
JoinedAt = DateTimeOffset.UtcNow
|
|
});
|
|
}
|
|
dbContext.Chats.Add(chat);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
await dbContext.Entry(chat).Collection(item => item.Members).Query().Include(member => member.User).LoadAsync(cancellationToken);
|
|
var mapped = (await BuildChatsAsync([chat], cancellationToken)).Single();
|
|
await PublishPacketAsync(memberIds, 131, new { chat = mapped }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { chat = mapped }));
|
|
}
|
|
|
|
private async Task<Chat?> CreateDirectChatFromClientIdAsync(User currentUser, int clientChatId, CancellationToken cancellationToken)
|
|
{
|
|
var peer = await ResolveDirectPeerAsync(currentUser.Id, clientChatId, cancellationToken);
|
|
if (peer is null) return null;
|
|
var chat = new Chat { Type = ChatType.Direct, Title = peer.DisplayName, CreatedById = currentUser.Id, LastActivityAt = DateTimeOffset.UtcNow };
|
|
chat.Members.Add(new ChatMember { Chat = chat, User = currentUser, UserId = currentUser.Id, IsOwner = true, Role = ChatMemberRole.Owner, CanPinMessages = true, CanDeleteMessages = true, CanInviteUsers = false, CanPostMessages = true });
|
|
chat.Members.Add(new ChatMember { Chat = chat, User = peer, UserId = peer.Id, Role = ChatMemberRole.Member, CanPinMessages = true, CanDeleteMessages = false, CanInviteUsers = false, CanPostMessages = true });
|
|
dbContext.Chats.Add(chat);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return chat;
|
|
}
|
|
|
|
private async Task<User?> ResolveDirectPeerAsync(Guid currentUserId, int clientChatId, CancellationToken cancellationToken)
|
|
{
|
|
if (clientChatId <= 0) return null;
|
|
var currentClientId = await clientIdMapper.GetOrCreateAsync("user", currentUserId, cancellationToken);
|
|
var peerClientId = clientChatId ^ currentClientId;
|
|
if (peerClientId <= 0 || peerClientId == currentClientId) return null;
|
|
var peerId = await clientIdMapper.FindEntityIdAsync("user", peerClientId, cancellationToken);
|
|
return peerId is null ? null : await dbContext.Users.SingleOrDefaultAsync(item => item.Id == peerId && !item.IsBlocked, cancellationToken);
|
|
}
|
|
|
|
private async Task<Chat?> GetOrCreateSavedChatAsync(Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
// Use the owner's UUID as the saved-chat UUID to guarantee one per user,
|
|
// without confusing it with a direct chat left by its other member.
|
|
var chat = await dbContext.Chats
|
|
.Include(item => item.Members).ThenInclude(member => member.User)
|
|
.Include(item => item.Messages).ThenInclude(message => message.Sender)
|
|
.SingleOrDefaultAsync(item => item.Id == userId, cancellationToken);
|
|
if (chat is not null) return IsSavedChat(chat) ? chat : null;
|
|
var user = await dbContext.Users.SingleOrDefaultAsync(item => item.Id == userId && !item.IsBlocked, cancellationToken);
|
|
if (user is null) return null;
|
|
chat = new Chat { Id = userId, Type = ChatType.Direct, Title = "Избранное", CreatedById = userId };
|
|
chat.Members.Add(new ChatMember { Chat = chat, User = user, UserId = userId, IsOwner = true, Role = ChatMemberRole.Owner, CanPostMessages = true, CanDeleteMessages = true, CanPinMessages = true, CanInviteUsers = false });
|
|
dbContext.Chats.Add(chat);
|
|
try
|
|
{
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// Opening history and subscribing can race on different requests.
|
|
// The primary key picks one creator; reload that same saved chat.
|
|
foreach (var member in chat.Members) dbContext.Entry(member).State = EntityState.Detached;
|
|
dbContext.Entry(chat).State = EntityState.Detached;
|
|
var concurrent = await dbContext.Chats
|
|
.Include(item => item.Members).ThenInclude(member => member.User)
|
|
.Include(item => item.Messages).ThenInclude(message => message.Sender)
|
|
.SingleOrDefaultAsync(item => item.Id == userId, cancellationToken);
|
|
if (concurrent is null || !IsSavedChat(concurrent)) throw;
|
|
return concurrent;
|
|
}
|
|
return chat;
|
|
}
|
|
|
|
private static bool IsSavedChat(Chat chat) =>
|
|
chat.Type == ChatType.Direct && chat.Id == chat.CreatedById &&
|
|
chat.Members.Count == 1 && chat.Members.Single().UserId == chat.CreatedById;
|
|
|
|
private async Task<IActionResult> MarkReadAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var messageId = ReadInt(payload, "messageId");
|
|
if (messageId is null) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_ID_REQUIRED", "Message id is required."));
|
|
var message = await dbContext.Messages.Join(
|
|
dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "message" && map.Id == messageId.Value),
|
|
entity => entity.Id,
|
|
map => map.EntityId,
|
|
(entity, _) => entity)
|
|
.SingleOrDefaultAsync(entity => entity.ChatId == chat.Id && entity.DeletedAt == null, cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
var membership = chat.Members.Single(member => member.UserId == user.Id);
|
|
if (membership.LastReadAt is null || membership.LastReadAt < message.SentAt)
|
|
{
|
|
membership.LastReadAt = message.SentAt;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var clientUserId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
await PublishPacketAsync(
|
|
chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId),
|
|
130,
|
|
new { chatId = clientChatId, userId = clientUserId, mark = message.SentAt.ToUnixTimeMilliseconds() },
|
|
cancellationToken);
|
|
}
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { }));
|
|
}
|
|
|
|
private async Task<IActionResult> SendTypingAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var clientUserId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
await PublishPacketAsync(
|
|
chat.Members.Where(member => member.UserId != user.Id).Select(member => member.UserId),
|
|
129,
|
|
new { chatId = clientChatId, userId = clientUserId, type = ReadString(payload, "type") ?? "TEXT" },
|
|
cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { }));
|
|
}
|
|
|
|
private async Task<IActionResult> EditMessageAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
if (message.SenderId != user.Id || message.DeletedAt is not null)
|
|
{
|
|
return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "MESSAGE_EDIT_FORBIDDEN", "Message cannot be edited."));
|
|
}
|
|
var text = ReadString(payload, "text") ?? string.Empty;
|
|
if (string.IsNullOrWhiteSpace(text)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "EMPTY_MESSAGE", "Message text is required."));
|
|
message.Text = text;
|
|
if (payload.TryGetProperty("elements", out var editedElements) && editedElements.ValueKind == JsonValueKind.Array) message.IkarElementsJson = editedElements.GetRawText();
|
|
if (message.ScheduledForUnixMs is not null && ReadScheduledTime(payload) is long rescheduled)
|
|
{
|
|
if (rescheduled <= DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() || rescheduled > DateTimeOffset.UtcNow.AddYears(1).ToUnixTimeMilliseconds())
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "SCHEDULE_INVALID", "Некорректное время отправки."));
|
|
message.ScheduledForUnixMs = rescheduled;
|
|
}
|
|
message.EditedAt = DateTimeOffset.UtcNow;
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
message.Sender = user;
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var mappedMessage = await BuildMessageAsync(message, cancellationToken);
|
|
await PublishPacketAsync(
|
|
message.ScheduledForUnixMs is not null ? [user.Id] : chat.Members.Select(member => member.UserId),
|
|
message.ScheduledForUnixMs is not null ? 154 : 128,
|
|
new { chatId = clientChatId, message = mappedMessage, unread = 0 },
|
|
cancellationToken);
|
|
if (message.ScheduledForUnixMs is null && pushDispatchQueue is not null) await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = mappedMessage }));
|
|
}
|
|
|
|
private async Task<IActionResult> DeleteMessagesAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var messageIds = ReadIntList(payload, "messageIds");
|
|
if (messageIds.Count == 0) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_ID_REQUIRED", "Message ids are required."));
|
|
if (ReadBool(payload, "forMe") == true && ReadString(payload, "itemType") != "DELAYED")
|
|
{
|
|
var hiddenMessageIds = await dbContext.MessageHiddenForUsers.Where(item => item.UserId == user.Id).Select(item => item.MessageId).ToListAsync(cancellationToken);
|
|
var hiddenSet = hiddenMessageIds.ToHashSet();
|
|
var newlyHidden = new List<int>();
|
|
foreach (var messageId in messageIds)
|
|
{
|
|
var message = await ResolveMessageAsync(chat.Id, messageId, cancellationToken);
|
|
if (message is null || !hiddenSet.Add(message.Id)) continue;
|
|
dbContext.MessageHiddenForUsers.Add(new MessageHiddenForUser { MessageId = message.Id, UserId = user.Id });
|
|
newlyHidden.Add(messageId);
|
|
}
|
|
if (newlyHidden.Count > 0) await dbContext.SaveChangesAsync(cancellationToken);
|
|
var clientChatIdForUser = await GetClientChatIdAsync(chat, cancellationToken);
|
|
await PublishPacketAsync([user.Id], 142, new { chatId = clientChatIdForUser, messageIds = newlyHidden }, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { }));
|
|
}
|
|
var messages = new List<Message>();
|
|
foreach (var messageId in messageIds)
|
|
{
|
|
var message = await ResolveMessageAsync(chat.Id, messageId, cancellationToken);
|
|
if (message is null) continue;
|
|
if (message.SenderId != user.Id) return StatusCode(StatusCodes.Status403Forbidden, QlyraCompatibilityResponse.Error(opcode, "MESSAGE_DELETE_FORBIDDEN", "Message cannot be deleted."));
|
|
if (message.DeletedAt is null)
|
|
{
|
|
message.Text = string.Empty;
|
|
message.DeletedAt = DateTimeOffset.UtcNow;
|
|
message.EditedAt = null;
|
|
messages.Add(message);
|
|
}
|
|
}
|
|
if (messages.Count > 0) await dbContext.SaveChangesAsync(cancellationToken);
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var clientMessageIds = await Task.WhenAll(messages.Select(message => clientIdMapper.GetOrCreateAsync("message", message.Id, cancellationToken)));
|
|
await PublishPacketAsync(
|
|
chat.Members.Select(member => member.UserId),
|
|
142,
|
|
new { chatId = clientChatId, messageIds = clientMessageIds },
|
|
cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { }));
|
|
}
|
|
|
|
private async Task<IActionResult> SetReactionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
|
if (message is null || message.DeletedAt is not null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
var emoji = ReadNestedString(payload, "reaction", "id");
|
|
if (string.IsNullOrWhiteSpace(emoji)) return BadRequest(QlyraCompatibilityResponse.Error(opcode, "REACTION_REQUIRED", "Reaction is required."));
|
|
if (!await ReactionAllowedAsync(chat, message.Id, emoji, cancellationToken))
|
|
return BadRequest(QlyraCompatibilityResponse.Error(opcode, "REACTION_FORBIDDEN", "Эта реакция недоступна в чате."));
|
|
var existing = await dbContext.MessageReactions.SingleOrDefaultAsync(reaction => reaction.MessageId == message.Id && reaction.UserId == user.Id, cancellationToken);
|
|
if (existing is null)
|
|
{
|
|
dbContext.MessageReactions.Add(new MessageReaction { MessageId = message.Id, UserId = user.Id, Emoji = emoji });
|
|
}
|
|
else
|
|
{
|
|
existing.Emoji = emoji;
|
|
}
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
var reactionInfo = await BuildReactionInfoAsync(message.Id, user.Id, cancellationToken);
|
|
await PublishReactionChangedAsync(chat, message.Id, reactionInfo, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactionInfo }));
|
|
}
|
|
|
|
private async Task<IActionResult> CancelReactionAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
var existing = await dbContext.MessageReactions.SingleOrDefaultAsync(reaction => reaction.MessageId == message.Id && reaction.UserId == user.Id, cancellationToken);
|
|
if (existing is not null)
|
|
{
|
|
dbContext.MessageReactions.Remove(existing);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
}
|
|
var reactionInfo = await BuildReactionInfoAsync(message.Id, user.Id, cancellationToken);
|
|
await PublishReactionChangedAsync(chat, message.Id, reactionInfo, cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactionInfo }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetDetailedReactionsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var chat = await ResolveMemberChatAsync(user.Id, ReadInt(payload, "chatId"), cancellationToken);
|
|
if (chat is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "CHAT_NOT_FOUND", "Chat not found."));
|
|
var message = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
|
if (message is null) return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Message not found."));
|
|
var count = Math.Clamp(ReadInt(payload, "count") ?? 100, 1, 100);
|
|
var entities = await dbContext.MessageReactions.Where(reaction => reaction.MessageId == message.Id).OrderBy(reaction => reaction.CreatedAt).Take(count).ToListAsync(cancellationToken);
|
|
var reactions = new List<object>(entities.Count);
|
|
foreach (var reaction in entities) reactions.Add(new { userId = await clientIdMapper.GetOrCreateAsync("user", reaction.UserId, cancellationToken), reaction = reaction.Emoji });
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { reactions }));
|
|
}
|
|
|
|
private async Task<IActionResult> GetCommonChatsAsync(int opcode, JsonElement payload, CancellationToken cancellationToken)
|
|
{
|
|
var user = await RequireCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized(QlyraCompatibilityResponse.Error(opcode, "FAIL_LOGIN_TOKEN", "An IKAR access token is required."));
|
|
var requested = ReadIntList(payload, "userIds");
|
|
if (requested.Count == 0) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = Array.Empty<object>() }));
|
|
var targetId = await clientIdMapper.FindEntityIdAsync("user", requested[0], cancellationToken);
|
|
if (targetId is null) return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = Array.Empty<object>() }));
|
|
var chats = await dbContext.Chats.Include(chat => chat.Members).ThenInclude(member => member.User).Include(chat => chat.Messages).ThenInclude(message => message.Sender)
|
|
.Where(chat => chat.Members.Any(member => member.UserId == user.Id) && chat.Members.Any(member => member.UserId == targetId.Value)).ToListAsync(cancellationToken);
|
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { commonChats = await BuildChatsAsync(chats, cancellationToken) }));
|
|
}
|
|
|
|
private async Task<Dictionary<string, object?>> BuildLoginPayloadAsync(User user, int accountId, CancellationToken cancellationToken)
|
|
{
|
|
var chats = await LoadMemberChatsAsync(user.Id, cancellationToken);
|
|
const int loginChatPageSize = 50;
|
|
var initialChats = chats.Take(loginChatPageSize).ToList();
|
|
var contacts = await BuildContactsAsync(user.Id, cancellationToken);
|
|
var privacy = await GetOrCreatePrivacySettingsAsync(user.Id, cancellationToken);
|
|
return new Dictionary<string, object?>
|
|
{
|
|
["time"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
|
["accountId"] = accountId,
|
|
["profile"] = await BuildProfileAsync(user, cancellationToken),
|
|
["contacts"] = contacts,
|
|
["chats"] = await BuildChatsAsync(initialChats, cancellationToken),
|
|
["chatMarker"] = chats.Count > initialChats.Count ? initialChats.Count : 0,
|
|
["presence"] = new Dictionary<string, object>(),
|
|
["config"] = new
|
|
{
|
|
hash = "ikar",
|
|
user = BuildUserConfig(privacy),
|
|
chats = await BuildChatConfigAsync(user.Id, cancellationToken)
|
|
}
|
|
};
|
|
}
|
|
|
|
private async Task<Dictionary<string, object>> BuildChatConfigAsync(Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
var memberships = await dbContext.ChatMembers
|
|
.Include(member => member.Chat)
|
|
.ThenInclude(chat => chat.Members)
|
|
.Where(member => member.UserId == userId)
|
|
.ToListAsync(cancellationToken);
|
|
var result = new Dictionary<string, object>();
|
|
foreach (var membership in memberships)
|
|
{
|
|
var chatId = await GetClientChatIdAsync(membership.Chat, cancellationToken);
|
|
var dontDisturbUntil = membership.MutedUntil is null
|
|
? 0L
|
|
: membership.MutedUntil.Value.Year >= 9999
|
|
? -1L
|
|
: membership.MutedUntil.Value.ToUnixTimeMilliseconds();
|
|
result[chatId.ToString()] = new
|
|
{
|
|
dontDisturbUntil,
|
|
favIndex = membership.IsPinned ? 1 : (int?)null
|
|
};
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private Task<User?> RequireCurrentUserAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (User.Identity?.IsAuthenticated != true) return Task.FromResult<User?>(null);
|
|
var userId = User.GetRequiredUserId();
|
|
return dbContext.Users.SingleOrDefaultAsync(x => x.Id == userId && !x.IsBlocked, cancellationToken);
|
|
}
|
|
|
|
private async Task<UserPrivacySettings> GetOrCreatePrivacySettingsAsync(Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
var settings = await dbContext.UserPrivacySettings.SingleOrDefaultAsync(
|
|
item => item.UserId == userId,
|
|
cancellationToken);
|
|
if (settings is not null) return settings;
|
|
settings = new UserPrivacySettings { UserId = userId };
|
|
dbContext.UserPrivacySettings.Add(settings);
|
|
await dbContext.SaveChangesAsync(cancellationToken);
|
|
return settings;
|
|
}
|
|
|
|
private static HashSet<Guid> ParseUserIds(string? value) =>
|
|
string.IsNullOrWhiteSpace(value)
|
|
? []
|
|
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(item => Guid.TryParse(item, out var parsed) ? parsed : Guid.Empty)
|
|
.Where(item => item != Guid.Empty)
|
|
.ToHashSet();
|
|
|
|
private Task<List<Chat>> LoadMemberChatsAsync(Guid userId, CancellationToken cancellationToken) =>
|
|
dbContext.Chats
|
|
.Include(chat => chat.Members).ThenInclude(member => member.User)
|
|
.Include(chat => chat.Messages).ThenInclude(message => message.Sender)
|
|
.Where(chat => chat.Members.Any(member => member.UserId == userId))
|
|
.OrderByDescending(chat => chat.LastActivityAt ?? chat.CreatedAt)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
private async Task<Chat?> ResolveMemberChatAsync(Guid userId, int? clientChatId, CancellationToken cancellationToken)
|
|
{
|
|
if (clientChatId is null) return null;
|
|
if (clientChatId == 0) return await GetOrCreateSavedChatAsync(userId, cancellationToken);
|
|
var mapped = await dbContext.Chats
|
|
.Include(chat => chat.Members).ThenInclude(member => member.User)
|
|
.Include(chat => chat.Messages).ThenInclude(message => message.Sender)
|
|
.Where(chat => chat.Members.Any(member => member.UserId == userId))
|
|
.Join(
|
|
dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "chat" && map.Id == clientChatId.Value),
|
|
chat => chat.Id,
|
|
map => map.EntityId,
|
|
(chat, _) => chat)
|
|
.SingleOrDefaultAsync(cancellationToken);
|
|
if (mapped is not null) return mapped;
|
|
var currentClientId = await clientIdMapper.GetOrCreateAsync("user", userId, cancellationToken);
|
|
var peerClientId = clientChatId.Value ^ currentClientId;
|
|
var peerId = await clientIdMapper.FindEntityIdAsync("user", peerClientId, cancellationToken);
|
|
if (peerId is null) return null;
|
|
return await dbContext.Chats
|
|
.Include(chat => chat.Members).ThenInclude(member => member.User)
|
|
.Include(chat => chat.Messages).ThenInclude(message => message.Sender)
|
|
.SingleOrDefaultAsync(chat => chat.Type == ChatType.Direct && chat.Members.Count == 2 && chat.Members.Any(member => member.UserId == userId) && chat.Members.Any(member => member.UserId == peerId.Value), cancellationToken);
|
|
}
|
|
|
|
private async Task<List<object>> BuildContactsAsync(Guid currentUserId, CancellationToken cancellationToken)
|
|
{
|
|
var relations = await dbContext.UserContacts
|
|
.Include(contact => contact.ContactUser)
|
|
.Where(contact => contact.OwnerUserId == currentUserId && !contact.ContactUser.IsBlocked)
|
|
.OrderBy(contact => contact.CustomFirstName ?? contact.ContactUser.DisplayName)
|
|
.ToListAsync(cancellationToken);
|
|
var contacts = new List<object>(relations.Count);
|
|
foreach (var relation in relations)
|
|
{
|
|
contacts.Add(await BuildContactAsync(
|
|
relation.ContactUser,
|
|
cancellationToken,
|
|
relation.CustomFirstName,
|
|
relation.CustomLastName));
|
|
}
|
|
return contacts;
|
|
}
|
|
|
|
private async Task<List<object>> BuildChatsAsync(IEnumerable<Chat> chats, CancellationToken cancellationToken)
|
|
{
|
|
var chatList = chats.ToList();
|
|
var hiddenMessageIds = new HashSet<Guid>();
|
|
if (User.Identity?.IsAuthenticated == true)
|
|
{
|
|
var currentUserId = User.GetRequiredUserId();
|
|
var chatIds = chatList.Select(chat => chat.Id).ToArray();
|
|
hiddenMessageIds = (await dbContext.MessageHiddenForUsers.AsNoTracking()
|
|
.Where(item => item.UserId == currentUserId && chatIds.Contains(item.Message.ChatId))
|
|
.Select(item => item.MessageId)
|
|
.ToListAsync(cancellationToken)).ToHashSet();
|
|
}
|
|
var result = new List<object>();
|
|
foreach (var chat in chatList)
|
|
{
|
|
var participants = new Dictionary<string, long>();
|
|
foreach (var member in chat.Members)
|
|
{
|
|
var participantId = await clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken);
|
|
participants[participantId.ToString()] = (member.LastReadAt ?? DateTimeOffset.UnixEpoch).ToUnixTimeMilliseconds();
|
|
}
|
|
var chatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var lastMessage = chat.Messages
|
|
.Where(message => message.DeletedAt is null && message.ScheduledForUnixMs == null && !hiddenMessageIds.Contains(message.Id))
|
|
.OrderByDescending(message => message.SentAt)
|
|
.FirstOrDefault();
|
|
var pinnedMessage = chat.Messages
|
|
.Where(message => message.DeletedAt is null && message.ScheduledForUnixMs == null && message.PinnedAt is not null && !hiddenMessageIds.Contains(message.Id))
|
|
.OrderByDescending(message => message.PinnedAt)
|
|
.FirstOrDefault();
|
|
var owner = chat.Members.FirstOrDefault(member => member.IsOwner || member.Role == ChatMemberRole.Owner);
|
|
var currentUserId = User.Identity?.IsAuthenticated == true ? User.GetRequiredUserId() : Guid.Empty;
|
|
var currentMembership = chat.Members.FirstOrDefault(member => member.UserId == currentUserId);
|
|
var unreadCount = currentMembership is null
|
|
? 0
|
|
: chat.Messages.Count(message =>
|
|
message.DeletedAt is null && message.ScheduledForUnixMs == null &&
|
|
message.SenderId != currentUserId &&
|
|
!hiddenMessageIds.Contains(message.Id) &&
|
|
(currentMembership.LastReadAt is null || message.SentAt > currentMembership.LastReadAt));
|
|
var options = new Dictionary<string, bool>
|
|
{
|
|
["MESSAGE_COPY_NOT_ALLOWED"] = chat.HasProtectedContent,
|
|
["ONLY_OWNER_CAN_CHANGE_ICON_TITLE"] = chat.Type is ChatType.Group or ChatType.Channel
|
|
};
|
|
result.Add(new
|
|
{
|
|
id = chatId,
|
|
type = chat.Type switch { ChatType.Direct => "DIALOG", ChatType.Group => "CHAT", _ => "CHANNEL" },
|
|
title = chat.Title,
|
|
baseIconUrl = string.IsNullOrWhiteSpace(chat.AvatarStoragePath)
|
|
? null
|
|
: BuildPublicUrl($"/api/ikar/chats/{chat.Id:D}/avatar/{Uri.EscapeDataString(Path.GetFileName(chat.AvatarStoragePath))}"),
|
|
participants,
|
|
participantsCount = participants.Count,
|
|
owner = owner is null ? (int?)null : await clientIdMapper.GetOrCreateAsync("user", owner.UserId, cancellationToken),
|
|
admins = (await Task.WhenAll(chat.Members.Where(member => member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin).Select(member => clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken)))).ToArray(),
|
|
options,
|
|
newMessages = unreadCount,
|
|
lastEventTime = (chat.LastActivityAt ?? chat.CreatedAt).ToUnixTimeMilliseconds(),
|
|
lastMessage = lastMessage is null ? null : await BuildMessageAsync(lastMessage, cancellationToken),
|
|
pinnedMessage = pinnedMessage is null ? null : await BuildMessageAsync(pinnedMessage, cancellationToken)
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async Task<int> GetClientChatIdAsync(Guid chatId, CancellationToken cancellationToken)
|
|
{
|
|
var chat = await dbContext.Chats.Include(item => item.Members).SingleAsync(item => item.Id == chatId, cancellationToken);
|
|
return await GetClientChatIdAsync(chat, cancellationToken);
|
|
}
|
|
|
|
private async Task<int> GetClientChatIdAsync(Chat chat, CancellationToken cancellationToken)
|
|
{
|
|
if (IsSavedChat(chat)) return 0;
|
|
if (chat.Type != ChatType.Direct || chat.Members.Count != 2) return await clientIdMapper.GetOrCreateAsync("chat", chat.Id, cancellationToken);
|
|
var ids = new List<int>(2);
|
|
foreach (var member in chat.Members) ids.Add(await clientIdMapper.GetOrCreateAsync("user", member.UserId, cancellationToken));
|
|
return ids[0] ^ ids[1];
|
|
}
|
|
|
|
private async Task<object> BuildContactAsync(
|
|
User user,
|
|
CancellationToken cancellationToken,
|
|
string? customFirstName = null,
|
|
string? customLastName = null)
|
|
{
|
|
var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
var names = SplitDisplayName(user.DisplayName, user.Username);
|
|
var botWebApp = user.IsBot ? await dbContext.Bots.Where(x => x.UserId == user.Id && x.IsEnabled).Select(x => x.WebAppUrl).SingleOrDefaultAsync(cancellationToken) : null;
|
|
var nameEntries = new List<object>();
|
|
if (!string.IsNullOrWhiteSpace(customFirstName) || !string.IsNullOrWhiteSpace(customLastName))
|
|
{
|
|
nameEntries.Add(new
|
|
{
|
|
type = "CUSTOM",
|
|
name = string.Join(' ', new[] { customFirstName, customLastName }.Where(value => !string.IsNullOrWhiteSpace(value))),
|
|
firstName = customFirstName ?? string.Empty,
|
|
lastName = customLastName
|
|
});
|
|
}
|
|
nameEntries.Add(new { type = "ONEME", name = user.DisplayName, firstName = names.FirstName, lastName = names.LastName });
|
|
return new
|
|
{
|
|
id = userId,
|
|
phone = PhoneAsNumber(user.NormalizedPhoneNumber),
|
|
country = "RU",
|
|
accountStatus = 0,
|
|
options = user.IsBot ? (string.IsNullOrWhiteSpace(botWebApp) ? new[] { "BOT" } : new[] { "BOT", "HAS_WEBAPP" }) : Array.Empty<string>(),
|
|
updateTime = user.CreatedAt.ToUnixTimeMilliseconds(),
|
|
names = nameEntries,
|
|
photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken),
|
|
baseUrl = BuildAvatarUrl(user)
|
|
};
|
|
}
|
|
|
|
private async Task<UserContact> UpsertContactAsync(
|
|
Guid ownerUserId,
|
|
Guid contactUserId,
|
|
string? firstName,
|
|
string? lastName,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var normalizedFirstName = NormalizeContactName(firstName);
|
|
var normalizedLastName = NormalizeContactName(lastName);
|
|
var relation = await dbContext.UserContacts.SingleOrDefaultAsync(
|
|
contact => contact.OwnerUserId == ownerUserId && contact.ContactUserId == contactUserId,
|
|
cancellationToken);
|
|
if (relation is null)
|
|
{
|
|
relation = new UserContact
|
|
{
|
|
OwnerUserId = ownerUserId,
|
|
ContactUserId = contactUserId,
|
|
CreatedAt = DateTimeOffset.UtcNow
|
|
};
|
|
dbContext.UserContacts.Add(relation);
|
|
}
|
|
relation.CustomFirstName = normalizedFirstName;
|
|
relation.CustomLastName = normalizedLastName;
|
|
relation.UpdatedAt = DateTimeOffset.UtcNow;
|
|
return relation;
|
|
}
|
|
|
|
private static string? NormalizeContactName(string? value)
|
|
{
|
|
var normalized = value?.Trim();
|
|
if (string.IsNullOrEmpty(normalized)) return null;
|
|
return normalized;
|
|
}
|
|
|
|
private static bool IsInstallationIdValid(string? installationId) =>
|
|
!string.IsNullOrWhiteSpace(installationId) &&
|
|
installationId.Length == 32 &&
|
|
installationId.All(Uri.IsHexDigit);
|
|
|
|
private async Task<User> CreatePhoneRegistrationUserAsync(string normalizedPhoneNumber, CancellationToken cancellationToken)
|
|
{
|
|
string username;
|
|
do
|
|
{
|
|
username = $"user{Convert.ToHexString(RandomNumberGenerator.GetBytes(6)).ToLowerInvariant()}";
|
|
}
|
|
while (await dbContext.Users.AnyAsync(user => user.NormalizedUsername == username.ToUpperInvariant(), cancellationToken));
|
|
|
|
var user = new User
|
|
{
|
|
Username = username,
|
|
NormalizedUsername = username.ToUpperInvariant(),
|
|
DisplayName = "Новый пользователь",
|
|
PhoneNumber = $"+{normalizedPhoneNumber}",
|
|
NormalizedPhoneNumber = normalizedPhoneNumber
|
|
};
|
|
user.PasswordHash = new PasswordHasher<User>().HashPassword(user, Convert.ToHexString(RandomNumberGenerator.GetBytes(32)));
|
|
dbContext.Users.Add(user);
|
|
return user;
|
|
}
|
|
|
|
private async Task<List<Guid>> LoadStoryPeerAuthorIdsAsync(Guid userId, CancellationToken cancellationToken)
|
|
{
|
|
var peerIds = await dbContext.ChatMembers.AsNoTracking()
|
|
.Where(member => member.UserId == userId)
|
|
.Join(
|
|
dbContext.ChatMembers.AsNoTracking(),
|
|
mine => mine.ChatId,
|
|
other => other.ChatId,
|
|
(_, other) => other.UserId)
|
|
.Where(peerId => peerId != userId)
|
|
.Distinct()
|
|
.ToListAsync(cancellationToken);
|
|
return peerIds;
|
|
}
|
|
|
|
private async Task<object> BuildStoryOwnerAsync(Guid userId, CancellationToken cancellationToken) =>
|
|
new { ownerId = await clientIdMapper.GetOrCreateAsync("user", userId, cancellationToken), type = 0 };
|
|
|
|
private async Task<List<object>> BuildStoryPreviewsAsync(IEnumerable<Story> stories, Guid viewerId, CancellationToken cancellationToken)
|
|
{
|
|
var result = new List<object>();
|
|
foreach (var group in stories.GroupBy(story => story.AuthorUserId))
|
|
{
|
|
var list = group.OrderBy(story => story.CreatedAt).ToList();
|
|
var owner = await BuildStoryOwnerAsync(group.Key, cancellationToken);
|
|
var readCount = group.Key == viewerId ? list.Count : list.Count(story => story.Views.Any(view => view.ViewerUserId == viewerId));
|
|
result.Add(new
|
|
{
|
|
owner,
|
|
updateTime = list.Max(story => story.CreatedAt).ToUnixTimeMilliseconds(),
|
|
totalCount = list.Count,
|
|
readCount,
|
|
lastStoryExpirationTime = list.Max(story => story.ExpiresAt).ToUnixTimeMilliseconds()
|
|
});
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private async Task<object> BuildStoryAsync(Story story, CancellationToken cancellationToken, int cid = 0)
|
|
{
|
|
var storyId = await clientIdMapper.GetOrCreateAsync("story", story.Id, cancellationToken);
|
|
var owner = await BuildStoryOwnerAsync(story.AuthorUserId, cancellationToken);
|
|
object? media = null;
|
|
var attachment = story.Attachments.OrderBy(item => item.SortOrder).FirstOrDefault();
|
|
if (attachment is not null)
|
|
{
|
|
var url = BuildPublicUrl($"/api/stories/{story.Id}/attachments/{attachment.Id}/content");
|
|
media = attachment.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase)
|
|
? new { _type = "VIDEO", mp4Url = url, videoUrl = url, baseUrl = url, duration = 0 }
|
|
: (object)new { _type = "PHOTO", photoUrl = url, baseUrl = url, url };
|
|
}
|
|
return new
|
|
{
|
|
id = storyId,
|
|
cid,
|
|
owner,
|
|
settings = story.Visibility == StoryVisibility.Everyone ? 1 : 2,
|
|
time = story.CreatedAt.ToUnixTimeMilliseconds(),
|
|
updateTime = story.CreatedAt.ToUnixTimeMilliseconds(),
|
|
expiration = story.ExpiresAt.ToUnixTimeMilliseconds(),
|
|
media
|
|
};
|
|
}
|
|
|
|
internal async Task<object> BuildMessageAsync(Message message, CancellationToken cancellationToken)
|
|
{
|
|
var messageId = await clientIdMapper.GetOrCreateAsync("message", message.Id, cancellationToken);
|
|
var senderId = await clientIdMapper.GetOrCreateAsync("user", message.SenderId, cancellationToken);
|
|
var attachments = message.Attachments.Count > 0
|
|
? message.Attachments.OrderBy(attachment => attachment.SortOrder).ToList()
|
|
: await dbContext.MessageAttachments.Where(attachment => attachment.MessageId == message.Id).OrderBy(attachment => attachment.SortOrder).ToListAsync(cancellationToken);
|
|
var mappedAttachments = new List<object>(attachments.Count);
|
|
if (!string.IsNullOrWhiteSpace(message.QlyraAttachesJson))
|
|
{
|
|
using var protocolDocument = JsonDocument.Parse(message.QlyraAttachesJson);
|
|
foreach (var item in protocolDocument.RootElement.EnumerateArray()) mappedAttachments.Add(item.Clone());
|
|
}
|
|
foreach (var attachment in attachments)
|
|
{
|
|
var attachmentId = await clientIdMapper.GetOrCreateAsync("attachment", attachment.Id, cancellationToken);
|
|
var url = BuildPublicUrl($"/api/chats/{message.ChatId}/attachments/{attachment.Id}/content");
|
|
var token = attachment.Id.ToString("N");
|
|
if (attachment.Kind == AttachmentKind.VoiceNote || attachment.ContentType.StartsWith("audio/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
mappedAttachments.Add(new { _type = "AUDIO", audioId = attachmentId, token, baseUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName });
|
|
}
|
|
else if (attachment.ContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
mappedAttachments.Add(new { _type = "PHOTO", photoId = attachmentId, photoToken = token, token, baseUrl = url, photoUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName });
|
|
}
|
|
else if (attachment.ContentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var metadata = videoPreviewService is null ? null : await videoPreviewService.GetAsync(attachment.StoragePath, cancellationToken);
|
|
var thumbnail = metadata is null ? null : BuildPublicUrl($"/api/chats/{message.ChatId}/attachments/{attachment.Id}/thumbnail");
|
|
mappedAttachments.Add(new { _type = "VIDEO", videoId = attachmentId, token, baseUrl = thumbnail, thumbnail,
|
|
previewData = (string?)null, width = metadata?.Width, height = metadata?.Height, duration = metadata?.Duration,
|
|
mp4Url = url, videoUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName });
|
|
}
|
|
else
|
|
{
|
|
mappedAttachments.Add(new { _type = "FILE", fileId = attachmentId, token, baseUrl = url, url, size = attachment.FileSizeBytes, name = attachment.OriginalFileName, contentType = attachment.ContentType });
|
|
}
|
|
}
|
|
object? replyLink = null;
|
|
if (message.ReplyToMessageId is Guid replyId)
|
|
{
|
|
var reply = await dbContext.Messages.AsNoTracking().SingleOrDefaultAsync(x => x.Id == replyId && x.ChatId == message.ChatId, cancellationToken);
|
|
if (reply is not null) replyLink = new { type = "REPLY", messageId = await clientIdMapper.GetOrCreateAsync("message", reply.Id, cancellationToken), message = new {
|
|
id = await clientIdMapper.GetOrCreateAsync("message", reply.Id, cancellationToken), sender = await clientIdMapper.GetOrCreateAsync("user", reply.SenderId, cancellationToken),
|
|
text = reply.DeletedAt is null ? reply.Text : string.Empty, time = reply.SentAt.ToUnixTimeMilliseconds(), attaches = Array.Empty<object>()
|
|
}};
|
|
}
|
|
return new
|
|
{
|
|
id = messageId,
|
|
cid = message.IkarClientCid ?? 0,
|
|
link = replyLink,
|
|
elements = string.IsNullOrWhiteSpace(message.IkarElementsJson) ? JsonSerializer.SerializeToElement(Array.Empty<object>()) : JsonSerializer.Deserialize<JsonElement>(message.IkarElementsJson),
|
|
sender = senderId,
|
|
text = message.DeletedAt is null ? message.Text : string.Empty,
|
|
time = message.SentAt.ToUnixTimeMilliseconds(),
|
|
status = message.DeletedAt is not null ? "REMOVED" : message.EditedAt is not null ? "EDITED" : "SENT",
|
|
delayedAttributes = message.ScheduledForUnixMs is null ? null : new { timeToFire = message.ScheduledForUnixMs.Value, notifySender = true },
|
|
attaches = mappedAttachments
|
|
};
|
|
}
|
|
|
|
private static bool IsChatManager(ChatMember member) =>
|
|
member.IsOwner || member.Role is ChatMemberRole.Owner or ChatMemberRole.Admin;
|
|
|
|
private static ChatMember CreateChatMember(Chat chat, Guid userId)
|
|
{
|
|
var isChannel = chat.Type == ChatType.Channel;
|
|
return new ChatMember
|
|
{
|
|
ChatId = chat.Id,
|
|
UserId = userId,
|
|
Role = ChatMemberRole.Member,
|
|
CanPostMessages = !isChannel,
|
|
CanInviteUsers = !isChannel,
|
|
CanPinMessages = false,
|
|
CanDeleteMessages = false,
|
|
JoinedAt = DateTimeOffset.UtcNow
|
|
};
|
|
}
|
|
|
|
private static string ExtractLinkKey(string link)
|
|
{
|
|
if (Uri.TryCreate(link, UriKind.Absolute, out var uri))
|
|
{
|
|
var segment = uri.Segments.LastOrDefault()?.Trim('/');
|
|
if (!string.IsNullOrWhiteSpace(segment)) return segment.TrimStart('@');
|
|
}
|
|
return link.Trim().TrimEnd('/').Split('/').Last().TrimStart('@');
|
|
}
|
|
|
|
private string BuildCallEndpoint(string token, string conversationId, int userId)
|
|
{
|
|
var baseUri = GetPublicBaseUri();
|
|
var builder = new UriBuilder(baseUri) { Scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws", Path = "/api/ikar/calls/ws2", Query = $"token={Uri.EscapeDataString(token)}&conversationId={Uri.EscapeDataString(conversationId)}&userId={userId}" };
|
|
return builder.Uri.ToString();
|
|
}
|
|
|
|
private string BuildCallVcp(string token, bool isVideo, int clientId)
|
|
{
|
|
var baseUri = GetPublicBaseUri();
|
|
var scheme = baseUri.Scheme == Uri.UriSchemeHttps ? "wss" : "ws";
|
|
var servers = qlyraCallStore?.IceServers ?? [];
|
|
var stun = servers.SelectMany(server => server.Urls).FirstOrDefault(url => url.StartsWith("stun:", StringComparison.OrdinalIgnoreCase));
|
|
var turn = servers.SelectMany(server => server.Urls).Where(url => url.StartsWith("turn:", StringComparison.OrdinalIgnoreCase)).ToArray();
|
|
var raw = JsonSerializer.SerializeToUtf8Bytes(new { tkn = token, wse = $"{scheme}://{baseUri.Authority}/api/ikar/calls/ws2", stne = stun, trne = turn.Length == 0 ? null : string.Join(',', turn), trnu = $"ikar:{clientId}", trnp = (string?)null, et = DateTimeOffset.UtcNow.AddHours(1).ToUnixTimeSeconds(), iv = isVideo });
|
|
var compressed = EncodeLz4LiteralBlock(raw);
|
|
return $"{raw.Length}:{Convert.ToBase64String(compressed)}";
|
|
}
|
|
|
|
private static byte[] EncodeLz4LiteralBlock(byte[] source)
|
|
{
|
|
using var output = new MemoryStream(source.Length + 8);
|
|
var literalLength = source.Length;
|
|
output.WriteByte((byte)(Math.Min(literalLength, 15) << 4));
|
|
if (literalLength >= 15)
|
|
{
|
|
var remaining = literalLength - 15;
|
|
while (remaining >= 255) { output.WriteByte(255); remaining -= 255; }
|
|
output.WriteByte((byte)remaining);
|
|
}
|
|
output.Write(source);
|
|
return output.ToArray();
|
|
}
|
|
|
|
private Uri GetPublicBaseUri()
|
|
{
|
|
var configured = publicEndpointOptions?.Value.BaseUrl;
|
|
if (!string.IsNullOrWhiteSpace(configured) && Uri.TryCreate(configured, UriKind.Absolute, out var uri)) return uri;
|
|
return new Uri($"{Request.Scheme}://{Request.Host}");
|
|
}
|
|
|
|
private string BuildPublicUrl(string path)
|
|
{
|
|
var url = new Uri(GetPublicBaseUri(), path).ToString();
|
|
if ((path.Contains("/attachments/", StringComparison.Ordinal) ||
|
|
path.StartsWith("/api/users/", StringComparison.Ordinal) && path.Contains("/avatar/", StringComparison.Ordinal)) &&
|
|
User.Identity?.IsAuthenticated == true)
|
|
{
|
|
var token = tokenService.CreateMediaToken(User.GetRequiredUserId(), User.GetRequiredSessionId(), path);
|
|
url += "?access_token=" + Uri.EscapeDataString(token);
|
|
}
|
|
return url;
|
|
}
|
|
|
|
private string? BuildAvatarUrl(User user) => string.IsNullOrWhiteSpace(user.AvatarStoragePath)
|
|
? null
|
|
: BuildPublicUrl($"/api/users/{user.Id}/avatar/{Uri.EscapeDataString(Path.GetFileName(user.AvatarStoragePath))}");
|
|
|
|
private async Task<int?> BuildAvatarPhotoIdAsync(User user, CancellationToken cancellationToken)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(user.AvatarStoragePath)) return null;
|
|
var fileName = Path.GetFileName(user.AvatarStoragePath);
|
|
var photoGuid = Guid.TryParseExact(Path.GetFileNameWithoutExtension(fileName), "N", out var parsed)
|
|
? parsed
|
|
: new Guid(SHA256.HashData(Encoding.UTF8.GetBytes($"{user.Id:N}:{fileName}")).AsSpan(0, 16));
|
|
return await clientIdMapper.GetOrCreateAsync("avatar", photoGuid, cancellationToken);
|
|
}
|
|
|
|
private async Task<object> BuildProfileAsync(User user, CancellationToken cancellationToken)
|
|
{
|
|
var userId = await clientIdMapper.GetOrCreateAsync("user", user.Id, cancellationToken);
|
|
var names = SplitDisplayName(user.DisplayName, user.Username);
|
|
return new
|
|
{
|
|
contact = new
|
|
{
|
|
id = userId,
|
|
phone = PhoneAsNumber(user.NormalizedPhoneNumber),
|
|
country = "RU",
|
|
accountStatus = user.IsBlocked ? 1 : 0,
|
|
updateTime = user.CreatedAt.ToUnixTimeMilliseconds(),
|
|
names = new[] { new { type = "ONEME", firstName = names.FirstName, lastName = names.LastName } },
|
|
photoId = await BuildAvatarPhotoIdAsync(user, cancellationToken),
|
|
baseUrl = BuildAvatarUrl(user),
|
|
about = user.About,
|
|
online = presenceTracker.IsOnline(user.Id)
|
|
},
|
|
profileOptions = Array.Empty<int>()
|
|
};
|
|
}
|
|
|
|
private Task PublishPacketAsync(
|
|
IEnumerable<Guid> userIds,
|
|
int opcode,
|
|
object payload,
|
|
CancellationToken cancellationToken) =>
|
|
hubContext.Clients.Users(userIds.Select(userId => userId.ToString()).Distinct())
|
|
.SendAsync("QlyraPacket", new { opcode, payload }, cancellationToken);
|
|
|
|
private async Task<QlyraReactionInfo> BuildReactionInfoAsync(Guid messageId, Guid currentUserId, CancellationToken cancellationToken)
|
|
{
|
|
var reactions = await dbContext.MessageReactions.Where(reaction => reaction.MessageId == messageId).ToListAsync(cancellationToken);
|
|
return new QlyraReactionInfo(
|
|
reactions.GroupBy(reaction => reaction.Emoji).OrderBy(group => group.Key).Select(group => (object)new { reaction = group.Key, count = group.Count() }).ToArray(),
|
|
reactions.SingleOrDefault(reaction => reaction.UserId == currentUserId)?.Emoji,
|
|
reactions.Count);
|
|
}
|
|
|
|
private async Task PublishReactionChangedAsync(Chat chat, Guid messageId, QlyraReactionInfo reactionInfo, CancellationToken cancellationToken)
|
|
{
|
|
var clientChatId = await GetClientChatIdAsync(chat, cancellationToken);
|
|
var clientMessageId = await clientIdMapper.GetOrCreateAsync("message", messageId, cancellationToken);
|
|
await PublishPacketAsync(
|
|
chat.Members.Select(member => member.UserId),
|
|
155,
|
|
new { chatId = clientChatId, messageId = clientMessageId, counters = reactionInfo.Counters, totalCount = reactionInfo.TotalCount },
|
|
cancellationToken);
|
|
}
|
|
|
|
private static string? ReadString(JsonElement payload, string property) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(property, out var value) &&
|
|
value.ValueKind == JsonValueKind.String
|
|
? value.GetString()
|
|
: null;
|
|
|
|
private static int? ReadInt(JsonElement payload, string property) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(property, out var value) &&
|
|
value.TryGetInt32(out var number)
|
|
? number
|
|
: null;
|
|
|
|
private static long? ReadLong(JsonElement payload, string property) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(property, out var value) &&
|
|
value.TryGetInt64(out var number)
|
|
? number
|
|
: null;
|
|
|
|
private static bool? ReadBool(JsonElement payload, string property) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(property, out var value) &&
|
|
(value.ValueKind == JsonValueKind.True || value.ValueKind == JsonValueKind.False)
|
|
? value.GetBoolean()
|
|
: null;
|
|
|
|
private static List<int> ReadIntList(JsonElement payload, string property)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object ||
|
|
!payload.TryGetProperty(property, out var value) ||
|
|
value.ValueKind != JsonValueKind.Array) return [];
|
|
return value.EnumerateArray().Where(item => item.TryGetInt32(out _)).Select(item => item.GetInt32()).ToList();
|
|
}
|
|
|
|
private static List<int> ReadFlexibleIntList(JsonElement payload, string property)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) return [];
|
|
var result = new List<int>();
|
|
foreach (var item in value.EnumerateArray())
|
|
{
|
|
if (item.TryGetInt32(out var number)) result.Add(number);
|
|
else if (item.ValueKind == JsonValueKind.String && int.TryParse(item.GetString(), out number)) result.Add(number);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
private static List<string> ReadStringList(JsonElement payload, string property)
|
|
{
|
|
if (payload.ValueKind != JsonValueKind.Object || !payload.TryGetProperty(property, out var value) || value.ValueKind != JsonValueKind.Array) return [];
|
|
return value.EnumerateArray()
|
|
.Where(item => item.ValueKind == JsonValueKind.String)
|
|
.Select(item => item.GetString()?.Trim())
|
|
.Where(item => !string.IsNullOrWhiteSpace(item))
|
|
.Select(item => item!)
|
|
.ToList();
|
|
}
|
|
|
|
private static object BuildQlyraFolder(QlyraChatFolder folder) => new
|
|
{
|
|
id = folder.Id,
|
|
title = folder.Title,
|
|
emoji = folder.Emoji,
|
|
include = DeserializeStoredIntList(folder.IncludeJson),
|
|
filters = DeserializeStoredIntList(folder.FiltersJson),
|
|
options = DeserializeStoredIntList(folder.OptionsJson),
|
|
favorites = DeserializeStoredIntList(folder.FavoritesJson),
|
|
widgets = Array.Empty<object>(),
|
|
updateTime = folder.UpdatedAt.ToUnixTimeMilliseconds()
|
|
};
|
|
|
|
private static int[] DeserializeStoredIntList(string json)
|
|
{
|
|
try
|
|
{
|
|
return JsonSerializer.Deserialize<int[]>(json) ?? [];
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return [];
|
|
}
|
|
}
|
|
|
|
private Task<Message?> ResolveMessageAsync(Guid chatId, int? clientMessageId, CancellationToken cancellationToken)
|
|
{
|
|
if (clientMessageId is null) return Task.FromResult<Message?>(null);
|
|
return dbContext.Messages.Join(
|
|
dbContext.QlyraClientIdMaps.Where(map => map.EntityKind == "message" && map.Id == clientMessageId.Value),
|
|
entity => entity.Id,
|
|
map => map.EntityId,
|
|
(entity, _) => entity)
|
|
.SingleOrDefaultAsync(entity => entity.ChatId == chatId && (entity.ScheduledForUnixMs == null || entity.SenderId == User.GetRequiredUserId()), cancellationToken);
|
|
}
|
|
|
|
private static string? ReadNestedString(JsonElement payload, string parent, string property) =>
|
|
payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(parent, out var parentValue) &&
|
|
parentValue.ValueKind == JsonValueKind.Object &&
|
|
parentValue.TryGetProperty(property, out var value) &&
|
|
value.ValueKind == JsonValueKind.String
|
|
? value.GetString()
|
|
: null;
|
|
|
|
private static JsonElement ReadNestedArray(JsonElement payload, string parent, string property)
|
|
{
|
|
if (payload.ValueKind == JsonValueKind.Object &&
|
|
payload.TryGetProperty(parent, out var parentValue) &&
|
|
parentValue.ValueKind == JsonValueKind.Object &&
|
|
parentValue.TryGetProperty(property, out var value) &&
|
|
value.ValueKind == JsonValueKind.Array) return value.Clone();
|
|
return default;
|
|
}
|
|
|
|
private static long PhoneAsNumber(string? normalizedPhone)
|
|
{
|
|
var digits = new string((normalizedPhone ?? string.Empty).Where(char.IsDigit).ToArray());
|
|
return long.TryParse(digits, out var value) ? value : 0;
|
|
}
|
|
|
|
private static (string FirstName, string? LastName) SplitDisplayName(string displayName, string fallback)
|
|
{
|
|
var values = displayName.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
return values.Length switch
|
|
{
|
|
0 => (fallback, null),
|
|
1 => (values[0], null),
|
|
_ => (values[0], string.Join(' ', values.Skip(1)))
|
|
};
|
|
}
|
|
}
|
|
|
|
public sealed record QlyraCompatibilityRequest(JsonElement Payload);
|
|
|
|
public sealed record QlyraReactionInfo(object[] Counters, string? YourReaction, int TotalCount);
|
|
|
|
public sealed class QlyraTwoFactorTrack(Guid userId)
|
|
{
|
|
public string Id { get; } = Guid.NewGuid().ToString("N");
|
|
public Guid UserId { get; } = userId;
|
|
public DateTimeOffset ExpiresAt { get; } = DateTimeOffset.UtcNow.AddMinutes(20);
|
|
public string? CandidatePasswordHash { get; set; }
|
|
public string? Hint { get; set; }
|
|
public string? PendingEmail { get; private set; }
|
|
public string? EmailCodeHash { get; private set; }
|
|
public DateTimeOffset? EmailCodeExpiresAt { get; private set; }
|
|
public bool EmailVerified { get; set; }
|
|
public bool PasswordVerified { get; set; }
|
|
|
|
public void SetEmailCode(string email, string code)
|
|
{
|
|
PendingEmail = email;
|
|
EmailCodeHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
|
|
EmailCodeExpiresAt = DateTimeOffset.UtcNow.AddMinutes(10);
|
|
EmailVerified = false;
|
|
}
|
|
|
|
public bool IsValidEmailCode(string code)
|
|
{
|
|
if (EmailCodeExpiresAt <= DateTimeOffset.UtcNow || EmailCodeHash is null) return false;
|
|
return CryptographicOperations.FixedTimeEquals(Convert.FromHexString(EmailCodeHash), SHA256.HashData(Encoding.UTF8.GetBytes(code)));
|
|
}
|
|
}
|
|
|
|
public sealed record QlyraCompatibilityResponse(int Cmd, int Opcode, object Payload, string? ErrorKey = null)
|
|
{
|
|
public static QlyraCompatibilityResponse Ok(int opcode, object payload) => new(1, opcode, payload);
|
|
|
|
public static QlyraCompatibilityResponse Error(int opcode, string errorKey, string message) =>
|
|
new(3, opcode, new { message, error = errorKey, localizedMessage = message }, errorKey);
|
|
}
|