Files
ClawdDotNet/src/ClawdDotNet.Tools.SocialMediaManager/SocialMediaManagerTool.cs
T
RichardandClaude Opus 5.5 257ba87cf5 Sandbox-Runner mit bubblewrap (#654) - noch ungeprueft unter Linux
Jede Codeausfuehrung ausserhalb des Hauptprozesses: eigene Namensraeume
(Mount, PID, Netz nur loopback, User ohne verschachtelte), nur der
Arbeitsbereich unter /work beschreibbar, scripts/ darin schreibgeschuetzt,
keine Umgebung und keine Deskriptoren des Hauptprozesses, alle Capabilities
weg, no_new_privs, seccomp-Sperrliste (eigene BPF, x86_64 und arm64),
Grenzen fuer Zeit, Speicher, Prozesse, Dateigroesse und Ausgabe. Unter
Windows oder ohne bwrap: keine Ausfuehrung, mit Grund im Protokoll.

Erster Nutzer: ffmpeg im SocialMediaManager (fremde Mediendateien). yt-dlp
braucht Netz und folgt mit dem Egress-Proxy (#655).

Die Ausbruchstests (SandboxEscapeTests) laufen nur unter Linux mit bwrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 18:40:42 +02:00

1294 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Diagnostics;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using ClawdDotNet.Core.State;
using ClawdDotNet.Core.Tools;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Tools.SocialMediaManager;
public sealed class SocialMediaManagerTool : IAgentTool, IToolJobProvider, ClawdDotNet.Core.Sandbox.ISandboxUser
{
public string Name => "SocialMediaManager";
/// <summary>#715: Brings text from outside into the context — posts and comments of the public.</summary>
public bool ReadsForeignContent => true;
public string Description => """
Manager for Social Media Monitoring (X/Twitter, Reddit) and YouTube Transcripts.
Actions:
- x_search: Free-text search on X/Twitter
- x_user_tweets: Retrieve tweets from a specific user (specify username)
- reddit_search: Free-text search on Reddit
- reddit_subreddit: Retrieve new posts from a specific subreddit (specify subreddit)
- youtube_transcript: Transcribe a YouTube video
Background jobs (configured in toolJobs):
- sm_x_account_monitor: Monitors configured X accounts for new tweets (Config: xWatchAccounts)
- sm_reddit_monitor: Monitors configured subreddits for new posts (Config: redditWatchSubreddits)
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["x_search", "x_user_tweets", "reddit_search", "reddit_subreddit", "youtube_transcript"],
"description": "The action to execute"
},
"query": {
"type": "string",
"description": "Search term for x_search or reddit_search"
},
"username": {
"type": "string",
"description": "X/Twitter username without @ (only for x_user_tweets)"
},
"subreddit": {
"type": "string",
"description": "Subreddit name without r/ (only for reddit_subreddit)"
},
"url": {
"type": "string",
"description": "YouTube video URL or video ID (only for youtube_transcript)"
},
"limit": {
"type": "integer",
"description": "Maximum number of results (default: 10)"
}
},
"required": ["action"]
}
""").RootElement.Clone();
private readonly HttpClient _httpClient = new();
public async Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var action = input.GetProperty("action").GetString() ?? throw new ArgumentException("Action is required");
// Save per-agent scoped WorkspacePath in StateStore for fallback
await context.StateStore.SetAsync($"sm_workspace_path:{context.AgentId}", context.WorkspacePath!, ct);
try
{
return action switch
{
"x_search" => await HandleXSearchAsync(input, context, ct),
"x_user_tweets" => await HandleXUserTweetsAsync(input, context, ct),
"reddit_search" => await HandleRedditSearchAsync(input, context, ct),
"reddit_subreddit" => await HandleRedditSubredditAsync(input, context, ct),
"youtube_transcript" => await HandleYoutubeTranscriptAsync(input, context, ct),
_ => ToolResult.Fail($"Unknown action: {action}")
};
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Error in SocialMediaManager ExecuteAsync ({Action})", action);
return ToolResult.Fail($"Error: {ex.Message}");
}
}
private async Task<ToolResult> HandleXSearchAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var query = input.TryGetProperty("query", out var q) ? q.GetString() : "";
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
var apiKey = context.ToolConfig.GetValueOrDefault("xApiKey")?.ToString();
if (string.IsNullOrWhiteSpace(apiKey)) return ToolResult.Fail("X API key missing.");
using var request = new HttpRequestMessage(HttpMethod.Get, $"https://api.twitter.com/2/tweets/search/recent?query={Uri.EscapeDataString(query ?? "")}&max_results={limit}");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode) return ToolResult.Fail($"X API error: {response.StatusCode} - {content}");
return ToolResult.Ok(content);
}
private async Task<ToolResult> HandleRedditSearchAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var query = input.TryGetProperty("query", out var q) ? q.GetString() : "";
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
var url = $"https://www.reddit.com/search.json?q={Uri.EscapeDataString(query ?? "")}&limit={limit}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("ClawdDotNet/1.0");
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode) return ToolResult.Fail($"Reddit API error: {response.StatusCode}");
return ToolResult.Ok(content);
}
private async Task<ToolResult> HandleXUserTweetsAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var username = input.TryGetProperty("username", out var u) ? u.GetString() : null;
if (string.IsNullOrWhiteSpace(username))
return ToolResult.Fail("'username' is required (without @).");
var cleanName = username.TrimStart('@').ToLowerInvariant();
// 1. Lokalen Cache prüfen — bereits abgerufene Tweets zurückgeben ohne API-Call
var cacheDir = Path.Combine(context.WorkspacePath!, "XMonitor");
if (Directory.Exists(cacheDir))
{
var cachedFiles = Directory.GetFiles(cacheDir, "*.json")
.OrderByDescending(f => f)
.Take(5); // Letzte 5 Cache-Dateien durchsuchen
var cachedTweets = new List<JsonElement>();
foreach (var file in cachedFiles)
{
try
{
var json = await File.ReadAllTextAsync(file, ct);
using var doc = JsonDocument.Parse(json);
foreach (var tweet in doc.RootElement.EnumerateArray())
{
if (tweet.TryGetProperty("Account", out var acc) &&
acc.GetString()?.TrimStart('@').Equals(cleanName, StringComparison.OrdinalIgnoreCase) == true)
{
cachedTweets.Add(tweet.Clone());
}
}
}
catch { /* fehlerhafte Datei ignorieren */ }
}
if (cachedTweets.Count > 0)
{
return ToolResult.Ok(
$"[From local cache — no API call needed, {cachedTweets.Count} tweets]\n" +
JsonSerializer.Serialize(cachedTweets, new JsonSerializerOptions { WriteIndented = true }));
}
}
// 2. Kein Cache vorhanden → Einmalige Abfrage über die Search API (1 API-Call)
var apiKey = context.ToolConfig.GetValueOrDefault("xApiKey")?.ToString();
if (string.IsNullOrWhiteSpace(apiKey)) return ToolResult.Fail("X API key (xApiKey) missing in tool config.");
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
var searchUrl = $"https://api.twitter.com/2/tweets/search/recent" +
$"?query=from:{Uri.EscapeDataString(cleanName)}" +
$"&max_results={Math.Clamp(limit, 10, 100)}" +
$"&tweet.fields=created_at,public_metrics,author_id";
using var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"X API error: {response.StatusCode} - {content}");
return ToolResult.Ok($"[1 API call used]\n{content}");
}
private async Task<ToolResult> HandleRedditSubredditAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var subreddit = input.TryGetProperty("subreddit", out var s) ? s.GetString() : null;
var limit = input.TryGetProperty("limit", out var l) ? l.GetInt32() : 10;
if (string.IsNullOrWhiteSpace(subreddit))
return ToolResult.Fail("'subreddit' is required (without r/).");
var url = $"https://www.reddit.com/r/{Uri.EscapeDataString(subreddit)}/new.json?limit={limit}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("ClawdDotNet/1.0");
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
return ToolResult.Fail($"Reddit API error: {response.StatusCode} - {content}");
// Kompakte Darstellung: Nur relevante Felder extrahieren
var posts = ExtractRedditPosts(content);
return ToolResult.Ok(JsonSerializer.Serialize(posts, new JsonSerializerOptions { WriteIndented = true }));
}
private async Task<ToolResult> HandleYoutubeTranscriptAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
{
var url = input.TryGetProperty("url", out var u) ? u.GetString() : "";
if (string.IsNullOrWhiteSpace(url)) return ToolResult.Fail("URL missing.");
var (available, path, hasFfmpeg, ffmpegPath) = await VerifyYtDlpAsync();
if (!available) return ToolResult.Fail(GetYtDlpInstallationGuide());
var targetDir = Path.Combine(context.WorkspacePath!, "YTTranscript");
if (!Directory.Exists(targetDir)) Directory.CreateDirectory(targetDir);
// Auftragskontext speichern damit der Agent nach dem Wake weiß was zu tun ist
await SavePendingTaskAsync(context, url, ct);
// Wir starten den Prozess im Hintergrund
_ = ProcessYoutubeVideoAsync(url, context.ToolConfig, context.WorkspacePath!, context.Logger, path!, hasFfmpeg, ffmpegPath, ct);
return ToolResult.Ok($"Transcription for {url} has been started and is running in the background. " +
"Downloading, splitting, and transcribing may take 2–10 minutes depending on video length. " +
"You will be automatically notified once the transcript is ready (Transcript Notifier). " +
"Tell the user that transcription has been started and they will receive the result in a few minutes.");
}
private async Task SavePendingTaskAsync(AgentToolContext context, string url, CancellationToken ct)
{
var pendingDir = Path.Combine(context.WorkspacePath!, "YTTranscript");
if (!Directory.Exists(pendingDir)) Directory.CreateDirectory(pendingDir);
var pendingFile = Path.Combine(pendingDir, "pending_tasks.json");
List<PendingTranscriptTask> tasks;
if (File.Exists(pendingFile))
{
var existingJson = await File.ReadAllTextAsync(pendingFile, ct);
tasks = JsonSerializer.Deserialize<List<PendingTranscriptTask>>(existingJson) ?? new();
}
else
{
tasks = new();
}
tasks.Add(new PendingTranscriptTask
{
Url = url,
RequestedBy = context.AgentId,
RequestedAt = DateTime.UtcNow,
Completed = false
});
await File.WriteAllTextAsync(pendingFile, JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true }), ct);
}
private sealed class PendingTranscriptTask
{
public string Url { get; set; } = "";
public string RequestedBy { get; set; } = "";
public DateTime RequestedAt { get; set; }
public bool Completed { get; set; }
}
// --- IToolJobProvider ---
public IReadOnlyList<ToolJobDefinition> GetJobDefinitions() =>
[
new("sm_yt_monitor", "YouTube Monitor", "Checks channels for new videos and starts transcription"),
new("sm_transcript_notifier", "Transcript Notifier", "Informs the agent about new transcripts"),
new("sm_x_account_monitor", "X Account Monitor", "Monitors configured X accounts for new tweets (Config: xWatchAccounts)"),
new("sm_reddit_monitor", "Reddit Monitor", "Monitors configured subreddits for new posts (Config: redditWatchSubreddits)")
];
public async Task<ToolJobResult> ExecuteJobAsync(string jobTypeId, IReadOnlyDictionary<string, object?> toolConfig, IStateStore stateStore, ILogger logger, CancellationToken ct, string? agentId = null, string? workspacePath = null)
{
// Use directly passed path, fallback to per-agent scoped StateStore
if (string.IsNullOrWhiteSpace(workspacePath) && !string.IsNullOrWhiteSpace(agentId))
workspacePath = await stateStore.GetAsync($"sm_workspace_path:{agentId}", ct);
if (string.IsNullOrWhiteSpace(workspacePath))
{
return ToolJobResult.NoAction("WorkspacePath unknown. Please run tool manually once.");
}
return jobTypeId switch
{
"sm_yt_monitor" => await ExecuteYoutubeMonitorJobAsync(toolConfig, workspacePath, stateStore, logger, ct),
"sm_transcript_notifier" => await ExecuteTranscriptNotifierJobAsync(toolConfig, workspacePath, stateStore, logger, ct),
"sm_x_account_monitor" => await ExecuteXAccountMonitorJobAsync(toolConfig, workspacePath, stateStore, logger, ct),
"sm_reddit_monitor" => await ExecuteRedditMonitorJobAsync(toolConfig, workspacePath, stateStore, logger, ct),
_ => ToolJobResult.NoAction($"Unknown job: {jobTypeId}")
};
}
private async Task<ToolJobResult> ExecuteYoutubeMonitorJobAsync(IReadOnlyDictionary<string, object?> toolConfig, string workspacePath, IStateStore stateStore, ILogger logger, CancellationToken ct)
{
var (available, path, hasFfmpeg, ffmpegPath) = await VerifyYtDlpAsync();
if (!available) return ToolJobResult.NoAction("YouTube Monitor disabled: " + GetYtDlpInstallationGuide());
var channels = GetConfigArray(toolConfig, "youtubeChannels").ToList();
if (channels.Count == 0) return ToolJobResult.NoAction("No channels configured.");
var lastVidsJson = await stateStore.GetAsync("sm_yt_last_vids", ct) ?? "{}";
var lastVids = JsonSerializer.Deserialize<Dictionary<string, string>>(lastVidsJson) ?? new();
int count = 0;
foreach (var channelInput in channels)
{
if (channelInput == null) continue;
try
{
// Normalized key for state store
var stateKey = channelInput.Trim().TrimStart('@').ToLowerInvariant();
var latestId = await GetLatestVideoIdAsync(channelInput, path!, ct);
if (string.IsNullOrWhiteSpace(latestId)) continue;
if (!lastVids.TryGetValue(stateKey, out var lastId) || lastId != latestId)
{
logger.LogInformation("New video found: {VideoId} on {Channel}", latestId, channelInput);
// Start transcription
_ = ProcessYoutubeVideoAsync($"https://www.youtube.com/watch?v={latestId}", toolConfig, workspacePath, logger, path!, hasFfmpeg, ffmpegPath, ct);
lastVids[stateKey] = latestId;
count++;
}
}
catch (Exception ex)
{
logger.LogWarning("Error checking channel {Channel}: {Msg}", channelInput, ex.Message);
}
}
await stateStore.SetAsync("sm_yt_last_vids", JsonSerializer.Serialize(lastVids), ct);
return ToolJobResult.NoAction(count > 0 ? $"{count} new video(s) started for transcription." : "No new videos.");
}
private async Task<ToolJobResult> ExecuteTranscriptNotifierJobAsync(IReadOnlyDictionary<string, object?> toolConfig, string workspacePath, IStateStore stateStore, ILogger logger, CancellationToken ct)
{
var targetDir = Path.Combine(workspacePath, "YTTranscript");
if (!Directory.Exists(targetDir))
return ToolJobResult.NoAction("YTTranscript directory does not exist yet");
var processedFilesJson = await stateStore.GetAsync("sm_processed_files", ct) ?? "[]";
var processedFiles = JsonSerializer.Deserialize<HashSet<string>>(processedFilesJson) ?? new();
var allTxtFiles = Directory.GetFiles(targetDir, "*.txt");
var newFiles = allTxtFiles
.Select(Path.GetFileName)
.Where(f => f != null
&& !processedFiles.Contains(f)
&& !f.Contains("summary", StringComparison.OrdinalIgnoreCase))
.ToList();
if (newFiles.Count == 0)
return ToolJobResult.NoAction($"No new transcripts ({allTxtFiles.Length} existing, {processedFiles.Count} already processed)");
foreach (var f in newFiles) if (f != null) processedFiles.Add(f);
await stateStore.SetAsync("sm_processed_files", JsonSerializer.Serialize(processedFiles), ct);
// Pending Tasks lesen um dem Agent mitzuteilen wer auf das Ergebnis wartet
var pendingContext = "";
var pendingFile = Path.Combine(targetDir, "pending_tasks.json");
if (File.Exists(pendingFile))
{
try
{
var pendingJson = await File.ReadAllTextAsync(pendingFile, ct);
var tasks = JsonSerializer.Deserialize<List<PendingTranscriptTask>>(pendingJson);
var openTasks = tasks?.Where(t => !t.Completed).ToList();
if (openTasks?.Count > 0)
{
pendingContext = "\n\n[TASK CONTEXT - Who is waiting for these transcripts:]\n" +
string.Join("\n", openTasks.Select(t =>
$" - Video URL: {t.Url} | Requester Agent: {t.RequestedBy} | Requested: {t.RequestedAt:yyyy-MM-dd HH:mm}"));
pendingContext += "\n\nIMPORTANT: Inform the requester agent via AgentComm about the completed transcript! " +
"Briefly summarize the content and let them know where to read the full transcript.";
// Tasks als completed markieren
foreach (var t in openTasks) t.Completed = true;
await File.WriteAllTextAsync(pendingFile, JsonSerializer.Serialize(tasks, new JsonSerializerOptions { WriteIndented = true }), ct);
}
}
catch (Exception ex)
{
logger.LogWarning(ex, "Could not read pending_tasks.json");
}
}
return ToolJobResult.WakeStateless(
$"[SYSTEM EVENT: New transcripts available]\n" +
$"There are {newFiles.Count} new video transcript(s) ready in your personal workspace under YTTranscript/:\n" +
string.Join("\n", newFiles.Select(f => $" - {f}")) +
pendingContext +
"\n\nPlease read the transcripts now using the FileRW tool (action: read, workspace: personal, path: YTTranscript/<filename>) and process them.",
logSummary: $"{newFiles.Count} new transcript(s) found"
);
}
// --- Helper ---
// Channel-Auflösung liegt jetzt in YouTubeUrl.TryResolveChannelUrl — dort wird die
// Eingabe validiert, bevor sie an einen externen Prozess geht.
private async Task<string?> GetLatestVideoIdAsync(string channelInput, string ytDlpPath, CancellationToken ct)
{
try
{
if (!YouTubeUrl.TryResolveChannelUrl(channelInput, out var channelUrl, out _))
return null;
var psi = new ProcessStartInfo(ytDlpPath)
{
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var arg in YouTubeUrl.BuildLatestVideoIdArgs(channelUrl))
psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process == null) return null;
var output = await process.StandardOutput.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
return output.Trim();
}
catch (Exception)
{
return null;
}
}
private async Task<string> ProcessYoutubeVideoAsync(string url, IReadOnlyDictionary<string, object?> toolConfig, string workspacePath, ILogger logger, string ytDlpPath, bool hasFfmpeg, string? ffmpegPath, CancellationToken ct)
{
try
{
// Validate input before passing to external process
if (!YouTubeUrl.TryResolveChannelUrl(url, out var safeUrl, out var urlError))
throw new ArgumentException($"Invalid YouTube URL: {urlError}");
var targetDir = Path.Combine(workspacePath, "YTTranscript");
var tmpDir = Path.Combine(targetDir, "tmp");
if (!Directory.Exists(tmpDir)) Directory.CreateDirectory(tmpDir);
// 1. Video-ID ermitteln
var videoId = await GetLatestVideoIdAsync(safeUrl, ytDlpPath, ct);
if (string.IsNullOrWhiteSpace(videoId)) videoId = Guid.NewGuid().ToString();
// 2. Audio herunterladen
string audioFile;
var ffmpegDir = !string.IsNullOrEmpty(ffmpegPath) && ffmpegPath != "ffmpeg"
? Path.GetDirectoryName(ffmpegPath)
: null;
if (hasFfmpeg)
{
// With ffmpeg: yt-dlp converts directly to mp3
audioFile = Path.Combine(tmpDir, $"{videoId}.mp3");
await RunYtDlpAsync(ytDlpPath,
YouTubeUrl.BuildAudioDownloadArgs(audioFile, safeUrl, ffmpegDir, convertToMp3: true),
logger, ct);
}
else
{
// Without ffmpeg: download audio in original format
var outputTemplate = Path.Combine(tmpDir, $"{videoId}.%(ext)s");
await RunYtDlpAsync(ytDlpPath,
YouTubeUrl.BuildAudioDownloadArgs(outputTemplate, safeUrl, null, convertToMp3: false),
logger, ct);
audioFile = ""; // looked up below
}
if (!File.Exists(audioFile))
{
var foundFile = Directory.GetFiles(tmpDir, videoId + ".*")
.FirstOrDefault(f => !f.EndsWith(".part") && !f.EndsWith(".ytdl"));
if (foundFile != null)
{
audioFile = foundFile;
logger.LogInformation("Audio file found: {File} ({Size} MB)",
Path.GetFileName(foundFile), new FileInfo(foundFile).Length / 1024.0 / 1024.0);
}
else
{
throw new FileNotFoundException("Could not download audio.");
}
}
// 3. Transcribe via OpenRouter
var apiKey = toolConfig.GetValueOrDefault("openRouterApiKey")?.ToString();
var model = toolConfig.GetValueOrDefault("sttModel")?.ToString() ?? "openai/whisper-1";
if (string.IsNullOrWhiteSpace(apiKey))
{
logger.LogError("OpenRouter API key missing in SocialMediaManager tool config (openRouterApiKey). " +
"Audio was downloaded to: {AudioFile}. Please configure openRouterApiKey in agent configuration.", audioFile);
return audioFile;
}
var fileSize = new FileInfo(audioFile).Length;
logger.LogInformation("Starting transcription for {File} ({Size:F1} MB) via OpenRouter ({Model}), ffmpeg={HasFfmpeg}",
Path.GetFileName(audioFile), fileSize / 1024.0 / 1024.0, model, hasFfmpeg);
var transcript = await TranscribeAudioAsync(audioFile, apiKey, model, hasFfmpeg, ffmpegPath, ytDlpPath, logger, ct, workspacePath);
// 4. Save transcript
var transcriptFile = Path.Combine(targetDir, $"{videoId}.txt");
await File.WriteAllTextAsync(transcriptFile, transcript, ct);
logger.LogInformation("Transcript successfully saved: {File}", Path.GetFileName(transcriptFile));
return transcriptFile;
}
catch (Exception ex)
{
logger.LogError(ex, "Error in ProcessYoutubeVideoAsync for {Url}", url);
throw;
}
}
/// <summary>
/// Starts yt-dlp. Arguments are passed individually (ArgumentList) so that
/// no value can accidentally be interpreted as another option.
/// </summary>
private async Task RunYtDlpAsync(string ytDlpPath, List<string> arguments, ILogger logger, CancellationToken ct)
{
try
{
var psi = new ProcessStartInfo(ytDlpPath)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true
};
foreach (var arg in arguments)
psi.ArgumentList.Add(arg);
using var process = Process.Start(psi);
if (process != null)
{
var stderr = await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
if (process.ExitCode != 0)
logger.LogWarning("yt-dlp exited with code {Code}: {Stderr}", process.ExitCode, stderr);
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error calling yt-dlp.");
throw;
}
}
private const long MaxChunkBytes = 24 * 1024 * 1024; // 24 MB (Whisper-Limit: 25 MB, Puffer)
/// <summary>#654: Where ffmpeg runs — set by the instance at start.</summary>
public ClawdDotNet.Core.Sandbox.ISandboxRunner? Sandbox { get; set; }
private int _warnedUnsandboxed;
private async Task<string> TranscribeAudioAsync(string filePath, string apiKey, string model,
bool hasFfmpeg, string? ffmpegPath, string ytDlpPath, ILogger logger, CancellationToken ct, string? workspacePath = null)
{
var fileInfo = new FileInfo(filePath);
if (!fileInfo.Exists)
throw new FileNotFoundException($"Audio file not found: {filePath}");
// Kleine Dateien direkt transkribieren
if (fileInfo.Length <= MaxChunkBytes)
return await TranscribeChunkAsync(filePath, apiKey, model, logger, ct);
logger.LogInformation("File is {Size:F1} MB — chunking required", fileInfo.Length / 1024.0 / 1024.0);
// Große Dateien in Chunks aufteilen
var chunkDir = Path.Combine(Path.GetDirectoryName(filePath)!, "chunks");
if (Directory.Exists(chunkDir)) Directory.Delete(chunkDir, true);
Directory.CreateDirectory(chunkDir);
try
{
List<string> chunks;
if (hasFfmpeg && workspacePath is not null && Sandbox is { IsAvailable: true } sandbox
&& ClawdDotNet.Core.Sandbox.SandboxPaths.InSandbox(workspacePath, filePath) is { } inFile
&& ClawdDotNet.Core.Sandbox.SandboxPaths.InSandbox(workspacePath, chunkDir) is { } inChunks)
{
// #654: The media comes from the internet — ffmpeg parses it in the sandbox,
// seeing nothing but the workspace, without network.
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
var result = await sandbox.RunAsync(new ClawdDotNet.Core.Sandbox.SandboxRequest
{
Executable = ffmpegExe,
Arguments = ["-hide_banner", "-loglevel", "error", "-i", inFile, "-f", "segment", "-segment_time", "1200",
"-c:a", "libmp3lame", "-q:a", "5", inChunks + "/chunk_%03d.mp3"],
WorkspacePath = workspacePath,
// A program outside /usr is made visible where it lies — read-only.
ReadOnlyPaths = Path.IsPathRooted(ffmpegExe) && !ffmpegExe.StartsWith("/usr/", StringComparison.Ordinal)
? [Path.GetDirectoryName(ffmpegExe)!]
: [],
Limits = new ClawdDotNet.Core.Sandbox.SandboxLimits
{
Timeout = TimeSpan.FromMinutes(30),
MaxFileBytes = 200L * 1024 * 1024,
MaxOutputBytes = 256 * 1024
}
}, ct);
if (!result.Succeeded)
logger.LogWarning("ffmpeg in the sandbox ended with {Code}{Timeout}: {Error}",
result.ExitCode, result.TimedOut ? " (timeout)" : "", result.StandardError.Trim());
chunks = Directory.GetFiles(chunkDir, "chunk_*.mp3").OrderBy(f => f).ToList();
}
else if (hasFfmpeg)
{
// #654: Without a sandbox (Windows, or no bwrap) as before — said once. yt-dlp,
// which needs the network, still runs this way everywhere until #655 brings the
// sandbox a network of its own.
if (OperatingSystem.IsLinux() && Interlocked.Exchange(ref _warnedUnsandboxed, 1) == 0)
logger.LogWarning("ffmpeg runs without a sandbox: {Reason}",
Sandbox?.UnavailableReason ?? "no sandbox was handed to the tool");
// ffmpeg: 20-minute segments as mp3 (under 25 MB Whisper limit at q:a 5)
var chunkPattern = Path.Combine(chunkDir, "chunk_%03d.mp3");
var ffmpegExe = !string.IsNullOrEmpty(ffmpegPath) ? ffmpegPath : "ffmpeg";
var psi = new ProcessStartInfo(ffmpegExe)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true
};
foreach (var arg in new[]
{
"-i", filePath,
"-f", "segment",
"-segment_time", "1200",
"-c:a", "libmp3lame",
"-q:a", "5",
chunkPattern
})
{
psi.ArgumentList.Add(arg);
}
using var process = Process.Start(psi);
if (process is not null)
{
await process.StandardError.ReadToEndAsync(ct);
await process.WaitForExitAsync(ct);
}
chunks = Directory.GetFiles(chunkDir, "chunk_*.mp3").OrderBy(f => f).ToList();
}
else
{
// Fallback without ffmpeg
logger.LogInformation("ffmpeg not available — using yt-dlp segmentation");
chunks = await ChunkWithYtDlpAsync(filePath, chunkDir, ytDlpPath, logger, ct);
}
if (chunks.Count == 0)
throw new InvalidOperationException(
"Chunking failed — no segments produced. " +
(hasFfmpeg ? "ffmpeg error?" : "Install ffmpeg for reliable chunking: 'winget install ffmpeg'"));
logger.LogInformation("{Count} chunks produced, starting transcription...", chunks.Count);
var transcripts = new List<string>();
for (int i = 0; i < chunks.Count; i++)
{
logger.LogInformation("Transcribing chunk {Index}/{Total}: {File} ({Size:F1} MB)",
i + 1, chunks.Count, Path.GetFileName(chunks[i]), new FileInfo(chunks[i]).Length / 1024.0 / 1024.0);
var chunkText = await TranscribeChunkAsync(chunks[i], apiKey, model, logger, ct);
transcripts.Add(chunkText);
}
return string.Join("\n\n", transcripts);
}
finally
{
if (Directory.Exists(chunkDir))
Directory.Delete(chunkDir, true);
}
}
/// <summary>
/// Fallback chunking without ffmpeg.
///
/// <para>yt-dlp cannot split a file that is already downloaded, and without ffmpeg
/// nothing else here can either. So a file that fits the Whisper limit is sent as a
/// single chunk; anything larger fails with an installation hint instead of a
/// truncated transcript.</para>
/// </summary>
private async Task<List<string>> ChunkWithYtDlpAsync(string filePath, string chunkDir,
string ytDlpPath, ILogger logger, CancellationToken ct)
{
var fileSize = new FileInfo(filePath).Length;
if (fileSize <= 25 * 1024 * 1024) // Whisper limit exact 25 MB
{
logger.LogInformation("File is {Size:F1} MB — attempting direct upload anyway (Whisper limit: 25 MB)", fileSize / 1024.0 / 1024.0);
return [filePath];
}
logger.LogWarning(
"Audio file is {Size:F1} MB and ffmpeg is not installed. " +
"Chunking without ffmpeg not possible. Please install ffmpeg: 'winget install ffmpeg'",
fileSize / 1024.0 / 1024.0);
throw new InvalidOperationException(
$"The audio file is {fileSize / 1024.0 / 1024.0:F1} MB and exceeds the Whisper limit of 25 MB. " +
"For large videos, ffmpeg is required for splitting. Installation: 'winget install ffmpeg'");
}
private static string GetMimeType(string filePath) => Path.GetExtension(filePath).ToLowerInvariant() switch
{
".mp3" => "audio/mpeg",
".webm" => "audio/webm",
".m4a" => "audio/mp4",
".ogg" or ".opus" => "audio/ogg",
".wav" => "audio/wav",
".flac" => "audio/flac",
_ => "application/octet-stream"
};
private async Task<string> TranscribeChunkAsync(string filePath, string apiKey, string model,
ILogger logger, CancellationToken ct)
{
var fileName = Path.GetFileName(filePath);
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
var audioBase64 = Convert.ToBase64String(fileBytes);
var audioFormat = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant();
logger.LogInformation("STT Request: Model={Model}, File={File} ({Size:F1} MB), Format={Format}",
model, fileName, fileBytes.Length / 1024.0 / 1024.0, audioFormat);
var payload = JsonSerializer.Serialize(new
{
model,
input_audio = new { data = audioBase64, format = audioFormat }
});
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
using var request = new HttpRequestMessage(HttpMethod.Post, "https://openrouter.ai/api/v1/audio/transcriptions");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
request.Headers.Add("HTTP-Referer", "https://github.com/ClawdDotNet");
request.Headers.Add("X-Title", "ClawdDotNet Social Media Manager");
request.Content = content;
var response = await _httpClient.SendAsync(request, ct);
var responseContent = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
logger.LogError("STT error for {File}: {Status} - {Response}",
fileName, response.StatusCode, responseContent);
throw new Exception($"STT error ({fileName}): {response.StatusCode} - {responseContent}");
}
var doc = JsonDocument.Parse(responseContent);
return doc.RootElement.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
}
private IEnumerable<string> GetConfigArray(IReadOnlyDictionary<string, object?> config, string key)
{
if (!config.TryGetValue(key, out var val) || val == null) return Array.Empty<string>();
if (val is string[] arr) return arr;
if (val is List<string> list) return list;
if (val is JsonElement je && je.ValueKind == JsonValueKind.Array)
return je.EnumerateArray().Select(x => x.GetString() ?? "").Where(x => !string.IsNullOrWhiteSpace(x));
if (val is string s) return s.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return Array.Empty<string>();
}
private async Task<(bool available, string? path, bool hasFfmpeg, string? ffmpegPath)> VerifyYtDlpAsync()
{
// 1. Check in PATH
string? detectedPath = null;
try
{
var psi = new ProcessStartInfo("yt-dlp", "--version")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true
};
using var process = Process.Start(psi);
if (process != null)
{
await process.WaitForExitAsync();
detectedPath = "yt-dlp";
}
}
catch { }
if (detectedPath == null)
{
// 2. Check local paths
// Unter Linux heißt das Programm ohne Endung. Ohne diese Variante fände die
// Suche im Programmverzeichnis dort nie etwas — und die PATH-Prüfung davor
// ist die einzige, die griffe.
var candidates = LocalToolCandidates("yt-dlp");
foreach (var p in candidates)
{
if (File.Exists(p))
{
detectedPath = p;
break;
}
}
}
if (detectedPath == null) return (false, null, false, null);
// 3. Check for ffmpeg (optional but recommended)
bool hasFfmpeg = false;
string? ffmpegPath = null;
try
{
var psi = new ProcessStartInfo("ffmpeg", "-version")
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true
};
using var process = Process.Start(psi);
if (process != null)
{
await process.WaitForExitAsync();
hasFfmpeg = true;
ffmpegPath = "ffmpeg";
}
}
catch { }
if (!hasFfmpeg)
{
// Check local paths for ffmpeg
var ffmpegCandidates = LocalToolCandidates("ffmpeg");
foreach (var fp in ffmpegCandidates)
{
if (File.Exists(fp))
{
hasFfmpeg = true;
ffmpegPath = fp;
break;
}
}
}
return (true, detectedPath, hasFfmpeg, ffmpegPath);
}
/// <summary>
/// Wo ein mitgeliefertes Hilfsprogramm liegen könnte — Programmverzeichnis und
/// <c>tools/</c>, je mit und ohne <c>.exe</c>.
///
/// Die Endung wird auf beiden Plattformen mitgeprüft, nicht nur auf der jeweils
/// „richtigen": Ein Deployment-Paket, das unter Windows geschnürt wurde, kann die
/// Windows-Binärdatei enthalten, und umgekehrt. Vier Aufrufe von
/// <see cref="File.Exists"/> sind billiger als eine Fallunterscheidung, die man
/// vergessen kann.
/// </summary>
private static string[] LocalToolCandidates(string name)
{
var baseDir = AppContext.BaseDirectory;
return
[
Path.Combine(baseDir, name),
Path.Combine(baseDir, name + ".exe"),
Path.Combine(baseDir, "tools", name),
Path.Combine(baseDir, "tools", name + ".exe")
];
}
private string GetYtDlpInstallationGuide()
{
return "ERROR: Dependencies missing for YouTube features.\n\n" +
"1. yt-dlp: Download 'yt-dlp.exe' from https://github.com/yt-dlp/yt-dlp/releases and place it in 'tools/yt-dlp.exe'.\n" +
"2. ffmpeg (optional): Recommended for MP3 conversion. Without ffmpeg, WebM/M4A files will be used.\n" +
" Installation via: 'winget install ffmpeg'.\n\n" +
"Tip: Use 'winget install yt-dlp ffmpeg' for a quick installation of both tools.";
}
// ═══════════════════════════════════════════════════
// X/TWITTER MONITOR JOB
//
// Cost optimisation:
// - query ALL watched accounts in ONE search query
// ("from:user1 OR from:user2 OR from:user3") → 1 API call instead of N
// - since_id tracking → only tweets newer than the last check
// - results cached locally → manual queries cost no API call
// - first run: set since_id to "now" without fetching old tweets (zero cost)
// ═══════════════════════════════════════════════════
private async Task<ToolJobResult> ExecuteXAccountMonitorJobAsync(
IReadOnlyDictionary<string, object?> toolConfig, string workspacePath,
IStateStore stateStore, ILogger logger, CancellationToken ct)
{
var apiKey = toolConfig.GetValueOrDefault("xApiKey")?.ToString();
if (string.IsNullOrWhiteSpace(apiKey))
return ToolJobResult.NoAction("X API key (xApiKey) not configured");
var accounts = GetConfigArray(toolConfig, "xWatchAccounts").ToList();
if (accounts.Count == 0)
return ToolJobResult.NoAction("No X accounts configured (xWatchAccounts)");
var cleanAccounts = accounts
.Where(a => !string.IsNullOrWhiteSpace(a))
.Select(a => a!.TrimStart('@').ToLowerInvariant())
.Distinct()
.ToList();
// Globale since_id laden (höchste bekannte Tweet-ID über alle Accounts)
var sinceId = await stateStore.GetAsync("sm_x_global_since_id", ct);
// Erster Lauf: Baseline setzen ohne alte Tweets abzurufen
if (string.IsNullOrWhiteSpace(sinceId))
{
// Eine minimale Abfrage machen um die aktuelle höchste ID zu ermitteln
var initQuery = $"from:{cleanAccounts.First()}";
var initUrl = $"https://api.twitter.com/2/tweets/search/recent" +
$"?query={Uri.EscapeDataString(initQuery)}&max_results=10&tweet.fields=created_at";
using var initReq = new HttpRequestMessage(HttpMethod.Get, initUrl);
initReq.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var initResp = await _httpClient.SendAsync(initReq, ct);
if (initResp.IsSuccessStatusCode)
{
var initContent = await initResp.Content.ReadAsStringAsync(ct);
using var initDoc = JsonDocument.Parse(initContent);
// newest_id aus meta extrahieren
if (initDoc.RootElement.TryGetProperty("meta", out var meta) &&
meta.TryGetProperty("newest_id", out var newestIdEl))
{
sinceId = newestIdEl.GetString();
}
}
if (!string.IsNullOrWhiteSpace(sinceId))
{
await stateStore.SetAsync("sm_x_global_since_id", sinceId, ct);
logger.LogInformation("X Monitor: Baseline set (since_id={SinceId}). Cost: 1 API call. From now on only new tweets will be reported.", sinceId);
return ToolJobResult.NoAction($"Baseline set. Starting with next tick only new tweets will be reported. (1 API call)");
}
return ToolJobResult.NoAction("Could not set baseline — retry on next tick.");
}
// ─── Hauptlogik: 1 Search-Query für ALLE Accounts ───
// Query bauen: "from:user1 OR from:user2 OR from:user3"
// X API max Query-Länge: 512 Zeichen (Basic), 1024 (Pro)
var queryParts = cleanAccounts.Select(a => $"from:{a}");
var query = string.Join(" OR ", queryParts);
if (query.Length > 512)
{
// Bei sehr vielen Accounts: aufteilen in Batches
logger.LogWarning("X Monitor: Query too long ({Len} characters), using only the first accounts", query.Length);
var trimmedAccounts = new List<string>();
var currentLen = 0;
foreach (var acc in cleanAccounts)
{
var part = $"from:{acc}";
if (currentLen + part.Length + 4 > 500) break; // 4 = " OR "
trimmedAccounts.Add(acc);
currentLen += part.Length + 4;
}
query = string.Join(" OR ", trimmedAccounts.Select(a => $"from:{a}"));
}
var searchUrl = $"https://api.twitter.com/2/tweets/search/recent" +
$"?query={Uri.EscapeDataString(query)}" +
$"&since_id={sinceId}" +
$"&max_results=100" +
$"&tweet.fields=created_at,public_metrics,author_id" +
$"&expansions=author_id" +
$"&user.fields=username";
using var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
logger.LogWarning("X Monitor API error: {Status} - {Content}", response.StatusCode, content);
return ToolJobResult.NoAction($"X API error: {response.StatusCode}");
}
using var doc = JsonDocument.Parse(content);
// newest_id für nächsten Tick speichern
if (doc.RootElement.TryGetProperty("meta", out var metaEl) &&
metaEl.TryGetProperty("newest_id", out var newSinceIdEl))
{
var newSinceId = newSinceIdEl.GetString();
if (!string.IsNullOrWhiteSpace(newSinceId))
await stateStore.SetAsync("sm_x_global_since_id", newSinceId, ct);
}
// Prüfen ob es überhaupt Ergebnisse gibt
if (!doc.RootElement.TryGetProperty("data", out var dataArr) ||
dataArr.ValueKind != JsonValueKind.Array ||
dataArr.GetArrayLength() == 0)
{
return ToolJobResult.NoAction("No new tweets (1 API call)");
}
// Author-ID → Username Mapping aus "includes.users" aufbauen
var authorMap = new Dictionary<string, string>();
if (doc.RootElement.TryGetProperty("includes", out var includes) &&
includes.TryGetProperty("users", out var usersArr))
{
foreach (var user in usersArr.EnumerateArray())
{
var userId = user.TryGetProperty("id", out var uid) ? uid.GetString() : null;
var uname = user.TryGetProperty("username", out var un) ? un.GetString() : null;
if (userId != null && uname != null)
authorMap[userId] = uname;
}
}
// Tweets in strukturiertes Format umwandeln
var allNewTweets = new List<object>();
foreach (var tweet in dataArr.EnumerateArray())
{
var authorId = tweet.TryGetProperty("author_id", out var aid) ? aid.GetString() : "";
var authorName = authorId != null && authorMap.TryGetValue(authorId, out var name) ? name : authorId;
allNewTweets.Add(new
{
Account = $"@{authorName}",
Id = tweet.TryGetProperty("id", out var tid) ? tid.GetString() : "",
Text = tweet.TryGetProperty("text", out var txt) ? txt.GetString() : "",
CreatedAt = tweet.TryGetProperty("created_at", out var ca) ? ca.GetString() : "",
Metrics = tweet.TryGetProperty("public_metrics", out var pm) ? pm.Clone() : default
});
}
logger.LogInformation("X Monitor: {Count} new tweets found (1 API call for {AccountCount} accounts)",
allNewTweets.Count, cleanAccounts.Count);
// Ergebnisse im Workspace cachen
var resultDir = Path.Combine(workspacePath, "XMonitor");
Directory.CreateDirectory(resultDir);
var resultFile = Path.Combine(resultDir, $"{DateTime.Now:yyyy-MM-dd_HHmmss}_tweets.json");
await File.WriteAllTextAsync(resultFile,
JsonSerializer.Serialize(allNewTweets, new JsonSerializerOptions { WriteIndented = true }),
new UTF8Encoding(false), ct);
// Alte Cache-Dateien aufräumen (nur die letzten 20 behalten)
CleanupOldFiles(resultDir, "*.json", keepCount: 20);
var summary = string.Join("\n", allNewTweets.Take(10).Select(t =>
{
var json = JsonSerializer.SerializeToElement(t);
var account = json.TryGetProperty("Account", out var a) ? a.GetString() : "?";
var text = json.TryGetProperty("Text", out var tx) ? tx.GetString() : "";
var preview = text?.Length > 80 ? text[..80] + "..." : text;
return $" - {account}: {preview}";
}));
var extra = allNewTweets.Count > 10 ? $"\n ... and {allNewTweets.Count - 10} more" : "";
return ToolJobResult.Wake(
$"[X Monitor] {allNewTweets.Count} new tweet(s) (1 API call for {cleanAccounts.Count} accounts):\n\n{summary}{extra}\n\n" +
$"Full data saved in: personal:/XMonitor/{Path.GetFileName(resultFile)}\n" +
$"Read the file with FileRW and analyze the relevant tweets.",
logSummary: $"X: {allNewTweets.Count} new tweet(s) (1 call)"
);
}
// ═══════════════════════════════════════════════════
// REDDIT MONITOR JOB
// ═══════════════════════════════════════════════════
private async Task<ToolJobResult> ExecuteRedditMonitorJobAsync(
IReadOnlyDictionary<string, object?> toolConfig, string workspacePath,
IStateStore stateStore, ILogger logger, CancellationToken ct)
{
var subreddits = GetConfigArray(toolConfig, "redditWatchSubreddits").ToList();
if (subreddits.Count == 0)
return ToolJobResult.NoAction("No subreddits configured (redditWatchSubreddits)");
var redditLimit = 15; // Posts pro Subreddit
if (toolConfig.TryGetValue("redditPostLimit", out var rl) && rl is JsonElement rlElem)
redditLimit = rlElem.TryGetInt32(out var rlVal) ? rlVal : 15;
// Letzte bekannte Post-IDs pro Subreddit laden
var lastPostsJson = await stateStore.GetAsync("sm_reddit_last_posts", ct) ?? "{}";
var lastPosts = JsonSerializer.Deserialize<Dictionary<string, string>>(lastPostsJson) ?? new();
var allNewPosts = new List<object>();
var errors = new List<string>();
foreach (var sub in subreddits)
{
if (string.IsNullOrWhiteSpace(sub)) continue;
var cleanSub = sub.TrimStart('/').Replace("r/", "").Trim();
try
{
var url = $"https://www.reddit.com/r/{Uri.EscapeDataString(cleanSub)}/new.json?limit={redditLimit}";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.UserAgent.ParseAdd("ClawdDotNet/1.0");
var response = await _httpClient.SendAsync(request, ct);
var content = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
errors.Add($"r/{cleanSub}: HTTP {response.StatusCode}");
continue;
}
var posts = ExtractRedditPosts(content);
if (posts.Count == 0) continue;
// Filtern auf neue Posts (nach der letzten bekannten ID)
lastPosts.TryGetValue(cleanSub, out var lastKnownId);
var newPosts = lastKnownId != null
? posts.Where(p =>
{
var postId = p.TryGetValue("Id", out var id) ? id?.ToString() : null;
return postId != null && string.Compare(postId, lastKnownId, StringComparison.Ordinal) > 0;
}).ToList()
: posts; // Erster Lauf: alle Posts melden
if (newPosts.Count == 0) continue;
// Neueste ID speichern
var newestId = posts
.Select(p => p.TryGetValue("Id", out var id) ? id?.ToString() : null)
.Where(id => id != null)
.OrderByDescending(id => id)
.FirstOrDefault();
if (newestId != null)
lastPosts[cleanSub] = newestId;
foreach (var post in newPosts)
{
post["Subreddit"] = $"r/{cleanSub}";
allNewPosts.Add(post);
}
logger.LogInformation("Reddit Monitor: {Count} new posts in r/{Sub}", newPosts.Count, cleanSub);
}
catch (Exception ex)
{
errors.Add($"r/{cleanSub}: {ex.Message}");
logger.LogWarning(ex, "Reddit Monitor error for r/{Sub}", cleanSub);
}
}
// State speichern
await stateStore.SetAsync("sm_reddit_last_posts", JsonSerializer.Serialize(lastPosts), ct);
if (allNewPosts.Count == 0)
{
var errMsg = errors.Count > 0 ? $" (Errors: {string.Join("; ", errors)})" : "";
return ToolJobResult.NoAction($"No new Reddit posts{errMsg}");
}
// Save results in workspace
var resultDir = Path.Combine(workspacePath, "RedditMonitor");
Directory.CreateDirectory(resultDir);
var resultFile = Path.Combine(resultDir, $"{DateTime.Now:yyyy-MM-dd_HHmmss}_posts.json");
await File.WriteAllTextAsync(resultFile,
JsonSerializer.Serialize(allNewPosts, new JsonSerializerOptions { WriteIndented = true }),
new UTF8Encoding(false), ct);
var summary = string.Join("\n", allNewPosts.Take(10).Select(p =>
{
var sub = p is Dictionary<string, object?> d ? d.GetValueOrDefault("Subreddit")?.ToString() : "?";
var title = p is Dictionary<string, object?> d2 ? d2.GetValueOrDefault("Title")?.ToString() : "";
var preview = title?.Length > 80 ? title[..80] + "..." : title;
return $" - [{sub}] {preview}";
}));
var extra = allNewPosts.Count > 10 ? $"\n ... and {allNewPosts.Count - 10} more" : "";
var errorInfo = errors.Count > 0 ? $"\n\n⚠ Errors with: {string.Join(", ", errors)}" : "";
// Cleanup old cache files
CleanupOldFiles(resultDir, "*.json", keepCount: 20);
return ToolJobResult.Wake(
$"[Reddit Monitor] {allNewPosts.Count} new post(s) from monitored subreddits:\n\n{summary}{extra}\n\n" +
$"Full data saved in: personal:/RedditMonitor/{Path.GetFileName(resultFile)}\n" +
$"Read the file with FileRW and analyze the relevant posts.{errorInfo}",
logSummary: $"Reddit Monitor: {allNewPosts.Count} new post(s)"
);
}
// ═══════════════════════════════════════════════════
// HELPER
// ═══════════════════════════════════════════════════
private static void CleanupOldFiles(string directory, string pattern, int keepCount)
{
try
{
var files = Directory.GetFiles(directory, pattern)
.OrderByDescending(f => File.GetCreationTime(f))
.Skip(keepCount)
.ToList();
foreach (var file in files)
{
try { File.Delete(file); } catch { /* best effort */ }
}
}
catch { /* ignorieren */ }
}
// ═══════════════════════════════════════════════════
// REDDIT HELPER
// ═══════════════════════════════════════════════════
private static List<Dictionary<string, object?>> ExtractRedditPosts(string jsonContent)
{
var posts = new List<Dictionary<string, object?>>();
try
{
using var doc = JsonDocument.Parse(jsonContent);
var root = doc.RootElement;
JsonElement children;
if (root.TryGetProperty("data", out var data) && data.TryGetProperty("children", out children))
{
// Standard Reddit listing format
}
else
{
return posts;
}
foreach (var child in children.EnumerateArray())
{
if (!child.TryGetProperty("data", out var postData)) continue;
posts.Add(new Dictionary<string, object?>
{
["Id"] = postData.TryGetProperty("id", out var id) ? id.GetString() : null,
["Title"] = postData.TryGetProperty("title", out var title) ? title.GetString() : null,
["Author"] = postData.TryGetProperty("author", out var author) ? author.GetString() : null,
["Score"] = postData.TryGetProperty("score", out var score) ? score.GetInt32() : 0,
["NumComments"] = postData.TryGetProperty("num_comments", out var nc) ? nc.GetInt32() : 0,
["Url"] = postData.TryGetProperty("url", out var postUrl) ? postUrl.GetString() : null,
["SelfText"] = postData.TryGetProperty("selftext", out var selfText) ? selfText.GetString() : null,
["CreatedUtc"] = postData.TryGetProperty("created_utc", out var created)
? DateTimeOffset.FromUnixTimeSeconds((long)created.GetDouble()).DateTime.ToString("yyyy-MM-dd HH:mm:ss")
: null,
["Permalink"] = postData.TryGetProperty("permalink", out var plink) ? $"https://reddit.com{plink.GetString()}" : null
});
}
}
catch
{
// Fehlerhafte JSON-Struktur ignorieren
}
return posts;
}
}