Update Ikar server and Android client

This commit is contained in:
Курнат Андрей
2026-05-17 22:23:43 +03:00
commit 22e19cdfdb
258 changed files with 51047 additions and 0 deletions
@@ -0,0 +1,289 @@
using System.Text.RegularExpressions;
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.Hubs;
using Ikar.Server.Infrastructure.Push;
using Ikar.Shared;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
namespace Ikar.Server.Controllers;
[ApiController]
[AllowAnonymous]
[Route("api/bot/{token}")]
public sealed class BotApiController(
IkarDbContext dbContext,
PresenceTracker presenceTracker,
BotTokenService botTokenService,
BotUpdateService botUpdateService,
IHubContext<MessengerHub> hubContext,
PushDispatchQueue pushDispatchQueue) : ControllerBase
{
[HttpGet("getMe")]
public async Task<ActionResult<UserSummaryDto>> GetMe(string token, CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
return Ok(bot.User.ToDto(presenceTracker));
}
[HttpGet("getUpdates")]
public async Task<ActionResult<BotUpdatesResponseDto>> GetUpdates(
string token,
[FromQuery] long offset = 0,
[FromQuery] int limit = 100,
[FromQuery] int timeoutSeconds = 20,
CancellationToken cancellationToken = default)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
limit = Math.Clamp(limit, 1, 100);
timeoutSeconds = Math.Clamp(timeoutSeconds, 0, 50);
var timeoutAt = DateTimeOffset.UtcNow.AddSeconds(timeoutSeconds);
while (true)
{
var updates = await LoadUpdatesAsync(bot, offset, limit, cancellationToken);
if (updates.Count > 0 || timeoutSeconds == 0 || DateTimeOffset.UtcNow >= timeoutAt)
{
var messages = updates
.Select(x => x.Message)
.Where(x => x is not null)
.Cast<Message>()
.ToList();
if (messages.Count > 0)
{
await botUpdateService.MarkMessagesReadByBotAsync(bot.UserId, messages, cancellationToken);
}
var payload = updates
.Select(update => new BotUpdateDto(
update.Id,
BotUpdateKind.Message,
update.Message?.ToDto(presenceTracker, bot.UserId)))
.ToList();
var nextOffset = payload.Count == 0 ? offset : payload[^1].Id + 1;
return Ok(new BotUpdatesResponseDto(nextOffset, payload));
}
await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken);
}
}
[HttpGet("getMyCommands")]
public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> GetMyCommands(string token, CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
return Ok(bot.Commands
.OrderBy(x => x.SortOrder)
.ThenBy(x => x.Command, StringComparer.OrdinalIgnoreCase)
.Select(x => new BotCommandDto(x.Command, x.Description))
.ToList());
}
[HttpPost("setMyCommands")]
public async Task<ActionResult<IReadOnlyList<BotCommandDto>>> SetMyCommands(
string token,
SetBotCommandsRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
var commands = NormalizeCommands(request.Commands);
var existingCommands = await dbContext.BotCommands
.Where(x => x.BotId == bot.Id)
.ToListAsync(cancellationToken);
dbContext.BotCommands.RemoveRange(existingCommands);
await dbContext.SaveChangesAsync(cancellationToken);
for (var index = 0; index < commands.Count; index++)
{
dbContext.BotCommands.Add(new BotCommand
{
BotId = bot.Id,
Command = commands[index].Command,
Description = commands[index].Description,
SortOrder = index
});
}
bot.UpdatedAt = DateTimeOffset.UtcNow;
await dbContext.SaveChangesAsync(cancellationToken);
return Ok(commands);
}
[HttpPost("sendMessage")]
public async Task<ActionResult<MessageDto>> SendMessage(
string token,
BotSendMessageRequest request,
CancellationToken cancellationToken)
{
var bot = await LoadBotByTokenAsync(token, cancellationToken);
if (bot is null)
{
return Unauthorized();
}
if (!bot.IsEnabled)
{
return StatusCode(StatusCodes.Status423Locked, "Bot is disabled.");
}
var text = request.Text?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(text))
{
return ValidationProblem("Message text is required.");
}
var chat = await dbContext.Chats
.Include(x => x.Members)
.SingleOrDefaultAsync(
x => x.Id == request.ChatId && x.Members.Any(member => member.UserId == bot.UserId),
cancellationToken);
if (chat is null)
{
return NotFound("Chat not found.");
}
if (!DtoMapper.BuildPermissions(chat.Members.FirstOrDefault(member => member.UserId == bot.UserId), chat.Type).CanPostMessages)
{
return StatusCode(StatusCodes.Status403Forbidden, "Bot cannot publish in this chat.");
}
var message = new Message
{
ChatId = chat.Id,
Chat = chat,
SenderId = bot.UserId,
Sender = bot.User,
PostedAsChannel = chat.Type == ChatType.Channel,
AuthorSignature = chat.Type == ChatType.Channel && chat.ChannelSignaturesEnabled
? string.IsNullOrWhiteSpace(bot.User.DisplayName) ? bot.User.Username : bot.User.DisplayName
: null,
Text = text,
SentAt = DateTimeOffset.UtcNow
};
chat.LastActivityAt = message.SentAt;
dbContext.Messages.Add(message);
await dbContext.SaveChangesAsync(cancellationToken);
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
await pushDispatchQueue.EnqueueAsync(chat.Id, message.Id);
return Ok(message.ToDto(presenceTracker, bot.UserId));
}
private async Task<Bot?> LoadBotByTokenAsync(string token, CancellationToken cancellationToken)
{
var tokenHash = botTokenService.HashToken(token);
return await dbContext.Bots
.Include(x => x.User)
.ThenInclude(x => x.Sessions)
.Include(x => x.Commands)
.SingleOrDefaultAsync(x => x.TokenHash == tokenHash, cancellationToken);
}
private async Task<List<BotUpdate>> LoadUpdatesAsync(Bot bot, long offset, int limit, CancellationToken cancellationToken)
{
return await dbContext.BotUpdates
.AsNoTracking()
.Where(x => x.BotId == bot.Id && x.Id >= offset)
.OrderBy(x => x.Id)
.Take(limit)
.Include(x => x.Message)
.ThenInclude(x => x.Sender)
.ThenInclude(x => x.Sessions)
.Include(x => x.Message)
.ThenInclude(x => x.Attachments)
.Include(x => x.Message)
.ThenInclude(x => x.Reactions)
.Include(x => x.Message)
.ThenInclude(x => x.Views)
.Include(x => x.Message)
.ThenInclude(x => x.Chat)
.ThenInclude(x => x.Members)
.ToListAsync(cancellationToken);
}
private Task NotifyMessageCreatedAsync(Chat chat, Message message, CancellationToken cancellationToken)
{
var tasks = chat.Members.Select(async member =>
{
var dto = message.ToDto(presenceTracker, member.UserId);
await hubContext.Clients.User(member.UserId.ToString())
.SendAsync("MessageCreated", dto, cancellationToken);
});
return Task.WhenAll(tasks);
}
private static List<BotCommandDto> NormalizeCommands(IReadOnlyList<BotCommandDto> commands)
{
var normalized = new List<BotCommandDto>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var command in commands ?? Array.Empty<BotCommandDto>())
{
var normalizedCommand = (command.Command ?? string.Empty).Trim().TrimStart('/');
var normalizedDescription = (command.Description ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalizedCommand) || string.IsNullOrWhiteSpace(normalizedDescription))
{
continue;
}
if (!Regex.IsMatch(normalizedCommand, "^[a-z0-9_]{1,32}$", RegexOptions.IgnoreCase))
{
continue;
}
if (!seen.Add(normalizedCommand))
{
continue;
}
normalized.Add(new BotCommandDto(normalizedCommand.ToLowerInvariant(), normalizedDescription));
}
return normalized;
}
}