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; /// /// 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. /// /// The reverse channel runs via : A scheduled tick checks /// without a model (and thus cost-free) whether anything is pending, waking the agent only then. /// public sealed class RocketChatTool : IAgentTool, IToolJobProvider { public string Name => "RocketChat"; /// #715: Brings text from outside into the context — chat messages from outside. public bool ReadsForeignContent => true; public string Description => """ Communication via Rocket.Chat: read and write messages in channels, private groups, and direct messages. 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. Actions: 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: 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": "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": "Message text (for send_message and reply)." }, "threadId": { "type": "string", "description": "ID of the message to reply to (only for reply)." }, "localPath": { "type": "string", "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": "Different filename for the recipient (only for send_file). If omitted, the name in the workspace is used." }, "fileId": { "type": "string", "description": "ID of a received file (only for download_file). Shown in the message as \"fileId: …\"." }, "limit": { "type": "integer", "description": "Number of messages to read in read_room (default 20, max 100)." } } } """).RootElement.Clone(); // ─── IAgentTool ─── public async Task ExecuteAsync( JsonElement input, AgentToolContext context, CancellationToken ct) { if (!input.TryGetProperty("action", out var actionElement) || actionElement.GetString() is not { } action) return ToolResult.Fail( "'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: Action {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( $"Unknown action '{action}'. Valid: send_message, reply, send_file, download_file, list_rooms, read_room, mark_read.") }; } catch (RocketChatException ex) { 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 is unreachable"); return ToolResult.Fail($"Rocket.Chat is unreachable: {ex.Message}"); } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return ToolResult.Fail("Rocket.Chat did not respond in time (timed out)."); } } private static async Task SendMessageAsync( RocketChatClient client, RocketChatConfig config, JsonElement input, bool threadAllowed, CancellationToken ct) { var text = GetString(input, "text"); if (string.IsNullOrWhiteSpace(text)) return ToolResult.Fail("'text' is required."); var threadId = threadAllowed ? GetString(input, "threadId") : null; if (threadAllowed && string.IsNullOrWhiteSpace(threadId)) 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); 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 })); } /// /// Sends a file from the agent's workspace to a room. /// private static async Task SendFileAsync( RocketChatClient client, RocketChatConfig config, JsonElement input, AgentToolContext context, CancellationToken ct) { var localPath = GetString(input, "localPath"); if (string.IsNullOrWhiteSpace(localPath)) return ToolResult.Fail( "'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); if (file is null) return ToolResult.Fail(pathError!); if (!File.Exists(file.FullPath)) return ToolResult.Fail($"File not found: {localPath}"); var info = new FileInfo(file.FullPath); if (info.Length == 0) return ToolResult.Fail($"The file {localPath} is empty."); if (info.Length > config.MaxUploadBytes) return ToolResult.Fail( $"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); 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 })); } /// /// Downloads an attached file into the agent's workspace — the counterpart of /// send_file. The fileId is in the wake message or in read_room. /// private static async Task DownloadFileAsync( RocketChatClient client, RocketChatConfig config, JsonElement input, AgentToolContext context, CancellationToken ct) { var fileId = GetString(input, "fileId"); if (string.IsNullOrWhiteSpace(fileId)) return ToolResult.Fail( "'fileId' is required — it is indicated for each file in the message " + "(\"File: … — fileId: …\")."); // 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; // 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)) { 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}' does not result in a valid filename."); var extension = Path.GetExtension(targetName); if (!config.AllowDangerousDownloads && DangerousExtensions.Contains(extension)) return ToolResult.Fail( $"Files of type '{extension}' are not downloaded. " + "If needed, set 'allowDangerousDownloads' in the tool configuration."); // 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) 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 })); } /// /// 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. /// private static readonly HashSet DangerousExtensions = new(StringComparer.OrdinalIgnoreCase) { ".exe", ".dll", ".bat", ".cmd", ".com", ".scr", ".msi", ".ps1", ".vbs", ".jar", ".lnk", ".reg", ".sh" }; private static async Task 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 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) }) }) } }); // Untrusted text — framed as data, not as instructions (K2). return ToolResult.Ok(Untrusted(payload)); } private static async Task 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($"Room {room.Label} marked as read."); } /// /// 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. /// private static async Task<(RoomSubscription? Room, string? Error)> ResolveRoomAsync( RocketChatClient client, IReadOnlyList rooms, RocketChatConfig config, JsonElement input, CancellationToken ct) { var wanted = GetString(input, "room") ?? config.DefaultRoom; if (string.IsNullOrWhiteSpace(wanted)) return (null, "'room' is required (no default room configured). " + Available(rooms)); var room = RocketChatClient.Resolve(rooms, wanted); // 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, $"Direct messages to '{username}' are not allowed for this agent " + "(see 'allowedRooms' in tool configuration)."); try { room = await client.OpenDirectMessageAsync(username, ct); } catch (RocketChatException ex) { return (null, $"Direct message to '{username}' not possible: {ex.Message}"); } if (room is null) return (null, $"Could not open direct message to '{username}'."); return (room, null); } if (room is null) return (null, $"No room '{wanted}' — this agent is not a member there. " + Available(rooms) + " For a new direct message, specify '@username'."); // Check against both notations: Allowlist can contain IDs or names. if (!config.IsRoomAllowed(room.RoomId) && !config.IsRoomAllowed(room.Name)) 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 rooms) => rooms.Count == 0 ? "The agent is not a member of any room." : "Available: " + string.Join(", ", rooms.Select(r => $"{r.Label} ({r.RoomId})")); // ─── IToolJobProvider ─── public IReadOnlyList GetJobDefinitions() => [ 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 ExecuteJobAsync( string jobTypeId, IReadOnlyDictionary toolConfig, IStateStore stateStore, ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null) { if (jobTypeId != "rocketchat_poll") return ToolJobResult.NoAction($"Unknown job type: {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); // 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}; 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) { 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("Nothing new"); var blocks = new List(); 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)); } // 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: Room {Room} could not be marked as read: {Message}", room.Label, ex.Message); } } if (blocks.Count == 0) return ToolJobResult.NoAction("Nothing new (after filtering)"); logger.LogInformation("RocketChat: {Count} new message(s) in {Rooms} room(s)", total, blocks.Count); return ToolJobResult.Wake(BuildWakeMessage(blocks), $"{total} new message(s)"); } catch (RocketChatException ex) { logger.LogWarning("RocketChat poll failed: {Message}", ex.Message); return ToolJobResult.NoAction($"Poll error: {ex.Message}"); } catch (HttpRequestException ex) { logger.LogWarning("Rocket.Chat is unreachable: {Message}", ex.Message); return ToolJobResult.NoAction($"Unreachable: {ex.Message}"); } catch (TaskCanceledException) when (!ct.IsCancellationRequested) { return ToolJobResult.NoAction("Poll timed out"); } } /// /// 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. /// private static async Task> 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)) { // 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: first poll for {Room} — {Minutes} minutes lookback", 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 []; // 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) .Where(m => !config.MentionOnly || room.IsDirect || IsMentioned(m, config)) .Where(m => !config.IsAgentUser(m.Username) || IsMentioned(m, config)) .ToList(); } /// /// Rocket.Chat returns mentions as usernames — comparison is made against username, not user ID. /// /// 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. /// private static bool IsMentioned(RoomMessage message, RocketChatConfig config) => message.Mentions.Any(m => string.Equals(m, "all", StringComparison.OrdinalIgnoreCase) || string.Equals(m, "here", StringComparison.OrdinalIgnoreCase) || (!string.IsNullOrWhiteSpace(config.Username) && string.Equals(m, config.Username, StringComparison.OrdinalIgnoreCase))); /// /// Limits how often a room can wake the agent per hour. /// private static async Task 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: Wake limit for {Room} reached ({Max}/hour) — messages remain unread", room.Label, config.MaxWakesPerRoomPerHour); return false; } await stateStore.SetAsync(key, (used + 1).ToString(), ct); return true; } private static string FormatRoom(RoomSubscription room, IReadOnlyList messages) { var sb = new StringBuilder(); 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}"); // 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($" File: {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 blocks) => $""" New messages in Rocket.Chat. {Untrusted(string.Join("\n\n", blocks))} 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) => $"\n{content}\n"; // ─── 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; }