diff --git a/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs b/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs index bb27a5b..316a366 100644 --- a/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs +++ b/src/CasaBot/AutoScan/DependencyInjectionExtensions.cs @@ -13,6 +13,7 @@ public static class DependencyInjectionExtensions { services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/src/CasaBot/AutoScan/Implementations/ShinobiConnector.cs b/src/CasaBot/AutoScan/Implementations/ShinobiConnector.cs index ad438fc..f8090a6 100644 --- a/src/CasaBot/AutoScan/Implementations/ShinobiConnector.cs +++ b/src/CasaBot/AutoScan/Implementations/ShinobiConnector.cs @@ -4,6 +4,7 @@ using AutoScan.Options; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Net.Http.Json; +using Monitor = AutoScan.Models.Monitor; namespace AutoScan.Implementations; @@ -11,19 +12,20 @@ public class ShinobiConnector : IDVRConnector { private readonly ILogger _logger; private readonly HttpClient _httpClient; - private readonly ShinobiOptions _options; + + private readonly IShinobiLinkFactory _linkFactory; + private string? _cachedVideoStream; - public ShinobiConnector(ILogger logger, HttpClient httpClient, IOptions options) + public ShinobiConnector(ILogger logger, HttpClient httpClient, IShinobiLinkFactory linkFactory) { _logger = logger; _httpClient = httpClient; - _options = options.Value; + _linkFactory = linkFactory; } public async Task> FetchMonitorVideosBetween(DateTime from, DateTime to, bool runDry = false, CancellationToken cancellationToken = default) { - var endpoint = $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}"; - endpoint += $"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}"; + var endpoint = _linkFactory.BuildVideosLink(from, to); _logger.LogDebug("Fetching videos details from endpoint: {Endpoint}", endpoint); @@ -56,7 +58,7 @@ public class ShinobiConnector : IDVRConnector public async Task DownloadMonitorVideo(VideoDetail video, string downloadFolder, bool runDry, CancellationToken cancellationToken = default) { - var endpoint = $"{_options.URL}{video.href}"; + var endpoint = _linkFactory.FromHref(video.href); _logger.LogDebug("Fetching video from endpoint: {Endpoint}", endpoint); //Video filenames format: "monitorId-2025-02-15T07-45-01.mp4" @@ -83,7 +85,17 @@ public class ShinobiConnector : IDVRConnector throw; } } + - - + public async Task GetVideoStream() + { + if (_cachedVideoStream is not null) + return _cachedVideoStream; + var endpoint = _linkFactory.BuildMonitorLink(); + _logger.LogDebug("Fetching video stream from endpoint: {Endpoint}", endpoint); + var monitors = await _httpClient.GetFromJsonAsync(endpoint); + + _cachedVideoStream = monitors?.FirstOrDefault()?.MonitorDetail?.auto_host ?? null; + return _cachedVideoStream ?? ""; + } } \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Implementations/ShinobiLinkFactory.cs b/src/CasaBot/AutoScan/Implementations/ShinobiLinkFactory.cs new file mode 100644 index 0000000..99ffd99 --- /dev/null +++ b/src/CasaBot/AutoScan/Implementations/ShinobiLinkFactory.cs @@ -0,0 +1,36 @@ +using AutoScan.Interfaces; +using AutoScan.Options; +using Microsoft.Extensions.Options; + +namespace AutoScan.Implementations; + +public class ShinobiLinkFactory : IShinobiLinkFactory +{ + private readonly ShinobiOptions _options; + + public ShinobiLinkFactory(IOptions options) + { + _options = options.Value; + } + + public string BuildMonitorLink() + { + return $"{_options.URL}/{_options.APIKey}/monitor/{_options.GroupId}/{_options.MonitorId}"; + } + + public string BuildFeedLink() + { + return $"{_options.URL}/{_options.APIKey}/mjpeg/{_options.GroupId}/{_options.MonitorId}"; + } + + public string BuildVideosLink(DateTime from, DateTime to) + { + return $"{_options.URL}/{_options.APIKey}/videos/{_options.GroupId}/{_options.MonitorId}" + + $"?start={from:yyyy-MM-ddTHH:mm:sszzz}&end={to:yyyy-MM-ddTHH:mm:sszzz}"; + } + + public string FromHref(string href) + { + return $"{_options.URL}{href}"; + } +} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Interfaces/IDVRConnector.cs b/src/CasaBot/AutoScan/Interfaces/IDVRConnector.cs index 0ed2008..c1aa14b 100644 --- a/src/CasaBot/AutoScan/Interfaces/IDVRConnector.cs +++ b/src/CasaBot/AutoScan/Interfaces/IDVRConnector.cs @@ -6,4 +6,6 @@ public interface IDVRConnector { Task> FetchMonitorVideosBetween(DateTime from, DateTime to, bool runDry = false, CancellationToken cancellationToken = default); Task DownloadMonitorVideo(VideoDetail video, string downloadFolder, bool runDry = false, CancellationToken cancellationToken = default); + + Task GetVideoStream(); } \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Interfaces/IShinobiLinkFactory.cs b/src/CasaBot/AutoScan/Interfaces/IShinobiLinkFactory.cs new file mode 100644 index 0000000..d1cc130 --- /dev/null +++ b/src/CasaBot/AutoScan/Interfaces/IShinobiLinkFactory.cs @@ -0,0 +1,9 @@ +namespace AutoScan.Interfaces; + +public interface IShinobiLinkFactory +{ + public string BuildMonitorLink(); + public string BuildFeedLink(); + public string BuildVideosLink(DateTime from, DateTime to); + public string FromHref(string href); +} \ No newline at end of file diff --git a/src/CasaBot/AutoScan/Models/Monitor.cs b/src/CasaBot/AutoScan/Models/Monitor.cs new file mode 100644 index 0000000..2dc30b5 --- /dev/null +++ b/src/CasaBot/AutoScan/Models/Monitor.cs @@ -0,0 +1,24 @@ +using System.Text.Json; + +namespace AutoScan.Models; + +public class Monitor +{ + public string? ke { get; init; } + public string? name { get; init; } + public string? mid { get; init; } + public string? details { get; init; } + public string? protocol { get; init; } + public string? host { get; init; } + public int port { get; init; } + public string? path { get; init; } + + public MonitorDetail? MonitorDetail => JsonSerializer.Deserialize(details); +} + +public class MonitorDetail +{ + public string auto_host { get; set; } + public string muser { get; set; } + public string mpass { get; set; } +} \ No newline at end of file diff --git a/src/CasaBot/CasaBot.sln b/src/CasaBot/CasaBot.sln index 6aa3b9e..1eb50bd 100644 --- a/src/CasaBot/CasaBot.sln +++ b/src/CasaBot/CasaBot.sln @@ -4,6 +4,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CasaBotApp", "CasaBotApp\Ca EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoScan", "AutoScan\AutoScan.csproj", "{13D75ACB-7913-4C4B-B696-9BD7383012AF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlServer", "ControlServer\ControlServer.csproj", "{11422EF5-FF40-4419-B72B-87F569E4DA3C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -18,5 +20,9 @@ Global {13D75ACB-7913-4C4B-B696-9BD7383012AF}.Debug|Any CPU.Build.0 = Debug|Any CPU {13D75ACB-7913-4C4B-B696-9BD7383012AF}.Release|Any CPU.ActiveCfg = Release|Any CPU {13D75ACB-7913-4C4B-B696-9BD7383012AF}.Release|Any CPU.Build.0 = Release|Any CPU + {11422EF5-FF40-4419-B72B-87F569E4DA3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {11422EF5-FF40-4419-B72B-87F569E4DA3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {11422EF5-FF40-4419-B72B-87F569E4DA3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {11422EF5-FF40-4419-B72B-87F569E4DA3C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/src/CasaBot/CasaBotApp/CasaBotApp.csproj b/src/CasaBot/CasaBotApp/CasaBotApp.csproj index 8bb3efa..0a73383 100644 --- a/src/CasaBot/CasaBotApp/CasaBotApp.csproj +++ b/src/CasaBot/CasaBotApp/CasaBotApp.csproj @@ -16,6 +16,7 @@ + @@ -47,6 +48,7 @@ + diff --git a/src/CasaBot/CasaBotApp/Extensions/CommandRegister.cs b/src/CasaBot/CasaBotApp/Extensions/CommandRegister.cs index 4e88835..420fd26 100644 --- a/src/CasaBot/CasaBotApp/Extensions/CommandRegister.cs +++ b/src/CasaBot/CasaBotApp/Extensions/CommandRegister.cs @@ -1,14 +1,36 @@ using AutoScan; +using AutoScan.Interfaces; +using CasaBotApp.TelegramBot; +using ControlServer; using Microsoft.Extensions.Logging; +using System.Diagnostics; using Telegram.Bots.Types; +using BotCommand = CasaBotApp.TelegramBot.BotCommand; namespace CasaBotApp.Extensions; -public static class CommandRegister +public class CommandRegister { - public static void RegisterCommands(BotHandler botHandler, AutoScanApp autoScanApp) + private readonly ILogger _logger; + private readonly BotHandler _botHandler; + private readonly AutoScanApp _autoScanApp; + private readonly IControlServer _controlServer; + private readonly IShinobiLinkFactory _shinobiLinkFactory; + private readonly IDVRConnector _dvrConnector; + + public CommandRegister(ILogger logger, BotHandler botHandler, AutoScanApp autoScanApp, IControlServer controlServer, IShinobiLinkFactory shinobiLinkFactory, IDVRConnector dvrConnector) { - botHandler.RegisterCommand(new BotCommand + _logger = logger; + _botHandler = botHandler; + _autoScanApp = autoScanApp; + _controlServer = controlServer; + _shinobiLinkFactory = shinobiLinkFactory; + _dvrConnector = dvrConnector; + } + + public void RegisterCommands() + { + _botHandler.RegisterCommand(new BotCommand { Command = "/soyandre", Description = "Soy Andre", @@ -17,33 +39,63 @@ public static class CommandRegister await ctx.Responder(message, "Hola vida, te amo mucho ❤️"); } }); - botHandler.RegisterCommand(new BotCommand + _botHandler.RegisterCommand(new BotCommand { Command = "/startScan", Description = "Start a scan of last night images", Action = async (message, ctx) => { await ctx.Responder(message, "Starting scan 🔍📼"); - await autoScanApp.StartNewScan(); + await _autoScanApp.StartNewScan(); } }); - botHandler.RegisterCommand(new BotCommand + _botHandler.RegisterCommand(new BotCommand { - Command = "/lastScan", + Command = "/lastscan", Description = "Send the images from the last scan", Action = async (message, ctx) => { - var images = autoScanApp.GetLastScanPictures(); + var images = _autoScanApp.GetLastScanPictures(); if (images.Length == 0) { await ctx.Responder(message, "No images found"); return; } - await botHandler.SendPhotos(message.Chat.Id, images); + await _botHandler.SendPhotos(message.Chat.Id, images); } }); - botHandler.OnReply = async msg => + _botHandler.RegisterCommand(new BotCommand() + { + Command = "/now", + Description = "Send the current snapshot", + Action = async (msg, ctx) => + { + var stopwatch = Stopwatch.StartNew(); + stopwatch.Start(); + var outputPath = await TakeSnapshot(); + stopwatch.Stop(); + if (string.IsNullOrEmpty(outputPath)) + { + await ctx.Responder(msg, "Error taking snapshot"); + return; + } + _ = _botHandler.SendPhoto(msg.Chat.Id, outputPath, "Current snapshot"); + _ = _botHandler.SendText(msg.Chat.Id, $"It took {stopwatch.ElapsedMilliseconds} ms to take the picture"); + } + }); + _botHandler.RegisterCommand(new BotCommand() + { + Command = "/disarm", + Description = "Disarm the Door Sensor", + Action = async (msg, ctx) => + { + await ctx.Responder(msg, "Disarming the door sensor"); + _controlServer.RequestDisarm(); + } + }); + + _botHandler.OnReply = async msg => { var originalMsg = msg.ReplyToMessage; @@ -51,36 +103,100 @@ public static class CommandRegister if (originalMsg is not PhotoMessage photoMessage || photoMessage.Caption is null) return; - var videoPath = autoScanApp.GetVideoPath(photoMessage.Caption); + var videoPath = _autoScanApp.GetVideoPath(photoMessage.Caption); if (string.IsNullOrEmpty(videoPath)) { - await botHandler.SendText(msg.Chat.Id, "No video found for this image"); + await _botHandler.SendText(msg.Chat.Id, "No video found for this image"); return; } - await botHandler.SendVideo(msg.Chat.Id, videoPath); + await _botHandler.SendVideo(msg.Chat.Id, videoPath); }; + } - public static void UpdateOnScanCompleted(BotHandler botHandler, AutoScanApp autoScanApp, ILogger logger) + public void RegisterAutoScanApp() { - autoScanApp.OnScanCompleted = async () => + _autoScanApp.OnScanCompleted = async () => { - logger.LogInformation("Scan completed at {At}", DateTime.Now); + _logger.LogInformation("Scan completed at {At}", DateTime.Now); try { - var images = autoScanApp.GetLastScanPictures(); + var images = _autoScanApp.GetLastScanPictures(); if (images.Length == 0) { - await botHandler.UpdateText("No images found"); + await _botHandler.UpdateText("No images found"); return; } - await botHandler.UpdateText($"Scan completed, found {images.Length} images"); - await botHandler.UpdatePhotos(images); + await _botHandler.UpdateText($"Scan completed, found {images.Length} images"); + await _botHandler.UpdatePhotos(images); }catch(Exception ex) { - logger.LogError(ex, "Error while sending message"); + _logger.LogError(ex, "Error while sending message"); } }; } + + public void RegisterControlServer() + { + _controlServer.OnEvent(async se => + { + var mediaPath = await TakeSnapshot(); + if (string.IsNullOrEmpty(mediaPath)) + { + await _botHandler.AlertText("Unauthorized access detected 🚨🚨🚨, but no media available"); + return; + } + if (se.Type == EventType.Fired) + { + await _botHandler.AlertPhoto(mediaPath, + "Unauthorized access detected 🚨 🚨 🚨", + [ + new(OptionType.Url, "Camera Feed", _shinobiLinkFactory.BuildFeedLink()), + new(OptionType.Action, "Authorize", $"authorize-{se.EventId}", (data, chatId ) => + { + _logger.LogWarning("Authorizing event {EventId}", se.EventId); + _controlServer.AuthorizeEvent(se.EventId); + return Task.FromResult("Authorization not implemented"); + }), + ]); + } + + if (se.Type == EventType.DisarmedEntrance) + { + await _botHandler.UpdateText("Authorize access"); + } + }); + } + private async Task TakeSnapshot() + { + var outputPath = Path.Combine(".", "media", "snp", "something.jpeg"); + var originalFeed = await _dvrConnector.GetVideoStream(); + var ffmArgs = $"-y -i \"{originalFeed}\" -ss 00:00:00.500 -vframes 1 {outputPath}"; + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + //To change this, I need to make sure ffmpeg is installed + FileName = "./dvr-scanner/ffmpeg.exe", + Arguments = ffmArgs, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + process.Start(); + await process.WaitForExitAsync(); + // You can read the output here. + // var output = await process.StandardOutput.ReadToEndAsync(); + // var error = await process.StandardError.ReadToEndAsync(); + if(process.ExitCode != 0) + { + _logger.LogError("Error taking snapshot, exit code: {ExitCode}", process.ExitCode); + return string.Empty; + } + return outputPath; + } } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/Program.cs b/src/CasaBot/CasaBotApp/Program.cs index cc59806..0548991 100644 --- a/src/CasaBot/CasaBotApp/Program.cs +++ b/src/CasaBot/CasaBotApp/Program.cs @@ -1,7 +1,8 @@ using AutoScan; using AutoScan.Options; -using CasaBotApp; using CasaBotApp.Extensions; +using CasaBotApp.TelegramBot; +using ControlServer; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -10,16 +11,16 @@ using Telegram.Bots; using Telegram.Bots.Extensions.Polling; using Telegram.Bots.Http; -var environment = Environment.GetEnvironmentVariable("CASABOT_ENVIRONMENT"); -IConfigurationRoot configuration = new ConfigurationBuilder() - .AddJsonFile($"appsettings.json", true, true) - .AddJsonFile($"appsettings.{environment}.json", true, true) - .AddEnvironmentVariables() - .Build(); - var hostBuilder = new HostBuilder(); hostBuilder.ConfigureServices((_, services) => { + var environment = Environment.GetEnvironmentVariable("CASABOT_ENVIRONMENT"); + var configuration = new ConfigurationBuilder() + .AddJsonFile($"appsettings.json", true, true) + .AddJsonFile($"appsettings.{environment}.json", true, true) + .AddEnvironmentVariables() + .Build(); + services.AddSingleton(configuration); services.AddLogging(configuration); @@ -27,14 +28,20 @@ hostBuilder.ConfigureServices((_, services) => services.Configure(configuration.GetSection("Telegram")); services.Configure(configuration.GetSection("AutoScan")); services.Configure(configuration.GetSection("Shinobi")); + services.Configure(configuration.GetSection("ControlServer")); - services.AddSingleton(); + + services.AddSingleton(); + services.AddHostedService(sp => sp.GetRequiredService()); var token = configuration["Telegram:BotToken"] ?? ""; + services.AddSingleton(); services.AddBotClient(token); services.AddPolling(); services.AddSingleton(sp => sp.GetService()!); + services.AddTransient(); + // To get notifications when a retry is performed @@ -47,6 +54,7 @@ hostBuilder.ConfigureServices((_, services) => services.Configure(hostOptions => { hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore; + hostOptions.ServicesStartConcurrently = true; }); }); @@ -56,17 +64,18 @@ var host = hostBuilder.Build(); var logger = host.Services.GetService>()!; -var botHandler = host.Services.GetService()!; var autoScanApp = host.Services.GetService()!; -CommandRegister.RegisterCommands(botHandler, autoScanApp); +var commandRegister = host.Services.GetRequiredService(); + using var cts = new CancellationTokenSource(); _ = autoScanApp.Run(cts.Token); -botHandler.Start(cts.Token); -CommandRegister.UpdateOnScanCompleted(botHandler, autoScanApp, logger); +commandRegister.RegisterAutoScanApp(); +commandRegister.RegisterCommands(); +commandRegister.RegisterControlServer(); logger.LogInformation("Bot started"); await host.RunAsync(cts.Token); diff --git a/src/CasaBot/CasaBotApp/BotCommand.cs b/src/CasaBot/CasaBotApp/TelegramBot/BotCommand.cs similarity index 89% rename from src/CasaBot/CasaBotApp/BotCommand.cs rename to src/CasaBot/CasaBotApp/TelegramBot/BotCommand.cs index 392617c..adca54c 100644 --- a/src/CasaBot/CasaBotApp/BotCommand.cs +++ b/src/CasaBot/CasaBotApp/TelegramBot/BotCommand.cs @@ -1,7 +1,6 @@ -using System.Net.Mime; using Telegram.Bots.Types; -namespace CasaBotApp; +namespace CasaBotApp.TelegramBot; public class BotCommand { diff --git a/src/CasaBot/CasaBotApp/BotHandler.cs b/src/CasaBot/CasaBotApp/TelegramBot/BotHandler.cs similarity index 61% rename from src/CasaBot/CasaBotApp/BotHandler.cs rename to src/CasaBot/CasaBotApp/TelegramBot/BotHandler.cs index 1054684..9eb9013 100644 --- a/src/CasaBot/CasaBotApp/BotHandler.cs +++ b/src/CasaBot/CasaBotApp/TelegramBot/BotHandler.cs @@ -1,21 +1,28 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Text; -using System.Xml.Schema; using Telegram.Bots; using Telegram.Bots.Extensions.Polling; -using Telegram.Bots.Requests.Usernames; +using Telegram.Bots.Requests; using Telegram.Bots.Types; using File = System.IO.File; +using SendMediaGroup = Telegram.Bots.Requests.Usernames.SendMediaGroup; +using SendPhotoFile = Telegram.Bots.Requests.Usernames.SendPhotoFile; +using SendText = Telegram.Bots.Requests.Usernames.SendText; +using SendVideoFile = Telegram.Bots.Requests.Usernames.SendVideoFile; -namespace CasaBotApp; +namespace CasaBotApp.TelegramBot; public class BotHandler : IUpdateHandler { private readonly ILogger _logger; private readonly TelegramOptions _telegramOptions; private readonly List _subscribers = []; + private readonly List _subscribersAlarm = []; private readonly Dictionary _commands; + //TODO hacerlo mejor. + private readonly Dictionary _callbackFunctions = new(); + private record CallbackQueueItem(DateTime inserted, Func> callback); public Func? OnReply { get; set; } = null; @@ -34,6 +41,15 @@ public class BotHandler : IUpdateHandler Action = RegisterUser, Responder = Respond }); + { + RegisterCommand(new() + { + Command = "/registeralarm", + Description = "Register to receive alarms", + Action = RegisterUserAlarm, + Responder = Respond + }); + } RegisterCommand(new() { Command = "/photo", @@ -41,6 +57,15 @@ public class BotHandler : IUpdateHandler Action = SendImageTest, Responder = Respond }); + + foreach (var subs in _telegramOptions.SubscribedChatIds) + { + Subscribe(subs); + } + foreach(var sub in _telegramOptions.SubscribedAlarmsChatIds) + { + SubscribeAlarm(sub); + } } public void RegisterCommand(BotCommand command) @@ -48,14 +73,20 @@ public class BotHandler : IUpdateHandler command.Responder = Respond; _commands[command.Command] = command; } - - - public void Start(CancellationToken cancellationToken) + + public void SubscribeAlarm(long id) { - foreach (var subs in _telegramOptions.SubscribedChatIds) + if (_subscribersAlarm.Any(x => x.Id == id)) { - Subscribe(subs); + _logger.LogWarning("User {Id} is already subscribed to alarm", id); + return; } + + _subscribersAlarm.Add(new Chat() + { + Id = id + }); + _logger.LogInformation("User {Id} subscribed to receive alarms", id); } public void Subscribe(long id) @@ -77,16 +108,18 @@ public class BotHandler : IUpdateHandler /// Send text message to all subscribers /// /// - public async Task UpdateText(string message) + public Task UpdateText(string message) => UpdateTextInt(_subscribers, message); + public Task AlertText(string message) => UpdateTextInt(_subscribersAlarm, message); + public async Task UpdateTextInt(List subscribers,string message) { - if (_subscribers.Count == 0) + if (subscribers.Count == 0) { _logger.LogWarning("No subscribers to send message to"); return; } var replacement = new List<(Chat from, Chat to)>(); - foreach (var subscriber in _subscribers) + foreach (var subscriber in subscribers) { var response = await SndTxt(subscriber, message); if (subscriber.FirstName is null) @@ -97,8 +130,8 @@ public class BotHandler : IUpdateHandler foreach (var rep in replacement) { - _subscribers.Remove(rep.from); - _subscribers.Add(rep.to); + subscribers.Remove(rep.from); + subscribers.Add(rep.to); } } @@ -107,19 +140,68 @@ public class BotHandler : IUpdateHandler /// Send photo to all subscribers /// /// - public async Task UpdatePhoto(string path) + /// Optional message with photo + /// Actions options + public Task UpdatePhoto(string path, string? caption = null, IEnumerable? options = null) => + UpdatePhotoInt(_subscribers, path, caption, options); + + public Task AlertPhoto(string path, string? caption = null, IEnumerable? options = null) => + UpdatePhotoInt(_subscribersAlarm, path, caption, options); + + private async Task UpdatePhotoInt(List subscription, string path, string? caption = null, IEnumerable? options = null) { - - if (_subscribers.Count == 0) + if (subscription.Count == 0) { _logger.LogWarning("No subscribers to send message to"); return; } - foreach (var subscriber in _subscribers) + foreach (var subscriber in subscription) { await using var stream = File.OpenRead(path); - await SndPhoto(subscriber, stream); + ReplyMarkup? replyMarkup = null; + if (options != null) + { + foreach(var opt in options) + { + if (opt.type == OptionType.Action && opt.callback != null) + { + _callbackFunctions.Add(opt.content, new(DateTime.Now, opt.callback)); + } + } + + // Get options in enumerations of 2 + var optionsGrouped = options.Select((x, i) => (x, i)).GroupBy(x => x.i / 2) + .Select(x => x.Select(y => y.x).ToArray()).ToArray(); + IEnumerable keyboard = optionsGrouped.Select(group => + group.Select(opt => opt.type switch + { + OptionType.Url => new UrlButton(opt.message, new Uri(opt.content)), + OptionType.Action => new CallbackDataButton(opt.message, opt.content), + _ => throw new ArgumentOutOfRangeException(nameof(opt), opt, null) + }).ToArray()).ToArray(); + + replyMarkup = new InlineKeyboardMarkup(keyboard); + } + + var send = new SendPhotoFile(subscriber.Id.ToString(), stream) + { + Caption = caption ?? Path.GetFileNameWithoutExtension(stream.Name), + ReplyMarkup = replyMarkup + }; + await _bot.HandleAsync(send); + _ = HandleQueueExpiration(); + } + } + + private async Task HandleQueueExpiration() + { + //remove expired items with more than 3 hs + var expired = _callbackFunctions.Where(x => x.Value.inserted.AddHours(3) < DateTime.Now).ToArray(); + foreach (var item in expired) + { + _callbackFunctions.Remove(item.Key); + _logger.LogDebug("Removed expired callback function {Key}", item.Key); } } @@ -198,6 +280,20 @@ public class BotHandler : IUpdateHandler await _bot.HandleAsync(send); } + public async Task SendPhoto(long chatId, string content, string caption) + { + await using var stream = File.OpenRead(content); + var send = new SendPhotoFile(chatId.ToString(), stream) + { + Caption = caption + }; + var response = await _bot.HandleAsync(send); + if (!response.Ok) + { + _logger.LogError("Error sending photo."); + } + } + /// /// Send photos to a specific chat /// @@ -317,6 +413,18 @@ public class BotHandler : IUpdateHandler _logger.LogInformation("User {User} ({id}) registered to receive messages", msg.Chat.FirstName, msg.Chat.Id); await Respond(msg, "You are registered to receive messages every minute"); } + private async Task RegisterUserAlarm(TextMessage msg, BotCommand _) + { + if (_subscribersAlarm.Any(c => c.Id == msg.Chat.Id)) + { + await Respond(msg, "You are already registered to receive Alarms"); + return; + } + + _subscribersAlarm.Add(msg.Chat); + _logger.LogInformation("User {User} ({id}) registered to receive Alarms", msg.Chat.FirstName, msg.Chat.Id); + await Respond(msg, "You are registered to receive alarms"); + } private Task> SndTxt(long id, string txt) => _bot.HandleAsync(new SendText(id.ToString(), txt)); @@ -340,7 +448,10 @@ public class BotHandler : IUpdateHandler { ReplyToMessageId = message.Id }, cst), - + + CallbackQueryUpdate u when u.Data is MessageCallbackQuery resp => + HandleCallbackResponse(u, resp), + _ => Task.CompletedTask }; }catch (Exception e) @@ -350,5 +461,26 @@ public class BotHandler : IUpdateHandler } } + private async Task HandleCallbackResponse(CallbackQueryUpdate update, MessageCallbackQuery response) + { + _logger.LogInformation("Callback message received: {id} {Response}", update.Id, response.Data); + if (!_callbackFunctions.TryGetValue(response.Data, out var queueCallback)) + { + _logger.LogError("Callback function not found for {Response}", response.Data); + _ = _bot.HandleAsync(new AnswerCallbackQuery(response.Id, "Option expired")); + return; + } + var chatId = response.Message.Chat.Id; + + var answer = await queueCallback.callback(response.Data, chatId); + + var r = await _bot.HandleAsync(new AnswerCallbackQuery(response.Id, answer)); + if (!r.Ok) + { + _logger.LogError("Error sending callback response: {Error}", r.Failure); + } + } + + } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/TelegramBot/MsgOption.cs b/src/CasaBot/CasaBotApp/TelegramBot/MsgOption.cs new file mode 100644 index 0000000..950a75d --- /dev/null +++ b/src/CasaBot/CasaBotApp/TelegramBot/MsgOption.cs @@ -0,0 +1,9 @@ +namespace CasaBotApp.TelegramBot; + +public record MsgOption(OptionType type, string message, string content, Func>? callback = null); + +public enum OptionType +{ + Url, + Action +} \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/TelegramOptions.cs b/src/CasaBot/CasaBotApp/TelegramBot/TelegramOptions.cs similarity index 57% rename from src/CasaBot/CasaBotApp/TelegramOptions.cs rename to src/CasaBot/CasaBotApp/TelegramBot/TelegramOptions.cs index 085f411..9de7309 100644 --- a/src/CasaBot/CasaBotApp/TelegramOptions.cs +++ b/src/CasaBot/CasaBotApp/TelegramBot/TelegramOptions.cs @@ -1,7 +1,8 @@ -namespace CasaBotApp; +namespace CasaBotApp.TelegramBot; public class TelegramOptions { public string? BotToken { get; set; } public long[] SubscribedChatIds { get; set; } = []; + public long[] SubscribedAlarmsChatIds { get; set; } = []; } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/appsettings.Docker.json b/src/CasaBot/CasaBotApp/appsettings.Docker.json index 37242d2..2c0acce 100644 --- a/src/CasaBot/CasaBotApp/appsettings.Docker.json +++ b/src/CasaBot/CasaBotApp/appsettings.Docker.json @@ -35,5 +35,8 @@ } }, "RemoveOriginalFiles": true, - "RemoveDetectionFiles": false + "RemoveDetectionFiles": false, + "ControlServer": { + "port": 9003 + } } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/appsettings.json b/src/CasaBot/CasaBotApp/appsettings.json index 6483d09..56de868 100644 --- a/src/CasaBot/CasaBotApp/appsettings.json +++ b/src/CasaBot/CasaBotApp/appsettings.json @@ -36,5 +36,8 @@ }, "RemoveOriginalFiles": false, "RemoveDetectionFiles": false + }, + "ControlServer": { + "port": 9003 } } \ No newline at end of file diff --git a/src/CasaBot/CasaBotApp/dvr-scan.cfg b/src/CasaBot/CasaBotApp/dvr-scan.cfg index 260325f..e6397c6 100644 --- a/src/CasaBot/CasaBotApp/dvr-scan.cfg +++ b/src/CasaBot/CasaBotApp/dvr-scan.cfg @@ -87,7 +87,7 @@ time-post-event = 2s # a motion event to be triggered. Lower values require less movement, and are # more sensitive to motion. If too high, some movement may not be detected, # while too low of a threshold can result in false detection events. -threshold = 2 +threshold = 1.7 # Scores of this amount or higher are ignored. 255.0 is the maximum score, so # values greater than 255.0 will disable the filter. diff --git a/src/CasaBot/ControlServer/ControlServer.cs b/src/CasaBot/ControlServer/ControlServer.cs new file mode 100644 index 0000000..cd66b62 --- /dev/null +++ b/src/CasaBot/ControlServer/ControlServer.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Net; +using System.Text; + +namespace ControlServer; + +public class ControlServer : IControlServer +{ + private readonly ILogger _logger; + private readonly ControlServerOptions _options; + private readonly HttpListener _httpListener; + private readonly Dictionary _events = new(); + private Func? _onEventRecived; + private bool _disarmRequestPending; + + public ControlServer(ILogger logger, IOptions options) + { + _logger = logger; + _options = options.Value; + _httpListener = new HttpListener(); + _httpListener.Prefixes.Add($"http://+:{_options.port}/"); + } + + public async Task StartAsync(CancellationToken ct) + { + try + { + _httpListener.Start(); + _logger.LogInformation("Http server listening on port {Port}", _options.port); + }catch (Exception e) + { + _logger.LogError("Error starting HTTP server: {Message}", e.Message); + return; + } + + while (!ct.IsCancellationRequested) + { + var ctx = await _httpListener.GetContextAsync(); + // _logger.LogDebug("Got a request"); + + var request = ctx.Request; + var response = ctx.Response; + + // _logger.LogInformation("Url: {Url} | Method {method}", request.Url, request.HttpMethod); + + //Query params + var eId = request.QueryString["eventId"]; + var eventType = request.QueryString["eventType"]; + var eventData = request.QueryString["eventData"]; + var eventId = long.TryParse(eId, out var id) ? id : 0; + var eventTypeEnum = Enum.TryParse(eventType, out var type) ? type : EventType.Unknown; + var eventDataString = eventData ?? string.Empty; + var sensorEvent = new SensorEvent(eventId, eventTypeEnum, eventDataString); + + + if (sensorEvent.Type == EventType.Update) + { + HandleUpdate(response); + response.Close(); + continue; + } + _logger.LogInformation("Event Received: {event}", sensorEvent); + + var notify = HandleSensorEvent(sensorEvent); + + + response.StatusCode = 200; + response.ContentType = "text/plain"; + + var message = sensorEvent.Authorization == Authorization.Unauthorized ? "UNAUTHORIZED" : "AUTHORIZE_ENTRANCE"; + response.ContentLength64 = Encoding.UTF8.GetByteCount(message); + await using (var writer = new StreamWriter(response.OutputStream)) + { + await writer.WriteAsync(message); + } + response.Close(); + + if (notify) + { + _ = _onEventRecived?.Invoke(sensorEvent); + } + } + + } + + private readonly List _cards = new() + { + new Card("1", "Card 1"), + new Card("2", "Card 2"), + new Card("3", "Card 3"), + new Card("4", "Card 4"), + new Card("5", "Card 5"), + }; + private void HandleUpdate(HttpListenerResponse response) + { + var updateResponse = new UpdateResponse(_disarmRequestPending, _cards.ToArray()); + _disarmRequestPending = false; + + response.StatusCode = 200; + response.ContentType = "application/json"; + // response.ContentType = "text/plain"; + var message = System.Text.Json.JsonSerializer.Serialize(updateResponse); + response.ContentLength64 = Encoding.UTF8.GetByteCount(message); + using var writer = new StreamWriter(response.OutputStream); + writer.Write(message); + } + + public record UpdateResponse(bool disarm, Card[] cards); + public record Card(string id, string name); + private bool HandleSensorEvent(SensorEvent se) + { + if (se.Type == EventType.Update && _disarmRequestPending) + { + _disarmRequestPending = false; + se.Authorization = Authorization.Authorized; + return false; + } + + _events.TryGetValue(se.EventId, out var storedEvent); + if (storedEvent is null) + { + //New One + if (se.Type == EventType.DisarmedEntrance) + { + _disarmRequestPending = false; + se.Authorization = Authorization.Authorized; + _events.Add(se.EventId, se); + return true; + } + + if (se.Type == EventType.Fired) + { + //Check pending desarmed. + se.Authorization = _disarmRequestPending ? Authorization.Authorized : Authorization.Unauthorized; + _disarmRequestPending = false; + _events.Add(se.EventId, se); + return true; + } + _events.Add(se.EventId, new SensorEvent(se.EventId, se.Type, se.Data)); + return false; + } + + //Already stored + if (se.Type == EventType.EventUpdate && (_disarmRequestPending || storedEvent.Authorization == Authorization.Authorized) ) + { + _disarmRequestPending = false; + storedEvent.Authorization= Authorization.Authorized; + se.Authorization = Authorization.Authorized; + return false; + } + return false; + } + + public Task StopAsync(CancellationToken ct) + { + // _httpListener.EndGetContext(null); + // _httpListener.Close(); + _httpListener.Stop(); + return Task.CompletedTask; + } + + public void OnEvent(Func onEventRecived) + { + _onEventRecived = onEventRecived; + } + + public void AuthorizeEvent(long eventId) + { + if (_events.TryGetValue(eventId, out var sensorEvent)) + { + sensorEvent.Authorization = Authorization.Authorized; + } + } + + public void RequestDisarm() + { + _disarmRequestPending = true; + } + +} \ No newline at end of file diff --git a/src/CasaBot/ControlServer/ControlServer.csproj b/src/CasaBot/ControlServer/ControlServer.csproj new file mode 100644 index 0000000..6f6c06e --- /dev/null +++ b/src/CasaBot/ControlServer/ControlServer.csproj @@ -0,0 +1,15 @@ + + + + net9.0 + enable + enable + + + + + + + + + diff --git a/src/CasaBot/ControlServer/ControlServerOptions.cs b/src/CasaBot/ControlServer/ControlServerOptions.cs new file mode 100644 index 0000000..5a9abe0 --- /dev/null +++ b/src/CasaBot/ControlServer/ControlServerOptions.cs @@ -0,0 +1,6 @@ +namespace ControlServer; + +public class ControlServerOptions +{ + public int port { get; set; } +} \ No newline at end of file diff --git a/src/CasaBot/ControlServer/IControlServer.cs b/src/CasaBot/ControlServer/IControlServer.cs new file mode 100644 index 0000000..a61fc2e --- /dev/null +++ b/src/CasaBot/ControlServer/IControlServer.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Hosting; + +namespace ControlServer; + +public interface IControlServer : IHostedService +{ + public void OnEvent(Func onEventRecived); + public void AuthorizeEvent(long eventId); + public void RequestDisarm(); +} \ No newline at end of file diff --git a/src/CasaBot/ControlServer/SensorEvent.cs b/src/CasaBot/ControlServer/SensorEvent.cs new file mode 100644 index 0000000..97a7843 --- /dev/null +++ b/src/CasaBot/ControlServer/SensorEvent.cs @@ -0,0 +1,21 @@ +namespace ControlServer; + +public record SensorEvent(long EventId, EventType Type, string Data) +{ + public Authorization Authorization { get; set; } +}; + +public enum EventType +{ + Fired, + DisarmedEntrance, + EventUpdate, + Update, + Unknown +} + +public enum Authorization +{ + Unauthorized, + Authorized +} \ No newline at end of file