Add project files.

This commit is contained in:
Sam Anson 2026-07-30 20:47:39 +01:00
parent 41177d9dca
commit ed957f31a1
94 changed files with 61505 additions and 0 deletions

30
.dockerignore Normal file
View file

@ -0,0 +1,30 @@
**/.classpath
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md
!**/.gitignore
!.git/HEAD
!.git/config
!.git/packed-refs
!.git/refs/heads/**

View file

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<UserSecretsId>f068a45d-cfab-49c4-a716-1468ea208513</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Discord.Net" Version="3.20.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AnsonBot.Core\AnsonBot.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Development.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View file

@ -0,0 +1,99 @@
using AnsonBot.Core;
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
namespace AnsonBot.Bot;
public class BotHostedService : IHostedService
{
private readonly DiscordSocketClient _client;
private readonly InteractionService _interactions;
private readonly IServiceProvider _services;
private readonly DiscordOptions _options;
private readonly ILogger<BotHostedService> _logger;
public BotHostedService(
DiscordSocketClient client,
InteractionService interactions,
IServiceProvider services,
IOptions<DiscordOptions> options,
ILogger<BotHostedService> logger)
{
_client = client;
_interactions = interactions;
_services = services;
_options = options.Value;
_logger = logger;
}
public async Task StartAsync(CancellationToken ct)
{
_client.Log += LogAsync;
_interactions.Log += LogAsync;
_client.Ready += ReadyAsync;
_client.InteractionCreated += InteractionCreatedAsync;
await _interactions.AddModulesAsync(Assembly.GetExecutingAssembly(), _services);
if (string.IsNullOrWhiteSpace(_options.Token))
throw new InvalidOperationException("Discord token is not configured.");
_logger.LogInformation("Token loaded, length {Length}", _options.Token.Length);
await _client.LoginAsync(TokenType.Bot, _options.Token);
await _client.StartAsync();
}
private async Task ReadyAsync()
{
if (_options.GuildId != 0)
{
await _interactions.RegisterCommandsToGuildAsync(_options.GuildId);
_logger.LogInformation("Commands registered to guild {GuildId}", _options.GuildId);
}
else
{
await _interactions.RegisterCommandsGloballyAsync();
_logger.LogInformation("Commands registered globally");
}
_logger.LogInformation("Connected as {User}", _client.CurrentUser);
}
private async Task InteractionCreatedAsync(SocketInteraction interaction)
{
var context = new SocketInteractionContext(_client, interaction);
await _interactions.ExecuteCommandAsync(context, _services);
}
private Task LogAsync(LogMessage message)
{
var level = message.Severity switch
{
LogSeverity.Critical => LogLevel.Critical,
LogSeverity.Error => LogLevel.Error,
LogSeverity.Warning => LogLevel.Warning,
LogSeverity.Info => LogLevel.Information,
_ => LogLevel.Debug
};
_logger.Log(level, message.Exception, "{Source}: {Message}", message.Source, message.Message);
return Task.CompletedTask;
}
public async Task StopAsync(CancellationToken ct)
{
await _client.LogoutAsync();
await _client.StopAsync();
}
}

View file

@ -0,0 +1,33 @@
using Discord;
using Discord.WebSocket;
using System;
using System.Collections.Generic;
using System.Text;
namespace AnsonBot.Bot;
internal class DiscordService
{
private readonly DiscordSocketClient _client;
public DiscordService(DiscordSocketClient client)
{
_client = client;
}
public async Task ClientReady()
{
await _client.SetActivityAsync(new Game("Awaiting commands"));
SlashCommandBuilder globalCommand = new();
globalCommand.WithName("palworld-status").WithDescription("Check the status of the Palworld server");
globalCommand.WithName("palworld-players").WithDescription("Check who is on the Palworld server");
await _client.CreateGlobalApplicationCommandAsync(globalCommand.Build());
}
public async Task SlashHandler(SocketSlashCommand command)
{
switch (command.Data.Name)
{
case "palworld-status":
await command.RespondAsync("Palworld status command received");
break;
}
}
}

28
AnsonBot.Bot/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
# See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging.
# This stage is used when running from VS in fast mode (Default for Debug configuration)
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
USER $APP_UID
WORKDIR /app
# This stage is used to build the service project
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["AnsonBot.Bot/AnsonBot.Bot.csproj", "AnsonBot.Bot/"]
RUN dotnet restore "./AnsonBot.Bot/AnsonBot.Bot.csproj"
COPY . .
WORKDIR "/src/AnsonBot.Bot"
RUN dotnet build "./AnsonBot.Bot.csproj" -c $BUILD_CONFIGURATION -o /app/build
# This stage is used to publish the service project to be copied to the final stage
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./AnsonBot.Bot.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
# This stage is used in production or when running from VS in regular mode (Default when not using the Debug configuration)
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "AnsonBot.Bot.dll"]

View file

@ -0,0 +1,62 @@
using AnsonBot.Core;
using Discord.WebSocket;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace AnsonBot.Bot;
/// <summary>
/// Bridges tracker events to a Discord channel. Kept out of the tracker itself
/// so AnsonBot.Core has no dependency on Discord.Net.
/// </summary>
public class PlayerNotificationService : IHostedService
{
private readonly PlayerTrackerService _tracker;
private readonly DiscordSocketClient _client;
private readonly DiscordOptions _options;
private readonly ILogger<PlayerNotificationService> _logger;
public PlayerNotificationService(
PlayerTrackerService tracker,
DiscordSocketClient client,
IOptions<DiscordOptions> options,
ILogger<PlayerNotificationService> logger)
{
_tracker = tracker;
_client = client;
_options = options.Value;
_logger = logger;
}
public Task StartAsync(CancellationToken ct)
{
if (_options.NotificationChannelId == 0)
{
_logger.LogInformation(
"No NotificationChannelId configured; join/leave notifications disabled.");
return Task.CompletedTask;
}
_tracker.PlayerJoined += e => SendAsync($"**{e.Name}** joined the server.");
_tracker.PlayerLeft += e =>
SendAsync($"**{e.Name}** left after {Humanize.Duration(e.Duration)}.");
return Task.CompletedTask;
}
private async Task SendAsync(string message)
{
if (await _client.GetChannelAsync(_options.NotificationChannelId)
is not ISocketMessageChannel channel)
{
_logger.LogWarning(
"Channel {Id} not found or not a text channel.", _options.NotificationChannelId);
return;
}
await channel.SendMessageAsync(message);
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}

75
AnsonBot.Bot/Program.cs Normal file
View file

@ -0,0 +1,75 @@
using AnsonBot.Bot;
using AnsonBot.Core;
using Discord;
using Discord.Interactions;
using Discord.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
// config binding
builder.Services.Configure<DockerOptions>(builder.Configuration.GetSection("Docker"));
builder.Services.Configure<DiscordOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.Configure<TrackerOptions>(builder.Configuration.GetSection("Tracker"));
// our services
builder.Services.AddSingleton<IRconService, DockerRconService>();
// persistence
builder.Services.AddDbContextFactory<BotDbContext>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Postgres")));
// Registered as a singleton as well as a hosted service so the /players command
// and the notifier resolve the same instance the polling loop runs on.
builder.Services.AddSingleton<PlayerTrackerService>();
builder.Services.AddHostedService(x => x.GetRequiredService<PlayerTrackerService>());
// discord
builder.Services.AddSingleton(new DiscordSocketConfig
{
GatewayIntents = GatewayIntents.Guilds,
AlwaysDownloadUsers = false,
LogLevel = LogSeverity.Info
});
builder.Services.AddSingleton<DiscordSocketClient>();
builder.Services.AddSingleton(x => new InteractionService(
x.GetRequiredService<DiscordSocketClient>()));
// dev-only: tunnel to the home server's Docker socket.
// Registered first so it starts before the bot and stops after it.
if (builder.Environment.IsDevelopment())
{
builder.Services.Configure<SshTunnelOptions>(builder.Configuration.GetSection("SshTunnel"));
builder.Services.AddHostedService<SshTunnelService>();
}
// the runner
builder.Services.AddHostedService<BotHostedService>();
// subscribes to tracker events; after BotHostedService so the client exists
builder.Services.AddHostedService<PlayerNotificationService>();
var app = builder.Build();
await app.RunAsync();
//DiscordSocketConfig config = new()
//{
// GatewayIntents = GatewayIntents.All | GatewayIntents.GuildMembers | GatewayIntents.GuildBans
//};
//DiscordSocketClient _client = new(config);
//DiscordService service = new(_client);
//var token = "MTUyNjk1Njk4MTM5MzI5MzQxMg.G46bg4.RTqBp1gvtEirLUtbojdpktz9MCZF76Sm4ecqOo";
//await _client.LoginAsync(TokenType.Bot, token);
//await _client.StartAsync();
//_client.Ready += service.ClientReady;
//_client.SlashCommandExecuted += service.SlashHandler;
//Console.WriteLine("Events registered!");
//await Task.Delay(-1);

View file

@ -0,0 +1,13 @@
{
"profiles": {
"Bot Development": {
"commandName": "Project",
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
},
"Container (Dockerfile)": {
"commandName": "Docker"
}
}
}

View file

@ -0,0 +1,50 @@
using AnsonBot.Core;
using Discord.Interactions;
namespace AnsonBot.Bot;
public class ServerModule : InteractionModuleBase<SocketInteractionContext>
{
private readonly IRconService _rcon;
private readonly PlayerTrackerService _tracker;
public ServerModule(IRconService rcon, PlayerTrackerService tracker)
{
_rcon = rcon;
_tracker = tracker;
}
[SlashCommand("players", "Show who's online in Palworld")]
public async Task PlayersAsync()
{
await DeferAsync();
try
{
// Live call so the list is current rather than up to a poll stale.
var raw = await _rcon.SendAsync("ShowPlayers");
var online = ShowPlayersParser.Parse(raw);
if (online.Count == 0)
{
await FollowupAsync("Nobody's online.");
return;
}
var sessions = (await _tracker.GetOpenSessionsAsync())
.ToDictionary(s => s.PlayerId);
var lines = online.Select(p =>
sessions.TryGetValue(p.Id, out var s)
? $"• {p.Name} — {Humanize.Duration(DateTime.UtcNow - s.StartedUtc)}"
// Joined since the last poll, so no session row exists yet.
: $"• {p.Name} — just joined");
await FollowupAsync($"**{online.Count} online:**\n{string.Join("\n", lines)}");
}
catch
{
await FollowupAsync("Couldn't reach the Palworld server.", ephemeral: true);
}
}
}

View file

@ -0,0 +1,110 @@
using AnsonBot.Core;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Diagnostics;
using System.Net.Sockets;
namespace AnsonBot.Bot;
/// <summary>
/// Development-only. Holds open an SSH tunnel from a local TCP port to the
/// home server's Docker socket, so DockerRconService can reach the daemon.
/// </summary>
public class SshTunnelService : IHostedService
{
private readonly SshTunnelOptions _options;
private readonly ILogger<SshTunnelService> _logger;
private Process? _ssh;
public SshTunnelService(
IOptions<SshTunnelOptions> options,
ILogger<SshTunnelService> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task StartAsync(CancellationToken ct)
{
// A tunnel may already be up from a previous debug session that was
// killed hard (Stop Debugging doesn't always run StopAsync).
if (await IsPortOpenAsync(_options.LocalPort, ct))
{
_logger.LogInformation(
"Port {Port} already open; assuming an existing tunnel.", _options.LocalPort);
return;
}
var args = $"-N -o ExitOnForwardFailure=yes -o BatchMode=yes " +
$"-L {_options.LocalPort}:{_options.RemoteSocket} " +
$"{_options.User}@{_options.Host}";
_logger.LogInformation("Starting SSH tunnel: ssh {Args}", args);
_ssh = Process.Start(new ProcessStartInfo("ssh", args)
{
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true
}) ?? throw new InvalidOperationException("Could not start ssh. Is it on PATH?");
// Don't let the bot start talking to Docker before the forward is live.
for (var i = 0; i < _options.StartupTimeoutSeconds * 10; i++)
{
if (_ssh.HasExited)
{
var err = await _ssh.StandardError.ReadToEndAsync(ct);
throw new InvalidOperationException($"SSH tunnel failed to start: {err}");
}
if (await IsPortOpenAsync(_options.LocalPort, ct))
{
_logger.LogInformation("SSH tunnel is up on port {Port}.", _options.LocalPort);
return;
}
await Task.Delay(100, ct);
}
throw new TimeoutException(
$"SSH tunnel did not open port {_options.LocalPort} within " +
$"{_options.StartupTimeoutSeconds}s.");
}
public Task StopAsync(CancellationToken ct)
{
if (_ssh is { HasExited: false })
{
_logger.LogInformation("Closing SSH tunnel.");
_ssh.Kill(entireProcessTree: true);
}
_ssh?.Dispose();
_ssh = null;
return Task.CompletedTask;
}
private static async Task<bool> IsPortOpenAsync(int port, CancellationToken ct)
{
try
{
using var client = new TcpClient();
await client.ConnectAsync("localhost", port, ct);
return true;
}
catch
{
return false;
}
}
}
public class SshTunnelOptions
{
public string Host { get; set; } = "192.168.40.100";
public string User { get; set; } = "sam";
public int LocalPort { get; set; } = 2375;
public string RemoteSocket { get; set; } = "/var/run/docker.sock";
public int StartupTimeoutSeconds { get; set; } = 15;
}

View file

@ -0,0 +1,14 @@
{
"Docker": {
"DaemonUri": "http://localhost:2375"
},
"SshTunnel": {
"Host": "192.168.40.100",
"User": "sam",
"LocalPort": 2375,
"RemoteSocket": "/var/run/docker.sock"
},
"Discord": {
"GuildId": 1323755326616371240
}
}

View file

@ -0,0 +1,13 @@
{
"Docker": {
"ContainerName": "palworld",
"RconCliPath": "rcon-cli"
},
"Tracker": {
"PollSeconds": 60,
"MissedPollsBeforeLeave": 2
},
"ConnectionStrings": {
"Postgres": "Host=postgres;Database=ansonbot;Username=ansonbot;Password="
}
}

View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.10" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,23 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View file

@ -0,0 +1,98 @@
.page {
position: relative;
display: flex;
flex-direction: column;
}
main {
flex: 1;
}
.sidebar {
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
}
.top-row {
background-color: #f7f7f7;
border-bottom: 1px solid #d6d5d5;
justify-content: flex-end;
height: 3.5rem;
display: flex;
align-items: center;
}
.top-row ::deep a, .top-row ::deep .btn-link {
white-space: nowrap;
margin-left: 1.5rem;
text-decoration: none;
}
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
text-decoration: underline;
}
.top-row ::deep a:first-child {
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 640.98px) {
.top-row {
justify-content: space-between;
}
.top-row ::deep a, .top-row ::deep .btn-link {
margin-left: 0;
}
}
@media (min-width: 641px) {
.page {
flex-direction: row;
}
.sidebar {
width: 250px;
height: 100vh;
position: sticky;
top: 0;
}
.top-row {
position: sticky;
top: 0;
z-index: 1;
}
.top-row.auth ::deep a:first-child {
flex: 1;
text-align: right;
width: 0;
}
.top-row, article {
padding-left: 2rem !important;
padding-right: 1.5rem !important;
}
}
#blazor-error-ui {
color-scheme: light only;
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
box-sizing: border-box;
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}

View file

@ -0,0 +1,30 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">AnsonBot</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>

View file

@ -0,0 +1,105 @@
.navbar-toggler {
appearance: none;
cursor: pointer;
width: 3.5rem;
height: 2.5rem;
color: white;
position: absolute;
top: 0.5rem;
right: 1rem;
border: 1px solid rgba(255, 255, 255, 0.1);
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
}
.navbar-toggler:checked {
background-color: rgba(255, 255, 255, 0.5);
}
.top-row {
min-height: 3.5rem;
background-color: rgba(0,0,0,0.4);
}
.navbar-brand {
font-size: 1.1rem;
}
.bi {
display: inline-block;
position: relative;
width: 1.25rem;
height: 1.25rem;
margin-right: 0.75rem;
top: -1px;
background-size: cover;
}
.bi-house-door-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
}
.bi-plus-square-fill-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
}
.bi-list-nested-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;
}
.nav-item:first-of-type {
padding-top: 1rem;
}
.nav-item:last-of-type {
padding-bottom: 1rem;
}
.nav-item ::deep .nav-link {
color: #d7d7d7;
background: none;
border: none;
border-radius: 4px;
height: 3rem;
display: flex;
align-items: center;
line-height: 3rem;
width: 100%;
}
.nav-item ::deep a.active {
background-color: rgba(255,255,255,0.37);
color: white;
}
.nav-item ::deep .nav-link:hover {
background-color: rgba(255,255,255,0.1);
color: white;
}
.nav-scrollable {
display: none;
}
.navbar-toggler:checked ~ .nav-scrollable {
display: block;
}
@media (min-width: 641px) {
.navbar-toggler {
display: none;
}
.nav-scrollable {
/* Never collapse the sidebar for wide screens */
display: block;
/* Allow sidebar to scroll for tall menus */
height: calc(100vh - 3.5rem);
overflow-y: auto;
}
}

