using System.Diagnostics; using System.Text; namespace JournalBot.Shared; public sealed record BotResult(int ExitCode, string Output); public sealed class BotRunner { private readonly string _pythonExe; private readonly string _workingDir; public BotRunner(string pythonExe, string workingDir) { _pythonExe = pythonExe; _workingDir = workingDir; } // Runs `python -m journal_bot `. public Task RunAsync(string command, CancellationToken ct = default) => RunRawAsync(new[] { "-m", "journal_bot", command }, ct); // Runs python with arbitrary args (used by tests and RunAsync). public async Task RunRawAsync(IReadOnlyList args, CancellationToken ct = default) { var psi = new ProcessStartInfo { FileName = _pythonExe, WorkingDirectory = _workingDir, RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8, }; foreach (var a in args) psi.ArgumentList.Add(a); var sb = new StringBuilder(); var gate = new object(); using var proc = new Process { StartInfo = psi }; proc.OutputDataReceived += (_, e) => { if (e.Data is not null) { lock (gate) sb.AppendLine(e.Data); } }; proc.ErrorDataReceived += (_, e) => { if (e.Data is not null) { lock (gate) sb.AppendLine(e.Data); } }; if (!proc.Start()) throw new InvalidOperationException($"Failed to start process: {_pythonExe}"); proc.BeginOutputReadLine(); proc.BeginErrorReadLine(); try { await proc.WaitForExitAsync(ct); } catch (OperationCanceledException) { try { proc.Kill(entireProcessTree: true); } catch { /* already exited */ } throw; } return new BotResult(proc.ExitCode, sb.ToString()); } }