diff --git a/SfeduSchedule.Plugin.Abstractions/IPlugin.cs b/SfeduSchedule.Plugin.Abstractions/IPlugin.cs
new file mode 100644
index 0000000..ef08039
--- /dev/null
+++ b/SfeduSchedule.Plugin.Abstractions/IPlugin.cs
@@ -0,0 +1,13 @@
+namespace SfeduSchedule.Plugin.Abstractions;
+
+// Базовый контракт плагина (общий для хоста и плагинов)
+public interface IPlugin
+{
+ string Name { get; }
+
+ // Регистрация сервисов плагина в DI (выполняется до Build())
+ void ConfigureServices(IServiceCollection services);
+
+ // Регистрация маршрутов (Minimal API) плагина после Build()
+ void MapEndpoints(IEndpointRouteBuilder endpoints);
+}
\ No newline at end of file
diff --git a/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj b/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj
new file mode 100644
index 0000000..0ad852d
--- /dev/null
+++ b/SfeduSchedule.Plugin.Abstractions/SfeduSchedule.Plugin.Abstractions.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net9.0
+ enable
+ enable
+ Library
+
+
+
diff --git a/SfeduSchedule.Plugin.Sample/Plugin.cs b/SfeduSchedule.Plugin.Sample/Plugin.cs
new file mode 100644
index 0000000..64cc96e
--- /dev/null
+++ b/SfeduSchedule.Plugin.Sample/Plugin.cs
@@ -0,0 +1,45 @@
+using Microsoft.AspNetCore.Mvc;
+using SfeduSchedule.Plugin.Abstractions;
+
+namespace SfeduSchedule.Plugin.Sample;
+
+// Пример сервиса плагина
+public interface IGreeter
+{
+ string Greet(string name);
+}
+
+public sealed class Greeter : IGreeter
+{
+ public string Greet(string name) => $"Hello, {name} from {nameof(Plugin)}!";
+}
+
+public sealed class SamplePlugin : IPlugin
+{
+ public string Name => "Sample";
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ // Регистрируем DI-сервисы плагина
+ services.AddScoped();
+ // Можно регистрировать любые IHostedService, Options и т.д.
+ }
+
+ public void MapEndpoints(IEndpointRouteBuilder endpoints)
+ {
+ // Пример Minimal API эндпоинта
+ endpoints.MapGet("/plugins/sample/hello", (IGreeter greeter) =>
+ {
+ return Results.Ok(new { message = greeter.Greet("world") });
+ });
+ }
+}
+
+// Пример MVC-контроллера из плагина
+[ApiController]
+[Route("plugins/sample/[controller]")]
+public class EchoController : ControllerBase
+{
+ [HttpGet("{text}")]
+ public IActionResult Get(string text) => Ok(new { echo = text, from = "Plugin.Sample" });
+}
\ No newline at end of file
diff --git a/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj b/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj
new file mode 100644
index 0000000..0a44fa7
--- /dev/null
+++ b/SfeduSchedule.Plugin.Sample/SfeduSchedule.Plugin.Sample.csproj
@@ -0,0 +1,21 @@
+
+
+
+ SfeduSchedule.Plugin.Sample.plugin
+ net9.0
+ enable
+
+ true
+ enable
+ Library
+ enable
+
+
+
+
+
+
+
+
+
+
diff --git a/SfeduSchedule.sln b/SfeduSchedule.sln
index 50668ae..35476ce 100644
--- a/SfeduSchedule.sln
+++ b/SfeduSchedule.sln
@@ -2,6 +2,10 @@
Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule", "SfeduSchedule\SfeduSchedule.csproj", "{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Abstractions", "SfeduSchedule.Plugin.Abstractions\SfeduSchedule.Plugin.Abstractions.csproj", "{B2E8DAD7-7373-4155-B230-4E53DFC04445}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SfeduSchedule.Plugin.Sample", "SfeduSchedule.Plugin.Sample\SfeduSchedule.Plugin.Sample.csproj", "{B2B6D730-46AE-40ED-815F-81176FB4E545}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,5 +16,13 @@ Global
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{57B088A7-D7E2-4B5D-82A4-A3070A78A3E4}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2E8DAD7-7373-4155-B230-4E53DFC04445}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B2B6D730-46AE-40ED-815F-81176FB4E545}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B2B6D730-46AE-40ED-815F-81176FB4E545}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/SfeduSchedule/PluginLoader.cs b/SfeduSchedule/PluginLoader.cs
new file mode 100644
index 0000000..f46a58c
--- /dev/null
+++ b/SfeduSchedule/PluginLoader.cs
@@ -0,0 +1,64 @@
+using System.Reflection;
+using System.Runtime.Loader;
+using SfeduSchedule.Plugin.Abstractions;
+
+public sealed record LoadedPlugin(IPlugin Instance, Assembly Assembly, PluginLoadContext Context);
+
+public static class PluginLoader
+{
+ // Загружаем все сборки *.plugin.dll из папки плагинов
+ public static IReadOnlyList LoadPlugins(string pluginsDir)
+ {
+ var result = new List();
+
+ if (!Directory.Exists(pluginsDir))
+ return result;
+
+ foreach (var file in Directory.EnumerateFiles(pluginsDir, "*.plugin.dll", SearchOption.AllDirectories))
+ {
+ var path = Path.GetFullPath(file);
+ var alc = new PluginLoadContext(path);
+ var asm = alc.LoadFromAssemblyPath(path);
+
+ // Ищем реализацию IPlugin
+ var pluginType = asm
+ .GetTypes()
+ .FirstOrDefault(t => typeof(IPlugin).IsAssignableFrom(t) && !t.IsAbstract && !t.IsInterface);
+
+ if (pluginType is null)
+ continue;
+
+ var instance = (IPlugin)Activator.CreateInstance(pluginType)!;
+ result.Add(new LoadedPlugin(instance, asm, alc));
+ }
+
+ return result;
+ }
+}
+
+// Отдельный контекст загрузки для изоляции зависимостей плагина
+public sealed class PluginLoadContext : AssemblyLoadContext
+{
+ private readonly AssemblyDependencyResolver _resolver;
+
+ public PluginLoadContext(string pluginMainAssemblyPath)
+ : base(isCollectible: true)
+ {
+ _resolver = new AssemblyDependencyResolver(pluginMainAssemblyPath);
+ }
+
+ // Разрешаем управляемые зависимости плагина из его папки.
+ // Возвращаем null, чтобы отдать решение в Default ALC для общих сборок (например, Plugin.Abstractions).
+ protected override Assembly? Load(AssemblyName assemblyName)
+ {
+ var path = _resolver.ResolveAssemblyToPath(assemblyName);
+ return path is null ? null : LoadFromAssemblyPath(path);
+ }
+
+ // Нативные зависимости (если есть)
+ protected override IntPtr LoadUnmanagedDll(string unmanagedDllName)
+ {
+ var path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
+ return path is null ? IntPtr.Zero : LoadUnmanagedDllFromPath(path);
+ }
+}
\ No newline at end of file
diff --git a/SfeduSchedule/Program.cs b/SfeduSchedule/Program.cs
index c7fc75a..21d409f 100644
--- a/SfeduSchedule/Program.cs
+++ b/SfeduSchedule/Program.cs
@@ -6,6 +6,7 @@ using SfeduSchedule.Jobs;
using SfeduSchedule.Services;
using X.Extensions.Logging.Telegram.Extensions;
using Microsoft.AspNetCore.HttpOverrides;
+using Microsoft.AspNetCore.Mvc.ApplicationParts;
var builder = WebApplication.CreateBuilder(args);
@@ -26,7 +27,7 @@ if (!Directory.Exists(dataDirectory))
}
GlobalVariables.JwtFilePath = Path.Combine(dataDirectory, "jwt.txt");
-
+var pluginsPath = Path.Combine(dataDirectory, "Plugins");
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
@@ -45,11 +46,24 @@ if (!string.IsNullOrEmpty(tgChatId) && !string.IsNullOrEmpty(tgToken))
};
});
-builder.Services.AddControllers();
+// Включаем MVC контроллеры
+var mvcBuilder = builder.Services.AddControllers();
builder.Services.AddHttpClient();
builder.Services.AddMicrosoftIdentityWebAppAuthentication(builder.Configuration);
+// Загружаем плагины (изолированно), даём им зарегистрировать DI и подключаем контроллеры
+var loaded = PluginLoader.LoadPlugins(pluginsPath);
+foreach (var p in loaded)
+{
+ // DI из плагина
+ p.Instance.ConfigureServices(builder.Services);
+
+ // Подключаем контроллеры плагина
+ mvcBuilder.PartManager.ApplicationParts.Add(new AssemblyPart(p.Assembly));
+}
+
+
var jobKey = new JobKey("UpdateJWTJob");
if (string.IsNullOrEmpty(preinstalledJwtToken))
@@ -175,6 +189,13 @@ app.MapGet("/", async context =>
app.MapControllers();
+// Маршруты Minimal API из плагинов
+foreach (var p in loaded)
+{
+ logger.LogInformation("Mapping endpoints for plugin: {PluginName}", p.Instance.Name);
+ p.Instance.MapEndpoints(app);
+}
+
app.UseRateLimiter();
app.Run();
\ No newline at end of file
diff --git a/SfeduSchedule/SfeduSchedule.csproj b/SfeduSchedule/SfeduSchedule.csproj
index 3e3865b..3bc8305 100644
--- a/SfeduSchedule/SfeduSchedule.csproj
+++ b/SfeduSchedule/SfeduSchedule.csproj
@@ -18,4 +18,8 @@
+
+
+
+