Update Ikar server and Android client
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user