View file

@ -0,0 +1,31 @@
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">
<div class="components-rejoining-animation" aria-hidden="true">
<div></div>
<div></div>
</div>
<p class="components-reconnect-first-attempt-visible">
Rejoining the server...
</p>
<p class="components-reconnect-repeated-attempt-visible">
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
</p>
<p class="components-reconnect-failed-visible">
Failed to rejoin.<br />Please retry or reload the page.
</p>
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
Retry
</button>
<p class="components-pause-visible">
The session has been paused by the server.
</p>
<p class="components-resume-failed-visible">
Failed to resume the session.<br />Please retry or reload the page.
</p>
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
Resume
</button>
</div>
</dialog>

View file

@ -0,0 +1,157 @@
.components-reconnect-first-attempt-visible,
.components-reconnect-repeated-attempt-visible,
.components-reconnect-failed-visible,
.components-pause-visible,
.components-resume-failed-visible,
.components-rejoining-animation {
display: none;
}
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
#components-reconnect-modal.components-reconnect-retrying,
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
#components-reconnect-modal.components-reconnect-failed,
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
display: block;
}
#components-reconnect-modal {
background-color: white;
width: 20rem;
margin: 20vh auto;
padding: 2rem;
border: 0;
border-radius: 0.5rem;
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
opacity: 0;
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
&[open]
{
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
animation-fill-mode: both;
}
}
#components-reconnect-modal::backdrop {
background-color: rgba(0, 0, 0, 0.4);
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
opacity: 1;
}
@keyframes components-reconnect-modal-slideUp {
0% {
transform: translateY(30px) scale(0.95);
}
100% {
transform: translateY(0);
}
}
@keyframes components-reconnect-modal-fadeInOpacity {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
@keyframes components-reconnect-modal-fadeOutOpacity {
0% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.components-reconnect-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
#components-reconnect-modal p {
margin: 0;
text-align: center;
}
#components-reconnect-modal button {
border: 0;
background-color: #6b9ed2;
color: white;
padding: 4px 24px;
border-radius: 4px;
}
#components-reconnect-modal button:hover {
background-color: #3b6ea2;
}
#components-reconnect-modal button:active {
background-color: #6b9ed2;
}
.components-rejoining-animation {
position: relative;
width: 80px;
height: 80px;
}
.components-rejoining-animation div {
position: absolute;
border: 3px solid #0087ff;
opacity: 1;
border-radius: 50%;
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
}
.components-rejoining-animation div:nth-child(2) {
animation-delay: -0.5s;
}
@keyframes components-rejoining-animation {
0% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
4.9% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 0;
}
5% {
top: 40px;
left: 40px;
width: 0;
height: 0;
opacity: 1;
}
100% {
top: 0px;
left: 0px;
width: 80px;
height: 80px;
opacity: 0;
}
}

