i18n: Englisch als Hauptsprache, Sprachpakete fuer Desktop und WebUI (#661)

Umbau durch Gemini Flash, Durchsicht und Nacharbeit durch Claude.

Umfang:
- Desktop: LocalizationManager (JSON-Pakete, Rueckfall auf Englisch,
  Umschalten zur Laufzeit), Localize-Markup-Erweiterung, Sprachwahl in den
  Einstellungen (AppSettings.Language, Vorgabe "en"); 317 Schluessel je Sprache
- WebUI: alle Ansichten auf t()/data-i18n, 656 Schluessel je Sprache
- Agenten-Werkzeuge: Beschreibungen, Schemata und Meldungen auf Englisch
- Tests an die englischen Meldungen angepasst, keine Pruefung abgeschwaecht

Nacharbeit bei der Durchsicht:
- ~25 Kommentarbloecke mit Begruendungen waren ersatzlos entfallen, darunter
  sicherheitsrelevante (NTFS-Datenstrom beim shared:-Praefix, Bremse gegen
  Agentenschleifen, doppelter POST beim Anlegen einer Instanz). Wieder
  eingesetzt: auf Englisch, wo die Datei umgestellt ist, sonst im Wortlaut.
- Drei WebUI-Schluessel fehlten (logs.paused, instances.instanceOfflineError,
  instances.noInstanceActiveError). Weil t() bei fehlendem Schluessel den
  Schluessel selbst liefert, griff der "|| err.message"-Rueckfall nie — die
  Oberflaeche haette den Schluesselnamen angezeigt.

Geprueft: Schluesselgleichheit en/de, Platzhalter je Schluessel, jeder im
Code benutzte Schluessel vorhanden, node --check fuer alle Module,
1.220 Tests gruen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-24 09:43:51 +02:00
co-authored by Claude Opus 5.5
parent 69718122f3
commit ad71682c2d
97 changed files with 4103 additions and 1928 deletions
@@ -103,7 +103,7 @@ internal sealed class RocketChatClient(RocketChatConfig config)
catch (JsonException)
{
throw new RocketChatException(
$"Rocket.Chat antwortete mit {(int)response.StatusCode} und keinem JSON: " +
$"Rocket.Chat responded with {(int)response.StatusCode} and non-JSON content: " +
Truncate(raw, 200));
}
@@ -116,7 +116,7 @@ internal sealed class RocketChatClient(RocketChatConfig config)
: json.TryGetProperty("message", out var m) ? m.GetString()
: $"HTTP {(int)response.StatusCode}";
throw new RocketChatException(message ?? "Unbekannter Fehler", errorType);
throw new RocketChatException(message ?? "Unknown error", errorType);
}
/// <summary>
@@ -203,7 +203,7 @@ internal sealed class RocketChatClient(RocketChatConfig config)
result.Add(new RoomMessage(
Id: id,
Timestamp: GetDate(m, "ts"),
Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unbekannt",
Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unknown",
UserId: (user.ValueKind == JsonValueKind.Object ? GetString(user, "_id") : null) ?? "",
Text: GetString(m, "msg") ?? "",
Mentions: ReadMentions(m),
@@ -290,20 +290,20 @@ internal sealed class RocketChatClient(RocketChatConfig config)
catch (JsonException)
{
throw new RocketChatException(
$"Upload scheiterte mit {(int)response.StatusCode}: {Truncate(raw, 200)}"
$"Upload failed with {(int)response.StatusCode}: {Truncate(raw, 200)}"
+ (response.StatusCode == System.Net.HttpStatusCode.NotFound
? " — kennt diese Rocket.Chat-Version den Endpunkt rooms.media?"
? " — does this Rocket.Chat version support the rooms.media endpoint?"
: ""));
}
if (!upload.TryGetProperty("success", out var ok) || ok.ValueKind != JsonValueKind.True)
throw new RocketChatException(
(upload.TryGetProperty("error", out var e) ? e.GetString() : null)
?? "Upload fehlgeschlagen");
?? "Upload failed");
var fileId = upload.TryGetProperty("file", out var f) ? GetString(f, "_id") : null;
if (string.IsNullOrEmpty(fileId))
throw new RocketChatException("Upload lieferte keine Datei-Id zurück.");
throw new RocketChatException("Upload did not return a file ID.");
// Schritt 2 — ohne ihn liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
object confirmBody = threadId is null
@@ -402,14 +402,14 @@ internal sealed class RocketChatClient(RocketChatConfig config)
throw new RocketChatException(response.StatusCode switch
{
System.Net.HttpStatusCode.Forbidden =>
"Zugriff auf die Datei verweigert — der Agent ist vermutlich kein Mitglied des Raums.",
System.Net.HttpStatusCode.NotFound => $"Keine Datei mit der Id {fileId}.",
_ => $"Datei konnte nicht geladen werden (HTTP {(int)response.StatusCode})."
"Access to the file denied — the agent is likely not a member of the room.",
System.Net.HttpStatusCode.NotFound => $"No file found with ID {fileId}.",
_ => $"Could not download file (HTTP {(int)response.StatusCode})."
});
if (response.Content.Headers.ContentLength is { } declared && declared > maxBytes)
throw new RocketChatException(
$"Die Datei ist {declared / 1024 / 1024} MB groß und überschreitet die Grenze von "
$"The file is {declared / 1024 / 1024} MB in size and exceeds the limit of "
+ $"{maxBytes / 1024 / 1024} MB.");
await using var source = await response.Content.ReadAsStreamAsync(ct);
@@ -432,7 +432,7 @@ internal sealed class RocketChatClient(RocketChatConfig config)
// Auch ohne Content-Length darf die Grenze nicht überschritten werden.
if (written > maxBytes)
throw new RocketChatException(
$"Die Datei überschreitet die Grenze von {maxBytes / 1024 / 1024} MB.");
$"The file exceeds the limit of {maxBytes / 1024 / 1024} MB.");
await target.WriteAsync(buffer.AsMemory(0, read), ct);
}
@@ -34,19 +34,19 @@ internal sealed record RocketChatConfig(
{
var baseUrl = GetString(raw, "baseUrl")?.TrimEnd('/');
if (string.IsNullOrWhiteSpace(baseUrl))
return (null, "Konfigurationsfehler: 'baseUrl' fehlt (z. B. \"https://chat.example.org\").");
return (null, "Configuration error: 'baseUrl' is missing (e.g. \"https://chat.example.org\").");
if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp))
return (null, $"Konfigurationsfehler: 'baseUrl' ist keine gültige http(s)-Adresse: {baseUrl}");
return (null, $"Configuration error: 'baseUrl' is not a valid http(s) address: {baseUrl}");
var userId = GetString(raw, "userId");
if (string.IsNullOrWhiteSpace(userId))
return (null, "Konfigurationsfehler: 'userId' fehlt (die Rocket.Chat-Benutzer-Id dieses Agenten).");
return (null, "Configuration error: 'userId' is missing (the Rocket.Chat user ID of this agent).");
var authToken = GetString(raw, "authToken");
if (string.IsNullOrWhiteSpace(authToken))
return (null, "Konfigurationsfehler: 'authToken' fehlt (Zugriffstoken des Agenten-Benutzers).");
return (null, "Configuration error: 'authToken' is missing (access token of the agent user).");
return (new RocketChatConfig(
BaseUrl: baseUrl,
+117 -124
View File
@@ -8,26 +8,26 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.RocketChat;
/// <summary>
/// Anbindung an eine selbstgehostete Rocket.Chat-Instanz. Jeder Agent tritt mit einem
/// eigenen Benutzer auf — dadurch funktionieren Direktnachrichten und Erwähnungen echt
/// und nicht als Alias eines Sammelkontos.
/// Connection to a self-hosted Rocket.Chat instance. Each agent appears with a
/// dedicated user — this allows direct messages and mentions to work natively
/// rather than as aliases of a shared account.
///
/// Der Rückkanal läuft über <see cref="IToolJobProvider"/>: Ein Takt prüft ohne Modell
/// (und damit kostenlos), ob etwas anliegt, und weckt den Agenten nur dann.
/// The reverse channel runs via <see cref="IToolJobProvider"/>: A scheduled tick checks
/// without a model (and thus cost-free) whether anything is pending, waking the agent only then.
/// </summary>
public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
public string Name => "RocketChat";
public string Description => """
Kommunikation über Rocket.Chat: Nachrichten in Kanälen, privaten Gruppen und
Direktnachrichten lesen und schreiben.
Communication via Rocket.Chat: read and write messages in channels, private groups, and
direct messages.
Räume werden per Id ("GENERAL") oder Name ("#team") angegeben; list_rooms zeigt,
wo dieser Agent Mitglied ist. Mit "@benutzername" wird eine Direktnachricht
geschrieben — auch an jemanden, mit dem noch kein Gespräch besteht.
Rooms are specified by ID ("GENERAL") or name ("#team"); list_rooms shows where this
agent is a member. Use "@username" to write a direct message — even to someone with
whom no conversation exists yet.
Aktionen: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
Actions: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
@@ -38,35 +38,35 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
"action": {
"type": "string",
"enum": ["send_message", "reply", "send_file", "download_file", "list_rooms", "read_room", "mark_read"],
"description": "send_message: Nachricht in einen Raum senden. reply: Antwort im Thread einer Nachricht. send_file: eine Datei aus dem Workspace in einen Raum schicken. download_file: eine empfangene Datei in den Workspace holen. list_rooms: eigene Räume mit Ungelesen-Zählern auflisten. read_room: letzte Nachrichten eines Raums lesen. mark_read: Raum als gelesen markieren."
"description": "send_message: Send a message to a room. reply: Reply in a message thread. send_file: Send a file from the workspace to a room. download_file: Download a received file into the workspace. list_rooms: List own rooms with unread counters. read_room: Read recent messages in a room. mark_read: Mark a room as read."
},
"room": {
"type": "string",
"description": "Raum-Id oder -Name (z. B. \"GENERAL\", \"#team\", \"@person\"). Entfällt bei list_rooms; ohne Angabe gilt der konfigurierte Standardraum."
"description": "Room ID or name (e.g. \"GENERAL\", \"#team\", \"@person\"). Omit for list_rooms; if not specified, the configured default room is used."
},
"text": {
"type": "string",
"description": "Nachrichtentext (für send_message und reply)."
"description": "Message text (for send_message and reply)."
},
"threadId": {
"type": "string",
"description": "Id der Nachricht, auf die geantwortet wird (nur für reply)."
"description": "ID of the message to reply to (only for reply)."
},
"localPath": {
"type": "string",
"description": "Pfad der zu sendenden Datei im Workspace (nur für send_file): \"bericht.pdf\" oder \"personal:bericht.pdf\" im eigenen Workspace, \"shared:auswertung.csv\" im geteilten."
"description": "Path of the file to send in the workspace (only for send_file): \"report.pdf\" or \"personal:report.pdf\" in personal workspace, \"shared:analysis.csv\" in shared workspace."
},
"filename": {
"type": "string",
"description": "Abweichender Dateiname für den Empfänger (nur für send_file). Ohne Angabe gilt der Name im Workspace."
"description": "Different filename for the recipient (only for send_file). If omitted, the name in the workspace is used."
},
"fileId": {
"type": "string",
"description": "Id einer empfangenen Datei (nur für download_file). Steht in der Nachricht als \"fileId: …\"."
"description": "ID of a received file (only for download_file). Shown in the message as \"fileId: …\"."
},
"limit": {
"type": "integer",
"description": "Anzahl der zu lesenden Nachrichten bei read_room (Standard 20, höchstens 100)."
"description": "Number of messages to read in read_room (default 20, max 100)."
}
}
}
@@ -80,14 +80,14 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
if (!input.TryGetProperty("action", out var actionElement)
|| actionElement.GetString() is not { } action)
return ToolResult.Fail(
"'action' ist erforderlich. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.");
"'action' is required. Valid: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.");
var (config, configError) = RocketChatConfig.Read(context.ToolConfig);
if (config is null)
return ToolResult.Fail(configError!);
var client = new RocketChatClient(config);
context.Logger.LogInformation("RocketChat: Aktion {Action}", action);
context.Logger.LogInformation("RocketChat: Action {Action}", action);
try
{
@@ -101,22 +101,22 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
"read_room" => await ReadRoomAsync(client, config, input, ct),
"mark_read" => await MarkReadAsync(client, config, input, ct),
_ => ToolResult.Fail(
$"Unbekannte Aktion '{action}'. Gültig: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.")
$"Unknown action '{action}'. Valid: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.")
};
}
catch (RocketChatException ex)
{
context.Logger.LogWarning("RocketChat meldet einen Fehler bei {Action}: {Message}", action, ex.Message);
context.Logger.LogWarning("RocketChat reports an error during {Action}: {Message}", action, ex.Message);
return ToolResult.Fail($"Rocket.Chat: {ex.Message}");
}
catch (HttpRequestException ex)
{
context.Logger.LogWarning(ex, "Rocket.Chat ist nicht erreichbar");
return ToolResult.Fail($"Rocket.Chat ist nicht erreichbar: {ex.Message}");
context.Logger.LogWarning(ex, "Rocket.Chat is unreachable");
return ToolResult.Fail($"Rocket.Chat is unreachable: {ex.Message}");
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return ToolResult.Fail("Rocket.Chat hat nicht rechtzeitig geantwortet (Zeitüberschreitung).");
return ToolResult.Fail("Rocket.Chat did not respond in time (timed out).");
}
}
@@ -126,11 +126,11 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
var text = GetString(input, "text");
if (string.IsNullOrWhiteSpace(text))
return ToolResult.Fail("'text' ist erforderlich.");
return ToolResult.Fail("'text' is required.");
var threadId = threadAllowed ? GetString(input, "threadId") : null;
if (threadAllowed && string.IsNullOrWhiteSpace(threadId))
return ToolResult.Fail("'threadId' ist für reply erforderlich (Id der Nachricht, auf die geantwortet wird).");
return ToolResult.Fail("'threadId' is required for reply (ID of the message to reply to).");
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
@@ -150,8 +150,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
}
/// <summary>
/// Schickt eine Datei aus dem Workspace des Agenten in einen Raum — der kurze Weg für
/// „stell mir das zusammen und schick es rüber", ohne Umweg über die Cloud.
/// Sends a file from the agent's workspace to a room.
/// </summary>
private static async Task<ToolResult> SendFileAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input,
@@ -160,8 +159,8 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
var localPath = GetString(input, "localPath");
if (string.IsNullOrWhiteSpace(localPath))
return ToolResult.Fail(
"'localPath' ist erforderlich — z. B. \"bericht.pdf\" (persönlicher Workspace) "
+ "oder \"shared:auswertung.csv\" (geteilter Workspace).");
"'localPath' is required — e.g. \"report.pdf\" (personal workspace) "
+ "or \"shared:analysis.csv\" (shared workspace).");
var (file, pathError) = WorkspaceFile.Resolve(
localPath, context.WorkspacePath, context.SharedWorkspacePath);
@@ -169,16 +168,16 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
return ToolResult.Fail(pathError!);
if (!File.Exists(file.FullPath))
return ToolResult.Fail($"Datei nicht gefunden: {localPath}");
return ToolResult.Fail($"File not found: {localPath}");
var info = new FileInfo(file.FullPath);
if (info.Length == 0)
return ToolResult.Fail($"Die Datei {localPath} ist leer.");
return ToolResult.Fail($"The file {localPath} is empty.");
if (info.Length > config.MaxUploadBytes)
return ToolResult.Fail(
$"Die Datei ist {info.Length / 1024 / 1024} MB groß und überschreitet die Grenze von "
+ $"{config.MaxUploadBytes / 1024 / 1024} MB. Für Größeres eignet sich die Cloud-Ablage.");
$"The file is {info.Length / 1024 / 1024} MB in size and exceeds the limit of "
+ $"{config.MaxUploadBytes / 1024 / 1024} MB. For larger files, use cloud storage.");
var rooms = await client.GetRoomsAsync(ct);
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
@@ -208,8 +207,8 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
}
/// <summary>
/// Holt eine angehängte Datei in den Workspace des Agenten — das Gegenstück zu
/// <c>send_file</c>. Die <c>fileId</c> steht in der Weckmeldung bzw. in read_room.
/// Downloads an attached file into the agent's workspace — the counterpart of
/// <c>send_file</c>. The <c>fileId</c> is in the wake message or in read_room.
/// </summary>
private static async Task<ToolResult> DownloadFileAsync(
RocketChatClient client, RocketChatConfig config, JsonElement input,
@@ -218,18 +217,18 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
var fileId = GetString(input, "fileId");
if (string.IsNullOrWhiteSpace(fileId))
return ToolResult.Fail(
"'fileId' ist erforderlich — sie steht bei jeder Datei in der Nachricht "
+ "(\"Datei: … — fileId: …\").");
"'fileId' is required — it is indicated for each file in the message "
+ "(\"File: … — fileId: …\").");
// Zielname: Angabe des Modells, sonst der Name aus der Nachricht. Beides ist
// fremdbestimmt, deshalb wird der reine Dateiname genommen und jede Pfadangabe
// darin verworfen, bevor überhaupt aufgelöst wird.
// Target name: the model's choice, otherwise the name from the message. Both are
// controlled by someone else, so only the bare file name is taken and any path in
// it is discarded before anything is resolved.
var wanted = GetString(input, "localPath") ?? GetString(input, "filename") ?? fileId;
// Den Workspace-Präfix zuerst abtrennen: Auf Windows ist ':' kein Pfadtrenner,
// sondern der Marker für NTFS-Alternativdatenströme. Bliebe "shared:" im Namen,
// schriebe Path.GetFileName ihn unverändert durch — und die Datei landete als
// Datenstrom am Verzeichnis statt als eigene Datei.
// Strip the workspace prefix first: on Windows ':' is not a path separator but the
// marker for NTFS alternate data streams. If "shared:" stayed in the name,
// Path.GetFileName would pass it through unchanged — and the file would end up as
// a data stream on the directory instead of a file of its own.
var prefix = "";
var rest = wanted;
if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
@@ -244,16 +243,16 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
var targetName = Path.GetFileName(rest.Replace('\\', '/'));
if (string.IsNullOrWhiteSpace(targetName))
return ToolResult.Fail($"'{wanted}' ergibt keinen gültigen Dateinamen.");
return ToolResult.Fail($"'{wanted}' does not result in a valid filename.");
var extension = Path.GetExtension(targetName);
if (!config.AllowDangerousDownloads && DangerousExtensions.Contains(extension))
return ToolResult.Fail(
$"Dateien vom Typ '{extension}' werden nicht heruntergeladen. "
+ "Bei Bedarf 'allowDangerousDownloads' in der Tool-Konfiguration setzen.");
$"Files of type '{extension}' are not downloaded. "
+ "If needed, set 'allowDangerousDownloads' in the tool configuration.");
// Das Verzeichnis darf das Modell wählen (personal:/shared:), den Dateinamen nicht
// frei zusammensetzen — deshalb Präfix und bereinigter Name getrennt.
// The model may choose the directory (personal:/shared:), but not compose the file
// name freely — hence prefix and sanitized name are kept separate.
var (file, pathError) = WorkspaceFile.Resolve(
prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
if (file is null)
@@ -274,8 +273,8 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
}
/// <summary>
/// Endungen, die nicht ungefragt im Workspace landen sollen. Die Datei kommt von
/// außen — der Dateiname ist eine Behauptung des Absenders, kein Befund.
/// Extensions that should not be placed into the workspace without confirmation. The file
/// comes from outside — its name is the sender's claim, not a finding.
/// </summary>
private static readonly HashSet<string> DangerousExtensions =
new(StringComparer.OrdinalIgnoreCase)
@@ -351,7 +350,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
}
});
// Fremder Text — als Daten gerahmt, nicht als Anweisung (K2).
// Untrusted text — framed as data, not as instructions (K2).
return ToolResult.Ok(Untrusted(payload));
}
@@ -364,7 +363,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
return ToolResult.Fail(error!);
await client.MarkReadAsync(room.RoomId, ct);
return ToolResult.Ok($"Raum {room.Label} als gelesen markiert.");
return ToolResult.Ok($"Room {room.Label} marked as read.");
}
/// <summary>
@@ -378,20 +377,19 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
var wanted = GetString(input, "room") ?? config.DefaultRoom;
if (string.IsNullOrWhiteSpace(wanted))
return (null, "'room' ist erforderlich (kein Standardraum konfiguriert). "
return (null, "'room' is required (no default room configured). "
+ Available(rooms));
var room = RocketChatClient.Resolve(rooms, wanted);
// Noch kein Gespräch mit dieser Person: Ein DM-Raum entsteht erst, wenn ihn jemand
// eröffnet. Nur bei ausdrücklicher @-Schreibweise — sonst würde ein vertippter
// Kanalname stillschweigend zu einer Direktnachricht.
// No conversation with this person yet: A DM room is created only when someone opens it.
// Only with explicit @ notation — otherwise a mistyped channel name would quietly become a DM.
if (room is null && wanted.StartsWith('@'))
{
var username = wanted[1..];
if (!config.IsRoomAllowed(username))
return (null, $"Direktnachrichten an '{username}' sind für diesen Agenten nicht "
+ "freigegeben (siehe 'allowedRooms' in der Tool-Konfiguration).");
return (null, $"Direct messages to '{username}' are not allowed for this agent "
+ "(see 'allowedRooms' in tool configuration).");
try
{
@@ -399,38 +397,38 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
}
catch (RocketChatException ex)
{
return (null, $"Direktnachricht an '{username}' nicht möglich: {ex.Message}");
return (null, $"Direct message to '{username}' not possible: {ex.Message}");
}
if (room is null)
return (null, $"Direktnachricht an '{username}' konnte nicht geöffnet werden.");
return (null, $"Could not open direct message to '{username}'.");
return (room, null);
}
if (room is null)
return (null, $"Kein Raum '{wanted}' — dieser Agent ist dort kein Mitglied. " + Available(rooms)
+ " Für eine neue Direktnachricht '@benutzername' angeben.");
return (null, $"No room '{wanted}' — this agent is not a member there. " + Available(rooms)
+ " For a new direct message, specify '@username'.");
// Gegen beide Schreibweisen prüfen: Die Allowlist darf Ids oder Namen enthalten.
// Check against both notations: Allowlist can contain IDs or names.
if (!config.IsRoomAllowed(room.RoomId) && !config.IsRoomAllowed(room.Name))
return (null, $"Zugriff auf Raum '{wanted}' ist für diesen Agenten nicht freigegeben "
+ "(siehe 'allowedRooms' in der Tool-Konfiguration).");
return (null, $"Access to room '{wanted}' is not allowed for this agent "
+ "(see 'allowedRooms' in tool configuration).");
return (room, null);
}
private static string Available(IReadOnlyList<RoomSubscription> rooms)
=> rooms.Count == 0
? "Der Agent ist in keinem Raum Mitglied."
: "Verfügbar: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})"));
? "The agent is not a member of any room."
: "Available: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})"));
// ─── IToolJobProvider ───
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
[
new("rocketchat_poll", "Rocket.Chat Abruf",
"Prüft auf neue Erwähnungen und Direktnachrichten und weckt den Agenten nur, wenn etwas anliegt.")
new("rocketchat_poll", "Rocket.Chat Poll",
"Checks for new mentions and direct messages and only wakes the agent when something is pending.")
];
public async Task<ToolJobResult> ExecuteJobAsync(
@@ -443,7 +441,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
string? workspacePath = null)
{
if (jobTypeId != "rocketchat_poll")
return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");
return ToolJobResult.NoAction($"Unknown job type: {jobTypeId}");
var (config, configError) = RocketChatConfig.Read(toolConfig);
if (config is null)
@@ -456,20 +454,20 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
{
var rooms = await client.GetRoomsAsync(ct);
// Vorfilter aus dem Sammelabruf: Nur Räume mit etwas Ungelesenem sind
// überhaupt einen zweiten Aufruf wert.
// Pre-filter from the bulk call: only rooms with something unread are worth a
// second request at all.
var candidates = rooms.Where(r => r.Unread > 0 || r.Alert).ToList();
logger.LogDebug(
"RocketChat: mentionOnly={MentionOnly}; Räume: {Rooms}",
"RocketChat: mentionOnly={MentionOnly}; Rooms: {Rooms}",
config.MentionOnly,
string.Join(" | ", rooms.Select(r =>
$"{r.Label} t={r.Type} unread={r.Unread} mentions={r.UserMentions}/{r.GroupMentions} alert={r.Alert}")));
// Without this rule, in a room with three agents all three answer every
// message. Direct messages are always meant for the agent.
if (config.MentionOnly)
{
// Ohne diese Regel antworten in einem Raum mit drei Agenten alle drei auf
// jede Nachricht. Direktnachrichten sind immer gemeint.
candidates = candidates
.Where(r => r.IsDirect || r.UserMentions > 0 || r.GroupMentions > 0)
.ToList();
@@ -480,7 +478,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
.ToList();
if (candidates.Count == 0)
return ToolJobResult.NoAction("Nichts Neues");
return ToolJobResult.NoAction("Nothing new");
var blocks = new List<string>();
var total = 0;
@@ -496,49 +494,48 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
blocks.Add(FormatRoom(room, messages));
}
// Jeden geprüften Raum als gelesen markieren — auch wenn nichts zu wecken
// war. Sonst bliebe eine alte, nie gelesene Erwähnung stehen und der Raum
// wäre bei jedem Takt erneut ein Kandidat.
// Mark every checked room as read — even if there was nothing to wake for.
// Otherwise an old, never-read mention would linger and the room would be a
// candidate again on every tick.
try
{
await client.MarkReadAsync(room.RoomId, ct);
}
catch (RocketChatException ex)
{
logger.LogWarning("RocketChat: Raum {Room} konnte nicht als gelesen markiert werden: {Message}",
logger.LogWarning("RocketChat: Room {Room} could not be marked as read: {Message}",
room.Label, ex.Message);
}
}
if (blocks.Count == 0)
return ToolJobResult.NoAction("Nichts Neues (nach Filterung)");
return ToolJobResult.NoAction("Nothing new (after filtering)");
logger.LogInformation("RocketChat: {Count} neue Nachricht(en) in {Rooms} Raum/Räumen",
logger.LogInformation("RocketChat: {Count} new message(s) in {Rooms} room(s)",
total, blocks.Count);
return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} neue Nachricht(en)");
return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} new message(s)");
}
catch (RocketChatException ex)
{
logger.LogWarning("RocketChat-Abruf fehlgeschlagen: {Message}", ex.Message);
return ToolJobResult.NoAction($"Abruf-Fehler: {ex.Message}");
logger.LogWarning("RocketChat poll failed: {Message}", ex.Message);
return ToolJobResult.NoAction($"Poll error: {ex.Message}");
}
catch (HttpRequestException ex)
{
logger.LogWarning("Rocket.Chat ist nicht erreichbar: {Message}", ex.Message);
return ToolJobResult.NoAction($"Nicht erreichbar: {ex.Message}");
logger.LogWarning("Rocket.Chat is unreachable: {Message}", ex.Message);
return ToolJobResult.NoAction($"Unreachable: {ex.Message}");
}
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
{
return ToolJobResult.NoAction("Zeitüberschreitung beim Abruf");
return ToolJobResult.NoAction("Poll timed out");
}
}
/// <summary>
/// Holt die neuen Nachrichten eines Raums und filtert alles heraus, was keinen Agenten
/// wecken darf: eigene Beiträge, Systemmeldungen und — sofern nicht namentlich
/// erwähnt — Beiträge anderer Agenten. Der letzte Punkt ist die Bremse gegen
/// Agentenschleifen: Ohne ihn antwortet A auf B auf A.
/// Fetches new messages for a room and filters out everything that should not wake an agent:
/// own posts, system messages, and (unless mentioned by name) posts by other agents.
/// The last point is the brake against agent loops: without it, A answers B answers A.
/// </summary>
private static async Task<IReadOnlyList<RoomMessage>> CollectNewMessagesAsync(
RocketChatClient client, RocketChatConfig config, IStateStore stateStore,
@@ -550,14 +547,13 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
DateTime since;
if (string.IsNullOrWhiteSpace(markerRaw))
{
// Erster Abruf für diesen Raum. Nicht überspringen — ein Raum wird erst dann
// zum Kandidaten, wenn Verkehr da ist, und genau die auslösende Nachricht
// würde sonst verschluckt. Stattdessen begrenzt zurückschauen: neu genug, um
// das Aktuelle zu erwischen, kurz genug, um beim Einrichten nicht die halbe
// Raumgeschichte in den Kontext zu kippen.
// First poll for this room. Do not skip it — a room only becomes a candidate once
// there is traffic, and exactly the triggering message would otherwise be
// swallowed. Instead look back for a limited time: recent enough to catch what is
// current, short enough not to dump half the room's history into the context on setup.
since = DateTime.UtcNow - config.InitialLookback;
logger.LogInformation(
"RocketChat: erster Abruf für {Room} — {Minutes} Minuten Rückschau",
"RocketChat: first poll for {Room} — {Minutes} minutes lookback",
room.Label, config.InitialLookback.TotalMinutes);
}
else
@@ -572,41 +568,38 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
if (history.Count == 0)
return [];
// Die Marke wandert auf die jüngste *gesehene* Nachricht — auch auf gefilterte.
// Sonst würde eine ignorierte Agentennachricht bei jedem Takt erneut geprüft.
// The marker moves to the newest *seen* message — including filtered ones.
// Otherwise an ignored agent message would be checked again on every tick.
await stateStore.SetAsync(markerKey, history[^1].Timestamp.ToString("O"), ct);
// The mention check belongs to the individual message, not the room: the counter from
// subscriptions.get counts *unread* mentions and stays put as long as nothing is read.
// A single old mention would otherwise keep the room permanently "hot" and deliver
// every further message.
return history
.Where(m => !m.IsSystem)
.Where(m => m.UserId != config.UserId)
// Die Erwähnungsprüfung gehört an die einzelne Nachricht, nicht an den Raum:
// Der Zähler aus subscriptions.get zählt *ungelesene* Erwähnungen und bleibt
// stehen, solange nichts gelesen wurde. Eine einzige alte Erwähnung würde den
// Raum sonst dauerhaft „heiß" halten und jede weitere Nachricht ausliefern.
.Where(m => !config.MentionOnly || room.IsDirect || IsMentioned(m, config))
.Where(m => !config.IsAgentUser(m.Username) || IsMentioned(m, config))
.ToList();
}
/// <summary>
/// Erwähnungen liefert Rocket.Chat als Benutzernamen — verglichen wird deshalb gegen
/// <c>username</c>, nicht gegen die Benutzer-Id.
/// Rocket.Chat returns mentions as usernames — comparison is made against username, not user ID.
///
/// Ist kein Benutzername konfiguriert, gilt eine Nachricht als nicht erwähnt. Das ist
/// die vorsichtige Richtung: Im Zweifel weckt die Nachricht eines anderen Agenten
/// diesen hier nicht, statt eine Antwortschleife zu riskieren.
/// If no username is configured, a message counts as not mentioned. That is the cautious
/// direction: when in doubt, another agent's message does not wake this one, rather than
/// risking a reply loop.
/// </summary>
private static bool IsMentioned(RoomMessage message, RocketChatConfig config)
=> message.Mentions.Any(m =>
// @all und @here meinen ausdrücklich auch diesen Agenten.
string.Equals(m, "all", StringComparison.OrdinalIgnoreCase)
|| string.Equals(m, "here", StringComparison.OrdinalIgnoreCase)
|| (!string.IsNullOrWhiteSpace(config.Username)
&& string.Equals(m, config.Username, StringComparison.OrdinalIgnoreCase)));
/// <summary>
/// Deckelt, wie oft ein Raum den Agenten je Stunde wecken darf. Jeder Weckvorgang ist
/// ein bezahlter Lauf; ohne Deckel wird ein lebhafter Raum zur Kostenfalle.
/// Limits how often a room can wake the agent per hour.
/// </summary>
private static async Task<bool> TryConsumeWakeBudgetAsync(
IStateStore stateStore, string agent, RoomSubscription room,
@@ -622,7 +615,7 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
if (used >= config.MaxWakesPerRoomPerHour)
{
logger.LogWarning(
"RocketChat: Weckgrenze für {Room} erreicht ({Max}/Stunde) — Nachrichten bleiben ungelesen",
"RocketChat: Wake limit for {Room} reached ({Max}/hour) — messages remain unread",
room.Label, config.MaxWakesPerRoomPerHour);
return false;
}
@@ -634,16 +627,16 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
private static string FormatRoom(RoomSubscription room, IReadOnlyList<RoomMessage> messages)
{
var sb = new StringBuilder();
sb.AppendLine($"Raum: {room.Label} (roomId: {room.RoomId})");
sb.AppendLine($"Room: {room.Label} (roomId: {room.RoomId})");
foreach (var m in messages)
{
sb.AppendLine($"[{m.Timestamp:HH:mm}] @{m.Username} (messageId: {m.Id}): {m.Text}");
// Ohne diese Zeilen sähe der Agent bei einer reinen Dateisendung nur einen
// leeren Beitrag — die Datei liegt nicht im Text, sondern daneben.
// Without these lines the agent would see only an empty post for a pure file
// upload — the file is not in the text but next to it.
foreach (var f in m.Files)
sb.AppendLine($" Datei: {f.Describe()}");
sb.AppendLine($" File: {f.Describe()}");
foreach (var l in m.Links)
sb.AppendLine($" Link: {l.Url}"
@@ -661,12 +654,12 @@ public sealed class RocketChatTool : IAgentTool, IToolJobProvider
private static string BuildWakeMessage(IReadOnlyList<string> blocks)
=> $"""
Neue Nachrichten in Rocket.Chat.
New messages in Rocket.Chat.
{Untrusted(string.Join("\n\n", blocks))}
Wenn du antworten möchtest, nutze das Tool RocketChat mit der roomId aus dem
jeweiligen Block (send_message), oder reply mit der messageId als threadId.
If you want to reply, use the RocketChat tool with the roomId from the
respective block (send_message), or reply with the messageId as threadId.
""";
private static string Untrusted(string content)
@@ -40,16 +40,16 @@ internal static class WorkspaceFile
}
if (string.IsNullOrWhiteSpace(root))
return (null, $"Kein {label}-Workspace konfiguriert.");
return (null, $"No {label} workspace configured.");
if (string.IsNullOrWhiteSpace(relative))
return (null, "Es wurde kein Dateiname angegeben.");
return (null, "No file name was specified.");
// Auf Windows trennt ':' einen NTFS-Alternativdatenstrom ab. Ein Pfad wie
// "bericht.txt:versteckt" schreibt dann neben die sichtbare Datei, und die
// Verzeichnisprüfung unten würde ihn trotzdem durchwinken.
if (relative.Contains(':'))
return (null, "Doppelpunkte sind im Pfad nicht erlaubt.");
return (null, "Colons are not allowed in the path.");
var basePath = Path.GetFullPath(root);
@@ -60,7 +60,7 @@ internal static class WorkspaceFile
}
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
{
return (null, $"Ungültiger Pfad: {path}");
return (null, $"Invalid path: {path}");
}
// Der abschließende Trenner verhindert, dass "…/workspace-geheim" als Treffer
@@ -70,7 +70,7 @@ internal static class WorkspaceFile
: basePath + Path.DirectorySeparatorChar;
if (!fullPath.StartsWith(fence, StringComparison.OrdinalIgnoreCase))
return (null, $"Zugriff außerhalb des {label}-Workspace verweigert.");
return (null, $"Access outside the {label} workspace denied.");
return (new Resolved(fullPath, label), null);
}