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";
/// #715: Brings text from outside into the context — posts and comments of the public.
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 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 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 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 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();
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 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 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 tasks;
if (File.Exists(pendingFile))
{
var existingJson = await File.ReadAllTextAsync(pendingFile, ct);
tasks = JsonSerializer.Deserialize>(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 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 ExecuteJobAsync(string jobTypeId, IReadOnlyDictionary 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 ExecuteYoutubeMonitorJobAsync(IReadOnlyDictionary 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>(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 ExecuteTranscriptNotifierJobAsync(IReadOnlyDictionary 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>(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>(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/) 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 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 ProcessYoutubeVideoAsync(string url, IReadOnlyDictionary 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;
}
}
///
/// Starts yt-dlp. Arguments are passed individually (ArgumentList) so that
/// no value can accidentally be interpreted as another option.
///
private async Task RunYtDlpAsync(string ytDlpPath, List 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)
/// #654: Where ffmpeg runs — set by the instance at start.
public ClawdDotNet.Core.Sandbox.ISandboxRunner? Sandbox { get; set; }
private int _warnedUnsandboxed;
private async Task 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 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();
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);
}
}
///
/// Fallback chunking without ffmpeg.
///
/// 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.
///
private async Task> 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 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 GetConfigArray(IReadOnlyDictionary config, string key)
{
if (!config.TryGetValue(key, out var val) || val == null) return Array.Empty();
if (val is string[] arr) return arr;
if (val is List 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();
}
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);
}
///
/// Wo ein mitgeliefertes Hilfsprogramm liegen könnte — Programmverzeichnis und
/// tools/, je mit und ohne .exe.
///
/// 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
/// sind billiger als eine Fallunterscheidung, die man
/// vergessen kann.
///
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 ExecuteXAccountMonitorJobAsync(
IReadOnlyDictionary 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();
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();
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