View file

@ -0,0 +1,63 @@
// Set up event handlers
const reconnectModal = document.getElementById("components-reconnect-modal");
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
const retryButton = document.getElementById("components-reconnect-button");
retryButton.addEventListener("click", retry);
const resumeButton = document.getElementById("components-resume-button");
resumeButton.addEventListener("click", resume);
function handleReconnectStateChanged(event) {
if (event.detail.state === "show") {
reconnectModal.showModal();
} else if (event.detail.state === "hide") {
reconnectModal.close();
} else if (event.detail.state === "failed") {
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
} else if (event.detail.state === "rejected") {
location.reload();
}
}
async function retry() {
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
try {
// Reconnect will asynchronously return:
// - true to mean success
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
// - exception to mean we didn't reach the server (this can be sync or async)
const successful = await Blazor.reconnect();
if (!successful) {
// We have been able to reach the server, but the circuit is no longer available.
// We'll reload the page so the user can continue using the app as quickly as possible.
const resumeSuccessful = await Blazor.resumeCircuit();
if (!resumeSuccessful) {
location.reload();
} else {
reconnectModal.close();
}
}
} catch (err) {
// We got an exception, server is currently unavailable
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
}
}
async function resume() {
try {
const successful = await Blazor.resumeCircuit();
if (!successful) {
location.reload();
}
} catch {
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
}
}
async function retryWhenDocumentBecomesVisible() {
if (document.visibilityState === "visible") {
await retry();
}
}

