Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)
- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch, Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das Budget unsichtbar. - #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt. - #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle, FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700. - #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet. - #593: OpenRouter-Statusdienst wird gestartet. - #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin. - #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr; das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext. - #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen, Tokennutzung); Ereignisstrom in camelCase. - #592: Chat zeigt Nachrichten nicht mehr doppelt. - #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats. - deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file. - Version 0.1.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,11 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
logger.LogInformation("Agent run started: {AgentId}, model={Model}",
|
||||
agentConfig.AgentId, agentConfig.Model);
|
||||
|
||||
// #578: Vor dem try, damit auch die Abbruchzweige wissen, was bis dahin bezahlt
|
||||
// wurde. Vorher lag sie im try, und ein Lauf an der Schrittgrenze ging mit 0 Token
|
||||
// und 0 USD in RunUsage - fuer das Tagesbudget unsichtbar.
|
||||
var tally = new TokenTally();
|
||||
|
||||
try
|
||||
{
|
||||
var tools = _toolRegistry.GetForAgent(agentConfig);
|
||||
@@ -198,7 +203,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
var tally = new TokenTally();
|
||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||
|
||||
while (true)
|
||||
@@ -315,7 +319,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -326,7 +330,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.Cancelled, null,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -338,7 +342,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, null,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -349,7 +353,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.Failed, null,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -441,6 +445,9 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(runCt, timeoutCts.Token);
|
||||
var ct = linkedCts.Token;
|
||||
|
||||
// #578: siehe RunCoreAsync - auch Abbrueche tragen ihre bezahlten Token.
|
||||
var tally = new TokenTally();
|
||||
|
||||
try
|
||||
{
|
||||
var tools = _toolRegistry.GetForAgent(agentConfig);
|
||||
@@ -472,7 +479,6 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
|
||||
string? finalMessage = null;
|
||||
var truncated = false;
|
||||
var tally = new TokenTally();
|
||||
var cachingEnabled = PromptCache.IsEnabledFor(agentConfig.PromptCaching, agentConfig.Model);
|
||||
|
||||
while (true)
|
||||
@@ -587,7 +593,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", cancelMsg, source);
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.Cancelled, cancelMsg,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -599,7 +605,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", limitMsg, source);
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.LoopLimitExceeded, limitMsg,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -611,7 +617,7 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
AddChatEntry(agentConfig.AgentId, "assistant", errorMsg, source);
|
||||
var result = new AgentRunResult(
|
||||
agentConfig.AgentId, AgentRunStatus.Failed, errorMsg,
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex);
|
||||
loopGuard.Steps, loopGuard.Tokens, sw.Elapsed, ex) with { PromptTokens = tally.Prompt, CompletionTokens = tally.Completion, CachedTokens = tally.Cached, ReportedCostUsd = tally.ReportedCost };
|
||||
OnRunCompleted?.Invoke(agentConfig.Model, result);
|
||||
return result;
|
||||
}
|
||||
@@ -1244,6 +1250,18 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
return JsonSerializer.Serialize(ToolResult.Fail($"Tool '{toolName}' not found."));
|
||||
}
|
||||
|
||||
// #581: Unvollstaendiges Argument-JSON gar nicht erst ans Tool reichen. Vorher
|
||||
// bekam das Modell den Parsertext ("Expected depth to be zero...") und
|
||||
// wiederholte denselben Aufruf bis zur Schrittgrenze. Beobachtet bei sehr
|
||||
// grossen Argumenten: Das JSON brach genau vor dem grossen Wert ab.
|
||||
if (!string.IsNullOrWhiteSpace(arguments) && !IstVollstaendigesJson(arguments))
|
||||
{
|
||||
var hinweis = UnvollstaendigeArgumente(toolName, arguments, agentConfig.LoopGuard.MaxResponseTokens);
|
||||
logger.LogWarning("Unvollstaendige Argumente fuer {Tool} ({Laenge} Zeichen)", toolName, arguments.Length);
|
||||
await Audit(AuditStatus.Error, hinweis);
|
||||
return JsonSerializer.Serialize(new { error = hinweis });
|
||||
}
|
||||
|
||||
// Staging-Durchsetzung (A2): irreversible Aktionen werden vorgeschlagen statt
|
||||
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
|
||||
if (_stagingGate is not null)
|
||||
@@ -1332,6 +1350,33 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ob die Argumente eines Werkzeugaufrufs vollstaendiges JSON sind (#581).</summary>
|
||||
internal static bool IstVollstaendigesJson(string arguments)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var _ = JsonDocument.Parse(arguments);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Rueckmeldung ans Modell, wenn Argumente abgeschnitten ankamen (#581): was los
|
||||
/// ist und was stattdessen zu tun ist - nicht der Text eines JSON-Parsers.
|
||||
/// </summary>
|
||||
internal static string UnvollstaendigeArgumente(string toolName, string arguments, int maxResponseTokens)
|
||||
{
|
||||
var ende = arguments.Length <= 40 ? arguments : "..." + arguments[^40..];
|
||||
return $"Die Argumente fuer '{toolName}' sind kein vollstaendiges JSON ({arguments.Length} Zeichen, "
|
||||
+ $"enden mit: {ende}). Meist war ein Argument zu gross fuer eine einzelne Antwort "
|
||||
+ $"(Ausgabegrenze {maxResponseTokens} Tokens) und wurde abgeschnitten. Denselben Aufruf "
|
||||
+ "nicht wiederholen - den Inhalt auf mehrere kleinere Aufrufe verteilen.";
|
||||
}
|
||||
|
||||
private AgentToolContext BuildToolContext(
|
||||
AgentConfig agentConfig, string instanceId, string toolName, CancellationToken ct,
|
||||
DelegationChain? chain = null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
|
||||
namespace ClawdDotNet.Core.Logging;
|
||||
|
||||
@@ -7,6 +8,10 @@ namespace ClawdDotNet.Core.Logging;
|
||||
/// Schreibt Log-Einträge asynchron in datei- und datumsgetrennte Logfiles.
|
||||
/// Struktur: {LogDirectory}/{Datum}/{Modul}.log
|
||||
/// Thread-safe durch ConcurrentQueue + dediziertem Writer-Task.
|
||||
///
|
||||
/// <para>Verzeichnisse <c>0700</c>, Dateien <c>0600</c> (#575): Im Protokoll stehen Pfade,
|
||||
/// Fehlermeldungen von Werkzeugen und Anfänge von Aufträgen. Vorher entstanden sie mit der
|
||||
/// Standardmaske, auf der Test-VM also <c>0775</c>/<c>0664</c>.</para>
|
||||
/// </summary>
|
||||
public sealed class FileLogWriter : IAsyncDisposable
|
||||
{
|
||||
@@ -20,7 +25,7 @@ public sealed class FileLogWriter : IAsyncDisposable
|
||||
public FileLogWriter(FileLoggerOptions options)
|
||||
{
|
||||
_options = options;
|
||||
Directory.CreateDirectory(_options.LogDirectory);
|
||||
AppPaths.EnsureDirectory(_options.LogDirectory);
|
||||
_writerTask = Task.Run(ProcessQueueAsync);
|
||||
}
|
||||
|
||||
@@ -73,7 +78,10 @@ public sealed class FileLogWriter : IAsyncDisposable
|
||||
_options.LogDirectory,
|
||||
entry.Timestamp.ToString(_options.DateFormat));
|
||||
|
||||
Directory.CreateDirectory(dateDir);
|
||||
// Nur beim Anlegen einschränken: EnsureDirectory setzt die Rechte, und das je
|
||||
// Zeile zu tun hiesse einen Systemaufruf je Protokolleintrag.
|
||||
if (!Directory.Exists(dateDir))
|
||||
AppPaths.EnsureDirectory(dateDir);
|
||||
|
||||
var safeModule = SanitizeModuleName(entry.Module);
|
||||
var filePath = Path.Combine(dateDir, $"{safeModule}.log");
|
||||
@@ -83,7 +91,12 @@ public sealed class FileLogWriter : IAsyncDisposable
|
||||
lock (fileLock)
|
||||
{
|
||||
var line = entry.Format(_options.TimestampFormat) + Environment.NewLine;
|
||||
File.AppendAllText(filePath, line, Encoding.UTF8);
|
||||
|
||||
// Wie vorher File.AppendAllText mit UTF-8: Die Präambel steht nur am Anfang
|
||||
// einer neuen Datei, weil der StreamWriter sie nur bei Position 0 schreibt.
|
||||
using var stream = OwnerOnlyFile.OpenAppend(filePath);
|
||||
using var writer = new StreamWriter(stream, Encoding.UTF8);
|
||||
writer.Write(line);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -139,8 +139,13 @@ public static class AtomicFile
|
||||
return (fullPath, tempPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Die Nebendatei ersetzt die Zieldatei — und mit ihr deren Rechte. Deshalb entsteht sie
|
||||
/// unter Unix mit <c>0600</c> (#575); mit der Standardmaske wurde aus einer geschützten
|
||||
/// <c>InstanceSettings.json</c> beim ersten Speichern eine für die Gruppe lesbare.
|
||||
/// </summary>
|
||||
private static FileStream CreateTempStream(string tempPath)
|
||||
=> new(tempPath, FileMode.CreateNew, FileAccess.Write, FileShare.None);
|
||||
=> new(tempPath, OwnerOnlyFile.Options(FileMode.CreateNew, FileAccess.Write, FileShare.None));
|
||||
|
||||
/// <summary>Versuche für das Ersetzen — siehe <see cref="Commit"/>.</summary>
|
||||
private const int CommitAttempts = 20;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace ClawdDotNet.Core.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// Legt Dateien so an, dass sie unter Unix von Anfang an nur dem Besitzer gehören (#575).
|
||||
///
|
||||
/// <para><b>Warum beim Anlegen und nicht hinterher.</b> .NET erzeugt neue Dateien mit
|
||||
/// <c>0666 & ~umask</c> — auf Ubuntu mit der Maske <c>0002</c> also <c>0664</c>. Ein
|
||||
/// nachträgliches <see cref="AppPaths.RestrictToOwner"/> lässt ein Fenster offen und
|
||||
/// wird an jeder neuen Schreibstelle vergessen: <c>InstanceSettings.json</c> ging auf der
|
||||
/// Test-VM beim ersten Speichern von <c>0600</c> auf <c>0664</c>.</para>
|
||||
///
|
||||
/// <para>Unter Windows wirkungslos; dort regelt die Vererbung aus dem Profil die Rechte.</para>
|
||||
/// </summary>
|
||||
public static class OwnerOnlyFile
|
||||
{
|
||||
private const UnixFileMode OwnerReadWrite = UnixFileMode.UserRead | UnixFileMode.UserWrite;
|
||||
|
||||
/// <summary>Öffnungsangaben, die eine neu entstehende Datei auf <c>0600</c> setzen.</summary>
|
||||
public static FileStreamOptions Options(FileMode mode, FileAccess access, FileShare share)
|
||||
{
|
||||
var options = new FileStreamOptions { Mode = mode, Access = access, Share = share };
|
||||
|
||||
if (!OperatingSystem.IsWindows())
|
||||
options.UnixCreateMode = OwnerReadWrite;
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet zum Anhängen. Eine vorhandene Datei behält ihre Rechte — nur was hier neu
|
||||
/// entsteht, gehört dem Besitzer allein.
|
||||
/// </summary>
|
||||
public static FileStream OpenAppend(string path)
|
||||
=> new(path, Options(FileMode.Append, FileAccess.Write, FileShare.Read));
|
||||
}
|
||||
@@ -39,6 +39,16 @@ public sealed class SqliteStorage
|
||||
}.ToString();
|
||||
|
||||
Initialize();
|
||||
|
||||
// #575: SQLite legt die Datenbank mit der Standardmaske an (auf der Test-VM 0644),
|
||||
// und WAL- und Shared-Memory-Datei erben deren Rechte. Darin stehen Chatverläufe,
|
||||
// Gedächtnis und die Argumente jedes Werkzeugaufrufs. Auch für bestehende Dateien,
|
||||
// die noch aus der Zeit vor dieser Zeile stammen.
|
||||
foreach (var datei in new[] { databasePath, databasePath + "-wal", databasePath + "-shm" })
|
||||
{
|
||||
if (File.Exists(datei))
|
||||
AppPaths.RestrictToOwner(datei);
|
||||
}
|
||||
}
|
||||
|
||||
public SqliteConnection OpenConnection()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text;
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Engine;
|
||||
using ClawdDotNet.Core.Scheduling;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Microsoft.Extensions.Logging;
|
||||
@@ -28,6 +29,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
private readonly ToolRegistry _toolRegistry;
|
||||
private readonly IStateStore _stateStore;
|
||||
private readonly ILogger _logger;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public EngineTaskDispatcher(
|
||||
AgentEngine engine,
|
||||
@@ -35,7 +37,8 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
string instanceId,
|
||||
ToolRegistry toolRegistry,
|
||||
IStateStore stateStore,
|
||||
ILoggerFactory loggerFactory)
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeProvider? clock = null)
|
||||
{
|
||||
_engine = engine;
|
||||
_agents = agents;
|
||||
@@ -43,6 +46,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
_toolRegistry = toolRegistry;
|
||||
_stateStore = stateStore;
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Tasks.Dispatcher");
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
|
||||
@@ -148,11 +152,16 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
return agents.Count == 1 ? agents[0] : null;
|
||||
}
|
||||
|
||||
private static string BuildMessage(TaskItem task)
|
||||
internal string BuildMessage(TaskItem task)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"[Aufgabe {task.Id}] {task.Title}");
|
||||
|
||||
// #589: Weder Systemprompt noch Auftrag nannten bisher die Zeit. Fast jede
|
||||
// wiederkehrende Aufgabe braucht sie (Dateiname je Tag, "Bericht ueber gestern"),
|
||||
// und ein Modell ohne diese Zeile raet aus seinem Trainingsstand.
|
||||
sb.AppendLine(Zeitangabe(task, _clock.GetUtcNow()));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(task.Body))
|
||||
{
|
||||
sb.AppendLine();
|
||||
@@ -168,4 +177,33 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Aktuelle Zeit in der Zeitzone der Aufgabe (sonst der des Systems), dazu UTC und -
|
||||
/// bei wiederkehrenden Aufgaben - der Termin selbst.
|
||||
/// </summary>
|
||||
internal static string Zeitangabe(TaskItem task, DateTimeOffset jetzt)
|
||||
{
|
||||
var zoneId = string.IsNullOrWhiteSpace(task.When?.TimeZone) ? TimeZones.LocalIanaId : task.When!.TimeZone;
|
||||
TimeZoneInfo zone;
|
||||
try
|
||||
{
|
||||
zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
|
||||
}
|
||||
catch (Exception ex) when (ex is TimeZoneNotFoundException or InvalidTimeZoneException)
|
||||
{
|
||||
zone = TimeZoneInfo.Local;
|
||||
zoneId = TimeZones.LocalIanaId;
|
||||
}
|
||||
|
||||
var lokal = TimeZoneInfo.ConvertTime(jetzt, zone);
|
||||
var invariant = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var zeile = string.Format(invariant,
|
||||
"Aktuelle Zeit: {0:dd.MM.yyyy HH:mm} Uhr ({1}, UTC{0:zzz}); in UTC {2:yyyy-MM-ddTHH:mm}Z.",
|
||||
lokal, zoneId, jetzt.UtcDateTime);
|
||||
|
||||
return task.When is { } when
|
||||
? zeile + $" Termin der Aufgabe: {when.Kind.ToString().ToLowerInvariant()} \"{when.Value}\"."
|
||||
: zeile;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user