Files
IKAR/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs
T
sevenhill 12b692f4a9
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
Fix avatar URLs, photo IDs and profile update notifications
2026-10-07 22:34:40 +03:00

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);
}