View file

@ -0,0 +1,18 @@
@page "/counter"
<PageTitle>Counter</PageTitle>
<h1>Counter</h1>
<p role="status">Current count: @currentCount</p>
<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}

View file

@ -0,0 +1,7 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View file

@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View file

@ -0,0 +1,63 @@
@page "/weather"
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate a loading indicator
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View file

@ -0,0 +1,5 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
await builder.Build().RunAsync();

View file

@ -0,0 +1,6 @@
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<FocusOnNavigate RouteData="routeData" Selector="h1" />
</Found>
</Router>

View file

@ -0,0 +1,10 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AnsonBot.Client
@using AnsonBot.Client.Layout

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Docker.DotNet" Version="3.125.15" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore;
namespace AnsonBot.Core;
public class BotDbContext : DbContext
{
public BotDbContext(DbContextOptions<BotDbContext> options) : base(options) { }
public DbSet<PlaySession> PlaySessions => Set<PlaySession>();
protected override void OnModelCreating(ModelBuilder b)
{
var session = b.Entity<PlaySession>();
session.Ignore(s => s.Duration);
// Main lookup: "is this player currently online" and per-player history.
session.HasIndex(s => new { s.PlayerId, s.EndedUtc });
// Supports finding all open sessions on startup for crash recovery.
session.HasIndex(s => s.EndedUtc);
}
}

View file

