feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
using ClawdDotNet.App.Services;
|
||||
using ClawdDotNet.App.Settings;
|
||||
using ClawdDotNet.Core.Accounting;
|
||||
using ClawdDotNet.Core.Api;
|
||||
using ClawdDotNet.Core.Audit;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Logging;
|
||||
using ClawdDotNet.Core.Memory;
|
||||
using ClawdDotNet.Core.Security;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Deploymentcenter;
|
||||
using ClawdDotNet.Core.Deploymentcenter.Watchdog;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.AgentComm;
|
||||
using ClawdDotNet.Tools.AgentEditor;
|
||||
using ClawdDotNet.Tools.AgentSpawn;
|
||||
using ClawdDotNet.Tools.Database;
|
||||
using ClawdDotNet.Tools.DirectAPI;
|
||||
using ClawdDotNet.Tools.FileRW;
|
||||
using ClawdDotNet.Tools.FTP;
|
||||
using ClawdDotNet.Tools.Mail;
|
||||
using ClawdDotNet.Tools.SocialMediaManager;
|
||||
using ClawdDotNet.Tools.Telegram;
|
||||
using ClawdDotNet.Tools.TelegramClient;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using ClawdDotNet.Tools.WebMonitor;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClawdDotNet.App;
|
||||
|
||||
/// <summary>
|
||||
/// Baut alles auf, was ClawdDotNet zum Laufen braucht — <b>ohne</b> eine einzige Zeile
|
||||
/// Oberflächencode.
|
||||
///
|
||||
/// <para>Vorher lag das in <c>Program.cs</c> der WinForms-Anwendung: 350 Zeilen zwischen
|
||||
/// <c>ApplicationConfiguration.Initialize()</c> und <c>Application.Run(form)</c>. Die
|
||||
/// Trennung bestand faktisch schon — alles war fertig aufgebaut, bevor das Fenster
|
||||
/// überhaupt entstand. Sie war nur nirgends festgehalten.</para>
|
||||
///
|
||||
/// <para>Damit setzen zwei Aufrufer auf demselben Aufbau auf: die Avalonia-Anwendung
|
||||
/// und der geplante systemd-Dienst. Was ein Fenster braucht — Instanzauswahl,
|
||||
/// Lizenzabfrage, Telegram-Anmeldung — kommt als Rückruf herein, statt hier
|
||||
/// festgeschrieben zu sein.</para>
|
||||
/// </summary>
|
||||
public sealed class AppHost : IAsyncDisposable
|
||||
{
|
||||
private readonly List<Func<ValueTask>> _shutdown = [];
|
||||
|
||||
public required SettingsManager Settings { get; init; }
|
||||
public required InstanceDirectoryManager Directories { get; init; }
|
||||
public required InstanceConfig Instance { get; init; }
|
||||
public required string InstancePath { get; init; }
|
||||
public required string LogDirectory { get; init; }
|
||||
public required ILoggerFactory LoggerFactory { get; init; }
|
||||
public required ToolRegistry Tools { get; init; }
|
||||
|
||||
/// <summary>Null, wenn kein OpenRouter-Schlüssel hinterlegt ist — dann laufen keine Agenten.</summary>
|
||||
public AgentEngine? Engine { get; private init; }
|
||||
|
||||
public TaskScanner? Scanner { get; private init; }
|
||||
public StagingService? Staging { get; private init; }
|
||||
public SqliteUsageRepository? Usage { get; private init; }
|
||||
public TelegramClientManager? TelegramClient { get; private init; }
|
||||
public OpenRouterStatusService? Status { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter (Heartbeat, Fehler-Stream, Bugtracker, Updates).
|
||||
/// Null, wenn Adresse oder Token fehlen.
|
||||
/// </summary>
|
||||
public DeploymentcenterService? Deploymentcenter { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Meldeweg für ungefangene Ausnahmen. Immer gesetzt — ohne Anbindung ist es der
|
||||
/// Leerlauf, damit Aufrufer nicht auf null prüfen müssen.
|
||||
/// </summary>
|
||||
public IErrorReporter Errors => Deploymentcenter?.Errors ?? NullErrorReporter.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Der Lizenz-Torwächter bleibt über die Laufzeit erhalten: Ein Widerruf soll auch
|
||||
/// eine bereits laufende Instanz erreichen, nicht erst den nächsten Start.
|
||||
/// </summary>
|
||||
public LicenseGate? License { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Die laufende Nachprüfung. Der Aufrufer hängt sich an
|
||||
/// <see cref="LicenseWatch.Revoked"/> und beendet die Anwendung, wenn es feuert.
|
||||
/// </summary>
|
||||
public LicenseWatch? LicenseWatch { get; private set; }
|
||||
|
||||
/// <summary>Was der Aufrufer beim Start erfragen muss.</summary>
|
||||
public sealed class Callbacks
|
||||
{
|
||||
/// <summary>
|
||||
/// Wählt die Instanz. Gibt <c>null</c> zurück, wenn der Nutzer abbricht.
|
||||
/// Ein Dienst liefert hier den fest eingestellten Pfad, ohne zu fragen.
|
||||
/// </summary>
|
||||
public required Func<InstanceDirectoryManager, Task<string?>> SelectInstance { get; init; }
|
||||
|
||||
/// <summary>Wie der Lizenz-Torwächter mit dem Benutzer spricht.</summary>
|
||||
public required ILicensePrompt License { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Telegram-Anmeldecode und 2FA-Passwort. Null lässt die MTProto-Anmeldung aus —
|
||||
/// im kopflosen Betrieb der richtige Weg, weil ein Eingabefenster dort einen
|
||||
/// Dienst dauerhaft blockieren würde.
|
||||
/// </summary>
|
||||
public Func<string, Task<string>>? TelegramLogin { get; init; }
|
||||
|
||||
public Func<Task<string>>? Telegram2FA { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ergebnis des Aufbaus. <see cref="Host"/> ist null, wenn der Nutzer abgebrochen hat
|
||||
/// oder die Lizenz fehlt — der Aufrufer beendet dann, ohne eine Fehlermeldung
|
||||
/// nachzureichen: Die hat der Torwächter schon gezeigt.
|
||||
/// </summary>
|
||||
public readonly record struct StartupResult(AppHost? Host, string? Error);
|
||||
|
||||
public static async Task<StartupResult> StartAsync(Callbacks callbacks, CancellationToken ct = default)
|
||||
{
|
||||
// ─── 1. Anwendungseinstellungen ───
|
||||
var settings = new SettingsManager();
|
||||
settings.Load();
|
||||
|
||||
// ─── 2. Instanz wählen ───
|
||||
var directories = new InstanceDirectoryManager(
|
||||
Path.GetFullPath(settings.AppSettings.InstancesDirectory));
|
||||
|
||||
var instancePath = await callbacks.SelectInstance(directories);
|
||||
if (string.IsNullOrWhiteSpace(instancePath))
|
||||
return new StartupResult(null, null); // Abbruch, keine Meldung nötig
|
||||
|
||||
InstanceConfig instance;
|
||||
try
|
||||
{
|
||||
instance = directories.LoadInstanceConfig(instancePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new StartupResult(null,
|
||||
$"Fehler beim Laden der Instanz:\n{instancePath}\n\n{ex.Message}");
|
||||
}
|
||||
|
||||
// ─── 3. Protokollierung ───
|
||||
var logDirectory = Path.GetFullPath(
|
||||
!string.IsNullOrWhiteSpace(instance.LogDirectory)
|
||||
? instance.LogDirectory
|
||||
: settings.AppSettings.LogDirectory);
|
||||
|
||||
var minLevel = Enum.TryParse<Core.Logging.LogLevel>(
|
||||
settings.AppSettings.MinimumLogLevel, true, out var parsed)
|
||||
? parsed
|
||||
: Core.Logging.LogLevel.Info;
|
||||
|
||||
var loggerFactory = LoggingExtensions.CreateClawdLoggerFactory(logDirectory, minLevel);
|
||||
var logger = loggerFactory.CreateLogger("ClawdDotNet.Startup");
|
||||
|
||||
logger.LogInformation("ClawdDotNet startet – Instanz: {Instance} ({Id})",
|
||||
instance.InstanceName, instance.InstanceId);
|
||||
logger.LogInformation("Instanz-Verzeichnis: {Path}", instancePath);
|
||||
logger.LogInformation("Einstellungen: {Path}", settings.SettingsPath);
|
||||
|
||||
// ─── 4. Lizenz ───
|
||||
var license = new LicenseGate(settings, loggerFactory.CreateLogger("ClawdDotNet.License"),
|
||||
callbacks.License);
|
||||
|
||||
if (!await license.RunStartupCheckAsync(ct))
|
||||
{
|
||||
logger.LogWarning("Start abgebrochen: keine gültige Lizenz.");
|
||||
loggerFactory.Dispose();
|
||||
return new StartupResult(null, null);
|
||||
}
|
||||
|
||||
// ─── 5. Werkzeuge ───
|
||||
var tools = RegisterTools();
|
||||
|
||||
TelegramClientManager? telegram = null;
|
||||
if (instance.TelegramClient is not null && callbacks.TelegramLogin is not null)
|
||||
{
|
||||
telegram = new TelegramClientManager(instance, instancePath,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Tools.TelegramClient"));
|
||||
|
||||
tools.Register(new TelegramClientTool(telegram));
|
||||
logger.LogInformation("TelegramClient-Tool registriert");
|
||||
}
|
||||
else if (instance.TelegramClient is not null)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"TelegramClient konfiguriert, aber kein Anmeldeweg vorhanden – übersprungen.");
|
||||
}
|
||||
|
||||
var host = BuildCore(settings, directories, instance, instancePath,
|
||||
logDirectory, loggerFactory, tools, telegram, logger);
|
||||
|
||||
// Nichts freizugeben: Der Torwächter nutzt den gemeinsamen HttpClient des SDK.
|
||||
host.License = license;
|
||||
|
||||
if (license.IsEnforcementConfigured)
|
||||
{
|
||||
host.LicenseWatch = new LicenseWatch(
|
||||
license, loggerFactory.CreateLogger("ClawdDotNet.License"));
|
||||
|
||||
host.LicenseWatch.Start();
|
||||
host._shutdown.Add(host.LicenseWatch.DisposeAsync);
|
||||
}
|
||||
|
||||
await host.ConnectTelegramAsync(callbacks, logger);
|
||||
await host.StartDeploymentcenterAsync(loggerFactory, ct);
|
||||
|
||||
return new StartupResult(host, null);
|
||||
}
|
||||
|
||||
// ─── Aufbau ───
|
||||
|
||||
private static ToolRegistry RegisterTools()
|
||||
{
|
||||
var registry = new ToolRegistry();
|
||||
|
||||
registry.Register(new FileRWTool());
|
||||
registry.Register(new TelegramTool());
|
||||
registry.Register(new MailTool());
|
||||
registry.Register(new DatabaseTool());
|
||||
registry.Register(new FTPTool());
|
||||
registry.Register(new DirectApiTool());
|
||||
registry.Register(new WebFetchTool());
|
||||
registry.Register(new WebMonitorTool());
|
||||
registry.Register(new AgentCommTool());
|
||||
registry.Register(new SocialMediaManagerTool());
|
||||
registry.Register(new AgentSpawnTool());
|
||||
registry.Register(new AgentEditorTool());
|
||||
registry.Register(new Tools.Memory.MemoryTool());
|
||||
registry.Register(new Tools.Taskboard.TaskboardTool());
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
private static AppHost BuildCore(
|
||||
SettingsManager settings,
|
||||
InstanceDirectoryManager directories,
|
||||
InstanceConfig instance,
|
||||
string instancePath,
|
||||
string logDirectory,
|
||||
ILoggerFactory loggerFactory,
|
||||
ToolRegistry tools,
|
||||
TelegramClientManager? telegram,
|
||||
ILogger logger)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(instance.OpenRouterApiKey))
|
||||
{
|
||||
logger.LogWarning("Kein OpenRouter API-Key konfiguriert – Agenten sind deaktiviert");
|
||||
|
||||
return new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
TelegramClient = telegram
|
||||
};
|
||||
}
|
||||
|
||||
var openRouter = new OpenRouterClient(instance.OpenRouterApiKey,
|
||||
loggerFactory.CreateLogger("ClawdDotNet.Core.Api.OpenRouterClient"));
|
||||
|
||||
// Eine Datenbank je Instanz; StateStore, Gedächtnis und Taskboard teilen sie sich.
|
||||
var storage = new SqliteStorage(Path.Combine(instancePath, "state.db"));
|
||||
var stateStore = new SqliteStateStore(storage);
|
||||
var memory = new SqliteMemoryRepository(storage);
|
||||
var taskRepository = new SqliteTaskRepository(storage);
|
||||
var audit = new SqliteAuditRepository(storage);
|
||||
var stagingRepository = new SqliteStagingRepository(storage);
|
||||
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
|
||||
var usage = new SqliteUsageRepository(storage);
|
||||
|
||||
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
|
||||
var pricing = new ModelPricingCatalog();
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { pricing.Load(await openRouter.GetAvailableModelsAsync()); }
|
||||
catch { /* Ohne Preise bleibt die Kostengrenze wirkungslos, die Token-Grenze nicht. */ }
|
||||
});
|
||||
|
||||
var engine = new AgentEngine(
|
||||
openRouter, tools, new PermissionGate(), stateStore, loggerFactory,
|
||||
memory, usage, pricing, taskRepository, audit, stagingGate)
|
||||
{
|
||||
InstanceBudget = instance.Budget
|
||||
};
|
||||
|
||||
engine.SetAgentConfigProvider(
|
||||
() => instance.Agents,
|
||||
instance.InstanceId,
|
||||
agentId =>
|
||||
{
|
||||
var agent = instance.Agents.FirstOrDefault(a => a.AgentId == agentId);
|
||||
return string.IsNullOrWhiteSpace(agent?.AgentDir) ? null : agent.AgentDir;
|
||||
});
|
||||
|
||||
engine.LoadPersistedChats();
|
||||
|
||||
var (scanner, staging) = BuildTaskboard(
|
||||
instance, taskRepository, engine, tools, stateStore, audit,
|
||||
stagingRepository, loggerFactory, logger);
|
||||
|
||||
var host = new AppHost
|
||||
{
|
||||
Settings = settings,
|
||||
Directories = directories,
|
||||
Instance = instance,
|
||||
InstancePath = instancePath,
|
||||
LogDirectory = logDirectory,
|
||||
LoggerFactory = loggerFactory,
|
||||
Tools = tools,
|
||||
Engine = engine,
|
||||
Scanner = scanner,
|
||||
Staging = staging,
|
||||
Usage = usage,
|
||||
TelegramClient = telegram,
|
||||
Status = new OpenRouterStatusService(instance.OpenRouterApiKey)
|
||||
};
|
||||
|
||||
host._shutdown.Add(() => { openRouter.Dispose(); return ValueTask.CompletedTask; });
|
||||
|
||||
logger.LogInformation("AgentEngine und Scanner erstellt, Chat-Verläufe geladen");
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Taskboard und Scanner. Der Scanner ist der einzige periodische Treiber (A1) —
|
||||
/// geplante Agentenläufe wie Tool-Job-Polls sind Tasks.
|
||||
/// </summary>
|
||||
private static (TaskScanner?, StagingService?) BuildTaskboard(
|
||||
InstanceConfig instance,
|
||||
SqliteTaskRepository taskRepository,
|
||||
AgentEngine engine,
|
||||
ToolRegistry tools,
|
||||
SqliteStateStore stateStore,
|
||||
SqliteAuditRepository audit,
|
||||
SqliteStagingRepository stagingRepository,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger logger)
|
||||
{
|
||||
var sharedWorkspace = instance.Agents
|
||||
.Select(a => a.SharedWorkspacePath)
|
||||
.FirstOrDefault(p => !string.IsNullOrWhiteSpace(p));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sharedWorkspace))
|
||||
return (null, null);
|
||||
|
||||
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
|
||||
|
||||
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
|
||||
|
||||
var dispatcher = new EngineTaskDispatcher(
|
||||
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
|
||||
|
||||
var scanner = new TaskScanner(taskRepository, dispatcher, loggerFactory);
|
||||
|
||||
// Reconciliation nicht blockierend: Der Start soll nicht auf das Dateisystem warten.
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var reset = await taskRepository.ReleaseStaleClaimsAsync(
|
||||
DateTime.UtcNow.AddMinutes(-15), DateTime.UtcNow, CancellationToken.None);
|
||||
|
||||
var coordination = new CoordinationMigration(
|
||||
board, Path.Combine(sharedWorkspace, "coordination"), loggerFactory);
|
||||
var migratedCoordination = await coordination.RunAsync(CancellationToken.None);
|
||||
|
||||
var scheduler = new SchedulerTaskMigration(board, taskRepository, loggerFactory);
|
||||
var migratedScheduler = await scheduler.RunAsync(instance.Agents, CancellationToken.None);
|
||||
|
||||
var imported = await board.ImportAllAsync(CancellationToken.None);
|
||||
|
||||
logger.LogInformation(
|
||||
"Taskboard bereit: {Imported} Aufgabe(n), migriert {Coord} coordination + "
|
||||
+ "{Sched} scheduler, {Reset} verwaiste Claims zurückgesetzt",
|
||||
imported, migratedCoordination, migratedScheduler, reset);
|
||||
|
||||
scanner.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Taskboard-Reconciliation beim Start fehlgeschlagen");
|
||||
}
|
||||
});
|
||||
|
||||
return (scanner, staging);
|
||||
}
|
||||
|
||||
private async Task ConnectTelegramAsync(Callbacks callbacks, ILogger logger)
|
||||
{
|
||||
if (TelegramClient is null || callbacks.TelegramLogin is null) return;
|
||||
|
||||
TelegramClient.OnLoginCodeRequired += prompt => callbacks.TelegramLogin(prompt);
|
||||
|
||||
if (callbacks.Telegram2FA is not null)
|
||||
TelegramClient.On2FAPasswordRequired += () => callbacks.Telegram2FA();
|
||||
|
||||
try
|
||||
{
|
||||
await TelegramClient.ConnectAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Telegram: Login fehlgeschlagen");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Anbindung ans Deploymentcenter: Instanz-Heartbeat, Fehler-Stream, Bugtracker und
|
||||
/// die einmalige Update-Prüfung.
|
||||
///
|
||||
/// <para>Jede laufende Instanz meldet sich als eigener Monitor — der Server führt
|
||||
/// sie über <c>source</c> + <c>instance</c>. Stürzt eine von mehreren ab, fällt
|
||||
/// genau deren Monitor, und der Evaluator schlägt nur dafür Alarm.</para>
|
||||
/// </summary>
|
||||
private async Task StartDeploymentcenterAsync(ILoggerFactory loggerFactory, CancellationToken ct)
|
||||
{
|
||||
Deploymentcenter = DeploymentcenterService.TryCreate(
|
||||
Settings.AppSettings, AppVersion, loggerFactory);
|
||||
|
||||
if (Deploymentcenter is null)
|
||||
return;
|
||||
|
||||
_shutdown.Add(Deploymentcenter.DisposeAsync);
|
||||
|
||||
var health = new InstanceHealthProvider(
|
||||
Instance.InstanceName,
|
||||
agentsEnabled: Engine is not null,
|
||||
Instance.Budget,
|
||||
Usage,
|
||||
() => Instance.Agents.Count,
|
||||
() => Engine?.RunningChatCount ?? 0,
|
||||
// „Noch nicht gestartet" ist kein Fehler: Der Scanner läuft erst nach der
|
||||
// Startabgleichung los, der erste Heartbeat geht sofort raus.
|
||||
schedulerRunning: Scanner is null ? null : () => !Scanner.HasStopped);
|
||||
|
||||
await Deploymentcenter.StartWatchdogAsync(
|
||||
Instance, health,
|
||||
saveInstanceConfig: () => Directories.SaveInstanceConfig(InstancePath, Instance),
|
||||
ct);
|
||||
|
||||
// Nicht abwarten: Ein langsamer oder stummer Server darf den Start nicht aufhalten.
|
||||
_ = Deploymentcenter.CheckForUpdateAsync(Settings.AppSettings, AppVersion, CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Version, die nach draußen geht: Aktivierungsliste, Heartbeat,
|
||||
/// Fehlermeldungen, Versionsvergleich. Kommt aus <c><Version></c> in
|
||||
/// <c>Directory.Build.props</c> und wird zur Übersetzungszeit eingebettet
|
||||
/// (<c>Deploymentcenter.BuildInfo.targets</c>) — zusammen mit Commit und Build-Datum.
|
||||
///
|
||||
/// <para>Nicht zu verwechseln mit <c>ClawdDotNet.Core.BuildInfo.Build</c>: das ist
|
||||
/// ein von Hand geführter Zähler mit Änderungstext, keine Versionsangabe.</para>
|
||||
/// </summary>
|
||||
public static string AppVersion => ReleaseInfo.Version;
|
||||
|
||||
/// <summary>Version, Commit, Build-Datum und Kanal in einer Zeile — für Anzeigen.</summary>
|
||||
public static string BuildSummary => ReleaseInfo.Summary;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
foreach (var step in _shutdown)
|
||||
{
|
||||
try { await step(); }
|
||||
catch { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
|
||||
}
|
||||
|
||||
if (Status is not null) await Status.DisposeAsync();
|
||||
if (Scanner is not null) await Scanner.DisposeAsync();
|
||||
if (TelegramClient is not null) await TelegramClient.DisposeAsync();
|
||||
|
||||
LoggerFactory.Dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user