UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>ClawdDotNet.Tools.RocketChat</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ClawdDotNet.Core\ClawdDotNet.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,490 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>Ein Raum, wie ihn das Abonnement des Agenten beschreibt.</summary>
|
||||
internal sealed record RoomSubscription(
|
||||
string RoomId,
|
||||
string Name,
|
||||
string Type, // c = Kanal, p = private Gruppe, d = Direktnachricht
|
||||
int Unread,
|
||||
int UserMentions,
|
||||
int GroupMentions,
|
||||
bool Alert)
|
||||
{
|
||||
public bool IsDirect => Type == "d";
|
||||
|
||||
/// <summary>Menschenlesbare Bezeichnung für Ausgaben.</summary>
|
||||
public string Label => IsDirect ? $"@{Name}" : $"#{Name}";
|
||||
}
|
||||
|
||||
/// <summary>Eine angehängte Datei. <paramref name="FileId"/> ist der Griff zum Abholen.</summary>
|
||||
internal sealed record MessageFile(string FileId, string Name, string ContentType, long Size)
|
||||
{
|
||||
public string Describe()
|
||||
{
|
||||
var size = Size >= 1024 * 1024 ? $"{Size / 1024.0 / 1024.0:0.#} MB"
|
||||
: Size >= 1024 ? $"{Size / 1024.0:0.#} KB"
|
||||
: $"{Size} B";
|
||||
return $"{Name} ({ContentType}, {size}) — fileId: {FileId}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ein Link samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat.
|
||||
/// Titel und Beschreibung stammen von der verlinkten Seite — also fremder Text, der
|
||||
/// niemals als Anweisung gelesen werden darf.
|
||||
/// </summary>
|
||||
internal sealed record MessageLink(string Url, string? Title, string? Description);
|
||||
|
||||
/// <summary>Eine Nachricht aus der Raum-Historie.</summary>
|
||||
internal sealed record RoomMessage(
|
||||
string Id,
|
||||
DateTime Timestamp,
|
||||
string Username,
|
||||
string UserId,
|
||||
string Text,
|
||||
IReadOnlyList<string> Mentions,
|
||||
string? SystemType,
|
||||
string? ThreadId,
|
||||
IReadOnlyList<MessageFile> Files,
|
||||
IReadOnlyList<MessageLink> Links)
|
||||
{
|
||||
/// <summary>
|
||||
/// Systemnachrichten („Benutzer ist beigetreten", Themenänderung …) tragen ein
|
||||
/// <c>t</c>-Feld. Sie sind kein Gesprächsbeitrag und dürfen keinen Agenten wecken —
|
||||
/// sonst antwortet er auf einen Raumbeitritt.
|
||||
/// </summary>
|
||||
public bool IsSystem => !string.IsNullOrEmpty(SystemType);
|
||||
}
|
||||
|
||||
/// <summary>Fehler der Rocket.Chat-API, mit dem Fehlerschlüssel aus der Antwort.</summary>
|
||||
internal sealed class RocketChatException(string message, string? errorType = null)
|
||||
: Exception(message)
|
||||
{
|
||||
public string? ErrorType { get; } = errorType;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Schmaler REST-Client für Rocket.Chat. Authentifiziert wird je Aufruf über die beiden
|
||||
/// Kopfzeilen <c>X-Auth-Token</c> und <c>X-User-Id</c>; woher das Token stammt
|
||||
/// (Personal Access Token oder vom Administrator erzeugt), ist dem Client gleich.
|
||||
///
|
||||
/// Geprüft gegen Rocket.Chat 8.7.
|
||||
/// </summary>
|
||||
internal sealed class RocketChatClient(RocketChatConfig config)
|
||||
{
|
||||
// Ein geteilter HttpClient — je Aufruf einen neuen zu erzeugen erschöpft unter Last
|
||||
// die Sockets. Die Zugangsdaten hängen deshalb an der Anfrage, nicht am Client.
|
||||
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(30) };
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<JsonElement> SendAsync(
|
||||
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, $"{config.BaseUrl}{path}");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
|
||||
if (body is not null)
|
||||
request.Content = JsonContent.Create(body, options: JsonOptions);
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
JsonElement json;
|
||||
try
|
||||
{
|
||||
json = JsonDocument.Parse(raw).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new RocketChatException(
|
||||
$"Rocket.Chat antwortete mit {(int)response.StatusCode} und keinem JSON: " +
|
||||
Truncate(raw, 200));
|
||||
}
|
||||
|
||||
var success = json.TryGetProperty("success", out var s) && s.ValueKind == JsonValueKind.True;
|
||||
if (success)
|
||||
return json;
|
||||
|
||||
var errorType = json.TryGetProperty("errorType", out var et) ? et.GetString() : null;
|
||||
var message = json.TryGetProperty("error", out var e) ? e.GetString()
|
||||
: json.TryGetProperty("message", out var m) ? m.GetString()
|
||||
: $"HTTP {(int)response.StatusCode}";
|
||||
|
||||
throw new RocketChatException(message ?? "Unbekannter Fehler", errorType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Alle Räume des Agenten samt Ungelesen-Zählern — <b>ein</b> Aufruf, unabhängig von
|
||||
/// der Zahl der Räume. Darauf steht der gesamte Poll-Entwurf: Erst dieser Sammelabruf
|
||||
/// entscheidet, ob überhaupt eine Historie geholt werden muss.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RoomSubscription>> GetRoomsAsync(CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(HttpMethod.Get, "/api/v1/subscriptions.get", null, ct);
|
||||
|
||||
if (!json.TryGetProperty("update", out var update) || update.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var rooms = new List<RoomSubscription>();
|
||||
foreach (var item in update.EnumerateArray())
|
||||
{
|
||||
var roomId = GetString(item, "rid");
|
||||
if (string.IsNullOrEmpty(roomId))
|
||||
continue;
|
||||
|
||||
rooms.Add(new RoomSubscription(
|
||||
RoomId: roomId,
|
||||
Name: GetString(item, "name") ?? roomId,
|
||||
Type: GetString(item, "t") ?? "c",
|
||||
Unread: GetInt(item, "unread"),
|
||||
UserMentions: GetInt(item, "userMentions"),
|
||||
GroupMentions: GetInt(item, "groupMentions"),
|
||||
Alert: item.TryGetProperty("alert", out var a) && a.ValueKind == JsonValueKind.True));
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Raumnamen oder eine Raum-Id gegen die Abonnements des Agenten auf.
|
||||
/// Was hier nicht auftaucht, ist ein Raum, in dem der Agent kein Mitglied ist — das
|
||||
/// ist eine klarere Auskunft als der API-Fehler <c>invalid-channel</c>.
|
||||
/// </summary>
|
||||
public static RoomSubscription? Resolve(IReadOnlyList<RoomSubscription> rooms, string roomIdOrName)
|
||||
{
|
||||
var needle = roomIdOrName.TrimStart('#', '@');
|
||||
|
||||
return rooms.FirstOrDefault(r => string.Equals(r.RoomId, roomIdOrName, StringComparison.Ordinal))
|
||||
?? rooms.FirstOrDefault(r => string.Equals(r.Name, needle, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Nachrichten eines Raums seit einem Zeitpunkt, älteste zuerst.</summary>
|
||||
public async Task<IReadOnlyList<RoomMessage>> GetHistoryAsync(
|
||||
RoomSubscription room, DateTime? since, int count, CancellationToken ct)
|
||||
{
|
||||
// Die Historie hat je Raumart einen eigenen Endpunkt.
|
||||
var endpoint = room.Type switch
|
||||
{
|
||||
"p" => "groups.history",
|
||||
"d" => "im.history",
|
||||
_ => "channels.history"
|
||||
};
|
||||
|
||||
var query = new StringBuilder($"/api/v1/{endpoint}?roomId={Uri.EscapeDataString(room.RoomId)}")
|
||||
.Append("&count=").Append(count);
|
||||
|
||||
if (since is { } from)
|
||||
{
|
||||
query.Append("&oldest=").Append(Uri.EscapeDataString(
|
||||
from.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")));
|
||||
query.Append("&inclusive=false");
|
||||
}
|
||||
|
||||
var json = await SendAsync(HttpMethod.Get, query.ToString(), null, ct);
|
||||
|
||||
if (!json.TryGetProperty("messages", out var messages) || messages.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<RoomMessage>();
|
||||
foreach (var m in messages.EnumerateArray())
|
||||
{
|
||||
var id = GetString(m, "_id");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
|
||||
var user = m.TryGetProperty("u", out var u) ? u : default;
|
||||
|
||||
result.Add(new RoomMessage(
|
||||
Id: id,
|
||||
Timestamp: GetDate(m, "ts"),
|
||||
Username: (user.ValueKind == JsonValueKind.Object ? GetString(user, "username") : null) ?? "unbekannt",
|
||||
UserId: (user.ValueKind == JsonValueKind.Object ? GetString(user, "_id") : null) ?? "",
|
||||
Text: GetString(m, "msg") ?? "",
|
||||
Mentions: ReadMentions(m),
|
||||
SystemType: GetString(m, "t"),
|
||||
ThreadId: GetString(m, "tmid"),
|
||||
Files: ReadFiles(m),
|
||||
Links: ReadLinks(m)));
|
||||
}
|
||||
|
||||
// Rocket.Chat liefert die jüngste Nachricht zuerst; für einen Verlauf ist die
|
||||
// umgekehrte Reihenfolge die brauchbare.
|
||||
return result.OrderBy(x => x.Timestamp).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Öffnet (oder findet) den Direktnachrichten-Raum zu einem Benutzer. Nötig, weil ein
|
||||
/// DM-Raum erst existiert, wenn jemand ihn eröffnet — ohne das könnte ein Agent nur
|
||||
/// auf bestehende Gespräche antworten, aber keines beginnen.
|
||||
/// </summary>
|
||||
public async Task<RoomSubscription?> OpenDirectMessageAsync(string username, CancellationToken ct)
|
||||
{
|
||||
var json = await SendAsync(HttpMethod.Post, "/api/v1/im.create", new { username }, ct);
|
||||
|
||||
if (!json.TryGetProperty("room", out var room) || room.ValueKind != JsonValueKind.Object)
|
||||
return null;
|
||||
|
||||
var roomId = GetString(room, "_id") ?? GetString(room, "rid");
|
||||
|
||||
return roomId is null
|
||||
? null
|
||||
: new RoomSubscription(roomId, username, "d", 0, 0, 0, false);
|
||||
}
|
||||
|
||||
public async Task<string> PostMessageAsync(
|
||||
string roomId, string text, string? threadId, CancellationToken ct)
|
||||
{
|
||||
object body = threadId is null
|
||||
? new { roomId, text }
|
||||
: new { roomId, text, tmid = threadId };
|
||||
|
||||
var json = await SendAsync(HttpMethod.Post, "/api/v1/chat.postMessage", body, ct);
|
||||
|
||||
return json.TryGetProperty("message", out var m) ? GetString(m, "_id") ?? "" : "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lädt eine Datei in einen Raum. Das läuft in <b>zwei</b> Schritten, anders als der
|
||||
/// Rest der API:
|
||||
///
|
||||
/// 1. <c>rooms.media</c> nimmt die Datei als <c>multipart/form-data</c> entgegen und
|
||||
/// gibt eine Datei-Id zurück — sichtbar ist damit noch nichts.
|
||||
/// 2. <c>rooms.mediaConfirm</c> veröffentlicht sie als Nachricht im Raum.
|
||||
///
|
||||
/// Der frühere Ein-Schritt-Endpunkt <c>rooms.upload</c> existiert in Rocket.Chat 8.7
|
||||
/// nicht mehr (er antwortet mit einem nackten 404). Diese Umsetzung setzt daher eine
|
||||
/// Version voraus, die <c>rooms.media</c> kennt (6.x und neuer).
|
||||
/// </summary>
|
||||
public async Task<string> UploadFileAsync(
|
||||
string roomId, Stream content, string fileName, string contentType,
|
||||
string? message, string? threadId, CancellationToken ct)
|
||||
{
|
||||
var room = Uri.EscapeDataString(roomId);
|
||||
|
||||
// Schritt 1 — die Datei selbst.
|
||||
using var form = new MultipartFormDataContent();
|
||||
var file = new StreamContent(content);
|
||||
file.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(contentType);
|
||||
form.Add(file, "file", fileName);
|
||||
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post, $"{config.BaseUrl}/api/v1/rooms.media/{room}");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
request.Content = form;
|
||||
|
||||
using var response = await Http.SendAsync(request, ct);
|
||||
var raw = await response.Content.ReadAsStringAsync(ct);
|
||||
|
||||
JsonElement upload;
|
||||
try
|
||||
{
|
||||
upload = JsonDocument.Parse(raw).RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
throw new RocketChatException(
|
||||
$"Upload scheiterte mit {(int)response.StatusCode}: {Truncate(raw, 200)}"
|
||||
+ (response.StatusCode == System.Net.HttpStatusCode.NotFound
|
||||
? " — kennt diese Rocket.Chat-Version den Endpunkt rooms.media?"
|
||||
: ""));
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
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.");
|
||||
|
||||
// Schritt 2 — ohne ihn liegt die Datei hochgeladen, aber unsichtbar auf dem Server.
|
||||
object confirmBody = threadId is null
|
||||
? new { msg = message ?? "" }
|
||||
: new { msg = message ?? "", tmid = threadId };
|
||||
|
||||
var confirm = await SendAsync(
|
||||
HttpMethod.Post, $"/api/v1/rooms.mediaConfirm/{room}/{Uri.EscapeDataString(fileId)}",
|
||||
confirmBody, ct);
|
||||
|
||||
return confirm.TryGetProperty("message", out var msg) ? GetString(msg, "_id") ?? "" : "";
|
||||
}
|
||||
|
||||
public async Task MarkReadAsync(string roomId, CancellationToken ct)
|
||||
=> await SendAsync(HttpMethod.Post, "/api/v1/subscriptions.read", new { rid = roomId }, ct);
|
||||
|
||||
// ─── JSON-Lesehelfer ───
|
||||
|
||||
/// <summary>
|
||||
/// Angehängte Dateien. Rocket.Chat führt sie in <c>files</c>; die Datei-Id daraus ist
|
||||
/// alles, was zum späteren Abholen nötig ist.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MessageFile> ReadFiles(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("files", out var files) || files.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<MessageFile>();
|
||||
foreach (var f in files.EnumerateArray())
|
||||
{
|
||||
var id = GetString(f, "_id");
|
||||
if (string.IsNullOrEmpty(id))
|
||||
continue;
|
||||
|
||||
result.Add(new MessageFile(
|
||||
FileId: id,
|
||||
Name: GetString(f, "name") ?? id,
|
||||
ContentType: GetString(f, "type") ?? "application/octet-stream",
|
||||
Size: f.TryGetProperty("size", out var s) && s.ValueKind == JsonValueKind.Number
|
||||
&& s.TryGetInt64(out var n) ? n : 0));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Links samt der Vorschau, die Rocket.Chat beim Entpacken selbst geholt hat. Das
|
||||
/// erspart dem Agenten oft einen eigenen Abruf der Seite.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<MessageLink> ReadLinks(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("urls", out var urls) || urls.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
var result = new List<MessageLink>();
|
||||
foreach (var u in urls.EnumerateArray())
|
||||
{
|
||||
var url = GetString(u, "url");
|
||||
if (string.IsNullOrEmpty(url))
|
||||
continue;
|
||||
|
||||
var meta = u.TryGetProperty("meta", out var m) && m.ValueKind == JsonValueKind.Object
|
||||
? m
|
||||
: default;
|
||||
|
||||
result.Add(new MessageLink(
|
||||
Url: url,
|
||||
Title: meta.ValueKind == JsonValueKind.Object
|
||||
? GetString(meta, "pageTitle") ?? GetString(meta, "ogTitle")
|
||||
: null,
|
||||
Description: meta.ValueKind == JsonValueKind.Object
|
||||
? GetString(meta, "description") ?? GetString(meta, "ogDescription")
|
||||
: null));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Holt eine angehängte Datei. Die Dateiablage liegt nicht unter <c>/api</c>, nimmt
|
||||
/// aber dieselben Kopfzeilen; ohne sie antwortet der Server mit 403. Der Namensteil
|
||||
/// der Adresse ist beliebig, muss aber vorhanden sein.
|
||||
/// </summary>
|
||||
public async Task<long> DownloadToFileAsync(
|
||||
string fileId, string destinationPath, long maxBytes, CancellationToken ct)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get, $"{config.BaseUrl}/file-upload/{Uri.EscapeDataString(fileId)}/download");
|
||||
request.Headers.Add("X-Auth-Token", config.AuthToken);
|
||||
request.Headers.Add("X-User-Id", config.UserId);
|
||||
|
||||
using var response = await Http.SendAsync(
|
||||
request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
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})."
|
||||
});
|
||||
|
||||
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 "
|
||||
+ $"{maxBytes / 1024 / 1024} MB.");
|
||||
|
||||
await using var source = await response.Content.ReadAsStreamAsync(ct);
|
||||
|
||||
// Erst in eine Nebendatei schreiben: Bricht der Download ab oder reißt die Grenze,
|
||||
// bleibt keine halbe Datei im Workspace liegen, die der Agent für vollständig hält.
|
||||
var temp = destinationPath + ".part";
|
||||
long written = 0;
|
||||
|
||||
try
|
||||
{
|
||||
await using (var target = File.Create(temp))
|
||||
{
|
||||
var buffer = new byte[81920];
|
||||
int read;
|
||||
while ((read = await source.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
written += read;
|
||||
|
||||
// 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.");
|
||||
|
||||
await target.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
}
|
||||
|
||||
File.Move(temp, destinationPath, overwrite: true);
|
||||
return written;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (File.Exists(temp))
|
||||
try { File.Delete(temp); } catch { /* Aufräumen darf den Fehler nicht verdecken */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadMentions(JsonElement message)
|
||||
{
|
||||
if (!message.TryGetProperty("mentions", out var mentions)
|
||||
|| mentions.ValueKind != JsonValueKind.Array)
|
||||
return [];
|
||||
|
||||
return mentions.EnumerateArray()
|
||||
.Select(x => GetString(x, "username"))
|
||||
.Where(x => !string.IsNullOrEmpty(x))
|
||||
.Select(x => x!)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string? GetString(JsonElement element, string name)
|
||||
=> element.ValueKind == JsonValueKind.Object
|
||||
&& element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private static int GetInt(JsonElement element, string name)
|
||||
=> element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.Number
|
||||
&& v.TryGetInt32(out var i)
|
||||
? i
|
||||
: 0;
|
||||
|
||||
private static DateTime GetDate(JsonElement element, string name)
|
||||
=> element.TryGetProperty(name, out var v)
|
||||
&& v.ValueKind == JsonValueKind.String
|
||||
&& DateTime.TryParse(v.GetString(), null,
|
||||
System.Globalization.DateTimeStyles.AdjustToUniversal
|
||||
| System.Globalization.DateTimeStyles.AssumeUniversal, out var d)
|
||||
? d
|
||||
: DateTime.UtcNow;
|
||||
|
||||
private static string Truncate(string value, int max)
|
||||
=> value.Length <= max ? value : value[..max] + "…";
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
/// Die Tool-Konfiguration eines Agenten, aus dem freien Wörterbuch gelesen.
|
||||
///
|
||||
/// Die Werte kommen je nach Weg als <see cref="JsonElement"/> (frisch deserialisiert) oder
|
||||
/// als einfache .NET-Werte (nach einer Bearbeitung in der Oberfläche) an — deshalb liest
|
||||
/// jeder Zugriff über die Helfer unten, nie über eine direkte Umwandlung.
|
||||
/// </summary>
|
||||
internal sealed record RocketChatConfig(
|
||||
string BaseUrl,
|
||||
string UserId,
|
||||
string AuthToken,
|
||||
string? Username,
|
||||
IReadOnlyList<string> AllowedRooms,
|
||||
string? DefaultRoom,
|
||||
bool MentionOnly,
|
||||
IReadOnlyList<string> AgentUsernames,
|
||||
int MaxWakesPerRoomPerHour,
|
||||
int MaxMessagesPerRoom,
|
||||
TimeSpan InitialLookback,
|
||||
long MaxUploadBytes,
|
||||
long MaxDownloadBytes,
|
||||
bool AllowDangerousDownloads)
|
||||
{
|
||||
/// <summary>
|
||||
/// Liest die Konfiguration. Gibt eine sprechende Fehlermeldung zurück, statt mit einer
|
||||
/// Ausnahme zu scheitern — das Modell soll aus dem Ergebnis erkennen, was fehlt.
|
||||
/// </summary>
|
||||
public static (RocketChatConfig? Config, string? Error) Read(
|
||||
IReadOnlyDictionary<string, object?> raw)
|
||||
{
|
||||
var baseUrl = GetString(raw, "baseUrl")?.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
return (null, "Konfigurationsfehler: 'baseUrl' fehlt (z. B. \"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}");
|
||||
|
||||
var userId = GetString(raw, "userId");
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return (null, "Konfigurationsfehler: 'userId' fehlt (die Rocket.Chat-Benutzer-Id dieses Agenten).");
|
||||
|
||||
var authToken = GetString(raw, "authToken");
|
||||
if (string.IsNullOrWhiteSpace(authToken))
|
||||
return (null, "Konfigurationsfehler: 'authToken' fehlt (Zugriffstoken des Agenten-Benutzers).");
|
||||
|
||||
return (new RocketChatConfig(
|
||||
BaseUrl: baseUrl,
|
||||
UserId: userId,
|
||||
AuthToken: authToken,
|
||||
Username: GetString(raw, "username"),
|
||||
AllowedRooms: GetStringList(raw, "allowedRooms"),
|
||||
DefaultRoom: GetString(raw, "defaultRoom"),
|
||||
MentionOnly: GetBool(raw, "mentionOnly", defaultValue: true),
|
||||
AgentUsernames: GetStringList(raw, "agentUsernames"),
|
||||
MaxWakesPerRoomPerHour: GetInt(raw, "maxWakesPerRoomPerHour", defaultValue: 12),
|
||||
MaxMessagesPerRoom: Math.Clamp(GetInt(raw, "maxMessagesPerRoom", defaultValue: 20), 1, 100),
|
||||
InitialLookback: TimeSpan.FromMinutes(
|
||||
Math.Clamp(GetInt(raw, "initialLookbackMinutes", defaultValue: 5), 1, 1440)),
|
||||
MaxUploadBytes: Math.Clamp(
|
||||
GetInt(raw, "maxUploadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
|
||||
MaxDownloadBytes: Math.Clamp(
|
||||
GetInt(raw, "maxDownloadMb", defaultValue: 25), 1, 100) * 1024L * 1024L,
|
||||
AllowDangerousDownloads: GetBool(raw, "allowDangerousDownloads", defaultValue: false)),
|
||||
null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein Raum benutzt werden darf. Eine leere Liste erlaubt jeden Raum — wer
|
||||
/// keine Allowlist pflegt, hat sich dagegen entschieden; das Tool erfindet keine.
|
||||
/// Verglichen wird gegen Raum-Id <b>und</b> Raumname, weil das Modell beides kennt.
|
||||
/// </summary>
|
||||
public bool IsRoomAllowed(string roomIdOrName)
|
||||
{
|
||||
if (AllowedRooms.Count == 0)
|
||||
return true;
|
||||
|
||||
return AllowedRooms.Any(r =>
|
||||
string.Equals(r, roomIdOrName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public bool IsAgentUser(string? username)
|
||||
=> username is not null
|
||||
&& AgentUsernames.Any(a => string.Equals(a, username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ─── Lesehelfer für das freie Wörterbuch ───
|
||||
|
||||
private static string? GetString(IReadOnlyDictionary<string, object?> raw, string key)
|
||||
=> raw.TryGetValue(key, out var v) ? AsString(v) : null;
|
||||
|
||||
private static string? AsString(object? value) => value switch
|
||||
{
|
||||
null => null,
|
||||
string s => string.IsNullOrWhiteSpace(s) ? null : s,
|
||||
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(),
|
||||
JsonElement je => je.ToString(),
|
||||
_ => value.ToString()
|
||||
};
|
||||
|
||||
private static bool GetBool(IReadOnlyDictionary<string, object?> raw, string key, bool defaultValue)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return defaultValue;
|
||||
|
||||
return v switch
|
||||
{
|
||||
bool b => b,
|
||||
JsonElement { ValueKind: JsonValueKind.True } => true,
|
||||
JsonElement { ValueKind: JsonValueKind.False } => false,
|
||||
_ => bool.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
|
||||
};
|
||||
}
|
||||
|
||||
private static int GetInt(IReadOnlyDictionary<string, object?> raw, string key, int defaultValue)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return defaultValue;
|
||||
|
||||
return v switch
|
||||
{
|
||||
int i => i,
|
||||
long l => (int)l,
|
||||
JsonElement { ValueKind: JsonValueKind.Number } je when je.TryGetInt32(out var n) => n,
|
||||
_ => int.TryParse(AsString(v), out var parsed) ? parsed : defaultValue
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> GetStringList(
|
||||
IReadOnlyDictionary<string, object?> raw, string key)
|
||||
{
|
||||
if (!raw.TryGetValue(key, out var v) || v is null)
|
||||
return [];
|
||||
|
||||
return v switch
|
||||
{
|
||||
JsonElement { ValueKind: JsonValueKind.Array } je =>
|
||||
je.EnumerateArray()
|
||||
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString())
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s))
|
||||
.Select(s => s!)
|
||||
.ToList(),
|
||||
IEnumerable<string> list => list.Where(s => !string.IsNullOrWhiteSpace(s)).ToList(),
|
||||
_ => AsString(v) is { } single ? [single] : []
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.State;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// </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.
|
||||
|
||||
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.
|
||||
|
||||
Aktionen: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read
|
||||
""";
|
||||
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"room": {
|
||||
"type": "string",
|
||||
"description": "Raum-Id oder -Name (z. B. \"GENERAL\", \"#team\", \"@person\"). Entfällt bei list_rooms; ohne Angabe gilt der konfigurierte Standardraum."
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Nachrichtentext (für send_message und reply)."
|
||||
},
|
||||
"threadId": {
|
||||
"type": "string",
|
||||
"description": "Id der Nachricht, auf die geantwortet wird (nur für 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."
|
||||
},
|
||||
"filename": {
|
||||
"type": "string",
|
||||
"description": "Abweichender Dateiname für den Empfänger (nur für send_file). Ohne Angabe gilt der Name im Workspace."
|
||||
},
|
||||
"fileId": {
|
||||
"type": "string",
|
||||
"description": "Id einer empfangenen Datei (nur für download_file). Steht in der Nachricht als \"fileId: …\"."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Anzahl der zu lesenden Nachrichten bei read_room (Standard 20, höchstens 100)."
|
||||
}
|
||||
}
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
// ─── IAgentTool ───
|
||||
|
||||
public async Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
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.");
|
||||
|
||||
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);
|
||||
|
||||
try
|
||||
{
|
||||
return action switch
|
||||
{
|
||||
"send_message" => await SendMessageAsync(client, config, input, threadAllowed: false, ct),
|
||||
"reply" => await SendMessageAsync(client, config, input, threadAllowed: true, ct),
|
||||
"send_file" => await SendFileAsync(client, config, input, context, ct),
|
||||
"download_file" => await DownloadFileAsync(client, config, input, context, ct),
|
||||
"list_rooms" => await ListRoomsAsync(client, ct),
|
||||
"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.")
|
||||
};
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
context.Logger.LogWarning("RocketChat meldet einen Fehler bei {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}");
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return ToolResult.Fail("Rocket.Chat hat nicht rechtzeitig geantwortet (Zeitüberschreitung).");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> SendMessageAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
bool threadAllowed, CancellationToken ct)
|
||||
{
|
||||
var text = GetString(input, "text");
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return ToolResult.Fail("'text' ist erforderlich.");
|
||||
|
||||
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).");
|
||||
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var messageId = await client.PostMessageAsync(room.RoomId, text, threadId, ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
sent = true,
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
messageId,
|
||||
inThread = threadId is not null
|
||||
}));
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> SendFileAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
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).");
|
||||
|
||||
var (file, pathError) = WorkspaceFile.Resolve(
|
||||
localPath, context.WorkspacePath, context.SharedWorkspacePath);
|
||||
if (file is null)
|
||||
return ToolResult.Fail(pathError!);
|
||||
|
||||
if (!File.Exists(file.FullPath))
|
||||
return ToolResult.Fail($"Datei nicht gefunden: {localPath}");
|
||||
|
||||
var info = new FileInfo(file.FullPath);
|
||||
if (info.Length == 0)
|
||||
return ToolResult.Fail($"Die Datei {localPath} ist leer.");
|
||||
|
||||
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.");
|
||||
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var fileName = GetString(input, "filename") ?? Path.GetFileName(file.FullPath);
|
||||
|
||||
await using var stream = File.OpenRead(file.FullPath);
|
||||
var messageId = await client.UploadFileAsync(
|
||||
room.RoomId, stream, fileName,
|
||||
WorkspaceFile.GuessContentType(fileName),
|
||||
GetString(input, "text"),
|
||||
GetString(input, "threadId"),
|
||||
ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
sent = true,
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
fileName,
|
||||
bytes = info.Length,
|
||||
workspace = file.Label,
|
||||
messageId
|
||||
}));
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static async Task<ToolResult> DownloadFileAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input,
|
||||
AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
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: …\").");
|
||||
|
||||
// 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.
|
||||
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.
|
||||
var prefix = "";
|
||||
var rest = wanted;
|
||||
if (rest.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
prefix = "shared:";
|
||||
rest = rest[7..];
|
||||
}
|
||||
else if (rest.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
rest = rest[9..];
|
||||
}
|
||||
|
||||
var targetName = Path.GetFileName(rest.Replace('\\', '/'));
|
||||
if (string.IsNullOrWhiteSpace(targetName))
|
||||
return ToolResult.Fail($"'{wanted}' ergibt keinen gültigen Dateinamen.");
|
||||
|
||||
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.");
|
||||
|
||||
// Das Verzeichnis darf das Modell wählen (personal:/shared:), den Dateinamen nicht
|
||||
// frei zusammensetzen — deshalb Präfix und bereinigter Name getrennt.
|
||||
var (file, pathError) = WorkspaceFile.Resolve(
|
||||
prefix + targetName, context.WorkspacePath, context.SharedWorkspacePath);
|
||||
if (file is null)
|
||||
return ToolResult.Fail(pathError!);
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(file.FullPath)!);
|
||||
|
||||
var bytes = await client.DownloadToFileAsync(fileId, file.FullPath, config.MaxDownloadBytes, ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
downloaded = true,
|
||||
fileId,
|
||||
savedAs = $"{(file.Label == "shared" ? "shared:" : "")}{targetName}",
|
||||
workspace = file.Label,
|
||||
bytes
|
||||
}));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Endungen, die nicht ungefragt im Workspace landen sollen. Die Datei kommt von
|
||||
/// außen — der Dateiname ist eine Behauptung des Absenders, kein Befund.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> DangerousExtensions =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".exe", ".dll", ".bat", ".cmd", ".com", ".scr", ".msi", ".ps1",
|
||||
".vbs", ".jar", ".lnk", ".reg", ".sh"
|
||||
};
|
||||
|
||||
private static async Task<ToolResult> ListRoomsAsync(RocketChatClient client, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
|
||||
return ToolResult.Ok(JsonSerializer.Serialize(new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
source = "rocketchat",
|
||||
data = new
|
||||
{
|
||||
count = rooms.Count,
|
||||
rooms = rooms.Select(r => new
|
||||
{
|
||||
roomId = r.RoomId,
|
||||
name = r.Name,
|
||||
type = r.Type switch { "c" => "channel", "p" => "private", "d" => "direct", _ => r.Type },
|
||||
unread = r.Unread,
|
||||
mentions = r.UserMentions
|
||||
})
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> ReadRoomAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
var limit = Math.Clamp(GetInt(input, "limit") ?? 20, 1, 100);
|
||||
var messages = await client.GetHistoryAsync(room, since: null, count: limit, ct);
|
||||
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
fetchedAt = DateTime.UtcNow,
|
||||
source = $"rocketchat_{room.RoomId}",
|
||||
data = new
|
||||
{
|
||||
room = room.Label,
|
||||
roomId = room.RoomId,
|
||||
count = messages.Count(m => !m.IsSystem),
|
||||
messages = messages.Where(m => !m.IsSystem).Select(m => new
|
||||
{
|
||||
messageId = m.Id,
|
||||
at = m.Timestamp,
|
||||
from = m.Username,
|
||||
text = m.Text,
|
||||
threadId = m.ThreadId,
|
||||
files = m.Files.Count == 0 ? null : m.Files.Select(f => new
|
||||
{
|
||||
fileId = f.FileId,
|
||||
name = f.Name,
|
||||
contentType = f.ContentType,
|
||||
size = f.Size
|
||||
}),
|
||||
links = m.Links.Count == 0 ? null : m.Links.Select(l => new
|
||||
{
|
||||
url = l.Url,
|
||||
title = l.Title,
|
||||
description = l.Description is null ? null : Shorten(l.Description, 300)
|
||||
})
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
// Fremder Text — als Daten gerahmt, nicht als Anweisung (K2).
|
||||
return ToolResult.Ok(Untrusted(payload));
|
||||
}
|
||||
|
||||
private static async Task<ToolResult> MarkReadAsync(
|
||||
RocketChatClient client, RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
var (room, error) = await ResolveRoomAsync(client, rooms, config, input, ct);
|
||||
if (room is null)
|
||||
return ToolResult.Fail(error!);
|
||||
|
||||
await client.MarkReadAsync(room.RoomId, ct);
|
||||
return ToolResult.Ok($"Raum {room.Label} als gelesen markiert.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bestimmt den Zielraum aus Eingabe oder Standardwert und prüft die Allowlist.
|
||||
/// Schlägt die Auflösung fehl, nennt die Meldung die tatsächlich verfügbaren Räume —
|
||||
/// das Modell soll sich korrigieren können, statt zu raten.
|
||||
/// </summary>
|
||||
private static async Task<(RoomSubscription? Room, string? Error)> ResolveRoomAsync(
|
||||
RocketChatClient client, IReadOnlyList<RoomSubscription> rooms,
|
||||
RocketChatConfig config, JsonElement input, CancellationToken ct)
|
||||
{
|
||||
var wanted = GetString(input, "room") ?? config.DefaultRoom;
|
||||
if (string.IsNullOrWhiteSpace(wanted))
|
||||
return (null, "'room' ist erforderlich (kein Standardraum konfiguriert). "
|
||||
+ 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.
|
||||
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).");
|
||||
|
||||
try
|
||||
{
|
||||
room = await client.OpenDirectMessageAsync(username, ct);
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
return (null, $"Direktnachricht an '{username}' nicht möglich: {ex.Message}");
|
||||
}
|
||||
|
||||
if (room is null)
|
||||
return (null, $"Direktnachricht an '{username}' konnte nicht geöffnet werden.");
|
||||
|
||||
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.");
|
||||
|
||||
// Gegen beide Schreibweisen prüfen: Die Allowlist darf Ids oder Namen enthalten.
|
||||
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 (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})"));
|
||||
|
||||
// ─── 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.")
|
||||
];
|
||||
|
||||
public async Task<ToolJobResult> ExecuteJobAsync(
|
||||
string jobTypeId,
|
||||
IReadOnlyDictionary<string, object?> toolConfig,
|
||||
IStateStore stateStore,
|
||||
ILogger logger,
|
||||
CancellationToken ct,
|
||||
string? agentId = null,
|
||||
string? workspacePath = null)
|
||||
{
|
||||
if (jobTypeId != "rocketchat_poll")
|
||||
return ToolJobResult.NoAction($"Unbekannter Job-Typ: {jobTypeId}");
|
||||
|
||||
var (config, configError) = RocketChatConfig.Read(toolConfig);
|
||||
if (config is null)
|
||||
return ToolJobResult.NoAction(configError);
|
||||
|
||||
var client = new RocketChatClient(config);
|
||||
var agent = agentId ?? "default";
|
||||
|
||||
try
|
||||
{
|
||||
var rooms = await client.GetRoomsAsync(ct);
|
||||
|
||||
// Vorfilter aus dem Sammelabruf: Nur Räume mit etwas Ungelesenem sind
|
||||
// überhaupt einen zweiten Aufruf wert.
|
||||
var candidates = rooms.Where(r => r.Unread > 0 || r.Alert).ToList();
|
||||
|
||||
logger.LogDebug(
|
||||
"RocketChat: mentionOnly={MentionOnly}; Räume: {Rooms}",
|
||||
config.MentionOnly,
|
||||
string.Join(" | ", rooms.Select(r =>
|
||||
$"{r.Label} t={r.Type} unread={r.Unread} mentions={r.UserMentions}/{r.GroupMentions} alert={r.Alert}")));
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
candidates = candidates
|
||||
.Where(r => config.IsRoomAllowed(r.RoomId) || config.IsRoomAllowed(r.Name))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
return ToolJobResult.NoAction("Nichts Neues");
|
||||
|
||||
var blocks = new List<string>();
|
||||
var total = 0;
|
||||
|
||||
foreach (var room in candidates)
|
||||
{
|
||||
var messages = await CollectNewMessagesAsync(client, config, stateStore, agent, room, logger, ct);
|
||||
|
||||
if (messages.Count > 0
|
||||
&& await TryConsumeWakeBudgetAsync(stateStore, agent, room, config, logger, ct))
|
||||
{
|
||||
total += messages.Count;
|
||||
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.
|
||||
try
|
||||
{
|
||||
await client.MarkReadAsync(room.RoomId, ct);
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
logger.LogWarning("RocketChat: Raum {Room} konnte nicht als gelesen markiert werden: {Message}",
|
||||
room.Label, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
if (blocks.Count == 0)
|
||||
return ToolJobResult.NoAction("Nichts Neues (nach Filterung)");
|
||||
|
||||
logger.LogInformation("RocketChat: {Count} neue Nachricht(en) in {Rooms} Raum/Räumen",
|
||||
total, blocks.Count);
|
||||
|
||||
return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} neue Nachricht(en)");
|
||||
}
|
||||
catch (RocketChatException ex)
|
||||
{
|
||||
logger.LogWarning("RocketChat-Abruf fehlgeschlagen: {Message}", ex.Message);
|
||||
return ToolJobResult.NoAction($"Abruf-Fehler: {ex.Message}");
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
logger.LogWarning("Rocket.Chat ist nicht erreichbar: {Message}", ex.Message);
|
||||
return ToolJobResult.NoAction($"Nicht erreichbar: {ex.Message}");
|
||||
}
|
||||
catch (TaskCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
return ToolJobResult.NoAction("Zeitüberschreitung beim Abruf");
|
||||
}
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static async Task<IReadOnlyList<RoomMessage>> CollectNewMessagesAsync(
|
||||
RocketChatClient client, RocketChatConfig config, IStateStore stateStore,
|
||||
string agent, RoomSubscription room, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
var markerKey = $"rocketchat:{agent}:{room.RoomId}:lastTs";
|
||||
var markerRaw = await stateStore.GetAsync(markerKey, ct);
|
||||
|
||||
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.
|
||||
since = DateTime.UtcNow - config.InitialLookback;
|
||||
logger.LogInformation(
|
||||
"RocketChat: erster Abruf für {Room} — {Minutes} Minuten Rückschau",
|
||||
room.Label, config.InitialLookback.TotalMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
since = DateTime.TryParse(markerRaw, null,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var parsed)
|
||||
? parsed
|
||||
: DateTime.UtcNow - config.InitialLookback;
|
||||
}
|
||||
|
||||
var history = await client.GetHistoryAsync(room, since, config.MaxMessagesPerRoom, ct);
|
||||
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.
|
||||
await stateStore.SetAsync(markerKey, history[^1].Timestamp.ToString("O"), ct);
|
||||
|
||||
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.
|
||||
///
|
||||
/// 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.
|
||||
/// </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.
|
||||
/// </summary>
|
||||
private static async Task<bool> TryConsumeWakeBudgetAsync(
|
||||
IStateStore stateStore, string agent, RoomSubscription room,
|
||||
RocketChatConfig config, ILogger logger, CancellationToken ct)
|
||||
{
|
||||
if (config.MaxWakesPerRoomPerHour <= 0)
|
||||
return true;
|
||||
|
||||
var bucket = DateTime.UtcNow.ToString("yyyyMMddHH");
|
||||
var key = $"rocketchat:{agent}:{room.RoomId}:wakes:{bucket}";
|
||||
|
||||
var used = int.TryParse(await stateStore.GetAsync(key, ct), out var n) ? n : 0;
|
||||
if (used >= config.MaxWakesPerRoomPerHour)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"RocketChat: Weckgrenze für {Room} erreicht ({Max}/Stunde) — Nachrichten bleiben ungelesen",
|
||||
room.Label, config.MaxWakesPerRoomPerHour);
|
||||
return false;
|
||||
}
|
||||
|
||||
await stateStore.SetAsync(key, (used + 1).ToString(), ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string FormatRoom(RoomSubscription room, IReadOnlyList<RoomMessage> messages)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine($"Raum: {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.
|
||||
foreach (var f in m.Files)
|
||||
sb.AppendLine($" Datei: {f.Describe()}");
|
||||
|
||||
foreach (var l in m.Links)
|
||||
sb.AppendLine($" Link: {l.Url}"
|
||||
+ (l.Title is null ? "" : $" — {Shorten(l.Title, 120)}"));
|
||||
}
|
||||
|
||||
return sb.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static string Shorten(string value, int max)
|
||||
{
|
||||
var flat = value.ReplaceLineEndings(" ").Trim();
|
||||
return flat.Length <= max ? flat : flat[..max] + "…";
|
||||
}
|
||||
|
||||
private static string BuildWakeMessage(IReadOnlyList<string> blocks)
|
||||
=> $"""
|
||||
Neue Nachrichten 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.
|
||||
""";
|
||||
|
||||
private static string Untrusted(string content)
|
||||
=> $"<untrusted_content>\n{content}\n</untrusted_content>";
|
||||
|
||||
// ─── Eingabe-Helfer ───
|
||||
|
||||
private static string? GetString(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
|
||||
? v.GetString()
|
||||
: null;
|
||||
|
||||
private static int? GetInt(JsonElement input, string name)
|
||||
=> input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.Number
|
||||
&& v.TryGetInt32(out var i)
|
||||
? i
|
||||
: null;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
namespace ClawdDotNet.Tools.RocketChat;
|
||||
|
||||
/// <summary>
|
||||
/// Löst einen Workspace-Pfad mit den im Projekt üblichen Prefixen auf
|
||||
/// (<c>personal:</c>, <c>shared:</c>, ohne Prefix = persönlicher Workspace) — gleiche
|
||||
/// Schreibweise wie beim FileRW- und FTP-Tool, damit ein Agent nicht je Tool umdenken muss.
|
||||
///
|
||||
/// Die Prüfung gegen einen Ausbruch aus dem Verzeichnis ist der eigentliche Zweck: Ohne sie
|
||||
/// könnte ein Agent per <c>../../</c> beliebige Dateien des Rechners in einen Chatraum
|
||||
/// schieben.
|
||||
/// </summary>
|
||||
internal static class WorkspaceFile
|
||||
{
|
||||
public sealed record Resolved(string FullPath, string Label);
|
||||
|
||||
public static (Resolved? File, string? Error) Resolve(
|
||||
string path, string? personalRoot, string? sharedRoot)
|
||||
{
|
||||
string relative;
|
||||
string? root;
|
||||
string label;
|
||||
|
||||
if (path.StartsWith("shared:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = path[7..];
|
||||
root = sharedRoot;
|
||||
label = "shared";
|
||||
}
|
||||
else if (path.StartsWith("personal:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
relative = path[9..];
|
||||
root = personalRoot;
|
||||
label = "personal";
|
||||
}
|
||||
else
|
||||
{
|
||||
relative = path;
|
||||
root = personalRoot;
|
||||
label = "personal";
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
return (null, $"Kein {label}-Workspace konfiguriert.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
return (null, "Es wurde kein Dateiname angegeben.");
|
||||
|
||||
// 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.");
|
||||
|
||||
var basePath = Path.GetFullPath(root);
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(Path.Combine(basePath, relative));
|
||||
}
|
||||
catch (Exception ex) when (ex is ArgumentException or NotSupportedException or PathTooLongException)
|
||||
{
|
||||
return (null, $"Ungültiger Pfad: {path}");
|
||||
}
|
||||
|
||||
// Der abschließende Trenner verhindert, dass "…/workspace-geheim" als Treffer
|
||||
// für "…/workspace" durchgeht.
|
||||
var fence = basePath.EndsWith(Path.DirectorySeparatorChar)
|
||||
? basePath
|
||||
: basePath + Path.DirectorySeparatorChar;
|
||||
|
||||
if (!fullPath.StartsWith(fence, StringComparison.OrdinalIgnoreCase))
|
||||
return (null, $"Zugriff außerhalb des {label}-Workspace verweigert.");
|
||||
|
||||
return (new Resolved(fullPath, label), null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MIME-Typ nach Dateiendung. Rocket.Chat zeigt Bilder und PDFs nur dann in der
|
||||
/// Vorschau, wenn der Typ stimmt — <c>application/octet-stream</c> landet immer als
|
||||
/// stummer Download.
|
||||
/// </summary>
|
||||
public static string GuessContentType(string fileName) =>
|
||||
Path.GetExtension(fileName).ToLowerInvariant() switch
|
||||
{
|
||||
".txt" or ".log" => "text/plain",
|
||||
".md" => "text/markdown",
|
||||
".csv" => "text/csv",
|
||||
".json" => "application/json",
|
||||
".xml" => "application/xml",
|
||||
".html" or ".htm" => "text/html",
|
||||
".pdf" => "application/pdf",
|
||||
".png" => "image/png",
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".zip" => "application/zip",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".odt" => "application/vnd.oasis.opendocument.text",
|
||||
".ods" => "application/vnd.oasis.opendocument.spreadsheet",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user