@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Text;
namespace AnsonBot.Core;
public class DiscordOptions
{
public string Token { get; set; } = "";
public ulong GuildId { get; set; }
/// <summary>Channel for join/leave notifications. 0 disables them.</summary>
public ulong NotificationChannelId { get; set; }
}

View file

@ -0,0 +1,18 @@
namespace AnsonBot.Core;
public class DockerOptions
{
/// <summary>Name or ID of the Palworld server container.</summary>
public string ContainerName { get; set; } = "palworld";
/// <summary>Executable invoked inside the container to issue RCON commands.</summary>
public string RconCliPath { get; set; } = "rcon-cli";
/// <summary>
/// Docker daemon endpoint. Leave null to use the local default
/// (unix:///var/run/docker.sock on Linux, npipe://./pipe/docker_engine on Windows).
/// </summary>
public string? DaemonUri { get; set; }
public int TimeoutSeconds { get; set; } = 10;
}

15
AnsonBot.Core/Humanize.cs Normal file
View file

@ -0,0 +1,15 @@
namespace AnsonBot.Core;
public static class Humanize
{
/// <summary>Formats a duration as "2h 15m", "45m", or "just joined".</summary>
public static string Duration(TimeSpan d)
{
if (d.TotalMinutes < 1) return "just joined";
var hours = (int)d.TotalHours;
var minutes = d.Minutes;
return hours > 0 ? $"{hours}h {minutes}m" : $"{minutes}m";
}
}

View file

@ -0,0 +1,70 @@
using Docker.DotNet;
using Docker.DotNet.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace AnsonBot.Core;
public interface IRconService
{
Task<string> SendAsync(string command, CancellationToken ct = default);
}
public class DockerRconService : IRconService, IDisposable
{
private readonly DockerOptions _options;
private readonly ILogger<DockerRconService> _logger;
private readonly DockerClient _client;
public DockerRconService(
IOptions<DockerOptions> options,
ILogger<DockerRconService> logger)
{
_options = options.Value;
_logger = logger;
var config = string.IsNullOrWhiteSpace(_options.DaemonUri)
? new DockerClientConfiguration()
: new DockerClientConfiguration(new Uri(_options.DaemonUri));
_client = config.CreateClient();
}
public async Task<string> SendAsync(string command, CancellationToken ct = default)
{
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(_options.TimeoutSeconds));
_logger.LogInformation("Exec in {Container}: {Cli} {Command}",
_options.ContainerName, _options.RconCliPath, command);
var exec = await _client.Exec.ExecCreateContainerAsync(
_options.ContainerName,
new ContainerExecCreateParameters
{
// Passed as argv, so no shell quoting concerns.
Cmd = new List<string> { _options.RconCliPath, command },
AttachStdout = true,
AttachStderr = true
},
cts.Token);
using var stream = await _client.Exec.StartAndAttachContainerExecAsync(
exec.ID, tty: false, cts.Token);
var (stdout, stderr) = await stream.ReadOutputToEndAsync(cts.Token);
var inspect = await _client.Exec.InspectContainerExecAsync(exec.ID, cts.Token);
if (inspect.ExitCode != 0)
{
_logger.LogError("rcon exec failed (exit {Code}): {Error}", inspect.ExitCode, stderr);
throw new InvalidOperationException(
$"'{_options.RconCliPath} {command}' exited with code {inspect.ExitCode}: {stderr}");
}
return stdout;
}
public void Dispose() => _client.Dispose();
}

View file

@ -0,0 +1,63 @@
namespace AnsonBot.Core;
/// <summary>A player as reported by Palworld's ShowPlayers command.</summary>
public record PalworldPlayer(string Name, string PlayerUid, string SteamId)
{
/// <summary>
/// Stable key for tracking. Prefers SteamId, falls back to PlayerUid, then
/// name. Palworld sometimes reports "00000000" for a uid mid-connection.
/// </summary>
public string Id =>
IsUsable(SteamId) ? SteamId :
IsUsable(PlayerUid) ? PlayerUid :
Name;
private static bool IsUsable(string value) =>
!string.IsNullOrWhiteSpace(value) && value.Trim('0').Length > 0;
}
public static class ShowPlayersParser
{
/// <summary>
/// Parses ShowPlayers output, which looks like:
/// name,playeruid,steamid
/// Sam,1234567890,76561198000000000
/// Malformed or incomplete rows are skipped rather than throwing, because a
/// single bad row shouldn't take down the polling loop.
/// </summary>
public static IReadOnlyList<PalworldPlayer> Parse(string raw)
{
if (string.IsNullOrWhiteSpace(raw))
return Array.Empty<PalworldPlayer>();
var players = new List<PalworldPlayer>();
var lines = raw.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Select(l => l.Trim('\r', ' '))
.Where(l => l.Length > 0);
foreach (var line in lines)
{
// Skip the header wherever it appears; rcon-cli sometimes echoes it.
if (line.StartsWith("name,", StringComparison.OrdinalIgnoreCase))
continue;
// Names may contain commas, so split from the right: the last two
// fields are uid and steamid, everything before is the name.
var parts = line.Split(',');
if (parts.Length < 3)
continue;
var steamId = parts[^1].Trim();
var uid = parts[^2].Trim();
var name = string.Join(',', parts[..^2]).Trim();
if (string.IsNullOrWhiteSpace(name))
continue;
players.Add(new PalworldPlayer(name, uid, steamId));
}
return players;
}
}

View file

