/
antonbilyk
/
PeonNotifier
Обзор
Документация
Войти
/
antonbilyk
/
PeonNotifier
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
Program.cs
556 строк
16 KB
antonbilyk
polishing
17 май 2026, 09:42
17 май 2026, 09:42
d2298a3
Код
Авторство
О чём код?
using System.Diagnostics; using System.Net; using System.Net.Sockets; using System.Runtime.InteropServices; using System.Text; #if WINDOWS using System.Drawing; using System.Windows.Forms; #endif internal static class PeonConstants { public const string AppName = "Peon"; public const int Port = 8999; } internal readonly record struct NotificationPayload(string ProjectName, string MessageText); internal static class NotificationMessageParser { public static NotificationPayload Parse(string rawMessage) { var message = rawMessage.Trim(); var separatorIndex = message.IndexOf(':'); if (separatorIndex <= 0) { return new NotificationPayload(PeonConstants.AppName, message); } var projectName = message[..separatorIndex].Trim(); var text = message[(separatorIndex + 1)..].Trim(); if (string.IsNullOrWhiteSpace(projectName)) { projectName = PeonConstants.AppName; } if (string.IsNullOrWhiteSpace(text)) { text = message; } return new NotificationPayload(projectName, text); } } internal static class Program { [STAThread] private static void Main() { #if WINDOWS ApplicationConfiguration.Initialize(); Application.Run(new TrayAppContext()); #else using var cts = new CancellationTokenSource(); Console.CancelKeyPress += (_, eventArgs) => { eventArgs.Cancel = true; cts.Cancel(); }; var app = new ConsoleNotifierApp(); app.RunAsync(cts.Token).GetAwaiter().GetResult(); #endif } } internal sealed class PortListener { private readonly int _port; private readonly Func<string, Task> _onMessage; public PortListener(int port, Func<string, Task> onMessage) { _port = port; _onMessage = onMessage; } public async Task RunAsync(CancellationToken cancellationToken) { var listener = new TcpListener(IPAddress.Any, _port); listener.Start(); using var stopRegistration = cancellationToken.Register(() => { try { listener.Stop(); } catch { // Ignore listener shutdown race conditions. } }); while (!cancellationToken.IsCancellationRequested) { TcpClient client; try { client = await listener.AcceptTcpClientAsync(cancellationToken); } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { break; } _ = Task.Run(() => HandleClientAsync(client, cancellationToken), cancellationToken); } } private async Task HandleClientAsync(TcpClient client, CancellationToken cancellationToken) { using (client) using (var stream = client.GetStream()) using (var reader = new StreamReader(stream, Encoding.UTF8)) { var message = (await reader.ReadToEndAsync(cancellationToken)).Trim(); if (string.IsNullOrWhiteSpace(message)) { return; } await _onMessage(message); } } } internal sealed class ConsoleNotifierApp { private readonly string _avatarPath; private readonly string _soundPath; public ConsoleNotifierApp() { var baseDir = AppContext.BaseDirectory; _avatarPath = Path.Combine(baseDir, "avatar.jpg"); _soundPath = Path.Combine(baseDir, "jobs_done.mp3"); } public async Task RunAsync(CancellationToken cancellationToken) { Console.WriteLine($"{PeonConstants.AppName} слушает порт {PeonConstants.Port}. Нажмите Ctrl+C для выхода."); var listener = new PortListener(PeonConstants.Port, async message => { var payload = NotificationMessageParser.Parse(message); var shownText = TruncateMessage(payload.MessageText); await NotificationService.NotifyAsync( payload.ProjectName, shownText, _avatarPath, _soundPath, cancellationToken); }); await listener.RunAsync(cancellationToken); } private static string TruncateMessage(string message) { return message.Length > 220 ? message[..220] + "..." : message; } } internal static class AgentsMdUpdater { private const string Marker = "<!-- peon-notifier-rule -->"; public static AgentsMdUpdateResult EnsureInstruction(string agentsFilePath, int port) { if (!File.Exists(agentsFilePath)) { return new AgentsMdUpdateResult(false, false, "Файл не найден."); } if (!string.Equals(Path.GetFileName(agentsFilePath), "AGENTS.md", StringComparison.OrdinalIgnoreCase)) { return new AgentsMdUpdateResult(false, false, "Нужно выбрать именно файл AGENTS.md."); } var content = File.ReadAllText(agentsFilePath, Encoding.UTF8); if (content.Contains(Marker, StringComparison.Ordinal)) { return new AgentsMdUpdateResult(true, false, "Инструкция уже есть в AGENTS.md."); } var projectDirectory = Path.GetDirectoryName(agentsFilePath); var projectName = string.IsNullOrWhiteSpace(projectDirectory) ? "project" : new DirectoryInfo(projectDirectory).Name; var nl = Environment.NewLine; var rule = $"- {Marker} После завершения работы в проекте \"{projectName}\" отправь TCP-сообщение на 127.0.0.1:{port} в формате \"{projectName}: <summary до 40 символов>\", молча, не сообщая об этом в чате."; var block = "## Agent Completion Notification" + nl + rule + nl; var builder = new StringBuilder(content); if (!content.EndsWith(nl, StringComparison.Ordinal)) { builder.Append(nl); } builder.Append(nl); builder.Append(block); File.WriteAllText(agentsFilePath, builder.ToString(), Encoding.UTF8); return new AgentsMdUpdateResult(true, true, "Инструкция добавлена в AGENTS.md."); } } internal readonly record struct AgentsMdUpdateResult(bool Success, bool Added, string Message); internal static class NotificationService { public static Task NotifyAsync(string title, string message, string avatarPath, string soundPath, CancellationToken cancellationToken) { ShowNotification(title, message, avatarPath); PlaySound(soundPath); return Task.CompletedTask; } public static void PlaySound(string soundPath) { if (!File.Exists(soundPath)) { return; } if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { PlayWindowsSound(soundPath); return; } if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { PlayLinuxSound(soundPath); return; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { PlayMacSound(soundPath); } } private static void ShowNotification(string title, string message, string avatarPath) { if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { ShowWindowsNotification(title, message, avatarPath); return; } if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { ShowLinuxNotification(title, message, avatarPath); return; } if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { ShowMacNotification(title, message); return; } Console.WriteLine($"[notify] {title}: {message}"); } private static void ShowWindowsNotification(string title, string message, string avatarPath) { var psTitle = EscapePowerShellSingleQuoted(title); var psMessage = EscapePowerShellSingleQuoted(message); var psAvatar = EscapePowerShellSingleQuoted(avatarPath); var script = $@" Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing $notify = New-Object System.Windows.Forms.NotifyIcon $notify.Visible = $true $notify.Icon = [System.Drawing.SystemIcons]::Information if (Test-Path '{psAvatar}') {{ try {{ $bmp = [System.Drawing.Bitmap]::new('{psAvatar}') $resized = [System.Drawing.Bitmap]::new(64,64) $g = [System.Drawing.Graphics]::FromImage($resized) $g.DrawImage($bmp,0,0,64,64) $g.Dispose() $bmp.Dispose() $h = $resized.GetHicon() $notify.Icon = [System.Drawing.Icon]::FromHandle($h) }} catch {{}} }} $notify.BalloonTipTitle = '{psTitle}' $notify.BalloonTipText = '{psMessage}' $notify.ShowBalloonTip(5000) Start-Sleep -Milliseconds 5200 $notify.Dispose() "; TryStartProcess("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", ToPowerShellEncodedCommand(script)); } private static void ShowLinuxNotification(string title, string message, string avatarPath) { if (File.Exists(avatarPath) && TryStartProcess("notify-send", "--icon", avatarPath, title, message)) { return; } if (TryStartProcess("notify-send", title, message)) { return; } Console.WriteLine($"[notify] {title}: {message}"); } private static void ShowMacNotification(string title, string message) { var escapedTitle = EscapeAppleScript(title); var escapedMessage = EscapeAppleScript(message); var script = $"display notification \"{escapedMessage}\" with title \"{escapedTitle}\""; if (!TryStartProcess("osascript", "-e", script)) { Console.WriteLine($"[notify] {title}: {message}"); } } private static void PlayWindowsSound(string soundPath) { var psSound = EscapePowerShellSingleQuoted(soundPath); var script = $@" $path = '{psSound}' if (Test-Path $path) {{ try {{ Add-Type -AssemblyName presentationCore $player = New-Object System.Windows.Media.MediaPlayer $player.Open([Uri]$path) $player.Play() Start-Sleep -Milliseconds 2500 $player.Close() }} catch {{ Start-Process -FilePath $path }} }} "; TryStartProcess("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", ToPowerShellEncodedCommand(script)); } private static void PlayLinuxSound(string soundPath) { if (TryStartProcess("paplay", soundPath)) { return; } if (TryStartProcess("mpg123", "-q", soundPath)) { return; } TryStartProcess("xdg-open", soundPath); } private static void PlayMacSound(string soundPath) { if (TryStartProcess("afplay", soundPath)) { return; } TryStartProcess("open", soundPath); } private static bool TryStartProcess(string fileName, params string[] args) { try { var startInfo = new ProcessStartInfo(fileName) { UseShellExecute = false, CreateNoWindow = true, }; foreach (var argument in args) { startInfo.ArgumentList.Add(argument); } _ = Process.Start(startInfo); return true; } catch { return false; } } private static string ToPowerShellEncodedCommand(string script) { var bytes = Encoding.Unicode.GetBytes(script); return Convert.ToBase64String(bytes); } private static string EscapePowerShellSingleQuoted(string value) { return value.Replace("'", "''"); } private static string EscapeAppleScript(string value) { return value.Replace("\\", "\\\\").Replace("\"", "\\\""); } } #if WINDOWS internal sealed class TrayAppContext : ApplicationContext { private readonly NotifyIcon _notifyIcon; private readonly Icon _icon; private readonly string _avatarPath; private readonly string _soundPath; private readonly SynchronizationContext _syncContext; private readonly CancellationTokenSource _cts = new(); private PortListener? _listener; public TrayAppContext() { _syncContext = SynchronizationContext.Current ?? new WindowsFormsSynchronizationContext(); var baseDir = AppContext.BaseDirectory; _avatarPath = Path.Combine(baseDir, "avatar.jpg"); _soundPath = Path.Combine(baseDir, "jobs_done.mp3"); _icon = CreateTrayIcon(_avatarPath); _notifyIcon = new NotifyIcon { Icon = _icon, Text = $"{PeonConstants.AppName} ({PeonConstants.Port})", Visible = true, ContextMenuStrip = BuildMenu(), }; _notifyIcon.ShowBalloonTip( 3000, PeonConstants.AppName, "Сервис запущен. Порт 8999 прослушивается.", ToolTipIcon.Info); _listener = new PortListener(PeonConstants.Port, OnMessageReceivedAsync); _ = Task.Run(() => _listener.RunAsync(_cts.Token)); } private ContextMenuStrip BuildMenu() { var menu = new ContextMenuStrip(); menu.Items.Add("Выбрать AGENTS.md...", null, (_, _) => SelectAgentsMdAndUpdate()); menu.Items.Add("Выход", null, (_, _) => ExitThread()); return menu; } private async Task OnMessageReceivedAsync(string message) { var payload = NotificationMessageParser.Parse(message); var shownText = payload.MessageText.Length > 220 ? payload.MessageText[..220] + "..." : payload.MessageText; _syncContext.Post(_ => { _notifyIcon.ShowBalloonTip( 5000, payload.ProjectName, shownText, ToolTipIcon.None); NotificationService.PlaySound(_soundPath); }, null); await Task.CompletedTask; } private void SelectAgentsMdAndUpdate() { using var dialog = new OpenFileDialog { Title = "Выберите AGENTS.md", Filter = "AGENTS.md|AGENTS.md|Markdown (*.md)|*.md|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false, FileName = "AGENTS.md", }; if (dialog.ShowDialog() != DialogResult.OK) { return; } var result = AgentsMdUpdater.EnsureInstruction(dialog.FileName, PeonConstants.Port); var tipIcon = result.Success ? ToolTipIcon.Info : ToolTipIcon.Warning; _notifyIcon.ShowBalloonTip(5000, "AGENTS.md", result.Message, tipIcon); } protected override void ExitThreadCore() { _cts.Cancel(); _notifyIcon.Visible = false; _notifyIcon.Dispose(); _icon.Dispose(); _cts.Dispose(); base.ExitThreadCore(); } private static Icon CreateTrayIcon(string avatarPath) { if (!File.Exists(avatarPath)) { return SystemIcons.Information; } try { using var bitmap = new Bitmap(avatarPath); using var resized = new Bitmap(bitmap, new Size(64, 64)); var iconHandle = resized.GetHicon(); using var tempIcon = Icon.FromHandle(iconHandle); var clonedIcon = (Icon)tempIcon.Clone(); DestroyIcon(iconHandle); return clonedIcon; } catch { return SystemIcons.Information; } } [DllImport("user32.dll", CharSet = CharSet.Auto)] private static extern bool DestroyIcon(IntPtr handle); } #endif