about summary refs log tree commit diff
path: root/Jenny
diff options
context:
space:
mode:
Diffstat (limited to 'Jenny')
-rw-r--r--Jenny/Commands/ConfigureCommand.cs38
-rw-r--r--Jenny/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs18
-rw-r--r--Jenny/Commands/ImgCommand.cs134
-rw-r--r--Jenny/Commands/PatCommand.cs164
-rw-r--r--Jenny/Handlers/CommandResultHandler.cs40
-rw-r--r--Jenny/Handlers/InviteHandler.cs12
-rw-r--r--Jenny/Jenny.csproj36
-rw-r--r--Jenny/JennyBot.cs32
-rw-r--r--Jenny/JennyConfiguration.cs9
-rw-r--r--Jenny/Program.cs33
-rw-r--r--Jenny/Properties/launchSettings.json26
m---------Jenny/Resources0
-rw-r--r--Jenny/appsettings.Development.json24
-rw-r--r--Jenny/appsettings.json9
14 files changed, 575 insertions, 0 deletions
diff --git a/Jenny/Commands/ConfigureCommand.cs b/Jenny/Commands/ConfigureCommand.cs
new file mode 100644
index 0000000..efd3417
--- /dev/null
+++ b/Jenny/Commands/ConfigureCommand.cs
@@ -0,0 +1,38 @@
+using System.Text;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Utilities.Bot.Commands;
+using LibMatrix.Utilities.Bot.Interfaces;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Jenny.Commands;
+
+public class ConfigureCommand(IServiceProvider svcs) : ICommandGroup {
+    public string Name { get; } = "configure";
+    public string[]? Aliases { get; } = ["config", "cfg"];
+    public string Description { get; }
+    public bool Unlisted { get; } = true;
+
+    public async Task Invoke(CommandContext ctx) {
+        var commands = svcs.GetServices<ICommand>().Where(x => x.GetType().IsAssignableTo(typeof(ICommand<>).MakeGenericType(GetType()))).ToList();
+
+        if (ctx.Args.Length == 0) {
+            await ctx.Room.SendMessageEventAsync(HelpCommand.GenerateCommandList(commands).Build());
+        }
+        else {
+            var subcommand = ctx.Args[0];
+            var command = commands.FirstOrDefault(x => x.Name == subcommand || x.Aliases?.Contains(subcommand) == true);
+            if (command == null) {
+                await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Unknown subcommand"));
+                return;
+            }
+
+            await command.Invoke(new CommandContext {
+                Room = ctx.Room,
+                MessageEvent = ctx.MessageEvent,
+                CommandName = ctx.CommandName,
+                Args = ctx.Args.Skip(1).ToArray(),
+                Homeserver = ctx.Homeserver
+            });
+        }
+    }
+}
\ No newline at end of file
diff --git a/Jenny/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs b/Jenny/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs
new file mode 100644
index 0000000..94294fb
--- /dev/null
+++ b/Jenny/Commands/ConfigureSubCommands/ControlRoomConfigureSubcommand.cs
@@ -0,0 +1,18 @@
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace Jenny.Commands.ConfigureSubCommands;
+
+public class ControlRoomConfigureSubCommand : ICommand<ConfigureCommand> {
+    public string Name { get; } = "controlroom";
+    public string[]? Aliases { get; }
+    public string Description { get; } = "Configure the control room";
+    public bool Unlisted { get; }
+
+    public async Task Invoke(CommandContext ctx) {
+        if (ctx.Args.Length == 0) {
+            await ctx.Room.SendMessageEventAsync(new MessageBuilder("m.notice").WithBody("meow").Build());
+        }
+        
+    }
+}
\ No newline at end of file
diff --git a/Jenny/Commands/ImgCommand.cs b/Jenny/Commands/ImgCommand.cs
new file mode 100644
index 0000000..9a4ed0c
--- /dev/null
+++ b/Jenny/Commands/ImgCommand.cs
@@ -0,0 +1,134 @@
+using System.Diagnostics;
+using System.Numerics;
+using ArcaneLibs;
+using ArcaneLibs.Extensions;
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace Jenny.Commands;
+
+public class ImgCommand : ICommand {
+    public string Name { get; } = "img";
+    public string[]? Aliases { get; } = [];
+    public string Description { get; }
+    public bool Unlisted { get; } = true;
+
+    public async Task Invoke(CommandContext ctx) {
+        int count = 1;
+        if (ctx.Args is { Length: 1 })
+            int.TryParse(ctx.Args[0], out count);
+
+        for (var i = 0; i < count; i++) {
+            new Thread(async () => {
+                var bigNoise = GenerateHeightMap(5000, 2000);
+                await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.image", "src_noise.png") {
+                    Url = await ctx.Homeserver.UploadFile("data.png", await Float2DArrayToPng(bigNoise), "image/png"),
+                    FileInfo = new() {
+                        Width = bigNoise.GetWidth(),
+                        Height = bigNoise.GetHeight()
+                    }
+                });
+            }).Start();
+        }
+
+    }
+    
+    public async Task<byte[]> Float2DArrayToPng(float[,] data) {
+        //dump heightmap as PPM
+        Console.WriteLine($"{DateTime.Now} Converting to PNG");
+        var width = data.GetLength(1);
+        var height = data.GetLength(0);
+
+        //convert ppm to png with ffmpeg
+        var process = new Process {
+            StartInfo = new ProcessStartInfo {
+                // FileName = "/nix/store/4hz763c5w2hnzm55ll5vgfgmrr6i9kgg-imagemagick-7.1.1-28/bin/convert",
+                FileName = "convert",
+                Arguments = $"ppm:- png:-",
+                UseShellExecute = false,
+                RedirectStandardInput = true,
+                RedirectStandardOutput = true
+            }
+        };
+        process.Start();
+        await process.StandardInput.WriteLineAsync($"P2\n{width} {height}\n255");
+        for (var i = 0; i < height; i++) {
+            for (var j = 0; j < width; j++) {
+                await process.StandardInput.WriteAsync($"{(int)(data[i, j] * 255)} ");
+            }
+
+            // ppm.AppendLine();
+        }
+        await process.StandardInput.FlushAsync();
+        process.StandardInput.Close();
+        await using var ms = new MemoryStream();
+        await process.StandardOutput.BaseStream.CopyToAsync(ms);
+        ms.Position = 0;
+        Console.WriteLine($"{DateTime.Now} Converted to PNG");
+        return ms.ToArray();
+    }
+
+    public float[,] GenerateHeightMap(int width, int height) {
+        var rnd = new Random();
+
+        var bigNoiseVector3 = new Vector3[height, width];
+        for (var y = 0; y < bigNoiseVector3.GetLength(0); y++) {
+            for (var x = 0; x < bigNoiseVector3.GetLength(1); x++) {
+                bigNoiseVector3[y, x] = new Vector3(0, 0, 0);
+            }
+        }
+
+        bigNoiseVector3[0, 0] = new Vector3(rnd.NextSingle(), rnd.NextSingle(), rnd.NextSingle());
+        var last = bigNoiseVector3[0, 0];
+        for (var y = 0; y < bigNoiseVector3.GetLength(0); y++) {
+            if (y > 0) break;
+            for (var x = 0; x < bigNoiseVector3.GetLength(1); x++) {
+                float currentX = x;
+                float currentY = y;
+                int steps = 0;
+                int maxSteps = 1000;
+                while (steps++ < maxSteps) {
+                    if (currentX < 0) break;
+                    if (currentY < 0) break;
+                    if (currentX > bigNoiseVector3.GetWidth() - 1) break;
+                    if (currentY > bigNoiseVector3.GetHeight() - 1) break;
+
+                    var current = bigNoiseVector3[(int)currentY, (int)currentX];
+                    // if (current is {X: 0f, Y: 0f, Z: 0f}) {
+                    // bigNoiseVector3[currentY, currentX] = current = new Vector3(rnd.NextSingle(), rnd.NextSingle(), rnd.NextSingle());
+                    // }
+
+                    current = new(last.X, last.Y, last.Z);
+                    var diff = new Vector3(
+                        MathUtil.Map(rnd.NextSingle(), 0f, 1f, -0.2f, 0.2f),
+                        MathUtil.Map(rnd.NextSingle(), 0f, 1f, -0.2f, 0.2f),
+                        -0.1f
+                    );
+                    current += diff;
+                    bigNoiseVector3[(int)currentY, (int)currentX] = current;
+
+                    // Console.WriteLine("{0}/{1}={2} (+{3})", currentX, currentY, current, diff);
+                    currentX += current.X;
+                    currentY += current.Y;
+                    
+                    // if (current.X > 0.666f) currentX++;
+                    // else if (current.X < 0.333f) currentX--;
+                    // if (current.Y > 0.666f) currentY++;
+                    // else if (current.Y < 0.333f) currentY--;
+
+                    last = current;
+                }
+            }
+        }
+
+        var bigNoise = new float[height, width];
+        for (var i = 0; i < bigNoise.GetLength(0); i++) {
+            for (var j = 0; j < bigNoise.GetLength(1); j++) {
+                // bigNoise[i, j] = (float) (bigNoiseVector[i, j].Length() / Math.Sqrt(2));
+                bigNoise[i, j] = (float)(bigNoiseVector3[i, j].Z);
+            }
+        }
+
+        return bigNoise;
+    }
+}
\ No newline at end of file
diff --git a/Jenny/Commands/PatCommand.cs b/Jenny/Commands/PatCommand.cs
new file mode 100644
index 0000000..efceaa4
--- /dev/null
+++ b/Jenny/Commands/PatCommand.cs
@@ -0,0 +1,164 @@
+using System.Text;
+using LibMatrix.EventTypes.Spec.State;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace Jenny.Commands;
+
+public class PatCommand : ICommand {
+    public string Name { get; } = "pat";
+    public string[]? Aliases { get; } = [ "patpat", "patpatpat" ];
+    public string Description { get; }
+    public bool Unlisted { get; } = true;
+
+    public async Task Invoke(CommandContext ctx) {
+        int count = 1;
+        if (ctx.Args is { Length: 1 })
+            int.TryParse(ctx.Args[0], out count);
+
+        var selfName =
+            (await ctx.Room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, ctx.Homeserver.UserId))?.DisplayName
+            ?? (await ctx.Homeserver.GetProfileAsync(ctx.Homeserver.UserId)).DisplayName
+            ?? ctx.Homeserver.WhoAmI.UserId;
+        
+        var remoteName =
+            ctx.MessageEvent.Sender == null
+                ? null
+                : (await ctx.Room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, ctx.MessageEvent.Sender))?.DisplayName
+                  ?? (await ctx.Homeserver.GetProfileAsync(ctx.MessageEvent.Sender)).DisplayName
+                  ?? ctx.MessageEvent.Sender;
+
+        var msb = new MessageBuilder("m.emote");
+        var pat = new StringBuilder();
+        var msg = $"snuggles {remoteName}";
+
+        Console.WriteLine(pat.ToString());
+        // msb.WithHtmlTag("code", await GenerateSkyboxAroundString(msg, ctx));
+        msb.WithBody(msg);
+        
+        await ctx.Room.SendMessageEventAsync(msb.Build());
+        Console.WriteLine(msb.Build().FormattedBody);
+
+    }
+    
+#region old stuff
+    // TODO: implement:
+    // var selfName =
+    //     (await ctx.Room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, ctx.Homeserver.UserId))?.DisplayName
+    //     ?? (await ctx.Homeserver.GetProfileAsync(ctx.Homeserver.UserId)).DisplayName
+    //     ?? ctx.Homeserver.WhoAmI.UserId;
+    //
+    // var remoteName =
+    //     ctx.MessageEvent.Sender == null
+    //         ? null
+    //         : (await ctx.Room.GetStateAsync<RoomMemberEventContent>(RoomMemberEventContent.EventId, ctx.MessageEvent.Sender))?.DisplayName
+    //           ?? (await ctx.Homeserver.GetProfileAsync(ctx.MessageEvent.Sender)).DisplayName
+    //           ?? ctx.MessageEvent.Sender;
+
+    // var msb = new MessageBuilder();
+    // var pat = new StringBuilder();
+    // var msg = $"{selfName} snuggles {remoteName}";
+
+    // Console.WriteLine(pat.ToString());
+    // msb.WithHtmlTag("code", await GenerateSkyboxAroundString(msg, ctx));
+    // Console.WriteLine(msb.Build().FormattedBody);
+//}
+
+    //
+    //
+    // private class CharacterWeight {
+    //     public float Width { get; set; }
+    //     public float Density { get; set; }
+    // }
+    //
+    // private Dictionary<char, CharacterWeight> starrySkyCharacters = new Dictionary<char, CharacterWeight> {
+    //     { ' ', new CharacterWeight { Width = 0.5f, Density = 0.1f } }, // Space has the lowest density and width
+    //     { '.', new CharacterWeight { Width = 0.2f, Density = 0.2f } }, // Dot has low density but small width
+    //     { '+', new CharacterWeight { Width = 0.6f, Density = 0.3f } }, // Plus sign has medium density and width
+    //     { '*', new CharacterWeight { Width = 0.7f, Density = 0.7f } }, // Asterisk has high density but medium width
+    //
+    //     { '¨', new CharacterWeight { Width = 0.3f, Density = 0.2f } },
+    //     { '˜', new CharacterWeight { Width = 0.4f, Density = 0.3f } },
+    //     { 'ˆ', new CharacterWeight { Width = 0.5f, Density = 0.4f } },
+    //     { '”', new CharacterWeight { Width = 0.6f, Density = 0.5f } },
+    //     { '⍣', new CharacterWeight { Width = 0.8f, Density = 0.7f } },
+    //     { '~', new CharacterWeight { Width = 0.9f, Density = 0.8f } },
+    //     { '⊹', new CharacterWeight { Width = 1.2f, Density = 1.1f } },
+    //     { '٭', new CharacterWeight { Width = 1.3f, Density = 1.2f } },
+    //     { '„', new CharacterWeight { Width = 1.4f, Density = 1.3f } },
+    //     { '¸', new CharacterWeight { Width = 1.5f, Density = 1.4f } },
+    //     { '¤', new CharacterWeight { Width = 1.9f, Density = 1.8f } },
+    //     { '✬', new CharacterWeight { Width = 2.1f, Density = 2.0f } },
+    //     { '°', new CharacterWeight { Width = 0.6f, Density = 0.35f } },
+    //     { '•', new CharacterWeight { Width = 0.6f, Density = 0.4f } },
+    //     { '✡', new CharacterWeight { Width = 2.0f, Density = 4.0f } },
+    //     { '#', new CharacterWeight { Width = 1.0f, Density = 1.0f } },
+    // };
+    //
+    // private Dictionary<char, CharacterWeight> characterWeights = new Dictionary<char, CharacterWeight> {
+    //     { 'a', new() { Density = 1, Width = 1 } }
+    // };
+    //
+    // private async Task<string> GenerateSkyboxAroundString(string str, CommandContext ctx) {
+    //     var sb = new StringBuilder();
+    //     int scale = 32;
+    //     var outerTopBottomBorder = 4;
+    //     var outerLeftRightBorder = 2;
+    //     var innerTopBottomBorder = 1;
+    //     var innerLeftRightBorder = 2;
+    //
+    //     var innerBorder = 2;
+    //
+    //     var width = str.Length + outerLeftRightBorder * 2 + innerLeftRightBorder * 2;
+    //     var height = outerTopBottomBorder * 2 + innerTopBottomBorder * 2 + 1;
+    //     var skybox = new char[height, width];
+    //     var bigNoise = GenerateHeightMap(2000, 1000);
+    //
+    //     // await ctx.Room.SendMessageEventAsync(new RoomMessageEventContent("m.image", "heightmap.png") {
+    //     //     Url = await Float2DArrayToMxc(noise, ctx),
+    //     //     FileInfo = new() {
+    //     //         Width = noise.GetLength(1),
+    //     //         Height = noise.GetLength(0)
+    //     //     }
+    //     // });
+    //     //
+    //     // //fill skybox with characters according to gradient noise
+    //     // for (var i = 0; i < height; i++) {
+    //     //     for (var j = 0; j < width; j++) {
+    //     //         var c = ' ';
+    //     //         var shuffled = Random.Shared.GetItems(starrySkyCharacters.ToArray(), starrySkyCharacters.Count);
+    //     //         var cellWeight = noise[i, j];
+    //     //         var item = starrySkyCharacters.OrderByDescending(x => x.Value.Density).FirstOrDefault(x => x.Value.Density <= cellWeight);
+    //     //         if (item.Value != null) {
+    //     //             c = item.Key;
+    //     //         }
+    //     //
+    //     //         // foreach (var (key, value) in starrySkyCharacters) {
+    //     //         //     var diff = Math.Abs(noise[i, j] - value.Density);
+    //     //         //     if (diff < min) {
+    //     //         //         min = diff;
+    //     //         //         c = key;
+    //     //         //     }
+    //     //         // }
+    //     //
+    //     //         skybox[i, j] = c;
+    //     //     }
+    //     // }
+    //     //
+    //     // for (var i = 0; i < str.Length; i++) {
+    //     //     skybox[outerTopBottomBorder + innerTopBottomBorder, i + outerLeftRightBorder + innerLeftRightBorder] = str[i];
+    //     // }
+    //     //
+    //     // for (var i = 0; i < height; i++) {
+    //     //     for (var j = 0; j < width; j++) {
+    //     //         sb.Append(skybox[i, j]);
+    //     //     }
+    //     //
+    //     //     sb.AppendLine();
+    //     // }
+    //
+    //     return sb.ToString();
+    // }
+
+#endregion
+}
\ No newline at end of file
diff --git a/Jenny/Handlers/CommandResultHandler.cs b/Jenny/Handlers/CommandResultHandler.cs
new file mode 100644
index 0000000..767944a
--- /dev/null
+++ b/Jenny/Handlers/CommandResultHandler.cs
@@ -0,0 +1,40 @@
+using ArcaneLibs;
+using LibMatrix.Helpers;
+using LibMatrix.Utilities.Bot.Interfaces;
+
+namespace Jenny.Handlers;
+
+public static class CommandResultHandler {
+    private static string binDir = FileUtils.GetBinDir();
+
+    public static async Task HandleAsync(CommandResult res) {
+        {
+            if (res.Success) return;
+            var room = res.Context.Room;
+            var hs = res.Context.Homeserver;
+            var msb = new MessageBuilder();
+            if (res.Result == CommandResult.CommandResultType.Failure_Exception) {
+                var angryEmojiPath = Path.Combine(binDir, "Resources", "Stickers", "JennyAngryPink.webp");
+                var hash = await FileUtils.GetFileSha384Async(angryEmojiPath);
+                var angryEmoji = await hs.NamedCaches.FileCache.GetOrSetValueAsync(hash, async () => {
+                    await using var fs = File.OpenRead(angryEmojiPath);
+                    return await hs.UploadFile("JennyAngryPink.webp", fs, "image/webp");
+                });
+                msb.WithCustomEmoji(angryEmoji, "JennyAngryPink")
+                    .WithColoredBody("#EE4444", "An error occurred during the execution of this command")
+                    .WithCodeBlock(res.Exception!.ToString(), "csharp");
+            }
+            // else if(res.Result == CommandResult.CommandResultType.) {
+            // msb.AddMessage(new RoomMessageEventContent("m.notice", "An error occurred during the execution of this command"));
+            // }
+            // var msg = res.Result switch {
+            //     CommandResult.CommandResultType.Failure_Exception => MessageFormatter.FormatException("An error occurred during the execution of this command", res.Exception!)
+            //     CommandResult.CommandResultType.Failure_NoPermission => new RoomMessageEventContent("m.notice", "You do not have permission to run this command!"),
+            //     CommandResult.CommandResultType.Failure_InvalidCommand => new RoomMessageEventContent("m.notice", $"Command \"{res.Context.CommandName}\" not found!"),
+            //     _ => throw new ArgumentOutOfRangeException()
+            // };
+
+            await room.SendMessageEventAsync(msb.Build());
+        }
+    }
+}
\ No newline at end of file
diff --git a/Jenny/Handlers/InviteHandler.cs b/Jenny/Handlers/InviteHandler.cs
new file mode 100644
index 0000000..128de44
--- /dev/null
+++ b/Jenny/Handlers/InviteHandler.cs
@@ -0,0 +1,12 @@
+using LibMatrix.EventTypes.Spec;
+using LibMatrix.Utilities.Bot.Services;
+
+namespace Jenny.Handlers;
+
+public static class InviteHandler {
+    public static async Task HandleAsync(InviteHandlerHostedService.InviteEventArgs invite) {
+        var room = invite.Homeserver.GetRoom(invite.RoomId);
+        await room.JoinAsync();
+        await room.SendMessageEventAsync(new RoomMessageEventContent("m.notice", "Hello! I'm Jenny!"));
+    }
+}
\ No newline at end of file
diff --git a/Jenny/Jenny.csproj b/Jenny/Jenny.csproj
new file mode 100644
index 0000000..c97a412
--- /dev/null
+++ b/Jenny/Jenny.csproj
@@ -0,0 +1,36 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+    <PropertyGroup>
+        <OutputType>Exe</OutputType>
+        <TargetFramework>net8.0</TargetFramework>
+        <LangVersion>preview</LangVersion>
+        <ImplicitUsings>enable</ImplicitUsings>
+        <Nullable>enable</Nullable>
+        <PublishAot>false</PublishAot>
+        <InvariantGlobalization>true</InvariantGlobalization>
+        <!--    <PublishTrimmed>true</PublishTrimmed>-->
+        <!--    <PublishReadyToRun>true</PublishReadyToRun>-->
+        <!--    <PublishSingleFile>true</PublishSingleFile>-->
+        <!--    <PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>-->
+        <!--    <PublishTrimmedShowLinkerSizeComparison>true</PublishTrimmedShowLinkerSizeComparison>-->
+        <!--    <PublishTrimmedShowLinkerSizeComparisonWarnings>true</PublishTrimmedShowLinkerSizeComparisonWarnings>-->
+    </PropertyGroup>
+
+    <ItemGroup>
+        <ProjectReference Include="..\LibMatrix\LibMatrix\LibMatrix.csproj" />
+        <ProjectReference Include="..\LibMatrix\Utilities\LibMatrix.Utilities.Bot\LibMatrix.Utilities.Bot.csproj" />
+    </ItemGroup>
+
+    <ItemGroup>
+        <PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.0" />
+    </ItemGroup>
+    <ItemGroup>
+        <Content Include="appsettings*.json">
+            <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+        </Content>
+        <Content Include="Resources\**\*">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </Content>
+        <Content Remove="Resources\.git\**\*"/>
+    </ItemGroup>
+</Project>
diff --git a/Jenny/JennyBot.cs b/Jenny/JennyBot.cs
new file mode 100644
index 0000000..2ba835d
--- /dev/null
+++ b/Jenny/JennyBot.cs
@@ -0,0 +1,32 @@
+using LibMatrix.Homeservers;
+using LibMatrix.RoomTypes;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace Jenny;
+
+public class JennyBot(AuthenticatedHomeserverGeneric hs, ILogger<JennyBot> logger, JennyConfiguration configuration) : IHostedService {
+    private Task _listenerTask;
+
+    // private GenericRoom _policyRoom;
+    private GenericRoom? _logRoom;
+    private GenericRoom? _controlRoom;
+
+    /// <summary>Triggered when the application host is ready to start the service.</summary>
+    /// <param name="cancellationToken">Indicates that the start process has been aborted.</param>
+    public async Task StartAsync(CancellationToken cancellationToken) {
+        _listenerTask = Run(cancellationToken);
+        logger.LogInformation("Bot started!");
+    }
+
+    private async Task Run(CancellationToken cancellationToken) {
+ 
+    }
+
+    /// <summary>Triggered when the application host is performing a graceful shutdown.</summary>
+    /// <param name="cancellationToken">Indicates that the shutdown process should no longer be graceful.</param>
+    public async Task StopAsync(CancellationToken cancellationToken) {
+        logger.LogInformation("Shutting down bot!");
+    }
+
+}
diff --git a/Jenny/JennyConfiguration.cs b/Jenny/JennyConfiguration.cs
new file mode 100644
index 0000000..a1609e7
--- /dev/null
+++ b/Jenny/JennyConfiguration.cs
@@ -0,0 +1,9 @@
+using Microsoft.Extensions.Configuration;
+
+namespace Jenny;
+
+public class JennyConfiguration {
+    public JennyConfiguration(IConfiguration config) => config.GetRequiredSection("Jenny").Bind(this);
+
+    public List<string> Admins { get; set; } = new();
+}
diff --git a/Jenny/Program.cs b/Jenny/Program.cs
new file mode 100644
index 0000000..2ee2c9b
--- /dev/null
+++ b/Jenny/Program.cs
@@ -0,0 +1,33 @@
+using Jenny;
+using Jenny.Handlers;
+using LibMatrix.Services;
+using LibMatrix.Utilities.Bot;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+
+var builder = Host.CreateDefaultBuilder(args);
+
+builder.ConfigureHostOptions(host => {
+    host.ServicesStartConcurrently = true;
+    host.ServicesStopConcurrently = true;
+    host.ShutdownTimeout = TimeSpan.FromSeconds(5);
+});
+
+if (Environment.GetEnvironmentVariable("JENNY_APPSETTINGS_PATH") is string path)
+    builder.ConfigureAppConfiguration(x => x.AddJsonFile(path));
+
+var host = builder.ConfigureServices((_, services) => {
+    services.AddSingleton<JennyConfiguration>();
+
+    services.AddRoryLibMatrixServices(new() {
+        AppName = "Jenny"
+    });
+    services.AddMatrixBot().AddCommandHandler().DiscoverAllCommands()
+        .WithInviteHandler(InviteHandler.HandleAsync)
+        .WithCommandResultHandler(CommandResultHandler.HandleAsync);
+
+    services.AddHostedService<JennyBot>();
+}).UseConsoleLifetime().Build();
+
+await host.RunAsync();
\ No newline at end of file
diff --git a/Jenny/Properties/launchSettings.json b/Jenny/Properties/launchSettings.json
new file mode 100644
index 0000000..997e294
--- /dev/null
+++ b/Jenny/Properties/launchSettings.json
@@ -0,0 +1,26 @@
+{
+  "$schema": "http://json.schemastore.org/launchsettings.json",
+  "profiles": {
+    "Default": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+
+      }
+    },
+    "Development": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Development"
+      }
+    },
+    "Local config": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "environmentVariables": {
+        "DOTNET_ENVIRONMENT": "Local"
+      }
+    }
+  }
+}
diff --git a/Jenny/Resources b/Jenny/Resources
new file mode 160000
+Subproject 46edc2287e1cc9009a9a72447a6603e959a8971
diff --git a/Jenny/appsettings.Development.json b/Jenny/appsettings.Development.json
new file mode 100644
index 0000000..224d0da
--- /dev/null
+++ b/Jenny/appsettings.Development.json
@@ -0,0 +1,24 @@
+{
+  "Logging": {
+    "LogLevel": {
+      "Default": "Debug",
+      "System": "Information",
+      "Microsoft": "Information"
+    }
+  },
+  "LibMatrixBot": {
+    // The homeserver to connect to
+    "Homeserver": "rory.gay",
+    // The access token to use
+    "AccessToken": "syt_xxxxxxxxxxxxxxxxx",
+    // The command prefix
+    "Prefix": "?"
+  },
+  "ModerationBot": {
+    // List of people who should be invited to the control room
+    "Admins": [
+      "@emma:conduit.rory.gay",
+      "@emma:rory.gay"
+    ]
+  }
+}
diff --git a/Jenny/appsettings.json b/Jenny/appsettings.json
new file mode 100644
index 0000000..6ba02f3
--- /dev/null
+++ b/Jenny/appsettings.json
@@ -0,0 +1,9 @@
+{
+    "Logging": {
+        "LogLevel": {
+            "Default": "Debug",
+            "System": "Information",
+            "Microsoft": "Information"
+        }
+    }
+}