@ -0,0 +1,33 @@
using System.ComponentModel.DataAnnotations;
namespace AnsonBot.Core;
/// <summary>
/// One continuous period a player was connected to the server.
/// A session with a null <see cref="EndedUtc"/> is still open.
/// </summary>
public class PlaySession
{
public int Id { get; set; }
/// <summary>Stable identity for the player. Steam ID where available.</summary>
[MaxLength(64)]
public string PlayerId { get; set; } = "";
/// <summary>Display name at the time of this session; players can rename.</summary>
[MaxLength(128)]
public string Name { get; set; } = "";
public DateTime StartedUtc { get; set; }
/// <summary>Null while the player is still online.</summary>
public DateTime? EndedUtc { get; set; }
/// <summary>
/// Updated on every successful poll. If the bot dies without closing the
/// session, this is the best available estimate of when the player left.
/// </summary>
public DateTime LastSeenUtc { get; set; }
public TimeSpan Duration => (EndedUtc ?? DateTime.UtcNow) - StartedUtc;
}

View file

@ -0,0 +1,187 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace AnsonBot.Core;
public class TrackerOptions
{
public int PollSeconds { get; set; } = 60;
/// <summary>
/// Consecutive polls a player must be absent before their session is
/// closed. Guards against a single dropped/partial ShowPlayers response
/// registering as everyone leaving and rejoining.
/// </summary>
public int MissedPollsBeforeLeave { get; set; } = 2;
}
public record PlayerEvent(string PlayerId, string Name, TimeSpan Duration);
public class PlayerTrackerService : BackgroundService
{
private readonly IRconService _rcon;
private readonly IDbContextFactory<BotDbContext> _dbFactory;
private readonly TrackerOptions _options;
private readonly ILogger<PlayerTrackerService> _logger;
// PlayerId -> consecutive polls missing.
private readonly Dictionary<string, int> _missing = new();
public event Func<PlayerEvent, Task>? PlayerJoined;
public event Func<PlayerEvent, Task>? PlayerLeft;
public PlayerTrackerService(
IRconService rcon,
IDbContextFactory<BotDbContext> dbFactory,
IOptions<TrackerOptions> options,
ILogger<PlayerTrackerService> logger)
{
_rcon = rcon;
_dbFactory = dbFactory;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken ct)
{
await RecoverOrphanedSessionsAsync(ct);
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(_options.PollSeconds));
do
{
try
{
await PollAsync(ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
// Deliberately swallow: a failed poll (server restarting, tunnel
// blipped) must not kill the loop or close everyone's session.
_logger.LogWarning(ex, "Poll failed; leaving sessions untouched.");
}
}
while (await timer.WaitForNextTickAsync(ct));
}
/// <summary>
/// If the bot died without closing sessions, settle them at the last time
/// we actually saw the player rather than leaving them open forever.
/// </summary>
private async Task RecoverOrphanedSessionsAsync(CancellationToken ct)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
await db.Database.MigrateAsync(ct);
var open = await db.PlaySessions
.Where(s => s.EndedUtc == null)
.ToListAsync(ct);
if (open.Count == 0) return;
foreach (var s in open)
s.EndedUtc = s.LastSeenUtc;
await db.SaveChangesAsync(ct);
_logger.LogInformation("Closed {Count} orphaned session(s) from a previous run.", open.Count);
}
private async Task PollAsync(CancellationToken ct)
{
var raw = await _rcon.SendAsync("ShowPlayers", ct);
var online = ShowPlayersParser.Parse(raw);
var now = DateTime.UtcNow;
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var openSessions = await db.PlaySessions
.Where(s => s.EndedUtc == null)
.ToListAsync(ct);
var openById = openSessions.ToDictionary(s => s.PlayerId);
var onlineById = online
.GroupBy(p => p.Id)
.ToDictionary(g => g.Key, g => g.First());
var joined = new List<PlayerEvent>();
var left = new List<PlayerEvent>();
// Joins, and heartbeat for anyone still connected.
foreach (var (id, player) in onlineById)
{
_missing.Remove(id);
if (openById.TryGetValue(id, out var existing))
{
existing.LastSeenUtc = now;
existing.Name = player.Name; // keep up with renames
}
else
{
db.PlaySessions.Add(new PlaySession
{
PlayerId = id,
Name = player.Name,
StartedUtc = now,
LastSeenUtc = now
});
joined.Add(new PlayerEvent(id, player.Name, TimeSpan.Zero));
}
}
// Leaves, only after enough consecutive absences.
foreach (var session in openSessions)
{
if (onlineById.ContainsKey(session.PlayerId)) continue;
var misses = _missing.GetValueOrDefault(session.PlayerId) + 1;
_missing[session.PlayerId] = misses;
if (misses < _options.MissedPollsBeforeLeave) continue;
// Credit them only up to when we last actually saw them.
session.EndedUtc = session.LastSeenUtc;
_missing.Remove(session.PlayerId);
left.Add(new PlayerEvent(
session.PlayerId, session.Name, session.EndedUtc.Value - session.StartedUtc));
}
await db.SaveChangesAsync(ct);
foreach (var e in joined) await RaiseAsync(PlayerJoined, e);
foreach (var e in left) await RaiseAsync(PlayerLeft, e);
}
private async Task RaiseAsync(Func<PlayerEvent, Task>? handler, PlayerEvent e)
{
if (handler is null) return;
try
{
await handler(e);
}
catch (Exception ex)
{
// A broken notification must not stop tracking.
_logger.LogError(ex, "Player event handler failed for {Player}.", e.Name);
}
}
/// <summary>Currently open sessions, for the /players command.</summary>
public async Task<IReadOnlyList<PlaySession>> GetOpenSessionsAsync(CancellationToken ct = default)
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
return await db.PlaySessions
.Where(s => s.EndedUtc == null)
.OrderBy(s => s.StartedUtc)
.ToListAsync(ct);
}
}

6
AnsonBot.slnx Normal file
View file

