feat: add support for http server control and entrance sensor
This commit is contained in:
parent
620096a7b1
commit
4507f5ab21
@ -13,6 +13,7 @@ public static class DependencyInjectionExtensions
|
||||
{
|
||||
services.AddSingleton<IDVRConnector, ShinobiConnector>();
|
||||
services.AddSingleton<IDVRScanner, DVRScanner>();
|
||||
services.AddSingleton<IShinobiLinkFactory, ShinobiLinkFactory>();
|
||||
|
||||
services.AddSingleton<FfmpegWrapper>();
|
||||
services.AddSingleton<AutoScanApp>();
|
||||
|
@ -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<ShinobiConnector> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ShinobiOptions _options;
|
||||
|
||||
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient, IOptions<ShinobiOptions> options)
|
||||
private readonly IShinobiLinkFactory _linkFactory;
|
||||
private string? _cachedVideoStream;
|
||||
|
||||
public ShinobiConnector(ILogger<ShinobiConnector> logger, HttpClient httpClient, IShinobiLinkFactory linkFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_options = options.Value;
|
||||
_linkFactory = linkFactory;
|
||||
}
|
||||
|
||||
public async Task<List<VideoDetail>> 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"
|
||||
@ -85,5 +87,15 @@ public class ShinobiConnector : IDVRConnector
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> 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<Monitor[]>(endpoint);
|
||||
|
||||
_cachedVideoStream = monitors?.FirstOrDefault()?.MonitorDetail?.auto_host ?? null;
|
||||
return _cachedVideoStream ?? "";
|
||||
}
|
||||
}
|
36
src/CasaBot/AutoScan/Implementations/ShinobiLinkFactory.cs
Normal file
36
src/CasaBot/AutoScan/Implementations/ShinobiLinkFactory.cs
Normal file
@ -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<ShinobiOptions> 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}";
|
||||
}
|
||||
}
|
@ -6,4 +6,6 @@ public interface IDVRConnector
|
||||
{
|
||||
Task<List<VideoDetail>> FetchMonitorVideosBetween(DateTime from, DateTime to, bool runDry = false, CancellationToken cancellationToken = default);
|
||||
Task DownloadMonitorVideo(VideoDetail video, string downloadFolder, bool runDry = false, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<string> GetVideoStream();
|
||||
}
|
9
src/CasaBot/AutoScan/Interfaces/IShinobiLinkFactory.cs
Normal file
9
src/CasaBot/AutoScan/Interfaces/IShinobiLinkFactory.cs
Normal file
@ -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);
|
||||
}
|
24
src/CasaBot/AutoScan/Models/Monitor.cs
Normal file
24
src/CasaBot/AutoScan/Models/Monitor.cs
Normal file
@ -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<MonitorDetail>(details);
|
||||
}
|
||||
|
||||
public class MonitorDetail
|
||||
{
|
||||
public string auto_host { get; set; }
|
||||
public string muser { get; set; }
|
||||
public string mpass { get; set; }
|
||||
}
|
@ -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
|
||||
|
@ -16,6 +16,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="9.0.2" />
|
||||
@ -47,6 +48,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AutoScan\AutoScan.csproj" />
|
||||
<ProjectReference Include="..\ControlServer\ControlServer.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
@ -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<CommandRegister> _logger;
|
||||
private readonly BotHandler _botHandler;
|
||||
private readonly AutoScanApp _autoScanApp;
|
||||
private readonly IControlServer _controlServer;
|
||||
private readonly IShinobiLinkFactory _shinobiLinkFactory;
|
||||
private readonly IDVRConnector _dvrConnector;
|
||||
|
||||
public CommandRegister(ILogger<CommandRegister> 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<string> 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;
|
||||
}
|
||||
}
|
@ -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 hostBuilder = new HostBuilder();
|
||||
hostBuilder.ConfigureServices((_, services) =>
|
||||
{
|
||||
var environment = Environment.GetEnvironmentVariable("CASABOT_ENVIRONMENT");
|
||||
IConfigurationRoot configuration = new ConfigurationBuilder()
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddJsonFile($"appsettings.json", true, true)
|
||||
.AddJsonFile($"appsettings.{environment}.json", true, true)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
var hostBuilder = new HostBuilder();
|
||||
hostBuilder.ConfigureServices((_, services) =>
|
||||
{
|
||||
services.AddSingleton(configuration);
|
||||
|
||||
services.AddLogging(configuration);
|
||||
@ -27,14 +28,20 @@ hostBuilder.ConfigureServices((_, services) =>
|
||||
services.Configure<TelegramOptions>(configuration.GetSection("Telegram"));
|
||||
services.Configure<AutoScanOptions>(configuration.GetSection("AutoScan"));
|
||||
services.Configure<ShinobiOptions>(configuration.GetSection("Shinobi"));
|
||||
services.Configure<ControlServerOptions>(configuration.GetSection("ControlServer"));
|
||||
|
||||
services.AddSingleton<BotHandler>();
|
||||
|
||||
services.AddSingleton<IControlServer, ControlServer.ControlServer>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<IControlServer>());
|
||||
|
||||
var token = configuration["Telegram:BotToken"] ?? "";
|
||||
services.AddSingleton<BotHandler>();
|
||||
services.AddBotClient(token);
|
||||
services.AddPolling<BotHandler>();
|
||||
services.AddSingleton<IUpdateHandler>(sp => sp.GetService<BotHandler>()!);
|
||||
|
||||
services.AddTransient<CommandRegister>();
|
||||
|
||||
|
||||
// To get notifications when a retry is performed
|
||||
|
||||
@ -47,6 +54,7 @@ hostBuilder.ConfigureServices((_, services) =>
|
||||
services.Configure<HostOptions>(hostOptions =>
|
||||
{
|
||||
hostOptions.BackgroundServiceExceptionBehavior = BackgroundServiceExceptionBehavior.Ignore;
|
||||
hostOptions.ServicesStartConcurrently = true;
|
||||
});
|
||||
|
||||
});
|
||||
@ -56,17 +64,18 @@ var host = hostBuilder.Build();
|
||||
|
||||
|
||||
var logger = host.Services.GetService<ILogger<Program>>()!;
|
||||
var botHandler = host.Services.GetService<BotHandler>()!;
|
||||
var autoScanApp = host.Services.GetService<AutoScanApp>()!;
|
||||
|
||||
CommandRegister.RegisterCommands(botHandler, autoScanApp);
|
||||
var commandRegister = host.Services.GetRequiredService<CommandRegister>();
|
||||
|
||||
|
||||
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);
|
||||
|
@ -1,7 +1,6 @@
|
||||
using System.Net.Mime;
|
||||
using Telegram.Bots.Types;
|
||||
|
||||
namespace CasaBotApp;
|
||||
namespace CasaBotApp.TelegramBot;
|
||||
|
||||
public class BotCommand
|
||||
{
|
@ -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<BotHandler> _logger;
|
||||
private readonly TelegramOptions _telegramOptions;
|
||||
private readonly List<Chat> _subscribers = [];
|
||||
private readonly List<Chat> _subscribersAlarm = [];
|
||||
private readonly Dictionary<string, BotCommand> _commands;
|
||||
//TODO hacerlo mejor.
|
||||
private readonly Dictionary<string, CallbackQueueItem> _callbackFunctions = new();
|
||||
private record CallbackQueueItem(DateTime inserted, Func<string, long, Task<string>> callback);
|
||||
|
||||
public Func<TextMessage, Task>? 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)
|
||||
@ -49,13 +74,19 @@ public class BotHandler : IUpdateHandler
|
||||
_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
|
||||
/// </summary>
|
||||
/// <param name="message"></param>
|
||||
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<Chat> 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
|
||||
/// </summary>
|
||||
/// <param name="path"></param>
|
||||
public async Task UpdatePhoto(string path)
|
||||
{
|
||||
/// <param name="caption">Optional message with photo</param>
|
||||
/// <param name="options">Actions options</param>
|
||||
public Task UpdatePhoto(string path, string? caption = null, IEnumerable<MsgOption>? options = null) =>
|
||||
UpdatePhotoInt(_subscribers, path, caption, options);
|
||||
|
||||
if (_subscribers.Count == 0)
|
||||
public Task AlertPhoto(string path, string? caption = null, IEnumerable<MsgOption>? options = null) =>
|
||||
UpdatePhotoInt(_subscribersAlarm, path, caption, options);
|
||||
|
||||
private async Task UpdatePhotoInt(List<Chat> subscription, string path, string? caption = null, IEnumerable<MsgOption>? options = null)
|
||||
{
|
||||
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<InlineButton[]> keyboard = optionsGrouped.Select(group =>
|
||||
group.Select<MsgOption, InlineButton>(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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Send photos to a specific chat
|
||||
/// </summary>
|
||||
@ -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<Response<TextMessage>> SndTxt(long id, string txt) => _bot.HandleAsync(new SendText(id.ToString(), txt));
|
||||
@ -341,6 +449,9 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
9
src/CasaBot/CasaBotApp/TelegramBot/MsgOption.cs
Normal file
9
src/CasaBot/CasaBotApp/TelegramBot/MsgOption.cs
Normal file
@ -0,0 +1,9 @@
|
||||
namespace CasaBotApp.TelegramBot;
|
||||
|
||||
public record MsgOption(OptionType type, string message, string content, Func<string, long, Task<string>>? callback = null);
|
||||
|
||||
public enum OptionType
|
||||
{
|
||||
Url,
|
||||
Action
|
||||
}
|
@ -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; } = [];
|
||||
}
|
@ -35,5 +35,8 @@
|
||||
}
|
||||
},
|
||||
"RemoveOriginalFiles": true,
|
||||
"RemoveDetectionFiles": false
|
||||
"RemoveDetectionFiles": false,
|
||||
"ControlServer": {
|
||||
"port": 9003
|
||||
}
|
||||
}
|
@ -36,5 +36,8 @@
|
||||
},
|
||||
"RemoveOriginalFiles": false,
|
||||
"RemoveDetectionFiles": false
|
||||
},
|
||||
"ControlServer": {
|
||||
"port": 9003
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
181
src/CasaBot/ControlServer/ControlServer.cs
Normal file
181
src/CasaBot/ControlServer/ControlServer.cs
Normal file
@ -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<ControlServer> _logger;
|
||||
private readonly ControlServerOptions _options;
|
||||
private readonly HttpListener _httpListener;
|
||||
private readonly Dictionary<long, SensorEvent> _events = new();
|
||||
private Func<SensorEvent, Task>? _onEventRecived;
|
||||
private bool _disarmRequestPending;
|
||||
|
||||
public ControlServer(ILogger<ControlServer> logger, IOptions<ControlServerOptions> 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>(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<Card> _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<SensorEvent, Task> onEventRecived)
|
||||
{
|
||||
_onEventRecived = onEventRecived;
|
||||
}
|
||||
|
||||
public void AuthorizeEvent(long eventId)
|
||||
{
|
||||
if (_events.TryGetValue(eventId, out var sensorEvent))
|
||||
{
|
||||
sensorEvent.Authorization = Authorization.Authorized;
|
||||
}
|
||||
}
|
||||
|
||||
public void RequestDisarm()
|
||||
{
|
||||
_disarmRequestPending = true;
|
||||
}
|
||||
|
||||
}
|
15
src/CasaBot/ControlServer/ControlServer.csproj
Normal file
15
src/CasaBot/ControlServer/ControlServer.csproj
Normal file
@ -0,0 +1,15 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
6
src/CasaBot/ControlServer/ControlServerOptions.cs
Normal file
6
src/CasaBot/ControlServer/ControlServerOptions.cs
Normal file
@ -0,0 +1,6 @@
|
||||
namespace ControlServer;
|
||||
|
||||
public class ControlServerOptions
|
||||
{
|
||||
public int port { get; set; }
|
||||
}
|
10
src/CasaBot/ControlServer/IControlServer.cs
Normal file
10
src/CasaBot/ControlServer/IControlServer.cs
Normal file
@ -0,0 +1,10 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace ControlServer;
|
||||
|
||||
public interface IControlServer : IHostedService
|
||||
{
|
||||
public void OnEvent(Func<SensorEvent, Task> onEventRecived);
|
||||
public void AuthorizeEvent(long eventId);
|
||||
public void RequestDisarm();
|
||||
}
|
21
src/CasaBot/ControlServer/SensorEvent.cs
Normal file
21
src/CasaBot/ControlServer/SensorEvent.cs
Normal file
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user