From 55e18f76e2026887849209e4f94d45ba24608801 Mon Sep 17 00:00:00 2001 From: genteure Date: Sat, 10 Dec 2022 12:06:00 +0800 Subject: [PATCH] initial commit for public --- .editorconfig | 12 + .gitattributes | 1 + .github/ISSUE_TEMPLATE/bug.yml | 39 ++ .github/ISSUE_TEMPLATE/feature.yml | 30 ++ .github/ISSUE_TEMPLATE/question.md | 6 + .github/ISSUE_TEMPLATE/service-request.yml | 44 ++ .github/workflows/build.yml | 38 ++ .github/workflows/codeql.yml | 46 ++ .github/workflows/docs.yml | 23 + .github/workflows/update-copyright-years.yml | 22 + .gitignore | 406 ++++++++++++++ .vscode/settings.json | 5 + CONTRIBUTING.md | 36 ++ LICENSE | 21 + README.md | 89 +++ docs/data/email_platforms.json | 448 ++++++++++++++++ docs/data/services.json | 153 ++++++ docs/docs/index.md | 31 ++ docs/docs/new-service.md | 101 ++++ docs/docs/service-url.md | 26 + docs/docs/services/apprise.md | 21 + docs/docs/services/bark.md | 34 ++ docs/docs/services/discord.md | 35 ++ docs/docs/services/gotify.md | 18 + docs/docs/services/index.md | 7 + docs/docs/services/mailkitemail.md | 88 +++ docs/docs/services/notica.md | 31 ++ docs/docs/services/notifyrun.md | 27 + docs/docs/services/ntfy.md | 33 ++ docs/docs/services/onebot11.md | 19 + docs/docs/services/onebot12.md | 22 + docs/docs/services/pushdeer.md | 26 + docs/docs/services/pushplus.md | 14 + docs/docs/services/serverchan.md | 17 + docs/docs/services/telegram.md | 66 +++ docs/docs/usage-examples.md | 124 +++++ docs/include/service_preface.md | 9 + docs/main.py | 18 + docs/mkdocs.yml | 101 ++++ docs/requirements.txt | 4 + scripts/verify_email_domain.mjs | 108 ++++ scripts/verify_smtp_server.mjs | 191 +++++++ src/.editorconfig | 225 ++++++++ src/BannedSymbols.txt | 6 + src/Directory.Build.props | 29 + src/Naprise.Cli/Naprise.Cli.csproj | 28 + src/Naprise.Cli/Program.cs | 118 ++++ src/Naprise.DocGenerator/Generator.cs | 166 ++++++ .../Naprise.DocGenerator.csproj | 16 + src/Naprise.DocGenerator/Program.cs | 58 ++ src/Naprise.Service.MailKit/MailKitEmail.cs | 506 ++++++++++++++++++ .../Naprise.Service.MailKit.csproj | 42 ++ .../PublicAPI.Shipped.txt | 1 + .../PublicAPI.Unshipped.txt | 5 + src/Naprise.Tests/Naprise.Tests.csproj | 39 ++ src/Naprise.Tests/NotificationMessageTests.cs | 66 +++ src/Naprise.Tests/Service/AppriseTests.cs | 26 + src/Naprise.Tests/Service/BarkTests.cs | 27 + src/Naprise.Tests/Service/DiscordTests.cs | 65 +++ src/Naprise.Tests/Service/GotifyTests.cs | 19 + src/Naprise.Tests/Service/MailKitTests.cs | 50 ++ src/Naprise.Tests/Service/NoticaTests.cs | 19 + src/Naprise.Tests/Service/NotifyRunTests.cs | 19 + src/Naprise.Tests/Service/NtfyTests.cs | 31 ++ src/Naprise.Tests/Service/OneBot11Tests.cs | 34 ++ src/Naprise.Tests/Service/OneBot12Tests.cs | 40 ++ src/Naprise.Tests/Service/PushDeerTests.cs | 18 + src/Naprise.Tests/Service/PushPlusTests.cs | 16 + src/Naprise.Tests/Service/ServerChanTests.cs | 20 + src/Naprise.Tests/Service/TelegramTests.cs | 30 ++ src/Naprise.Tests/ServiceRegistryTests.cs | 176 ++++++ src/Naprise.Tests/ServicesTests.cs | 77 +++ src/Naprise.Tests/Usings.cs | 4 + src/Naprise.sln | 49 ++ src/Naprise/Attributes.cs | 83 +++ src/Naprise/Color.cs | 88 +++ src/Naprise/CompositeNotifier.cs | 47 ++ src/Naprise/Exceptions.cs | 77 +++ src/Naprise/Format.cs | 10 + src/Naprise/INotifier.cs | 10 + src/Naprise/Json/JsonSnakeCaseNamingPolicy.cs | 37 ++ src/Naprise/Message.cs | 185 +++++++ src/Naprise/MessageType.cs | 11 + src/Naprise/Naprise.cs | 30 ++ src/Naprise/Naprise.csproj | 42 ++ src/Naprise/NapriseAsset.cs | 29 + src/Naprise/NotificationService.cs | 34 ++ src/Naprise/PublicAPI.Shipped.txt | 1 + src/Naprise/PublicAPI.Unshipped.txt | 196 +++++++ src/Naprise/QueryParamsExtensions.cs | 57 ++ src/Naprise/Service/Apprise.cs | 122 +++++ src/Naprise/Service/Bark.cs | 116 ++++ src/Naprise/Service/Discord.cs | 91 ++++ src/Naprise/Service/Gotify.cs | 109 ++++ src/Naprise/Service/Notica.cs | 75 +++ src/Naprise/Service/NotifyRun.cs | 74 +++ src/Naprise/Service/Ntfy.cs | 106 ++++ src/Naprise/Service/OneBot11.cs | 143 +++++ src/Naprise/Service/OneBot12.cs | 190 +++++++ src/Naprise/Service/PushDeer.cs | 94 ++++ src/Naprise/Service/PushPlus.cs | 129 +++++ src/Naprise/Service/ServerChan.cs | 75 +++ src/Naprise/Service/Telegram.cs | 134 +++++ src/Naprise/Service/Template.cs | 91 ++++ src/Naprise/ServiceConfig.cs | 20 + src/Naprise/ServiceRegistry.cs | 202 +++++++ src/Naprise/SharedJsonOptions.cs | 19 + src/dotnet-releaser.toml | 39 ++ 108 files changed, 7381 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/ISSUE_TEMPLATE/question.md create mode 100644 .github/ISSUE_TEMPLATE/service-request.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/docs.yml create mode 100644 .github/workflows/update-copyright-years.yml create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/data/email_platforms.json create mode 100644 docs/data/services.json create mode 100644 docs/docs/index.md create mode 100644 docs/docs/new-service.md create mode 100644 docs/docs/service-url.md create mode 100644 docs/docs/services/apprise.md create mode 100644 docs/docs/services/bark.md create mode 100644 docs/docs/services/discord.md create mode 100644 docs/docs/services/gotify.md create mode 100644 docs/docs/services/index.md create mode 100644 docs/docs/services/mailkitemail.md create mode 100644 docs/docs/services/notica.md create mode 100644 docs/docs/services/notifyrun.md create mode 100644 docs/docs/services/ntfy.md create mode 100644 docs/docs/services/onebot11.md create mode 100644 docs/docs/services/onebot12.md create mode 100644 docs/docs/services/pushdeer.md create mode 100644 docs/docs/services/pushplus.md create mode 100644 docs/docs/services/serverchan.md create mode 100644 docs/docs/services/telegram.md create mode 100644 docs/docs/usage-examples.md create mode 100644 docs/include/service_preface.md create mode 100644 docs/main.py create mode 100644 docs/mkdocs.yml create mode 100644 docs/requirements.txt create mode 100644 scripts/verify_email_domain.mjs create mode 100644 scripts/verify_smtp_server.mjs create mode 100644 src/.editorconfig create mode 100644 src/BannedSymbols.txt create mode 100644 src/Directory.Build.props create mode 100644 src/Naprise.Cli/Naprise.Cli.csproj create mode 100644 src/Naprise.Cli/Program.cs create mode 100644 src/Naprise.DocGenerator/Generator.cs create mode 100644 src/Naprise.DocGenerator/Naprise.DocGenerator.csproj create mode 100644 src/Naprise.DocGenerator/Program.cs create mode 100644 src/Naprise.Service.MailKit/MailKitEmail.cs create mode 100644 src/Naprise.Service.MailKit/Naprise.Service.MailKit.csproj create mode 100644 src/Naprise.Service.MailKit/PublicAPI.Shipped.txt create mode 100644 src/Naprise.Service.MailKit/PublicAPI.Unshipped.txt create mode 100644 src/Naprise.Tests/Naprise.Tests.csproj create mode 100644 src/Naprise.Tests/NotificationMessageTests.cs create mode 100644 src/Naprise.Tests/Service/AppriseTests.cs create mode 100644 src/Naprise.Tests/Service/BarkTests.cs create mode 100644 src/Naprise.Tests/Service/DiscordTests.cs create mode 100644 src/Naprise.Tests/Service/GotifyTests.cs create mode 100644 src/Naprise.Tests/Service/MailKitTests.cs create mode 100644 src/Naprise.Tests/Service/NoticaTests.cs create mode 100644 src/Naprise.Tests/Service/NotifyRunTests.cs create mode 100644 src/Naprise.Tests/Service/NtfyTests.cs create mode 100644 src/Naprise.Tests/Service/OneBot11Tests.cs create mode 100644 src/Naprise.Tests/Service/OneBot12Tests.cs create mode 100644 src/Naprise.Tests/Service/PushDeerTests.cs create mode 100644 src/Naprise.Tests/Service/PushPlusTests.cs create mode 100644 src/Naprise.Tests/Service/ServerChanTests.cs create mode 100644 src/Naprise.Tests/Service/TelegramTests.cs create mode 100644 src/Naprise.Tests/ServiceRegistryTests.cs create mode 100644 src/Naprise.Tests/ServicesTests.cs create mode 100644 src/Naprise.Tests/Usings.cs create mode 100644 src/Naprise.sln create mode 100644 src/Naprise/Attributes.cs create mode 100644 src/Naprise/Color.cs create mode 100644 src/Naprise/CompositeNotifier.cs create mode 100644 src/Naprise/Exceptions.cs create mode 100644 src/Naprise/Format.cs create mode 100644 src/Naprise/INotifier.cs create mode 100644 src/Naprise/Json/JsonSnakeCaseNamingPolicy.cs create mode 100644 src/Naprise/Message.cs create mode 100644 src/Naprise/MessageType.cs create mode 100644 src/Naprise/Naprise.cs create mode 100644 src/Naprise/Naprise.csproj create mode 100644 src/Naprise/NapriseAsset.cs create mode 100644 src/Naprise/NotificationService.cs create mode 100644 src/Naprise/PublicAPI.Shipped.txt create mode 100644 src/Naprise/PublicAPI.Unshipped.txt create mode 100644 src/Naprise/QueryParamsExtensions.cs create mode 100644 src/Naprise/Service/Apprise.cs create mode 100644 src/Naprise/Service/Bark.cs create mode 100644 src/Naprise/Service/Discord.cs create mode 100644 src/Naprise/Service/Gotify.cs create mode 100644 src/Naprise/Service/Notica.cs create mode 100644 src/Naprise/Service/NotifyRun.cs create mode 100644 src/Naprise/Service/Ntfy.cs create mode 100644 src/Naprise/Service/OneBot11.cs create mode 100644 src/Naprise/Service/OneBot12.cs create mode 100644 src/Naprise/Service/PushDeer.cs create mode 100644 src/Naprise/Service/PushPlus.cs create mode 100644 src/Naprise/Service/ServerChan.cs create mode 100644 src/Naprise/Service/Telegram.cs create mode 100644 src/Naprise/Service/Template.cs create mode 100644 src/Naprise/ServiceConfig.cs create mode 100644 src/Naprise/ServiceRegistry.cs create mode 100644 src/Naprise/SharedJsonOptions.cs create mode 100644 src/dotnet-releaser.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f5d808 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = true diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..2d022ab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,39 @@ +name: Bug Report +description: File a bug report +labels: ["bug", "triage"] +body: + - type: input + id: version + attributes: + label: Version + description: What version of Naprise are you running? + placeholder: 1.0.0 + validations: + required: true + - type: input + id: platform + attributes: + label: Platform + description: What .NET version and operating system are you running on? + placeholder: .NET 6 on Windows 10 and Ubuntu 20.04 + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: What you expected to happen. + validations: + required: true + - type: textarea + id: current-behavior + attributes: + label: Current Behavior + description: What is happening. + validations: + required: true + - type: textarea + id: extra-info + attributes: + label: Extra Info + description: Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..d686cd1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,30 @@ +name: Feature Request +description: Suggest an idea +labels: ["enhancement", "triage"] +body: + - type: textarea + id: description + attributes: + label: Description + description: What the problem is. Ex. I'm always frustrated when [...] + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed Solution + description: What you want to happen. + validations: + required: true + - type: textarea + id: alternatives + attributes: + label: Alternatives + description: Any alternative solutions or features you've considered. + validations: + required: false + - type: textarea + id: extra-info + attributes: + label: Extra Info + description: Add any other context about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..23ba077 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,6 @@ +--- +name: Question +about: Ask a question about Naprise +title: '' +labels: 'question' +--- diff --git a/.github/ISSUE_TEMPLATE/service-request.yml b/.github/ISSUE_TEMPLATE/service-request.yml new file mode 100644 index 0000000..77e3783 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/service-request.yml @@ -0,0 +1,44 @@ +name: Service Request +description: Request a new notification service +title: "Add New Service: " +labels: ["service-request", "up for grabs", "good first issue", "help wanted"] +body: + - type: input + id: name + attributes: + label: Name + description: What is the name of the service? + placeholder: "Discord" + validations: + required: true + - type: input + id: url + attributes: + label: Homepage + description: What is the URL of the service? + placeholder: "https://discord.com" + validations: + required: true + - type: input + id: api-docs + attributes: + label: API Documentation + description: What is the URL of the service's API documentation? + placeholder: "https://discord.com/developers/docs/resources/webhook#execute-webhook" + validations: + required: true + - type: dropdown + id: contribution + attributes: + label: Contribution + description: Are you planning to contribute a PR for this service in the near future? + options: + - "I'm planning to contribute a PR in the near future" + - "I'm not planning to contribute a PR in the near future" + validations: + required: true + - type: textarea + id: extra-info + attributes: + label: Extra Info + description: Add any other context about the service here. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9d6223f --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,38 @@ +name: Build & Publish + +on: + push: + pull_request: + +jobs: + build: + runs-on: windows-latest + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + submodules: recursive + fetch-depth: 0 + + - name: Install .NET 7 + uses: actions/setup-dotnet@v3 + with: + dotnet-version: '7' + + - name: Build, Test, Pack, Publish + shell: bash + run: | + dotnet tool install -g dotnet-releaser + dotnet-releaser run --nuget-token "${{secrets.NUGET_TOKEN}}" --github-token "${{secrets.GITHUB_TOKEN}}" src/dotnet-releaser.toml + + - name: Upload build artifacts + if: success() || failure() + uses: actions/upload-artifact@v3 + with: + name: build-artifacts + path: src/artifacts-dotnet-releaser diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..ad47761 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,46 @@ +name: "CodeQL" + +on: + push: + branches: [ 'main' ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ 'main' ] + schedule: + - cron: '50 7 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + env: + DOTNET_NOLOGO: true + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + submodules: false + + - uses: actions/setup-dotnet@v2 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: csharp + queries: +security-extended + + - name: Build CLI + run: dotnet build src + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:csharp" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..ce2cb7a --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,23 @@ +name: Publish Docs + +on: + workflow_dispatch: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: 3.x + - run: | + cd docs + pip3 install -r requirements.txt + mkdocs gh-deploy --strict --force --no-history diff --git a/.github/workflows/update-copyright-years.yml b/.github/workflows/update-copyright-years.yml new file mode 100644 index 0000000..d71a152 --- /dev/null +++ b/.github/workflows/update-copyright-years.yml @@ -0,0 +1,22 @@ +name: Update copyright year(s) in license file + +on: + workflow_dispatch: + schedule: + - cron: "0 3 1 1 *" + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: FantasticFiasco/action-update-license-year@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} + transform: (?<=copyright\s+(?:\(c\)|©)?\s*)(?\d{4})(?:-\d{4})? + path: | + LICENSE + src/Directory.Build.props + docs/mkdocs.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eef6d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,406 @@ +## Ignored files for this project + +artifacts-dotnet-releaser +coveragereport +docs/site + +## ---------------------------------------------------- + +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Ww][Ii][Nn]32/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# ASP.NET Scaffolding +ScaffoldingReadMe.txt + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.tlog +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Coverlet is a free, cross platform Code Coverage Tool +coverage*.json +coverage*.xml +coverage*.info + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio 6 auto-generated project file (contains which files were open etc.) +*.vbp + +# Visual Studio 6 workspace and project file (working project files containing files to include in project) +*.dsw +*.dsp + +# Visual Studio 6 technical files +*.ncb +*.aps + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# Visual Studio History (VSHistory) files +.vshistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ + +# Fody - auto-generated XML schema +FodyWeavers.xsd + +# VS Code files for those working on multiple tools +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for Visual Studio Code +.history/ + +# Windows Installer files from build outputs +*.cab +*.msi +*.msix +*.msm +*.msp + +# JetBrains Rider +*.sln.iml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..251dc39 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "yaml.schemas": { + "https://squidfunk.github.io/mkdocs-material/schema.json": "mkdocs.yml" + } +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4b5321c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Naprise contributing guide + +Thanks for your interest in contributing to Naprise! + +- [Naprise contributing guide](#naprise-contributing-guide) + - [Reporting issues](#reporting-issues) + - [Requesting new services](#requesting-new-services) + - [Implementing new services](#implementing-new-services) + - [Style Guide](#style-guide) + +## Reporting issues + +For bug reports, general feature requests, and other questions, please open an issue with the appropriate template. + +Please note if I (`@Genteure`) am not actively using the service you are reporting an issue with, it may take longer for me to fix it. +It would be greatly appreciated if you could open a pull request with a fix! + +## Requesting new services + +Before requesting a new service, please check if it's already been requested by searching in the issues. +After you've checked, please open an issue with the [**Service Request**](https://github.com/Genteure/naprise/issues/new?template=service-request.yml) template. + +## Implementing new services + +Check out issues with the label `service-request` for a list of requested services. +Please send a comment on the issue if you have decided to work on it to avoid duplicate work. + +If you want to implement multiple services, please open a pull request for each service. + +See [Adding new service](https://genteure.github.io/naprise/new-service/) for more information. + +## Style Guide + +Use the settings in `.editorconfig` for formatting, format the code before committing. +The code style set in `.editorconfig` is only a guideline, not a strict rule. +If you think some code should be formatted differently, please open an issue. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7028cef --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Genteure + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cf4002d --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +**Naprise** is a .NET library that allows you to easily send notifications to popular messaging services like Telegram, Discord, and more. Similar to [Apprise](https://github.com/caronc/apprise). + +Note: not all services are used by `@Genteure` on a daily basis, some are not tested as thoroughly, if you find any problems please open an issue or even better a pull request. + +No API compatibility guarantees are made before version 1.0.0. + +----- + +[![.NET Standard 2.0](https://img.shields.io/badge/.NET%20Standard-2.0-brightgreen)](#) +[![Docs at https://genteure.github.io/naprise](https://img.shields.io/badge/docs-github.io-brightgreen)](https://genteure.github.io/naprise) +[![License: MIT](https://img.shields.io/github/license/genteure/naprise)](#) +[![Nuget Version](https://img.shields.io/nuget/v/naprise)](https://www.nuget.org/packages/Naprise) +[![Nuget Downloads](https://img.shields.io/nuget/dt/naprise)](https://www.nuget.org/packages/Naprise) + +- [Quick Start](#quick-start) +- [Supported Services](#supported-services) + - [Naprise.Service.MailKit](#napriseservicemailkit) +- [Compatibility](#compatibility) + +## Quick Start + +```powershell +dotnet add package Naprise +# or +Install-Package Naprise +``` + +```csharp +var notifier = Naprise.Create("discord://106943697/YGCTVYbXQ7_pTEv-f3jX3e", "notifyruns://notify.run/wiFAz0Kp2BsDicPZI4Tk"); + +await notifier.NotifyAsync(new Message +{ + Type = MessageType.Success, + Title = "Hello from Naprise!", + Markdown = "**This** is a _test_ message. :heart:", + Text = "This is a test message.", // same message in different formats +}); +``` + +More examples are available in the [documentation](https://genteure.github.io/naprise/usage-examples/). + +## Supported Services + + + +| Service | Doc | URL Scheme | +| ------- | --- | ---------- | +| [Apprise](https://github.com/caronc/apprise-api) | [Apprise](https://genteure.github.io/naprise/services/apprise) | `apprise://`
`apprises://` | +| [Bark](https://github.com/Finb/Bark) | [Bark](https://genteure.github.io/naprise/services/bark) | `bark://`
`barks://` | +| [Discord](https://discord.com) | [Discord](https://genteure.github.io/naprise/services/discord) | `discord://` | +| [Gotify](https://gotify.net/) | [Gotify](https://genteure.github.io/naprise/services/gotify) | `gotify://`
`gotifys://` | +| [Notica](https://notica.us/) | [Notica](https://genteure.github.io/naprise/services/notica) | `notica://`
`noticas://` | +| [notify.run](https://notify.run/) | [notify.run](https://genteure.github.io/naprise/services/notifyrun) | `notifyrun://`
`notifyruns://` | +| [ntfy.sh](https://ntfy.sh/) | [ntfy.sh](https://genteure.github.io/naprise/services/ntfy) | `ntfy://`
`ntfys://` | +| [OneBot 11](https://11.onebot.dev/) | [OneBot 11](https://genteure.github.io/naprise/services/onebot11) | `onebot11://`
`onebot11s://` | +| [OneBot 12](https://12.onebot.dev/) | [OneBot 12](https://genteure.github.io/naprise/services/onebot12) | `onebot12://`
`onebot12s://` | +| [PushDeer](https://www.pushdeer.com/) | [PushDeer](https://genteure.github.io/naprise/services/pushdeer) | `pushdeer://`
`pushdeers://` | +| [PushPlus](https://www.pushplus.plus/) | [PushPlus](https://genteure.github.io/naprise/services/pushplus) | `pushplus://` | +| [ServerChan](https://sct.ftqq.com/) | [ServerChan](https://genteure.github.io/naprise/services/serverchan) | `serverchan://` | +| [Telegram](https://telegram.org/) | [Telegram](https://genteure.github.io/naprise/services/telegram) | `telegram://` | + +### Naprise.Service.MailKit + +[![Nuget Version](https://img.shields.io/nuget/v/Naprise.Service.MailKit)](https://www.nuget.org/packages/Naprise.Service.MailKit) +[![Nuget Downloads](https://img.shields.io/nuget/dt/Naprise.Service.MailKit)](https://www.nuget.org/packages/Naprise.Service.MailKit) + +``` +dotnet add package Naprise.Service.MailKit +``` + +```csharp +Naprise.DefaultRegistry.AddMailKit(); +``` + + +| Service | Doc | URL Scheme | +| ------- | --- | ---------- | +| [Email via MailKit](https://genteure.github.io/naprise/services/mailkitemail) | [Email via MailKit](https://genteure.github.io/naprise/services/mailkitemail) | `email://`
`smtp://`
`smtps://` | + + +## Compatibility + +No promises are made for both API compatibility and URL compatibility before version 1.0.0. + +Naprise will use SemVer 2.0.0 for versioning. When there is a breaking change in the API, the major version will be incremented. + +I'd like to keep URL backward compatibility as much as possible, but if the URL format of a service is requiered to be changed for some reason, the major version will be incremented. + +`naprisecli` is designed to be used as a way to test the library, the CLI options and arguments is not considered part of the API and may change at any time. diff --git a/docs/data/email_platforms.json b/docs/data/email_platforms.json new file mode 100644 index 0000000..6e949b1 --- /dev/null +++ b/docs/data/email_platforms.json @@ -0,0 +1,448 @@ +[ + { + "name": "Gmail", + "host": "smtp.gmail.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "gmail.com" + ] + }, + { + "name": "Outlook", + "host": "smtp.office365.com", + "port": 587, + "useSsl": false, + "userNameWithDomain": true, + "domains": [ + "outlook.com", + "hotmail.com", + "live.com", + "outlook.jp", + "hotmail.co.jp", + "live.jp" + ] + }, + { + "name": "iCloud", + "host": "smtp.mail.me.com", + "port": 587, + "useSsl": false, + "userNameWithDomain": true, + "domains": [ + "icloud.com" + ] + }, + { + "name": "Yahoo \u30E1\u30FC\u30EB", + "host": "smtp.mail.yahoo.co.jp", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "yahoo.co.jp", + "ymail.ne.jp" + ] + }, + { + "name": "Zoho Mail", + "host": "smtp.zoho.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "zoho.com", + "zohomail.com" + ] + }, + { + "name": "QQ Mail", + "host": "smtp.qq.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "qq.com", + "vip.qq.com", + "foxmail.com" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.163.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "163.com" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.126.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "126.com" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.yeah.net", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "yeah.net" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.vip.163.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "vip.163.com" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.vip.126.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "vip.126.com" + ] + }, + { + "name": "Netease Mail", + "host": "smtp.188.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "188.com" + ] + }, + { + "name": "139 Mail (China Mobile)", + "host": "smtp.139.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "139.com" + ] + }, + { + "name": "189 Mail (China Telecom)", + "host": "smtp.189.cn", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "189.cn" + ] + }, + { + "name": "Wo Mail (China Unicom)", + "host": "smtp.wo.cn", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "wo.cn" + ] + }, + { + "name": "Sohu Mail", + "host": "smtp.sohu.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "sohu.com" + ] + }, + { + "name": "Sohu Mail", + "host": "smtp.vip.sohu.com", + "port": 25, + "useSsl": false, + "userNameWithDomain": true, + "domains": [ + "vip.sohu.com" + ] + }, + { + "name": "Sina Mail", + "host": "smtp.sina.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "sina.com" + ] + }, + { + "name": "Sina Mail", + "host": "smtp.sina.cn", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "sina.cn" + ] + }, + { + "name": "Sina Mail", + "host": "smtp.vip.sina.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "vip.sina.com" + ] + }, + { + "name": "Sina Mail", + "host": "smtp.vip.sina.cn", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "vip.sina.cn" + ] + }, + { + "name": "Tom Mail", + "host": "smtp.tom.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "tom.com", + "vip.tom.com", + "163.net", + "163vip.com" + ] + }, + { + "name": "Yandex Mail", + "host": "smtp.yandex.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "yandex.com", + "yandex.net", + "ya.ru", + "yandex.ru", + "yandex.by", + "yandex.kz", + "yandex.uz", + "yandex.fr", + "narod.ru" + ] + }, + { + "name": "Mail.ru", + "host": "smtp.mail.ru", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "mail.ru", + "inbox.ru", + "list.ru", + "bk.ru" + ] + }, + { + "name": "Yahoo Mail", + "host": "smtp.mail.yahoo.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "yahoo.com", + "myyahoo.com", + "ymail.com", + "y7mail.com", + "rocketmail.com", + "yahoo.com.ar", + "yahoo.com.au", + "yahoo.com.br", + "yahoo.com.co", + "yahoo.com.hk", + "yahoo.com.hr", + "yahoo.com.mx", + "yahoo.com.my", + "yahoo.com.pe", + "yahoo.com.ph", + "yahoo.com.sg", + "yahoo.com.tr", + "yahoo.com.tw", + "yahoo.com.ua", + "yahoo.com.ve", + "yahoo.com.vn", + "yahoo.co.id", + "yahoo.co.il", + "yahoo.co.in", + "yahoo.co.kr", + "yahoo.co.nz", + "yahoo.co.th", + "yahoo.co.uk", + "yahoo.co.za", + "yahoo.at", + "yahoo.be", + "yahoo.bg", + "yahoo.ca", + "yahoo.cl", + "yahoo.cz", + "yahoo.de", + "yahoo.dk", + "yahoo.ee", + "yahoo.es", + "yahoo.fi", + "yahoo.fr", + "yahoo.gr", + "yahoo.hu", + "yahoo.ie", + "yahoo.in", + "yahoo.it", + "yahoo.lv", + "yahoo.nl", + "yahoo.no", + "yahoo.pl", + "yahoo.pt", + "yahoo.ro", + "yahoo.se", + "yahoo.sk" + ] + }, + { + "name": "Fastmail", + "host": "smtp.fastmail.com", + "port": 465, + "useSsl": true, + "userNameWithDomain": true, + "domains": [ + "123mail.org", + "150mail.com", + "150ml.com", + "16mail.com", + "2-mail.com", + "4email.net", + "50mail.com", + "airpost.net", + "allmail.net", + "cluemail.com", + "elitemail.org", + "emailcorner.net", + "emailengine.net", + "emailengine.org", + "emailgroups.net", + "emailplus.org", + "emailuser.net", + "eml.cc", + "f-m.fm", + "fast-email.com", + "fast-mail.org", + "fastem.com", + "fastemailer.com", + "fastest.cc", + "fastimap.com", + "fastmail.cn", + "fastmail.co.uk", + "fastmail.com", + "fastmail.com.au", + "fastmail.de", + "fastmail.es", + "fastmail.fm", + "fastmail.fr", + "fastmail.im", + "fastmail.in", + "fastmail.jp", + "fastmail.mx", + "fastmail.net", + "fastmail.nl", + "fastmail.org", + "fastmail.se", + "fastmail.to", + "fastmail.tw", + "fastmail.uk", + "fastmailbox.net", + "fastmessaging.com", + "fea.st", + "fmail.co.uk", + "fmailbox.com", + "fmgirl.com", + "fmguy.com", + "ftml.net", + "hailmail.net", + "imap-mail.com", + "imap.cc", + "imapmail.org", + "inoutbox.com", + "internet-e-mail.com", + "internet-mail.org", + "internetemails.net", + "internetmailing.net", + "jetemail.net", + "justemail.net", + "letterboxes.org", + "mail-central.com", + "mail-page.com", + "mailas.com", + "mailbolt.com", + "mailc.net", + "mailcan.com", + "mailforce.net", + "mailhaven.com", + "mailingaddress.org", + "mailite.com", + "mailmight.com", + "mailnew.com", + "mailsent.net", + "mailservice.ms", + "mailup.net", + "mailworks.org", + "ml1.net", + "mm.st", + "myfastmail.com", + "mymacmail.com", + "nospammail.net", + "ownmail.net", + "petml.com", + "postinbox.com", + "postpro.net", + "proinbox.com", + "promessage.com", + "realemail.net", + "reallyfast.biz", + "reallyfast.info", + "rushpost.com", + "sent.as", + "sent.at", + "sent.com", + "speedpost.net", + "speedymail.org", + "ssl-mail.com", + "swift-mail.com", + "the-fastest.net", + "the-quickest.com", + "theinternetemail.com", + "veryfast.biz", + "veryspeedy.net", + "warpmail.net", + "xsmail.com", + "yepmail.net", + "your-mail.com" + ] + } +] \ No newline at end of file diff --git a/docs/data/services.json b/docs/data/services.json new file mode 100644 index 0000000..1e72f88 --- /dev/null +++ b/docs/data/services.json @@ -0,0 +1,153 @@ +[ + { + "id": "apprise", + "name": "Apprise", + "format": "text, markdown, html", + "schemes": [ + "apprise", + "apprises" + ], + "website": "https://github.com/caronc/apprise-api", + "doc": "https://github.com/caronc/apprise-api#persistent-storage-solution" + }, + { + "id": "bark", + "name": "Bark", + "format": "text", + "schemes": [ + "bark", + "barks" + ], + "website": "https://github.com/Finb/Bark", + "doc": "https://github.com/Finb/bark-server/blob/master/docs/API_V2.md" + }, + { + "id": "discord", + "name": "Discord", + "format": "text, markdown", + "schemes": [ + "discord" + ], + "website": "https://discord.com", + "doc": "https://discord.com/developers/docs/resources/webhook#execute-webhook" + }, + { + "id": "gotify", + "name": "Gotify", + "format": "text, markdown", + "schemes": [ + "gotify", + "gotifys" + ], + "website": "https://gotify.net/", + "doc": "https://gotify.net/docs/pushmsg" + }, + { + "id": "mailkitemail", + "name": "Email via MailKit", + "format": "text, markdown, html", + "schemes": [ + "email", + "smtp", + "smtps" + ], + "website": "https://genteure.github.io/naprise/services/mailkitemail", + "doc": "https://genteure.github.io/naprise/services/mailkitemail" + }, + { + "id": "notica", + "name": "Notica", + "format": "text", + "schemes": [ + "notica", + "noticas" + ], + "website": "https://notica.us/", + "doc": "https://notica.us/" + }, + { + "id": "notifyrun", + "name": "notify.run", + "format": "text", + "schemes": [ + "notifyrun", + "notifyruns" + ], + "website": "https://notify.run/", + "doc": "https://notify.run/" + }, + { + "id": "ntfy", + "name": "ntfy.sh", + "format": "text", + "schemes": [ + "ntfy", + "ntfys" + ], + "website": "https://ntfy.sh/", + "doc": "https://docs.ntfy.sh/publish/" + }, + { + "id": "onebot11", + "name": "OneBot 11", + "format": "text", + "schemes": [ + "onebot11", + "onebot11s" + ], + "website": "https://11.onebot.dev/", + "doc": "https://github.com/botuniverse/onebot-11/blob/master/api/public.md#send_msg-%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF" + }, + { + "id": "onebot12", + "name": "OneBot 12", + "format": "text", + "schemes": [ + "onebot12", + "onebot12s" + ], + "website": "https://12.onebot.dev/", + "doc": "https://12.onebot.dev/interface/message/actions/#send_message" + }, + { + "id": "pushdeer", + "name": "PushDeer", + "format": "text, markdown", + "schemes": [ + "pushdeer", + "pushdeers" + ], + "website": "https://www.pushdeer.com/", + "doc": "https://www.pushdeer.com/dev.html#%E6%8E%A8%E9%80%81%E6%B6%88%E6%81%AF" + }, + { + "id": "pushplus", + "name": "PushPlus", + "format": "text, markdown", + "schemes": [ + "pushplus" + ], + "website": "https://www.pushplus.plus/", + "doc": "https://www.pushplus.plus/doc/guide/api.html" + }, + { + "id": "serverchan", + "name": "ServerChan", + "format": "text, markdown", + "schemes": [ + "serverchan" + ], + "website": "https://sct.ftqq.com/", + "doc": "https://sct.ftqq.com/sendkey" + }, + { + "id": "telegram", + "name": "Telegram", + "format": "text", + "schemes": [ + "telegram" + ], + "website": "https://telegram.org/", + "doc": "https://core.telegram.org/bots/api#sendmessage" + } +] \ No newline at end of file diff --git a/docs/docs/index.md b/docs/docs/index.md new file mode 100644 index 0000000..68385ae --- /dev/null +++ b/docs/docs/index.md @@ -0,0 +1,31 @@ +# Naprise + +**Naprise** is a .NET library that allows you to easily send notifications to popular messaging services like Telegram, Discord, and more. + +Naprise is heavily inspired by [Apprise](https://github.com/caronc/apprise). + +![.NET Standard 2.0](https://img.shields.io/badge/.NET%20Standard-2.0-brightgreen) +![License: MIT](https://img.shields.io/github/license/genteure/naprise) +[![Nuget Version](https://img.shields.io/nuget/v/naprise)](https://www.nuget.org/packages/Naprise) +[![Nuget Downloads](https://img.shields.io/nuget/dt/naprise)](https://www.nuget.org/packages/Naprise) + +## Quick Start + +Link to NuGet: [https://www.nuget.org/packages/Naprise](https://www.nuget.org/packages/Naprise) + +```powershell +dotnet add package Naprise +# or +Install-Package Naprise +``` + +```csharp +var notifier = Naprise.Create("discord://106943697/YGCTVYbXQ7_pTEv-f3jX3e"); + +await notifier.NotifyAsync(new Message +{ + Type = MessageType.Success, + Title = "Hello from Naprise!", + Markdown = "**This** is a _test_ message. :heart:", +}); +``` diff --git a/docs/docs/new-service.md b/docs/docs/new-service.md new file mode 100644 index 0000000..e465792 --- /dev/null +++ b/docs/docs/new-service.md @@ -0,0 +1,101 @@ +# Adding new service + +A service is any class that: + +- Implements `INotifier` +- Have a public constructor that takes a `ServiceConfig` +- Have a `NapriseNotificationService` attribute + +Services can then be added to the `ServiceRegistry` + +```csharp +Naprise.DefaultRegistry.Add(); +// or +Naprise.DefaultRegistry.Add(typeof(MyService)); +``` + +```csharp +var registry = new ServiceRegistry().AddDefaultServices(); + +registry.Add(); +// or +registry.Add(typeof(MyService)); +``` + +In addition, services provided by Naprise should: + +- Be sealed +- Inherit from `NotificationService` +- Have a `NotificationServiceWebsite` attribute +- Have a `NotificationServiceApiDoc` attribute + +If a service requires another library, consider creating a new project for it so that the dependency is not required for users don't use the service. + +## Designing the service URL + +The service URL is the way to configure a service. + +Here are some guidelines for designing the URL: + +- **IMPORTANT**: Host MUST NOT be used for passing tokens or api keys, as it is case insensitive, all uppercase characters will be converted to lowercase. +- Prefer using the service's full name as the URL scheme. For example, use `discord` instead of `dc`. +- For self-hostable services, add `s` to the scheme for requests over https/tls. For example, use `apprise` for calling API over http and `apprises` for calling API over https. +- Prefer using host and path for required arguments. +- Prefer using query parameters for optional arguments. + +## Implementing the service + +Steps to implement a service: + +- Create a new file in `src/Naprise/Service`. +- Copy the content of [`src/Naprise/Service/Template.cs`](#template-for-implementing-new-service) to the new file. +- Rename the class name to the service name. +- Fill in all the attributes. +- Parse the URL in the constructor. +- Implement the `SendAsync` method. + +Some considerations when implementing the constructor: + +- Use `Flurl.Url` instead of `System.Uri` for parsing and building URLs. +- Throw `NapriseInvalidUrlException` if the URL is invalid, e.g. missing required arguments, or contains invalid arguments. +- Check the token format if applicable, but do not send network requests in the constructor. +- Store the parsed arguments in readonly fields. + +Some considerations when implementing the SendAsync method: + +- Use `Flurl.Url` for parsing and building URLs. +- If the service supports setting color, convert the message type to a color using `this.Asset.GetColor(type)`. +- If the service does not support setting color, prepend the message with the string returned by `this.Asset.GetAscii(type)`. +- If the service only support one message format (e.g. markdown), convert the message to the supported format using `message.PreferBody()`. +- If the service supports multiple message formats, it's up to you to decide which format to use. +- Check the response and throw `NapriseNotifyFailedException` if the request failed. + +## Adding tests + +If you're adding a new service for Naprise: + +Please also add tests for the new service in `src/Naprise.Tests/Service/`. +Please add test cases for all valid URLs. + +You can optionally add tests for invalid URLs and tests for sending messages, see `DiscordTests.cs` for an example. + +## Adding documentation + +Please add documentation for the new service in `docs/docs/services/`. + +Add link to the new page in `nav` section of `docs/mkdocs.yml`. + +Generate README.md and the json file for documentation website by running + +``` +dotnet run --project src/Naprise.DocGenerator +``` + +## Template for implementing new service + +- Check [GitHub](https://github.com/Genteure/naprise/blob/main/src/Naprise/Service/Template.cs) for latest version of this template. +- If you are not adding the service to Naprise, the namespace should also be changed. + +```csharp +--8<-- "../src/Naprise/Service/Template.cs" +``` diff --git a/docs/docs/service-url.md b/docs/docs/service-url.md new file mode 100644 index 0000000..915fabc --- /dev/null +++ b/docs/docs/service-url.md @@ -0,0 +1,26 @@ +# Service URL + +The Naprise service URL is the way to configure a service. They have the following format: + +``` +service://configuration/?query=values +``` + +The [Services](services/index.md) page have a list of all supported services and their URL format. + +Some service also have a version of scheme with `s` added to the end, which is used for https/tls. For example, `apprise` is used for requests over http and `apprises` is used for requests over https. + +## Compared to Apprise + +Naprise's service URL are **not compatible** with [Apprise](https://github.com/caronc/apprise)'s URL. + +There are no global parameters in Naprise. + +Naprise always send the message as is to the service, equivalent to Apprise's `overflow=upstream`. + +Instead of letting the user specify the message format, each service in Naprise decides what format to use. +You can provide message in multiple formats, each service will choose the format it supports, converting the message format as needed. + +If you want to send to a selfhosted self-signed service, instead of adding `verify=no` to the URL, you can create a new `HttpClientHandlr` with the `ServerCertificateCustomValidationCallback` property set to always return `true`, create a new `HttpClient` with the handler then assign it to `Naprise.DefaultHttpClient`. See [Usage Examples](./usage-examples.md) for more details. + +Instead of using `cto` and `rto` to specify request timeouts, you can set the `Timeout` property of `Naprise.DefaultHttpClient`, or pass a `CancelationToken` to the `NotifyAsync` method. diff --git a/docs/docs/services/apprise.md b/docs/docs/services/apprise.md new file mode 100644 index 0000000..fb69dc6 --- /dev/null +++ b/docs/docs/services/apprise.md @@ -0,0 +1,21 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +apprise://{user}:{password}@{host}:{port}/{token} +apprises://{user}:{password}@{host}:{port}/{token} +``` + +- `user` and `password`: Optional HTTP Basic Auth credentials. +- `host` and `port`: The address of the service. +- `token`: The `KEY` of the Apprise API server. + +### Query Parameters + +| Parameter | Description | +| --------- | ----------------------------------------------------------- | +| `tag` | A tag to for Apprise API to filter target URL. | +| `format` | Force the format of the notification. Default is `markdown` | + +## Setup Guide diff --git a/docs/docs/services/bark.md b/docs/docs/services/bark.md new file mode 100644 index 0000000..3d49218 --- /dev/null +++ b/docs/docs/services/bark.md @@ -0,0 +1,34 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +bark://{host}/{token} +bark://{host}:{port}/{token} +barks://{host}/{token} +barks://{host}:{port}/{token} +``` + +### Query Parameters + +| Parameter | Description | +| --------- | ------------------------------------------------ | +| `url` | The URL to open when the notification is tapped. | +| `group` | The group of the notification. | +| `icon` | The icon of the notification. | +| `level` | The level of the notification. | +| `sound` | The sound of the notification. | + +## Setup Guide + +After installing Bark, you can find your push URL in the app. It should look like: + +```text +https://api.day.app/kgZxA3pkswQpZ67J +``` + +`api.day.app` is the host, and `kgZxA3pkswQpZ67J` is the token, so the URL would be + +``` +barks://api.day.app/kgZxA3pkswQpZ67J +``` diff --git a/docs/docs/services/discord.md b/docs/docs/services/discord.md new file mode 100644 index 0000000..9b8773d --- /dev/null +++ b/docs/docs/services/discord.md @@ -0,0 +1,35 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +discord://{webhookId}/{webhookToken} +``` + +### Query Parameters + +| Parameter | Description | +| ------------ | ---------------------------------------------- | +| `username` | The username to use for the message. | +| `avatar_url` | The URL of the avatar to use for the message. | +| `tts` | Whether to use text-to-speech for the message. | + +## Setup Guide + +1. Open channel settings or server settings and go to **Integrations**. +2. Create a new webhook. +3. Copy the webhook URL. + +The webhook URL will look like this: + +```text +https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz +``` + +`1234567890` is the webhook ID, and `abcdefghijklmnopqrstuvwxyz` is the webhook token, so the URL would be + +```text +discord://1234567890/abcdefghijklmnopqrstuvwxyz +``` + +Or just replace the `https://discord.com/api/webhooks/` part with `discord://` and you're done. diff --git a/docs/docs/services/gotify.md b/docs/docs/services/gotify.md new file mode 100644 index 0000000..d7fed68 --- /dev/null +++ b/docs/docs/services/gotify.md @@ -0,0 +1,18 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +gotify://{host}:{port}/{apptoken} +gotifys://{host}:{port}/{apptoken} +``` + +- `host` and `port`: The address of the service. +- `apptoken`: The Gotify application token. + +### Query Parameters + +| Parameter | Description | +| ----------- | -------------------------------------------- | +| `priority` | The priority of the message as a number. | +| `click_url` | The URL to open when the message is clicked. | diff --git a/docs/docs/services/index.md b/docs/docs/services/index.md new file mode 100644 index 0000000..034372d --- /dev/null +++ b/docs/docs/services/index.md @@ -0,0 +1,7 @@ +# Services + +| Service | Schemes | Service's homepage | +| ------- | ------- | ------- | +{% for service in services -%} +| [{{ service.name }}](./{{ service.id }}.md) | {{ service.schemes | map('format_scheme') | join('
') }} | [{{ service.website }}]({{ service.website }}) | +{% endfor -%} diff --git a/docs/docs/services/mailkitemail.md b/docs/docs/services/mailkitemail.md new file mode 100644 index 0000000..bc577a0 --- /dev/null +++ b/docs/docs/services/mailkitemail.md @@ -0,0 +1,88 @@ +{% include 'service_preface.md' %} + +This service is in a separate nuget package: [Naprise.Service.MailKit](https://www.nuget.org/packages/Naprise.Service.MailKit/). +It uses [MailKit](https://www.nuget.org/packages/MailKit/) to send emails. + +```powershell +dotnet add package Naprise.Service.MailKit +# or +Install-Package Naprise.Service.MailKit +``` + +You will need to register the service in ServiceRegistry before using it. + +```csharp +Naprise.DefaultRegistry.AddMailKit(); +``` + +## `email` URL Format + +As a quick way to send emails from your personal email account to yourself, you can use the `email` URL format. + +```text +email://{user}:{pass}@{domain} +``` + +- `user`: The name of your email account. +- `pass`: The password or application-secret of your email account. +- `domain`: The domain of your email account. + +Examples: + +```text +email://john:myS3cr5t@gmail.com +email://aoi:somePassword@yahoo.co.jp +email://123456:furageoupjhjygto@qq.com +``` + +See [the table below](#supported-domains) for a list of supported domains. + + +## `smtp` and `smtps` URL Format + +If your email service is not supported by `email` URL, or you want to send emails from a custom email server, you can use the `smtp` and `smtps` URL formats. + +```text +smtp://{smtp_host}:{smtp_port}/{from}/{to} +smtps://{smtp_host}:{smtp_port}/{from}/{to} +smtp://{smtp_host}:{smtp_port}/{username}/{password}/{from}/{to} +smtps://{smtp_host}:{smtp_port}/{username}/{password}/{from}/{to} +``` + +* `smtp://`: Connect to the SMTP server using unencrypted plain text or `STARTTLS`. +* `smtps://`: Connect to the SMTP server using encrypted `SSL/TLS`. + +??? info "**TLDR**: Use `smtp://` for port `25` and `587`, and `smtps://` for port `465`." + Some email providers' documentation refer `STARTTLS` as just `TLS`, which technically is not the same thing. + + `SSL` now days in the context of SMTP often means `TLS 1.2` is being used, even if it's being called `SSL` and not `TLS`. The client will establish a SSL/TLS connection first just like HTTPS, and then start the SMTP protocol. The standard port for SMTP over SSL/TLS is `465`. + + `STARTTLS` on the other hand, is a SMTP command that tells the SMTP server to switch to TLS encryption after the connection is established. The client will establish a plain text connection first, and then send the `STARTTLS` command to the SMTP server, if both parties support it, the SMTP server will switch to TLS encryption. The most common port for SMTP with `STARTTLS` support is `587`. + +- `smtp_host`: The SMTP host of your email server. +- `smtp_port`: The SMTP port of your email server, **always required**. +- `username`: The username of your email account. +- `password`: The password or application-secret of your email account. +- `from`: The email address of the sender. +- `to`: The email address of the recipient. + +If the `username` and `password` are not provided, it's assumed that the SMTP server does not require authentication, which might be the case for self-hosted local SMTP servers. + +Examples: + +```text +smtps://smtp.gmail.com:465/jone@gmail.com/JoneDoe1234/jone@gmail.com/friend@outlook.com +smtp://smtp.local:25/homeserver/password/homeserver@local/admin@local +``` + +## Supported Domains + +The following table lists the supported domains for `email` URLs and their corresponding SMTP settings. +If your domain is not listed here, you can still use the `smtp` or `smtps` URL formats to send emails. + +| Domain | Name | SMTP Server | Encryption | +| ------------- | ----- | -------------------- | ---------- | +{% for platform in email_platforms -%} +{% for domain in platform.domains -%} +| `{{ domain }}` | {{ platform.name }} | `{{ platform.host }}:{{ platform.port }}` | `{{ 'SSL/TLS' if platform.useSsl else 'NONE/STARTTLS' }}` | +{% endfor -%}{% endfor %} diff --git a/docs/docs/services/notica.md b/docs/docs/services/notica.md new file mode 100644 index 0000000..ab13cee --- /dev/null +++ b/docs/docs/services/notica.md @@ -0,0 +1,31 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +notica://{user}:{password}@{host}:{port}/{token} +noticas://{user}:{password}@{host}:{port}/{token} +``` + +- `user` and `password`: Optional HTTP Basic Auth credentials. +- `host` and `port`: The address of the service. +- `token`: The unique ID displayed on Notica. + +### Query Parameters + +_None_ + +## Setup Guide + +For the hosted service, open [https://notica.us](https://notica.us) and allow notifications. +The URL of the page will change to something like `https://notica.us/?7WUU7N`. + +The query parameter is the token, in this case `7WUU7N`. + +So the URL would be: + +```text +noticas://notica.us/7WUU7N +``` + +Note there is no `?` in the URL, the token is part of the path, not the query. diff --git a/docs/docs/services/notifyrun.md b/docs/docs/services/notifyrun.md new file mode 100644 index 0000000..a34215a --- /dev/null +++ b/docs/docs/services/notifyrun.md @@ -0,0 +1,27 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +notifyrun://{user}:{password}@{host}:{port}/{channel} +notifyruns://{user}:{password}@{host}:{port}/{channel} +``` + +- `user` and `password`: Optional HTTP Basic Auth credentials. +- `host` and `port`: The address of the service. +- `channel`: The notify.run notification channel. + +### Query Parameters + +_None_ + +## Setup Guide + +For the hosted service, open [https://notify.run](https://notify.run) and click **Create a Channel**. +A channel id will be generated, for example `pCEVD6IkQiwQvIKtOLVn`. + +The URL would be: + +```text +notifyruns://notify.run/pCEVD6IkQiwQvIKtOLVn +``` diff --git a/docs/docs/services/ntfy.md b/docs/docs/services/ntfy.md new file mode 100644 index 0000000..fce8bd0 --- /dev/null +++ b/docs/docs/services/ntfy.md @@ -0,0 +1,33 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +ntfy://{user}:{password}@{host}:{port}/{topic} +ntfys://{user}:{password}@{host}:{port}/{topic} +``` + +- `user` and `password`: Optional HTTP Basic Auth credentials. +- `host` and `port`: The address of the service. +- `topic`: The ntfy notification topic. + +### Query Parameters + +| Parameter | Description | +| ---------- | ----------------------------------------------- | +| `tags` | A comma-separated list of tags. | +| `priority` | The priority of the notification as a number. | +| `click` | A URL to open when the notification is clicked. | +| `delay` | The delay before the notification is sent. | +| `email` | The email address to send the notification to. | + + +## Setup Guide + +Just use any string as the `topic` and you're good to go, no setup required. + +Example: + +```text +ntfys://ntfy.sh/test +``` diff --git a/docs/docs/services/onebot11.md b/docs/docs/services/onebot11.md new file mode 100644 index 0000000..6b2fb6a --- /dev/null +++ b/docs/docs/services/onebot11.md @@ -0,0 +1,19 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +onebot11://{access_token}@{host}:{port}/private/{user_id} +onebot11s://{access_token}@{host}:{port}/private/{user_id} +onebot11://{access_token}@{host}:{port}/group/{group_id} +onebot11s://{access_token}@{host}:{port}/group/{group_id} +``` + +- `access_token`: Optional. The access token of your bot. +- `host` and `port`: The address of the service. +- `user_id`: The id of the user. +- `group_id`: The group number. + +### Query Parameters + +_None_ diff --git a/docs/docs/services/onebot12.md b/docs/docs/services/onebot12.md new file mode 100644 index 0000000..db242dd --- /dev/null +++ b/docs/docs/services/onebot12.md @@ -0,0 +1,22 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +onebot12://{access_token}@{host}:{port}/private/{user_id} +onebot12://{access_token}@{host}:{port}/group/{group_id} +onebot12://{access_token}@{host}:{port}/channel/{guild_id}/{channel_id} +onebot12s://{access_token}@{host}:{port}/private/{user_id} +onebot12s://{access_token}@{host}:{port}/group/{group_id} +onebot12s://{access_token}@{host}:{port}/channel/{guild_id}/{channel_id} +``` + +- `access_token`: Optional. The access token of your bot. +- `host` and `port`: The address of the service. +- `user_id`: The id of the user. +- `group_id`: The group number. +- `guild_id` and `channel_id`: The ids for the guild and channel. + +### Query Parameters + +_None_ diff --git a/docs/docs/services/pushdeer.md b/docs/docs/services/pushdeer.md new file mode 100644 index 0000000..fe4ea94 --- /dev/null +++ b/docs/docs/services/pushdeer.md @@ -0,0 +1,26 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +pushdeer://{user}:{pass}@{host}:{port}/{pushkey} +pushdeers://{user}:{pass}@{host}:{port}/{pushkey} +``` + +- `user` and `password`: Optional HTTP Basic Auth credentials. +- `host` and `port`: The address of the service. +- `pushkey`: The PushKey. + +### Query Parameters + +_None_ + +## Setup Guide + +Create a Key in the PushDeer app, it should look similar to `PDUBj4fkoihKi93dLfC7PXDzuHUVN4NSq`. + +The hosted service's API domain is `api2.pushdeer.com`, so the URL would be: + +```text +pushdeers://api2.pushdeer.com/PDUBj4fkoihKi93dLfC7PXDzuHUVN4NSq +``` diff --git a/docs/docs/services/pushplus.md b/docs/docs/services/pushplus.md new file mode 100644 index 0000000..06e731b --- /dev/null +++ b/docs/docs/services/pushplus.md @@ -0,0 +1,14 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +pushplus://{token}@{channel} +``` + +- `token`: The pushplus token. +- `channel`: The messaging channel, valid values are `wechat`, `webhook`, `cp`, `mail` and `sms`. + +### Query Parameters + +_None_ diff --git a/docs/docs/services/serverchan.md b/docs/docs/services/serverchan.md new file mode 100644 index 0000000..242219f --- /dev/null +++ b/docs/docs/services/serverchan.md @@ -0,0 +1,17 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +serverchan://{token}@serverchan +``` + +- `token`: The serverchan token. +- The host is always `serverchan`. + +### Query Parameters + +| Parameter | Description | +| --------- | ---------------------------------------------- | +| `channel` | The messaging channel. | +| `openid` | The openid of the user to send the message to. | diff --git a/docs/docs/services/telegram.md b/docs/docs/services/telegram.md new file mode 100644 index 0000000..55c32fb --- /dev/null +++ b/docs/docs/services/telegram.md @@ -0,0 +1,66 @@ +{% include 'service_preface.md' %} + +## URL Format + +```text +telegram://{token}@{chat_id} +``` + +- `token`: The telegram bot token. +- `chat_id`: The chat id to send the message to. + +If `chat_id` is a number (group id or user id) it will be sent to telegram api as is, otherwise it will be prefixed with `@` before sent to telegram api. + +### Query Parameters + +| Parameter | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------- | +| `api_host` | The telegram api host, default is `https://api.telegram.org`. | +| `parse_mode` | Mode for parsing entities in the message text, default is none. | +| `message_thread_id` | Unique identifier for the target message thread (topic) of the forum; for forum supergroups only. | +| `disable_web_page_preview` | Disables link previews for links in this message. | +| `disable_notification` | Sends the message silently. Users will receive a notification with no sound. | +| `protect_content` | Protects the contents of the sent message from forwarding and saving. | + +## Setup Guide + +### Create a Telegram Bot + +1. Open [BotFather](https://t.me/botfather) in Telegram. +2. Send `/newbot` to create a new bot. +3. Enter the bot name and username. +4. Copy the bot token. + +### Get the Chat ID + +For private chat, send a message to the bot. For groups, add the bot to the group. + +Open the following link in your browser: + +``` +https://api.telegram.org/bot{token}/getUpdates +``` + +Replace `{token}` with your bot token. + +The `id` field under `chat` is the chat id. + +### Example URLs + +Direct message (private chat): + +``` +telegram://123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11@123456789 +``` + +Group: + +``` +telegram://123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11@-123456789 +``` + +Channel: + +``` +telegram://123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11@channel_name +``` diff --git a/docs/docs/usage-examples.md b/docs/docs/usage-examples.md new file mode 100644 index 0000000..efdfe1a --- /dev/null +++ b/docs/docs/usage-examples.md @@ -0,0 +1,124 @@ +# Usage Examples + +## Send to multiple services + +```csharp +// You can also pass in any enumerable of string like List or string[] +var notifier = Naprise.Create("discord://106943697/YGCTVYbXQ7_pTEv-f3jX3e", "telegram://123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"); + +await notifier.NotifyAsync(new Message +{ + Type = MessageType.Success, + Title = "Hello from Naprise!", + Markdown = "**This** is a _test_ message. :heart:", +}); +``` + +## Adding a custom service + +```csharp +Naprise.DefaultRegistry.Add(); +``` + +See [Adding new service](./new-service.md) for more details. + +If you are implementing a new service, please consider submitting a pull request so everyone can benefit from your work, thanks! :slight_smile: + +## Using `ServiceRegistry` + +```csharp +var registry = new ServiceRegistry().AddDefaultServices(); +var notifier = registry.Create("discord://106943697/YGCTVYbXQ7_pTEv-f3jX3e"); +``` + +## Ignore invalid schemes and URLs + +```csharp +Naprise.DefaultRegistry.IgnoreUnknownScheme = true; + +// "invalid" is not a valid scheme +var notifier = Naprise.Create("invalid://anything"); +Assert.Equal(Naprise.NoopNotifier, notifier); +``` + +```csharp +Naprise.DefaultRegistry.IgnoreInvalidUrl = true; + +// "discord" expects a id and a token +var notifier = Naprise.Create("discord://missing-token"); +Assert.Equal(Naprise.NoopNotifier, notifier); +``` + +## Customizing the `HttpClient` + +Changing the `HttpClient` used by one registry: + +```csharp +var registry = new ServiceRegistry().AddDefaultServices(); +registry.HttpClient = new HttpClient(); +// registry.HttpClient is null by default +// when it is null, Naprise.DefaultHttpClient is used +``` + +or globally: + +```csharp +Naprise.DefaultHttpClient = new HttpClient(); +``` + +New `HttpClient` instances are used even if it's set after the notifier is created. + +```csharp +// Create a notifier first +var notifier = Naprise.Create("discord://106943697/YGCTVYbXQ7_pTEv-f3jX3e"); + +// Then change the default HttpClient +Naprise.DefaultHttpClient = new HttpClient(); + +// The notifier uses the new HttpClient +await notifier.NotifyAsync(new Message +{ + Type = MessageType.Success, + Title = "Hello from Naprise!", + Markdown = "**This** is a _test_ message. :heart:", +}); +``` + +## Setting a custom user agent + +```csharp +Naprise.DefaultHttpClient.DefaultRequestHeaders.Add("User-Agent", "MyGreatApp/0.1.0"); +``` + +## Using proxies + +```csharp +var handler = new HttpClientHandler +{ + Proxy = new WebProxy("http://127.0.0.1:8080"), +}; + +Naprise.DefaultHttpClient = new HttpClient(handler); +``` + +## Send to a selfhosted self-signed service + +a.k.a how to disable SSL certificate validation. + +```csharp +var handler = new HttpClientHandler +{ + ServerCertificateCustomValidationCallback = (message, cert, chain, errors) => true +}; + +Naprise.DefaultHttpClient = new HttpClient(handler); + +// Apprise API at https://selfhosted.example.com with a self-signed certificate +var notifier = Naprise.Create("apprises://selfhosted.example.com/apprise"); +await notifier.NotifyAsync(new Message +{ + Type = MessageType.Success, + Title = "Hello from Naprise!", + Markdown = "**This** is a _test_ message. :heart:", +}); +``` diff --git a/docs/include/service_preface.md b/docs/include/service_preface.md new file mode 100644 index 0000000..dfd85b4 --- /dev/null +++ b/docs/include/service_preface.md @@ -0,0 +1,9 @@ +{%- set service = services | selectattr("id", "equalto", page.file.name) | first -%} +# {{ service.name }} + +- Schemes: {{ service.schemes | map('format_scheme') | join(', ') }} +- Format: {{ service.format }} +- Homepage: [{{ service.website }}]({{ service.website }}) +- API Documentation: [{{ service.doc }}]({{ service.doc }}) + +_List of all supported services: [Services](./index.md)_ diff --git a/docs/main.py b/docs/main.py new file mode 100644 index 0000000..1dd005a --- /dev/null +++ b/docs/main.py @@ -0,0 +1,18 @@ +from pprint import pprint + +def on_pre_page_macros(env): + # check if the page is a service page + if env.page.file.url.startswith('services/'): + # match the service data by file name + service = next((x for x in env.variables.services if x["id"] == env.page.file.name), None) + # skip if no service data found + if not service: + return + # set the page title + env.page.title = service["name"] + env.page.meta["title"] = service["name"] + +def define_env(env): + @env.filter + def format_scheme(scheme): + return '```' + scheme + '://```' diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..af7f812 --- /dev/null +++ b/docs/mkdocs.yml @@ -0,0 +1,101 @@ +site_name: Naprise +site_url: https://genteure.github.io/naprise +repo_url: https://github.com/Genteure/naprise +repo_name: Genteure/naprise +edit_uri: blob/main/docs/ +site_description: A .NET library for sending notifications to various services +site_author: Genteure +copyright: Copyright © 2022 Genteure + +strict: !ENV [CI, false] + +nav: + - index.md + - service-url.md + - usage-examples.md + - new-service.md + - Services: + - services/index.md + - services/apprise.md + - services/bark.md + - services/discord.md + - services/gotify.md + - services/mailkitemail.md + - services/notica.md + - services/notifyrun.md + - services/ntfy.md + - services/onebot11.md + - services/onebot12.md + - services/pushdeer.md + - services/pushplus.md + - services/serverchan.md + - services/telegram.md + +theme: + language: en + name: material + icon: + repo: fontawesome/brands/github + palette: + # Palette toggle for light mode + - media: "(prefers-color-scheme: light)" + scheme: default + primary: cyan + accent: amber + toggle: + icon: material/weather-night + name: Switch to dark mode + + # Palette toggle for dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: cyan + accent: amber + toggle: + icon: material/weather-sunny + name: Switch to light mode + features: + - content.code.annotate + - navigation.indexes + - navigation.sections + - navigation.top + - search.suggest + - toc.follow + +plugins: + - minify: + minify_html: true + - search: + lang: en + - macros: + on_error_fail: !ENV [CI, false] + include_dir: include + include_yaml: + - services: data/services.json + - email_platforms: data/email_platforms.json + +watch: + - data + - include + +markdown_extensions: + - admonition + - attr_list + - footnotes + - tables + - toc: + permalink: true + slugify: !!python/object/apply:pymdownx.slugs.slugify + kwds: + case: lower + - md_in_html + - pymdownx.details + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.emoji: + emoji_index: !!python/name:materialx.emoji.twemoji + emoji_generator: !!python/name:materialx.emoji.to_svg + diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..4984a13 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,4 @@ +mkdocs-material +mkdocs-minify-plugin +mkdocs-redirects +mkdocs-macros-plugin diff --git a/scripts/verify_email_domain.mjs b/scripts/verify_email_domain.mjs new file mode 100644 index 0000000..4639c18 --- /dev/null +++ b/scripts/verify_email_domain.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node + +// This script is used to verify the email domain in email_platforms.json +// by resolving the MX records. It will output the result in JSON format +// that makes it easy to glance through the result and confirm the domains +// in a group have the same or similar MX records. + +import { Resolver } from "dns/promises"; +import { readFileSync } from "fs"; + +const r = new Resolver(); +var args = process.argv.slice(2); +const src = args.shift(); + +if (!src) { + console.log("Usage: verify_email_domain.mjs [DNS server...]"); + process.exit(1); +} + +if (args.length > 0) { + console.log("Using custom DNS servers: " + args); + r.setServers(args); +} else { + console.log("Using default DNS servers"); +} + +/* +Sample data: +[ + { + "name": "Gmail", + "host": "smtp.gmail.com", + "port": 587, + "useSsl": true, + "userNameWithDomain": true, + "domains": ["gmail.com"] + }, +] +*/ + +/** @type {{name: string, host: string, port: number, useSsl: boolean, userNameWithDomain: boolean, domains: string[] }[]} */ +const input = JSON.parse(readFileSync(src, 'utf8')); + +// wrapper for r.resolveMx() to try again if failed +async function resolveMx(domain) { + let f = true; + while (true) { + try { + return await r.resolveMx(domain); + } + catch (e) { + if (f) { + // retry a second time + f = false; + } else { + // give up + console.log(`Error resolving MX for ${domain}: ${e}`); + return []; + } + } + } +} + +/* +Target output: + +{ + "Gmail": { + "gmail.com": ["gmail-smtp-in.l.google.com", "alt1.gmail-smtp-in.l.google.com"], + "example.com": "mx.example.com", + } +} +*/ + +(async () => { + const result = {}; + + for (const { name, domains } of input) { + + console.log(`Resolving MX for ${name}...`); + console.log(`Domain count: ${domains.length}`) + + for (const domain of domains) { + // resolve MX records + const records = await resolveMx(domain); + // if there is any MX record + if (records.length > 0) { + // create the name group if not exists + if (!result[name]) { + result[name] = {}; + } + // if there is only one MX record, use the string value + result[name][domain] = records.length === 1 ? records[0].exchange : records.map(r => r.exchange); + } + } + + // sort domains by number of MX records ascending + // string value count as 1 + result[name] = Object.fromEntries(Object.entries(result[name]).sort((a, b) => { + const aCount = typeof a[1] === 'string' ? 1 : a[1].length; + const bCount = typeof b[1] === 'string' ? 1 : b[1].length; + return aCount - bCount; + })); + + } + + console.log(JSON.stringify(result, null, 2)); +})(); diff --git a/scripts/verify_smtp_server.mjs b/scripts/verify_smtp_server.mjs new file mode 100644 index 0000000..0049fe2 --- /dev/null +++ b/scripts/verify_smtp_server.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +// This script is used to verify the SMTP server by connecting to it and +// sending a simple handshake message. It is used to verify the SMTP server +// configuration in email_platforms.json. Depending on the SMTP server +// it will connect directly (STARTTLS) or via TLS. + +import net from "net"; +import tls from "tls"; +import { readFileSync } from "fs"; + +const args = process.argv.slice(2); +const src = args.shift(); + +if (!src) { + console.log("Usage: verify_smtp_server.mjs "); + process.exit(1); +} + +/* +Sample data: +[ + { + "name": "Gmail", + "host": "smtp.gmail.com", + "port": 587, + "useSsl": true, + "userNameWithDomain": true, + "domains": ["gmail.com"] + }, +] +*/ + +/** @type {{name: string, host: string, port: number, useSsl: boolean, userNameWithDomain: boolean, domains: string[] }[]} */ +const input = JSON.parse(readFileSync(src, "utf8")); + +/** + * super simple and naive SMTP handshake probe + * @param {string} host + * @param {string} port + * @param {boolean} useSsl + * @returns {Promise<{send: boolean, msg: string}[]>} + */ +function probe(host, port, useSsl, domain) { + return new Promise((resolve, reject) => { + const socket = useSsl + ? tls.connect({ host, port }) + : net.connect({ host, port }); + + setTimeout(() => { + // close the socket after 15 seconds as a timeout + socket.destroy(); + }, 1000 * 15); + + // wait for the server to send the greeting + // send EHLO domain + // wait for response, expect 250. + // send QUIT + // wait for response, expect 221. + // close the socket + + // if useSsl is false, also check if the server supports STARTTLS, otherwise reject the promise + // if any step got an unexpected response, close the socket and reject the promise + + /** state machine */ + let state = 0; + /** + * message logs, returned via Promise in the end + * @type {{send: boolean, msg: string}[]} + */ + const msg = []; + + // buffer for the response + let buf = ""; + + function send(data) { + msg.push({ send: true, msg: data }); + socket.write(data + "\r\n"); + } + + function machine(data) { + buf += data; + const lines = buf.split("\n"); + buf = lines.pop(); + for (const line of lines) { + msg.push({ send: false, msg: line }); + + // ignore lines with a dash at the fourth position + if (line[3] === "-") { + continue; + } + const code = line.substring(0, 3); + switch (state) { + case 0: + // wait for the server to send the greeting + if (code === "220") { + // use first domain as the EHLO domain + send("EHLO " + domain); + state = 1; + } else { + socket.end(); + reject(msg); + } + break; + case 1: + // send EHLO mail.example.com + // wait for response, expect 250. + if (code === "250") { + send("QUIT"); + state = 2; + } else { + socket.end(); + reject(msg); + } + break; + case 2: + // send QUIT + // wait for response, expect 221. + if (code === "221") { + socket.end(); + resolve(msg); + } else { + socket.end(); + reject(msg); + } + break; + default: + socket.end(); + reject("Unexpected state"); + } + } + } + + socket.on("connect", () => { + console.log(`Connected to ${host}:${port} using ${useSsl ? "TLS" : "TCP"}`); + socket.on("data", machine); + }); + + socket.on("error", (e) => { + reject(e); + }); + + socket.on("close", () => { + console.log(`Connection to ${host}:${port} closed`); + reject(msg); + }); + }); +} + +(async () => { + // record host, port and if it was successful + let summary = []; + + for (const entry of input) { + const { host, port, useSsl, domains } = entry; + console.log("========================================="); + console.log(`Probing ${host}:${port} using ${useSsl ? "TLS" : "TCP"}`); + try { + const domain = domains[0]; + const result = await probe(host, port, useSsl, domain); + console.log(result.map((e) => (e.send ? "S: " : "R: ") + e.msg).join("\n")); + + // if useSsl is false, check for STARTTLS + if (!useSsl) { + const starttls = result.find((e) => e.msg.includes("250-STARTTLS") || e.msg.includes("250 STARTTLS")); + if (!starttls) { + console.error(`SMTP server ${host}:${port} does not support STARTTLS`); + summary.push({ host, port, useSsl, success: false }); + continue; + } + } + + console.log(`SMTP server ${host}:${port} is reachable`); + summary.push({ host, port, useSsl, success: true }); + + } catch (e) { + // if the error is the message log, pretty print it + if (Array.isArray(e) && e.length > 0 && e[0].hasOwnProperty("send")) { + console.error(e.map((e) => (e.send ? "S: " : "R: ") + e.msg).join("\n")); + } else { + console.error(e); + } + summary.push({ host, port, useSsl, success: false }); + console.error(`SMTP server ${host}:${port} is not reachable`); + } + } + + console.log("========================================="); + console.log("Summary:"); + console.log(summary.map((e) => `${e.success ? "[ OK ]" : "[FAIL]"} ${e.useSsl ? "TLS" : "TCP"} ${e.host}:${e.port}`).join("\n")); +})(); diff --git a/src/.editorconfig b/src/.editorconfig new file mode 100644 index 0000000..8e33817 --- /dev/null +++ b/src/.editorconfig @@ -0,0 +1,225 @@ +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +charset = utf-8 +end_of_line = lf +insert_final_newline = false +trim_trailing_whitespace = true + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = true +dotnet_style_qualification_for_field = true +dotnet_style_qualification_for_method = true +dotnet_style_qualification_for_property = true + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:suggestion +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:suggestion + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = false +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = true +csharp_style_var_for_built_in_types = true +csharp_style_var_when_type_is_apparent = true + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = true +csharp_style_expression_bodied_methods = when_on_single_line +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true + +# Code-block preferences +csharp_prefer_braces = when_multiline +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = file_scoped +csharp_style_prefer_method_group_conversion = true +csharp_style_prefer_top_level_statements = false + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true:silent +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = false +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable:none +csharp_style_unused_value_expression_statement_preference = discard_variable:none + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case diff --git a/src/BannedSymbols.txt b/src/BannedSymbols.txt new file mode 100644 index 0000000..813efa1 --- /dev/null +++ b/src/BannedSymbols.txt @@ -0,0 +1,6 @@ +T:System.Uri;Use Flurl.Url instead. +T:System.Web.HttpUtility;Use Flurl.Url for URL manipulations. +M:System.Net.WebUtility.UrlDecode(System.String);Use Flurl.Url instead. +M:System.Net.WebUtility.UrlDecodeToBytes(System.Byte[], System.Int32, System.Int32);Use Flurl.Url instead. +M:System.Net.WebUtility.UrlEncode(System.String);Use Flurl.Url instead. +M:System.Net.WebUtility.UrlEncodeToBytes(System.Byte[], System.Int32, System.Int32);Use Flurl.Url instead. diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..38090df --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,29 @@ + + + Genteure + Copyright (c) 2022 Genteure + Naprise allows you to easily send notifications to popular messaging services like Telegram, Discord, and more. + https://github.com/Genteure/naprise + MIT + notification;apprise;push;message;discord;telegram + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + v + + + + + + diff --git a/src/Naprise.Cli/Naprise.Cli.csproj b/src/Naprise.Cli/Naprise.Cli.csproj new file mode 100644 index 0000000..dc06b91 --- /dev/null +++ b/src/Naprise.Cli/Naprise.Cli.csproj @@ -0,0 +1,28 @@ + + + + Exe + net7.0 + enable + enable + naprisecli + true + true + true + true + + + + true + + + + + + + + + + + + diff --git a/src/Naprise.Cli/Program.cs b/src/Naprise.Cli/Program.cs new file mode 100644 index 0000000..d43d599 --- /dev/null +++ b/src/Naprise.Cli/Program.cs @@ -0,0 +1,118 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.Net; + +namespace Naprise.Cli; + +internal class Program +{ + private static async Task Main(string[] args) + { + if (Console.IsOutputRedirected || Console.IsErrorRedirected || Console.IsInputRedirected) + { + Console.Error.WriteLine(""" + WARNING: naprisecli is designed mainly for interactive use and as a way to test Naprise. + It does not have have a stable CLI interface, use in scripts at your own risk. + Consider using Apprise instead: https://github.com/caronc/apprise + + """); + } + + Naprise.DefaultRegistry.AddMailKit(); + + await BuildCommand().InvokeAsync(args); + } + + private static readonly Option title = new(aliases: new[] { "-t", "--title" }, description: "Title of the message"); + private static readonly Option text = new(aliases: new[] { "-e", "--text" }, description: "Body of the message in plain text"); + private static readonly Option markdown = new(aliases: new[] { "-m", "--markdown" }, description: "Body of the message in Markdown"); + private static readonly Option html = new(aliases: new[] { "-l", "--html" }, description: "Body of the message in HTML"); + private static readonly Option verbose = new(aliases: new[] { "-v", "--verbose" }, description: "Show verbose output"); + private static readonly Option timeout = new(aliases: new[] { "--timeout" }, getDefaultValue: () => TimeSpan.FromSeconds(10), description: "Set timeout for HTTP requests"); + private static readonly Option proxy = new(aliases: new[] { "-x", "--proxy" }, description: "Proxy to use for HTTP requests"); + private static readonly Option insecure = new(aliases: new[] { "-k", "--insecure" }, getDefaultValue: () => false, description: "Allow insecure server connections when using SSL"); + private static readonly Argument urls = new(name: "urls", description: "URLs to send the message to"); + + private static RootCommand BuildCommand() + { + var c = new RootCommand(description: "Naprise CLI"); + + c.AddOption(title); + c.AddOption(text); + c.AddOption(markdown); + c.AddOption(html); + c.AddOption(verbose); + c.AddOption(timeout); + c.AddOption(proxy); + c.AddOption(insecure); + + c.AddArgument(urls); + + c.SetHandler(SendAsync); + + c.AddValidator(v => + { + if (v.GetValueForArgument(urls).Length == 0) + v.ErrorMessage = "At least one URL must be specified"; + }); + + return c; + } + + private static async Task SendAsync(InvocationContext invocationContext) + { + // read args + + var result = invocationContext.ParseResult; + + var title = result.GetValueForOption(Program.title); + var text = result.GetValueForOption(Program.text); + var markdown = result.GetValueForOption(Program.markdown); + var html = result.GetValueForOption(Program.html); + var verbose = result.GetValueForOption(Program.verbose); + var timeout = result.GetValueForOption(Program.timeout); + var proxy = result.GetValueForOption(Program.proxy); + var insecure = result.GetValueForOption(Program.insecure); + + var urls = result.GetValueForArgument(Program.urls); + + // setup HttpClient + + var handler = new HttpClientHandler(); + + if (proxy is not null) + handler.Proxy = new WebProxy(Address: proxy, BypassOnLocal: true); + + if (insecure) + handler.ServerCertificateCustomValidationCallback = (req, cert, chain, error) => true; + + var client = new HttpClient(handler) + { + Timeout = timeout + }; + Naprise.DefaultHttpClient = client; + + // send message + + var message = new Message + { + Title = title, + Text = text, + Markdown = markdown, + Html = html, + }; + + var notifier = Naprise.Create(urls); + + try + { + await notifier.NotifyAsync(message); + Console.WriteLine("Message sent successfully"); + } + catch (Exception) + { + // TODO: pretty print NapriseNotifyFailedException, AggregateException and it's inner exceptions + throw; + } + } +} diff --git a/src/Naprise.DocGenerator/Generator.cs b/src/Naprise.DocGenerator/Generator.cs new file mode 100644 index 0000000..80f604f --- /dev/null +++ b/src/Naprise.DocGenerator/Generator.cs @@ -0,0 +1,166 @@ +using Naprise.Service.MailKit; +using System.Reflection; +using System.Text; +using System.Text.Json; + +namespace Naprise.DocGenerator; + +internal class Generator +{ + private readonly string basePath; + + public Generator(string basePath) + { + this.basePath = basePath; + } + + internal void Generate() + { + this.GenerateReadme(); + this.GenerateServiceJson(); + this.GenerateEmailPlatformJson(); + } + + private class Platform + { + public string? Name { get; set; } + public string? Host { get; set; } + public int Port { get; set; } + public bool UseSsl { get; set; } + public bool UserNameWithDomain { get; set; } + public string[]? Domains { get; set; } + } + + private void GenerateEmailPlatformJson() + { + var list = MailKitEmail.EmailPlatforms.GroupBy(x => x.Value).Select(x => new Platform + { + Name = x.Key.Name, + Host = x.Key.Host, + Port = x.Key.Port, + UseSsl = x.Key.UseSsl, + UserNameWithDomain = x.Key.UserNameWithDomain, + Domains = x.Select(y => y.Key).ToArray() + }).ToArray(); + + var path = Path.Combine(this.basePath, "docs", "data", "email_platforms.json"); + File.WriteAllText(path, JsonSerializer.Serialize(list, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true + })); + } + + private class Service + { + public string? Id { get; set; } + public string? Name { get; set; } + public string? Format { get; set; } + public string[]? Schemes { get; set; } + public string? Website { get; set; } + public string? Doc { get; set; } + } + + private void GenerateServiceJson() + { + var types = new List(ServiceRegistry.DefaultServices); + + foreach (var assem in new[] { typeof(MailKitEmail).Assembly }) + { + types.AddRange(assem.GetTypes().Where(x => x.IsPublic && typeof(INotifier).IsAssignableFrom(x))); + } + + var list = types.OrderBy(x => x.Name).Select(t => + { + var attr = t.GetCustomAttribute()!; + var formats = new List(3); + if (attr.SupportText) + formats.Add("text"); + if (attr.SupportMarkdown) + formats.Add("markdown"); + if (attr.SupportHtml) + formats.Add("html"); + var format = string.Join(", ", formats); + + return new Service + { + Id = t.Name.ToLowerInvariant(), + Name = attr.DisplayName, + Schemes = attr.Schemes, + Format = format, + Website = t.GetCustomAttribute()?.Url ?? string.Empty, + Doc = t.GetCustomAttribute()?.Url ?? string.Empty, + }; + }).ToList(); + + var path = Path.Combine(this.basePath, "docs", "data", "services.json"); + File.WriteAllText(path, JsonSerializer.Serialize(list, new JsonSerializerOptions(JsonSerializerDefaults.Web) + { + WriteIndented = true + })); + } + + private void GenerateReadme() + { + var readme = Path.Combine(this.basePath, "README.md"); + var source = File.ReadAllText(readme); + + var generated = BuildMarkdownTable(ServiceRegistry.DefaultServices); + + generated += """ + + ### Naprise.Service.MailKit + + [![Nuget Version](https://img.shields.io/nuget/v/Naprise.Service.MailKit)](https://www.nuget.org/packages/Naprise.Service.MailKit) + [![Nuget Downloads](https://img.shields.io/nuget/dt/Naprise.Service.MailKit)](https://www.nuget.org/packages/Naprise.Service.MailKit) + + ``` + dotnet add package Naprise.Service.MailKit + ``` + + ```csharp + Naprise.DefaultRegistry.AddMailKit(); + ``` + + """; + + generated += BuildMarkdownTable(typeof(MailKitEmail).Assembly.GetTypes() + .Where(x => x.IsPublic && typeof(INotifier).IsAssignableFrom(x)) + .ToList()); // although there is only one type, this will be useful in the future when/if more types are added + + var headerIndex = source.IndexOf("# Supported Services\n"); + var before = source.IndexOf("\n\n", headerIndex) + 2; + var after = source.IndexOf("\n\n## ", before); // ## Compatibility + + source = source[..before] + generated + source[after..]; + + File.WriteAllText(readme, source); + + static string BuildMarkdownTable(IReadOnlyList types) + { + var b = new StringBuilder(); + b.AppendLine("\n"); + b.AppendLine("| Service | Doc | URL Scheme |"); + b.AppendLine("| ------- | --- | ---------- |"); + + foreach (var t in types.OrderBy(x => x.Name)) + { + var a = t.GetCustomAttribute()!; + + b.Append("| ["); + b.Append(a.DisplayName); + b.Append("]("); + b.Append(t.GetCustomAttribute()!.Url); + b.Append(") | ["); + b.Append(a.DisplayName); + b.Append("]("); + b.Append($"https://genteure.github.io/naprise/services/{t.Name.ToLowerInvariant()}"); + b.Append(") | "); + b.Append(string.Join("
", a.Schemes.Select(x => $"`{x}://`"))); + b.Append(" |"); + b.AppendLine(); + } + + return b.ToString(); + } + } +} diff --git a/src/Naprise.DocGenerator/Naprise.DocGenerator.csproj b/src/Naprise.DocGenerator/Naprise.DocGenerator.csproj new file mode 100644 index 0000000..ec0c8a4 --- /dev/null +++ b/src/Naprise.DocGenerator/Naprise.DocGenerator.csproj @@ -0,0 +1,16 @@ + + + + Exe + net7.0 + enable + enable + false + + + + + + + + diff --git a/src/Naprise.DocGenerator/Program.cs b/src/Naprise.DocGenerator/Program.cs new file mode 100644 index 0000000..e5c9e05 --- /dev/null +++ b/src/Naprise.DocGenerator/Program.cs @@ -0,0 +1,58 @@ +namespace Naprise.DocGenerator; + +internal class Program +{ + private static void Main(string[] args) + { + var basePath = FindBasePath(args); + + if (basePath is null) + { + Console.WriteLine("Base path not found"); + Environment.Exit(1); + } + + Console.WriteLine($"Running at path: {basePath}"); + + var generator = new Generator(basePath); + generator.Generate(); + } + + private static string? FindBasePath(string[] args) + { + static bool ValidPath(string? path) + { + if (!Directory.Exists(path)) + return false; + + if (!File.Exists(Path.Combine(path, "README.md"))) + return false; + + if (!Directory.Exists(Path.Combine(path, "src", "Naprise"))) + return false; + + if (!Directory.Exists(Path.Combine(path, "docs"))) + return false; + + return true; + } + + if (args.Length > 0) + { + var path = args[0]; + return ValidPath(path) ? path : null; + } + else + { + var path = new DirectoryInfo(Environment.ProcessPath!); + while (path != null) + { + if (ValidPath(path.FullName)) + return path.FullName; + path = path.Parent; + } + + return null; + } + } +} diff --git a/src/Naprise.Service.MailKit/MailKitEmail.cs b/src/Naprise.Service.MailKit/MailKitEmail.cs new file mode 100644 index 0000000..3c6471b --- /dev/null +++ b/src/Naprise.Service.MailKit/MailKitEmail.cs @@ -0,0 +1,506 @@ +using MailKit.Net.Smtp; +using MimeKit; +using Naprise.Service.MailKit; +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +[assembly: InternalsVisibleTo("Naprise.Tests")] +[assembly: InternalsVisibleTo("Naprise.DocGenerator")] + +namespace Naprise +{ + public static class ServiceRegistryExtensions + { + public static ServiceRegistry AddMailKit(this ServiceRegistry registry) + { + registry.Add(); + return registry; + } + } +} + +namespace Naprise.Service.MailKit +{ + [NapriseNotificationService("Email via MailKit", "email", "smtp", "smtps", SupportText = true, SupportMarkdown = true, SupportHtml = true)] + [NotificationServiceWebsite("https://genteure.github.io/naprise/services/mailkitemail")] + [NotificationServiceApiDoc("https://genteure.github.io/naprise/services/mailkitemail")] + public sealed class MailKitEmail : NotificationService + { + internal static readonly IReadOnlyDictionary EmailPlatforms; + + static MailKitEmail() + { + EmailPlatforms = BuildEmailPlatformMap(); + } + + internal readonly bool useSsl; + internal readonly string host; + internal readonly int port; + internal readonly string? username; + internal readonly string? password; + internal readonly string from; + internal readonly string to; + + public MailKitEmail(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // Easy setup urls: email + // Can only send to self + // email://{user}:{pass}@{domain} + + // Full setup: stmp, smtps + // smtp://{smtp_host}:{smtp_port}/{from}/{to} + // smtps://{smtp_host}:{smtp_port}/{from}/{to} + // smtp://{smtp_host}:{smtp_port}/{username}/{password}/{from}/{to} + // smtps://{smtp_host}:{smtp_port}/{username}/{password}/{from}/{to} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + switch (url.Scheme) + { + case "email": + { + if (url.Port.HasValue) + throw new NapriseInvalidUrlException($"Port is not allowed in email urls"); + + if (EmailPlatforms.TryGetValue(url.Host, out var p)) + { + this.useSsl = p.UseSsl; + this.host = p.Host; + this.port = p.Port; + + var colen = url.UserInfo.IndexOf(':'); + if (colen == -1) + throw new NapriseInvalidUrlException($"Username and password are required in email urls"); + + this.username = url.UserInfo.Substring(0, colen); + this.password = url.UserInfo.Substring(colen + 1); + + this.from = $"{this.username}@{url.Host}"; ; + this.to = this.from; + + if (p.UserNameWithDomain) + this.username = this.from; // use "user@example.com" as username instead of just "user" + } + else + { + throw new NapriseInvalidUrlException($"Domain \"{url.Host}\" is not supported (yet)"); + } + break; + } + case "smtp": + case "smtps": + { + if (!url.Port.HasValue) + throw new NapriseInvalidUrlException($"Port is required in smtp urls"); + + this.useSsl = url.Scheme == "smtps"; + this.host = url.Host; + this.port = url.Port.Value; + + if (segment.Count == 2) + { + this.from = segment[0]; + this.to = segment[1]; + } + else if (segment.Count == 4) + { + this.username = segment[0]; + this.password = segment[1]; + this.from = segment[2]; + this.to = segment[3]; + } + else + { + throw new NapriseInvalidUrlException($"Invalid number of segments in smtp urls, expected 2 or 4, got {segment.Count}"); + } + break; + } + default: + throw new NapriseInvalidUrlException($"Unknown scheme: {url.Scheme}"); + } + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var body = new BodyBuilder + { + TextBody = message.PreferTextBody() + }; + + if (message.Markdown is not null || message.Html is not null) + body.HtmlBody = message.PreferHtmlBody(); + + var mime = new MimeMessage + { + Body = body.ToMessageBody(), + Subject = message.GetTitleWithFallback() + }; + + mime.From.Add(new MailboxAddress(string.Empty, this.from)); + mime.To.Add(new MailboxAddress(string.Empty, this.to)); + + using var client = new SmtpClient(); + await client.ConnectAsync(this.host, this.port, this.useSsl, cancellationToken).ConfigureAwait(false); + + if (this.username is not null && this.password is not null) + await client.AuthenticateAsync(this.username, this.password, cancellationToken).ConfigureAwait(false); + + await client.SendAsync(mime, cancellationToken).ConfigureAwait(false); + await client.DisconnectAsync(true, cancellationToken).ConfigureAwait(false); + + // TODO build the message body + /* + var payload = new Payload + { + // TODO fill payload + // TODO check message.Type + }; + + var url = new Url($"{(true ? "https" : "http")}://{"localhost"}").AppendPathSegments("example"); + var content = JsonContent.Create(payload, options: null); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + var respText = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(MailKit)}: {resp.StatusCode}") // TODO change class name + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + + try + { + var jobj = JsonDocument.Parse(respText); + // TODO parse response and check if it's successful + var status = jobj.RootElement.GetProperty("status").GetString(); + if (status != "ok") + { + var respMessage = jobj.RootElement.GetProperty("message").GetString(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(MailKit)}: \"{respMessage}\"") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + catch (Exception ex) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(MailKit)}", ex) + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + */ + } + + private class Payload + { + // TODO add payload + } + + internal readonly struct EmailPlatform + { + public readonly string Name; + public readonly string Host; + public readonly int Port; + public readonly bool UseSsl; + public readonly bool UserNameWithDomain; + + public EmailPlatform(string name, string host, int port, bool useSsl, bool userNameWithDomain) + { + this.Name = name ?? throw new ArgumentNullException(nameof(name)); + this.Host = host ?? throw new ArgumentNullException(nameof(host)); + this.Port = port; + this.UseSsl = useSsl; + this.UserNameWithDomain = userNameWithDomain; + } + } + + private static Dictionary BuildEmailPlatformMap() + { + var d = new Dictionary(); + EmailPlatform p; + // also see https://github.com/caronc/apprise/blob/master/apprise/plugins/NotifyEmail.py + + // https://support.google.com/mail/answer/7126229 + d.Add("gmail.com", new(name: "Gmail", host: "smtp.gmail.com", port: 465, useSsl: true, userNameWithDomain: true)); + + // https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-8361e398-8af4-4e97-b147-6c6c4ac95353 + p = new(name: "Outlook", host: "smtp.office365.com", port: 587, useSsl: false, userNameWithDomain: true); + d.Add("outlook.com", p); + d.Add("hotmail.com", p); + d.Add("live.com", p); + d.Add("outlook.jp", p); + d.Add("hotmail.co.jp", p); + d.Add("live.jp", p); + + // https://support.apple.com/en-us/HT202304 + p = new(name: "iCloud", host: "smtp.mail.me.com", port: 587, useSsl: false, userNameWithDomain: true); + d.Add("icloud.com", p); + + // https://support.yahoo-net.jp/PccMail/s/article/H000007321 + p = new(name: "Yahoo メール", host: "smtp.mail.yahoo.co.jp", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("yahoo.co.jp", p); + d.Add("ymail.ne.jp", p); + + // https://www.zoho.com/mail/help/zoho-smtp.html + p = new(name: "Zoho Mail", host: "smtp.zoho.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("zoho.com", p); + d.Add("zohomail.com", p); + + // https://service.mail.qq.com/cgi-bin/help?subtype=1&&id=28&&no=331 + p = new(name: "QQ Mail", host: "smtp.qq.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("qq.com", p); + d.Add("vip.qq.com", p); + d.Add("foxmail.com", p); + + // https://mail.163.com/mailhelp/client.htm#pop3_smtp_server + // https://help.163.com/special/sp/vip163_client.html + d.Add("163.com", new(name: "Netease Mail", host: "smtp.163.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("126.com", new(name: "Netease Mail", host: "smtp.126.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("yeah.net", new(name: "Netease Mail", host: "smtp.yeah.net", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("vip.163.com", new(name: "Netease Mail", host: "smtp.vip.163.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("vip.126.com", new(name: "Netease Mail", host: "smtp.vip.126.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("188.com", new(name: "Netease Mail", host: "smtp.188.com", port: 465, useSsl: true, userNameWithDomain: true)); + + // https://help.mail.10086.cn/statichtml/9/Content/837.html + d.Add("139.com", new(name: "139 Mail (China Mobile)", host: "smtp.139.com", port: 465, useSsl: true, userNameWithDomain: true)); + + // https://help.189.cn/client/client.html + d.Add("189.cn", new(name: "189 Mail (China Telecom)", host: "smtp.189.cn", port: 465, useSsl: true, userNameWithDomain: true)); + + // no docs, but: https://mail.wo.cn/ + d.Add("wo.cn", new(name: "Wo Mail (China Unicom)", host: "smtp.wo.cn", port: 465, useSsl: true, userNameWithDomain: true)); + + // https://mail.sohu.com/fe/#/help + // https://vip.sohu.com/#/help + d.Add("sohu.com", new(name: "Sohu Mail", host: "smtp.sohu.com", port: 465, useSsl: true, userNameWithDomain: true)); + // TLS certificate not confiured on port 465 as of 2022-12-09 + d.Add("vip.sohu.com", new(name: "Sohu Mail", host: "smtp.vip.sohu.com", port: 25, useSsl: false, userNameWithDomain: true)); + + // https://help.sina.com.cn/comquestiondetail/view/160/ + d.Add("sina.com", new(name: "Sina Mail", host: "smtp.sina.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("sina.cn", new(name: "Sina Mail", host: "smtp.sina.cn", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("vip.sina.com", new(name: "Sina Mail", host: "smtp.vip.sina.com", port: 465, useSsl: true, userNameWithDomain: true)); + d.Add("vip.sina.cn", new(name: "Sina Mail", host: "smtp.vip.sina.cn", port: 465, useSsl: true, userNameWithDomain: true)); + + // https://help.tom.com/freemail/3421485459.html?col_index=16 + p = new(name: "Tom Mail", host: "smtp.tom.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("tom.com", p); + d.Add("vip.tom.com", p); + d.Add("163.net", p); + d.Add("163vip.com", p); + + // https://yandex.com/support/mail/mail-clients/others.html + p = new(name: "Yandex Mail", host: "smtp.yandex.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("yandex.com", p); + d.Add("yandex.net", p); + d.Add("ya.ru", p); + d.Add("yandex.ru", p); + d.Add("yandex.by", p); + d.Add("yandex.kz", p); + d.Add("yandex.uz", p); + d.Add("yandex.fr", p); + d.Add("narod.ru", p); + + // https://help.mail.ru/mail/mailer/popsmtp + p = new(name: "Mail.ru", host: "smtp.mail.ru", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("mail.ru", p); + d.Add("inbox.ru", p); + d.Add("list.ru", p); + d.Add("bk.ru", p); + + + // https://help.yahoo.com/kb/SLN4724.html + // https://help.yahoo.com/kb/SLN2153.html + p = new(name: "Yahoo Mail", host: "smtp.mail.yahoo.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("yahoo.com", p); + d.Add("myyahoo.com", p); + d.Add("ymail.com", p); + d.Add("y7mail.com", p); + d.Add("rocketmail.com", p); + + d.Add("yahoo.com.ar", p); + d.Add("yahoo.com.au", p); + d.Add("yahoo.com.br", p); + d.Add("yahoo.com.co", p); + d.Add("yahoo.com.hk", p); + d.Add("yahoo.com.hr", p); + d.Add("yahoo.com.mx", p); + d.Add("yahoo.com.my", p); + d.Add("yahoo.com.pe", p); + d.Add("yahoo.com.ph", p); + d.Add("yahoo.com.sg", p); + d.Add("yahoo.com.tr", p); + d.Add("yahoo.com.tw", p); + d.Add("yahoo.com.ua", p); + d.Add("yahoo.com.ve", p); + d.Add("yahoo.com.vn", p); + d.Add("yahoo.co.id", p); + d.Add("yahoo.co.il", p); + d.Add("yahoo.co.in", p); + d.Add("yahoo.co.kr", p); + d.Add("yahoo.co.nz", p); + d.Add("yahoo.co.th", p); + d.Add("yahoo.co.uk", p); + d.Add("yahoo.co.za", p); + d.Add("yahoo.at", p); + d.Add("yahoo.be", p); + d.Add("yahoo.bg", p); + d.Add("yahoo.ca", p); + d.Add("yahoo.cl", p); + d.Add("yahoo.cz", p); + d.Add("yahoo.de", p); + d.Add("yahoo.dk", p); + d.Add("yahoo.ee", p); + d.Add("yahoo.es", p); + d.Add("yahoo.fi", p); + d.Add("yahoo.fr", p); + d.Add("yahoo.gr", p); + d.Add("yahoo.hu", p); + d.Add("yahoo.ie", p); + d.Add("yahoo.in", p); + d.Add("yahoo.it", p); + d.Add("yahoo.lv", p); + d.Add("yahoo.nl", p); + d.Add("yahoo.no", p); + d.Add("yahoo.pl", p); + d.Add("yahoo.pt", p); + d.Add("yahoo.ro", p); + d.Add("yahoo.se", p); + d.Add("yahoo.sk", p); + + // https://www.fastmail.help/hc/en-us/articles/1500000278342-Server-names-and-ports + // https://www.fastmail.com/about/ourdomains/ + p = new(name: "Fastmail", host: "smtp.fastmail.com", port: 465, useSsl: true, userNameWithDomain: true); + d.Add("123mail.org", p); + d.Add("150mail.com", p); + d.Add("150ml.com", p); + d.Add("16mail.com", p); + d.Add("2-mail.com", p); + d.Add("4email.net", p); + d.Add("50mail.com", p); + d.Add("airpost.net", p); + d.Add("allmail.net", p); + d.Add("cluemail.com", p); + d.Add("elitemail.org", p); + d.Add("emailcorner.net", p); + d.Add("emailengine.net", p); + d.Add("emailengine.org", p); + d.Add("emailgroups.net", p); + d.Add("emailplus.org", p); + d.Add("emailuser.net", p); + d.Add("eml.cc", p); + d.Add("f-m.fm", p); + d.Add("fast-email.com", p); + d.Add("fast-mail.org", p); + d.Add("fastem.com", p); + d.Add("fastemailer.com", p); + d.Add("fastest.cc", p); + d.Add("fastimap.com", p); + d.Add("fastmail.cn", p); + d.Add("fastmail.co.uk", p); + d.Add("fastmail.com", p); + d.Add("fastmail.com.au", p); + d.Add("fastmail.de", p); + d.Add("fastmail.es", p); + d.Add("fastmail.fm", p); + d.Add("fastmail.fr", p); + d.Add("fastmail.im", p); + d.Add("fastmail.in", p); + d.Add("fastmail.jp", p); + d.Add("fastmail.mx", p); + d.Add("fastmail.net", p); + d.Add("fastmail.nl", p); + d.Add("fastmail.org", p); + d.Add("fastmail.se", p); + d.Add("fastmail.to", p); + d.Add("fastmail.tw", p); + d.Add("fastmail.uk", p); + d.Add("fastmailbox.net", p); + d.Add("fastmessaging.com", p); + d.Add("fea.st", p); + d.Add("fmail.co.uk", p); + d.Add("fmailbox.com", p); + d.Add("fmgirl.com", p); + d.Add("fmguy.com", p); + d.Add("ftml.net", p); + d.Add("hailmail.net", p); + d.Add("imap-mail.com", p); + d.Add("imap.cc", p); + d.Add("imapmail.org", p); + d.Add("inoutbox.com", p); + d.Add("internet-e-mail.com", p); + d.Add("internet-mail.org", p); + d.Add("internetemails.net", p); + d.Add("internetmailing.net", p); + d.Add("jetemail.net", p); + d.Add("justemail.net", p); + d.Add("letterboxes.org", p); + d.Add("mail-central.com", p); + d.Add("mail-page.com", p); + d.Add("mailas.com", p); + d.Add("mailbolt.com", p); + d.Add("mailc.net", p); + d.Add("mailcan.com", p); + d.Add("mailforce.net", p); + d.Add("mailhaven.com", p); + d.Add("mailingaddress.org", p); + d.Add("mailite.com", p); + d.Add("mailmight.com", p); + d.Add("mailnew.com", p); + d.Add("mailsent.net", p); + d.Add("mailservice.ms", p); + d.Add("mailup.net", p); + d.Add("mailworks.org", p); + d.Add("ml1.net", p); + d.Add("mm.st", p); + d.Add("myfastmail.com", p); + d.Add("mymacmail.com", p); + d.Add("nospammail.net", p); + d.Add("ownmail.net", p); + d.Add("petml.com", p); + d.Add("postinbox.com", p); + d.Add("postpro.net", p); + d.Add("proinbox.com", p); + d.Add("promessage.com", p); + d.Add("realemail.net", p); + d.Add("reallyfast.biz", p); + d.Add("reallyfast.info", p); + d.Add("rushpost.com", p); + d.Add("sent.as", p); + d.Add("sent.at", p); + d.Add("sent.com", p); + d.Add("speedpost.net", p); + d.Add("speedymail.org", p); + d.Add("ssl-mail.com", p); + d.Add("swift-mail.com", p); + d.Add("the-fastest.net", p); + d.Add("the-quickest.com", p); + d.Add("theinternetemail.com", p); + d.Add("veryfast.biz", p); + d.Add("veryspeedy.net", p); + d.Add("warpmail.net", p); + d.Add("xsmail.com", p); + d.Add("yepmail.net", p); + d.Add("your-mail.com", p); + + return d; + } + } +} diff --git a/src/Naprise.Service.MailKit/Naprise.Service.MailKit.csproj b/src/Naprise.Service.MailKit/Naprise.Service.MailKit.csproj new file mode 100644 index 0000000..def2e69 --- /dev/null +++ b/src/Naprise.Service.MailKit/Naprise.Service.MailKit.csproj @@ -0,0 +1,42 @@ + + + + netstandard2.0 + enable + 9.0 + true + true + $(WarningsAsErrors);RS0016;RS0017;RS0041 + + + + true + + + + true + true + true + snupkg + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + diff --git a/src/Naprise.Service.MailKit/PublicAPI.Shipped.txt b/src/Naprise.Service.MailKit/PublicAPI.Shipped.txt new file mode 100644 index 0000000..ab058de --- /dev/null +++ b/src/Naprise.Service.MailKit/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Naprise.Service.MailKit/PublicAPI.Unshipped.txt b/src/Naprise.Service.MailKit/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..f4457ed --- /dev/null +++ b/src/Naprise.Service.MailKit/PublicAPI.Unshipped.txt @@ -0,0 +1,5 @@ +Naprise.Service.MailKit.MailKitEmail +Naprise.Service.MailKit.MailKitEmail.MailKitEmail(Naprise.ServiceConfig! config) -> void +Naprise.ServiceRegistryExtensions +override Naprise.Service.MailKit.MailKitEmail.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +static Naprise.ServiceRegistryExtensions.AddMailKit(this Naprise.ServiceRegistry! registry) -> Naprise.ServiceRegistry! diff --git a/src/Naprise.Tests/Naprise.Tests.csproj b/src/Naprise.Tests/Naprise.Tests.csproj new file mode 100644 index 0000000..4991472 --- /dev/null +++ b/src/Naprise.Tests/Naprise.Tests.csproj @@ -0,0 +1,39 @@ + + + + net6.0;net7.0;net472;net48 + enable + enable + 10.0 + false + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + diff --git a/src/Naprise.Tests/NotificationMessageTests.cs b/src/Naprise.Tests/NotificationMessageTests.cs new file mode 100644 index 0000000..cff6a4b --- /dev/null +++ b/src/Naprise.Tests/NotificationMessageTests.cs @@ -0,0 +1,66 @@ +namespace Naprise.Tests +{ + public class NotificationMessageTests + { + [Theory] + [InlineData("Hello World")] + [InlineData("super duper duper duper duper loooooooong")] + public void GetTitleFromTitle(string title) + { + var message = new Message(title: title); + Assert.Equal(title, message.GetTitleWithFallback(maxLengthFromBody: 20)); + } + + [Theory] + [InlineData("Hello World", "Hello World")] + [InlineData("Hello World\nBBBBBBBBB", "Hello World")] + [InlineData("Hello World\rBBBBBBBBB", "Hello World")] + [InlineData("AAAAAAAAAAAAAAAAAAAAbbbbbbb", "AAAAAAAAAAAAAAAAAAAA")] + public void GetTitleFromText(string text, string title) + { + var message = new Message(text: text); + Assert.Equal(title, message.GetTitleWithFallback(maxLengthFromBody: 20)); + } + + [Theory] + [InlineData("Hello World", "Hello World")] + [InlineData("#Hello World", "Hello World")] + [InlineData("# Hello World", "Hello World")] + [InlineData("##Hello World", "Hello World")] + [InlineData("## Hello World", "Hello World")] + [InlineData("##### Hello World", "Hello World")] + [InlineData("##### 你好!", "你好!")] + [InlineData("# Hello World", "Hello World")] + [InlineData("# Hello World\nAAAAA", "Hello World")] + [InlineData("##### Title\nBody", "Title")] + [InlineData("AAAAAAAAAAAAAAAAAAAAbbbbbbb", "AAAAAAAAAAAAAAAAAAAA")] + [InlineData("AAAAAAAAAAAAAAAAAAAAbbbbbbb\nBBBB", "AAAAAAAAAAAAAAAAAAAA")] + [InlineData("# AAAAAAAAAAAAAAAAAAAAbbbbbbb", "AAAAAAAAAAAAAAAAAAAA")] + [InlineData("# AAAAAAAAAAAAAAAAAAAAbbbbbbb\nBBBBB", "AAAAAAAAAAAAAAAAAAAA")] + public void GetTitleFromMarkdown(string markdown, string title) + { + var message = new Message(markdown: markdown); + Assert.Equal(title, message.GetTitleWithFallback(maxLengthFromBody: 20)); + } + + [Theory] + [InlineData("Hello World", "Hello World")] + [InlineData("
Hi!
Lorem ipsum dolor
Lorem ipsum dolor
Lorem ipsum dolor
", "Hi!Lorem ipsum dolor")] + public void GetTitleFromHtml(string html, string title) + { + var message = new Message(html: html); + Assert.Equal(title, message.GetTitleWithFallback(maxLengthFromBody: 20)); + } + + [Theory] + [InlineData("Hello World", "Hello World")] + [InlineData("

Hello World

", "<h1>Hello World</h1>")] + [InlineData("", "<img>")] + [InlineData("\"Hello", "<img src="https://example.com/" alt="Hello World">")] + public void EnsureHtmlFromTextAreEscaped(string text, string html) + { + var message = new Message(text: text); + Assert.Equal(html, message.PreferHtmlBody()); + } + } +} \ No newline at end of file diff --git a/src/Naprise.Tests/Service/AppriseTests.cs b/src/Naprise.Tests/Service/AppriseTests.cs new file mode 100644 index 0000000..4f5f8cb --- /dev/null +++ b/src/Naprise.Tests/Service/AppriseTests.cs @@ -0,0 +1,26 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class AppriseTests +{ + [Theory] + [InlineData("apprise://localhost/thisistoken", false, "", "localhost", "thisistoken", Format.Unknown, null)] + [InlineData("apprises://localhost/thisistoken", true, "", "localhost", "thisistoken", Format.Unknown, null)] + [InlineData("apprise://localhost:8080/thisistoken", false, "", "localhost:8080", "thisistoken", Format.Unknown, null)] + [InlineData("apprises://localhost:8080/thisistoken", true, "", "localhost:8080", "thisistoken", Format.Unknown, null)] + [InlineData("apprise://username:password@localhost/thisistoken", false, "username:password", "localhost", "thisistoken", Format.Unknown, null)] + [InlineData("apprise://localhost/thisistoken?tag=test", false, "", "localhost", "thisistoken", Format.Unknown, "test")] + [InlineData("apprise://localhost/thisistoken?format=text", false, "", "localhost", "thisistoken", Format.Text, null)] + [InlineData("apprise://localhost/thisistoken?format=markdown", false, "", "localhost", "thisistoken", Format.Markdown, null)] + [InlineData("apprise://localhost/thisistoken?format=html", false, "", "localhost", "thisistoken", Format.Html, null)] + public void TestUrlParsing(string url, bool https, string userinfo, string hostAndPort, string token, Format format, string? tag) + { + var service = new Apprise(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(userinfo, service.userinfo); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(token, service.token); + Assert.Equal(format, service.format); + Assert.Equal(tag, service.tag); + } +} diff --git a/src/Naprise.Tests/Service/BarkTests.cs b/src/Naprise.Tests/Service/BarkTests.cs new file mode 100644 index 0000000..b8822f4 --- /dev/null +++ b/src/Naprise.Tests/Service/BarkTests.cs @@ -0,0 +1,27 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class BarkTests +{ + [Theory] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ", true, "api.day.app", "YXeWgbg8grJbPdVZ", null, null, null, null, null)] + [InlineData("bark://localhost:8080/YXeWgbg8grJbPdVZ", false, "localhost:8080", "YXeWgbg8grJbPdVZ", null, null, null, null, null)] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ?url=https://example.com", true, "api.day.app", "YXeWgbg8grJbPdVZ", "https://example.com", null, null, null, null)] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ?group=testgroup", true, "api.day.app", "YXeWgbg8grJbPdVZ", null, "testgroup", null, null, null)] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ?icon=https://example.com/icon.svg", true, "api.day.app", "YXeWgbg8grJbPdVZ", null, null, "https://example.com/icon.svg", null, null)] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ?level=timeSensitive", true, "api.day.app", "YXeWgbg8grJbPdVZ", null, null, null, "timeSensitive", null)] + [InlineData("barks://api.day.app/YXeWgbg8grJbPdVZ?sound=alarm.caf", true, "api.day.app", "YXeWgbg8grJbPdVZ", null, null, null, null, "alarm.caf")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string token, string? click_url, string? group, string? icon, string? level, string? sound) + { + var service = new Bark(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(token, service.token); + Assert.Equal(click_url, service.click_url); + Assert.Equal(group, service.group); + Assert.Equal(icon, service.icon); + Assert.Equal(level, service.level); + Assert.Equal(sound, service.sound); + } +} diff --git a/src/Naprise.Tests/Service/DiscordTests.cs b/src/Naprise.Tests/Service/DiscordTests.cs new file mode 100644 index 0000000..356ac31 --- /dev/null +++ b/src/Naprise.Tests/Service/DiscordTests.cs @@ -0,0 +1,65 @@ +using Moq; +using Moq.Contrib.HttpClient; +using Naprise.Service; +using System.Net; + +namespace Naprise.Tests.Service; +public class DiscordTests +{ + [Theory] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz", "1234567890", "abcdefghijklmnopqrstuvwxyz", null, null, false)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?username=TestUser", "1234567890", "abcdefghijklmnopqrstuvwxyz", "TestUser", null, false)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?avatar_url=https://example.com/avatar.png", "1234567890", "abcdefghijklmnopqrstuvwxyz", null, "https://example.com/avatar.png", false)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?tts", "1234567890", "abcdefghijklmnopqrstuvwxyz", null, null, true)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?tts=true", "1234567890", "abcdefghijklmnopqrstuvwxyz", null, null, true)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?tts=false", "1234567890", "abcdefghijklmnopqrstuvwxyz", null, null, false)] + [InlineData("discord://1234567890/abcdefghijklmnopqrstuvwxyz?username=TestUser&avatar_url=https://example.com/avatar.png&tts=true", "1234567890", "abcdefghijklmnopqrstuvwxyz", "TestUser", "https://example.com/avatar.png", true)] + public void TestUrlParsing(string url, string webhookId, string webhookToken, string? username, string? avatarUrl, bool? tts) + { + var service = new Discord(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(webhookId, service.webhookId); + Assert.Equal(webhookToken, service.webhookToken); + Assert.Equal(username, service.username); + Assert.Equal(avatarUrl, service.avatarUrl); + Assert.Equal(tts, service.tts); + } + + [Theory] + [InlineData("discord://1234567890")] // missing webhookToken + [InlineData("discord://1234567890/aaa/bbb")] // too many path segments + public void ExpectNapriseInvalidUrlException(string url) + { + Assert.Throws(() => + { + var service = new Discord(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + }); + } + + [Fact] + public async void TestRequestPayload() + { + var handler = new Mock(MockBehavior.Strict); + var client = handler.CreateClient(); + + handler.SetupRequest(HttpMethod.Post, "https://discord.com/api/webhooks/1234567890/abcdefghijklmnopqrstuvwxyz", async r => + { + Assert.NotNull(r.Content); + var reqBody = await r.Content.ReadAsStringAsync(); + const string expected = @"{""avatar_url"":""https://example.com/avatar.png"",""username"":""TestUser"",""tts"":true,""embeds"":[{""title"":""From Naprise"",""description"":""_Hello_ **World**! :heart:"",""color"":3842871}]}"; + Assert.Equal(expected, reqBody); + return true; + }).ReturnsResponse(HttpStatusCode.OK); + + var url = "discord://1234567890/abcdefghijklmnopqrstuvwxyz?username=TestUser&avatar_url=https://example.com/avatar.png&tts"; + var service = new Discord(new ServiceConfig(new Url(url), new NapriseAsset(), () => client)); + + await service.NotifyAsync(new Message + { + Markdown = @"_Hello_ **World**! :heart:", + Title = "From Naprise", + Type = MessageType.Success, + }); + + handler.VerifyAll(); + } +} diff --git a/src/Naprise.Tests/Service/GotifyTests.cs b/src/Naprise.Tests/Service/GotifyTests.cs new file mode 100644 index 0000000..c155b61 --- /dev/null +++ b/src/Naprise.Tests/Service/GotifyTests.cs @@ -0,0 +1,19 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class GotifyTests +{ + [Theory] + [InlineData("gotify://example.com/1234567890", false, "example.com", "1234567890", null, null)] + [InlineData("gotifys://example.com/1234567890", true, "example.com", "1234567890", null, null)] + [InlineData("gotify://127.0.0.1:9980/Axjd7nAIBIi0iKv?priority=4&click_url=https://example.com", false, "127.0.0.1:9980", "Axjd7nAIBIi0iKv", 4, "https://example.com")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string token, int? priority, string? clickUrl) + { + var service = new Gotify(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(token, service.token); + Assert.Equal(priority, service.priority); + Assert.Equal(clickUrl, service.clickUrl); + } +} diff --git a/src/Naprise.Tests/Service/MailKitTests.cs b/src/Naprise.Tests/Service/MailKitTests.cs new file mode 100644 index 0000000..eb14dc3 --- /dev/null +++ b/src/Naprise.Tests/Service/MailKitTests.cs @@ -0,0 +1,50 @@ +using Naprise.Service.MailKit; + +namespace Naprise.Tests.Service; + +public class MailKitTests +{ + [Theory] + [InlineData("email://user:pass@gmail.com", true, "smtp.gmail.com", 465, "user@gmail.com", "pass", "user@gmail.com", "user@gmail.com")] + [InlineData("email://12345:pAs$w0r*@qq.com", true, "smtp.qq.com", 465, "12345@qq.com", "pAs$w0r*", "12345@qq.com", "12345@qq.com")] + [InlineData("smtp://example.com:25/user/pass/a@example.com/b@example.com", false, "example.com", 25, "user", "pass", "a@example.com", "b@example.com")] + [InlineData("smtps://example.com:465/user/pass/a@example.com/b@example.com", true, "example.com", 465, "user", "pass", "a@example.com", "b@example.com")] + [InlineData("smtps://example.com:465/a@example.com/b@example.com", true, "example.com", 465, null, null, "a@example.com", "b@example.com")] + [InlineData("smtps://example.com:465/user@example.org/pAs$w0r*/a@example.com/b@example.com", true, "example.com", 465, "user@example.org", "pAs$w0r*", "a@example.com", "b@example.com")] + public void TestUrlParsing(string url, bool useSsl, string host, int port, string? username, string? password, string from, string to) + { + var service = new MailKitEmail(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(useSsl, service.useSsl); + Assert.Equal(host, service.host); + Assert.Equal(port, service.port); + Assert.Equal(username, service.username); + Assert.Equal(password, service.password); + Assert.Equal(from, service.from); + Assert.Equal(to, service.to); + } + + [Theory] + [InlineData("email://example.com")] // domain not supported + [InlineData("email://user@example.com")] + [InlineData("email://user:pass@example.com")] + [InlineData("email://gmail.com")] // missing username and password + [InlineData("email://user@gmail.com")] // missing password + [InlineData("smtp://example.com")] // missing port + [InlineData("smtp://example.com/from/to")] // missing port + [InlineData("smtp://example.com/user/pass/from/to")] // missing port + [InlineData("smtps://example.com")] // missing port + [InlineData("smtps://example.com/from/to")] // missing port + [InlineData("smtps://example.com/user/pass/from/to")] // missing port + [InlineData("smtp://example.com:25")] // missing path arguments + [InlineData("smtp://example.com:25/from")] // missing path arguments + [InlineData("smtp://example.com:25/user/pass/from")] // missing path arguments + [InlineData("smtp://example.com:25/user/pass/from/to/what")] // too much arguments + public void TestInvalidUrl(string url) + { + Assert.Throws(() => + { + var service = new MailKitEmail(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + }); + } +} diff --git a/src/Naprise.Tests/Service/NoticaTests.cs b/src/Naprise.Tests/Service/NoticaTests.cs new file mode 100644 index 0000000..e006b3b --- /dev/null +++ b/src/Naprise.Tests/Service/NoticaTests.cs @@ -0,0 +1,19 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class NoticaTests +{ + [Theory] + [InlineData("noticas://notica.us/Ht4d1H", true, "notica.us", "", "Ht4d1H")] + [InlineData("notica://localhost:1234/AbC123", false, "localhost:1234", "", "AbC123")] + [InlineData("noticas://localhost:1234/AbC123", true, "localhost:1234", "", "AbC123")] + [InlineData("notica://user:pass@localhost:1234/AbC123", false, "localhost:1234", "user:pass", "AbC123")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string userinfo, string token) + { + var service = new Notica(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(userinfo, service.userinfo); + Assert.Equal(token, service.token); + } +} diff --git a/src/Naprise.Tests/Service/NotifyRunTests.cs b/src/Naprise.Tests/Service/NotifyRunTests.cs new file mode 100644 index 0000000..e5ddd2b --- /dev/null +++ b/src/Naprise.Tests/Service/NotifyRunTests.cs @@ -0,0 +1,19 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class NotifyRunTests +{ + [Theory] + [InlineData("notifyruns://notify.run/wiFAz0Kp2BsDicG1zafTk", true, "notify.run", "", "wiFAz0Kp2BsDicG1zafTk")] + [InlineData("notifyrun://localhost:1234/wiFAz0Kp2BsDicG1zafTk", false, "localhost:1234", "", "wiFAz0Kp2BsDicG1zafTk")] + [InlineData("notifyruns://localhost:1234/wiFAz0Kp2BsDicG1zafTk", true, "localhost:1234", "", "wiFAz0Kp2BsDicG1zafTk")] + [InlineData("notifyrun://user:pass@localhost:1234/wiFAz0Kp2BsDicG1zafTk", false, "localhost:1234", "user:pass", "wiFAz0Kp2BsDicG1zafTk")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string userinfo, string channel) + { + var service = new NotifyRun(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(userinfo, service.userinfo); + Assert.Equal(channel, service.channel); + } +} diff --git a/src/Naprise.Tests/Service/NtfyTests.cs b/src/Naprise.Tests/Service/NtfyTests.cs new file mode 100644 index 0000000..9570d06 --- /dev/null +++ b/src/Naprise.Tests/Service/NtfyTests.cs @@ -0,0 +1,31 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class NtfyTests +{ + [Theory] + [InlineData("ntfy://localhost/my_ntfy_topic", false, "localhost", "", "my_ntfy_topic", new string[] { }, null, null, null, null)] + [InlineData("ntfys://localhost/my_ntfy_topic", true, "localhost", "", "my_ntfy_topic", new string[] { }, null, null, null, null)] + [InlineData("ntfy://localhost:8080/my_ntfy_topic", false, "localhost:8080", "", "my_ntfy_topic", new string[] { }, null, null, null, null)] + [InlineData("ntfys://localhost:8080/my_ntfy_topic", true, "localhost:8080", "", "my_ntfy_topic", new string[] { }, null, null, null, null)] + [InlineData("ntfy://username:password@localhost/my_ntfy_topic", false, "localhost", "username:password", "my_ntfy_topic", new string[] { }, null, null, null, null)] + [InlineData("ntfy://localhost/my_ntfy_topic?tags=test", false, "localhost", "", "my_ntfy_topic", new string[] { "test" }, null, null, null, null)] + [InlineData("ntfy://localhost/my_ntfy_topic?tags=a,b,c&tags=d,e&tags=f", false, "localhost", "", "my_ntfy_topic", new string[] { "a", "b", "c", "d", "e", "f" }, null, null, null, null)] + [InlineData("ntfy://localhost/my_ntfy_topic?priority=1", false, "localhost", "", "my_ntfy_topic", new string[] { }, 1, null, null, null)] + [InlineData("ntfy://localhost/my_ntfy_topic?click=https://example.com", false, "localhost", "", "my_ntfy_topic", new string[] { }, null, "https://example.com", null, null)] + [InlineData("ntfy://localhost/my_ntfy_topic?delay=1m", false, "localhost", "", "my_ntfy_topic", new string[] { }, null, null, "1m", null)] + [InlineData("ntfy://localhost/my_ntfy_topic?email=user@example.com", false, "localhost", "", "my_ntfy_topic", new string[] { }, null, null, null, "user@example.com")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string userinfo, string topic, string[] tags, int? priority, string? click, string? delay, string? email) + { + var service = new Ntfy(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(userinfo, service.userinfo); + Assert.Equal(topic, service.topic); + Assert.Equal(tags, service.tags); + Assert.Equal(priority, service.priority); + Assert.Equal(click, service.click); + Assert.Equal(delay, service.delay); + Assert.Equal(email, service.email); + } +} diff --git a/src/Naprise.Tests/Service/OneBot11Tests.cs b/src/Naprise.Tests/Service/OneBot11Tests.cs new file mode 100644 index 0000000..b9250a7 --- /dev/null +++ b/src/Naprise.Tests/Service/OneBot11Tests.cs @@ -0,0 +1,34 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class OneBot11Tests +{ + [Theory] + [InlineData("onebot11://mytoken@localhost:8080/private/123456", false, "localhost:8080", "mytoken", "private", "123456", null)] + [InlineData("onebot11://mytoken@localhost:8080/group/123456", false, "localhost:8080", "mytoken", "group", null, "123456")] + [InlineData("onebot11s://mytoken@localhost:8080/private/123456", true, "localhost:8080", "mytoken", "private", "123456", null)] + public void TestUrlParsing(string url, bool https, string hostAndPort, string access_token, string message_type, string? user_id, string? group_id) + { + var service = new OneBot11(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(access_token, service.access_token); + Assert.Equal(message_type, service.message_type); + Assert.Equal(user_id, service.user_id); + Assert.Equal(group_id, service.group_id); + } + + [Theory] + [InlineData("onebot11://localhost")] // missing detail_type + [InlineData("onebot11://localhost/aaaaaa")] // invalid message_type + [InlineData("onebot11://localhost/private")] // missing user_id + [InlineData("onebot11://localhost/group")] // missing group_id + public void ExpectNapriseInvalidUrlException(string url) + { + Assert.Throws(() => + { + var service = new OneBot11(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + }); + } +} diff --git a/src/Naprise.Tests/Service/OneBot12Tests.cs b/src/Naprise.Tests/Service/OneBot12Tests.cs new file mode 100644 index 0000000..0d72c13 --- /dev/null +++ b/src/Naprise.Tests/Service/OneBot12Tests.cs @@ -0,0 +1,40 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class OneBot12Tests +{ + [Theory] + [InlineData("onebot12://mytoken@localhost:8080/private/123456", false, "localhost:8080", "mytoken", "private", "123456", null, null, null)] + [InlineData("onebot12://mytoken@localhost:8080/group/123456", false, "localhost:8080", "mytoken", "group", null, "123456", null, null)] + [InlineData("onebot12://mytoken@localhost:8080/channel/123456/654987", false, "localhost:8080", "mytoken", "channel", null, null, "123456", "654987")] + [InlineData("onebot12s://mytoken@localhost:8080/private/123456", true, "localhost:8080", "mytoken", "private", "123456", null, null, null)] + public void TestUrlParsing(string url, bool https, string hostAndPort, string access_token, string detail_type, string? user_id, string? group_id, string? guild_id, string? channel_id) + { + var service = new OneBot12(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(access_token, service.access_token); + Assert.Equal(detail_type, service.detail_type); + Assert.Equal(user_id, service.user_id); + Assert.Equal(group_id, service.group_id); + Assert.Equal(guild_id, service.guild_id); + Assert.Equal(channel_id, service.channel_id); + } + + [Theory] + [InlineData("onebot12://localhost")] // missing detail_type + [InlineData("onebot12://localhost/aaaaaa")] // invalid detail_type + [InlineData("onebot12://localhost/private")] // missing user_id + [InlineData("onebot12://localhost/group")] // missing group_id + [InlineData("onebot12://localhost/channel")] // missing guild_id and channel_id + [InlineData("onebot12://localhost/channel?guild_id=123456")] // missing channel_id + [InlineData("onebot12://localhost/channel?channel_id=654987")] // missing guild_id + public void ExpectNapriseInvalidUrlException(string url) + { + Assert.Throws(() => + { + var service = new OneBot12(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + }); + } +} diff --git a/src/Naprise.Tests/Service/PushDeerTests.cs b/src/Naprise.Tests/Service/PushDeerTests.cs new file mode 100644 index 0000000..5605c67 --- /dev/null +++ b/src/Naprise.Tests/Service/PushDeerTests.cs @@ -0,0 +1,18 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class PushDeerTests +{ + [Theory] + [InlineData("pushdeers://api2.pushdeer.com/x6oJU9DE7wWr5nW", true, "api2.pushdeer.com", "", "x6oJU9DE7wWr5nW")] + [InlineData("pushdeers://user:pass@api2.pushdeer.com/x6oJU9DE7wWr5nW", true, "api2.pushdeer.com", "user:pass", "x6oJU9DE7wWr5nW")] + [InlineData("pushdeer://localhost:8080/x6oJU9DE7wWr5nW", false, "localhost:8080", "", "x6oJU9DE7wWr5nW")] + public void TestUrlParsing(string url, bool https, string hostAndPort, string userinfo, string pushkey) + { + var service = new PushDeer(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + Assert.Equal(https, service.https); + Assert.Equal(hostAndPort, service.hostAndPort); + Assert.Equal(userinfo, service.userinfo); + Assert.Equal(pushkey, service.pushkey); + } +} diff --git a/src/Naprise.Tests/Service/PushPlusTests.cs b/src/Naprise.Tests/Service/PushPlusTests.cs new file mode 100644 index 0000000..45dc2be --- /dev/null +++ b/src/Naprise.Tests/Service/PushPlusTests.cs @@ -0,0 +1,16 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class PushPlusTests +{ + [Theory] + [InlineData("pushplus://abcdefg@wechat/", "abcdefg", "wechat")] + [InlineData("pushplus://a123335@cp", "a123335", "cp")] + public void TestUrlParsing(string url, string token, string channel) + { + var service = new PushPlus(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(token, service.token); + Assert.Equal(channel, service.channel); + } +} diff --git a/src/Naprise.Tests/Service/ServerChanTests.cs b/src/Naprise.Tests/Service/ServerChanTests.cs new file mode 100644 index 0000000..c504cb3 --- /dev/null +++ b/src/Naprise.Tests/Service/ServerChanTests.cs @@ -0,0 +1,20 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class ServerChanTests +{ + [Theory] + [InlineData("serverchan://SCT123456@serverchan", "SCT123456", null, null)] + [InlineData("serverchan://6AbCd4321@serverchan", "6AbCd4321", null, null)] + [InlineData("serverchan://SCT123456@serverchan?channel=1|2", "SCT123456", "1|2", null)] + [InlineData("serverchan://SCT123456@serverchan?openid=aaaaa", "SCT123456", null, "aaaaa")] + [InlineData("serverchan://SCT123456@serverchan?openid=aaaaa,bbbbb", "SCT123456", null, "aaaaa,bbbbb")] + public void TestUrlParsing(string url, string token, string? channel, string? openid) + { + var service = new ServerChan(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(token, service.token); + Assert.Equal(channel, service.channel); + Assert.Equal(openid, service.openid); + } +} diff --git a/src/Naprise.Tests/Service/TelegramTests.cs b/src/Naprise.Tests/Service/TelegramTests.cs new file mode 100644 index 0000000..90f6acd --- /dev/null +++ b/src/Naprise.Tests/Service/TelegramTests.cs @@ -0,0 +1,30 @@ +using Naprise.Service; + +namespace Naprise.Tests.Service; +public class TelegramTests +{ + [Theory] + [InlineData("telegram://123456:ABC5e-7z_3@channelusername", "123456:ABC5e-7z_3", "@channelusername", "https://api.telegram.org", null, null, false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, null, false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@-654987", "123456:ABC5e-7z_3", "-654987", "https://api.telegram.org", null, null, false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?api_host=https://api.example.com", "123456:ABC5e-7z_3", "654987", "https://api.example.com", null, null, false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?message_thread_id=123", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", "123", null, false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?parse_mode=Markdown", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, "Markdown", false, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?disable_web_page_preview", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, null, true, false, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?disable_notification", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, null, false, true, false)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?protect_content", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, null, false, false, true)] + [InlineData("telegram://123456:ABC5e-7z_3@654987?disable_web_page_preview&disable_notification&protect_content", "123456:ABC5e-7z_3", "654987", "https://api.telegram.org", null, null, true, true, true)] + public void TelegramUrlConverterTest(string url, string token, string chat_id, string api_host, string? message_thread_id, string? parse_mode, bool disable_web_page_preview, bool disable_notification, bool protect_content) + { + var telegram = new Telegram(new ServiceConfig(new Url(url), new NapriseAsset(), () => new HttpClient())); + + Assert.Equal(token, telegram.token); + Assert.Equal(chat_id, telegram.chat_id); + Assert.Equal(api_host, telegram.api_host); + Assert.Equal(message_thread_id, telegram.message_thread_id); + Assert.Equal(parse_mode, telegram.parse_mode); + Assert.Equal(disable_web_page_preview, telegram.disable_web_page_preview); + Assert.Equal(disable_notification, telegram.disable_notification); + Assert.Equal(protect_content, telegram.protect_content); + } +} diff --git a/src/Naprise.Tests/ServiceRegistryTests.cs b/src/Naprise.Tests/ServiceRegistryTests.cs new file mode 100644 index 0000000..f0c013b --- /dev/null +++ b/src/Naprise.Tests/ServiceRegistryTests.cs @@ -0,0 +1,176 @@ +namespace Naprise.Tests; +public class ServiceRegistryTests +{ + public ServiceRegistryTests() + { + this.ServiceRegistry = new ServiceRegistry().AddDefaultServices().Add(); + } + + public ServiceRegistry ServiceRegistry { get; } + + [Fact] + public void EnsureCreateMethodsWillNotInfiniteLoop() + { + Naprise.DefaultRegistry = this.ServiceRegistry; + + // ServiceRegistryExtensions.Create() + // params string[] urls + this.ServiceRegistry.Create(urls: "mock://").Should().BeOfType(); + this.ServiceRegistry.Create("mock://", "mock://").Should().BeOfType(); + this.ServiceRegistry.Create(urls: new[] { "mock://" }).Should().BeOfType(); + this.ServiceRegistry.Create(urls: new[] { "mock://", "mock://" }).Should().BeOfType(); + + // params Url[] urls + this.ServiceRegistry.Create(urls: new Url("mock://")).Should().BeOfType(); + this.ServiceRegistry.Create(new Url("mock://"), new Url("mock://")).Should().BeOfType(); + this.ServiceRegistry.Create(urls: new[] { new Url("mock://") }).Should().BeOfType(); + this.ServiceRegistry.Create(urls: new[] { new Url("mock://"), new Url("mock://") }).Should().BeOfType(); + + // ServiceRegistry.Create() + // IEnumerable serviceUrls + this.ServiceRegistry.Create(serviceUrls: new[] { new Url("mock://") }).Should().BeOfType(); + this.ServiceRegistry.Create(serviceUrls: new[] { new Url("mock://"), new Url("mock://") }).Should().BeOfType(); + + // Naprise.Create() + // params string[] urls + Naprise.Create(urls: "mock://").Should().BeOfType(); + Naprise.Create("mock://", "mock://").Should().BeOfType(); + Naprise.Create(urls: new[] { "mock://" }).Should().BeOfType(); + Naprise.Create(urls: new[] { "mock://", "mock://" }).Should().BeOfType(); + + // params Url[] urls + Naprise.Create(urls: new Url("mock://")).Should().BeOfType(); + Naprise.Create(new Url("mock://"), new Url("mock://")).Should().BeOfType(); + Naprise.Create(urls: new[] { new Url("mock://") }).Should().BeOfType(); + Naprise.Create(urls: new[] { new Url("mock://"), new Url("mock://") }).Should().BeOfType(); + + // IEnumerable urls + Naprise.Create(urls: new List { new Url("mock://") }).Should().BeOfType(); + Naprise.Create(urls: new List { new Url("mock://"), new Url("mock://") }).Should().BeOfType(); + } + + [Fact] + public void EnsureUsingCorrectHttpClientWhenNull() + { + this.ServiceRegistry.HttpClient = null; + var notifier = this.ServiceRegistry.Create(urls: "naprise-a://"); + notifier.Should() + .BeOfType() + .Which.Config.HttpClientFactory() + .Should() + .BeSameAs(Naprise.DefaultHttpClient); + } + + [Fact] + public void EnsureUsingCorrectHttpClientWhenSet() + { + this.ServiceRegistry.HttpClient = new HttpClient(); + var notifier = this.ServiceRegistry.Create(urls: "naprise-a://"); + notifier.Should() + .BeOfType() + .Which.Config.HttpClientFactory() + .Should() + .NotBeSameAs(Naprise.DefaultHttpClient).And + .BeSameAs(this.ServiceRegistry.HttpClient); + } + + [Fact] + public void EnsureUsingCorrectHttpClientAfterCreated() + { + var notifier = this.ServiceRegistry.Create(urls: "naprise-a://").Should().BeOfType().Subject; + + notifier.Config.HttpClientFactory().Should().BeSameAs(Naprise.DefaultHttpClient); + + var httpclient1 = new HttpClient(); + var httpclient2 = new HttpClient(); + + this.ServiceRegistry.HttpClient = httpclient1; + notifier.Config.HttpClientFactory() + .Should() + .NotBeSameAs(Naprise.DefaultHttpClient).And + .BeSameAs(httpclient1); + + this.ServiceRegistry.HttpClient = httpclient2; + notifier.Config.HttpClientFactory() + .Should() + .NotBeSameAs(Naprise.DefaultHttpClient).And + .NotBeSameAs(httpclient1).And + .BeSameAs(httpclient2); + } + + [Theory] + [InlineData("naprise-a://host")] + [InlineData("naprise-b://host")] + [InlineData("mock://host")] + public void EnsureCorrectServiceSelected(string url) + { + var notifier = this.ServiceRegistry.Create(url); + notifier.Should().BeOfType().Which.Config.Url.ToString().Should().Be(url); + } + + [Theory] + [InlineData("unknown://host")] + [InlineData("http://example.com")] + [InlineData("https://example.com")] + public void EnsureThrowOnUnknownScheme(string url) + { + this.ServiceRegistry.IgnoreUnknownScheme = false; + Assert.Throws(() => this.ServiceRegistry.Create(url)); + } + + [Theory] + [InlineData("unknown://host")] + [InlineData("http://example.com")] + [InlineData("https://example.com")] + public void EnsureNoopNotifierOnUnknownScheme(string url) + { + this.ServiceRegistry.IgnoreUnknownScheme = true; + this.ServiceRegistry.Create(url).Should().BeSameAs(Naprise.NoopNotifier); + } + + [Fact] + public void TestSingleInvalidUrlWithIgnoreFalse() + { + this.ServiceRegistry.IgnoreInvalidUrl = false; + Assert.Throws(() => this.ServiceRegistry.Create("mock://throw")); + } + + [Fact] + public void TestSingleInvalidUrlWithIgnoreTrue() + { + this.ServiceRegistry.IgnoreInvalidUrl = true; + this.ServiceRegistry.Create("mock://throw").Should().BeSameAs(Naprise.NoopNotifier); + } + + [Fact] + public void TestMultipleInvalidUrlWithIgnoreFalse() + { + this.ServiceRegistry.IgnoreInvalidUrl = false; + Assert.Throws(() => this.ServiceRegistry.Create("mock://throw", "mock://throw")); + } + + [Fact] + public void TestMultipleInvalidUrlWithIgnoreTrue() + { + this.ServiceRegistry.IgnoreInvalidUrl = true; + this.ServiceRegistry.Create("mock://throw", "mock://throw").Should().BeSameAs(Naprise.NoopNotifier); + } + + public static IEnumerable GetMultilineSchemeTestData() => throw new NotImplementedException(); // TODO: add multi-service notifer test + + [NapriseNotificationService("MockService", "mock", "naprise-a", "naprise-b")] + private class MockService : NotificationService + { + public ServiceConfig Config { get; } + + public MockService(ServiceConfig config) : base(config) + { + this.Config = config; + + if (config.Url.Host == "throw") + throw new NapriseInvalidUrlException("Invalid URL"); + } + + public override Task NotifyAsync(Message message, CancellationToken cancellationToken = default) => throw new NotImplementedException(); + } +} diff --git a/src/Naprise.Tests/ServicesTests.cs b/src/Naprise.Tests/ServicesTests.cs new file mode 100644 index 0000000..7dec510 --- /dev/null +++ b/src/Naprise.Tests/ServicesTests.cs @@ -0,0 +1,77 @@ +using Naprise.Service; +using Naprise.Service.MailKit; +using System.Reflection; + +namespace Naprise.Tests; +public class ServicesTests +{ + public static readonly IReadOnlyList ServiceAssemblies = new List + { + typeof(MailKitEmail).Assembly, + }; + + [Fact] + public void EnsureMainProjServicesAreValid() + { + var types = typeof(ServiceRegistry).Assembly.GetTypes() + .Where(t => t.Namespace == "Naprise.Service" && t.IsPublic) + .ToArray(); + + ServiceRegistry.DefaultServices.Should().BeEquivalentTo(types); + } + + [Fact] + public void EnsureMainProjNoNonPublicClasses() + { + var types = typeof(ServiceRegistry).Assembly.GetTypes() + .Where(t => t.Namespace == "Naprise.Service" && !t.IsNested && !t.IsPublic) + .ToArray(); + + types.Should().BeEquivalentTo(new[] { typeof(Template) }); // only the template is allowed + } + + [Fact] + public void EnsureMainProjAllNestClassAreNotPublic() + { + var types = typeof(ServiceRegistry).Assembly.GetTypes() + .Where(t => t.Namespace == "Naprise.Service" && t.IsNestedPublic) + .ToArray(); + + types.Should().BeEmpty(); + } + + [Fact] + public void EnsureBuiltinServiceDontHaveDuplicateSchemes() + { + var types = ServiceAssemblies.SelectMany(x => x.GetTypes().Where(x => x.IsPublic && typeof(INotifier).IsAssignableFrom(x))).ToList(); + types.AddRange(ServiceRegistry.DefaultServices); + + types.SelectMany(x => x.GetCustomAttribute() is { } attr ? attr.Schemes : throw new InvalidOperationException($"Service {x} is missing the {nameof(NapriseNotificationServiceAttribute)} attribute.")) + .Should() + .OnlyHaveUniqueItems(); + } + + [Fact] + public void EnsureMainProjServiceHaveTests() + { + var a = typeof(ServicesTests).Assembly; + + var tests = ServiceRegistry.DefaultServices + .Select(x => $"Naprise.Tests.Service.{x.Name}Tests") + .Select(x => a.GetType(x)) + .ToArray(); + + if (tests.Contains(null)) + { + // find what's missing for better error messages + var pnames = ServiceRegistry.DefaultServices.Select(x => x.Name).ToList(); + var tnames = tests.Where(x => x is not null).Select(x => x!.Name.Substring(0, x.Name.Length - 5)).ToArray(); + foreach (var n in tnames) + { + Assert.True(pnames.Remove(n), $"Should able to remove {n} from the list of service names."); + } + + pnames.Should().BeEmpty(); + } + } +} diff --git a/src/Naprise.Tests/Usings.cs b/src/Naprise.Tests/Usings.cs new file mode 100644 index 0000000..18c8582 --- /dev/null +++ b/src/Naprise.Tests/Usings.cs @@ -0,0 +1,4 @@ +global using FluentAssertions; +global using FluentAssertions.Execution; +global using Flurl; +global using Xunit; diff --git a/src/Naprise.sln b/src/Naprise.sln new file mode 100644 index 0000000..33f56f7 --- /dev/null +++ b/src/Naprise.sln @@ -0,0 +1,49 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.1.32421.90 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Naprise", "Naprise\Naprise.csproj", "{4FF56F2B-B557-4DA2-B939-8D7E699A1343}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Naprise.Tests", "Naprise.Tests\Naprise.Tests.csproj", "{09DD2A9E-BEFB-4103-91D7-5526A4923E3C}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Naprise.DocGenerator", "Naprise.DocGenerator\Naprise.DocGenerator.csproj", "{9F7F40BF-D46F-4FA5-84AB-BE78DE6063F5}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Naprise.Cli", "Naprise.Cli\Naprise.Cli.csproj", "{04483DBC-90DC-4DE2-BBD8-C637C879DB2B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Naprise.Service.MailKit", "Naprise.Service.MailKit\Naprise.Service.MailKit.csproj", "{229E9F5F-A788-4DB7-AD5B-B185FD3F7BE5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {4FF56F2B-B557-4DA2-B939-8D7E699A1343}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4FF56F2B-B557-4DA2-B939-8D7E699A1343}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4FF56F2B-B557-4DA2-B939-8D7E699A1343}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4FF56F2B-B557-4DA2-B939-8D7E699A1343}.Release|Any CPU.Build.0 = Release|Any CPU + {09DD2A9E-BEFB-4103-91D7-5526A4923E3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {09DD2A9E-BEFB-4103-91D7-5526A4923E3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {09DD2A9E-BEFB-4103-91D7-5526A4923E3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {09DD2A9E-BEFB-4103-91D7-5526A4923E3C}.Release|Any CPU.Build.0 = Release|Any CPU + {9F7F40BF-D46F-4FA5-84AB-BE78DE6063F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9F7F40BF-D46F-4FA5-84AB-BE78DE6063F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F7F40BF-D46F-4FA5-84AB-BE78DE6063F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9F7F40BF-D46F-4FA5-84AB-BE78DE6063F5}.Release|Any CPU.Build.0 = Release|Any CPU + {04483DBC-90DC-4DE2-BBD8-C637C879DB2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {04483DBC-90DC-4DE2-BBD8-C637C879DB2B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {04483DBC-90DC-4DE2-BBD8-C637C879DB2B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {04483DBC-90DC-4DE2-BBD8-C637C879DB2B}.Release|Any CPU.Build.0 = Release|Any CPU + {229E9F5F-A788-4DB7-AD5B-B185FD3F7BE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {229E9F5F-A788-4DB7-AD5B-B185FD3F7BE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {229E9F5F-A788-4DB7-AD5B-B185FD3F7BE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {229E9F5F-A788-4DB7-AD5B-B185FD3F7BE5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {BD6195DB-769E-43D4-998A-D601A5B8A3C6} + EndGlobalSection +EndGlobal diff --git a/src/Naprise/Attributes.cs b/src/Naprise/Attributes.cs new file mode 100644 index 0000000..15a316c --- /dev/null +++ b/src/Naprise/Attributes.cs @@ -0,0 +1,83 @@ +using System; +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Naprise.Tests")] + +namespace Naprise +{ + /// + /// Specifies the URL Schemes and supported message formats of a notification service. + /// + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class NapriseNotificationServiceAttribute : Attribute + { + /// + /// Specifies the URL Schemes and supported message formats of a notification service. + /// + /// URL Schemes for this notification service. + /// + /// + public NapriseNotificationServiceAttribute(string displayName, params string[] schemes) + { + if (schemes == null) + throw new ArgumentNullException(nameof(schemes)); + + if (schemes.Length == 0) + throw new ArgumentException("At least one scheme must be specified.", nameof(schemes)); + + foreach (var scheme in schemes) + { + if (scheme.ToLowerInvariant() != scheme) + throw new ArgumentException($"Scheme '{scheme}' must be lowercase.", nameof(schemes)); + } + + this.DisplayName = displayName; + this.Schemes = schemes; + } + + /// + /// Display name of this notification service. + /// + public string DisplayName { get; } + + /// + /// URL Schemes for this notification service. + /// + public string[] Schemes { get; } + + /// + /// Plain text support of this notification service. Currently only used for documentation purpose. + /// + public bool SupportText { get; set; } = false; + /// + /// Markdown support of this notification service. Currently only used for documentation purpose. + /// + public bool SupportMarkdown { get; set; } = false; + /// + /// HTML support of this notification service. Currently only used for documentation purpose. + /// + public bool SupportHtml { get; set; } = false; + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class NotificationServiceWebsiteAttribute : Attribute + { + public NotificationServiceWebsiteAttribute(string url) + { + this.Url = url; + } + + public string Url { get; } + } + + [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)] + public sealed class NotificationServiceApiDocAttribute : Attribute + { + public NotificationServiceApiDocAttribute(string url) + { + this.Url = url; + } + + public string Url { get; } + } +} diff --git a/src/Naprise/Color.cs b/src/Naprise/Color.cs new file mode 100644 index 0000000..4ef25c1 --- /dev/null +++ b/src/Naprise/Color.cs @@ -0,0 +1,88 @@ +using System; +using System.Globalization; + +namespace Naprise +{ + public sealed class Color + { + public Color(int value) + { + this.Value = value; + } + + public Color(byte red, byte green, byte blue) + { + this.Value = (red << 16) | (green << 8) | blue; + } + + public Color(string hex) + { + if (hex.StartsWith("#")) + hex = hex.Substring(1); + + if (hex.Length == 3) + hex = string.Format("{0}{0}{1}{1}{2}{2}", hex[0], hex[1], hex[2]); + + if (hex.Length != 6) + throw new ArgumentException("Invalid hex color", "hex"); + + this.Value = int.Parse(hex, NumberStyles.HexNumber); + } + + public int Value { get; set; } + + public byte R + { + get => (byte)(this.Value >> 16); + set => this.Value = (this.Value & 0x00FFFF) | (value << 16); + } + + public byte G + { + get => (byte)(this.Value >> 8); + set => this.Value = (this.Value & 0xFF00FF) | (value << 8); + } + + public byte B + { + get => (byte)this.Value; + set => this.Value = (this.Value & 0xFFFF00) | (value); + } + + public string Hex + { + get => $"#{this.R:X2}{this.G:X2}{this.B:X2}"; + set + { + if (value.StartsWith("#")) + { + value = value.Substring(1); + } + + this.R = byte.Parse(value.Substring(0, 2), NumberStyles.HexNumber); + this.G = byte.Parse(value.Substring(2, 2), NumberStyles.HexNumber); + this.B = byte.Parse(value.Substring(4, 2), NumberStyles.HexNumber); + } + } + + public static implicit operator Color(int value) + { + return new Color(value); + } + + public static implicit operator int(Color color) + { + return color.Value; + } + + public static implicit operator Color(string hex) + { + return new Color(hex); + } + + public static implicit operator string(Color color) + { + return color.Hex; + } + } +} diff --git a/src/Naprise/CompositeNotifier.cs b/src/Naprise/CompositeNotifier.cs new file mode 100644 index 0000000..ac9e4fe --- /dev/null +++ b/src/Naprise/CompositeNotifier.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise +{ + public class CompositeNotifier : INotifier + { + private readonly IReadOnlyList notifiers; + + public CompositeNotifier(IReadOnlyList notifiers) + { + this.notifiers = notifiers ?? throw new ArgumentNullException(nameof(notifiers)); + } + + public async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var exceptions = new List(); + var tasks = new Task[this.notifiers.Count]; + + for (var i = 0; i < this.notifiers.Count; i++) + { + var notifier = this.notifiers[i]; + tasks[i] = Task.Run(() => notifier.NotifyAsync(message, cancellationToken)); + } + + // make sure all tasks are completed and every exception is collected + for (var i = 0; i < tasks.Length; i++) + { + try + { + await tasks[i]; + } + catch (Exception ex) + { + exceptions.Add(ex); + } + } + + if (exceptions.Count > 0) + throw new AggregateException("One or more notifications failed.", exceptions); + } + } +} diff --git a/src/Naprise/Exceptions.cs b/src/Naprise/Exceptions.cs new file mode 100644 index 0000000..f75ad7e --- /dev/null +++ b/src/Naprise/Exceptions.cs @@ -0,0 +1,77 @@ +using System; +using System.Net; + +namespace Naprise +{ + /// + /// Base exception for Naprise + /// + [Serializable] + public class NapriseException : Exception + { + public NapriseException() { } + public NapriseException(string message) : base(message) { } + public NapriseException(string message, Exception inner) : base(message, inner) { } + } + + [Serializable] + public class NapriseUnknownSchemeException : NapriseException + { + public NapriseUnknownSchemeException() { } + public NapriseUnknownSchemeException(string message) : base(message) { } + public NapriseUnknownSchemeException(string message, Exception inner) : base(message, inner) { } + } + + [Serializable] + public class NapriseInvalidUrlException : NapriseException + { + public NapriseInvalidUrlException() { } + public NapriseInvalidUrlException(string message) : base(message) { } + public NapriseInvalidUrlException(string message, Exception inner) : base(message, inner) { } + } + + [Serializable] + public class NapriseEmptyMessageException : NapriseException + { + public NapriseEmptyMessageException() { } + public NapriseEmptyMessageException(string message) : base(message) { } + public NapriseEmptyMessageException(string message, Exception inner) : base(message, inner) { } + } + + [Serializable] + public class NapriseNotifyFailedException : NapriseException + { + private const string NAPRISE_NOTIFIER = "NapriseNotifier"; + private const string NAPRISE_NOTIFICATION = "NapriseNotification"; + private const string NAPRISE_RESPONSE_BODY = "NapriseResponseBody"; + private const string NAPRISE_RESPONSE_STATUS_CODE = "NapriseResponseStatusCode"; + + public INotifier? Notifier + { + get => this.Data.Contains(NAPRISE_NOTIFIER) ? (INotifier)this.Data[NAPRISE_NOTIFIER] : null; + set => this.Data[NAPRISE_NOTIFIER] = value; + } + + public Message? Notification + { + get => this.Data.Contains(NAPRISE_NOTIFICATION) ? (Message)this.Data[NAPRISE_NOTIFICATION] : null; + set => this.Data[NAPRISE_NOTIFICATION] = value; + } + + public HttpStatusCode? ResponseStatus + { + get => this.Data.Contains(NAPRISE_RESPONSE_STATUS_CODE) ? (HttpStatusCode)this.Data[NAPRISE_RESPONSE_STATUS_CODE] : null; + set => this.Data[NAPRISE_RESPONSE_STATUS_CODE] = value; + } + + public string? ResponseBody + { + get => this.Data.Contains(NAPRISE_RESPONSE_BODY) ? (string)this.Data[NAPRISE_RESPONSE_BODY] : null; + set => this.Data[NAPRISE_RESPONSE_BODY] = value; + } + + public NapriseNotifyFailedException() { } + public NapriseNotifyFailedException(string message) : base(message) { } + public NapriseNotifyFailedException(string message, Exception inner) : base(message, inner) { } + } +} diff --git a/src/Naprise/Format.cs b/src/Naprise/Format.cs new file mode 100644 index 0000000..9e78d6d --- /dev/null +++ b/src/Naprise/Format.cs @@ -0,0 +1,10 @@ +namespace Naprise +{ + public enum Format + { + Unknown, + Text, + Markdown, + Html + } +} diff --git a/src/Naprise/INotifier.cs b/src/Naprise/INotifier.cs new file mode 100644 index 0000000..5957d45 --- /dev/null +++ b/src/Naprise/INotifier.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise +{ + public interface INotifier + { + Task NotifyAsync(Message message, CancellationToken cancellationToken = default); + } +} diff --git a/src/Naprise/Json/JsonSnakeCaseNamingPolicy.cs b/src/Naprise/Json/JsonSnakeCaseNamingPolicy.cs new file mode 100644 index 0000000..f2baf0b --- /dev/null +++ b/src/Naprise/Json/JsonSnakeCaseNamingPolicy.cs @@ -0,0 +1,37 @@ +using System.Text; +using System.Text.Json; + +namespace Naprise.Json +{ + // TODO: use JsonNamingPolicy.SnakeCaseLower when that is available + internal class JsonSnakeCaseNamingPolicy : JsonNamingPolicy + { + internal static readonly JsonSnakeCaseNamingPolicy Instance = new JsonSnakeCaseNamingPolicy(); + + public override string ConvertName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return name; + + + var b = new StringBuilder(); + for (var i = 0; i < name.Length; i++) + { + var c = name[i]; + if (char.IsUpper(c)) + { + if (i > 0) + b.Append('_'); + + b.Append(char.ToLowerInvariant(c)); + } + else + { + b.Append(c); + } + } + + return b.ToString(); + } + } +} diff --git a/src/Naprise/Message.cs b/src/Naprise/Message.cs new file mode 100644 index 0000000..504207f --- /dev/null +++ b/src/Naprise/Message.cs @@ -0,0 +1,185 @@ +using AngleSharp; +using Markdig; +using System; +using System.Net; + +namespace Naprise +{ + public interface IMessage + { + string? Title { get; } + MessageType Type { get; } + string? Text { get; } + string? Markdown { get; } + string? Html { get; } + } + + /// + /// Contains the notification message title and body. + ///
+ /// , , and should be the exact same content in different formats. + ///
+ public class Message : IMessage + { + public Message() { } + + /// + /// + /// + /// Message title. Optional. + /// Type of the message. + /// Message body in plain text. Optional. + /// Message body in Markdown. Optional. + /// Message body in HTML. Optional. + public Message(MessageType type = MessageType.None, string? title = null, string? text = null, string? markdown = null, string? html = null) + { + this.Title = title; + this.Type = type; + this.Text = text; + this.Markdown = markdown; + this.Html = html; + } + + public string? Title { get; set; } + public MessageType Type { get; set; } + public string? Text { get; set; } + public string? Markdown { get; set; } + public string? Html { get; set; } + + public static implicit operator Message(string? title) + { + return new Message(title: title); + } + + public void ThrowIfEmpty() + { + if (this.Title is null && this.Text is null && this.Markdown is null && this.Html is null) + throw new NapriseEmptyMessageException("Notification message is empty."); + } + } + + public static class NotificationMessageExtensions + { + private static readonly MarkdownPipeline pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().UseEmojiAndSmiley().Build(); + + /// + /// Get the message title, or create a title from message body as fallback if title is + /// + /// The payload + /// Maximum title length if a title is being generated from body. is always returned as is. + /// + public static string GetTitleWithFallback(this IMessage message, int maxLengthFromBody = 20) + { + if (maxLengthFromBody < 1) + throw new ArgumentOutOfRangeException("maxLengthFromBody must be a positive number", nameof(maxLengthFromBody)); + + if (message.Title is not null) + return message.Title; + + if (message.Text is string text) + { + // search the end of first line + var len = text.IndexOfAny(new[] { '\n', '\r' }); + // use length of the text if there's only a single line + if (len < 1) + len = text.Length; + // cap max length + len = Math.Min(maxLengthFromBody, len); + + return text.Substring(0, len); + } + + if (message.Markdown is string markdown) + { + // find the index after markdown header (e.g. #, ##, ###, etc.) + var start = 0; + while (start < markdown.Length && markdown[start] == '#') + start++; + + // skip whitespace + while (start < markdown.Length && char.IsWhiteSpace(markdown[start])) + start++; + + // search the end of first line + var len = markdown.IndexOfAny(new[] { '\n', '\r' }); + + // use length of the text if there's only a single line + if (len < 1) + len = markdown.Length; + + // cap max length + len = Math.Min(maxLengthFromBody + start, len); + + return markdown.Substring(start, len - start); + } + + if (message.Html is not null) + { + // we're just reading directly from memory, hopefully this won't cause any deadlocks... + var document = BrowsingContext.New().OpenAsync(req => req.Content(message.Html)).GetAwaiter().GetResult(); + var tc = document.Body?.TextContent ?? string.Empty; + return tc.Length > maxLengthFromBody ? tc.Substring(0, maxLengthFromBody) : tc; + } + + return string.Empty; + } + + public static string? PreferTextBody(this IMessage message) + { + if (message.Text is not null) + return message.Text; + + if (message.Markdown is not null) + return Markdown.ToPlainText(message.Markdown, pipeline); + + if (message.Html is not null) + { + // we're just reading directly from memory, hopefully this won't cause any deadlocks... + var document = BrowsingContext.New().OpenAsync(req => req.Content(message.Html)).GetAwaiter().GetResult(); + return document.Body?.TextContent; + } + + return null; + } + + public static string? PreferMarkdownBody(this IMessage message) + { + if (message.Markdown is not null) + return message.Markdown; + + if (message.Text is not null) + return message.Text; + + if (message.Html is not null) + { + // returns plain text even though markdown is preferred, good enough for now + var document = BrowsingContext.New().OpenAsync(req => req.Content(message.Html)).GetAwaiter().GetResult(); + return document.Body?.TextContent; + } + + return null; + } + + public static string? PreferHtmlBody(this IMessage message) + { + if (message.Html is not null) + return message.Html; + + if (message.Markdown is not null) + return Markdown.ToHtml(message.Markdown, pipeline); + + if (message.Text is not null) + return WebUtility.HtmlEncode(message.Text); + + return null; + } + + public static Message GenerateAllBodyFormats(this Message message) + { + message.Html ??= message.PreferHtmlBody(); + message.Markdown ??= message.PreferMarkdownBody(); + message.Text ??= message.PreferTextBody(); + return message; + } + } +} diff --git a/src/Naprise/MessageType.cs b/src/Naprise/MessageType.cs new file mode 100644 index 0000000..8eb712d --- /dev/null +++ b/src/Naprise/MessageType.cs @@ -0,0 +1,11 @@ +namespace Naprise +{ + public enum MessageType + { + None = 0, + Info, + Success, + Warning, + Error, + } +} diff --git a/src/Naprise/Naprise.cs b/src/Naprise/Naprise.cs new file mode 100644 index 0000000..7c89aca --- /dev/null +++ b/src/Naprise/Naprise.cs @@ -0,0 +1,30 @@ +using Flurl; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise +{ + public static class Naprise + { + public static HttpClient DefaultHttpClient { get; set; } = new HttpClient(); + + public static ServiceRegistry DefaultRegistry { get; set; } = new ServiceRegistry().AddDefaultServices(); + + /// + /// A that does nothing. + /// + public static INotifier NoopNotifier { get; } = new Noop(); + + public static INotifier Create(params string[] urls) => DefaultRegistry.Create(urls); + public static INotifier Create(params Url[] urls) => DefaultRegistry.Create(urls); + public static INotifier Create(IEnumerable urls) => DefaultRegistry.Create(urls); + public static INotifier Create(IEnumerable urls) => DefaultRegistry.Create(urls); + + private class Noop : INotifier + { + public Task NotifyAsync(Message message, CancellationToken cancellationToken = default) => Task.CompletedTask; + } + } +} diff --git a/src/Naprise/Naprise.csproj b/src/Naprise/Naprise.csproj new file mode 100644 index 0000000..354cda2 --- /dev/null +++ b/src/Naprise/Naprise.csproj @@ -0,0 +1,42 @@ + + + + netstandard2.0 + enable + 9.0 + true + true + $(WarningsAsErrors);RS0016;RS0017;RS0041 + + + + true + + + + true + true + true + snupkg + + + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + diff --git a/src/Naprise/NapriseAsset.cs b/src/Naprise/NapriseAsset.cs new file mode 100644 index 0000000..4c700c4 --- /dev/null +++ b/src/Naprise/NapriseAsset.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace Naprise +{ + public class NapriseAsset + { + public Dictionary NotificationTypeColor { get; set; } = new() + { + [MessageType.None] = "202225", + [MessageType.Info] = "3AA3E3", + [MessageType.Success] = "3AA337", + [MessageType.Warning] = "CACF29", + [MessageType.Error] = "A32037", + }; + + public Color GetColor(MessageType type) => this.NotificationTypeColor.TryGetValue(type, out var color) ? color : "202225"; + + public Dictionary NotificationTypeAscii { get; set; } = new() + { + [MessageType.None] = string.Empty, + [MessageType.Info] = "[i]", + [MessageType.Success] = "[+]", + [MessageType.Warning] = "[~]", + [MessageType.Error] = "[!]", + }; + + public string GetAscii(MessageType type) => this.NotificationTypeAscii.TryGetValue(type, out var ascii) ? ascii : string.Empty; + } +} diff --git a/src/Naprise/NotificationService.cs b/src/Naprise/NotificationService.cs new file mode 100644 index 0000000..dc49136 --- /dev/null +++ b/src/Naprise/NotificationService.cs @@ -0,0 +1,34 @@ +using System; +using System.Linq; +using System.Net.Http; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise +{ + public abstract class NotificationService : INotifier + { + protected NapriseAsset Asset { get; } + protected Func HttpClientFactory { get; } + + protected NotificationService(ServiceConfig config, bool bypassChecks = false) + { + this.Asset = config.Asset; + this.HttpClientFactory = config.HttpClientFactory; + + if (bypassChecks) + return; + + var schemes = this.GetType().GetCustomAttribute()?.Schemes; + + if (schemes is null) + throw new NapriseInvalidUrlException($"Service \"{this.GetType().Name}\" does not have a NapriseNotificationServiceAttribute, this should not happen."); + + if (!schemes.Contains(config.Url.Scheme)) + throw new NapriseInvalidUrlException($"Service \"{this.GetType().Name}\" does not support URL scheme \"{config.Url.Scheme}\"."); + } + + public abstract Task NotifyAsync(Message message, CancellationToken cancellationToken = default); + } +} diff --git a/src/Naprise/PublicAPI.Shipped.txt b/src/Naprise/PublicAPI.Shipped.txt new file mode 100644 index 0000000..7dc5c58 --- /dev/null +++ b/src/Naprise/PublicAPI.Shipped.txt @@ -0,0 +1 @@ +#nullable enable diff --git a/src/Naprise/PublicAPI.Unshipped.txt b/src/Naprise/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..0b136ab --- /dev/null +++ b/src/Naprise/PublicAPI.Unshipped.txt @@ -0,0 +1,196 @@ +abstract Naprise.NotificationService.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Naprise.Color +Naprise.Color.B.get -> byte +Naprise.Color.B.set -> void +Naprise.Color.Color(byte red, byte green, byte blue) -> void +Naprise.Color.Color(int value) -> void +Naprise.Color.Color(string! hex) -> void +Naprise.Color.G.get -> byte +Naprise.Color.G.set -> void +Naprise.Color.Hex.get -> string! +Naprise.Color.Hex.set -> void +Naprise.Color.R.get -> byte +Naprise.Color.R.set -> void +Naprise.Color.Value.get -> int +Naprise.Color.Value.set -> void +Naprise.CompositeNotifier +Naprise.CompositeNotifier.CompositeNotifier(System.Collections.Generic.IReadOnlyList! notifiers) -> void +Naprise.CompositeNotifier.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Naprise.Format +Naprise.Format.Html = 3 -> Naprise.Format +Naprise.Format.Markdown = 2 -> Naprise.Format +Naprise.Format.Text = 1 -> Naprise.Format +Naprise.Format.Unknown = 0 -> Naprise.Format +Naprise.IMessage +Naprise.IMessage.Html.get -> string? +Naprise.IMessage.Markdown.get -> string? +Naprise.IMessage.Text.get -> string? +Naprise.IMessage.Title.get -> string? +Naprise.IMessage.Type.get -> Naprise.MessageType +Naprise.INotifier +Naprise.INotifier.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +Naprise.Message +Naprise.Message.Html.get -> string? +Naprise.Message.Html.set -> void +Naprise.Message.Markdown.get -> string? +Naprise.Message.Markdown.set -> void +Naprise.Message.Message() -> void +Naprise.Message.Message(Naprise.MessageType type = Naprise.MessageType.None, string? title = null, string? text = null, string? markdown = null, string? html = null) -> void +Naprise.Message.Text.get -> string? +Naprise.Message.Text.set -> void +Naprise.Message.ThrowIfEmpty() -> void +Naprise.Message.Title.get -> string? +Naprise.Message.Title.set -> void +Naprise.Message.Type.get -> Naprise.MessageType +Naprise.Message.Type.set -> void +Naprise.MessageType +Naprise.MessageType.Error = 4 -> Naprise.MessageType +Naprise.MessageType.Info = 1 -> Naprise.MessageType +Naprise.MessageType.None = 0 -> Naprise.MessageType +Naprise.MessageType.Success = 2 -> Naprise.MessageType +Naprise.MessageType.Warning = 3 -> Naprise.MessageType +Naprise.Naprise +Naprise.NapriseAsset +Naprise.NapriseAsset.GetAscii(Naprise.MessageType type) -> string! +Naprise.NapriseAsset.GetColor(Naprise.MessageType type) -> Naprise.Color! +Naprise.NapriseAsset.NapriseAsset() -> void +Naprise.NapriseAsset.NotificationTypeAscii.get -> System.Collections.Generic.Dictionary! +Naprise.NapriseAsset.NotificationTypeAscii.set -> void +Naprise.NapriseAsset.NotificationTypeColor.get -> System.Collections.Generic.Dictionary! +Naprise.NapriseAsset.NotificationTypeColor.set -> void +Naprise.NapriseEmptyMessageException +Naprise.NapriseEmptyMessageException.NapriseEmptyMessageException() -> void +Naprise.NapriseEmptyMessageException.NapriseEmptyMessageException(string! message) -> void +Naprise.NapriseEmptyMessageException.NapriseEmptyMessageException(string! message, System.Exception! inner) -> void +Naprise.NapriseException +Naprise.NapriseException.NapriseException() -> void +Naprise.NapriseException.NapriseException(string! message) -> void +Naprise.NapriseException.NapriseException(string! message, System.Exception! inner) -> void +Naprise.NapriseInvalidUrlException +Naprise.NapriseInvalidUrlException.NapriseInvalidUrlException() -> void +Naprise.NapriseInvalidUrlException.NapriseInvalidUrlException(string! message) -> void +Naprise.NapriseInvalidUrlException.NapriseInvalidUrlException(string! message, System.Exception! inner) -> void +Naprise.NapriseNotificationServiceAttribute +Naprise.NapriseNotificationServiceAttribute.DisplayName.get -> string! +Naprise.NapriseNotificationServiceAttribute.NapriseNotificationServiceAttribute(string! displayName, params string![]! schemes) -> void +Naprise.NapriseNotificationServiceAttribute.Schemes.get -> string![]! +Naprise.NapriseNotificationServiceAttribute.SupportHtml.get -> bool +Naprise.NapriseNotificationServiceAttribute.SupportHtml.set -> void +Naprise.NapriseNotificationServiceAttribute.SupportMarkdown.get -> bool +Naprise.NapriseNotificationServiceAttribute.SupportMarkdown.set -> void +Naprise.NapriseNotificationServiceAttribute.SupportText.get -> bool +Naprise.NapriseNotificationServiceAttribute.SupportText.set -> void +Naprise.NapriseNotifyFailedException +Naprise.NapriseNotifyFailedException.NapriseNotifyFailedException() -> void +Naprise.NapriseNotifyFailedException.NapriseNotifyFailedException(string! message) -> void +Naprise.NapriseNotifyFailedException.NapriseNotifyFailedException(string! message, System.Exception! inner) -> void +Naprise.NapriseNotifyFailedException.Notification.get -> Naprise.Message? +Naprise.NapriseNotifyFailedException.Notification.set -> void +Naprise.NapriseNotifyFailedException.Notifier.get -> Naprise.INotifier? +Naprise.NapriseNotifyFailedException.Notifier.set -> void +Naprise.NapriseNotifyFailedException.ResponseBody.get -> string? +Naprise.NapriseNotifyFailedException.ResponseBody.set -> void +Naprise.NapriseNotifyFailedException.ResponseStatus.get -> System.Net.HttpStatusCode? +Naprise.NapriseNotifyFailedException.ResponseStatus.set -> void +Naprise.NapriseUnknownSchemeException +Naprise.NapriseUnknownSchemeException.NapriseUnknownSchemeException() -> void +Naprise.NapriseUnknownSchemeException.NapriseUnknownSchemeException(string! message) -> void +Naprise.NapriseUnknownSchemeException.NapriseUnknownSchemeException(string! message, System.Exception! inner) -> void +Naprise.NotificationMessageExtensions +Naprise.NotificationService +Naprise.NotificationService.Asset.get -> Naprise.NapriseAsset! +Naprise.NotificationService.HttpClientFactory.get -> System.Func! +Naprise.NotificationService.NotificationService(Naprise.ServiceConfig! config, bool bypassChecks = false) -> void +Naprise.NotificationServiceApiDocAttribute +Naprise.NotificationServiceApiDocAttribute.NotificationServiceApiDocAttribute(string! url) -> void +Naprise.NotificationServiceApiDocAttribute.Url.get -> string! +Naprise.NotificationServiceWebsiteAttribute +Naprise.NotificationServiceWebsiteAttribute.NotificationServiceWebsiteAttribute(string! url) -> void +Naprise.NotificationServiceWebsiteAttribute.Url.get -> string! +Naprise.QueryParamsExtensions +Naprise.Service.Apprise +Naprise.Service.Apprise.Apprise(Naprise.ServiceConfig! config) -> void +Naprise.Service.Bark +Naprise.Service.Bark.Bark(Naprise.ServiceConfig! config) -> void +Naprise.Service.Discord +Naprise.Service.Discord.Discord(Naprise.ServiceConfig! config) -> void +Naprise.Service.Gotify +Naprise.Service.Gotify.Gotify(Naprise.ServiceConfig! config) -> void +Naprise.Service.Notica +Naprise.Service.Notica.Notica(Naprise.ServiceConfig! config) -> void +Naprise.Service.NotifyRun +Naprise.Service.NotifyRun.NotifyRun(Naprise.ServiceConfig! config) -> void +Naprise.Service.Ntfy +Naprise.Service.Ntfy.Ntfy(Naprise.ServiceConfig! config) -> void +Naprise.Service.OneBot11 +Naprise.Service.OneBot11.OneBot11(Naprise.ServiceConfig! config) -> void +Naprise.Service.OneBot12 +Naprise.Service.OneBot12.OneBot12(Naprise.ServiceConfig! config) -> void +Naprise.Service.PushDeer +Naprise.Service.PushDeer.PushDeer(Naprise.ServiceConfig! config) -> void +Naprise.Service.PushPlus +Naprise.Service.PushPlus.PushPlus(Naprise.ServiceConfig! config) -> void +Naprise.Service.ServerChan +Naprise.Service.ServerChan.ServerChan(Naprise.ServiceConfig! config) -> void +Naprise.Service.Telegram +Naprise.Service.Telegram.Telegram(Naprise.ServiceConfig! config) -> void +Naprise.ServiceConfig +Naprise.ServiceConfig.ServiceConfig(Flurl.Url! url, Naprise.NapriseAsset! asset, System.Func! httpClientFactory) -> void +Naprise.ServiceRegistry +Naprise.ServiceRegistry.Add(System.Type! service) -> Naprise.ServiceRegistry! +Naprise.ServiceRegistry.Add() -> Naprise.ServiceRegistry! +Naprise.ServiceRegistry.AddDefaultServices() -> Naprise.ServiceRegistry! +Naprise.ServiceRegistry.Asset.get -> Naprise.NapriseAsset! +Naprise.ServiceRegistry.Asset.set -> void +Naprise.ServiceRegistry.Create(System.Collections.Generic.IEnumerable! serviceUrls) -> Naprise.INotifier! +Naprise.ServiceRegistry.HttpClient.get -> System.Net.Http.HttpClient? +Naprise.ServiceRegistry.HttpClient.set -> void +Naprise.ServiceRegistry.IgnoreInvalidUrl.get -> bool +Naprise.ServiceRegistry.IgnoreInvalidUrl.set -> void +Naprise.ServiceRegistry.IgnoreUnknownScheme.get -> bool +Naprise.ServiceRegistry.IgnoreUnknownScheme.set -> void +Naprise.ServiceRegistry.ServiceRegistry() -> void +Naprise.ServiceRegistryExtensions +override Naprise.Service.Apprise.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Bark.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Discord.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Gotify.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Notica.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.NotifyRun.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Ntfy.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.OneBot11.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.OneBot12.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.PushDeer.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.PushPlus.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.ServerChan.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +override Naprise.Service.Telegram.NotifyAsync(Naprise.Message! message, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +readonly Naprise.ServiceConfig.Asset -> Naprise.NapriseAsset! +readonly Naprise.ServiceConfig.HttpClientFactory -> System.Func! +readonly Naprise.ServiceConfig.Url -> Flurl.Url! +static Naprise.Color.implicit operator int(Naprise.Color! color) -> int +static Naprise.Color.implicit operator Naprise.Color!(int value) -> Naprise.Color! +static Naprise.Color.implicit operator Naprise.Color!(string! hex) -> Naprise.Color! +static Naprise.Color.implicit operator string!(Naprise.Color! color) -> string! +static Naprise.Message.implicit operator Naprise.Message!(string? title) -> Naprise.Message! +static Naprise.Naprise.Create(params Flurl.Url![]! urls) -> Naprise.INotifier! +static Naprise.Naprise.Create(params string![]! urls) -> Naprise.INotifier! +static Naprise.Naprise.Create(System.Collections.Generic.IEnumerable! urls) -> Naprise.INotifier! +static Naprise.Naprise.Create(System.Collections.Generic.IEnumerable! urls) -> Naprise.INotifier! +static Naprise.Naprise.DefaultHttpClient.get -> System.Net.Http.HttpClient! +static Naprise.Naprise.DefaultHttpClient.set -> void +static Naprise.Naprise.DefaultRegistry.get -> Naprise.ServiceRegistry! +static Naprise.Naprise.DefaultRegistry.set -> void +static Naprise.Naprise.NoopNotifier.get -> Naprise.INotifier! +static Naprise.NotificationMessageExtensions.GenerateAllBodyFormats(this Naprise.Message! message) -> Naprise.Message! +static Naprise.NotificationMessageExtensions.GetTitleWithFallback(this Naprise.IMessage! message, int maxLengthFromBody = 20) -> string! +static Naprise.NotificationMessageExtensions.PreferHtmlBody(this Naprise.IMessage! message) -> string? +static Naprise.NotificationMessageExtensions.PreferMarkdownBody(this Naprise.IMessage! message) -> string? +static Naprise.NotificationMessageExtensions.PreferTextBody(this Naprise.IMessage! message) -> string? +static Naprise.QueryParamsExtensions.GetBool(this Flurl.QueryParamCollection! query, string! key, bool? defaultValue = null) -> bool? +static Naprise.QueryParamsExtensions.GetInt(this Flurl.QueryParamCollection! query, string! key, int? defaultValue = null) -> int? +static Naprise.QueryParamsExtensions.GetString(this Flurl.QueryParamCollection! query, string! key, string? defaultValue = null) -> string? +static Naprise.QueryParamsExtensions.GetStringArray(this Flurl.QueryParamCollection! query, string! key) -> string![]! +static Naprise.ServiceRegistryExtensions.Create(this Naprise.ServiceRegistry! registry, params Flurl.Url![]! urls) -> Naprise.INotifier! +static Naprise.ServiceRegistryExtensions.Create(this Naprise.ServiceRegistry! registry, params string![]! urls) -> Naprise.INotifier! +static Naprise.ServiceRegistryExtensions.Create(this Naprise.ServiceRegistry! registry, System.Collections.Generic.IEnumerable! urls) -> Naprise.INotifier! +static readonly Naprise.ServiceRegistry.DefaultServices -> System.Collections.Generic.IReadOnlyList! diff --git a/src/Naprise/QueryParamsExtensions.cs b/src/Naprise/QueryParamsExtensions.cs new file mode 100644 index 0000000..7d2c8f8 --- /dev/null +++ b/src/Naprise/QueryParamsExtensions.cs @@ -0,0 +1,57 @@ +using Flurl; +using System; +using System.Linq; + +namespace Naprise +{ + public static class QueryParamsExtensions + { + public static string? GetString(this QueryParamCollection query, string key, string? defaultValue = null) + { + if (!query.TryGetFirst(key, out var value)) + return defaultValue; + + if (value is string str) + return str; + + return value?.ToString(); + } + + public static string[] GetStringArray(this QueryParamCollection query, string key) + => query.GetAll(key) + .SelectMany(x => (x as string)?.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) ?? Array.Empty()) + .ToArray(); + + public static int? GetInt(this QueryParamCollection query, string key, int? defaultValue = null) + { + if (!query.TryGetFirst(key, out var value)) + return defaultValue; + + if (value is int i) + return i; + + return int.TryParse(value as string, out var x) ? x : defaultValue; + } + + public static bool? GetBool(this QueryParamCollection query, string key, bool? defaultValue = null) + { + if (!query.TryGetFirst(key, out var value)) + return defaultValue; + + if (value is null) + return true; + + if (value is bool b) + return b; + + if (value is not string str) + return defaultValue; + + return str.ToLowerInvariant() switch + { + "false" or "0" or "no" => false, + _ => true, + }; + } + } +} diff --git a/src/Naprise/Service/Apprise.cs b/src/Naprise/Service/Apprise.cs new file mode 100644 index 0000000..657ab3a --- /dev/null +++ b/src/Naprise/Service/Apprise.cs @@ -0,0 +1,122 @@ +using Flurl; +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Apprise", "apprise", "apprises", SupportText = true, SupportMarkdown = true, SupportHtml = true)] + [NotificationServiceWebsite("https://github.com/caronc/apprise-api")] + [NotificationServiceApiDoc("https://github.com/caronc/apprise-api#persistent-storage-solution")] + public sealed class Apprise : NotificationService + { + internal readonly bool https; + internal readonly string userinfo; + internal readonly string hostAndPort; + internal readonly string token; + internal readonly Format format = Format.Unknown; + internal readonly string? tag; + + public Apprise(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // apprise(s)://{user}:{password}@{host}:{port}/{token} + + var url = config.Url; + var segment = url.PathSegments; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid Apprise URL. Expected format: apprise(s)://{user}:{password}@{host}:{port}/{token}"); + + this.token = segment[0]; + + if (string.IsNullOrWhiteSpace(this.token)) + throw new NapriseInvalidUrlException("Invalid Apprise URL. Expected format: apprise(s)://{user}:{password}@{host}:{port}/{token}"); + + this.https = url.Scheme == "apprises"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.userinfo = url.UserInfo; + this.tag = url.QueryParams.GetString("tag"); + this.format = url.QueryParams.GetString("format") switch + { + "text" => Format.Text, + "markdown" => Format.Markdown, + "html" => Format.Html, + _ => Format.Unknown, + }; + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + Title = message.Title, + Body = this.format switch + { + Format.Text => message.PreferTextBody(), + Format.Markdown => message.PreferMarkdownBody(), + Format.Html => message.PreferHtmlBody(), + _ => message.PreferMarkdownBody(), + }, + Format = this.format switch + { + Format.Text => "text", + Format.Markdown => "markdown", + Format.Html => "html", + _ => "markdown", + }, + Type = message.Type switch + { + MessageType.Info => "info", + MessageType.Success => "success", + MessageType.Warning => "warning", + MessageType.Error => "failure", + MessageType.None or _ => null, + }, + Tag = this.tag + }; + + var target = new Url("http://" + this.hostAndPort) + .AppendPathSegment("notify") + .AppendPathSegment(this.token); + + if (this.https) + target.Scheme = "https"; + + var req = new HttpRequestMessage(method: HttpMethod.Post, requestUri: target.ToString()) + { + Content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingOptions), + }; + + if (!string.IsNullOrEmpty(this.userinfo)) + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(this.userinfo))); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().SendAsync(req, cancellationToken); + + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to Apprise: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + + private class Payload + { + public string? Body { get; set; } + public string? Title { get; set; } + public string? Type { get; set; } + public string? Tag { get; set; } + public string? Format { get; set; } + } + } +} diff --git a/src/Naprise/Service/Bark.cs b/src/Naprise/Service/Bark.cs new file mode 100644 index 0000000..285cbc2 --- /dev/null +++ b/src/Naprise/Service/Bark.cs @@ -0,0 +1,116 @@ +using Flurl; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Bark", "bark", "barks", SupportText = true)] + [NotificationServiceWebsite("https://github.com/Finb/Bark")] + [NotificationServiceApiDoc("https://github.com/Finb/bark-server/blob/master/docs/API_V2.md")] + public sealed class Bark : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string token; + internal readonly string? click_url; + internal readonly string? group; + internal readonly string? icon; + internal readonly string? level; + internal readonly string? sound; + + public Bark(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // bark://{host}/{token} + // bark://{host}:{port}/{token} + // barks://{host}/{token} + // barks://{host}:{port}/{token} + // bark://{host}/{token}?url={url} + // bark://{host}/{token}?group={group} + // bark://{host}/{token}?icon={icon} + // bark://{host}/{token}?level={level} + // bark://{host}/{token}?sound={sound} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid Bark URL. Expected format: bark://{host}/{token}"); + + this.https = url.Scheme == "barks"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.token = segment[0]; + this.click_url = query.GetString("url"); + this.group = query.GetString("group"); + this.icon = query.GetString("icon"); + this.level = query.GetString("level"); + this.sound = query.GetString("sound"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + DeviceKey = token, + Url = click_url, + Group = group, + Level = level, + Icon = icon, + Sound = sound, + }; + + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + { + b.AppendLine(message.Title); + payload.Title = b.ToString(); + b.Clear(); + } + + if (payload.Title is not null) + { + payload.Body = message.PreferTextBody(); + } + else + { + b.Append(message.PreferTextBody()); + payload.Body = b.ToString(); + } + + var req = new Url($"{(this.https ? "https" : "http")}://{this.hostAndPort}/").AppendPathSegment("push"); + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(req.ToString(), content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var respText = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Bark)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + public string? Title { get; set; } + public string? Body { get; set; } + public string? DeviceKey { get; set; } + public string? Level { get; set; } + public string? Sound { get; set; } + public string? Icon { get; set; } + public string? Group { get; set; } + public string? Url { get; set; } + } + } +} diff --git a/src/Naprise/Service/Discord.cs b/src/Naprise/Service/Discord.cs new file mode 100644 index 0000000..6dd10c4 --- /dev/null +++ b/src/Naprise/Service/Discord.cs @@ -0,0 +1,91 @@ +using Flurl; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Discord", "discord", SupportText = true, SupportMarkdown = true)] + [NotificationServiceWebsite("https://discord.com")] + [NotificationServiceApiDoc("https://discord.com/developers/docs/resources/webhook#execute-webhook")] + public sealed class Discord : NotificationService + { + internal readonly string webhookId; + internal readonly string webhookToken; + + internal readonly string? username; + internal readonly string? avatarUrl; + internal readonly bool? tts; + + public Discord(ServiceConfig config) : base(config: config, bypassChecks: false) + { + var url = config.Url; + var segments = url.PathSegments; + var queryParams = url.QueryParams; + + if (segments.Count != 1) + throw new NapriseInvalidUrlException("Invalid Discord URL. Expected format: discord://{webhookId}/{webhookToken}"); + + this.webhookId = url.Host; + this.webhookToken = segments[0]; + + this.username = queryParams.GetString("username"); + this.avatarUrl = queryParams.GetString("avatar_url"); + this.tts = queryParams.GetBool("tts", false); + } + + // https://discord.com/developers/docs/resources/webhook#execute-webhook + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + message.ThrowIfEmpty(); + + var payload = new DiscordWebhookPayload + { + Username = this.username, + AvatarUrl = this.avatarUrl, + Tts = this.tts, + Embeds = new Embed[1], + }; + + payload.Embeds[0] = new Embed + { + Title = message.Title, + Description = message.PreferMarkdownBody(), + Color = message.Type == MessageType.None ? null : this.Asset.GetColor(message.Type).Value, + }; + + var url = new Url("https://discord.com/api/webhooks/").AppendPathSegments(this.webhookId, this.webhookToken); + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to Discord: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + + private class DiscordWebhookPayload + { + public string? AvatarUrl { get; set; } + public string? Username { get; set; } + public bool? Tts { get; set; } + public string? Content { get; set; } + public Embed[]? Embeds { get; set; } + } + + private class Embed + { + public string? Title { get; set; } + public string? Description { get; set; } + public int? Color { get; set; } + } + } +} diff --git a/src/Naprise/Service/Gotify.cs b/src/Naprise/Service/Gotify.cs new file mode 100644 index 0000000..8c69d3a --- /dev/null +++ b/src/Naprise/Service/Gotify.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Gotify", "gotify", "gotifys", SupportText = true, SupportMarkdown = true)] + [NotificationServiceWebsite("https://gotify.net/")] + [NotificationServiceApiDoc("https://gotify.net/docs/pushmsg")] + public sealed class Gotify : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string token; + internal readonly int? priority; + internal readonly string? clickUrl; + + public Gotify(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // gotify://{hostname}/{token} + // gotifys://{hostname}/{token} + // gotifys://{hostname}:{port}/{token} + // gotifys://{hostname}/{path}/{token} + // gotifys://{hostname}:{port}/{path}/{token} + // gotifys://{hostname}/{token}/?priority=5 + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid Gotify URL. Expected format: gotify(s)://{hostname}/{token}"); + + this.https = url.Scheme == "gotifys"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.token = segment[0]; + + if (string.IsNullOrWhiteSpace(this.token)) + throw new NapriseInvalidUrlException("Invalid Gotify URL. Expected format: gotify(s)://{hostname}/{token}"); + + this.priority = query.GetInt("priority"); + this.clickUrl = query.GetString("click_url"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + Title = message.Title, + Priority = priority, + Extras = new Dictionary>() + }; + + if (message.Markdown is not null) + { + payload.Message = message.Markdown; + payload.Extras.Add("client::display", new Dictionary { ["contentType"] = "text/markdown" }); + } + else + { + payload.Message = message.PreferTextBody(); + payload.Extras.Add("client::display", new Dictionary { ["contentType"] = "text/plain" }); + } + + if (payload.Message is null) + { + payload.Message = payload.Title ?? string.Empty; + payload.Title = string.Empty; + } + + payload.Title = message.Type switch + { + MessageType.None => payload.Title, + MessageType t => this.Asset.GetAscii(t) + " " + payload.Title, + }; + + if (this.clickUrl is not null) + { + payload.Extras.Add("client::notification", new Dictionary { ["click"] = new { url = this.clickUrl } }); + } + + var url = $"{(this.https ? "https" : "http")}://{this.hostAndPort}/message?token={this.token}"; + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Gotify)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + + private class Payload + { + public string? Title { get; set; } + public string? Message { get; set; } + public int? Priority { get; set; } + public Dictionary>? Extras { get; set; } + } + } +} diff --git a/src/Naprise/Service/Notica.cs b/src/Naprise/Service/Notica.cs new file mode 100644 index 0000000..5908948 --- /dev/null +++ b/src/Naprise/Service/Notica.cs @@ -0,0 +1,75 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Notica", "notica", "noticas", SupportText = true)] + [NotificationServiceWebsite("https://notica.us/")] + [NotificationServiceApiDoc("https://notica.us/")] + public sealed class Notica : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string userinfo; + internal readonly string token; + + public Notica(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // notica(s)://{host}/{token} + // notica(s)://{host}:{port}/{token} + // notica(s)://{user}@{host}/{token} + // notica(s)://{user}@{host}:{port}/{token} + // notica(s)://{user}:{password}@{host}/{token} + // notica(s)://{user}:{password}@{host}:{port}/{token} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid Notica URL. Expected format: notica(s)://{user}:{password}@{host}:{port}/{token}"); + + this.https = url.Scheme == "noticas"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.token = segment[0]; + this.userinfo = url.UserInfo; + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder("d:"); + + b.Append(this.Asset.GetAscii(message.Type)); + + if (b.Length > 2) + b.Append(' '); + + if (message.Title is not null) + b.AppendLine(message.Title); + + b.Append(message.PreferTextBody()); + + var url = $"{(this.https ? "https" : "http")}://{this.hostAndPort}/?{this.token}"; + + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(b.ToString())); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Notica)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + } +} diff --git a/src/Naprise/Service/NotifyRun.cs b/src/Naprise/Service/NotifyRun.cs new file mode 100644 index 0000000..0c15a86 --- /dev/null +++ b/src/Naprise/Service/NotifyRun.cs @@ -0,0 +1,74 @@ +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("notify.run", "notifyrun", "notifyruns", SupportText = true)] + [NotificationServiceWebsite("https://notify.run/")] + [NotificationServiceApiDoc("https://notify.run/")] + public sealed class NotifyRun : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string userinfo; + internal readonly string channel; + + public NotifyRun(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // notifyrun(s)://{host}/{channel} + // notifyrun(s)://{host}:{port}/{channel} + // notifyrun(s)://{user}@{host}/{channel} + // notifyrun(s)://{user}@{host}:{port}/{channel} + // notifyrun(s)://{user}:{password}@{host}/{channel} + // notifyrun(s)://{user}:{password}@{host}:{port}/{channel} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid NotifyRun URL. Expected format: notifyrun(s)://{user}:{password}@{host}:{port}/{channel}"); + + this.https = url.Scheme == "notifyruns"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.channel = segment[0]; + this.userinfo = url.UserInfo; + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + b.AppendLine(message.Title); + + b.Append(message.PreferTextBody()); + + var url = $"{(this.https ? "https" : "http")}://{this.hostAndPort}/{this.channel}"; + + var content = new ByteArrayContent(Encoding.UTF8.GetBytes(b.ToString())); + content.Headers.ContentType = MediaTypeHeaderValue.Parse("application/x-www-form-urlencoded"); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(NotifyRun)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + } +} diff --git a/src/Naprise/Service/Ntfy.cs b/src/Naprise/Service/Ntfy.cs new file mode 100644 index 0000000..3e9c85c --- /dev/null +++ b/src/Naprise/Service/Ntfy.cs @@ -0,0 +1,106 @@ +using System; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("ntfy.sh", "ntfy", "ntfys", SupportText = true)] + [NotificationServiceWebsite("https://ntfy.sh/")] + [NotificationServiceApiDoc("https://docs.ntfy.sh/publish/")] + public sealed class Ntfy : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string userinfo; + internal readonly string topic; + internal readonly string[] tags; + internal readonly int? priority; + internal readonly string? click; + internal readonly string? delay; + internal readonly string? email; + + public Ntfy(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // ntfy(s)://{user}:{password}@{host}:{port}/{topic} + // ntfy://{host}/{topic} + // ntfys://{host}/{topic} + // ntfy://{user:password}@{host}/{topic} + // ntfy://{host}:{port}/{topic} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid Ntfy URL. Expected format: ntfy(s)://{user}:{password}@{host}:{port}/{topic}"); + + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.topic = segment[0]; + + if (string.IsNullOrWhiteSpace(this.topic)) + throw new NapriseInvalidUrlException("Invalid Ntfy URL. Expected format: ntfy(s)://{user}:{password}@{host}:{port}/{topic}"); + + this.https = url.Scheme == "ntfys"; + this.userinfo = url.UserInfo; + this.tags = query.GetStringArray("tags"); + this.priority = query.GetInt("priority"); + this.click = query.GetString("click"); + this.delay = query.GetString("delay"); + this.email = query.GetString("email"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + Topic = topic, + Message = message.PreferTextBody(), + Title = message.Title, + Tags = tags, + Priority = priority, + Click = click, + Delay = delay, + Email = email, + }; + + var req = new HttpRequestMessage(HttpMethod.Post, $"{(this.https ? "https" : "http")}://{this.hostAndPort}") + { + Content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingOptions) + }; + + if (!string.IsNullOrEmpty(this.userinfo)) + req.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.UTF8.GetBytes(this.userinfo))); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().SendAsync(req, cancellationToken); + + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to Ntfy: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + + private class Payload + { + public string? Topic { get; set; } + public string? Message { get; set; } + public string? Title { get; set; } + public string[]? Tags { get; set; } + public int? Priority { get; set; } + public string? Click { get; set; } + public string? Delay { get; set; } + public string? Email { get; set; } + } + } +} diff --git a/src/Naprise/Service/OneBot11.cs b/src/Naprise/Service/OneBot11.cs new file mode 100644 index 0000000..a074332 --- /dev/null +++ b/src/Naprise/Service/OneBot11.cs @@ -0,0 +1,143 @@ +using System; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("OneBot 11", "onebot11", "onebot11s", SupportText = true)] + [NotificationServiceWebsite("https://11.onebot.dev/")] + [NotificationServiceApiDoc("https://github.com/botuniverse/onebot-11/blob/master/api/public.md#send_msg-%E5%8F%91%E9%80%81%E6%B6%88%E6%81%AF")] + public sealed class OneBot11 : NotificationService + { + private static readonly string ContentType = "application/json"; + + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string access_token; + internal readonly string message_type; + internal readonly string? user_id; + internal readonly string? group_id; + + public OneBot11(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // onebot11://{access_token}@{host}:{port}/private/{user_id} + // onebot11://{access_token}@{host}:{port}/group/{group_id} + + var url = config.Url; + var segment = url.PathSegments; + + if (segment.Count != 2) + throw new NapriseInvalidUrlException("Invalid OneBot11 URL. Expected format: onebot11://{access_token}@{host}:{port}/{private_or_group}/{id}"); + + this.https = url.Scheme == "onebot11s"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.access_token = url.UserInfo; + + this.message_type = segment[0]; + + switch (this.message_type) + { + case "private": + this.user_id = segment[1]; + if (string.IsNullOrEmpty(this.user_id)) + throw new NapriseInvalidUrlException("Invalid OneBot11 URL. Expected format: onebot11://{access_token}@{host}:{port}/private/{user_id}"); + break; + case "group": + this.group_id = segment[1]; + if (string.IsNullOrEmpty(this.group_id)) + throw new NapriseInvalidUrlException("Invalid OneBot11 URL. Expected format: onebot11://{access_token}@{host}:{port}/group/{group_id}"); + break; + default: + throw new NapriseInvalidUrlException("Invalid OneBot11 URL. Supported message_type: private, group"); + } + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + b.AppendLine(message.Title); + + b.Append(message.PreferTextBody()); + + var payload = new Payload + { + MessageType = this.message_type, + Message = b.ToString(), + }; + + switch (this.message_type) + { + case "private": + payload.UserId = this.user_id; + break; + case "group": + payload.GroupId = this.group_id; + break; + default: + break; + } + + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType); + var url = $"{(this.https ? "https" : "http")}://{this.hostAndPort}"; + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + var respText = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot11)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + + try + { + var jobj = JsonDocument.Parse(respText); + var status = jobj.RootElement.GetProperty("status").GetString(); + if (status is not "ok" and not "async") + { + var respMessage = jobj.RootElement.GetProperty("retcode").GetString(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot11)}: \"{respMessage}\"") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + catch (Exception ex) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot11)}", ex) + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + public string? MessageType { get; set; } + public string? UserId { get; set; } + public string? GroupId { get; set; } + public string? Message { get; set; } + } + } +} diff --git a/src/Naprise/Service/OneBot12.cs b/src/Naprise/Service/OneBot12.cs new file mode 100644 index 0000000..9002027 --- /dev/null +++ b/src/Naprise/Service/OneBot12.cs @@ -0,0 +1,190 @@ +using System; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("OneBot 12", "onebot12", "onebot12s", SupportText = true)] + [NotificationServiceWebsite("https://12.onebot.dev/")] + [NotificationServiceApiDoc("https://12.onebot.dev/interface/message/actions/#send_message")] + public sealed class OneBot12 : NotificationService + { + private static readonly string ContentType = "application/json"; + + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string access_token; + internal readonly string detail_type; + internal readonly string? user_id; + internal readonly string? group_id; + internal readonly string? guild_id; + internal readonly string? channel_id; + + public OneBot12(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // onebot12://{access_token}@{host}:{port}/private/{user_id} + // onebot12://{access_token}@{host}:{port}/group/{group_id} + // onebot12://{access_token}@{host}:{port}/channel/{guild_id}/{channel_id} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count is < 1 or > 3) + throw new NapriseInvalidUrlException("Invalid OneBot12 URL. Expected format: onebot12://{access_token}@{host}:{port}/{detail_type}/{id}..."); + + this.https = url.Scheme == "onebot12s"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.access_token = url.UserInfo; + + this.detail_type = segment[0]; + + switch (this.detail_type) + { + case "private": + if (segment.Count != 2 || string.IsNullOrEmpty(segment[1])) + throw new NapriseInvalidUrlException("Invalid OneBot12 URL. Expected format: onebot12://{access_token}@{host}:{port}/private?user_id={user_id}"); + this.user_id = segment[1]; + break; + case "group": + if (segment.Count != 2 || string.IsNullOrEmpty(segment[1])) + throw new NapriseInvalidUrlException("Invalid OneBot12 URL. Expected format: onebot12://{access_token}@{host}:{port}/group?group_id={group_id}"); + this.group_id = segment[1]; + break; + case "channel": + if (segment.Count != 3 || string.IsNullOrEmpty(segment[1]) || string.IsNullOrEmpty(segment[2])) + throw new NapriseInvalidUrlException("Invalid OneBot12 URL. Expected format: onebot12://{access_token}@{host}:{port}/channel?guild_id={guild_id}&channel_id={channel_id}"); + this.guild_id = segment[1]; + this.channel_id = segment[2]; + break; + default: + throw new NapriseInvalidUrlException("Invalid OneBot12 URL. Supported detail_type: private, group, channel"); + } + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + b.AppendLine(message.Title); + + b.Append(message.PreferTextBody()); + + var payload = new Payload + { + Params = new Params + { + DetailType = this.detail_type, + Message = new Msg[1], + } + }; + + switch (this.detail_type) + { + case "private": + payload.Params.UserId = this.user_id; + break; + case "group": + payload.Params.GroupId = this.group_id; + break; + case "channel": + payload.Params.ChannelId = this.channel_id; + payload.Params.GuildId = this.guild_id; + break; + default: + break; + } + + payload.Params.Message[0] = new Msg + { + Type = "text", + Data = new MsgData + { + Text = b.ToString(), + } + }; + + var url = $"{(this.https ? "https" : "http")}://{this.hostAndPort}"; + + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + content.Headers.ContentType = MediaTypeHeaderValue.Parse(ContentType); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + var respText = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot12)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + + try + { + var jobj = JsonDocument.Parse(respText); + var status = jobj.RootElement.GetProperty("status").GetString(); + if (status != "ok") + { + var respMessage = jobj.RootElement.GetProperty("message").GetString(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot12)}: \"{respMessage}\"") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + catch (Exception ex) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(OneBot12)}", ex) + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + public string Action { get; set; } = "send_message"; + public Params? Params { get; set; } + } + + private class Params + { + public string? DetailType { get; set; } + public string? UserId { get; set; } + public string? GroupId { get; set; } + public string? GuildId { get; set; } + public string? ChannelId { get; set; } + public Msg[]? Message { get; set; } + } + + private class Msg + { + public string? Type { get; set; } + public MsgData? Data { get; set; } + + } + + private class MsgData + { + public string? Text { get; set; } + } + } +} diff --git a/src/Naprise/Service/PushDeer.cs b/src/Naprise/Service/PushDeer.cs new file mode 100644 index 0000000..3ad53db --- /dev/null +++ b/src/Naprise/Service/PushDeer.cs @@ -0,0 +1,94 @@ +using Flurl; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("PushDeer", "pushdeer", "pushdeers", SupportText = true, SupportMarkdown = true)] + [NotificationServiceWebsite("https://www.pushdeer.com/")] + [NotificationServiceApiDoc("https://www.pushdeer.com/dev.html#%E6%8E%A8%E9%80%81%E6%B6%88%E6%81%AF")] + public sealed class PushDeer : NotificationService + { + internal readonly bool https; + internal readonly string hostAndPort; + internal readonly string userinfo; + internal readonly string pushkey; + + public PushDeer(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // pushdeer://{host}/{pushkey} + // pushdeers://{host}/{pushkey} + // pushdeer://{host}:{port}/{pushkey} + // pushdeers://{host}:{port}/{pushkey} + // pushdeer://{userinfo}@{host}/{pushkey} + // pushdeers://{userinfo}@{host}/{pushkey} + // pushdeer://{userinfo}@{host}:{port}/{pushkey} + // pushdeers://{userinfo}@{host}:{port}/{pushkey} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 1) + throw new NapriseInvalidUrlException("Invalid PushDeer URL. Expected format: pushdeer://{host}/{pushkey}"); + + this.https = url.Scheme == "pushdeers"; + this.hostAndPort = url.Port is null ? url.Host : $"{url.Host}:{url.Port}"; + this.userinfo = url.UserInfo; + this.pushkey = segment[0]; + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + if (b.Length > 0) + b.Append(' '); + + string? title = null; + if (message.Title is not null) + { + b.AppendLine(message.Title); + title = b.ToString(); + b.Clear(); + } + + bool isMarkdown; + + if (message.Markdown is not null) + { + isMarkdown = true; + b.Append(message.Markdown); + } + else + { + isMarkdown = false; + b.Append(message.PreferTextBody()); + } + + var url = new Url($"{(this.https ? "https" : "http")}://{this.hostAndPort}").AppendPathSegments("message", "push").SetQueryParams(new + { + this.pushkey, + type = isMarkdown ? "markdown" : "text", + text = title ?? b.ToString(), + }); + if (title is not null) + url.SetQueryParam("desp", b.ToString()); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url.ToString(), null, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var respText = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(PushDeer)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + } +} diff --git a/src/Naprise/Service/PushPlus.cs b/src/Naprise/Service/PushPlus.cs new file mode 100644 index 0000000..b241fdc --- /dev/null +++ b/src/Naprise/Service/PushPlus.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("PushPlus", "pushplus", SupportText = true, SupportMarkdown = true, SupportHtml = false)] + [NotificationServiceWebsite("https://www.pushplus.plus/")] + [NotificationServiceApiDoc("https://www.pushplus.plus/doc/guide/api.html")] + public sealed class PushPlus : NotificationService + { + private static readonly IReadOnlyList ValidChannels = new[] { "wechat", "webhook", "cp", "mail", "sms" }; + + internal readonly string token; + internal readonly string channel; + + public PushPlus(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // pushplus://{token}@{channel} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 0) + throw new NapriseInvalidUrlException("Invalid PushPlus URL. Expected format: pushplus://{token}@{channel}"); + + this.token = url.UserInfo; + this.channel = url.Host; + + if (!ValidChannels.Contains(this.channel)) + throw new NapriseInvalidUrlException($"Invalid PushPlus URL. Channel must be one of: {string.Join(", ", ValidChannels)}"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + Token = token, + Channel = channel, + }; + + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + { + b.AppendLine(message.Title); + payload.Title = b.ToString(); + b.Clear(); + } + + static void SelectBody(Message message, out string type, out string body) + { + if (message.Markdown is not null) + { + type = "markdown"; + body = message.Markdown; + } + else if (message.Text is not null) + { + type = "txt"; + body = message.Text; + } + else if (message.Html is not null) + { + type = "html"; + body = message.Html; + } + else + { + type = "txt"; + body = string.Empty; + } + } + + if (payload.Title is not null) + { + SelectBody(message, out var type, out var body); + payload.Template = type; + payload.Content = body; + } + else + { + SelectBody(message, out var type, out var body); + payload.Template = type; + if (type == "html") + { + payload.Content = b.ToString(); + } + else + { + b.Append(body); + payload.Content = b.ToString(); + } + } + + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync("https://www.pushplus.plus/send", content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var respText = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(PushPlus)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + public string? Token { get; set; } + public string? Title { get; set; } + public string? Content { get; set; } + public string? Template { get; set; } + public string? Channel { get; set; } + } + } +} diff --git a/src/Naprise/Service/ServerChan.cs b/src/Naprise/Service/ServerChan.cs new file mode 100644 index 0000000..8180ead --- /dev/null +++ b/src/Naprise/Service/ServerChan.cs @@ -0,0 +1,75 @@ +using Flurl; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("ServerChan", "serverchan", SupportText = true, SupportMarkdown = true)] + [NotificationServiceWebsite("https://sct.ftqq.com/")] + [NotificationServiceApiDoc("https://sct.ftqq.com/sendkey")] + public sealed class ServerChan : NotificationService + { + internal readonly string token; + internal readonly string? channel; + internal readonly string? openid; + + public ServerChan(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // serverchan://{token}@serverchan + // serverchan://{token}@serverchan?channel={channel} + // serverchan://{token}@serverchan?openid={openid} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 0) + throw new NapriseInvalidUrlException("Invalid ServerChan URL. Expected format: serverchan://{token}@serverchan"); + + this.token = url.UserInfo; + + if (string.IsNullOrEmpty(this.token)) + throw new NapriseInvalidUrlException("Invalid ServerChan URL. Expected format: serverchan://{token}@serverchan"); + + this.channel = query.GetString("channel"); + this.openid = query.GetString("openid"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var payload = new Payload + { + Title = message.GetTitleWithFallback(maxLengthFromBody: 32), + Desp = message.PreferMarkdownBody(), + Channel = channel, + Openid = openid, + }; + + var url = new Url("https://sctapi.ftqq.com").AppendPathSegment($"{this.token}.send"); + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url.ToString(), content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var respText = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(ServerChan)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + public string? Title { get; set; } + public string? Desp { get; set; } + public string? Channel { get; set; } + public string? Openid { get; set; } + } + } +} diff --git a/src/Naprise/Service/Telegram.cs b/src/Naprise/Service/Telegram.cs new file mode 100644 index 0000000..734dc29 --- /dev/null +++ b/src/Naprise/Service/Telegram.cs @@ -0,0 +1,134 @@ +using Flurl; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http.Json; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Telegram", "telegram", SupportText = true)] + [NotificationServiceWebsite("https://telegram.org/")] + [NotificationServiceApiDoc("https://core.telegram.org/bots/api#sendmessage")] + public sealed class Telegram : NotificationService + { + private static readonly IReadOnlyList ValidParseMode = new[] { "MarkdownV2", "HTML", "Markdown" }; + + internal readonly string token; + internal readonly string chat_id; + internal readonly string api_host; + internal readonly string? message_thread_id; + internal readonly string? parse_mode; + internal readonly bool disable_web_page_preview; + internal readonly bool disable_notification; + internal readonly bool protect_content; + + public Telegram(ServiceConfig config) : base(config: config, bypassChecks: false) + { + // telegram://{token}@{chat_id} + // telegram://{token}@{chat_id}?api_host={api_host} + // telegram://{token}@{chat_id}?message_thread_id={message_thread_id} + // telegram://{token}@{chat_id}?parse_mode={parse_mode} + // telegram://{token}@{chat_id}?disable_web_page_preview={disable_web_page_preview} + // telegram://{token}@{chat_id}?disable_notification={disable_notification} + // telegram://{token}@{chat_id}?protect_content={protect_content} + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + if (segment.Count != 0) + throw new NapriseInvalidUrlException("Invalid Telegram URL. Expected format: telegram://{token}@{chat_id}"); + + this.token = url.UserInfo; + if (string.IsNullOrWhiteSpace(this.token)) + throw new NapriseInvalidUrlException("Invalid Telegram URL. Expected format: telegram://{token}@{chat_id}"); + + + var host = url.Host; + if (string.IsNullOrWhiteSpace(host)) + throw new NapriseInvalidUrlException("Invalid Telegram URL. Expected format: telegram://{token}@{chat_id}"); + this.chat_id = long.TryParse(host, out var _) ? host : "@" + host; + + this.api_host = query.GetString("api_host") ?? "https://api.telegram.org"; + this.message_thread_id = query.GetString("message_thread_id"); + this.parse_mode = query.GetString("parse_mode"); + + if (this.parse_mode is not null && !ValidParseMode.Any(x => x.Equals(this.parse_mode, StringComparison.OrdinalIgnoreCase))) + throw new NapriseInvalidUrlException("Invalid Telegram URL. parse_mode must be one of the following: " + string.Join(", ", ValidParseMode)); + + this.disable_web_page_preview = query.GetBool("disable_web_page_preview") ?? false; + this.disable_notification = query.GetBool("disable_notification") ?? false; + this.protect_content = query.GetBool("protect_content") ?? false; + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + var b = new StringBuilder(); + b.Append(this.Asset.GetAscii(message.Type)); + + if (b.Length > 0) + b.Append(' '); + + if (message.Title is not null) + b.AppendLine(message.Title); + + if (this.parse_mode is null) + { + b.AppendLine(message.PreferTextBody()); + } + else if (this.parse_mode.StartsWith("markdown", StringComparison.OrdinalIgnoreCase)) + { + b.AppendLine(message.PreferMarkdownBody()); + } + else + { + if (message.Html is not null) + b.AppendLine(message.Html); + else + b.AppendLine(message.PreferTextBody()); + } + + var payload = new Payload + { + ChatId = this.chat_id, + MessageThreadId = this.message_thread_id, + Text = b.ToString(), + ParseMode = this.parse_mode, + DisableWebPagePreview = this.disable_web_page_preview, + DisableNotification = this.disable_notification, + ProtectContent = this.protect_content, + }; + + var url = new Url(this.api_host).AppendPathSegments($"bot{this.token}", "sendMessage").ToString(); + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingIngoreNullOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + if (!resp.IsSuccessStatusCode) + { + var text = await resp.Content.ReadAsStringAsync(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Telegram)}: {resp.StatusCode}") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = text, + }; + } + } + + private class Payload + { + public string? ChatId { get; set; } + public string? MessageThreadId { get; set; } + public string? Text { get; set; } + public string? ParseMode { get; set; } + public bool DisableWebPagePreview { get; set; } + public bool DisableNotification { get; set; } + public bool ProtectContent { get; set; } + } + } +} diff --git a/src/Naprise/Service/Template.cs b/src/Naprise/Service/Template.cs new file mode 100644 index 0000000..4630513 --- /dev/null +++ b/src/Naprise/Service/Template.cs @@ -0,0 +1,91 @@ +using Flurl; +using System; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Naprise.Service +{ + [NapriseNotificationService("Template", "", SupportText = true, SupportMarkdown = true, SupportHtml = false)] // TODO fill in blank + [NotificationServiceWebsite("")] // TODO fill in blank + [NotificationServiceApiDoc("")] // TODO fill in blank + // TODO change visibility and class name + internal sealed class Template : NotificationService + { + // TODO change class name + public Template(ServiceConfig config) : base(config: config, bypassChecks: false) + { + + var url = config.Url; + var segment = url.PathSegments; + var query = url.QueryParams; + + // fill in all the blanks and change visibility to public + throw new InvalidProgramException("this is a template for adding new notification services"); + } + + public override async Task NotifyAsync(Message message, CancellationToken cancellationToken = default) + { + message.ThrowIfEmpty(); + + // TODO build the message body + + var payload = new Payload + { + // TODO fill payload + // TODO check message.Type + }; + + var url = new Url($"{(true ? "https" : "http")}://{"localhost"}").AppendPathSegments("example"); + var content = JsonContent.Create(payload, options: SharedJsonOptions.SnakeCaseNamingOptions); + + cancellationToken.ThrowIfCancellationRequested(); + var resp = await this.HttpClientFactory().PostAsync(url, content, cancellationToken); + var respText = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Template)}: {resp.StatusCode}") // TODO change class name + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + + try + { + var jobj = JsonDocument.Parse(respText); + // TODO parse response and check if it's successful + var status = jobj.RootElement.GetProperty("status").GetString(); + if (status != "ok") + { + var respMessage = jobj.RootElement.GetProperty("message").GetString(); + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Template)}: \"{respMessage}\"") + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + catch (Exception ex) + { + throw new NapriseNotifyFailedException($"Failed to send notification to {nameof(Template)}", ex) + { + Notifier = this, + Notification = message, + ResponseStatus = resp.StatusCode, + ResponseBody = respText, + }; + } + } + + private class Payload + { + // TODO add payload + } + } +} diff --git a/src/Naprise/ServiceConfig.cs b/src/Naprise/ServiceConfig.cs new file mode 100644 index 0000000..107bd9c --- /dev/null +++ b/src/Naprise/ServiceConfig.cs @@ -0,0 +1,20 @@ +using Flurl; +using System; +using System.Net.Http; + +namespace Naprise +{ + public class ServiceConfig + { + public readonly Url Url; + public readonly NapriseAsset Asset; + public readonly Func HttpClientFactory; + + public ServiceConfig(Url url, NapriseAsset asset, Func httpClientFactory) + { + this.Url = url ?? throw new ArgumentNullException(nameof(url)); + this.Asset = asset ?? throw new ArgumentNullException(nameof(asset)); + this.HttpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); + } + } +} diff --git a/src/Naprise/ServiceRegistry.cs b/src/Naprise/ServiceRegistry.cs new file mode 100644 index 0000000..8215845 --- /dev/null +++ b/src/Naprise/ServiceRegistry.cs @@ -0,0 +1,202 @@ +using Flurl; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Net.Http; +using System.Reflection; + +namespace Naprise +{ + public sealed class ServiceRegistry + { + private static readonly Type[] constructorArgumentTypes = new[] { typeof(ServiceConfig) }; + private static readonly ParameterExpression serviceConfigParameterExpression = Expression.Parameter(typeof(ServiceConfig), "config"); + + public static readonly IReadOnlyList DefaultServices; + + static ServiceRegistry() + { + DefaultServices = typeof(ServiceRegistry).Assembly.GetTypes() + .Where(t => t.Namespace == "Naprise.Service" + && t.IsPublic + && t.IsSealed + && t.IsSubclassOf(typeof(NotificationService)) + && t.GetCustomAttribute() != null) + .ToArray(); + } + + private readonly Dictionary> services = new(); + + public ServiceRegistry() + { + } + + /// + /// Silently ignore invalid URLs, return instead of throwing . + /// Default is . + /// + public bool IgnoreUnknownScheme { get; set; } = false; + + /// + /// Silently ignore invalid URLs, catches and returns instead. + /// Default is . + /// + public bool IgnoreInvalidUrl { get; set; } = false; + + /// + /// Set the for this registry, + /// will use by default. + /// + public HttpClient? HttpClient { get; set; } + + public NapriseAsset Asset { get; set; } = new(); + + public ServiceRegistry AddDefaultServices() + { + foreach (var type in DefaultServices) + this.Add(type); + + return this; + } + + public ServiceRegistry Add() where T : INotifier => this.Add(typeof(T)); + + public ServiceRegistry Add(Type service) + { + if (!typeof(INotifier).IsAssignableFrom(service)) + throw new ArgumentException("Service must be a INotifier", nameof(service)); + + var attr = service.GetCustomAttribute(); + if (attr == null) + throw new ArgumentException("Service must have a NapriseNotificationServiceAttribute", nameof(service)); + + var ctor = service.GetConstructor(constructorArgumentTypes); + if (ctor == null) + throw new ArgumentException("Service must have a constructor with a single Naprise.NotificationServiceConfig argument", nameof(service)); + + var expr = Expression.Lambda(Expression.New(ctor, serviceConfigParameterExpression), serviceConfigParameterExpression); + var factory = (Func)expr.Compile(); + + foreach (var scheme in attr.Schemes) + this.services[scheme] = factory; + + return this; + } + + public INotifier Create(IEnumerable serviceUrls) + { + if (serviceUrls is null) + throw new ArgumentNullException(nameof(serviceUrls)); + + var urls = serviceUrls as IReadOnlyList ?? serviceUrls.ToList(); + + if (urls.Count == 0) + return Naprise.NoopNotifier; + + if (urls.Count == 1) + { + try + { + // fast path for single service + var url = urls[0]; + var notifier = this.NewNotifier(url); + if (notifier is not null) + { + return notifier; + } + else if (!this.IgnoreUnknownScheme) + { + var scheme = url.Scheme.ToLowerInvariant(); + throw new NapriseUnknownSchemeException($"\"{scheme}://\" is not registered with this ServiceRegistry"); + } + else + { + return Naprise.NoopNotifier; + } + } + catch (NapriseInvalidUrlException) + { + if (this.IgnoreInvalidUrl) + return Naprise.NoopNotifier; + else + throw; + } + } + + // multiple services + var services = new List(urls.Count); + + foreach (var url in urls) + { + try + { + var notifier = this.NewNotifier(url); + + if (notifier is not null) + { + services.Add(notifier); + } + else if (!this.IgnoreUnknownScheme) + { + var scheme = url.Scheme.ToLowerInvariant(); + throw new NapriseUnknownSchemeException($"\"{scheme}://\" is not registered with this ServiceRegistry"); + } + } + catch (NapriseInvalidUrlException) + { + if (!this.IgnoreInvalidUrl) + throw; + } + } + + return services.Count switch + { + 0 => Naprise.NoopNotifier, + 1 => services[0], + _ => new CompositeNotifier(services) + }; + } + + private INotifier? NewNotifier(Url url) + { + var scheme = url.Scheme; + + if (scheme is "http" or "https") + {/* + if (this.httpHostServiceConstructors.TryGetValue(url.Host.ToLowerInvariant(), out var constructor)) + { + var config = new ServiceConfig(url: url, mode: UrlParsingMode.Http, asset: this.Asset, httpClientFactory: this.GetHttpClient); + return (INotifier)constructor.Invoke(new object[] { config }); + } + else + { + }*/ + return null; + } + else if (this.services.TryGetValue(scheme, out var factory)) + { + var config = new ServiceConfig(url: url, asset: this.Asset, httpClientFactory: this.GetHttpClient); + return factory(config); + } + else + { + return null; + } + } + + private HttpClient GetHttpClient() => this.HttpClient ?? Naprise.DefaultHttpClient; + } + + public static class ServiceRegistryExtensions + { + public static INotifier Create(this ServiceRegistry registry, params string[] urls) + => registry.Create(serviceUrls: urls.Where(x => !string.IsNullOrEmpty(x)).Select(x => new Url(x))); + + public static INotifier Create(this ServiceRegistry registry, IEnumerable urls) + => registry.Create(serviceUrls: urls.Where(x => !string.IsNullOrEmpty(x)).Select(x => new Url(x))); + + public static INotifier Create(this ServiceRegistry registry, params Url[] urls) + => registry.Create(serviceUrls: urls); + } +} diff --git a/src/Naprise/SharedJsonOptions.cs b/src/Naprise/SharedJsonOptions.cs new file mode 100644 index 0000000..4c5bae6 --- /dev/null +++ b/src/Naprise/SharedJsonOptions.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Naprise +{ + internal static class SharedJsonOptions + { + internal static readonly JsonSerializerOptions SnakeCaseNamingOptions = new() + { + PropertyNamingPolicy = Json.JsonSnakeCaseNamingPolicy.Instance, + }; + + internal static readonly JsonSerializerOptions SnakeCaseNamingIngoreNullOptions = new() + { + PropertyNamingPolicy = Json.JsonSnakeCaseNamingPolicy.Instance, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + } +} diff --git a/src/dotnet-releaser.toml b/src/dotnet-releaser.toml new file mode 100644 index 0000000..801b908 --- /dev/null +++ b/src/dotnet-releaser.toml @@ -0,0 +1,39 @@ +# configuration file for dotnet-releaser +profile = "custom" + +[msbuild] +project = "Naprise.sln" + +[github] +user = "Genteure" +repo = "naprise" +version_prefix = "v" + +[[pack]] +rid = ["win-x64", "win-arm", "win-arm64"] +kinds = ["zip"] +[[pack]] +rid = ["linux-x64", "linux-arm", "linux-arm64"] +kinds = ["tar"] +[[pack]] +rid = ["osx-x64", "osx-arm64"] +kinds = ["tar"] + +[nuget] +publish = true + +[coverage] +version = "3.2.0" +format = ["json", "cobertura"] +deterministic_report = true + +[changelog] +body_template = ''' +# Changes + +{{ changes }} + +**Full Changelog**: {{ url_full_changelog_compare_changes }} + +`naprisecli` is designed to be a way to test the library without having to write a full application, no compatibility between versions is guaranteed. +'''