@ -0,0 +1,6 @@
<Solution>
<Project Path="AnsonBot.Bot/AnsonBot.Bot.csproj" Id="d2d78be4-ede1-4587-9977-e5898f5d88b9" />
<Project Path="AnsonBot.Client/AnsonBot.Client.csproj" />
<Project Path="AnsonBot.Core/AnsonBot.Core.csproj" Id="cdf2834a-8b02-4dd0-aa76-800597adfb6d" />
<Project Path="AnsonBot/AnsonBot.csproj" />
</Solution>

16
AnsonBot/AnsonBot.csproj Normal file
View file

@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AnsonBot.Client\AnsonBot.Client.csproj" />
<PackageReference Include="Discord.Net" Version="3.20.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Server" Version="10.0.10" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["AnsonBot.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="InteractiveAuto" />
</head>
<body>
<Routes @rendermode="InteractiveAuto" />
<ReconnectModal />
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View file

@ -0,0 +1,36 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}

View file

@ -0,0 +1,12 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using AnsonBot
@using AnsonBot.Client
@using AnsonBot.Client.Layout
@using AnsonBot.Components

51
AnsonBot/Program.cs Normal file
View file

@ -0,0 +1,51 @@
using AnsonBot.Client.Pages;
using AnsonBot.Components;
using Discord;
using Discord.WebSocket;
using Newtonsoft.Json.Linq;
var builder = WebApplication.CreateBuilder(args);
DiscordSocketConfig config = new()
{
GatewayIntents = GatewayIntents.All | GatewayIntents.GuildMembers | GatewayIntents.GuildBans
};
DiscordSocketClient _client = new(config);
var token = "MTUyNjk1Njk4MTM5MzI5MzQxMg.G46bg4.RTqBp1gvtEirLUtbojdpktz9MCZF76Sm4ecqOo";
await _client.LoginAsync(TokenType.Bot, token);
await _client.StartAsync();
builder.Services.AddSingleton(config);
builder.Services.AddSingleton<DiscordSocketClient>(_client);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents()
.AddInteractiveWebAssemblyComponents();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
app.UseWebAssemblyDebugging();
}
else
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode()
.AddInteractiveWebAssemblyRenderMode()
.AddAdditionalAssemblies(typeof(AnsonBot.Client._Imports).Assembly);
app.Run();

View file

@ -0,0 +1,25 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5049",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7267;http://localhost:5049",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View file

@ -0,0 +1,19 @@
using Discord.WebSocket;
namespace AnsonBot.Services;
public interface IDiscordService
{
}
public class DiscordService : IDiscordService
{
private readonly DiscordSocketClient _client;
public DiscordService(DiscordSocketClient client)
{
_client = client;
}
}

View file

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View file

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

60
AnsonBot/wwwroot/app.css Normal file
View file

