mirror of
https://github.com/tgstation/tgstation-server.git
synced 2026-08-27 15:07:03 +01:00
385 lines
15 KiB
C#
385 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Linq.Expressions;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
using Tgstation.Server.Api;
|
|
using Tgstation.Server.Api.Models;
|
|
using Tgstation.Server.Api.Models.Internal;
|
|
using Tgstation.Server.Api.Models.Request;
|
|
using Tgstation.Server.Api.Models.Response;
|
|
using Tgstation.Server.Api.Rights;
|
|
using Tgstation.Server.Host.Authority;
|
|
using Tgstation.Server.Host.Components;
|
|
using Tgstation.Server.Host.Controllers.Results;
|
|
using Tgstation.Server.Host.Database;
|
|
using Tgstation.Server.Host.Extensions;
|
|
using Tgstation.Server.Host.Models;
|
|
using Tgstation.Server.Host.Security;
|
|
using Tgstation.Server.Host.Utils;
|
|
|
|
namespace Tgstation.Server.Host.Controllers
|
|
{
|
|
/// <summary>
|
|
/// <see cref="ApiController"/> for managing <see cref="ChatBot"/>s.
|
|
/// </summary>
|
|
[Route(Routes.Chat)]
|
|
#pragma warning disable CA1506 // TODO: Decomplexify
|
|
public sealed class ChatController : InstanceRequiredController
|
|
{
|
|
/// <summary>
|
|
/// The <see cref="IRestAuthorityInvoker{TAuthority}"/> for the <see cref="IChatAuthority"/>.
|
|
/// </summary>
|
|
readonly IRestAuthorityInvoker<IChatAuthority> chatAuthority;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ChatController"/> class.
|
|
/// </summary>
|
|
/// <param name="chatAuthority">The value of <see cref="chatAuthority"/>.</param>
|
|
/// <param name="databaseContext">The <see cref="IDatabaseContext"/> for the <see cref="InstanceRequiredController"/>.</param>
|
|
/// <param name="authenticationContext">The <see cref="IAuthenticationContext"/> for the <see cref="InstanceRequiredController"/>.</param>
|
|
/// <param name="logger">The <see cref="ILogger"/> for the <see cref="InstanceRequiredController"/>.</param>
|
|
/// <param name="instanceManager">The <see cref="IInstanceManager"/> for the <see cref="InstanceRequiredController"/>.</param>
|
|
/// <param name="apiHeaders">The <see cref="IApiHeadersProvider"/> for the <see cref="InstanceRequiredController"/>.</param>
|
|
public ChatController(
|
|
IRestAuthorityInvoker<IChatAuthority> chatAuthority,
|
|
IDatabaseContext databaseContext,
|
|
IAuthenticationContext authenticationContext,
|
|
ILogger<ChatController> logger,
|
|
IInstanceManager instanceManager,
|
|
IApiHeadersProvider apiHeaders)
|
|
: base(
|
|
databaseContext,
|
|
authenticationContext,
|
|
logger,
|
|
instanceManager,
|
|
apiHeaders)
|
|
{
|
|
this.chatAuthority = chatAuthority ?? throw new ArgumentNullException(nameof(chatAuthority));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Converts <paramref name="api"/> to a <see cref="ChatChannel"/>.
|
|
/// </summary>
|
|
/// <param name="api">The <see cref="Api.Models.ChatChannel"/>. </param>
|
|
/// <param name="chatProvider">The channel's <see cref="ChatProvider"/>.</param>
|
|
/// <returns>A <see cref="ChatChannel"/> based on <paramref name="api"/>.</returns>
|
|
static Models.ChatChannel ConvertApiChatChannel(Api.Models.ChatChannel api, ChatProvider chatProvider)
|
|
{
|
|
var result = new Models.ChatChannel
|
|
{
|
|
IsAdminChannel = api.IsAdminChannel ?? false,
|
|
IsWatchdogChannel = api.IsWatchdogChannel ?? false,
|
|
IsUpdatesChannel = api.IsUpdatesChannel ?? false,
|
|
IsSystemChannel = api.IsSystemChannel ?? false,
|
|
Tag = api.Tag,
|
|
};
|
|
|
|
if (api.ChannelData != null)
|
|
{
|
|
switch (chatProvider)
|
|
{
|
|
case ChatProvider.Discord:
|
|
result.DiscordChannelId = UInt64.Parse(api.ChannelData, CultureInfo.InvariantCulture);
|
|
break;
|
|
case ChatProvider.Irc:
|
|
result.IrcChannel = api.ChannelData;
|
|
break;
|
|
default:
|
|
throw new InvalidOperationException($"Invalid chat provider: {chatProvider}");
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a new chat bot <paramref name="model"/>.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="ChatBotCreateRequest"/>.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
|
/// <response code="201">Created <see cref="ChatBot"/> successfully.</response>
|
|
[HttpPut]
|
|
[ProducesResponseType(typeof(ChatBotResponse), 201)]
|
|
public async ValueTask<IActionResult> Create([FromBody] ChatBotCreateRequest model, CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
|
|
if (!model.Provider.HasValue)
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotProviderMissing));
|
|
|
|
if (String.IsNullOrWhiteSpace(model.Name))
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceName));
|
|
|
|
if (String.IsNullOrWhiteSpace(model.ConnectionString))
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceConnectionString));
|
|
|
|
if (!model.ValidateProviderChannelTypes())
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWrongChannelType));
|
|
|
|
var newChannels = model.Channels?.Select(x => ConvertApiChatChannel(x, model.Provider!.Value)).ToList() ?? new List<Models.ChatChannel>(); // important that this isn't null
|
|
|
|
return await chatAuthority.InvokeTransformable<ChatBot, ChatBotResponse>(
|
|
this,
|
|
authority => authority.Create(
|
|
newChannels,
|
|
model.Name,
|
|
model.ConnectionString,
|
|
model.Provider.Value,
|
|
Instance.Require(x => x.Id),
|
|
model.ReconnectionInterval,
|
|
model.ChannelLimit,
|
|
model.Enabled ?? false,
|
|
cancellationToken));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Delete a <see cref="ChatBot"/>.
|
|
/// </summary>
|
|
/// <param name="id">The <see cref="EntityId.Id"/> to delete.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
|
/// <response code="204">Chat bot deleted or does not exist.</response>
|
|
[HttpDelete("{id}")]
|
|
[TgsAuthorize(ChatBotRights.Delete)]
|
|
[ProducesResponseType(204)]
|
|
public async ValueTask<IActionResult> Delete(long id, CancellationToken cancellationToken)
|
|
=> await WithComponentInstanceNullable(
|
|
async instance =>
|
|
{
|
|
await Task.WhenAll(
|
|
instance.Chat.DeleteConnection(id, cancellationToken),
|
|
DatabaseContext
|
|
.ChatBots
|
|
.Where(x => x.Id == id)
|
|
.ExecuteDeleteAsync(cancellationToken));
|
|
return null;
|
|
})
|
|
|
|
?? NoContent();
|
|
|
|
/// <summary>
|
|
/// List <see cref="ChatBot"/>s.
|
|
/// </summary>
|
|
/// <param name="page">The current page.</param>
|
|
/// <param name="pageSize">The page size.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
|
/// <response code="200">Listed chat bots successfully.</response>
|
|
[HttpGet(Routes.List)]
|
|
[TgsAuthorize(ChatBotRights.Read)]
|
|
[ProducesResponseType(typeof(PaginatedResponse<ChatBotResponse>), 200)]
|
|
public ValueTask<IActionResult> List([FromQuery] int? page, [FromQuery] int? pageSize, CancellationToken cancellationToken)
|
|
{
|
|
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
|
|
return Paginated<ChatBot, ChatBotResponse>(
|
|
() => ValueTask.FromResult<PaginatableResult<ChatBot>?>(
|
|
new PaginatableResult<ChatBot>(
|
|
DatabaseContext
|
|
.ChatBots
|
|
.Where(x => x.InstanceId == Instance.Id)
|
|
.Include(x => x.Channels)
|
|
.OrderBy(x => x.Id))),
|
|
chatBot =>
|
|
{
|
|
if (!connectionStrings)
|
|
chatBot.ConnectionString = null;
|
|
|
|
return ValueTask.CompletedTask;
|
|
},
|
|
page,
|
|
pageSize,
|
|
cancellationToken);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get a specific <see cref="ChatBot"/>.
|
|
/// </summary>
|
|
/// <param name="id">The <see cref="EntityId.Id"/> to retrieve.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
|
/// <response code="200">Retrieved <see cref="ChatBot"/> successfully.</response>
|
|
/// <response code="410">The <see cref="ChatBot"/> with the given ID does not exist in this instance.</response>
|
|
[HttpGet("{id}")]
|
|
[TgsAuthorize(ChatBotRights.Read)]
|
|
[ProducesResponseType(typeof(ChatBotResponse), 200)]
|
|
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
|
|
public async ValueTask<IActionResult> GetId(long id, CancellationToken cancellationToken)
|
|
{
|
|
var query = DatabaseContext
|
|
.ChatBots
|
|
.Where(x => x.Id == id && x.InstanceId == Instance.Id)
|
|
.Include(x => x.Channels);
|
|
|
|
var results = await query.FirstOrDefaultAsync(cancellationToken);
|
|
if (results == default)
|
|
return this.Gone();
|
|
|
|
var connectionStrings = (AuthenticationContext.GetRight(RightsType.ChatBots) & (ulong)ChatBotRights.ReadConnectionString) != 0;
|
|
|
|
if (!connectionStrings)
|
|
results.ConnectionString = null;
|
|
|
|
return Json(results.ToApi());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates a chat bot <paramref name="model"/>.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="ChatBotUpdateRequest"/>.</param>
|
|
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
|
|
/// <returns>A <see cref="ValueTask{TResult}"/> resulting in the <see cref="IActionResult"/> for the operation.</returns>
|
|
/// <response code="200">Update applied successfully.</response>
|
|
/// <response code="204">Update applied successfully. <see cref="ChatBot"/> not returned based on user permissions.</response>
|
|
/// <response code="410">The <see cref="ChatBot"/> with the given ID does not exist in this instance.</response>
|
|
[HttpPost]
|
|
[TgsAuthorize(ChatBotRights.WriteChannels | ChatBotRights.WriteConnectionString | ChatBotRights.WriteEnabled | ChatBotRights.WriteName | ChatBotRights.WriteProvider)]
|
|
[ProducesResponseType(typeof(ChatBotResponse), 200)]
|
|
[ProducesResponseType(204)]
|
|
[ProducesResponseType(typeof(ErrorMessageResponse), 410)]
|
|
#pragma warning disable CA1502, CA1506 // TODO: Decomplexify
|
|
public async ValueTask<IActionResult> Update([FromBody] ChatBotUpdateRequest model, CancellationToken cancellationToken)
|
|
#pragma warning restore CA1502, CA1506
|
|
{
|
|
ArgumentNullException.ThrowIfNull(model);
|
|
|
|
IActionResult? earlyOut = StandardModelChecks(model, false);
|
|
if (earlyOut != null)
|
|
return earlyOut;
|
|
|
|
var query = DatabaseContext
|
|
.ChatBots
|
|
.Where(x => x.InstanceId == Instance.Id && x.Id == model.Id)
|
|
.Include(x => x.Channels);
|
|
|
|
var current = await query.FirstOrDefaultAsync(cancellationToken);
|
|
|
|
if (current == default)
|
|
return this.Gone();
|
|
|
|
if ((model.Channels?.Count ?? current.Channels!.Count) > (model.ChannelLimit ?? current.ChannelLimit!.Value))
|
|
{
|
|
// 400 or 409 depends on if the client sent both
|
|
var errorMessage = new ErrorMessageResponse(ErrorCode.ChatBotMaxChannels);
|
|
if (model.Channels != null && model.ChannelLimit.HasValue)
|
|
return BadRequest(errorMessage);
|
|
return Conflict(errorMessage);
|
|
}
|
|
|
|
var userRights = (ChatBotRights)AuthenticationContext.GetRight(RightsType.ChatBots);
|
|
|
|
bool anySettingsModified = false;
|
|
|
|
bool CheckModified<T>(Expression<Func<ChatBotSettings, T>> expression, ChatBotRights requiredRight)
|
|
{
|
|
var memberSelectorExpression = (MemberExpression)expression.Body;
|
|
var property = (PropertyInfo)memberSelectorExpression.Member;
|
|
|
|
var newVal = property.GetValue(model);
|
|
if (newVal == null)
|
|
return false;
|
|
if (!userRights.HasFlag(requiredRight) && property.GetValue(current) != newVal)
|
|
return true;
|
|
|
|
property.SetValue(current, newVal);
|
|
anySettingsModified = true;
|
|
return false;
|
|
}
|
|
|
|
var oldProvider = current.Provider;
|
|
|
|
if (CheckModified(x => x.ConnectionString, ChatBotRights.WriteConnectionString)
|
|
|| CheckModified(x => x.Enabled, ChatBotRights.WriteEnabled)
|
|
|| CheckModified(x => x.Name, ChatBotRights.WriteName)
|
|
|| CheckModified(x => x.Provider, ChatBotRights.WriteProvider)
|
|
|| CheckModified(x => x.ReconnectionInterval, ChatBotRights.WriteReconnectionInterval)
|
|
|| CheckModified(x => x.ChannelLimit, ChatBotRights.WriteChannelLimit)
|
|
|| (model.Channels != null && !userRights.HasFlag(ChatBotRights.WriteChannels)))
|
|
return Forbid();
|
|
|
|
var hasChannels = model.Channels != null;
|
|
if (hasChannels || (model.Provider.HasValue && model.Provider != oldProvider))
|
|
{
|
|
DatabaseContext.ChatChannels.RemoveRange(current.Channels!);
|
|
if (hasChannels)
|
|
{
|
|
var dbChannels = model.Channels!.Select(x => ConvertApiChatChannel(x, model.Provider ?? current.Provider!.Value)).ToList();
|
|
DatabaseContext.ChatChannels.AddRange(dbChannels);
|
|
current.Channels = dbChannels;
|
|
}
|
|
else
|
|
current.Channels!.Clear();
|
|
}
|
|
|
|
await DatabaseContext.Save(cancellationToken);
|
|
|
|
earlyOut = await WithComponentInstanceNullable(
|
|
async instance =>
|
|
{
|
|
var chat = instance.Chat;
|
|
if (anySettingsModified)
|
|
await chat.ChangeSettings(current, cancellationToken); // have to rebuild the thing first
|
|
|
|
if ((model.Channels != null || anySettingsModified) && current.Enabled!.Value)
|
|
await chat.ChangeChannels(current.Id!.Value, current.Channels, cancellationToken);
|
|
|
|
return null;
|
|
});
|
|
if (earlyOut != null)
|
|
return earlyOut;
|
|
|
|
if (userRights.HasFlag(ChatBotRights.Read))
|
|
{
|
|
if (!userRights.HasFlag(ChatBotRights.ReadConnectionString))
|
|
current.ConnectionString = null;
|
|
return Json(current.ToApi());
|
|
}
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Perform some basic validation of a given <paramref name="model"/>.
|
|
/// </summary>
|
|
/// <param name="model">The <see cref="ChatBotApiBase"/> to validate.</param>
|
|
/// <param name="forCreation">If the <paramref name="model"/> is being created.</param>
|
|
/// <returns>An <see cref="BadRequestObjectResult"/> to respond with or <see langword="null"/>.</returns>
|
|
BadRequestObjectResult? StandardModelChecks(ChatBotApiBase model, bool forCreation)
|
|
{
|
|
if (model.ReconnectionInterval == 0)
|
|
throw new InvalidOperationException("RecconnectionInterval cannot be zero!");
|
|
|
|
if (forCreation && !model.Provider.HasValue)
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotProviderMissing));
|
|
|
|
if (model.Name != null && String.IsNullOrWhiteSpace(model.Name))
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceName));
|
|
|
|
if (model.ConnectionString != null && String.IsNullOrWhiteSpace(model.ConnectionString))
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWhitespaceConnectionString));
|
|
|
|
if (!model.ValidateProviderChannelTypes())
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotWrongChannelType));
|
|
|
|
var defaultMaxChannels = (ulong)Math.Max(ChatBot.DefaultChannelLimit, model.Channels?.Count ?? 0);
|
|
if (defaultMaxChannels > UInt16.MaxValue)
|
|
return BadRequest(new ErrorMessageResponse(ErrorCode.ChatBotMaxChannels));
|
|
|
|
if (forCreation)
|
|
model.ChannelLimit ??= (ushort)defaultMaxChannels;
|
|
|
|
return null;
|
|
}
|
|
}
|
|
#pragma warning restore CA1506
|
|
}
|