- IAgentTool.ReadsForeignContent: Werkzeuge mit Aussenkontakt erklaeren sich selbst (WebFetch, WebMonitor, DirectAPI, Mail, RocketChat, Telegram, TelegramClient, SocialMediaManager, FTP, Database); AgentConfig.TreatAsForeignReader kann nur verschaerfen - ReportingLine: Stufe aus den Werkzeugen, "wer steht darueber", Pruefung des Wegs nach oben (nie Aenderungen an Aufgaben darueber, nur eine Ebene hoch, Fremdinhalt-Stufe gar kein Freitext) und Rahmung als untrusted_content - Engine: Nachricht und Spawn nach oben geprueft, Text der Fremdinhalt-Stufe kommt gerahmt an - Taskboard: task_create/update/comment, reopen und die Dubletten-Notiz folgen dem Berichtsweg - Dispatcher: Aufgaben von Agenten der Fremdinhalt-Stufe gehen gerahmt in den Lauf - TaskReports: Erledigt-Signal und Systemmeldung (in Pruefung, blockiert, abgebrochen) an den Auftraggeber, je Zustand einmal, gesammelt in einer offenen Aufgabe je Empfaenger - OrgBriefing nennt den Berichtsweg; WebUI zeigt Stufe und Schalter am Agenten Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
684 lines
29 KiB
C#
684 lines
29 KiB
C#
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>
|
|
/// 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 <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";
|
|
|
|
/// <summary>#715: Brings text from outside into the context — chat messages from outside.</summary>
|
|
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<ToolResult> 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<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' 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
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sends a file from the agent's workspace to a room.
|
|
/// </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' 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
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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,
|
|
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
|
|
}));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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)
|
|
{
|
|
".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)
|
|
})
|
|
})
|
|
}
|
|
});
|
|
|
|
// Untrusted text — framed as data, not as instructions (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($"Room {room.Label} marked as read.");
|
|
}
|
|
|
|
/// <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' 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<RoomSubscription> 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<ToolJobDefinition> 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<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($"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<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));
|
|
}
|
|
|
|
// 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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,
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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)));
|
|
|
|
/// <summary>
|
|
/// Limits how often a room can wake the agent per hour.
|
|
/// </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: 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<RoomMessage> 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<string> 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)
|
|
=> $"<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;
|
|
}
|