commit 5d3a5a173d0b9230029ed23f7e675d3e3bdbfbc6 Author: Oliver Booth Date: Tue Aug 22 14:57:18 2023 +0100 feat: initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..af50df1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/.idea +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md \ No newline at end of file diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..593e231 --- /dev/null +++ b/.github/CODE_OF_CONDUCT.md @@ -0,0 +1,133 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +yiliansource@gmail.com or me@olivr.me. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations + diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..86dc0fe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,2 @@ +ko_fi: oliverbooth +custom: ['https://buymeacoffee.com/oliverbooth'] diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml new file mode 100644 index 0000000..28319a4 --- /dev/null +++ b/.github/workflows/docker-release.yml @@ -0,0 +1,42 @@ +name: Docker Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Log in to the Container registry + uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Build and push Docker image + uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml new file mode 100644 index 0000000..c945a45 --- /dev/null +++ b/.github/workflows/dotnet.yml @@ -0,0 +1,25 @@ +name: .NET + +on: + push: + pull_request: + +jobs: + build: + name: "Build & Test" + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + - name: Add NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + - name: Restore dependencies + run: dotnet restore + - name: Build + run: dotnet build --no-restore --configuration Release + - name: Test + run: dotnet test --no-build --verbosity normal diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml new file mode 100644 index 0000000..9c76953 --- /dev/null +++ b/.github/workflows/prerelease.yml @@ -0,0 +1,35 @@ +name: Tagged Pre-Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+-*" + +jobs: + prerelease: + name: "Tagged Pre-Release" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Add GitHub NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c Release + + - name: Create Release + uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..6e91b3a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,35 @@ +name: Tagged Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + +jobs: + release: + name: "Tagged Release" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Setup .NET + uses: actions/setup-dotnet@v3 + with: + dotnet-version: 7.0.x + + - name: Add GitHub NuGet source + run: dotnet nuget add source --username oliverbooth --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/oliverbooth/index.json" + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build -c Release + + - name: Create Release + uses: "marvinpinto/action-automatic-releases@latest" + with: + repo_token: "${{ secrets.GITHUB_TOKEN }}" + prerelease: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4997fcf --- /dev/null +++ b/.gitignore @@ -0,0 +1,169 @@ +*.swp +*.*~ +project.lock.json +.DS_Store +*.pyc +nupkg/ +tmp/ + +# Visual Studio Code +.vscode + +# Rider +.idea + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +build/ +bld/ +[Bb]in/ +[Oo]bj/ +[Oo]ut/ +msbuild.log +msbuild.err +msbuild.wrn + +# Visual Studio 2015 +.vs/ + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f98b30 --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# VpBridge + +VpBridge is a simple bot for both Discord and Virtual Paradise which bridges chat messages from a designated Discord channel, to a world in Virtual Paradise. \ No newline at end of file diff --git a/VpBridge.sln b/VpBridge.sln new file mode 100644 index 0000000..fddd12c --- /dev/null +++ b/VpBridge.sln @@ -0,0 +1,16 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VpBridge", "VpBridge\VpBridge.csproj", "{CD488A1E-0232-4EB5-A381-38A42B267B11}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD488A1E-0232-4EB5-A381-38A42B267B11}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/VpBridge/Dockerfile b/VpBridge/Dockerfile new file mode 100644 index 0000000..511f9c0 --- /dev/null +++ b/VpBridge/Dockerfile @@ -0,0 +1,20 @@ +FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS base +WORKDIR /app +EXPOSE 80 +EXPOSE 443 + +FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build +WORKDIR /src +COPY ["VpBridge/VpBridge.csproj", "VpBridge/"] +RUN dotnet restore "VpBridge/VpBridge.csproj" +COPY . . +WORKDIR "/src/VpBridge" +RUN dotnet build "VpBridge.csproj" -c Release -o /app/build + +FROM build AS publish +RUN dotnet publish "VpBridge.csproj" -c Release -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "VpBridge.dll"] diff --git a/VpBridge/Program.cs b/VpBridge/Program.cs new file mode 100644 index 0000000..49e4679 --- /dev/null +++ b/VpBridge/Program.cs @@ -0,0 +1,36 @@ +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog; +using Tomlyn.Extensions.Configuration; +using VpBridge.Services; +using VpSharp; +using X10D.Hosting.DependencyInjection; + +Log.Logger = new LoggerConfiguration() + .WriteTo.Console() + .WriteTo.File("logs/latest.log", rollingInterval: RollingInterval.Day) +#if DEBUG + .MinimumLevel.Debug() +#endif + .CreateLogger(); + +HostApplicationBuilder builder = Host.CreateApplicationBuilder(args); +builder.Configuration.AddTomlFile("data/config.toml", true, true); + +builder.Logging.ClearProviders(); +builder.Logging.AddSerilog(); + +builder.Services.AddSingleton(); +builder.Services.AddSingleton(new DiscordSocketClient(new DiscordSocketConfig +{ + GatewayIntents = GatewayIntents.AllUnprivileged | GatewayIntents.MessageContent +})); + +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); +builder.Services.AddHostedSingleton(); + +await builder.Build().RunAsync(); diff --git a/VpBridge/Services/DiscordService.cs b/VpBridge/Services/DiscordService.cs new file mode 100644 index 0000000..226a978 --- /dev/null +++ b/VpBridge/Services/DiscordService.cs @@ -0,0 +1,105 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using System.Text.RegularExpressions; +using Discord; +using Discord.WebSocket; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp.Entities; + +namespace VpBridge.Services; + +/// +internal sealed partial class DiscordService : BackgroundService, IDiscordService +{ + private static readonly Regex UnescapeRegex = GetUnescapeRegex(); + private static readonly Regex EscapeRegex = GetEscapeRegex(); + + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly DiscordSocketClient _discordClient; + private readonly Subject _messageReceived = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The configuration. + /// The Discord client. + public DiscordService(ILogger logger, + IConfiguration configuration, + DiscordSocketClient discordClient) + { + _logger = logger; + _configuration = configuration; + _discordClient = discordClient; + } + + /// + public IObservable OnMessageReceived => _messageReceived.AsObservable(); + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + _discordClient.MessageReceived += arg => + { + if (arg is not IUserMessage message) + return Task.CompletedTask; + + if (message.Channel.Id != _configuration.GetSection("Discord:ChannelId").Get()) + return Task.CompletedTask; + + _messageReceived.OnNext(message); + return Task.CompletedTask; + }; + + string token = _configuration.GetSection("Discord:Token").Value ?? + throw new InvalidOperationException("Token is not set."); + + _logger.LogDebug("Connecting to Discord"); + await _discordClient.LoginAsync(TokenType.Bot, token); + await _discordClient.StartAsync(); + } + + /// + public Task SendMessageAsync(VirtualParadiseMessage message) + { + if (message is null) throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask; + + if (message.Author is not { } author) + { + _logger.LogWarning("Received message without author, ignoring message"); + return Task.CompletedTask; + } + + if (author.IsBot && !_configuration.GetSection("Bot:RelayBotMessages").Get()) + { + _logger.LogDebug("Bot messages are disabled, ignoring message"); + return Task.CompletedTask; + } + + _logger.LogInformation("Message by {Author}: {Content}", author, message.Content); + + var channelId = _configuration.GetSection("Discord:ChannelId").Get(); + if (_discordClient.GetChannel(channelId) is not ITextChannel channel) + { + _logger.LogError("Channel {ChannelId} does not exist", channelId); + return Task.CompletedTask; + } + + string unescaped = UnescapeRegex.Replace(message.Content, "$1"); + string escaped = EscapeRegex.Replace(unescaped, "\\$1"); + + string displayName = author.Name; + return channel.SendMessageAsync($"{displayName}: {escaped}"); + } + + [GeneratedRegex(@"\\(\*|_|`|~|\\)", RegexOptions.Compiled)] + private static partial Regex GetUnescapeRegex(); + + [GeneratedRegex(@"(\*|_|`|~|\\)", RegexOptions.Compiled)] + private static partial Regex GetEscapeRegex(); +} diff --git a/VpBridge/Services/IDiscordService.cs b/VpBridge/Services/IDiscordService.cs new file mode 100644 index 0000000..e39c8f7 --- /dev/null +++ b/VpBridge/Services/IDiscordService.cs @@ -0,0 +1,23 @@ +using Discord; +using VpSharp.Entities; + +namespace VpBridge.Services; + +/// +/// Represents a service that sends messages to the Discord channel. +/// +public interface IDiscordService +{ + /// + /// Gets an observable that is triggered when a message is received from the Discord channel. + /// + /// An observable that is triggered when a message is received from the Discord channel. + IObservable OnMessageReceived { get; } + + /// + /// Sends a message to the Discord channel. + /// + /// The message to send. + /// A representing the asynchronous operation. + Task SendMessageAsync(VirtualParadiseMessage message); +} \ No newline at end of file diff --git a/VpBridge/Services/IVirtualParadiseService.cs b/VpBridge/Services/IVirtualParadiseService.cs new file mode 100644 index 0000000..bf2ae01 --- /dev/null +++ b/VpBridge/Services/IVirtualParadiseService.cs @@ -0,0 +1,25 @@ +using Discord; +using VpSharp.Entities; + +namespace VpBridge.Services; + +/// +/// Represents a service that sends messages to the Virtual Paradise world server. +/// +public interface IVirtualParadiseService +{ + /// + /// Gets an observable that is triggered when a message is received from the Virtual Paradise world server. + /// + /// + /// An observable that is triggered when a message is received from the Virtual Paradise world server. + /// + IObservable OnMessageReceived { get; } + + /// + /// Sends a message to the Virtual Paradise world server. + /// + /// The Discord message to send. + /// A representing the asynchronous operation. + Task SendMessageAsync(IUserMessage message); +} diff --git a/VpBridge/Services/RelayService.cs b/VpBridge/Services/RelayService.cs new file mode 100644 index 0000000..57a0524 --- /dev/null +++ b/VpBridge/Services/RelayService.cs @@ -0,0 +1,54 @@ +using System.Reactive.Linq; +using Discord.WebSocket; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp; +using VpSharp.Extensions; + +namespace VpBridge.Services; + +internal sealed class RelayService : BackgroundService +{ + private readonly ILogger _logger; + private readonly IDiscordService _discordService; + private readonly IVirtualParadiseService _virtualParadiseService; + private readonly DiscordSocketClient _discordClient; + private readonly VirtualParadiseClient _virtualParadiseClient; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The Discord service. + /// The Virtual Paradise service. + /// The Discord client. + /// The Virtual Paradise client. + public RelayService(ILogger logger, + IDiscordService discordService, + IVirtualParadiseService virtualParadiseService, + DiscordSocketClient discordClient, + VirtualParadiseClient virtualParadiseClient) + { + _logger = logger; + _discordService = discordService; + _virtualParadiseService = virtualParadiseService; + _discordClient = discordClient; + _virtualParadiseClient = virtualParadiseClient; + } + + /// + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + + _discordService.OnMessageReceived + .Where(m => m.Author != _discordClient.CurrentUser) + .SubscribeAsync(_virtualParadiseService.SendMessageAsync); + + _virtualParadiseService.OnMessageReceived + .Where(m => m.Author != _virtualParadiseClient.CurrentAvatar) + .SubscribeAsync(_discordService.SendMessageAsync); + + return Task.CompletedTask; + } +} diff --git a/VpBridge/Services/VirtualParadiseService.cs b/VpBridge/Services/VirtualParadiseService.cs new file mode 100644 index 0000000..2407e67 --- /dev/null +++ b/VpBridge/Services/VirtualParadiseService.cs @@ -0,0 +1,79 @@ +using System.Reactive.Linq; +using System.Reactive.Subjects; +using Discord; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using VpSharp; +using VpSharp.Entities; +using Color = System.Drawing.Color; + +namespace VpBridge.Services; + +/// +internal sealed class VirtualParadiseService : BackgroundService, IVirtualParadiseService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly VirtualParadiseClient _virtualParadiseClient; + private readonly Subject _messageReceived = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + /// The configuration. + /// The Virtual Paradise client. + public VirtualParadiseService(ILogger logger, + IConfiguration configuration, + VirtualParadiseClient virtualParadiseClient) + { + _logger = logger; + _configuration = configuration; + _virtualParadiseClient = virtualParadiseClient; + } + + /// + public IObservable OnMessageReceived => _messageReceived.AsObservable(); + + /// + public Task SendMessageAsync(IUserMessage message) + { + if (message is null) throw new ArgumentNullException(nameof(message)); + if (string.IsNullOrWhiteSpace(message.Content)) return Task.CompletedTask; + + if (message.Author.IsBot && !_configuration.GetSection("Bot:RelayBotMessages").Get()) + { + _logger.LogDebug("Bot messages are disabled, ignoring message"); + return Task.CompletedTask; + } + + _logger.LogInformation("Message by {Author}: {Content}", message.Author, message.Content); + + string displayName = message.Author.GlobalName ?? message.Author.Username; + return _virtualParadiseClient.SendMessageAsync(displayName, message.Content, FontStyle.Bold, Color.MidnightBlue); + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Establishing relay"); + _virtualParadiseClient.MessageReceived.Subscribe(_messageReceived); + + string username = _configuration.GetSection("VirtualParadise:Username").Value ?? + throw new InvalidOperationException("Username is not set."); + string password = _configuration.GetSection("VirtualParadise:Password").Value ?? + throw new InvalidOperationException("Password is not set."); + string world = _configuration.GetSection("VirtualParadise:World").Value ?? + throw new InvalidOperationException("World is not set."); + string botName = _configuration.GetSection("VirtualParadise:BotName").Value ?? + throw new InvalidOperationException("Bot name is not set."); + + _logger.LogDebug("Connecting to Virtual Paradise"); + await _virtualParadiseClient.ConnectAsync().ConfigureAwait(false); + await _virtualParadiseClient.LoginAsync(username, password, botName).ConfigureAwait(false); + + _logger.LogInformation("Entering world {World}", world); + await _virtualParadiseClient.EnterAsync(world).ConfigureAwait(false); + } +} diff --git a/VpBridge/VpBridge.csproj b/VpBridge/VpBridge.csproj new file mode 100644 index 0000000..4724034 --- /dev/null +++ b/VpBridge/VpBridge.csproj @@ -0,0 +1,57 @@ + + + + Exe + net7.0 + enable + enable + Linux + Oliver Booth + https://github.com/oliverbooth/VpBridge + git + 1.0.0 + + + + true + + + + $(VersionPrefix)-$(VersionSuffix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + $(VersionPrefix)-$(VersionSuffix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + $(VersionPrefix).$(BuildNumber) + + + + $(VersionPrefix) + $(VersionPrefix).0 + $(VersionPrefix).0 + + + + + .dockerignore + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7633b19 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: '3.9' +services: + vpbridge: + container_name: VpBridge + pull_policy: build + build: + context: . + dockerfile: VpBridge/Dockerfile + volumes: + - type: bind + source: /var/log/vp/vp-bridge + target: /app/logs + - type: bind + source: /etc/vp/vp-bridge + target: /app/data + restart: always diff --git a/global.json b/global.json new file mode 100644 index 0000000..aaac9e0 --- /dev/null +++ b/global.json @@ -0,0 +1,7 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMinor", + "allowPrerelease": false + } +} \ No newline at end of file