@ -0,0 +1,60 @@
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
a, .btn-link {
color: #006bb7;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
}
.content {
padding-top: 1.1rem;
}
h1:focus {
outline: none;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid #e50000;
}
.validation-message {
color: #e50000;
}
.blazor-error-boundary {
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
padding: 1rem 1rem 1rem 3.7rem;
color: white;
}
.blazor-error-boundary::after {
content: "An error has occurred."
}
.darker-border-checkbox.form-check-input {
border-color: #929292;
}
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
color: var(--bs-secondary-color);
text-align: end;
}
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
text-align: start;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,597 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,594 @@
/*!
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
:root,
[data-bs-theme=light] {
--bs-blue: #0d6efd;
--bs-indigo: #6610f2;
--bs-purple: #6f42c1;
--bs-pink: #d63384;
--bs-red: #dc3545;
--bs-orange: #fd7e14;
--bs-yellow: #ffc107;
--bs-green: #198754;
--bs-teal: #20c997;
--bs-cyan: #0dcaf0;
--bs-black: #000;
--bs-white: #fff;
--bs-gray: #6c757d;
--bs-gray-dark: #343a40;
--bs-gray-100: #f8f9fa;
--bs-gray-200: #e9ecef;
--bs-gray-300: #dee2e6;
--bs-gray-400: #ced4da;
--bs-gray-500: #adb5bd;
--bs-gray-600: #6c757d;
--bs-gray-700: #495057;
--bs-gray-800: #343a40;
--bs-gray-900: #212529;
--bs-primary: #0d6efd;
--bs-secondary: #6c757d;
--bs-success: #198754;
--bs-info: #0dcaf0;
--bs-warning: #ffc107;
--bs-danger: #dc3545;
--bs-light: #f8f9fa;
--bs-dark: #212529;
--bs-primary-rgb: 13, 110, 253;
--bs-secondary-rgb: 108, 117, 125;
--bs-success-rgb: 25, 135, 84;
--bs-info-rgb: 13, 202, 240;
--bs-warning-rgb: 255, 193, 7;
--bs-danger-rgb: 220, 53, 69;
--bs-light-rgb: 248, 249, 250;
--bs-dark-rgb: 33, 37, 41;
--bs-primary-text-emphasis: #052c65;
--bs-secondary-text-emphasis: #2b2f32;
--bs-success-text-emphasis: #0a3622;
--bs-info-text-emphasis: #055160;
--bs-warning-text-emphasis: #664d03;
--bs-danger-text-emphasis: #58151c;
--bs-light-text-emphasis: #495057;
--bs-dark-text-emphasis: #495057;
--bs-primary-bg-subtle: #cfe2ff;
--bs-secondary-bg-subtle: #e2e3e5;
--bs-success-bg-subtle: #d1e7dd;
--bs-info-bg-subtle: #cff4fc;
--bs-warning-bg-subtle: #fff3cd;
--bs-danger-bg-subtle: #f8d7da;
--bs-light-bg-subtle: #fcfcfd;
--bs-dark-bg-subtle: #ced4da;
--bs-primary-border-subtle: #9ec5fe;
--bs-secondary-border-subtle: #c4c8cb;
--bs-success-border-subtle: #a3cfbb;
--bs-info-border-subtle: #9eeaf9;
--bs-warning-border-subtle: #ffe69c;
--bs-danger-border-subtle: #f1aeb5;
--bs-light-border-subtle: #e9ecef;
--bs-dark-border-subtle: #adb5bd;
--bs-white-rgb: 255, 255, 255;
--bs-black-rgb: 0, 0, 0;
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
--bs-body-font-family: var(--bs-font-sans-serif);
--bs-body-font-size: 1rem;
--bs-body-font-weight: 400;
--bs-body-line-height: 1.5;
--bs-body-color: #212529;
--bs-body-color-rgb: 33, 37, 41;
--bs-body-bg: #fff;
--bs-body-bg-rgb: 255, 255, 255;
--bs-emphasis-color: #000;
--bs-emphasis-color-rgb: 0, 0, 0;
--bs-secondary-color: rgba(33, 37, 41, 0.75);
--bs-secondary-color-rgb: 33, 37, 41;
--bs-secondary-bg: #e9ecef;
--bs-secondary-bg-rgb: 233, 236, 239;
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
--bs-tertiary-color-rgb: 33, 37, 41;
--bs-tertiary-bg: #f8f9fa;
--bs-tertiary-bg-rgb: 248, 249, 250;
--bs-heading-color: inherit;
--bs-link-color: #0d6efd;
--bs-link-color-rgb: 13, 110, 253;
--bs-link-decoration: underline;
--bs-link-hover-color: #0a58ca;
--bs-link-hover-color-rgb: 10, 88, 202;
--bs-code-color: #d63384;
--bs-highlight-color: #212529;
--bs-highlight-bg: #fff3cd;
--bs-border-width: 1px;
--bs-border-style: solid;
--bs-border-color: #dee2e6;
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
--bs-border-radius: 0.375rem;
--bs-border-radius-sm: 0.25rem;
--bs-border-radius-lg: 0.5rem;
--bs-border-radius-xl: 1rem;
--bs-border-radius-xxl: 2rem;
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
--bs-border-radius-pill: 50rem;
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
--bs-focus-ring-width: 0.25rem;
--bs-focus-ring-opacity: 0.25;
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
--bs-form-valid-color: #198754;
--bs-form-valid-border-color: #198754;
--bs-form-invalid-color: #dc3545;
--bs-form-invalid-border-color: #dc3545;
}
[data-bs-theme=dark] {
color-scheme: dark;
--bs-body-color: #dee2e6;
--bs-body-color-rgb: 222, 226, 230;
--bs-body-bg: #212529;
--bs-body-bg-rgb: 33, 37, 41;
--bs-emphasis-color: #fff;
--bs-emphasis-color-rgb: 255, 255, 255;
--bs-secondary-color: rgba(222, 226, 230, 0.75);
--bs-secondary-color-rgb: 222, 226, 230;
--bs-secondary-bg: #343a40;
--bs-secondary-bg-rgb: 52, 58, 64;
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
--bs-tertiary-color-rgb: 222, 226, 230;
--bs-tertiary-bg: #2b3035;
--bs-tertiary-bg-rgb: 43, 48, 53;
--bs-primary-text-emphasis: #6ea8fe;
--bs-secondary-text-emphasis: #a7acb1;
--bs-success-text-emphasis: #75b798;
--bs-info-text-emphasis: #6edff6;
--bs-warning-text-emphasis: #ffda6a;
--bs-danger-text-emphasis: #ea868f;
--bs-light-text-emphasis: #f8f9fa;
--bs-dark-text-emphasis: #dee2e6;
--bs-primary-bg-subtle: #031633;
--bs-secondary-bg-subtle: #161719;
--bs-success-bg-subtle: #051b11;
--bs-info-bg-subtle: #032830;
--bs-warning-bg-subtle: #332701;
--bs-danger-bg-subtle: #2c0b0e;
--bs-light-bg-subtle: #343a40;
--bs-dark-bg-subtle: #1a1d20;
--bs-primary-border-subtle: #084298;
--bs-secondary-border-subtle: #41464b;
--bs-success-border-subtle: #0f5132;
--bs-info-border-subtle: #087990;
--bs-warning-border-subtle: #997404;
--bs-danger-border-subtle: #842029;
--bs-light-border-subtle: #495057;
--bs-dark-border-subtle: #343a40;
--bs-heading-color: inherit;
--bs-link-color: #6ea8fe;
--bs-link-hover-color: #8bb9fe;
--bs-link-color-rgb: 110, 168, 254;
--bs-link-hover-color-rgb: 139, 185, 254;
--bs-code-color: #e685b5;
--bs-highlight-color: #dee2e6;
--bs-highlight-bg: #664d03;
--bs-border-color: #495057;
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
--bs-form-valid-color: #75b798;
--bs-form-valid-border-color: #75b798;
--bs-form-invalid-color: #ea868f;
--bs-form-invalid-border-color: #ea868f;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: var(--bs-body-font-family);
font-size: var(--bs-body-font-size);
font-weight: var(--bs-body-font-weight);
line-height: var(--bs-body-line-height);
color: var(--bs-body-color);
text-align: var(--bs-body-text-align);
background-color: var(--bs-body-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
border: 0;
border-top: var(--bs-border-width) solid;
opacity: 0.25;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
color: var(--bs-heading-color);
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-right: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-right: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.1875em;
color: var(--bs-highlight-color);
background-color: var(--bs-highlight-bg);
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
text-decoration: underline;
}
a:hover {
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: var(--bs-font-monospace);
font-size: 1em;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: var(--bs-code-color);
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.1875rem 0.375rem;
font-size: 0.875em;
color: var(--bs-body-bg);
background-color: var(--bs-body-color);
border-radius: 0.25rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: var(--bs-secondary-color);
text-align: right;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
display: none !important;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: right;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: right;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
::file-selector-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long