diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..1800566 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,173 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "contributors": [ + { + "login": "nicholas-fedor", + "name": "Nicholas Fedor", + "avatar_url": "https://avatars2.githubusercontent.com/u/71477161?v=4", + "profile": "https://github.com/nicholas-fedor", + "contributions": [ + "code", + "doc", + "maintenance", + "review" + ] + }, + { + "login": "amirschnell", + "name": "Amir Schnell", + "avatar_url": "https://avatars3.githubusercontent.com/u/9380508?v=4", + "profile": "https://github.com/amirschnell", + "contributions": [ + "code" + ] + }, + { + "login": "piksel", + "name": "nils måsén", + "avatar_url": "https://avatars2.githubusercontent.com/u/807383?v=4", + "profile": "https://piksel.se", + "contributions": [ + "code", + "doc", + "maintenance" + ] + }, + { + "login": "lukapeschke", + "name": "Luka Peschke", + "avatar_url": "https://avatars1.githubusercontent.com/u/17085536?v=4", + "profile": "https://github.com/lukapeschke", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "MrLuje", + "name": "MrLuje", + "avatar_url": "https://avatars0.githubusercontent.com/u/632075?v=4", + "profile": "https://github.com/MrLuje", + "contributions": [ + "code", + "doc" + ] + }, + { + "login": "simskij", + "name": "Simon Aronsson", + "avatar_url": "https://avatars0.githubusercontent.com/u/1596025?v=4", + "profile": "http://simme.dev", + "contributions": [ + "code", + "doc", + "maintenance" + ] + }, + { + "login": "arnested", + "name": "Arne Jørgensen", + "avatar_url": "https://avatars2.githubusercontent.com/u/190005?v=4", + "profile": "https://arnested.dk", + "contributions": [ + "doc", + "code" + ] + }, + { + "login": "atighineanu", + "name": "Alexei Tighineanu", + "avatar_url": "https://avatars1.githubusercontent.com/u/27206712?v=4", + "profile": "https://github.com/atighineanu", + "contributions": [ + "code" + ] + }, + { + "login": "ellisab", + "name": "Alexandru Bonini", + "avatar_url": "https://avatars2.githubusercontent.com/u/1402047?v=4", + "profile": "https://github.com/ellisab", + "contributions": [ + "code" + ] + }, + { + "login": "sentriz", + "name": "Senan Kelly", + "avatar_url": "https://avatars0.githubusercontent.com/u/6832539?v=4", + "profile": "https://senan.xyz", + "contributions": [ + "code" + ] + }, + { + "login": "JonasPf", + "name": "JonasPf", + "avatar_url": "https://avatars.githubusercontent.com/u/2216775?v=4", + "profile": "https://github.com/JonasPf", + "contributions": [ + "code" + ] + }, + { + "login": "claycooper", + "name": "claycooper", + "avatar_url": "https://avatars.githubusercontent.com/u/3612906?v=4", + "profile": "https://github.com/claycooper", + "contributions": [ + "doc" + ] + }, + { + "login": "darktohka", + "name": "Derzsi Dániel", + "avatar_url": "https://avatars.githubusercontent.com/u/16326697?v=4", + "profile": "http://ko-fi.com/disyer", + "contributions": [ + "code" + ] + }, + { + "login": "JosephKav", + "name": "Joseph Kavanagh", + "avatar_url": "https://avatars.githubusercontent.com/u/4267227?v=4", + "profile": "https://josephkav.io", + "contributions": [ + "code", + "bug" + ] + }, + { + "login": "justinsteven", + "name": "Justin Steven", + "avatar_url": "https://avatars.githubusercontent.com/u/1893909?v=4", + "profile": "https://ring0.lol", + "contributions": [ + "bug" + ] + }, + { + "login": "serverleader", + "name": "Carlos Savcic", + "avatar_url": "https://avatars.githubusercontent.com/u/34089?v=4", + "profile": "https://github.com/serverleader", + "contributions": [ + "code", + "doc" + ] + } + ], + "contributorsPerLine": 7, + "projectName": "shoutrrr", + "projectOwner": "nicholas-fedor", + "repoType": "github", + "repoHost": "https://github.com", + "skipCi": true, + "commitType": "docs", + "commitConvention": "angular" +} diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 0000000..a8829f9 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,48 @@ +# Use the latest 2.1 version of CircleCI pipeline process engine. +# See: https://circleci.com/docs/configuration-reference + +# For a detailed guide to building and testing with Go, read the docs: +# https://circleci.com/docs/language-go/ for more details +version: 2.1 + +# Define a job to be invoked later in a workflow. +# See: https://circleci.com/docs/jobs-steps/#jobs-overview & https://circleci.com/docs/configuration-reference/#jobs +jobs: + build: + # Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub. + # See: https://circleci.com/docs/executor-intro/ & https://circleci.com/docs/configuration-reference/#executor-job + docker: + # Specify the version you desire here + # See: https://circleci.com/developer/images/image/cimg/go + - image: cimg/go:1.24.2@sha256:cd027ede83e11c7b1002dfff3f4975fbf0124c5028df4c63da571c30db88fb3c + + # Add steps to the job + # See: https://circleci.com/docs/jobs-steps/#steps-overview & https://circleci.com/docs/configuration-reference/#steps + steps: + # Checkout the code as the first step. + - checkout + - restore_cache: + keys: + - go-mod-v4-{{ checksum "go.sum" }} + - run: + name: Install Dependencies + command: go mod download + - save_cache: + key: go-mod-v4-{{ checksum "go.sum" }} + paths: + - "/go/pkg/mod" + - run: + name: Run tests + command: | + mkdir -p /tmp/test-reports + gotestsum --junitfile /tmp/test-reports/unit-tests.xml + - store_test_results: + path: /tmp/test-reports + +# Orchestrate jobs using workflows +# See: https://circleci.com/docs/workflows/ & https://circleci.com/docs/configuration-reference/#workflows +workflows: + build-test: # This is the name of the workflow, feel free to change it to better match your workflow. + # Inside the workflow, you define the jobs you want to run. + jobs: + - build \ No newline at end of file diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..c117878 --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,6 @@ +--- +engines: + coverage: + exclude_paths: + - "*.md" + - "**/*.md" \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c682787 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,16 @@ + diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..02309a9 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,66 @@ +name: Build + +on: + workflow_call: + inputs: + snapshot: + description: "Whether to run in snapshot mode" + required: false + type: boolean + default: false + +jobs: + build: + name: Build + runs-on: ubuntu-latest + permissions: + packages: write + contents: write + attestations: write + id-token: write + env: + CGO_ENABLED: 0 + TAG: ${{ github.ref_name }} + steps: + - name: Checkout + uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + with: + fetch-depth: 0 + + - name: Set up QEMU + uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3 + with: + platforms: linux/amd64,linux/arm64,linux/386,linux/arm/v6 + + - name: Set up Go + uses: actions/setup-go@29694d72cd5e7ef3b09496b39f28a942af47737e + with: + go-version: 1.24.3 + + - name: Login to Docker Hub + uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Login to GHCR + uses: docker/login-action@6d4b68b490aef8836e8fb5e50ee7b3bdfa5894f0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@90c43f2c197eeb47adb636c4329af34ae5a2a5f0 + with: + distribution: goreleaser + version: v2.7.0 + args: release --clean ${{ inputs.snapshot && '--snapshot' || '' }} + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2 + if: success() + with: + subject-path: "dist/**/*" diff --git a/.github/workflows/clean-cache.yaml b/.github/workflows/clean-cache.yaml new file mode 100644 index 0000000..c84eaf1 --- /dev/null +++ b/.github/workflows/clean-cache.yaml @@ -0,0 +1,32 @@ +name: Cache cleanup +on: + workflow_dispatch: {} + pull_request: + types: + - closed + +permissions: + actions: write + contents: read + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Cleanup + run: | + echo "Fetching list of cache key" + cacheKeysForPR=$(gh cache list --ref $BRANCH --limit 100 --json id --jq '.[].id') + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh cache delete $cacheKey + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + BRANCH: refs/pull/${{ github.event.pull_request.number }}/merge diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..8210616 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,56 @@ +name: Publish Docs + +on: + workflow_dispatch: {} + +permissions: + contents: write + actions: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + + - name: Configure Git Credentials + run: | + git config user.name github-actions[bot] + git config user.email 41898282+github-actions[bot]@users.noreply.github.com + + - name: Setup Go + uses: actions/setup-go@29694d72cd5e7ef3b09496b39f28a942af47737e + with: + go-version: "1.24" + + - name: Generate Service Config Docs + run: | + go mod download + go clean -cache # Clear build cache + ./generate-service-config-docs.sh + + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.13.3" + cache: "pip" + cache-dependency-path: | + docs-requirements.txt + + - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV + + - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4 + with: + key: mkdocs-material-${{ env.cache_id }} + path: .cache + + restore-keys: | + mkdocs-material- + + - name: Install mkdocs + run: | + pip install -r docs-requirements.txt + + - name: Build and Deploy + run: mkdocs gh-deploy --force --verbose diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..4225409 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,36 @@ +name: Lint + +on: + workflow_call: + +permissions: + contents: read + +jobs: + lint: + name: Run Linter + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + + - name: Set up Go + uses: actions/setup-go@29694d72cd5e7ef3b09496b39f28a942af47737e + with: + go-version: "1.24.3" + + - name: Install dependencies + run: go mod download + + - name: Run golangci-lint + uses: golangci/golangci-lint-action@4d56fa9e3c67fb4afa92b38c99fc7f20f5eeff4e + with: + args: --timeout=5m --config= # Use default linter settings + + - name: Format Go code + run: | + go fmt ./... + + - name: Check for uncommitted changes after formatting + run: | + git diff --exit-code || (echo "Detected unformatted files. Run 'go fmt' to format your code."; exit 1) diff --git a/.github/workflows/pull-request.yaml b/.github/workflows/pull-request.yaml new file mode 100644 index 0000000..693b4df --- /dev/null +++ b/.github/workflows/pull-request.yaml @@ -0,0 +1,20 @@ +name: Pull Request + +on: + workflow_dispatch: {} + pull_request: + branches: + - main + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +jobs: + lint: + uses: ./.github/workflows/lint.yaml + + test: + uses: ./.github/workflows/test.yaml diff --git a/.github/workflows/release-dev.yaml b/.github/workflows/release-dev.yaml new file mode 100644 index 0000000..07b4d04 --- /dev/null +++ b/.github/workflows/release-dev.yaml @@ -0,0 +1,32 @@ +name: Push to main +on: + workflow_dispatch: {} + push: + branches: + - main + tags-ignore: + - "v*" + paths-ignore: + - "docs/*" + +permissions: + contents: write + actions: read + packages: write + id-token: write + attestations: write + +jobs: + lint: + uses: ./.github/workflows/lint.yaml + + test: + uses: ./.github/workflows/test.yaml + + build-and-publish: + uses: ./.github/workflows/build.yaml + secrets: inherit + needs: + - test + with: + snapshot: true diff --git a/.github/workflows/release-production.yaml b/.github/workflows/release-production.yaml new file mode 100644 index 0000000..6f1b156 --- /dev/null +++ b/.github/workflows/release-production.yaml @@ -0,0 +1,37 @@ +name: Release (Production) + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + workflow_dispatch: {} + +permissions: + contents: write + packages: write + attestations: write + id-token: write + +jobs: + lint: + uses: ./.github/workflows/lint.yaml + + test: + uses: ./.github/workflows/test.yaml + + build: + uses: ./.github/workflows/build.yaml + secrets: inherit + needs: + - test + + renew-docs: + name: Refresh pkg.go.dev + needs: build + runs-on: ubuntu-latest + steps: + - name: Pull new module version + uses: nicholas-fedor/go-proxy-pull-action@ad5d0f8b44e5478055cf78227eb300d2b02786f2 + with: + goproxy: https://proxy.golang.org + import_path: github.com/nicholas-fedor/shoutrrr diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..56ada1d --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,32 @@ +name: Run tests and upload coverage + +on: + workflow_call: + +permissions: + contents: read + +jobs: + test: + name: Run tests and collect coverage + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 + + - name: Set up Go + uses: actions/setup-go@29694d72cd5e7ef3b09496b39f28a942af47737e + with: + go-version: "1.24.3" + + - name: Install dependencies + run: go mod download + + - name: Run tests + run: | + go test -v -coverprofile coverage.out -covermode atomic ./... + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@ad3126e916f78f00edff4ed0317cf185271ccc2d + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c19170 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Binaries for programs and plugins +# --- + +*.exe +*.exe~ +*.dll +*.so +*.dylib +/shoutrrr/shoutrrr +*.snap + +# Test binary, build with `go test -c` +# --- + +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +# --- + +*.out +.idea + +report.json +coverage.txt +*.coverprofile +dist +site +docs/services/*/config.md diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..b39a6c0 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,634 @@ +###################################################################################################### +# # +# Shoutrrr golangci-lint Configuration # +# # +# Shoutrrr: https://github.com/nicholas-fedor/shoutrrr/ # +# Golangci-lint: https://golangci-lint.run/ # +# # +###################################################################################################### + +version: "2" + +###################################################################################################### +# Linters Configuration +# https://golangci-lint.run/usage/linters/ +###################################################################################################### +linters: + #################################################################################################### + # Default set of linters. + # The value can be: `standard`, `all`, `none`, or `fast`. + # Default: standard + # default: all + + #################################################################################################### + enable: + ################################################################################################## + # Enabled linters that automatically resolve issues + - canonicalheader # Canonicalheader checks whether net/http.Header uses canonical header. + - copyloopvar # A linter detects places where loop variables are copied. + - dupword # Checks for duplicate words in the source code. + - err113 # Go linter to check the errors handling expressions. + - errorlint # Errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. + - exptostd # Detects functions from golang.org/x/exp/ that can be replaced by std functions. + - fatcontext # Detects nested contexts in loops and function literals. + - ginkgolinter # Enforces standards of using ginkgo and gomega. + - gocritic # Provides diagnostics that check for bugs, performance and style issues. + - godot # Check if comments end in a period. + - goheader # Checks if file header matches to pattern. + - importas # Enforces consistent import aliases. + - intrange # Intrange is a linter to find places where for loops could make use of an integer range. + - mirror # Reports wrong mirror patterns of bytes/strings usage. + - misspell # Finds commonly misspelled English words. + - nakedret # Checks that functions with naked returns are not longer than a maximum size (can be zero). + - nlreturn # Nlreturn checks for a new line before return and branch statements to increase code clarity. + - nolintlint # Reports ill-formed or insufficient nolint directives. + - perfsprint # Checks that fmt.Sprintf can be replaced with a faster alternative. + - revive # Fast, configurable, extensible, flexible, and beautiful linter for Go. Drop-in replacement of golint. + - staticcheck # It's a set of rules from staticcheck. It's not the same thing as the staticcheck binary. The author of staticcheck doesn't support or approve the use of staticcheck as a library inside golangci-lint. + - tagalign # Check that struct tags are well aligned. + - testifylint # Checks usage of github.com/stretchr/testify. + - usestdlibvars # A linter that detect the possibility to use variables/constants from the Go standard library. + - usetesting # Reports uses of functions with replacement inside the testing package. + - whitespace # Whitespace is a linter that checks for unnecessary newlines at the start and end of functions, if, for, etc. + - wsl # Add or remove empty lines. + ################################################################################################## + # Enabled linters that require manual issue resolution + - asasalint # Check for pass []any as any in variadic func(...any). + - asciicheck # Checks that all code identifiers does not have non-ASCII symbols in the name. + - bidichk # Checks for dangerous unicode character sequences. + - bodyclose # Checks whether HTTP response body is closed successfully. + - containedctx # Containedctx is a linter that detects struct contained context.Context field. + - contextcheck # Check whether the function uses a non-inherited context. + - decorder # Check declaration order and count of types, constants, variables and functions. + - dogsled # Checks assignments with too many blank identifiers (e.g. x, _, _, _, := f()). + - dupl # Detects duplicate fragments of code. + - durationcheck # Check for two durations multiplied together. + - errchkjson # Checks types passed to the json encoding functions. Reports unsupported types and reports occurrences where the check for the returned error can be omitted. + - errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`. + - exhaustive # Check exhaustiveness of enum switch statements. + - forbidigo # Forbids identifiers. + - forcetypeassert # Finds forced type assertions. + - gocheckcompilerdirectives # Checks that go compiler directive comments (//go:) are valid. + - gochecksumtype # Run exhaustiveness checks on Go "sum types". + - goconst # Finds repeated strings that could be replaced by a constant. + - godox # Detects usage of FIXME, TODO and other keywords inside comments. + - gomoddirectives # Manage the use of 'replace', 'retract', and 'excludes' directives in go.mod. + - goprintffuncname # Checks that printf-like functions are named with `f` at the end. + - gosec # Inspects source code for security problems. + - grouper # Analyze expression groups. + - iface # Detect the incorrect use of interfaces, helping developers avoid interface pollution. + - inamedparam # Reports interfaces with unnamed method parameters. + - loggercheck # Checks key value pairs for common logger libraries (kitlog,klog,logr,zap). + - makezero # Finds slice declarations with non-zero initial length. + - mnd # An analyzer to detect magic numbers. + - musttag # Enforce field tags in (un)marshaled structs. + - nilerr # Finds the code that returns nil even if it checks that the error is not nil. + - nilnesserr # Reports constructs that checks for err != nil, but returns a different nil value error. + - nilnil # Checks that there is no simultaneous return of `nil` error and an invalid value. + - noctx # Finds sending http request without context.Context. + - nonamedreturns # Reports all named returns. + - nosprintfhostport # Checks for misuse of Sprintf to construct a host with port in a URL. + - prealloc # Finds slice declarations that could potentially be pre-allocated. + - predeclared # Find code that shadows one of Go's predeclared identifiers. + - promlinter # Check Prometheus metrics naming via promlint. + - reassign # Checks that package variables are not reassigned. + - recvcheck # Checks for receiver type consistency. + - spancheck # Checks for mistakes with OpenTelemetry/Census spans. + - sqlclosecheck # Checks that sql.Rows, sql.Stmt, sqlx.NamedStmt, pgx.Query are closed. + - thelper # Thelper detects tests helpers which is not start with t.Helper() method. + - tparallel # Tparallel detects inappropriate usage of t.Parallel() method in your Go test codes. + - unconvert # Remove unnecessary type conversions. + - unparam # Reports unused function parameters. + - varnamelen # Checks that the length of a variable's name matches its scope. + - wastedassign # Finds wasted assignment statements. + - wrapcheck # Checks that errors returned from external packages are wrapped. + disable: + - cyclop # Checks function and package cyclomatic complexity. + - depguard # Checks if package imports are in a list of acceptable packages. + - exhaustruct # Checks if all structure fields are initialized. + - funlen # Checks for long functions. + - gochecknoinits # Checks that no init functions are present in Go code. + - gochecknoglobals # Check that no global variables exist. + - gocognit # Computes and checks the cognitive complexity of functions. + - gocyclo # Computes and checks the cyclomatic complexity of functions. [fast] + - gomodguard # Allow and block list linter for direct Go module dependencies. This is different from depguard where there are different block types for example version constraints and module recommendations. [fast] + - gosmopolitan # Report certain i18n/l10n anti-patterns in your Go codebase. + - interfacebloat # A linter that checks the number of methods inside an interface. [fast] + - ireturn # Accept Interfaces, Return Concrete Types. + - lll # Reports long lines. + - maintidx # Maintidx measures the maintainability index of each function. [fast] + - nestif # Reports deeply nested if statements. + - rowserrcheck # Checks whether Rows.Err of rows is checked successfully. + - paralleltest # Detects missing usage of t.Parallel() method in your Go test. + - protogetter # Reports direct reads from proto message fields when getters should be used. [auto-fix] + - sloglint # Ensure consistent code style when using log/slog. + - tagliatelle # Checks the struct tags. + - testableexamples # Linter checks if examples are testable (have an expected output). [fast] + - testpackage # Linter that makes you use a separate _test package. + - zerologlint # Detects the wrong usage of `zerolog` that a user forgets to dispatch with `Send` or `Msg`. + + ###################################################################################################### + # Linter Settings Configuration + ###################################################################################################### + settings: + varnamelen: + max-distance: 5 + min-name-length: 3 + check-return: true + check-type-param: true + ignore-type-assert-ok: true + ignore-map-index-ok: true + ignore-chan-recv-ok: true + ignore-names: + - err + - c + - ctx + - i + - j + ignore-decls: + - c echo.Context + - t testing.T + - f *foo.Bar + - e error + - i int + - j int + - const C + - T any + - m map[string]int + - w http.ResponseWriter + - r *http.Request + - r http.Request + - r *net/http/Request + - r *mux.Router + + ###################################################################################################### + # Defines a set of rules to ignore issues. + # It does not skip the analysis, and so does not ignore "typecheck" errors. + exclusions: + # Mode of the generated files analysis. + # + # - `strict`: sources are excluded by strictly following the Go generated file convention. + # Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$` + # This line must appear before the first non-comment, non-blank text in the file. + # https://go.dev/s/generatedcode + # - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc. + # - `disable`: disable the generated files exclusion. + # + # Default: lax + # generated: strict + + #################################################################################################### + # Log a warning if an exclusion rule is unused. + # Default: false + warn-unused: true + + #################################################################################################### + # Predefined exclusion rules. + # Default: [] + presets: + - comments + - std-error-handling + - common-false-positives + - legacy + + #################################################################################################### + # Excluding configuration per-path, per-linter, per-text and per-source. + rules: + # Exclude some linters from running on tests files. + - path: ".*_test.go$" + linters: + - dupl + - err113 + - errcheck + - errorlint + - exhaustive + - forcetypeassert + - gocyclo + - gosec + - promlinter + - wrapcheck + - varnamelen + + # Run some linter only for test files by excluding its issues for everything else. + # - path-except: _test\.go + # linters: + # - forbidigo + + # Exclude known linters from partially hard-vendored code, + # which is impossible to exclude via `nolint` comments. + # `/` will be replaced by the current OS file path separator to properly work on Windows. + # - path: internal/hmac/ + # text: "weak cryptographic primitive" + # linters: + # - gosec + + # Exclude some `staticcheck` messages. + # - linters: + # - staticcheck + # text: "SA9003:" + + # Exclude `lll` issues for long lines with `go:generate`. + # - linters: + # - lll + # source: "^//go:generate " + + #################################################################################################### + # Which file paths to exclude: they will be analyzed, but issues from them won't be reported. + # "/" will be replaced by the current OS file path separator to properly work on Windows. + # Default: [] + # paths: + # - ".*\\.my\\.go$" + # - lib/bad.go + # - ".*/mocks/.*" + # - third_party$ + # - builtin$ + # - examples$ + + #################################################################################################### + # Which file paths to not exclude. + # Default: [] + # paths-except: + # - ".*\\.my\\.go$" + # - lib/bad.go + +###################################################################################################### +# Formatters Configuration +# https://golangci-lint.run/usage/configuration/#formatters-configuration +###################################################################################################### + +formatters: + # Enable specific formatter. + # Default: [] + enable: + - gci + - gofmt + - gofumpt + - goimports + - golines + + #################################################################################################### + # Formatters settings. + settings: + gci: + # Section configuration to compare against. + # Section names are case-insensitive and may contain parameters in (). + # The default order of sections is `standard > default > custom > blank > dot > alias > localmodule`, + # If `custom-order` is `true`, it follows the order of `sections` option. + # Default: ["standard", "default"] + sections: + - standard # Standard section: captures all standard packages. + - default # Default section: contains all imports that could not be matched to another section type. + - prefix(github.com/nicholas-fedor/watchtower) # Custom section: groups all imports with the specified Prefix. + - blank # Blank section: contains all blank imports. This section is not present unless explicitly enabled. + - dot # Dot section: contains all dot imports. This section is not present unless explicitly enabled. + - alias # Alias section: contains all alias imports. This section is not present unless explicitly enabled. + - localmodule # Local module section: contains all local packages. This section is not present unless explicitly enabled. + # Checks that no inline comments are present. + # Default: false + # no-inline-comments: true + # Checks that no prefix comments (comment lines above an import) are present. + # Default: false + # no-prefix-comments: true + # Enable custom order of sections. + # If `true`, make the section order the same as the order of `sections`. + # Default: false + # custom-order: true + # Drops lexical ordering for custom sections. + # Default: false + # no-lex-order: true + gofmt: + # Simplify code: gofmt with `-s` option. + # Default: true + # simplify: false + # Apply the rewrite rules to the source before reformatting. + # https://pkg.go.dev/cmd/gofmt + # Default: [] + rewrite-rules: + - pattern: "interface{}" + replacement: "any" + - pattern: "a[b:len(a)]" + replacement: "a[b:]" + + #################################################################################################### + # exclusions: + # Mode of the generated files analysis. + # + # - `strict`: sources are excluded by strictly following the Go generated file convention. + # Source files that have lines matching only the following regular expression will be excluded: `^// Code generated .* DO NOT EDIT\.$` + # This line must appear before the first non-comment, non-blank text in the file. + # https://go.dev/s/generatedcode + # - `lax`: sources are excluded if they contain lines like `autogenerated file`, `code generated`, `do not edit`, etc. + # - `disable`: disable the generated files exclusion. + # + # Default: lax + # generated: strict + # Which file paths to exclude. + # Default: [] + # paths: + # - ".*mocks$" + # - third_party$ + # - builtin$ + # - examples$ + +###################################################################################################### +# Issues Configuration +# https://golangci-lint.run/usage/configuration/#issues-configuration +###################################################################################################### +issues: + # Maximum issues count per one linter. + # Set to 0 to disable. + # Default: 50 + max-issues-per-linter: 0 + + #################################################################################################### + # Maximum count of issues with the same text. + # Set to 0 to disable. + # Default: 3 + max-same-issues: 0 + + #################################################################################################### + # Make issues output unique by line. + # Default: true + # uniq-by-line: false + + #################################################################################################### + # Show only new issues: if there are unstaged changes or untracked files, + # only those changes are analyzed, else only changes in HEAD~ are analyzed. + # It's a super-useful option for integration of golangci-lint into existing large codebase. + # It's not practical to fix all existing issues at the moment of integration: + # much better don't allow issues in new code. + # + # Default: false + # new: true + + #################################################################################################### + # Show only new issues created after the best common ancestor (merge-base against HEAD). + # Default: "" + # new-from-merge-base: main + + #################################################################################################### + # Show only new issues created after git revision `REV`. + # Default: "" + # new-from-rev: HEAD + + #################################################################################################### + # Show only new issues created in git patch with set file path. + # Default: "" + # new-from-patch: path/to/patch/file + + #################################################################################################### + # Show issues in any part of update files (requires new-from-rev or new-from-patch). + # Default: false + whole-files: true + + #################################################################################################### + # Fix found issues (if it's supported by the linter). + # Default: false + fix: true + + ###################################################################################################### + # Output Configuration + # https://golangci-lint.run/usage/configuration/#output-configuration + ###################################################################################################### +output: + #################################################################################################### + # The formats used to render issues. + formats: + ################################################################################################## + # Prints issues in a text format with colors, line number, and linter name. + # This format is the default format. + text: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.txt + + # Print linter name in the end of issue text. + # Default: true + # print-linter-name: false + print-linter-name: true + + # Print lines of code with issue. + # Default: true + # print-issued-lines: false + print-issued-lines: true + + # Use colors. + # Default: true + # colors: false + colors: true + + ################################################################################################## + # Prints issues in a JSON representation. + # json: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.json + # path: stderr + + ################################################################################################## + # Prints issues in columns representation separated by tabulations. + tab: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.txt + # Print linter name in the end of issue text. + # Default: true + # print-linter-name: true + # Use colors. + # Default: true + # colors: false + colors: true + + ################################################################################################## + # Prints issues in an HTML page. + # It uses the Cloudflare CDN (cdnjs) and React. + # html: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.html + + ################################################################################################## + # Prints issues in the Checkstyle format. + # checkstyle: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.xml + + ################################################################################################## + # Prints issues in the Code Climate format. + # code-climate: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.json + + ################################################################################################## + # Prints issues in the JUnit XML format. + # junit-xml: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.xml + # Support extra JUnit XML fields. + # Default: false + # extended: true + + ################################################################################################## + # Prints issues in the TeamCity format. + # teamcity: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.txt + + ################################################################################################## + # Prints issues in the SARIF format. + # sarif: + # Output path can be either `stdout`, `stderr` or path to the file to write to. + # Default: stdout + # path: ./path/to/output.json + + #################################################################################################### + # Add a prefix to the output file references. + # Default: "" + # path-prefix: "" + + #################################################################################################### + # Order to use when sorting results. + # Possible values: `file`, `linter`, and `severity`. + # + # If the severity values are inside the following list, they are ordered in this order: + # 1. error + # 2. warning + # 3. high + # 4. medium + # 5. low + # Either they are sorted alphabetically. + # + # Default: ["linter", "file"] + sort-order: + - file # filepath, line, and column. + - severity + - linter + +#################################################################################################### +# Show statistics per linter. +# Default: true +# show-stats: false + +###################################################################################################### +# Run Configuration +# Options for analysis running. +# https://golangci-lint.run/usage/configuration/#run-configuration +###################################################################################################### +run: + #################################################################################################### + # Timeout for analysis, e.g. 30s, 5m, 5m30s. + # If the value is lower or equal to 0, the timeout is disabled. + # Default: 1m + # timeout: 5m + + #################################################################################################### + # The mode used to evaluate relative paths. + # It's used by exclusions, Go plugins, and some linters. + # The value can be: + # - `gomod`: the paths will be relative to the directory of the `go.mod` file. + # - `gitroot`: the paths will be relative to the git root (the parent directory of `.git`). + # - `cfg`: the paths will be relative to the configuration file. + # - `wd` (NOT recommended): the paths will be relative to the place where golangci-lint is run. + # Default: wd + # relative-path-mode: gomod + + #################################################################################################### + # Exit code when at least one issue was found. + # Default: 1 + # issues-exit-code: 2 + + #################################################################################################### + # Include test files or not. + # Default: true + # tests: false + + #################################################################################################### + # List of build tags, all linters use it. + # Default: [] + # build-tags: + # - mytag + + #################################################################################################### + # If set, we pass it to "go list -mod={option}". From "go help modules": + # If invoked with -mod=readonly, the go command is disallowed from the implicit + # automatic updating of go.mod described above. Instead, it fails when any changes + # to go.mod are needed. This setting is most useful to check that go.mod does + # not need updates, such as in a continuous integration and testing system. + # If invoked with -mod=vendor, the go command assumes that the vendor + # directory holds the correct copies of dependencies and ignores + # the dependency descriptions in go.mod. + # + # Allowed values: readonly|vendor|mod + # Default: "" + # modules-download-mode: readonly + + #################################################################################################### + # Allow multiple parallel golangci-lint instances running. + # If false, golangci-lint acquires file lock on start. + # Default: false + allow-parallel-runners: true + + #################################################################################################### + # Allow multiple golangci-lint instances running, but serialize them around a lock. + # If false, golangci-lint exits with an error if it fails to acquire file lock on start. + # Default: false + allow-serial-runners: true + + #################################################################################################### + # Define the Go version limit. + # Mainly related to generics support since go1.18. + # Default: use Go version from the go.mod file, fallback on the env var `GOVERSION`, fallback on 1.22. + # go: "1.24" + + #################################################################################################### + # Number of operating system threads (`GOMAXPROCS`) that can execute golangci-lint simultaneously. + # If it is explicitly set to 0 (i.e. not the default) then golangci-lint will automatically set the value to match Linux container CPU quota. + # Default: the number of logical CPUs in the machine + # concurrency: 4 +###################################################################################################### +# Severity Configuration +# https://golangci-lint.run/usage/configuration/#severity-configuration +###################################################################################################### +# severity: +#################################################################################################### +# Set the default severity for issues. +# +# If severity rules are defined and the issues do not match or no severity is provided to the rule +# this will be the default severity applied. +# Severities should match the supported severity names of the selected out format. +# - Code climate: https://docs.codeclimate.com/docs/issues#issue-severity +# - Checkstyle: https://checkstyle.sourceforge.io/property_types.html#SeverityLevel +# - GitHub: https://help.github.com/en/actions/reference/workflow-commands-for-github-actions#setting-an-error-message +# - TeamCity: https://www.jetbrains.com/help/teamcity/service-messages.html#Inspection+Instance +# +# `@linter` can be used as severity value to keep the severity from linters (e.g. revive, gosec, ...) +# +# Default: "" +# default: error + +#################################################################################################### +# If set to true `severity-rules` regular expressions become case-sensitive. +# Default: false +# case-sensitive: true + +#################################################################################################### +# When a list of severity rules are provided, severity information will be added to lint issues. +# Severity rules have the same filtering capability as exclude rules +# except you are allowed to specify one matcher per severity rule. +# +# `@linter` can be used as severity value to keep the severity from linters (e.g. revive, gosec, ...) +# +# Only affects out formats that support setting severity information. +# +# Default: [] +# rules: +# - linters: +# - dupl +# severity: info +###################################################################################################### +# End of golangci-lint Configuration +###################################################################################################### diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a06d55d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +## Prerequisites + +To contribute code changes to this project you will need to install the go distribution. + +* [Go](https://golang.org/doc/install) + +Also, as shoutrrr utilizes go modules for vendor locking, you'll need at least Go 1.24. +You can check your current version of the go language as follows: + +```bash + ~ $ go version + go version go1.24.0 windows/amd64 +``` + +## Checking out the code + +Do not place your code in the go source path. + +```bash +git clone git@github.com:/shoutrrr.git +cd shoutrrr +``` + +## Building and testing + +shoutrrr is a go library and is built with go commands. The following commands assume that you are at the root level of your repo. + +```bash +./build.sh # compiles and packages an executable stand-alone client of shoutrrr +go test ./... -v # runs tests with verbose output +./shoutrrr/shoutrrr # runs the application +``` + +## Commit messages + +Shoutrrr try to follow the conventional commit specification. More information is available [here](https://www.conventionalcommits.org/en/v1.0.0-beta.4/#summary) diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..a31544b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Nicholas Fedor + +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..7b9bdb0 --- /dev/null +++ b/README.md @@ -0,0 +1,158 @@ +
+ + + + + +# Shoutrrr + +Notification library for gophers and their furry friends. +Heavily inspired by caronc/apprise. + +![github actions workflow status](https://github.com/nicholas-fedor/shoutrrr/workflows/Main%20Workflow/badge.svg) +[![codecov](https://codecov.io/gh/nicholas-fedor/shoutrrr/branch/main/graph/badge.svg)](https://codecov.io/gh/nicholas-fedor/shoutrrr) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/47eed72de79448e2a6e297d770355544)](https://www.codacy.com/gh/nicholas-fedor/shoutrrr/dashboard?utm_source=github.com&utm_medium=referral&utm_content=nicholas-fedor/shoutrrr&utm_campaign=Badge_Grade) +[![report card](https://goreportcard.com/badge/github.com/nicholas-fedor/shoutrrr)](https://goreportcard.com/badge/github.com/nicholas-fedor/shoutrrr) +[![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/nicholas-fedor/shoutrrr) +[![github code size in bytes](https://img.shields.io/github/languages/code-size/nicholas-fedor/shoutrrr.svg?style=flat-square)](https://github.com/nicholas-fedor/shoutrrr) +[![license](https://img.shields.io/github/license/nicholas-fedor/shoutrrr.svg?style=flat-square)](https://github.com/nicholas-fedor/shoutrrr/blob/main/LICENSE) +[![Pulls from DockerHub](https://img.shields.io/docker/pulls/nickfedor/shoutrrr.svg)](https://hub.docker.com/r/nickfedor/shoutrrr) +[![godoc](https://godoc.org/github.com/nicholas-fedor/shoutrrr?status.svg)](https://godoc.org/github.com/nicholas-fedor/shoutrrr) +[![All Contributors](https://img.shields.io/badge/all_contributors-16-orange.svg?style=flat-square)](#contributors-) + + +
+

+ +## Installation + +### Using the Go CLI + +```bash +go install github.com/nicholas-fedor/shoutrrr/shoutrrr@latest +``` + +### From Source + +```bash +go build -o shoutrrr ./shoutrrr +``` + +## Quick Start + +### As a package + +Using shoutrrr is easy! There is currently two ways of using it as a package. + +#### Using the direct send command + +```go + url := "slack://token-a/token-b/token-c" + err := shoutrrr.Send(url, "Hello world (or slack channel) !") + +``` + +#### Using a sender + +```go + url := "slack://token-a/token-b/token-c" + sender, err := shoutrrr.CreateSender(url) + sender.Send("Hello world (or slack channel) !", map[string]string { /* ... */ }) +``` + +#### Using a sender with multiple URLs + +```go + urls := []string { + "slack://token-a/token-b/token-c" + "discord://token@channel" + } + sender, err := shoutrrr.CreateSender(urls...) + sender.Send("Hello world (or slack channel) !", map[string]string { /* ... */ }) +``` + +### Through the CLI + +Start by running the `build.sh` script. +You may then run send notifications using the shoutrrr executable: + +```shell +shoutrrr send [OPTIONS] +``` + +### From a GitHub Actions workflow + +You can also use Shoutrrr from a GitHub Actions workflow. + +See this example and the [action on GitHub +Marketplace](https://github.com/marketplace/actions/shoutrrr-action): + +```yaml +name: Deploy +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Some other steps needed for deploying + run: ... + - name: Shoutrrr + uses: nicholas-fedor/shoutrrr-action@v1 + with: + url: ${{ secrets.SHOUTRRR_URL }} + title: Deployed ${{ github.sha }} + message: See changes at ${{ github.event.compare }}. +``` + +## Documentation + +For additional details, visit the [full documentation](https://nicholas-fedor.github.io/shoutrrr). + +## Contributors ✨ + +Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Nicholas Fedor
Nicholas Fedor

💻 📖 🚧 👀
Amir Schnell
Amir Schnell

💻
nils måsén
nils måsén

💻 📖 🚧
Luka Peschke
Luka Peschke

💻 📖
MrLuje
MrLuje

💻 📖
Simon Aronsson
Simon Aronsson

💻 📖 🚧
Arne Jørgensen
Arne Jørgensen

📖 💻
Alexei Tighineanu
Alexei Tighineanu

💻
Alexandru Bonini
Alexandru Bonini

💻
Senan Kelly
Senan Kelly

💻
JonasPf
JonasPf

💻
claycooper
claycooper

📖
Derzsi Dániel
Derzsi Dániel

💻
Joseph Kavanagh
Joseph Kavanagh

💻 🐛
Justin Steven
Justin Steven

🐛
Carlos Savcic
Carlos Savcic

💻 📖
+ + + + + + +This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! + +## Related Project(s) + +- [watchtower](https://github.com/nicholas-fedor/watchtower) - automate Docker container image updates diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..2cdc30d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Supported Versions + +Shoutrrr v0.x strives to be fully backwards-compatible, and hence only the latest minor will get security updates unless explicitly requested. + +| Version | Supported | +| ------- | ------------------ | +| 0.6.x | :white_check_mark: | +| < 0.6 | :x: | + +## Reporting a Vulnerability + +Vulnerabilities can be disclosed via email to diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..c529784 --- /dev/null +++ b/build.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +go build -o shoutrrr/ ./shoutrrr diff --git a/dockerfiles/Dockerfile b/dockerfiles/Dockerfile new file mode 100644 index 0000000..dad392d --- /dev/null +++ b/dockerfiles/Dockerfile @@ -0,0 +1,12 @@ +FROM alpine:3.21.3@sha256:a8560b36e8b8210634f77d9f7f9efd7ffa463e380b75e2e74aff4511df3ef88c AS alpine + +RUN apk add --no-cache ca-certificates + +FROM scratch + +COPY --from=alpine \ + /etc/ssl/certs/ca-certificates.crt \ + /etc/ssl/certs/ca-certificates.crt +COPY shoutrrr/shoutrrr / + +ENTRYPOINT ["/shoutrrr"] diff --git a/docs-requirements.txt b/docs-requirements.txt new file mode 100644 index 0000000..f111edc --- /dev/null +++ b/docs-requirements.txt @@ -0,0 +1,4 @@ +mkdocs +mkdocs-material +mkdocs-git-revision-date-localized-plugin +mkdocs-minify-plugin \ No newline at end of file diff --git a/docs/examples/generic.md b/docs/examples/generic.md new file mode 100644 index 0000000..e725955 --- /dev/null +++ b/docs/examples/generic.md @@ -0,0 +1,19 @@ +# Examples + +Examples of service URLs that can be used with [the generic service](../../services/generic) together with common service providers. + +## Home Assistant + +The service URL needs to be: +``` +generic://HAIPAddress:HAPort/api/webhook/WebhookIDFromHA?template=json +``` + +And, if you need http:// +``` +generic://HAIPAddress:HAPort/api/webhook/WebhookIDFromHA?template=json&disabletls=yes +``` + +Then, in HA, use `{{ trigger.json.message }}` to get the message sent from the JSON. + +_Credit [@JeffCrum1](https://github.com/JeffCrum1), source: [https://github.com/nicholas-fedor/shoutrrr/issues/325#issuecomment-1460105065]_ diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..e6d6ca1 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/generators/basic.md b/docs/generators/basic.md new file mode 100644 index 0000000..e1c35b5 --- /dev/null +++ b/docs/generators/basic.md @@ -0,0 +1,20 @@ +# Basic generator + +The basic generator looks at the `key:""`, `desc:""` and `default:""` tags on service configuration structs and uses them to ask the user to fill in their corresponding values. + +Example: +```shell +$ shoutrrr generate telegram +``` +```yaml +Generating URL for telegram using basic generator +Enter the configuration values as prompted + +Token: 110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw +Preview[Yes]: No +Notification[Yes]: +ParseMode[None]: +Channels: @mychannel + +URL: telegram://110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw@telegram?channels=@mychannel¬ification=Yes&parsemode=None&preview=No +``` diff --git a/docs/generators/overview.md b/docs/generators/overview.md new file mode 100644 index 0000000..a02b10b --- /dev/null +++ b/docs/generators/overview.md @@ -0,0 +1,10 @@ +# Generators + +Generators are used to create service configurations via the command line. +The main generator is the reflection based [Basic generator](./basic) that aims to be able to generator configurations for all the core services via a set of simple questions. + +## Usage + +```bash +$ shoutrrr generate [OPTIONS] -g +``` \ No newline at end of file diff --git a/docs/getting-started.md b/docs/getting-started.md new file mode 100644 index 0000000..b7c4263 --- /dev/null +++ b/docs/getting-started.md @@ -0,0 +1,156 @@ +# Getting started + +## As a package + +Using shoutrrr is easy! There is currently two ways of using it as a package. + +### Using the direct send command +Easiest to use, but very limited. + +```go +url := "slack://token-a/token-b/token-c" +err := shoutrrr.Send(url, "Hello world (or slack channel) !") +``` + +### Using a sender +Using a sender gives you the ability to preconfigure multiple notification services and send to all of them with the same `Send(message, params)` method. + +```go +urlA := "slack://token-a/token-b/token-c" +urlB := "telegram://110201543:AAHdqTcvCH1vGWJxfSeofSAs0K5PALDsaw@telegram?channels=@mychannel" +sender, err := shoutrrr.CreateSender(urlA, urlB) + +// Send notifications instantly to all services +sender.Send("Hello world (or slack/telegram channel)!", map[string]string { "title": "He-hey~!" }) + +// ...or bundle notifications... +func doWork() error { + // ...and send them when leaving the scope + defer sender.Flush(map[string]string { "title": "Work Result" }) + + sender.Enqueue("Started doing %v", stuff) + + // Maybe get creative...? + defer func(start time.Time) { + sender.Enqueue("Elapsed: %v", time.Now().Sub(start)) + }(time.Now()) + + if err := doMoreWork(); err != nil { + sender.Enqueue("Oh no! %v", err) + + // This will send the currently queued up messages... + return + } + + sender.Enqueue("Everything went very well!") + + // ...or this: +} + +``` + + +## Through the CLI + +Start by running the `build.sh` script. +You may then run the shoutrrr executable: + +```shell +$ ./shoutrrr + +Usage: +./shoutrrr [...] +Possible actions: send, verify, generate +``` + +On a system with Go installed you can install the latest Shoutrrr CLI +command with: + +```shell +go install github.com/nicholas-fedor/shoutrrr/shoutrrr@latest +``` + +### Commands + +#### Send + +Send a notification using the supplied notification service url. + +```bash +$ shoutrrr send \ + --url "" \ + --message "" +``` + +#### Verify + +Verify the validity of a notification service url. + +```bash +$ shoutrrr verify \ + --url "" +``` + +#### Generate + +Generate and display the configuration for a notification service url. + +```bash +$ shoutrrr generate [OPTIONS] +``` + +| Flags | Description | +| ---------------------------- | ------------------------------------------------| +| `-g, --generator string` | The generator to use (default "basic") | +| `-p, --property stringArray` | Configuration property in key=value format | +| `-s, --service string` | The notification service to generate a URL for | + +**Note**: Service can either be supplied as the first argument or using the `-s` flag. + +For more information on generators, see [Generators](./generators/overview.md). + +### Options + +#### Debug + +Enables debug output from the CLI. + +| Flags | Env. | Default | Required | +| --------------- | ---------------- | ------- | -------- | +| `--debug`, `-d` | `SHOUTRRR_DEBUG` | `false` | | + +#### URL + +The target url for the notifications generated, see [overview](./services/overview). + +| Flags | Env. | Default | Required | +| ------------- | -------------- | ------- | -------- | +| `--url`, `-u` | `SHOUTRRR_URL` | N/A | ✅ | + +## From a GitHub Actions workflow + +You can also use Shoutrrr from a GitHub Actions workflow. + +See this example and the [action on GitHub +Marketplace](https://github.com/marketplace/actions/shoutrrr-action): + +```yaml +name: Deploy +on: + push: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Some other steps needed for deploying + run: ... + - name: Shoutrrr + uses: nicholas-fedor/shoutrrr-action@v1 + with: + url: ${{ secrets.SHOUTRRR_URL }} + title: Deployed ${{ github.sha }} + message: See changes at ${{ github.event.compare }}. +``` diff --git a/docs/guides/slack/app-api-channel-details-id.png b/docs/guides/slack/app-api-channel-details-id.png new file mode 100644 index 0000000..075b879 Binary files /dev/null and b/docs/guides/slack/app-api-channel-details-id.png differ diff --git a/docs/guides/slack/app-api-copy-oauth-token.png b/docs/guides/slack/app-api-copy-oauth-token.png new file mode 100644 index 0000000..135d011 Binary files /dev/null and b/docs/guides/slack/app-api-copy-oauth-token.png differ diff --git a/docs/guides/slack/app-api-oauth-menu.png b/docs/guides/slack/app-api-oauth-menu.png new file mode 100644 index 0000000..9df1f9b Binary files /dev/null and b/docs/guides/slack/app-api-oauth-menu.png differ diff --git a/docs/guides/slack/app-api-select-channel.png b/docs/guides/slack/app-api-select-channel.png new file mode 100644 index 0000000..a2f3c42 Binary files /dev/null and b/docs/guides/slack/app-api-select-channel.png differ diff --git a/docs/guides/slack/index.md b/docs/guides/slack/index.md new file mode 100644 index 0000000..bba5023 --- /dev/null +++ b/docs/guides/slack/index.md @@ -0,0 +1,47 @@ +# Slack Guides + +Guides for setting up the [Slack](../../services/slack.md) service + +## Getting a token + +To enable all features, either the Legacy Webhook- (deprecated and might stop working) or the bot API tokens needs to +be used. Only use the non-legacy Webhook if you don't need to customize the bot name or icon. + +### Bot API (preferred) + +1. Create a new App for your bot using the [Basic app setup guide](https://api.slack.com/authentication/basics) +2. Install the App into your workspace ([slack docs](https://api.slack.com/authentication/basics#installing)). +3. From [Apps](https://api.slack.com/apps), select your new App and go to **Oauth & Permissions** +
Slack app management menu screenshot
+4. Copy the Bot User OAuth Token +
Copy OAuth token screenshot
+ +!!! example + Given the API token +
xoxb-123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N
+ and the channel ID `C001CH4NN3L` (obtained by using the [guide below](#getting_the_channel_id)), the Shoutrrr URL + should look like this: +
slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C001CH4NN3L
+ +### Webhook tokens + +Get a Webhook URL using the legacy [WebHooks Integration](https://slack.com/apps/new/A0F7XDUAZ-incoming-webhooks), +or by using the [Getting started with Incoming Webhooks](https://api.slack.com/messaging/webhooks#getting_started) guide and +replace the initial `https://hooks.slack.com/services/` part of the webhook URL with `slack://hook:` to get your Shoutrrr URL. + +!!! info "Slack Webhook URL" + https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX + +!!! info "Shoutrrr URL" + slack://hook:T00000000-B00000000-XXXXXXXXXXXXXXXXXXXXXXXX@webhook + +## Getting the Channel ID + +!!! note "" + Only needed for API token. Use `webhook` as the channel for webhook tokens. + +1. In the channel you wish to post to, open **Channel Details** by clicking on the channel title. +
Opening channel details screenshot
+ +2. Copy the Channel ID from the bottom of the popup and append it to your Shoutrrr URL +
Copy channel ID screenshot
\ No newline at end of file diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ba6de13 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,43 @@ +# Shoutrrr + +
+ +
+ +

+Notification library for gophers and their furry friends.
+Heavily inspired by caronc/apprise. +

+ +

+ + github actions workflow status + + + codecov + + + Codacy Badge + + + report card + + + go.dev reference + + + Pulls from DockerHub + + + github code size in bytes + + + license + +

+ + + +To make it easy and streamlined to consume shoutrrr regardless of the notification service you want to use, +we've implemented a notification service url schema. To send notifications, instantiate the `ShoutrrrClient` using one of +the service urls from the [overview](services/overview.md). diff --git a/docs/overrides/main.html b/docs/overrides/main.html new file mode 100644 index 0000000..6758502 --- /dev/null +++ b/docs/overrides/main.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block outdated %} +You're not viewing the latest version. + + + Click here to go to latest. + +{% endblock %} \ No newline at end of file diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 0000000..a5fb37c --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,21 @@ +To use a proxy with shoutrrr, you could either set the proxy URL in the environment variable `HTTP_PROXY` or override the default HTTP client like this: + +```go +proxyurl, err := url.Parse("socks5://localhost:1337") +if err != nil { + log.Fatalf("Error parsing proxy URL: %q", err) +} + +http.DefaultClient.Transport = &http.Transport{ + Proxy: http.ProxyURL(proxyurl), + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + ForceAttemptHTTP2: true, + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, +} +``` diff --git a/docs/services/bark.md b/docs/services/bark.md new file mode 100644 index 0000000..75769b0 --- /dev/null +++ b/docs/services/bark.md @@ -0,0 +1,7 @@ +# Bark + +Upstream docs: https://github.com/Finb/Bark + +## URL Format + +--8<-- "docs/services/bark/config.md" \ No newline at end of file diff --git a/docs/services/discord.md b/docs/services/discord.md new file mode 100644 index 0000000..5dc2433 --- /dev/null +++ b/docs/services/discord.md @@ -0,0 +1,52 @@ +# Discord + +## URL Format + +Your Discord Webhook-URL will look like this: + +!!! info "" + https://discord.com/api/webhooks/__`webhookid`__/__`token`__ + +The shoutrrr service URL should look like this: + +!!! info "" + discord://__`token`__@__`webhookid`__[?thread_id=__`threadid`__] + +### Thread Support + +To send messages to a specific thread in a Discord channel, include the `thread_id` query parameter in the service URL with the ID of the target thread. For example: + +!!! info "" + discord://__`token`__@__`webhookid`__?thread_id=123456789 + +You can obtain the `thread_id` by right-clicking a thread in Discord and selecting "Copy ID" (requires Developer Mode to be enabled in Discord settings). + +--8<-- "docs/services/discord/config.md" + +## Creating a webhook in Discord + +1. Open your channel settings by first clicking on the gear icon next to the name of the channel. +![Screenshot 1](discord/sc-1.png) + +2. In the menu on the left, click on *Integrations*. +![Screenshot 2](discord/sc-2.png) + +3. In the menu on the right, click on *Create Webhook*. +![Screenshot 3](discord/sc-3.png) + +4. Set the name, channel, and icon to your liking and click the *Copy Webhook URL* button. +![Screenshot 4](discord/sc-4.png) + +5. Press the *Save Changes* button. +![Screenshot 5](discord/sc-5.png) + +6. Format the service URL: +``` +https://discord.com/api/webhooks/693853386302554172/W3dE2OZz4C13_4z_uHfDOoC7BqTW288s-z1ykqI0iJnY_HjRqMGO8Sc7YDqvf_KVKjhJ + └────────────────┘ └──────────────────────────────────────────────────────────────────┘ + webhook id token + +discord://W3dE2OZz4C13_4z_uHfDOoC7BqTW288s-z1ykqI0iJnY_HjRqMGO8Sc7YDqvf_KVKjhJ@693853386302554172?thread_id=123456789 + └──────────────────────────────────────────────────────────────────┘ └────────────────┘ └─────────────────┘ + token webhook id thread id +``` diff --git a/docs/services/discord/sc-1.png b/docs/services/discord/sc-1.png new file mode 100644 index 0000000..d92a07a Binary files /dev/null and b/docs/services/discord/sc-1.png differ diff --git a/docs/services/discord/sc-2.png b/docs/services/discord/sc-2.png new file mode 100644 index 0000000..4d89db5 Binary files /dev/null and b/docs/services/discord/sc-2.png differ diff --git a/docs/services/discord/sc-3.png b/docs/services/discord/sc-3.png new file mode 100644 index 0000000..b5dda4d Binary files /dev/null and b/docs/services/discord/sc-3.png differ diff --git a/docs/services/discord/sc-4.png b/docs/services/discord/sc-4.png new file mode 100644 index 0000000..1472660 Binary files /dev/null and b/docs/services/discord/sc-4.png differ diff --git a/docs/services/discord/sc-5.png b/docs/services/discord/sc-5.png new file mode 100644 index 0000000..e1f50b0 Binary files /dev/null and b/docs/services/discord/sc-5.png differ diff --git a/docs/services/email.md b/docs/services/email.md new file mode 100644 index 0000000..d779ad4 --- /dev/null +++ b/docs/services/email.md @@ -0,0 +1,8 @@ +# Email + +## URL Format + +!!! info "" + smtp://__`username`__:__`password`__@__`host`__:__`port`__/?from=__`fromAddress`__&to=__`recipient1`__[,__`recipient2`__,...] + +--8<-- "docs/services/smtp/config.md" diff --git a/docs/services/generic.md b/docs/services/generic.md new file mode 100644 index 0000000..2575065 --- /dev/null +++ b/docs/services/generic.md @@ -0,0 +1,76 @@ +# Generic +The Generic service can be used for any target that is not explicitly supported by Shoutrrr, as long as it +supports receiving the message via a POST request. +Usually, this requires customization on the receiving end to interpret the payload that it receives, and might +not be a viable approach. + +Common examples for use with service providers can be found under [examples](../examples/generic.md). + +## Custom headers +You can add additional HTTP headers to your request by adding query variables prefixed with `@` (`@key=value`). + +Using +```url +generic://example.com?@acceptLanguage=tlh-Piqd +``` +would result in the additional header being added: + +```http +Accept-Language: tlh-Piqd +``` + +## JSON template +By using the built in `JSON` template (`template=json`) you can create a generic JSON payload. The keys used for `title` and `message` can be overriden +by supplying the params/query values `titleKey` and `messageKey`. + +!!! example + ```json + { + "title": "Oh no!", + "message": "The thing happened and now there is stuff all over the area!" + } + ``` + +### Custom data fields +When using the JSON template, you can add additional key/value pairs to the JSON object by adding query variables prefixed with `$` (`$key=value`). + +!!! example + Using `generic://example.com?$projection=retroazimuthal` would yield: + + ```json + { + "title": "Amazing opportunities!", + "message": "New map book available for purchase.", + "projection": "retroazimuthal" + } + ``` + +## Shortcut URL +You can just add `generic+` as a prefix to your target URL to use it with the generic service, so +```url +https://example.com/api/v1/postStuff +``` +would become +```url +generic+https://example.com/api/v1/postStuff +``` + +!!! note + Any query variables added to the URL will be escaped so that they can be forwarded to the remote server. That means that you cannot use `?template=json` with the `generic+https://`, just use `generic://` instead! + +## Forwarded query variables +All query variables that are not listed in the [Query/Param Props](#queryparam_props) section will be +forwarded to the target endpoint. +If you need to pass a query variable that _is_ reserved, you can prefix it with an underscore (`_`). + +!!! example + The URL `generic+https://example.com/api/v1/postStuff?contenttype=text/plain` would send a POST message + to `https://example.com/api/v1/postStuff` using the `Content-Type: text/plain` header. + + If instead escaped, `generic+https://example.com/api/v1/postStuff?_contenttype=text/plain` would send a POST message + to `https://example.com/api/v1/postStuff?contenttype=text/plain` using the `Content-Type: application/json` header (as it's the default). + + +## URL Format + +--8<-- "docs/services/generic/config.md" diff --git a/docs/services/googlechat.md b/docs/services/googlechat.md new file mode 100644 index 0000000..21c85d3 --- /dev/null +++ b/docs/services/googlechat.md @@ -0,0 +1,37 @@ +# Google Chat + +## URL Format + +Your Google Chat Incoming Webhook URL will look like this: + +!!! info "" + https://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ + +The shoutrrr service URL should look like this: + +!!! info "" + googlechat://chat.googleapis.com/v1/spaces/__`FOO`__/messages?key=__`bar`__&token=__`baz`__ + +In other words the incoming webhook URL with `https` replaced by `googlechat`. + +Google Chat was previously known as Hangouts Chat. Using `hangouts` in +the service URL instead `googlechat` is still supported, although +deprecated. + +## Creating an incoming webhook in Google Chat + +1. Open the room you would like to add Shoutrrr to and open the chat +room menu. +![Screenshot 1](googlechat/hangouts-1.png) + +2. Then click on *Configure webhooks*. +![Screenshot 2](googlechat/hangouts-2.png) + +3. Name the webhook and save. +![Screenshot 3](googlechat/hangouts-3.png) + +4. Copy the URL. +![Screenshot 4](googlechat/hangouts-4.png) + + +5. Format the service URL by replacing `https` with `googlechat`. diff --git a/docs/services/googlechat/hangouts-1.png b/docs/services/googlechat/hangouts-1.png new file mode 100644 index 0000000..4786cb3 Binary files /dev/null and b/docs/services/googlechat/hangouts-1.png differ diff --git a/docs/services/googlechat/hangouts-2.png b/docs/services/googlechat/hangouts-2.png new file mode 100644 index 0000000..552697c Binary files /dev/null and b/docs/services/googlechat/hangouts-2.png differ diff --git a/docs/services/googlechat/hangouts-3.png b/docs/services/googlechat/hangouts-3.png new file mode 100644 index 0000000..55028f0 Binary files /dev/null and b/docs/services/googlechat/hangouts-3.png differ diff --git a/docs/services/googlechat/hangouts-4.png b/docs/services/googlechat/hangouts-4.png new file mode 100644 index 0000000..2b0dbad Binary files /dev/null and b/docs/services/googlechat/hangouts-4.png differ diff --git a/docs/services/gotify.md b/docs/services/gotify.md new file mode 100644 index 0000000..030a9f5 --- /dev/null +++ b/docs/services/gotify.md @@ -0,0 +1,18 @@ +# Gotify + +## URL Format + +--8<-- "docs/services/gotify/config.md" + +## Examples + +!!! example "Common usage" + + ```uri + gotify://gotify.example.com:443/AzyoeNS.D4iJLVa/?title=Great+News&priority=1 + ``` + +!!! example "With subpath" + ```uri + gotify://example.com:443/path/to/gotify/AzyoeNS.D4iJLVa/?title=Great+News&priority=1 + ``` \ No newline at end of file diff --git a/docs/services/hangouts.md b/docs/services/hangouts.md new file mode 100644 index 0000000..2421a4c --- /dev/null +++ b/docs/services/hangouts.md @@ -0,0 +1,7 @@ +# Hangouts Chat + +Google Chat was previously known as *Hangouts Chat*. See [Google +Chat](googlechat.md). + +Using `hangouts` in the service URL instead `googlechat` is still +supported, although deprecated. diff --git a/docs/services/ifttt.md b/docs/services/ifttt.md new file mode 100644 index 0000000..cde9907 --- /dev/null +++ b/docs/services/ifttt.md @@ -0,0 +1,8 @@ +# IFTTT + +## URL Format + +!!! info "" + ifttt://__`key`__/?events=__`event1`__[,__`event2`__,...]&value1=__`value1`__&value2=__`value2`__&value3=__`value3`__ + +--8<-- "docs/services/ifttt/config.md" \ No newline at end of file diff --git a/docs/services/join.md b/docs/services/join.md new file mode 100644 index 0000000..926b53f --- /dev/null +++ b/docs/services/join.md @@ -0,0 +1,21 @@ +# Join + +## URL Format + +!!! info "" + join://shoutrrr:__`api-key`__@join/?devices=__`device1`__[,__`device2`__, ...][&icon=__`icon`__][&title=__`title`__] + +--8<-- "docs/services/join/config.md" + +## Guide + +1. Go to the [Join Webapp](https://joinjoaomgcd.appspot.com/) +2. Select your device +3. Click **Join API** +4. Your `deviceId` is shown in the top +5. Click **Show** next to `API Key` to see your key +6. Your Shoutrrr URL will then be: + `join://shoutrrr:`__`api-key`__`@join/?devices=`__`deviceId`__ + +!!! note "" + Multiple `deviceId`s can be combined with a `,` (repeat steps 2-4). \ No newline at end of file diff --git a/docs/services/lark.md b/docs/services/lark.md new file mode 100644 index 0000000..59efc19 --- /dev/null +++ b/docs/services/lark.md @@ -0,0 +1,46 @@ +# Lark + +Send notifications to Lark using a custom bot webhook. + +## URL Format + +!!! info "" + lark://__`host`__/__`token`__?secret=__`secret`__&title=__`title`__&link=__`url`__ + +--8<-- "docs/services/lark/config.md" + +- `host`: The bot API host (`open.larksuite.com` for Lark, `open.feishu.cn` for Feishu). +- `token`: The bot webhook token (required). +- `secret`: Optional bot secret for signed requests. +- `title`: Optional message title (switches to post format if set). +- `link`: Optional URL to include as a clickable link in the message. + +### Example URL + +```url +lark://open.larksuite.com/abc123?secret=xyz789&title=Alert&link=https://example.com +``` + +## Create a Custom Bot in Lark + +Official Documentation: [Custom Bot Guide](https://open.larksuite.com/document/client-docs/bot-v3/add-custom-bot) + +1. __Invite the Custom Bot to a Group__: + a. Open the target group, click `More` in the upper-right corner, and then select `Settings`. + b. In the `Settings` panel, click `Group Bot`. + c. Click `Add a Bot` under `Group Bot`. + d. In the `Add Bot` dialog, locate `Custom Bot` and select it. + e. Set the bot’s name and description, then click `Add`. + f. Copy the webhook address and click `Finish`. + +2. __Get Host and Token__: + - For __Lark__: Use `host = open.larksuite.com`. + - For __Feishu__: Use `host = open.feishu.cn`. + - The `token` is the last segment of the webhook URL. + For example, in `https://open.larksuite.com/open-apis/bot/v2/hook/xxxxxxxxxxxxxxxxx`, the token is `xxxxxxxxxxxxxxxxx`. + +3. __Get Secret (Optional)__: + a. In group settings, open the bot list, find your custom bot, and select it to access its configuration. + b. Under `Security Settings`, enable `Signature Verification`. + c. Click `Copy` to save the secret. + d. Click `Save` to apply the changes. diff --git a/docs/services/logger.md b/docs/services/logger.md new file mode 100644 index 0000000..9db6e86 --- /dev/null +++ b/docs/services/logger.md @@ -0,0 +1,6 @@ +# Logger + +No configuration options are available for this service. + +It simply emits notifications to the Shoutrrr log which is +configured by the consumer. \ No newline at end of file diff --git a/docs/services/matrix.md b/docs/services/matrix.md new file mode 100644 index 0000000..9484208 --- /dev/null +++ b/docs/services/matrix.md @@ -0,0 +1,44 @@ +# Matrix + +!!! note Usage of the `title` parameter + Do note that Matrix will discard any information put in the `title` parameter as the service has no analogue to a + a title. Instead, use a custom message format to supply your wanted title as part of the message. + +## URL Format + +*matrix://__`user`__:__`password`__@__`host`__:__`port`__/[?rooms=__`!roomID1`__[,__`roomAlias2`__]][&disableTLS=yes]* + +--8<-- "docs/services/matrix/config.md" + +## Authentication + +If no `user` is specified, the `password` is treated as the authentication token. This means that no matter what login +flow your server uses, if you can manually retrieve a token, then Shoutrrr can use it. + +### Password Login Flow + +If a `user` and `password` is supplied, the `m.login.password` login flow is attempted if the server supports it. + +## Rooms + +If `rooms` are *not* specified, the service will send the message to all the rooms that the user has currently joined. + +Otherwise, the service will only send the message to the specified rooms. If the user is *not* in any of those rooms, +but have been invited to it, it will automatically accept that invite. + +**Note**: The service will **not** join any rooms unless they are explicitly specified in `rooms`. If you need the user +to join those rooms, you can send a notification with `rooms` explicitly set once. + +### Room Lookup + +Rooms specified in `rooms` will be treated as room IDs if the start with a `!` and used directly to identify rooms. If +they have no such prefix (or use a *correctly escaped* `#`) they will instead be treated as aliases, and a directory +lookup will be used to resolve their corresponding IDs. + +**Note**: Don't use unescaped `#` for the channel aliases as that will be treated as the `fragment` part of the URL. +Either omit them or URL encode them, I.E. `rooms=%23alias:server` or `rooms=alias:server` + +### TLS + +If you do not have TLS enabled on the server you can disable it by providing `disableTLS=yes`. This will effectively +use `http` intead of `https` for the API calls. diff --git a/docs/services/mattermost.md b/docs/services/mattermost.md new file mode 100644 index 0000000..d64d1d1 --- /dev/null +++ b/docs/services/mattermost.md @@ -0,0 +1,69 @@ +# MatterMost + +## URL Format + +!!! info "" + mattermost://[__`username`__@]__`mattermost-host`__/__`token`__[/__`channel`__][?icon=__`smiley`__&disabletls=__`yes`__] + +--8<-- "docs/services/mattermost/config.md" + + + +## Creating a Webhook in MatterMost + +1. Open up the Integrations page by clicking on *Integrations* within the menu +![Screenshot 1](mattermost/1.PNG) + +2. Click *Incoming Webhooks* +![Screenshot 2](mattermost/2.PNG) + +3. Click *Add Incoming Webhook* +![Screenshot 3](mattermost/3.PNG) + +4. Fill in the information for the webhook and click *Save* +![Screenshot 4](mattermost/4.PNG) + +5. If you did everything correctly, MatterMost will give you the *URL* to your newly created webhook +![Screenshot 5](mattermost/5.PNG) + +6. Format the service URL +``` +https://your-domain.com/hooks/bywsw8zt5jgpte3nm65qjiru6h + └────────────────────────┘ + token +mattermost://your-domain.com/bywsw8zt5jgpte3nm65qjiru6h + └────────────────────────┘ + token +``` + +## Additional URL configuration + +Mattermost provides functionality to post as another user or to another channel, compared to the webhook configuration. +
+To do this, you can add a *user* and/or *channel* to the service URL. + +``` +mattermost://shoutrrrUser@your-domain.com/bywsw8zt5jgpte3nm65qjiru6h/shoutrrrChannel + └──────────┘ └────────────────────────┘ └─────────────┘ + user token channel +``` + +## Passing parameters via code + +If you want to, you also have the possibility to pass parameters to the `send` function. +
+The following example contains all parameters that are currently supported. + +```gotemplate +params := (*types.Params)( + &map[string]string{ + "username": "overwriteUserName", + "channel": "overwriteChannel", + "icon": "overwriteIcon", + }, +) + +service.Send("this is a message", params) +``` + +This will overwrite any options, that you passed via URL. \ No newline at end of file diff --git a/docs/services/mattermost/1.PNG b/docs/services/mattermost/1.PNG new file mode 100644 index 0000000..6a385d9 Binary files /dev/null and b/docs/services/mattermost/1.PNG differ diff --git a/docs/services/mattermost/2.PNG b/docs/services/mattermost/2.PNG new file mode 100644 index 0000000..814f566 Binary files /dev/null and b/docs/services/mattermost/2.PNG differ diff --git a/docs/services/mattermost/3.PNG b/docs/services/mattermost/3.PNG new file mode 100644 index 0000000..411a56f Binary files /dev/null and b/docs/services/mattermost/3.PNG differ diff --git a/docs/services/mattermost/4.PNG b/docs/services/mattermost/4.PNG new file mode 100644 index 0000000..e94535b Binary files /dev/null and b/docs/services/mattermost/4.PNG differ diff --git a/docs/services/mattermost/5.PNG b/docs/services/mattermost/5.PNG new file mode 100644 index 0000000..9afa2b0 Binary files /dev/null and b/docs/services/mattermost/5.PNG differ diff --git a/docs/services/ntfy.md b/docs/services/ntfy.md new file mode 100644 index 0000000..9014905 --- /dev/null +++ b/docs/services/ntfy.md @@ -0,0 +1,7 @@ +# Ntfy + +Upstream docs: https://docs.ntfy.sh/publish/ + +## URL Format + +--8<-- "docs/services/ntfy/config.md" \ No newline at end of file diff --git a/docs/services/opsgenie.md b/docs/services/opsgenie.md new file mode 100644 index 0000000..d844f5d --- /dev/null +++ b/docs/services/opsgenie.md @@ -0,0 +1,66 @@ +# OpsGenie + +## URL Format + +--8<-- "docs/services/opsgenie/config.md" + +## Creating a REST API endpoint in OpsGenie + +1. Open up the Integration List page by clicking on *Settings => Integration List* within the menu +![Screenshot 1](opsgenie/1.png) + +2. Click *API => Add* + +3. Make sure *Create and Update Access* and *Enabled* are checked and click *Save Integration* +![Screenshot 2](opsgenie/2.png) + +4. Copy the *API Key* + +5. Format the service URL + +The host can be either api.opsgenie.com or api.eu.opsgenie.com depending on the location of your instance. See +the [OpsGenie documentation](https://docs.opsgenie.com/docs/alert-api) for details. + +``` +opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889 + └───────────────────────────────────┘ + token +``` + +## Passing parameters via code + +If you want to, you can pass additional parameters to the `send` function. +
+The following example contains all parameters that are currently supported. + +```go +service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, + "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, + "actions": "An action", + "tags": "tag1 tag2", + "details": `{"key1": "value1", "key2": "value2"}`, + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", +}) +``` + +## Optional parameters + +You can optionally specify the parameters in the URL: + +!!! info "" + opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=["tag1","tag2"]&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note + +Example using the command line: + +```shell +shoutrrr send -u 'opsgenie://api.eu.opsgenie.com/token?tags=["tag1","tag2"]&description=testing&responders=[{"username":"superuser", "type": "user"}]&entity=Example Entity&source=Example Source&actions=["asdf", "bcde"]' -m "Hello World6" +``` + + diff --git a/docs/services/opsgenie/1.png b/docs/services/opsgenie/1.png new file mode 100644 index 0000000..dc4cadf Binary files /dev/null and b/docs/services/opsgenie/1.png differ diff --git a/docs/services/opsgenie/2.png b/docs/services/opsgenie/2.png new file mode 100644 index 0000000..0bf1cc2 Binary files /dev/null and b/docs/services/opsgenie/2.png differ diff --git a/docs/services/overview.md b/docs/services/overview.md new file mode 100644 index 0000000..6341b66 --- /dev/null +++ b/docs/services/overview.md @@ -0,0 +1,32 @@ +# Services overview + +Click on the service for a more thorough explanation. + +| Service | URL format | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| [Bark](./bark.md) | *bark://__`devicekey`__@__`host`__* | +| [Discord](./discord.md) | *discord://__`token`__@__`id`__[?thread_id=__`threadid`__]* | +| [Email](./email.md) | *smtp://__`username`__:__`password`__@__`host`__:__`port`__/?from=__`fromAddress`__&to=__`recipient1`__[,__`recipient2`__,...]* | +| [Gotify](./gotify.md) | *gotify://__`gotify-host`__/__`token`__* | +| [Google Chat](./googlechat.md) | *googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz* | +| [IFTTT](./ifttt.md) | *ifttt://__`key`__/?events=__`event1`__[,__`event2`__,...]&value1=__`value1`__&value2=__`value2`__&value3=__`value3`__* | +| [Join](./join.md) | *join://shoutrrr:__`api-key`__@join/?devices=__`device1`__[,__`device2`__, ...][&icon=__`icon`__][&title=__`title`__]* | +| [Mattermost](./mattermost.md) | *mattermost://[__`username`__@]__`mattermost-host`__/__`token`__[/__`channel`__]* | +| [Matrix](./matrix.md) | *matrix://__`username`__:__`password`__@__`host`__:__`port`__/[?rooms=__`!roomID1`__[,__`roomAlias2`__]]* | +| [Ntfy](./ntfy.md) | *ntfy://__`username`__:__`password`__@ntfy.sh/__`topic`__* | +| [OpsGenie](./opsgenie.md) | *opsgenie://__`host`__/token?responders=__`responder1`__[,__`responder2`__]* | +| [Pushbullet](./pushbullet.md) | *pushbullet://__`api-token`__[/__`device`__/#__`channel`__/__`email`__]* | +| [Pushover](./pushover.md) | *pushover://shoutrrr:__`apiToken`__@__`userKey`__/?devices=__`device1`__[,__`device2`__, ...]* | +| [Rocketchat](./rocketchat.md) | *rocketchat://[__`username`__@]__`rocketchat-host`__/__`token`__[/__`channel`|`@recipient`__]* | +| [Slack](./slack.md) | *slack://[__`botname`__@]__`token-a`__/__`token-b`__/__`token-c`__* | +| [Teams](./teams.md) | *teams://__`group`__@__`tenant`__/__`altId`__/__`groupOwner`__?host=__`organization`__.webhook.office.com* | +| [Telegram](./telegram.md) | *telegram://__`token`__@telegram?chats=__`@channel-1`__[,__`chat-id-1`__,...]* | +| [Zulip Chat](./zulip.md) | *zulip://__`bot-mail`__:__`bot-key`__@__`zulip-domain`__/?stream=__`name-or-id`__&topic=__`name`__* | +| [Lark](./lark.md) | *lark://__`host`__/__`token`__?secret=__`secret`__&title=__`title`__&link=__`url`__* | + +## Specialized services + +| Service | Description | +| --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | +| [Logger](./logger.md) | Writes notification to a configured go `log.Logger` | +| [Generic Webhook](./generic.md) | Sends notifications directly to a webhook | diff --git a/docs/services/pushbullet.md b/docs/services/pushbullet.md new file mode 100644 index 0000000..07f6ccd --- /dev/null +++ b/docs/services/pushbullet.md @@ -0,0 +1,8 @@ +# Pushbullet + +## URL Format + +!!! info "" + pushbullet://__`api-token`__[/__`device`__/#__`channel`__/__`email`__] + +--8<-- "docs/services/pushbullet/config.md" \ No newline at end of file diff --git a/docs/services/pushover.md b/docs/services/pushover.md new file mode 100644 index 0000000..b92ce4c --- /dev/null +++ b/docs/services/pushover.md @@ -0,0 +1,32 @@ +# Pushover + +## URL Format + +!!! info "" + pushover://shoutrrr:__`apiToken`__@__`userKey`__/?devices=__`device1`__[,__`device2`__, ...] + +--8<-- "docs/services/pushover/config.md" + +## Getting the keys from Pushover + +At your [Pushover dashboard](https://pushover.net/) you can view your __`userKey`__ in the top right. +![Screenshot 1](pushover/po-1.png) + +The `Name` column of the device list is what is used to refer to your devices (__`device1`__ etc.) +![Screenshot 4](pushover/po-4.png) + +At the bottom of the same page there are links your _applications_, where you can find your __`apiToken`__ +![Screenshot 2](pushover/po-2.png) + +The __`apiToken`__ is displayed at the top of the application page. +![Screenshot 3](pushover/po-3.png) + +## Optional parameters + +You can optionally specify the __`title`__ and __`priority`__ parameters in the URL: +*pushover://shoutrrr:__`token`__@__`userKey`__/?devices=__`device`__&title=Custom+Title&priority=1* + +!!! note + Only supply priority values between -1 and 1, since 2 requires additional parameters that are not supported yet. + +Please refer to the [Pushover API documentation](https://pushover.net/api#messages) for more information. diff --git a/docs/services/pushover/po-1.png b/docs/services/pushover/po-1.png new file mode 100644 index 0000000..1797b1e Binary files /dev/null and b/docs/services/pushover/po-1.png differ diff --git a/docs/services/pushover/po-2.png b/docs/services/pushover/po-2.png new file mode 100644 index 0000000..d668500 Binary files /dev/null and b/docs/services/pushover/po-2.png differ diff --git a/docs/services/pushover/po-3.png b/docs/services/pushover/po-3.png new file mode 100644 index 0000000..90c333c Binary files /dev/null and b/docs/services/pushover/po-3.png differ diff --git a/docs/services/pushover/po-4.png b/docs/services/pushover/po-4.png new file mode 100644 index 0000000..653e431 Binary files /dev/null and b/docs/services/pushover/po-4.png differ diff --git a/docs/services/rocketchat.md b/docs/services/rocketchat.md new file mode 100644 index 0000000..5f62935 --- /dev/null +++ b/docs/services/rocketchat.md @@ -0,0 +1,66 @@ +# Rocket.chat + +## URL Format + +!!! info "" + rocketchat://[__`username`__@]__`rocketchat-host`__/__`token`__[/__`channel`|`@recipient`__]* + +--8<-- "docs/services/rocketchat/config.md" + +## Creating a Webhook in Rocket.chat + +1. Open up the chat Administration by clicking on *Administration* menu +![Screenshot 1](rocketchat/1.png) + +2. Open *Integrations* and then click *New* +![Screenshot 2](rocketchat/2.png) + +3. Fill in the information for the webhook and click *Save*. Please don't forget to Enable your integration. +![Screenshot 3](rocketchat/3.png) + +5. If you did everything correctly, Rocket.chat will give you the *URL* and *Token* to your newly created webhook. +![Screenshot 4](rocketchat/4.png) + +6. Format the service URL +``` +rocketchat://your-domain.com/8eGdRzc9r4YYNyvge/2XYQcX9NBwJBKfQnphpebPcnXZcPEi32Nt4NKJfrnbhsbRfX + └────────────────────────────────────────────────────────────────┘ + token +``` + +## Additional URL configuration + +Rocket.chat provides functionality to post as another user or to another channel / user, compared to the webhook configuration. +
+To do this, you can add a *sender* and/or *channel* / *receiver* to the service URL. + +``` +rocketchat://shoutrrrUser@your-domain.com/8eGdRzc9r4YYNyvge/2XYQcX9NBwJBKfQnphpebPcnXZcPEi32Nt4NKJfrnbhsbRfX/shoutrrrChannel + └──────────┘ └────────────────────────────────────────────────────────────────┘ └─────────────┘ + sender token channel + +rocketchat://shoutrrrUser@your-domain.com/8eGdRzc9r4YYNyvge/2XYQcX9NBwJBKfQnphpebPcnXZcPEi32Nt4NKJfrnbhsbRfX/@shoutrrrReceiver + └──────────┘ └────────────────────────────────────────────────────────────────┘ └───────────────┘ + sender token receiver +``` + +## Passing parameters via code + +If you want to, you also have the possibility to pass parameters to the `send` function. +
+The following example contains all parameters that are currently supported. + +```gotemplate +params := (*types.Params)( + &map[string]string{ + "username": "overwriteUserName", + "channel": "overwriteChannel", + }, +) + +service.Send("this is a message", params) +``` + +This will overwrite any options, that you passed via URL. + +For more Rocket.chat Webhooks options see [official guide](https://docs.rocket.chat/guides/administrator-guides/integrations). diff --git a/docs/services/rocketchat/1.png b/docs/services/rocketchat/1.png new file mode 100644 index 0000000..fdf8fb5 Binary files /dev/null and b/docs/services/rocketchat/1.png differ diff --git a/docs/services/rocketchat/2.png b/docs/services/rocketchat/2.png new file mode 100644 index 0000000..7f98517 Binary files /dev/null and b/docs/services/rocketchat/2.png differ diff --git a/docs/services/rocketchat/3.png b/docs/services/rocketchat/3.png new file mode 100644 index 0000000..b961fb8 Binary files /dev/null and b/docs/services/rocketchat/3.png differ diff --git a/docs/services/rocketchat/4.png b/docs/services/rocketchat/4.png new file mode 100644 index 0000000..019fe9d Binary files /dev/null and b/docs/services/rocketchat/4.png differ diff --git a/docs/services/slack.md b/docs/services/slack.md new file mode 100644 index 0000000..9cb7e23 --- /dev/null +++ b/docs/services/slack.md @@ -0,0 +1,36 @@ +# Slack + +!!! attention "New URL format" + The URL format for Slack has been changed to allow for API- as well as webhook tokens. + Using the old format (`slack://xxxx/yyyy/zzzz`) will still work as before and will automatically be upgraded to + the new format when used. + +The Slack notification service uses either [Slack Webhooks](https://api.slack.com/messaging/webhooks) or the +[Bot API](https://api.slack.com/methods/chat.postMessage) to send messages. + +See the [guides](../guides/slack/index.md) for information on how to get your *token* and *channel*. + + +## URL Format + +!!! note "" + Note that the token uses a prefix to determine the type, usually either `hook` (for webhooks) or `xoxb` (for bot API). + +--8<-- "docs/services/slack/config.md" + +!!! info "Color format" + The format for the `Color` prop follows the [slack docs](https://api.slack.com/reference/messaging/attachments#fields) + but `#` needs to be escaped as `%23` when passed in a URL. + So #ff8000 would be `%23ff8000` etc. + +## Examples + +!!! example "Bot API" + ```uri + slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C001CH4NN3L?color=good&title=Great+News&icon=man-scientist&botname=Shoutrrrbot + ``` + +!!! example "Webhook" + ```uri + slack://hook:WNA3PBYV6-F20DUQND3RQ-Webc4MAvoacrpPakR8phF0zi@webhook?color=good&title=Great+News&icon=man-scientist&botname=Shoutrrrbot + ``` \ No newline at end of file diff --git a/docs/services/teams.md b/docs/services/teams.md new file mode 100644 index 0000000..125ff3c --- /dev/null +++ b/docs/services/teams.md @@ -0,0 +1,69 @@ +# Teams + +!!! attention "New webhook URL format only" + Shoutrrr now only supports the new Teams webhook URL format with an + organization-specific domain. + + You must specify your organization domain using: + + ```text + ?host=example.webhook.office.com + ``` + Where `example` is your organization's short name. + + Legacy webhook formats (e.g., `outlook.office.com`) are no longer supported. + +## URL Format + +``` +teams://group@tenant/altId/groupOwner/extraId?host=organization.webhook.office.com[&color=color][&title=title] +``` + +Where: + +- `group`: The first UUID component from the webhook URL. +- `tenant`: The second UUID component from the webhook URL. +- `altId`: The third component (hex string) from the webhook URL. +- `groupOwner`: The fourth UUID component from the webhook URL. +- `extraId`: The fifth component at the end of the webhook URL. +- `organization`: Your organization name for the webhook domain (required). +- `color`: Optional hex color code for the message card (e.g., `FF0000` for red). +- `title`: Optional title for the message card. + +--8<-- "docs/services/teams/config.md" + +## Setting up a webhook + +To use the Microsoft Teams notification service, you need to set up a custom +incoming webhook. Follow the instructions in [this Microsoft guide](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook#create-an-incoming-webhook). + +## Extracting the token + +The token is extracted from your webhook URL: + +
https://<organization>.webhook.office.com/webhookb2/<group>@<tenant>/IncomingWebhook/<altId>/<groupOwner>/<extraId>
+ +!!! note "Important components" + All parts of the webhook URL are required: + + - `organization`: Your organization name (e.g., `contoso`). + - `group`: First UUID component. + - `tenant`: Second UUID component. + - `altId`: Third component (hex string). + - `groupOwner`: Fourth UUID component. + - `extraId`: Fifth component. + +## Example + +``` +# Original webhook URL: +https://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05 + +# Shoutrrr URL: +teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=contoso.webhook.office.com&color=FF0000&title=Alert +``` + +In this example: + +- `color=FF0000` sets a red theme. +- `title=Alert` adds a custom title to the message card. diff --git a/docs/services/telegram.md b/docs/services/telegram.md new file mode 100644 index 0000000..f82715e --- /dev/null +++ b/docs/services/telegram.md @@ -0,0 +1,73 @@ +# Telegram + +## URL Format + +!!! info "" + telegram://__`token`__@telegram?chats=__`channel-1`__[,__`chat-id-1`__,...] + +--8<-- "docs/services/telegram/config.md" + +## Getting a token for Telegram + +Talk to [the botfather](https://core.telegram.org/bots#6-botfather). + +## Identifying the target chats/channels + +The `chats` param consists of one or more `Chat ID`s or `channel name`s. + +### Public Channels +The channel names can be retrieved in the telegram client in the `Channel info` section for public channels. +Replace the `t.me/` prefix from the link with a `@`. + +!!! note + Channels names need to be prefixed by `@` to identify them as such. + +!!! note + If your channel only has an invite link (starting with `t.me/+`), you have to use it's Chat ID (see below) + +!!! note + A `message_thread_id` param ([reference](https://core.telegram.org/bots/api#sendmessage)) can be added, with the format of `$chat_id:$message_thread_id`. [More info](https://stackoverflow.com/questions/74773675/how-to-get-topic-id-for-telegram-group-chat/75178418#75178418) on how to obtain the `message_thread_id`. + +### Chats +Private channels, Group chats and private chats are identified by `Chat ID`s. Unfortunatly, they are generally not visible in the +telegram clients. +The easiest way to retrieve them is by using the `shoutrrr generate telegram` command which will guide you through +creating a URL with your target chats. + +!!! tip + You can use the `nickfedor/shoutrrr` image in docker to run it without download/installing the `shoutrrr` CLI using: + ``` + docker run --rm -it nickfedor/shoutrrr generate telegram + ``` + +### Asking @shoutrrrbot +Another way of retrieving the Chat IDs, is by forwarding a message from the target chat to the [@shoutrrrbot](https://t.me/shoutrrrbot). +It will reply with the Chat ID for the chat where the forwarded message was originally posted. +Note that it will not work correctly for Group chats, as those messages are just seen as being posted by a user, not in a specific chat. +Instead you can use the second method, which is to invite the @shoutrrrbot into your group chat and address a message to it (start the message with @shoutrrrbot). You can then safely kick the bot from the group. + +The bot should be constantly online, unless it's usage exceeds the free tier on GCP. It's source is available at [github.com/nicholas-fedor/shoutrrrbot](https://github.com/nicholas-fedor/shoutrrrbot). + + + +## Optional parameters + +You can optionally specify the __`notification`__, __`parseMode`__ and __`preview`__ parameters in the URL: + +!!! info "" +
telegram://__`token`__@__`telegram`__/?channels=__`channel`__¬ification=no&preview=false&parseMode=html
+ +See [the telegram documentation](https://core.telegram.org/bots/api#sendmessage) for more information. + +!!! note + `preview` and `notification` are inverted in regards to their API counterparts (`disable_web_page_preview` and `disable_notification`) + +### Parse Mode and Title + +If a parse mode is specified, the message needs to be escaped as per the corresponding sections in +[Formatting options](https://core.telegram.org/bots/api#formatting-options). + +When a title has been specified, it will be prepended to the message, but this is only supported for +the `HTML` parse mode. Note that, if no parse mode is specified, the message will be escaped and sent using `HTML`. + +Since the markdown modes are really hard to escape correctly, it's recommended to stick to `HTML` parse mode. diff --git a/docs/services/zulip.md b/docs/services/zulip.md new file mode 100644 index 0000000..3ff461a --- /dev/null +++ b/docs/services/zulip.md @@ -0,0 +1,29 @@ +# Zulip Chat + +## URL Format + +The shoutrrr service URL should look like this: +!!! info "" + zulip://__`botmail`__:__`botkey`__@__`host`__/?stream=__`stream`__&topic=__`topic`__ + +--8<-- "docs/services/zulip/config.md" + +!!! note + Since __`botmail`__ is a mail address you need to URL escape the `@` in it to `%40`. + +### Examples + +Stream and topic are both optional and can be given as parameters to the Send method: + +```go + sender, _ := shoutrrr.CreateSender(url) + + params := make(types.Params) + params["stream"] = "mystream" + params["topic"] = "This is my topic" + + sender.Send(message, ¶ms) +``` + +!!! example "Example service URL" + zulip://my-bot%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo&topic=bar diff --git a/docs/shoutrrr-180px.png b/docs/shoutrrr-180px.png new file mode 100644 index 0000000..39ec1f4 Binary files /dev/null and b/docs/shoutrrr-180px.png differ diff --git a/docs/shoutrrr-logotype.png b/docs/shoutrrr-logotype.png new file mode 100644 index 0000000..f901ff0 Binary files /dev/null and b/docs/shoutrrr-logotype.png differ diff --git a/docs/shoutrrr-logotype.svg b/docs/shoutrrr-logotype.svg new file mode 100644 index 0000000..5665a0a --- /dev/null +++ b/docs/shoutrrr-logotype.svg @@ -0,0 +1,269 @@ + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/shoutrrr.jpg b/docs/shoutrrr.jpg new file mode 100644 index 0000000..11e9c5c Binary files /dev/null and b/docs/shoutrrr.jpg differ diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css new file mode 100644 index 0000000..83c0653 --- /dev/null +++ b/docs/stylesheets/extra.css @@ -0,0 +1,30 @@ + +.md-typeset li img { + display: inline-block; +} + +.md-typeset figure { + background: var(--md-code-bg-color); + display: block; + width: 100%; +} + +.md-typeset figure img { + box-shadow: 2px 2px 4px #00000080; + padding: 3px; + background: var(--md-code-bg-color); +} + + +.md-typeset li img:last-child { + margin: 10px 0; +} + +.badges img { + height: 20px; + max-width: 100%; + display: inline-block; + padding: 0; + background: transparent; + border-radius: 3px; +} \ No newline at end of file diff --git a/docs/stylesheets/theme.css b/docs/stylesheets/theme.css new file mode 100644 index 0000000..512016c --- /dev/null +++ b/docs/stylesheets/theme.css @@ -0,0 +1,8 @@ +[data-md-color-scheme="shoutrrr"] { + --md-primary-fg-color: hsl(193, 100%, 44%); + --md-primary-fg-color--light: hsl(193, 100%, 55%); + --md-primary-fg-color--dark: hsl(193, 76%, 33%); + --md-accent-fg-color: hsl(39, 84%, 44%); + --md-accent-fg-color--transparent: hsla(39, 84%, 58%, 0.1); + --md-typeset-a-color: var(--md-primary-fg-color--dark); +} diff --git a/generate-release-notes.sh b/generate-release-notes.sh new file mode 100644 index 0000000..f7ce7e9 --- /dev/null +++ b/generate-release-notes.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +current=$1 +if [ -z "$current" ]; then + echo "Missing argument VERSION" + exit 1 +fi + +tags=($(git tag --list)) + +for i in "${!tags[@]}"; do + if [[ "${tags[$i]}" = "$current" ]]; then + previous="${tags[$i - 1]}" + break + fi +done + +if [ -z "$previous" ]; then + echo "Invalid tag, or could not find previous tag" + exit 1 +fi + +echo -e "\e[97mListing changes from \e[96m$previous\e[97m to \e[96m$current\e[0m:\n" + +changes=$(git log --pretty=format:"* %h %s" $previous...$current) + +echo "## Changelog" +echo "$changes" | grep -v "chore(deps)" | grep -v "Merge " | grep -v "chore(ci)" +echo +echo "### Dependencies" +echo "$changes" | grep "chore(deps)" \ No newline at end of file diff --git a/generate-service-config-docs.sh b/generate-service-config-docs.sh new file mode 100755 index 0000000..aa1d860 --- /dev/null +++ b/generate-service-config-docs.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +set -e + +function generate_docs() { + SERVICE=$1 + DOCSPATH=./docs/services/$SERVICE + echo -en "Creating docs for \e[96m$SERVICE\e[0m... " + mkdir -p "$DOCSPATH" + go run ./shoutrrr docs -f markdown "$SERVICE" > "$DOCSPATH"/config.md + if [ $? ]; then + echo -e "Done!" + fi +} + +if [[ -n "$1" ]]; then + generate_docs "$1" + exit 0 +fi + +for S in ./pkg/services/*; do + SERVICE=$(basename "$S") + if [[ "$SERVICE" == "standard" ]] || [[ "$SERVICE" == "xmpp" ]] || [[ -f "$S" ]]; then + continue + fi + generate_docs "$SERVICE" +done diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..aef11bc --- /dev/null +++ b/go.mod @@ -0,0 +1,44 @@ +module github.com/nicholas-fedor/shoutrrr + +go 1.24.2 + +require ( + github.com/fatih/color v1.18.0 + github.com/jarcoal/httpmock v1.4.0 + github.com/mattn/go-colorable v0.1.14 + github.com/onsi/ginkgo/v2 v2.23.4 + github.com/onsi/gomega v1.37.0 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + golang.org/x/oauth2 v0.30.0 +) + +require ( + cloud.google.com/go/compute/metadata v0.6.0 // indirect + golang.org/x/net v0.40.0 +) + +require ( + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/sagikazarmark/locafero v0.9.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.14.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect + golang.org/x/tools v0.32.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1723270 --- /dev/null +++ b/go.sum @@ -0,0 +1,96 @@ +cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= +cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k= +github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/maxatome/go-testdeep v1.14.0 h1:rRlLv1+kI8eOI3OaBXZwb3O7xY3exRzdW5QyX48g9wI= +github.com/maxatome/go-testdeep v1.14.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= +github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= +github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k= +github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA= +github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= +golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/tools v0.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU= +golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/goreleaser.yml b/goreleaser.yml new file mode 100644 index 0000000..93331ba --- /dev/null +++ b/goreleaser.yml @@ -0,0 +1,110 @@ +version: 2 + +builds: + - main: ./shoutrrr/main.go + binary: shoutrrr/shoutrrr + goos: + - linux + - windows + goarch: + - amd64 + - "386" + - arm + - arm64 + ldflags: + - -s -w -X github.com/nicholas-fedor/shoutrrr/internal/meta.Version={{ .Version }} + +archives: + - id: default # Unique ID for this archive configuration + name_template: >- + {{- .ProjectName }}_ + {{- if eq .Os "darwin" }}macOS + {{- else }}{{ .Os }}{{ end }}_ + {{- if eq .Arch "amd64" }}amd64 + {{- else if eq .Arch "386" }}i386 + {{- else if eq .Arch "arm" }}armhf + {{- else if eq .Arch "arm64" }}arm64v8 + {{- else }}{{ .Arch }}{{ end }}_ + {{- .Version -}} + files: + - LICENSE.md + builds: + - shoutrrr + formats: ["tar.gz"] + - id: windows + name_template: >- + {{- .ProjectName }}_ + {{- .Os }}_ + {{- if eq .Arch "amd64" }}amd64 + {{- else if eq .Arch "386" }}i386 + {{- else if eq .Arch "arm" }}armhf + {{- else if eq .Arch "arm64" }}arm64v8 + {{- else }}{{ .Arch }}{{ end }}_ + {{- .Version -}} + files: + - LICENSE.md + builds: + - shoutrrr + formats: ["zip"] + +dockers: + - use: buildx + build_flag_templates: + - "--platform=linux/amd64" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + goos: linux + goarch: amd64 + goarm: "" + dockerfile: dockerfiles/Dockerfile + image_templates: + - nickfedor/shoutrrr:amd64-{{ .Version }} + - nickfedor/shoutrrr:amd64-latest + - ghcr.io/nicholas-fedor/shoutrrr:amd64-{{ .Version }} + - ghcr.io/nicholas-fedor/shoutrrr:amd64-latest + - use: buildx + build_flag_templates: + - "--platform=linux/i386" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + goos: linux + goarch: "386" + goarm: "" + dockerfile: dockerfiles/Dockerfile + image_templates: + - nickfedor/shoutrrr:i386-{{ .Version }} + - nickfedor/shoutrrr:i386-{{ if .IsSnapshot }}latest-dev{{ else }}latest{{ end }} + - ghcr.io/nicholas-fedor/shoutrrr:i386-{{ .Version }} + - ghcr.io/nicholas-fedor/shoutrrr:i386-{{ if .IsSnapshot }}latest-dev{{ else }}latest{{ end }} + - use: buildx + build_flag_templates: + - "--platform=linux/arm/v6" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + goos: linux + goarch: arm + goarm: 6 + dockerfile: dockerfiles/Dockerfile + image_templates: + - nickfedor/shoutrrr:armhf-{{ .Version }} + - nickfedor/shoutrrr:armhf-{{ if .IsSnapshot }}latest-dev{{ else }}latest{{ end }} + - ghcr.io/nicholas-fedor/shoutrrr:armhf-{{ .Version }} + - ghcr.io/nicholas-fedor/shoutrrr:armhf-{{ if .IsSnapshot }}latest-dev{{ else }}latest{{ end }} + - use: buildx + build_flag_templates: + - "--platform=linux/arm64/v8" + - "--label=org.opencontainers.image.created={{ .Date }}" + - "--label=org.opencontainers.image.version={{ .Version }}" + - "--label=org.opencontainers.image.revision={{ .FullCommit }}" + goos: linux + goarch: arm64 + goarm: "" + dockerfile: dockerfiles/Dockerfile + image_templates: + - nickfedor/shoutrrr:arm64v8-{{ .Version }} + - nickfedor/shoutrrr:arm64v8-latest + - ghcr.io/nicholas-fedor/shoutrrr:arm64v8-{{ .Version }} + - ghcr.io/nicholas-fedor/shoutrrr:arm64v8-{{ if .IsSnapshot }}latest-dev{{ else }}latest{{ end }} diff --git a/internal/dedupe/dedupe.go b/internal/dedupe/dedupe.go new file mode 100644 index 0000000..c5e26a3 --- /dev/null +++ b/internal/dedupe/dedupe.go @@ -0,0 +1,16 @@ +package dedupe + +import "slices" + +// RemoveDuplicates from a slice of strings. +func RemoveDuplicates(src []string) []string { + unique := make([]string, 0, len(src)) + for _, s := range src { + found := slices.Contains(unique, s) + if !found { + unique = append(unique, s) + } + } + + return unique +} diff --git a/internal/dedupe/dedupe_test.go b/internal/dedupe/dedupe_test.go new file mode 100644 index 0000000..3f66002 --- /dev/null +++ b/internal/dedupe/dedupe_test.go @@ -0,0 +1,41 @@ +package dedupe_test + +import ( + "reflect" + "testing" + + "github.com/nicholas-fedor/shoutrrr/internal/dedupe" +) + +func TestRemoveDuplicates(t *testing.T) { + tests := map[string]struct { + input []string + want []string + }{ + "no duplicates": { + input: []string{"a", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + "duplicate inside slice": { + input: []string{"a", "b", "a", "c"}, + want: []string{"a", "b", "c"}, + }, + "duplicate at end of slice": { + input: []string{"a", "b", "c", "a"}, + want: []string{"a", "b", "c"}, + }, + "duplicate next to each other inside slice": { + input: []string{"a", "b", "b", "c"}, + want: []string{"a", "b", "c"}, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := dedupe.RemoveDuplicates(tc.input) + if !reflect.DeepEqual(tc.want, got) { + t.Fatalf("expected: %#v, got: %#v", tc.want, got) + } + }) + } +} diff --git a/internal/failures/failure.go b/internal/failures/failure.go new file mode 100644 index 0000000..f52c365 --- /dev/null +++ b/internal/failures/failure.go @@ -0,0 +1,68 @@ +package failures + +import "fmt" + +// FailureID is a unique identifier for a specific error type. +type FailureID int + +// failure is the concrete implementation of the Failure interface. +// It wraps an error with a message and an ID for categorization. +type failure struct { + message string // Descriptive message for the error + id FailureID // Unique identifier for this error type + wrapped error // Underlying error, if any, for chaining +} + +// Failure extends the error interface with an ID and methods for unwrapping and comparison. +// It allows errors to be identified by a unique ID and supports Go’s error wrapping conventions. +type Failure interface { + error + ID() FailureID // Returns the unique identifier for this failure + Unwrap() error // Returns the wrapped error, if any + Is(target error) bool // Checks if the target error matches this failure by ID +} + +// Error returns the failure’s message, appending the wrapped error’s message if present. +func (f *failure) Error() string { + if f.wrapped == nil { + return f.message + } + + return fmt.Sprintf("%s: %v", f.message, f.wrapped) +} + +// Unwrap returns the underlying error wrapped by this failure, or nil if none exists. +func (f *failure) Unwrap() error { + return f.wrapped +} + +// ID returns the unique identifier assigned to this failure. +func (f *failure) ID() FailureID { + return f.id +} + +// Is reports whether the target error is a failure with the same ID. +// It only returns true for failures of the same type with matching IDs. +func (f *failure) Is(target error) bool { + targetFailure, ok := target.(*failure) + + return ok && targetFailure.id == f.id +} + +// Wrap creates a new failure with the given message, ID, and optional wrapped error. +// If variadic arguments are provided, they are used to format the message using fmt.Sprintf. +// This supports Go’s error wrapping pattern while adding a unique ID for identification. +func Wrap(message string, failureID FailureID, wrappedError error, v ...any) Failure { + if len(v) > 0 { + message = fmt.Sprintf(message, v...) + } + + return &failure{ + message: message, + id: failureID, + wrapped: wrappedError, + } +} + +// Ensure failure implements the error interface at compile time. +var _ error = &failure{} diff --git a/internal/failures/failure_test.go b/internal/failures/failure_test.go new file mode 100644 index 0000000..5c7dc63 --- /dev/null +++ b/internal/failures/failure_test.go @@ -0,0 +1,174 @@ +package failures_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + + "github.com/nicholas-fedor/shoutrrr/internal/failures" + "github.com/nicholas-fedor/shoutrrr/internal/testutils" +) + +// TestFailures runs the Ginkgo test suite for the failures package. +func TestFailures(t *testing.T) { + format.CharactersAroundMismatchToInclude = 20 // Show more context in failure output + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Failure Suite") +} + +var _ = ginkgo.Describe("the failure package", func() { + // Common test fixtures + var ( + testID failures.FailureID = 42 // Consistent ID for testing + testMessage = "test failure occurred" // Sample error message + wrappedErr = errors.New("underlying error") // Sample wrapped error + ) + + ginkgo.Describe("Wrap function", func() { + ginkgo.When("creating a basic failure", func() { + ginkgo.It("returns a failure with the provided message and ID", func() { + failure := failures.Wrap(testMessage, testID, nil) + gomega.Expect(failure.Error()).To(gomega.Equal(testMessage)) + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + gomega.Expect(failure.Unwrap()).To(gomega.Succeed()) + }) + }) + + ginkgo.When("wrapping an existing error", func() { + ginkgo.It("combines the message and wrapped error", func() { + failure := failures.Wrap(testMessage, testID, wrappedErr) + expectedError := fmt.Sprintf("%s: %v", testMessage, wrappedErr) + gomega.Expect(failure.Error()).To(gomega.Equal(expectedError)) + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr)) + }) + }) + + ginkgo.When("using formatted message with arguments", func() { + ginkgo.It("formats the message correctly", func() { + formatMessage := "test failure %d" + failure := failures.Wrap(formatMessage, testID, nil, 123) + gomega.Expect(failure.Error()).To(gomega.Equal("test failure 123")) + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + }) + }) + }) + + ginkgo.Describe("Failure interface methods", func() { + var failure failures.Failure + + // Setup a failure with a wrapped error before each test + ginkgo.BeforeEach(func() { + failure = failures.Wrap(testMessage, testID, wrappedErr) + }) + + ginkgo.Describe("Error method", func() { + ginkgo.It("returns only the message when no wrapped error exists", func() { + failureNoWrap := failures.Wrap(testMessage, testID, nil) + gomega.Expect(failureNoWrap.Error()).To(gomega.Equal(testMessage)) + }) + ginkgo.It("combines message with wrapped error", func() { + expected := fmt.Sprintf("%s: %v", testMessage, wrappedErr) + gomega.Expect(failure.Error()).To(gomega.Equal(expected)) + }) + }) + + ginkgo.Describe("ID method", func() { + ginkgo.It("returns the assigned ID", func() { + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + }) + }) + + ginkgo.Describe("Unwrap method", func() { + ginkgo.It("returns the wrapped error", func() { + gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr)) + }) + ginkgo.It("returns nil when no wrapped error exists", func() { + failureNoWrap := failures.Wrap(testMessage, testID, nil) + gomega.Expect(failureNoWrap.Unwrap()).To(gomega.Succeed()) + }) + }) + + ginkgo.Describe("Is method", func() { + ginkgo.It("returns true for failures with the same ID", func() { + f1 := failures.Wrap("first", testID, nil) + f2 := failures.Wrap("second", testID, nil) + gomega.Expect(f1.Is(f2)).To(gomega.BeTrue()) + gomega.Expect(f2.Is(f1)).To(gomega.BeTrue()) + }) + ginkgo.It("returns false for failures with different IDs", func() { + f1 := failures.Wrap("first", testID, nil) + f2 := failures.Wrap("second", testID+1, nil) + gomega.Expect(f1.Is(f2)).To(gomega.BeFalse()) + gomega.Expect(f2.Is(f1)).To(gomega.BeFalse()) + }) + ginkgo.It("returns false when comparing with a non-failure error", func() { + f1 := failures.Wrap("first", testID, nil) + gomega.Expect(f1.Is(wrappedErr)).To(gomega.BeFalse()) + }) + }) + }) + + ginkgo.Describe("edge cases", func() { + ginkgo.When("wrapping with an empty message", func() { + ginkgo.It("handles an empty message gracefully", func() { + failure := failures.Wrap("", testID, wrappedErr) + gomega.Expect(failure.Error()).To(gomega.Equal(": " + wrappedErr.Error())) + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + gomega.Expect(failure.Unwrap()).To(gomega.Equal(wrappedErr)) + }) + }) + + ginkgo.When("wrapping with nil error and no args", func() { + ginkgo.It("returns a valid failure with just message and ID", func() { + failure := failures.Wrap(testMessage, testID, nil) + gomega.Expect(failure.Error()).To(gomega.Equal(testMessage)) + gomega.Expect(failure.ID()).To(gomega.Equal(testID)) + gomega.Expect(failure.Unwrap()).To(gomega.Succeed()) + }) + }) + + ginkgo.When("using multiple wrapped failures", func() { + ginkgo.It("correctly chains and unwraps multiple errors", func() { + innerErr := errors.New("inner error") + middleErr := failures.Wrap("middle", testID+1, innerErr) + outerErr := failures.Wrap("outer", testID, middleErr) + gomega.Expect(outerErr.Error()).To(gomega.Equal("outer: middle: inner error")) + gomega.Expect(outerErr.ID()).To(gomega.Equal(testID)) + gomega.Expect(outerErr.Unwrap()).To(gomega.Equal(middleErr)) + gomega.Expect(middleErr.Unwrap()).To(gomega.Equal(innerErr)) + }) + }) + }) + + ginkgo.Describe("integration-like scenarios", func() { + ginkgo.It("works with standard error wrapping utilities", func() { + innerErr := errors.New("inner error") + failure := failures.Wrap("wrapped failure", testID, innerErr) + gomega.Expect(errors.Is(failure, innerErr)).To(gomega.BeTrue()) // Matches wrapped error + gomega.Expect(errors.Unwrap(failure)).To(gomega.Equal(innerErr)) + }) + + ginkgo.It("handles fmt.Errorf wrapping", func() { + failure := failures.Wrap("failure", testID, nil) + wrapped := fmt.Errorf("additional context: %w", failure) + gomega.Expect(wrapped.Error()).To(gomega.Equal("additional context: failure")) + gomega.Expect(errors.Unwrap(wrapped)).To(gomega.Equal(failure)) + }) + }) + + ginkgo.Describe("testutils integration", func() { + ginkgo.It("can use TestLogger for logging failures", func() { + // Demonstrate compatibility with testutils logger + failure := failures.Wrap("logged failure", testID, nil) + logger := testutils.TestLogger() + logger.Printf("Error occurred: %v", failure) + // No assertion needed; ensures no panic during logging + }) + }) +}) diff --git a/internal/meta/version.go b/internal/meta/version.go new file mode 100644 index 0000000..20e5ee6 --- /dev/null +++ b/internal/meta/version.go @@ -0,0 +1,7 @@ +package meta + +// Version of Shoutrrr. +const Version = `0.6-dev` + +// DocsVersion is prepended to documentation URLs and usually equals MAJOR.MINOR of Version. +const DocsVersion = `dev` diff --git a/internal/testutils/config.go b/internal/testutils/config.go new file mode 100644 index 0000000..550b1dd --- /dev/null +++ b/internal/testutils/config.go @@ -0,0 +1,48 @@ +package testutils + +import ( + "net/url" + + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// TestConfigGetInvalidQueryValue tests whether the config returns +// an error when an invalid query value is requested. +func TestConfigGetInvalidQueryValue(config types.ServiceConfig) { + value, err := format.GetConfigQueryResolver(config).Get("invalid query var") + gomega.ExpectWithOffset(1, value).To(gomega.BeEmpty()) + gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred()) +} + +// TestConfigSetInvalidQueryValue tests whether the config returns +// an error when a URL with an invalid query value is parsed. +func TestConfigSetInvalidQueryValue(config types.ServiceConfig, rawInvalidURL string) { + invalidURL, err := url.Parse(rawInvalidURL) + gomega.ExpectWithOffset(1, err). + ToNot(gomega.HaveOccurred(), "the test URL did not parse correctly") + + err = config.SetURL(invalidURL) + gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred()) +} + +// TestConfigSetDefaultValues tests whether setting the default values +// can be set for an empty config without any errors. +func TestConfigSetDefaultValues(config types.ServiceConfig) { + pkr := format.NewPropKeyResolver(config) + gomega.ExpectWithOffset(1, pkr.SetDefaultProps(config)).To(gomega.Succeed()) +} + +// TestConfigGetEnumsCount tests whether the config.Enums returns the expected amount of items. +func TestConfigGetEnumsCount(config types.ServiceConfig, expectedCount int) { + enums := config.Enums() + gomega.ExpectWithOffset(1, enums).To(gomega.HaveLen(expectedCount)) +} + +// TestConfigGetFieldsCount tests whether the config.QueryFields return the expected amount of fields. +func TestConfigGetFieldsCount(config types.ServiceConfig, expectedCount int) { + fields := format.GetConfigQueryResolver(config).QueryFields() + gomega.ExpectWithOffset(1, fields).To(gomega.HaveLen(expectedCount)) +} diff --git a/internal/testutils/eavesdropper.go b/internal/testutils/eavesdropper.go new file mode 100644 index 0000000..d0e1648 --- /dev/null +++ b/internal/testutils/eavesdropper.go @@ -0,0 +1,7 @@ +package testutils + +// Eavesdropper is an interface that provides a way to get a summarized output of a connection RX and TX. +type Eavesdropper interface { + GetConversation(includeGreeting bool) string + GetClientSentences() []string +} diff --git a/internal/testutils/failwriter.go b/internal/testutils/failwriter.go new file mode 100644 index 0000000..e8a5af4 --- /dev/null +++ b/internal/testutils/failwriter.go @@ -0,0 +1,36 @@ +package testutils + +import ( + "errors" + "fmt" + "io" +) + +var ErrWriteLimitReached = errors.New("reached write limit") + +type failWriter struct { + writeLimit int + writeCount int +} + +// Close is just a dummy function to implement io.Closer. +func (fw *failWriter) Close() error { + return nil +} + +// Write returns an error if the write limit has been reached. +func (fw *failWriter) Write(data []byte) (int, error) { + fw.writeCount++ + if fw.writeCount > fw.writeLimit { + return 0, fmt.Errorf("%w: %d", ErrWriteLimitReached, fw.writeLimit) + } + + return len(data), nil +} + +// CreateFailWriter returns a io.WriteCloser that returns an error after the amount of writes indicated by writeLimit. +func CreateFailWriter(writeLimit int) io.WriteCloser { + return &failWriter{ + writeLimit: writeLimit, + } +} diff --git a/internal/testutils/iofaker.go b/internal/testutils/iofaker.go new file mode 100644 index 0000000..3e1960d --- /dev/null +++ b/internal/testutils/iofaker.go @@ -0,0 +1,14 @@ +package testutils + +import ( + "io" +) + +type ioFaker struct { + io.ReadWriter +} + +// Close is just a dummy function to implement the io.Closer interface. +func (iof ioFaker) Close() error { + return nil +} diff --git a/internal/testutils/logging.go b/internal/testutils/logging.go new file mode 100644 index 0000000..d0687d1 --- /dev/null +++ b/internal/testutils/logging.go @@ -0,0 +1,12 @@ +package testutils + +import ( + "log" + + "github.com/onsi/ginkgo/v2" +) + +// TestLogger returns a log.Logger that writes to ginkgo.GinkgoWriter for use in tests. +func TestLogger() *log.Logger { + return log.New(ginkgo.GinkgoWriter, "[Test] ", 0) +} diff --git a/internal/testutils/mockclientservice.go b/internal/testutils/mockclientservice.go new file mode 100644 index 0000000..63e7b6e --- /dev/null +++ b/internal/testutils/mockclientservice.go @@ -0,0 +1,8 @@ +package testutils + +import "net/http" + +// MockClientService is used to allow mocking the HTTP client when testing. +type MockClientService interface { + GetHTTPClient() *http.Client +} diff --git a/internal/testutils/must.go b/internal/testutils/must.go new file mode 100644 index 0000000..0aa7ad6 --- /dev/null +++ b/internal/testutils/must.go @@ -0,0 +1,25 @@ +package testutils + +import ( + "net/url" + + "github.com/jarcoal/httpmock" + "github.com/onsi/gomega" +) + +// URLMust creates a url.URL from the given rawURL and fails the test if it cannot be parsed. +func URLMust(rawURL string) *url.URL { + parsed, err := url.Parse(rawURL) + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) + + return parsed +} + +// JSONRespondMust creates a httpmock.Responder with the given response +// as the body, and fails the test if it cannot be created. +func JSONRespondMust(code int, response any) httpmock.Responder { + responder, err := httpmock.NewJsonResponder(code, response) + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred(), "invalid test response struct") + + return responder +} diff --git a/internal/testutils/service.go b/internal/testutils/service.go new file mode 100644 index 0000000..83b4574 --- /dev/null +++ b/internal/testutils/service.go @@ -0,0 +1,14 @@ +package testutils + +import ( + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// TestServiceSetInvalidParamValue tests whether the service returns an error +// when an invalid param key/value is passed through Send. +func TestServiceSetInvalidParamValue(service types.Service, key string, value string) { + err := service.Send("TestMessage", &types.Params{key: value}) + gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred()) +} diff --git a/internal/testutils/testutils_test.go b/internal/testutils/testutils_test.go new file mode 100644 index 0000000..2f9c81e --- /dev/null +++ b/internal/testutils/testutils_test.go @@ -0,0 +1,135 @@ +package testutils_test + +import ( + "net/url" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestTestUtils(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + + ginkgo.RunSpecs(t, "Shoutrrr TestUtils Suite") +} + +var _ = ginkgo.Describe("the testutils package", func() { + ginkgo.When("calling function TestLogger", func() { + ginkgo.It("should not return nil", func() { + gomega.Expect(testutils.TestLogger()).NotTo(gomega.BeNil()) + }) + ginkgo.It(`should have the prefix "[Test] "`, func() { + gomega.Expect(testutils.TestLogger().Prefix()).To(gomega.Equal("[Test] ")) + }) + }) + + ginkgo.Describe("Must helpers", func() { + ginkgo.Describe("URLMust", func() { + ginkgo.It("should panic when an invalid URL is passed", func() { + failures := gomega.InterceptGomegaFailures(func() { testutils.URLMust(":") }) + gomega.Expect(failures).To(gomega.HaveLen(1)) + }) + }) + + ginkgo.Describe("JSONRespondMust", func() { + ginkgo.It("should panic when an invalid struct is passed", func() { + notAValidJSONSource := func() {} + failures := gomega.InterceptGomegaFailures( + func() { testutils.JSONRespondMust(200, notAValidJSONSource) }, + ) + gomega.Expect(failures).To(gomega.HaveLen(1)) + }) + }) + }) + + ginkgo.Describe("Config test helpers", func() { + var config dummyConfig + ginkgo.BeforeEach(func() { + config = dummyConfig{} + }) + ginkgo.Describe("TestConfigSetInvalidQueryValue", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestConfigSetInvalidQueryValue(&config, "mock://host?invalid=value") + }) + gomega.Expect(failures).To(gomega.HaveLen(1)) + }) + }) + + ginkgo.Describe("TestConfigGetInvalidQueryValue", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestConfigGetInvalidQueryValue(&config) + }) + gomega.Expect(failures).To(gomega.HaveLen(1)) + }) + }) + + ginkgo.Describe("TestConfigSetDefaultValues", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestConfigSetDefaultValues(&config) + }) + gomega.Expect(failures).NotTo(gomega.BeEmpty()) + }) + }) + + ginkgo.Describe("TestConfigGetEnumsCount", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestConfigGetEnumsCount(&config, 99) + }) + gomega.Expect(failures).NotTo(gomega.BeEmpty()) + }) + }) + + ginkgo.Describe("TestConfigGetFieldsCount", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestConfigGetFieldsCount(&config, 99) + }) + gomega.Expect(failures).NotTo(gomega.BeEmpty()) + }) + }) + }) + + ginkgo.Describe("Service test helpers", func() { + var service dummyService + ginkgo.BeforeEach(func() { + service = dummyService{} + }) + ginkgo.Describe("TestConfigSetInvalidQueryValue", func() { + ginkgo.It("should fail when not correctly implemented", func() { + failures := gomega.InterceptGomegaFailures(func() { + testutils.TestServiceSetInvalidParamValue(&service, "invalid", "value") + }) + gomega.Expect(failures).To(gomega.HaveLen(1)) + }) + }) + }) +}) + +type dummyConfig struct { + standard.EnumlessConfig + Foo uint64 `default:"-1" key:"foo"` +} + +func (dc *dummyConfig) GetURL() *url.URL { return &url.URL{} } +func (dc *dummyConfig) SetURL(_ *url.URL) error { return nil } +func (dc *dummyConfig) Get(string) (string, error) { return "", nil } +func (dc *dummyConfig) Set(string, string) error { return nil } +func (dc *dummyConfig) QueryFields() []string { return []string{} } + +type dummyService struct { + standard.Standard + Config dummyConfig +} + +func (s *dummyService) Initialize(_ *url.URL, _ types.StdLogger) error { return nil } +func (s *dummyService) Send(_ string, _ *types.Params) error { return nil } +func (s *dummyService) GetID() string { return "dummy" } diff --git a/internal/testutils/textconfaker.go b/internal/testutils/textconfaker.go new file mode 100644 index 0000000..1f2ff19 --- /dev/null +++ b/internal/testutils/textconfaker.go @@ -0,0 +1,106 @@ +package testutils + +import ( + "bufio" + "bytes" + "fmt" + "net/textproto" + "strings" +) + +type textConFaker struct { + inputBuffer *bytes.Buffer + inputWriter *bufio.Writer + outputReader *bufio.Reader + responses []string + delim string +} + +func (tcf *textConFaker) GetInput() string { + _ = tcf.inputWriter.Flush() + + return tcf.inputBuffer.String() +} + +// GetConversation returns the input and output streams as a conversation. +func (tcf *textConFaker) GetConversation(includeGreeting bool) string { + conv := "" + inSequence := false + input := strings.Split(tcf.GetInput(), tcf.delim) + responseIndex := 0 + + if includeGreeting { + conv += fmt.Sprintf(" %-55s << %-50s\n", "(server greeting)", tcf.responses[0]) + responseIndex = 1 + } + + for i, query := range input { + if query == "." { + inSequence = false + } + + resp := "" + if len(tcf.responses) > responseIndex && !inSequence { + resp = tcf.responses[responseIndex] + } + + if query == "" && resp == "" && i == len(input)-1 { + break + } + + conv += fmt.Sprintf(" #%2d >> %50s << %-50s\n", i, query, resp) + + for len(resp) > 3 && resp[3] == '-' { + responseIndex++ + resp = tcf.responses[responseIndex] + conv += fmt.Sprintf(" %50s << %-50s\n", " ", resp) + } + + if !inSequence { + responseIndex++ + } + + if len(resp) > 0 && resp[0] == '3' { + inSequence = true + } + } + + return conv +} + +// GetClientSentences returns all the input received from the client separated by the delimiter. +func (tcf *textConFaker) GetClientSentences() []string { + _ = tcf.inputWriter.Flush() + + return strings.Split(tcf.inputBuffer.String(), tcf.delim) +} + +// CreateReadWriter returns a ReadWriter from the textConFakers internal reader and writer. +func (tcf *textConFaker) CreateReadWriter() *bufio.ReadWriter { + return bufio.NewReadWriter(tcf.outputReader, tcf.inputWriter) +} + +func (tcf *textConFaker) init() { + tcf.inputBuffer = &bytes.Buffer{} + stringReader := strings.NewReader(strings.Join(tcf.responses, tcf.delim)) + tcf.outputReader = bufio.NewReader(stringReader) + tcf.inputWriter = bufio.NewWriter(tcf.inputBuffer) +} + +// CreateTextConFaker returns a textproto.Conn to fake textproto based connections. +func CreateTextConFaker(responses []string, delim string) (*textproto.Conn, Eavesdropper) { + tcfaker := textConFaker{ + responses: responses, + delim: delim, + } + tcfaker.init() + + // rx := iotest.NewReadLogger("TextConRx", tcfaker.outputReader) + // tx := iotest.NewWriteLogger("TextConTx", tcfaker.inputWriter) + // faker := CreateIOFaker(rx, tx) + faker := ioFaker{ + ReadWriter: tcfaker.CreateReadWriter(), + } + + return textproto.NewConn(faker), &tcfaker +} diff --git a/internal/util/cobra.go b/internal/util/cobra.go new file mode 100644 index 0000000..3132ebb --- /dev/null +++ b/internal/util/cobra.go @@ -0,0 +1,37 @@ +package util + +import ( + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// LoadFlagsFromAltSources is a WORKAROUND to make cobra count env vars and +// positional arguments when checking required flags. +func LoadFlagsFromAltSources(cmd *cobra.Command, args []string) { + flags := cmd.Flags() + + if len(args) > 0 { + _ = flags.Set("url", args[0]) + + if len(args) > 1 { + _ = flags.Set("message", args[1]) + } + + return + } + + if hasURLInEnvButNotFlag(cmd) { + _ = flags.Set("url", viper.GetViper().GetString("SHOUTRRR_URL")) + + // If the URL has been set in ENV, default the message to read from stdin. + if msg, _ := flags.GetString("message"); msg == "" { + _ = flags.Set("message", "-") + } + } +} + +func hasURLInEnvButNotFlag(cmd *cobra.Command) bool { + s, _ := cmd.Flags().GetString("url") + + return s == "" && viper.GetViper().GetString("SHOUTRRR_URL") != "" +} diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..97c0a5a --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,63 @@ +site_name: Shoutrrr +site_url: https://nicholas-fedor.github.io/shoutrrr/ +repo_url: https://github.com/nicholas-fedor/shoutrrr/ +edit_uri: edit/main/docs/ +theme: + name: 'material' + palette: + scheme: shoutrrr + logo: shoutrrr-180px.png + favicon: favicon.ico + custom_dir: docs/overrides +extra: + version: + provider: mike + generator: false +extra_css: + - stylesheets/theme.css + - stylesheets/extra.css +markdown_extensions: + - toc: + permalink: True + separator: '_' + - admonition + - pymdownx.details + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.superfences + - pymdownx.mark + - pymdownx.snippets: + check_paths: true +nav: + - 'Home': 'index.md' + - 'Getting started': 'getting-started.md' + - 'Service Overview': 'services/overview.md' + - Services: + - Bark: 'services/bark.md' + - Discord: 'services/discord.md' + - Email: 'services/email.md' + - Gotify: 'services/gotify.md' + - Google Chat: 'services/googlechat.md' + - IFTTT: 'services/ifttt.md' + - Join: 'services/join.md' + - Mattermost: 'services/mattermost.md' + - Matrix: 'services/matrix.md' + - Ntfy: 'services/ntfy.md' + - OpsGenie: 'services/opsgenie.md' + - Pushbullet: 'services/pushbullet.md' + - Pushover: 'services/pushover.md' + - Rocketchat: 'services/rocketchat.md' + - Slack: 'services/slack.md' + - Teams: 'services/teams.md' + - Telegram: 'services/telegram.md' + - Zulip Chat: 'services/zulip.md' + - Generic Webhook: 'services/generic.md' + - Guides: + - Slack: 'guides/slack/index.md' + - Examples: + - Generic Webhook: 'examples/generic.md' + - Advanced usage: + - Proxy: 'proxy.md' + +plugins: + - search diff --git a/pkg/format/config_props.go b/pkg/format/config_props.go new file mode 100644 index 0000000..cb3a2e3 --- /dev/null +++ b/pkg/format/config_props.go @@ -0,0 +1,49 @@ +package format + +import ( + "errors" + "fmt" + "reflect" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var ErrNotConfigProp = errors.New("struct field cannot be used as a prop") + +// GetConfigPropFromString deserializes a config property from a string representation using the ConfigProp interface. +func GetConfigPropFromString(structType reflect.Type, value string) (reflect.Value, error) { + valuePtr := reflect.New(structType) + + configProp, ok := valuePtr.Interface().(types.ConfigProp) + if !ok { + return reflect.Value{}, ErrNotConfigProp + } + + if err := configProp.SetFromProp(value); err != nil { + return reflect.Value{}, fmt.Errorf("failed to set config prop from string: %w", err) + } + + return valuePtr, nil +} + +// GetConfigPropString serializes a config property to a string representation using the ConfigProp interface. +func GetConfigPropString(propPtr reflect.Value) (string, error) { + if propPtr.Kind() != reflect.Ptr { + propVal := propPtr + propPtr = reflect.New(propVal.Type()) + propPtr.Elem().Set(propVal) + } + + if propPtr.CanInterface() { + if configProp, ok := propPtr.Interface().(types.ConfigProp); ok { + s, err := configProp.GetPropValue() + if err != nil { + return "", fmt.Errorf("failed to get config prop string: %w", err) + } + + return s, nil + } + } + + return "", ErrNotConfigProp +} diff --git a/pkg/format/enum_formatter.go b/pkg/format/enum_formatter.go new file mode 100644 index 0000000..2b7a08c --- /dev/null +++ b/pkg/format/enum_formatter.go @@ -0,0 +1,71 @@ +package format + +import ( + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// EnumInvalid is the constant value that an enum gets assigned when it could not be parsed. +const EnumInvalid = -1 + +// EnumFormatter is the helper methods for enum-like types. +type EnumFormatter struct { + names []string + firstOffset int + aliases map[string]int +} + +// Names is the list of the valid Enum string values. +func (ef EnumFormatter) Names() []string { + return ef.names[ef.firstOffset:] +} + +// Print takes a enum mapped int and returns it's string representation or "Invalid". +func (ef EnumFormatter) Print(e int) string { + if e >= len(ef.names) || e < 0 { + return "Invalid" + } + + return ef.names[e] +} + +// Parse takes an enum mapped string and returns it's int representation or EnumInvalid (-1). +func (ef EnumFormatter) Parse(mappedString string) int { + target := strings.ToLower(mappedString) + for index, name := range ef.names { + if target == strings.ToLower(name) { + return index + } + } + + if index, found := ef.aliases[mappedString]; found { + return index + } + + return EnumInvalid +} + +// CreateEnumFormatter creates a EnumFormatter struct. +func CreateEnumFormatter(names []string, optAliases ...map[string]int) types.EnumFormatter { + aliases := map[string]int{} + if len(optAliases) > 0 { + aliases = optAliases[0] + } + + firstOffset := 0 + + for i, name := range names { + if name != "" { + firstOffset = i + + break + } + } + + return &EnumFormatter{ + names, + firstOffset, + aliases, + } +} diff --git a/pkg/format/field_info.go b/pkg/format/field_info.go new file mode 100644 index 0000000..638220e --- /dev/null +++ b/pkg/format/field_info.go @@ -0,0 +1,134 @@ +package format + +import ( + "reflect" + "strconv" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// DefaultBase represents the default numeric base (decimal) for fields. +const DefaultBase = 10 + +// FieldInfo is the meta data about a config field. +type FieldInfo struct { + Name string + Type reflect.Type + EnumFormatter types.EnumFormatter + Description string + DefaultValue string + Template string + Required bool + URLParts []URLPart + Title bool + Base int + Keys []string + ItemSeparator rune +} + +// IsEnum returns whether a EnumFormatter has been assigned to the field and that it is of a suitable type. +func (fi *FieldInfo) IsEnum() bool { + return fi.EnumFormatter != nil && fi.Type.Kind() == reflect.Int +} + +// IsURLPart returns whether the field is serialized as the specified part of an URL. +func (fi *FieldInfo) IsURLPart(part URLPart) bool { + for _, up := range fi.URLParts { + if up == part { + return true + } + } + + return false +} + +func getStructFieldInfo(structType reflect.Type, enums map[string]types.EnumFormatter) []FieldInfo { + numFields := structType.NumField() + fields := make([]FieldInfo, 0, numFields) + maxKeyLen := 0 + + for i := range numFields { + fieldDef := structType.Field(i) + + if isHiddenField(fieldDef) { + // This is an embedded or private field, which should not be part of the Config output + continue + } + + info := FieldInfo{ + Name: fieldDef.Name, + Type: fieldDef.Type, + Required: true, + Title: false, + ItemSeparator: ',', + } + + if util.IsNumeric(fieldDef.Type.Kind()) { + info.Base = getFieldBase(fieldDef) + } + + if tag, ok := fieldDef.Tag.Lookup("desc"); ok { + info.Description = tag + } + + if tag, ok := fieldDef.Tag.Lookup("tpl"); ok { + info.Template = tag + } + + if tag, ok := fieldDef.Tag.Lookup("default"); ok { + info.Required = false + info.DefaultValue = tag + } + + if _, ok := fieldDef.Tag.Lookup("optional"); ok { + info.Required = false + } + + if _, ok := fieldDef.Tag.Lookup("title"); ok { + info.Title = true + } + + if tag, ok := fieldDef.Tag.Lookup("url"); ok { + info.URLParts = ParseURLParts(tag) + } + + if tag, ok := fieldDef.Tag.Lookup("key"); ok { + tag := strings.ToLower(tag) + info.Keys = strings.Split(tag, ",") + } + + if tag, ok := fieldDef.Tag.Lookup("sep"); ok { + info.ItemSeparator = rune(tag[0]) + } + + if ef, isEnum := enums[fieldDef.Name]; isEnum { + info.EnumFormatter = ef + } + + fields = append(fields, info) + + keyLen := len(fieldDef.Name) + if keyLen > maxKeyLen { + maxKeyLen = keyLen + } + } + + return fields +} + +func isHiddenField(field reflect.StructField) bool { + return field.Anonymous || strings.ToUpper(field.Name[0:1]) != field.Name[0:1] +} + +func getFieldBase(field reflect.StructField) int { + if tag, ok := field.Tag.Lookup("base"); ok { + if base, err := strconv.ParseUint(tag, 10, 8); err == nil { + return int(base) + } + } + + // Default to base 10 if not tagged + return DefaultBase +} diff --git a/pkg/format/format.go b/pkg/format/format.go new file mode 100644 index 0000000..a93f7ae --- /dev/null +++ b/pkg/format/format.go @@ -0,0 +1,34 @@ +package format + +import ( + "strconv" + "strings" +) + +// ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value. +func ParseBool(value string, defaultValue bool) (bool, bool) { + switch strings.ToLower(value) { + case "true", "1", "yes", "y": + return true, true + case "false", "0", "no", "n": + return false, true + default: + return defaultValue, false + } +} + +// PrintBool returns "Yes" if value is true, otherwise returns "No". +func PrintBool(value bool) string { + if value { + return "Yes" + } + + return "No" +} + +// IsNumber returns whether the specified string is number-like. +func IsNumber(value string) bool { + _, err := strconv.ParseFloat(value, 64) + + return err == nil +} diff --git a/pkg/format/format_colorize.go b/pkg/format/format_colorize.go new file mode 100644 index 0000000..d8673a3 --- /dev/null +++ b/pkg/format/format_colorize.go @@ -0,0 +1,80 @@ +package format + +import "github.com/fatih/color" + +// ColorizeDesc colorizes the input string as "Description". +var ColorizeDesc = color.New(color.FgHiBlack).SprintFunc() + +// ColorizeTrue colorizes the input string as "True". +var ColorizeTrue = color.New(color.FgHiGreen).SprintFunc() + +// ColorizeFalse colorizes the input string as "False". +var ColorizeFalse = color.New(color.FgHiRed).SprintFunc() + +// ColorizeNumber colorizes the input string as "Number". +var ColorizeNumber = color.New(color.FgHiBlue).SprintFunc() + +// ColorizeString colorizes the input string as "String". +var ColorizeString = color.New(color.FgHiYellow).SprintFunc() + +// ColorizeEnum colorizes the input string as "Enum". +var ColorizeEnum = color.New(color.FgHiCyan).SprintFunc() + +// ColorizeProp colorizes the input string as "Prop". +var ColorizeProp = color.New(color.FgHiMagenta).SprintFunc() + +// ColorizeError colorizes the input string as "Error". +var ColorizeError = ColorizeFalse + +// ColorizeContainer colorizes the input string as "Container". +var ColorizeContainer = ColorizeDesc + +// ColorizeLink colorizes the input string as "Link". +var ColorizeLink = color.New(color.FgHiBlue).SprintFunc() + +// ColorizeValue colorizes the input string according to what type appears to be. +func ColorizeValue(value string, isEnum bool) string { + if isEnum { + return ColorizeEnum(value) + } + + if isTrue, isType := ParseBool(value, false); isType { + if isTrue { + return ColorizeTrue(value) + } + + return ColorizeFalse(value) + } + + if IsNumber(value) { + return ColorizeNumber(value) + } + + return ColorizeString(value) +} + +// ColorizeToken colorizes the value according to the tokenType. +func ColorizeToken(value string, tokenType NodeTokenType) string { + switch tokenType { + case NumberToken: + return ColorizeNumber(value) + case EnumToken: + return ColorizeEnum(value) + case TrueToken: + return ColorizeTrue(value) + case FalseToken: + return ColorizeFalse(value) + case PropToken: + return ColorizeProp(value) + case ErrorToken: + return ColorizeError(value) + case ContainerToken: + return ColorizeContainer(value) + case StringToken: + return ColorizeString(value) + case UnknownToken: + default: + } + + return value +} diff --git a/pkg/format/format_query.go b/pkg/format/format_query.go new file mode 100644 index 0000000..69a8f59 --- /dev/null +++ b/pkg/format/format_query.go @@ -0,0 +1,95 @@ +package format + +import ( + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// BuildQuery converts the fields of a config object to a delimited query string. +func BuildQuery(cqr types.ConfigQueryResolver) string { + return BuildQueryWithCustomFields(cqr, url.Values{}).Encode() +} + +// escaping any custom fields that share the same key as a config prop using a "__" prefix. +func BuildQueryWithCustomFields(cqr types.ConfigQueryResolver, query url.Values) url.Values { + fields := cqr.QueryFields() + skipEscape := len(query) < 1 + + pkr, isPkr := cqr.(*PropKeyResolver) + + for _, key := range fields { + if !skipEscape { + // Escape any webhook query keys using the same name as service props + escValues := query[key] + if len(escValues) > 0 { + query.Del(key) + query[EscapeKey(key)] = escValues + } + } + + if isPkr && !pkr.KeyIsPrimary(key) { + continue + } + + value, err := cqr.Get(key) + + if err != nil || isPkr && pkr.IsDefault(key, value) { + continue + } + + query.Set(key, value) + } + + return query +} + +// SetConfigPropsFromQuery iterates over all the config prop keys and sets the config prop to the corresponding +// query value based on the key. +// SetConfigPropsFromQuery returns a non-nil url.Values query with all config prop keys removed, even if any of +// them could not be used to set a config field, and with any escaped keys unescaped. +// The error returned is the first error that occurred, subsequent errors are just discarded. +func SetConfigPropsFromQuery(cqr types.ConfigQueryResolver, query url.Values) (url.Values, error) { + var firstError error + + if query == nil { + return url.Values{}, nil + } + + for _, key := range cqr.QueryFields() { + // Retrieve the service-related prop value + values := query[key] + if len(values) > 0 { + if err := cqr.Set(key, values[0]); err != nil && firstError == nil { + firstError = err + } + } + // Remove it from the query Values + query.Del(key) + + // If an escaped version of the key exist, unescape it + escKey := EscapeKey(key) + escValues := query[escKey] + + if len(escValues) > 0 { + query.Del(escKey) + query[key] = escValues + } + } + + return query, firstError +} + +// EscapeKey adds the KeyPrefix to custom URL query keys that conflict with service config prop keys. +func EscapeKey(key string) string { + return KeyPrefix + key +} + +// UnescapeKey removes the KeyPrefix from custom URL query keys that conflict with service config prop keys. +func UnescapeKey(key string) string { + return strings.TrimPrefix(key, KeyPrefix) +} + +// consisting of two underscore characters ("__"). +const KeyPrefix = "__" diff --git a/pkg/format/format_query_test.go b/pkg/format/format_query_test.go new file mode 100644 index 0000000..4e41f83 --- /dev/null +++ b/pkg/format/format_query_test.go @@ -0,0 +1,50 @@ +package format + +import ( + "net/url" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = ginkgo.Describe("Query Formatter", func() { + var pkr PropKeyResolver + ginkgo.BeforeEach(func() { + ts = &testStruct{} + pkr = NewPropKeyResolver(ts) + _ = pkr.SetDefaultProps(ts) + }) + ginkgo.Describe("Creating a service URL query from a config", func() { + ginkgo.When("a config property has been changed from default", func() { + ginkgo.It("should be included in the query string", func() { + ts.Str = "test" + query := BuildQuery(&pkr) + // (pkr, ) + gomega.Expect(query).To(gomega.Equal("str=test")) + }) + }) + ginkgo.When("a custom query key conflicts with a config property key", func() { + ginkgo.It("should include both values, with the custom escaped", func() { + ts.Str = "service" + customQuery := url.Values{"str": {"custom"}} + query := BuildQueryWithCustomFields(&pkr, customQuery) + gomega.Expect(query.Encode()).To(gomega.Equal("__str=custom&str=service")) + }) + }) + }) + ginkgo.Describe("Setting prop values from query", func() { + ginkgo.When("a custom query key conflicts with a config property key", func() { + ginkgo.It( + "should set the config prop from the regular and return the custom one unescaped", + func() { + ts.Str = "service" + serviceQuery := url.Values{"__str": {"custom"}, "str": {"service"}} + query, err := SetConfigPropsFromQuery(&pkr, serviceQuery) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(ts.Str).To(gomega.Equal("service")) + gomega.Expect(query.Get("str")).To(gomega.Equal("custom")) + }, + ) + }) + }) +}) diff --git a/pkg/format/format_test.go b/pkg/format/format_test.go new file mode 100644 index 0000000..80477d3 --- /dev/null +++ b/pkg/format/format_test.go @@ -0,0 +1,157 @@ +package format + +import ( + "errors" + "net/url" + "testing" + + "github.com/fatih/color" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestFormat(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Format Suite") +} + +var _ = ginkgo.BeforeSuite(func() { + // Disable color output for tests to have them match the string format rather than the colors + color.NoColor = true +}) + +var _ = ginkgo.Describe("the format package", func() { + ginkgo.Describe("Generic Format Utils", func() { + ginkgo.When("parsing a bool", func() { + testParseValidBool := func(raw string, expected bool) { + parsed, ok := ParseBool(raw, !expected) + gomega.Expect(parsed).To(gomega.Equal(expected)) + gomega.Expect(ok).To(gomega.BeTrue()) + } + ginkgo.It("should parse truthy values as true", func() { + testParseValidBool("true", true) + testParseValidBool("1", true) + testParseValidBool("yes", true) + }) + ginkgo.It("should parse falsy values as false", func() { + testParseValidBool("false", false) + testParseValidBool("0", false) + testParseValidBool("no", false) + }) + ginkgo.It("should match regardless of case", func() { + testParseValidBool("trUE", true) + }) + ginkgo.It("should return the default if no value matches", func() { + parsed, ok := ParseBool("bad", true) + gomega.Expect(parsed).To(gomega.BeTrue()) + gomega.Expect(ok).To(gomega.BeFalse()) + parsed, ok = ParseBool("values", false) + gomega.Expect(parsed).To(gomega.BeFalse()) + gomega.Expect(ok).To(gomega.BeFalse()) + }) + }) + ginkgo.When("printing a bool", func() { + ginkgo.It("should return yes or no", func() { + gomega.Expect(PrintBool(true)).To(gomega.Equal("Yes")) + gomega.Expect(PrintBool(false)).To(gomega.Equal("No")) + }) + }) + ginkgo.When("checking for number-like strings", func() { + ginkgo.It("should be true for numbers", func() { + gomega.Expect(IsNumber("1.5")).To(gomega.BeTrue()) + gomega.Expect(IsNumber("0")).To(gomega.BeTrue()) + gomega.Expect(IsNumber("NaN")).To(gomega.BeTrue()) + }) + ginkgo.It("should be false for non-numbers", func() { + gomega.Expect(IsNumber("baNaNa")).To(gomega.BeFalse()) + }) + }) + }) + ginkgo.Describe("Enum Formatter", func() { + ginkgo.It("should return all enum values on listing", func() { + gomega.Expect(testEnum.Names()).To(gomega.ConsistOf("None", "Foo", "Bar")) + }) + }) +}) + +type testStruct struct { + Signed int `default:"0" key:"signed"` + Unsigned uint + Str string `default:"notempty" key:"str"` + StrSlice []string + StrArray [3]string + Sub subStruct + TestEnum int `default:"None" key:"testenum"` + SubProp subPropStruct + SubSlice []subStruct + SubPropSlice []subPropStruct + SubPropPtrSlice []*subPropStruct + StrMap map[string]string + IntMap map[string]int + Int8Map map[string]int8 + Int16Map map[string]int16 + Int32Map map[string]int32 + Int64Map map[string]int64 + UintMap map[string]uint + Uint8Map map[string]int8 + Uint16Map map[string]int16 + Uint32Map map[string]int32 + Uint64Map map[string]int64 +} + +func (t *testStruct) GetURL() *url.URL { + panic("not implemented") +} + +func (t *testStruct) SetURL(_ *url.URL) error { + panic("not implemented") +} + +func (t *testStruct) Enums() map[string]types.EnumFormatter { + return enums +} + +type subStruct struct { + Value string +} + +type subPropStruct struct { + Value string +} + +func (s *subPropStruct) SetFromProp(propValue string) error { + if len(propValue) < 1 || propValue[0] != '@' { + return errors.New("invalid value") + } + + s.Value = propValue[1:] + + return nil +} + +func (s *subPropStruct) GetPropValue() (string, error) { + return "@" + s.Value, nil +} + +var ( + testEnum = CreateEnumFormatter([]string{"None", "Foo", "Bar"}) + enums = map[string]types.EnumFormatter{ + "TestEnum": testEnum, + } +) + +type testStructBadDefault struct { + standard.EnumlessConfig + Value int `default:"NaN" key:"value"` +} + +func (t *testStructBadDefault) GetURL() *url.URL { + panic("not implemented") +} + +func (t *testStructBadDefault) SetURL(_ *url.URL) error { + panic("not implemented") +} diff --git a/pkg/format/formatter.go b/pkg/format/formatter.go new file mode 100644 index 0000000..cb9cadd --- /dev/null +++ b/pkg/format/formatter.go @@ -0,0 +1,509 @@ +package format + +import ( + "errors" + "fmt" + "math" + "reflect" + "strconv" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// Constants for map parsing and type sizes. +const ( + KeyValuePairSize = 2 // Number of elements in a key:value pair + Int32BitSize = 32 // Bit size for 32-bit integers + Int64BitSize = 64 // Bit size for 64-bit integers +) + +// Errors defined as static variables for better error handling. +var ( + ErrInvalidEnumValue = errors.New("not a valid enum value") + ErrInvalidBoolValue = errors.New("accepted values are 1, true, yes or 0, false, no") + ErrUnsupportedFieldKey = errors.New("field key format is not supported") + ErrInvalidFieldValue = errors.New("invalid field value format") + ErrUnsupportedField = errors.New("field format is not supported") + ErrInvalidFieldCount = errors.New("invalid field value count") + ErrInvalidFieldKind = errors.New("invalid field kind") + ErrUnsupportedMapValue = errors.New("map value format is not supported") + ErrInvalidFieldValueData = errors.New("invalid field value") + ErrFailedToSetEnumValue = errors.New("failed to set enum value") + ErrUnexpectedUintKind = errors.New("unexpected uint kind") + ErrUnexpectedIntKind = errors.New("unexpected int kind") + ErrParseIntFailed = errors.New("failed to parse integer") + ErrParseUintFailed = errors.New("failed to parse unsigned integer") +) + +// GetServiceConfig extracts the inner config from a service. +func GetServiceConfig(service types.Service) types.ServiceConfig { + serviceValue := reflect.Indirect(reflect.ValueOf(service)) + + configField, ok := serviceValue.Type().FieldByName("Config") + if !ok { + panic("service does not have a Config field") + } + + configRef := serviceValue.FieldByIndex(configField.Index) + if configRef.IsNil() { + configType := configField.Type + if configType.Kind() == reflect.Ptr { + configType = configType.Elem() + } + + newConfig := reflect.New(configType).Interface() + if config, ok := newConfig.(types.ServiceConfig); ok { + return config + } + + panic("failed to create new config instance") + } + + if config, ok := configRef.Interface().(types.ServiceConfig); ok { + return config + } + + panic("config reference is not a ServiceConfig") +} + +// ColorFormatTree generates a color-highlighted string representation of a node tree. +func ColorFormatTree(rootNode *ContainerNode, withValues bool) string { + return ConsoleTreeRenderer{WithValues: withValues}.RenderTree(rootNode, "") +} + +// GetServiceConfigFormat retrieves type and field information from a service's config. +func GetServiceConfigFormat(service types.Service) *ContainerNode { + return GetConfigFormat(GetServiceConfig(service)) +} + +// GetConfigFormat retrieves type and field information from a ServiceConfig. +func GetConfigFormat(config types.ServiceConfig) *ContainerNode { + return getRootNode(config) +} + +// SetConfigField updates a config field with a deserialized value from a string. +func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (bool, error) { + configField := config.FieldByName(field.Name) + if field.EnumFormatter != nil { + return setEnumField(configField, field, inputValue) + } + + switch field.Type.Kind() { + case reflect.String: + configField.SetString(inputValue) + + return true, nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return setIntField(configField, field, inputValue) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return setUintField(configField, field, inputValue) + case reflect.Bool: + return setBoolField(configField, inputValue) + case reflect.Map: + return setMapField(configField, field, inputValue) + case reflect.Struct: + return setStructField(configField, field, inputValue) + case reflect.Slice, reflect.Array: + return setSliceOrArrayField(configField, field, inputValue) + case reflect.Invalid, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Pointer, + reflect.UnsafePointer: + return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind()) + default: + return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind()) + } +} + +// setIntField handles integer field setting. +func setIntField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) { + number, base := util.StripNumberPrefix(inputValue) + + value, err := strconv.ParseInt(number, base, field.Type.Bits()) + if err != nil { + return false, fmt.Errorf("%w: %w", ErrParseIntFailed, err) + } + + configField.SetInt(value) + + return true, nil +} + +// setUintField handles unsigned integer field setting. +func setUintField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) { + number, base := util.StripNumberPrefix(inputValue) + + value, err := strconv.ParseUint(number, base, field.Type.Bits()) + if err != nil { + return false, fmt.Errorf("%w: %w", ErrParseUintFailed, err) + } + + configField.SetUint(value) + + return true, nil +} + +// setBoolField handles boolean field setting. +func setBoolField(configField reflect.Value, inputValue string) (bool, error) { + value, ok := ParseBool(inputValue, false) + if !ok { + return false, ErrInvalidBoolValue + } + + configField.SetBool(value) + + return true, nil +} + +// setMapField handles map field setting. +func setMapField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) { + if field.Type.Key().Kind() != reflect.String { + return false, ErrUnsupportedFieldKey + } + + mapValue := reflect.MakeMap(field.Type) + + pairs := strings.Split(inputValue, ",") + for _, pair := range pairs { + elems := strings.Split(pair, ":") + if len(elems) != KeyValuePairSize { + return false, ErrInvalidFieldValue + } + + key, valueRaw := elems[0], elems[1] + + value, err := getMapValue(field.Type.Elem(), valueRaw) + if err != nil { + return false, err + } + + mapValue.SetMapIndex(reflect.ValueOf(key), value) + } + + configField.Set(mapValue) + + return true, nil +} + +// setStructField handles struct field setting. +func setStructField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) { + valuePtr, err := GetConfigPropFromString(field.Type, inputValue) + if err != nil { + return false, err + } + + configField.Set(valuePtr.Elem()) + + return true, nil +} + +// setSliceOrArrayField handles slice or array field setting. +func setSliceOrArrayField( + configField reflect.Value, + field FieldInfo, + inputValue string, +) (bool, error) { + elemType := field.Type.Elem() + elemKind := elemType.Kind() + + if elemKind == reflect.Ptr { + elemKind = elemType.Elem().Kind() + } + + if elemKind != reflect.Struct && elemKind != reflect.String { + return false, ErrUnsupportedField + } + + values := strings.Split(inputValue, string(field.ItemSeparator)) + if field.Type.Kind() == reflect.Array && len(values) != field.Type.Len() { + return false, fmt.Errorf("%w: needs to be %d", ErrInvalidFieldCount, field.Type.Len()) + } + + return setSliceOrArrayValues(configField, field, elemType, values) +} + +// setSliceOrArrayValues sets the actual values for slice or array fields. +func setSliceOrArrayValues( + configField reflect.Value, + field FieldInfo, + elemType reflect.Type, + values []string, +) (bool, error) { + isPtrSlice := elemType.Kind() == reflect.Ptr + baseType := elemType + + if isPtrSlice { + baseType = elemType.Elem() + } + + if baseType.Kind() == reflect.Struct { + slice := reflect.MakeSlice(reflect.SliceOf(elemType), 0, len(values)) + + for _, v := range values { + propPtr, err := GetConfigPropFromString(baseType, v) + if err != nil { + return false, err + } + + if isPtrSlice { + slice = reflect.Append(slice, propPtr) + } else { + slice = reflect.Append(slice, propPtr.Elem()) + } + } + + configField.Set(slice) + + return true, nil + } + + // Handle string slice/array + value := reflect.ValueOf(values) + + if field.Type.Kind() == reflect.Array { + arr := reflect.Indirect(reflect.New(field.Type)) + reflect.Copy(arr, value) + configField.Set(arr) + } else { + configField.Set(value) + } + + return true, nil +} + +// setEnumField handles enum field setting. +func setEnumField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) { + value := field.EnumFormatter.Parse(inputValue) + if value == EnumInvalid { + return false, fmt.Errorf( + "%w: accepted values are %v", + ErrInvalidEnumValue, + field.EnumFormatter.Names(), + ) + } + + configField.SetInt(int64(value)) + + if actual := int(configField.Int()); actual != value { + return false, fmt.Errorf( + "%w: expected %d, got %d (canSet: %v)", + ErrFailedToSetEnumValue, + value, + actual, + configField.CanSet(), + ) + } + + return true, nil +} + +// getMapValue converts a raw string to a map value based on type. +func getMapValue(valueType reflect.Type, valueRaw string) (reflect.Value, error) { + switch valueType.Kind() { + case reflect.String: + return reflect.ValueOf(valueRaw), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return getMapUintValue(valueRaw, valueType) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return getMapIntValue(valueRaw, valueType) + case reflect.Invalid, + reflect.Bool, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Array, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.Struct, + reflect.UnsafePointer: + return reflect.Value{}, ErrUnsupportedMapValue + default: + return reflect.Value{}, ErrUnsupportedMapValue + } +} + +// getMapUintValue converts a string to an unsigned integer map value. +func getMapUintValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) { + number, base := util.StripNumberPrefix(valueRaw) + + numValue, err := strconv.ParseUint(number, base, valueType.Bits()) + if err != nil { + return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseUintFailed, err) + } + + switch valueType.Kind() { + case reflect.Uint: + return reflect.ValueOf(uint(numValue)), nil + case reflect.Uint8: + if numValue > math.MaxUint8 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds uint8 range", + ErrParseUintFailed, + numValue, + ) + } + + return reflect.ValueOf(uint8(numValue)), nil + case reflect.Uint16: + if numValue > math.MaxUint16 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds uint16 range", + ErrParseUintFailed, + numValue, + ) + } + + return reflect.ValueOf(uint16(numValue)), nil + case reflect.Uint32: + if numValue > math.MaxUint32 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds uint32 range", + ErrParseUintFailed, + numValue, + ) + } + + return reflect.ValueOf(uint32(numValue)), nil + case reflect.Uint64: + return reflect.ValueOf(numValue), nil + case reflect.Invalid, + reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Array, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.String, + reflect.Struct, + reflect.UnsafePointer: + return reflect.Value{}, ErrUnexpectedUintKind + default: + return reflect.Value{}, ErrUnexpectedUintKind + } +} + +// getMapIntValue converts a string to a signed integer map value. +func getMapIntValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) { + number, base := util.StripNumberPrefix(valueRaw) + + numValue, err := strconv.ParseInt(number, base, valueType.Bits()) + if err != nil { + return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseIntFailed, err) + } + + switch valueType.Kind() { + case reflect.Int: + bits := valueType.Bits() + if bits == Int32BitSize { + if numValue < math.MinInt32 || numValue > math.MaxInt32 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds int%d range", + ErrParseIntFailed, + numValue, + bits, + ) + } + } + + return reflect.ValueOf(int(numValue)), nil + case reflect.Int8: + if numValue < math.MinInt8 || numValue > math.MaxInt8 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds int8 range", + ErrParseIntFailed, + numValue, + ) + } + + return reflect.ValueOf(int8(numValue)), nil + case reflect.Int16: + if numValue < math.MinInt16 || numValue > math.MaxInt16 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds int16 range", + ErrParseIntFailed, + numValue, + ) + } + + return reflect.ValueOf(int16(numValue)), nil + case reflect.Int32: + if numValue < math.MinInt32 || numValue > math.MaxInt32 { + return reflect.Value{}, fmt.Errorf( + "%w: value %d exceeds int32 range", + ErrParseIntFailed, + numValue, + ) + } + + return reflect.ValueOf(int32(numValue)), nil + case reflect.Int64: + return reflect.ValueOf(numValue), nil + case reflect.Invalid, + reflect.Bool, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Array, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Map, + reflect.Pointer, + reflect.Slice, + reflect.String, + reflect.Struct, + reflect.UnsafePointer: + return reflect.Value{}, ErrUnexpectedIntKind + default: + return reflect.Value{}, ErrUnexpectedIntKind + } +} + +// GetConfigFieldString converts a config field value to its string representation. +func GetConfigFieldString(config reflect.Value, field FieldInfo) (string, error) { + configField := config.FieldByName(field.Name) + if field.IsEnum() { + return field.EnumFormatter.Print(int(configField.Int())), nil + } + + strVal, token := getValueNodeValue(configField, &field) + if token == ErrorToken { + return "", ErrInvalidFieldValueData + } + + return strVal, nil +} diff --git a/pkg/format/formatter_test.go b/pkg/format/formatter_test.go new file mode 100644 index 0000000..c7bbfc0 --- /dev/null +++ b/pkg/format/formatter_test.go @@ -0,0 +1,288 @@ +package format + +import ( + "reflect" + "strings" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +// logger *log.Logger. +var ( + ts *testStruct + tv reflect.Value + nodeMap map[string]Node +) + +var _ = ginkgo.Describe("SetConfigField", func() { + testConfig := testStruct{} + tt := reflect.TypeOf(testConfig) + + ginkgo.When("updating a struct", func() { + ginkgo.BeforeEach(func() { + tsPtr := reflect.New(tt) + tv = tsPtr.Elem() + ts = tsPtr.Interface().(*testStruct) + gomega.Expect(tv.CanSet()).To(gomega.BeTrue()) + gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue()) + rootNode := getRootNode(ts) + nodeMap = make(map[string]Node, len(rootNode.Items)) + for _, item := range rootNode.Items { + field := item.Field() + nodeMap[field.Name] = item + } + gomega.Expect(int(tv.FieldByName("TestEnum").Int())). + To(gomega.Equal(0), "TestEnum initial value") + }) + ginkgo.When("setting an integer value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "3") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.Signed).To(gomega.Equal(3)) + }) + }) + ginkgo.When("the value is invalid", func() { + ginkgo.It("should return an error", func() { + ts.Signed = 2 + valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "z7") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeFalse()) + gomega.Expect(ts.Signed).To(gomega.Equal(2)) + }) + }) + }) + ginkgo.When("setting an unsigned integer value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "6") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(6))) + }) + }) + ginkgo.When("the value is invalid", func() { + ginkgo.It("should return an error", func() { + ts.Unsigned = 2 + valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "-3") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeFalse()) + gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(2))) + }) + }) + }) + ginkgo.When("setting a string slice value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField( + tv, + *nodeMap["StrSlice"].Field(), + "meawannowalkalitabitalleh,meawannofeelalitabitstrongah", + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.StrSlice).To(gomega.HaveLen(2)) + }) + }) + }) + ginkgo.When("setting a string array value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField( + tv, + *nodeMap["StrArray"].Field(), + "meawannowalkalitabitalleh,meawannofeelalitabitstrongah,meawannothinkalitabitsmartah", + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + }) + }) + ginkgo.When("the value has too many elements", func() { + ginkgo.It("should return an error", func() { + valid, err := SetConfigField( + tv, + *nodeMap["StrArray"].Field(), + "one,two,three,four?", + ) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeFalse()) + }) + }) + ginkgo.When("the value has too few elements", func() { + ginkgo.It("should return an error", func() { + valid, err := SetConfigField(tv, *nodeMap["StrArray"].Field(), "oneassis,two") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeFalse()) + }) + }) + }) + ginkgo.When("setting a struct value", func() { + ginkgo.When("it doesn't implement ConfigProp", func() { + ginkgo.It("should return an error", func() { + valid, err := SetConfigField(tv, *nodeMap["Sub"].Field(), "@awol") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).NotTo(gomega.BeTrue()) + }) + }) + ginkgo.When("it implements ConfigProp", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField(tv, *nodeMap["SubProp"].Field(), "@awol") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.SubProp.Value).To(gomega.Equal("awol")) + }) + }) + ginkgo.When("the value is invalid", func() { + ginkgo.It("should return an error", func() { + valid, err := SetConfigField( + tv, + *nodeMap["SubProp"].Field(), + "missing initial at symbol", + ) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(valid).NotTo(gomega.BeTrue()) + }) + }) + }) + }) + ginkgo.When("setting a struct slice value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField( + tv, + *nodeMap["SubPropSlice"].Field(), + "@alice,@merton", + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.SubPropSlice).To(gomega.HaveLen(2)) + }) + }) + }) + ginkgo.When("setting a struct pointer slice value", func() { + ginkgo.When("the value is valid", func() { + ginkgo.It("should set it", func() { + valid, err := SetConfigField( + tv, + *nodeMap["SubPropPtrSlice"].Field(), + "@the,@best", + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(valid).To(gomega.BeTrue()) + gomega.Expect(ts.SubPropPtrSlice).To(gomega.HaveLen(2)) + }) + }) + }) + }) + ginkgo.When("formatting stuct values", func() { + ginkgo.BeforeEach(func() { + tsPtr := reflect.New(tt) + tv = tsPtr.Elem() + ts = tsPtr.Interface().(*testStruct) + gomega.Expect(tv.CanSet()).To(gomega.BeTrue()) + gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue()) + rootNode := getRootNode(ts) + nodeMap = make(map[string]Node, len(rootNode.Items)) + for _, item := range rootNode.Items { + field := item.Field() + nodeMap[field.Name] = item + } + gomega.Expect(int(tv.FieldByName("TestEnum").Int())). + To(gomega.Equal(0), "TestEnum initial value") + }) + ginkgo.When("setting and formatting", func() { + ginkgo.It("should format signed integers identical to input", func() { + testSetAndFormat(tv, nodeMap["Signed"], "-45", "-45") + }) + ginkgo.It("should format unsigned integers identical to input", func() { + testSetAndFormat(tv, nodeMap["Unsigned"], "5", "5") + }) + ginkgo.It("should format structs identical to input", func() { + testSetAndFormat(tv, nodeMap["SubProp"], "@whoa", "@whoa") + }) + ginkgo.It("should format enums identical to input", func() { + testSetAndFormat(tv, nodeMap["TestEnum"], "Foo", "Foo") + }) + ginkgo.It("should format string slices identical to input", func() { + testSetAndFormat( + tv, + nodeMap["StrSlice"], + "one,two,three,four", + "[ one, two, three, four ]", + ) + }) + ginkgo.It("should format string arrays identical to input", func() { + testSetAndFormat(tv, nodeMap["StrArray"], "one,two,three", "[ one, two, three ]") + }) + ginkgo.It("should format prop struct slices identical to input", func() { + testSetAndFormat( + tv, + nodeMap["SubPropSlice"], + "@be,@the,@best", + "[ @be, @the, @best ]", + ) + }) + ginkgo.It("should format prop struct pointer slices identical to input", func() { + testSetAndFormat(tv, nodeMap["SubPropPtrSlice"], "@diet,@glue", "[ @diet, @glue ]") + }) + ginkgo.It("should format string maps identical to input", func() { + testSetAndFormat(tv, nodeMap["StrMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format int maps identical to input", func() { + testSetAndFormat(tv, nodeMap["IntMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format int8 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Int8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format int16 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Int16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format int32 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Int32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format int64 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Int64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format uint maps identical to input", func() { + testSetAndFormat(tv, nodeMap["UintMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format uint8 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Uint8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format uint16 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Uint16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format uint32 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Uint32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + ginkgo.It("should format uint64 maps identical to input", func() { + testSetAndFormat(tv, nodeMap["Uint64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }") + }) + }) + }) +}) + +func testSetAndFormat(tv reflect.Value, node Node, value string, prettyFormat string) { + field := node.Field() + + valid, err := SetConfigField(tv, *field, value) + if !valid { + gomega.Expect(err).To(gomega.HaveOccurred(), "SetConfigField returned false but no error") + } + + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "SetConfigField error: %v", err) + gomega.Expect(valid).To(gomega.BeTrue(), "SetConfigField failed") + + formatted, err := GetConfigFieldString(tv, *field) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "GetConfigFieldString error: %v", err) + gomega.Expect(formatted).To(gomega.Equal(value), "Expected %q, got %q", value, formatted) + node.Update(tv.FieldByName(field.Name)) + + sb := strings.Builder{} + renderer := ConsoleTreeRenderer{} + renderer.writeNodeValue(&sb, node) + gomega.Expect(sb.String()).To(gomega.Equal(prettyFormat)) +} diff --git a/pkg/format/node.go b/pkg/format/node.go new file mode 100644 index 0000000..949e064 --- /dev/null +++ b/pkg/format/node.go @@ -0,0 +1,390 @@ +package format + +import ( + "fmt" + "reflect" + "sort" + "strconv" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// NodeTokenType is used to represent the type of value that a node has for syntax highlighting. +type NodeTokenType int + +const ( + // UnknownToken represents all unknown/unspecified tokens. + UnknownToken NodeTokenType = iota + // NumberToken represents all numbers. + NumberToken + // StringToken represents strings and keys. + StringToken + // EnumToken represents enum values. + EnumToken + // TrueToken represent boolean true. + TrueToken + // FalseToken represent boolean false. + FalseToken + // PropToken represent a serializable struct prop. + PropToken + // ErrorToken represent a value that was not serializable or otherwise invalid. + ErrorToken + // ContainerToken is used for Array/Slice and Map tokens. + ContainerToken +) + +// Constants for number bases. +const ( + BaseDecimalLen = 10 + BaseHexLen = 16 +) + +// Node is the generic config tree item. +type Node interface { + Field() *FieldInfo + TokenType() NodeTokenType + Update(tv reflect.Value) +} + +// ValueNode is a Node without any child items. +type ValueNode struct { + *FieldInfo + Value string + tokenType NodeTokenType +} + +// Field returns the inner FieldInfo. +func (n *ValueNode) Field() *FieldInfo { + return n.FieldInfo +} + +// TokenType returns a NodeTokenType that matches the value. +func (n *ValueNode) TokenType() NodeTokenType { + return n.tokenType +} + +// Update updates the value string from the provided value. +func (n *ValueNode) Update(tv reflect.Value) { + value, token := getValueNodeValue(tv, n.FieldInfo) + n.Value = value + n.tokenType = token +} + +// ContainerNode is a Node with child items. +type ContainerNode struct { + *FieldInfo + Items []Node + MaxKeyLength int +} + +// Field returns the inner FieldInfo. +func (n *ContainerNode) Field() *FieldInfo { + return n.FieldInfo +} + +// TokenType always returns ContainerToken for ContainerNode. +func (n *ContainerNode) TokenType() NodeTokenType { + return ContainerToken +} + +// Update updates the items to match the provided value. +func (n *ContainerNode) Update(tv reflect.Value) { + switch n.Type.Kind() { + case reflect.Array, reflect.Slice: + n.updateArrayNode(tv) + case reflect.Map: + n.updateMapNode(tv) + case reflect.Invalid, + reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Pointer, + reflect.String, + reflect.Struct, + reflect.UnsafePointer: + // No-op for unsupported kinds + default: + // No-op for any remaining kinds + } +} + +func (n *ContainerNode) updateArrayNode(arrayValue reflect.Value) { + itemCount := arrayValue.Len() + n.Items = make([]Node, 0, itemCount) + + elemType := arrayValue.Type().Elem() + + for i := range itemCount { + key := strconv.Itoa(i) + val := arrayValue.Index(i) + n.Items = append(n.Items, getValueNode(val, &FieldInfo{ + Name: key, + Type: elemType, + })) + } +} + +func getArrayNode(arrayValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode { + node := &ContainerNode{ + FieldInfo: fieldInfo, + MaxKeyLength: 0, + } + node.updateArrayNode(arrayValue) + + return node +} + +func sortNodeItems(nodeItems []Node) { + sort.Slice(nodeItems, func(i, j int) bool { + return nodeItems[i].Field().Name < nodeItems[j].Field().Name + }) +} + +func (n *ContainerNode) updateMapNode(mapValue reflect.Value) { + base := n.Base + if base == 0 { + base = BaseDecimalLen + } + + elemType := mapValue.Type().Elem() + mapKeys := mapValue.MapKeys() + nodeItems := make([]Node, len(mapKeys)) + maxKeyLength := 0 + + for i, keyVal := range mapKeys { + // The keys will always be strings + key := keyVal.String() + val := mapValue.MapIndex(keyVal) + nodeItems[i] = getValueNode(val, &FieldInfo{ + Name: key, + Type: elemType, + Base: base, + }) + maxKeyLength = util.Max(len(key), maxKeyLength) + } + + sortNodeItems(nodeItems) + + n.Items = nodeItems + n.MaxKeyLength = maxKeyLength +} + +func getMapNode(mapValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode { + if mapValue.Kind() == reflect.Ptr { + mapValue = mapValue.Elem() + } + + node := &ContainerNode{ + FieldInfo: fieldInfo, + } + node.updateMapNode(mapValue) + + return node +} + +func getNode(fieldVal reflect.Value, fieldInfo *FieldInfo) Node { + switch fieldInfo.Type.Kind() { + case reflect.Array, reflect.Slice: + return getArrayNode(fieldVal, fieldInfo) + case reflect.Map: + return getMapNode(fieldVal, fieldInfo) + case reflect.Invalid, + reflect.Bool, + reflect.Int, + reflect.Int8, + reflect.Int16, + reflect.Int32, + reflect.Int64, + reflect.Uint, + reflect.Uint8, + reflect.Uint16, + reflect.Uint32, + reflect.Uint64, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.Pointer, + reflect.String, + reflect.Struct, + reflect.UnsafePointer: + return getValueNode(fieldVal, fieldInfo) + default: + return getValueNode(fieldVal, fieldInfo) + } +} + +func getRootNode(value any) *ContainerNode { + structValue := reflect.ValueOf(value) + if structValue.Kind() == reflect.Ptr { + structValue = structValue.Elem() + } + + structType := structValue.Type() + + enums := map[string]types.EnumFormatter{} + if enummer, isEnummer := value.(types.Enummer); isEnummer { + enums = enummer.Enums() + } + + infoFields := getStructFieldInfo(structType, enums) + nodeItems := make([]Node, 0, len(infoFields)) + maxKeyLength := 0 + + for _, fieldInfo := range infoFields { + fieldValue := structValue.FieldByName(fieldInfo.Name) + if !fieldValue.IsValid() { + fieldValue = reflect.Zero(fieldInfo.Type) + } + + nodeItems = append(nodeItems, getNode(fieldValue, &fieldInfo)) + maxKeyLength = util.Max(len(fieldInfo.Name), maxKeyLength) + } + + sortNodeItems(nodeItems) + + return &ContainerNode{ + FieldInfo: &FieldInfo{Type: structType}, + Items: nodeItems, + MaxKeyLength: maxKeyLength, + } +} + +func getValueNode(fieldVal reflect.Value, fieldInfo *FieldInfo) *ValueNode { + value, tokenType := getValueNodeValue(fieldVal, fieldInfo) + + return &ValueNode{ + FieldInfo: fieldInfo, + Value: value, + tokenType: tokenType, + } +} + +func getValueNodeValue(fieldValue reflect.Value, fieldInfo *FieldInfo) (string, NodeTokenType) { + kind := fieldValue.Kind() + + base := fieldInfo.Base + if base == 0 { + base = BaseDecimalLen + } + + if fieldInfo.IsEnum() { + return fieldInfo.EnumFormatter.Print(int(fieldValue.Int())), EnumToken + } + + switch kind { + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val := strconv.FormatUint(fieldValue.Uint(), base) + if base == BaseHexLen { + val = "0x" + val + } + + return val, NumberToken + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(fieldValue.Int(), base), NumberToken + case reflect.String: + return fieldValue.String(), StringToken + case reflect.Bool: + val := fieldValue.Bool() + if val { + return PrintBool(val), TrueToken + } + + return PrintBool(val), FalseToken + case reflect.Array, reflect.Slice, reflect.Map: + return getContainerValueString(fieldValue, fieldInfo), UnknownToken + case reflect.Ptr, reflect.Struct: + if val, err := GetConfigPropString(fieldValue); err == nil { + return val, PropToken + } + + return "", ErrorToken + case reflect.Invalid, + reflect.Uintptr, + reflect.Float32, + reflect.Float64, + reflect.Complex64, + reflect.Complex128, + reflect.Chan, + reflect.Func, + reflect.Interface, + reflect.UnsafePointer: + return fmt.Sprintf("", kind.String()), UnknownToken + default: + return fmt.Sprintf("", kind.String()), UnknownToken + } +} + +func getContainerValueString(fieldValue reflect.Value, fieldInfo *FieldInfo) string { + itemSeparator := fieldInfo.ItemSeparator + sliceLength := fieldValue.Len() + + var mapKeys []reflect.Value + if fieldInfo.Type.Kind() == reflect.Map { + mapKeys = fieldValue.MapKeys() + sort.Slice(mapKeys, func(a, b int) bool { + return mapKeys[a].String() < mapKeys[b].String() + }) + } + + stringBuilder := strings.Builder{} + + var itemFieldInfo *FieldInfo + + for i := range sliceLength { + var itemValue reflect.Value + + if i > 0 { + stringBuilder.WriteRune(itemSeparator) + } + + if mapKeys != nil { + mapKey := mapKeys[i] + stringBuilder.WriteString(mapKey.String()) + stringBuilder.WriteRune(':') + + itemValue = fieldValue.MapIndex(mapKey) + } else { + itemValue = fieldValue.Index(i) + } + + if i == 0 { + itemFieldInfo = &FieldInfo{ + Type: itemValue.Type(), + // Inherit the base from the container + Base: fieldInfo.Base, + } + + if itemFieldInfo.Base == 0 { + itemFieldInfo.Base = BaseDecimalLen + } + } + + strVal, _ := getValueNodeValue(itemValue, itemFieldInfo) + stringBuilder.WriteString(strVal) + } + + return stringBuilder.String() +} diff --git a/pkg/format/prop_key_resolver.go b/pkg/format/prop_key_resolver.go new file mode 100644 index 0000000..5531254 --- /dev/null +++ b/pkg/format/prop_key_resolver.go @@ -0,0 +1,176 @@ +package format + +import ( + "errors" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var ( + ErrInvalidConfigKey = errors.New("not a valid config key") + ErrInvalidValueForType = errors.New("invalid value for type") +) + +// PropKeyResolver implements the ConfigQueryResolver interface for services that uses key tags for query props. +type PropKeyResolver struct { + confValue reflect.Value + keyFields map[string]FieldInfo + keys []string +} + +// NewPropKeyResolver creates a new PropKeyResolver and initializes it using the provided config. +func NewPropKeyResolver(config types.ServiceConfig) PropKeyResolver { + configNode := GetConfigFormat(config) + items := configNode.Items + + keyFields := make(map[string]FieldInfo, len(items)) + keys := make([]string, 0, len(items)) + + for _, item := range items { + field := *item.Field() + if len(field.Keys) == 0 { + continue // Skip fields without explicit 'key' tags + } + + for _, key := range field.Keys { + key = strings.ToLower(key) + if key != "" { + keys = append(keys, key) + keyFields[key] = field + } + } + } + + sort.Strings(keys) + + confValue := reflect.ValueOf(config) + if confValue.Kind() == reflect.Ptr { + confValue = confValue.Elem() + } + + return PropKeyResolver{ + keyFields: keyFields, + confValue: confValue, + keys: keys, + } +} + +// QueryFields returns a list of tagged keys. +func (pkr *PropKeyResolver) QueryFields() []string { + return pkr.keys +} + +// Get returns the value of a config property tagged with the corresponding key. +func (pkr *PropKeyResolver) Get(key string) (string, error) { + if field, found := pkr.keyFields[strings.ToLower(key)]; found { + return GetConfigFieldString(pkr.confValue, field) + } + + return "", fmt.Errorf("%w: %v", ErrInvalidConfigKey, key) +} + +// Set sets the value of its bound struct's property, tagged with the corresponding key. +func (pkr *PropKeyResolver) Set(key string, value string) error { + return pkr.set(pkr.confValue, key, value) +} + +// set sets the value of a target struct tagged with the corresponding key. +func (pkr *PropKeyResolver) set(target reflect.Value, key string, value string) error { + if field, found := pkr.keyFields[strings.ToLower(key)]; found { + valid, err := SetConfigField(target, field, value) + if !valid && err == nil { + return ErrInvalidValueForType + } + + return err + } + + return fmt.Errorf("%w: %v (valid keys: %v)", ErrInvalidConfigKey, key, pkr.keys) +} + +// UpdateConfigFromParams mutates the provided config, updating the values from its corresponding params. +// If the provided config is nil, the internal config will be updated instead. +// The error returned is the first error that occurred; subsequent errors are discarded. +func (pkr *PropKeyResolver) UpdateConfigFromParams( + config types.ServiceConfig, + params *types.Params, +) error { + var firstError error + + confValue := pkr.configValueOrInternal(config) + + if params != nil { + for key, val := range *params { + if err := pkr.set(confValue, key, val); err != nil && firstError == nil { + firstError = err + } + } + } + + return firstError +} + +// SetDefaultProps mutates the provided config, setting the tagged fields with their default values. +// If the provided config is nil, the internal config will be updated instead. +// The error returned is the first error that occurred; subsequent errors are discarded. +func (pkr *PropKeyResolver) SetDefaultProps(config types.ServiceConfig) error { + var firstError error + + confValue := pkr.configValueOrInternal(config) + for key, info := range pkr.keyFields { + if err := pkr.set(confValue, key, info.DefaultValue); err != nil && firstError == nil { + firstError = err + } + } + + return firstError +} + +// Bind creates a new instance of the PropKeyResolver with the internal config reference +// set to the provided config. This should only be used for configs of the same type. +func (pkr *PropKeyResolver) Bind(config types.ServiceConfig) PropKeyResolver { + bound := *pkr + bound.confValue = configValue(config) + + return bound +} + +// GetConfigQueryResolver returns the ConfigQueryResolver interface for the config if it implements it, +// otherwise it creates and returns a PropKeyResolver that implements it. +func GetConfigQueryResolver(config types.ServiceConfig) types.ConfigQueryResolver { + var resolver types.ConfigQueryResolver + + var ok bool + if resolver, ok = config.(types.ConfigQueryResolver); !ok { + pkr := NewPropKeyResolver(config) + resolver = &pkr + } + + return resolver +} + +// KeyIsPrimary returns whether the key is the primary (and not an alias). +func (pkr *PropKeyResolver) KeyIsPrimary(key string) bool { + return pkr.keyFields[key].Keys[0] == key +} + +func (pkr *PropKeyResolver) configValueOrInternal(config types.ServiceConfig) reflect.Value { + if config != nil { + return configValue(config) + } + + return pkr.confValue +} + +func configValue(config types.ServiceConfig) reflect.Value { + return reflect.Indirect(reflect.ValueOf(config)) +} + +// IsDefault returns whether the specified key value is the default value. +func (pkr *PropKeyResolver) IsDefault(key string, value string) bool { + return pkr.keyFields[key].DefaultValue == value +} diff --git a/pkg/format/prop_key_resolver_test.go b/pkg/format/prop_key_resolver_test.go new file mode 100644 index 0000000..c25a163 --- /dev/null +++ b/pkg/format/prop_key_resolver_test.go @@ -0,0 +1,58 @@ +package format + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var _ = ginkgo.Describe("Prop Key Resolver", func() { + var ( + ts *testStruct + pkr PropKeyResolver + ) + ginkgo.BeforeEach(func() { + ts = &testStruct{} + pkr = NewPropKeyResolver(ts) + _ = pkr.SetDefaultProps(ts) + }) + ginkgo.Describe("Updating config props from params", func() { + ginkgo.When("a param matches a prop key", func() { + ginkgo.It("should be updated in the config", func() { + err := pkr.UpdateConfigFromParams(nil, &types.Params{"str": "newValue"}) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(ts.Str).To(gomega.Equal("newValue")) + }) + }) + ginkgo.When("a param does not match a prop key", func() { + ginkgo.It("should report the first error", func() { + err := pkr.UpdateConfigFromParams(nil, &types.Params{"a": "z"}) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("should process the other keys", func() { + _ = pkr.UpdateConfigFromParams( + nil, + &types.Params{"signed": "1", "b": "c", "str": "val"}, + ) + gomega.Expect(ts.Signed).To(gomega.Equal(1)) + gomega.Expect(ts.Str).To(gomega.Equal("val")) + }) + }) + }) + ginkgo.Describe("Setting default props", func() { + ginkgo.When("a default tag are set for a field", func() { + ginkgo.It("should have that value as default", func() { + gomega.Expect(ts.Str).To(gomega.Equal("notempty")) + }) + }) + ginkgo.When("a default tag have an invalid value", func() { + ginkgo.It("should have that value as default", func() { + tsb := &testStructBadDefault{} + pkr = NewPropKeyResolver(tsb) + err := pkr.SetDefaultProps(tsb) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) +}) diff --git a/pkg/format/render_console.go b/pkg/format/render_console.go new file mode 100644 index 0000000..c3926b5 --- /dev/null +++ b/pkg/format/render_console.go @@ -0,0 +1,201 @@ +package format + +import ( + "fmt" + "strings" + + "github.com/fatih/color" + + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// Constants for console rendering. +const ( + DescriptionColumnWidth = 60 // Width of the description column in console output + ItemSeparatorLength = 2 // Length of the ", " separator between container items + DefaultValueOffset = 16 // Minimum offset before description when no values are shown + ValueOffset = 30 // Offset before description when values are shown + ContainerBracketLength = 4 // Length of container delimiters (e.g., "{ }" or "[ ]") + KeySeparatorLength = 2 // Length of the ": " separator after a key in containers +) + +// ConsoleTreeRenderer renders a ContainerNode tree into an ansi-colored console string. +type ConsoleTreeRenderer struct { + WithValues bool +} + +// RenderTree renders a ContainerNode tree into an ansi-colored console string. +func (r ConsoleTreeRenderer) RenderTree(root *ContainerNode, _ string) string { + stringBuilder := strings.Builder{} + + for _, node := range root.Items { + fieldKey := node.Field().Name + stringBuilder.WriteString(fieldKey) + + for i := len(fieldKey); i <= root.MaxKeyLength; i++ { + stringBuilder.WriteRune(' ') + } + + var valueLen int // Initialize without assignment; set later + + preLen := DefaultValueOffset // Default spacing before the description when no values are rendered + + field := node.Field() + + if r.WithValues { + preLen = ValueOffset // Adjusts the spacing when values are included + valueLen = r.writeNodeValue(&stringBuilder, node) + } else { + // Since no values were supplied, substitute the value with the type + typeName := field.Type.String() + + // If the value is an enum type, providing the name is a bit pointless + // Instead, use a common string "option" to signify the type + if field.EnumFormatter != nil { + typeName = "option" + } + + valueLen = len(typeName) + stringBuilder.WriteString(color.CyanString(typeName)) + } + + stringBuilder.WriteString(strings.Repeat(" ", util.Max(preLen-valueLen, 1))) + stringBuilder.WriteString(ColorizeDesc(field.Description)) + stringBuilder.WriteString( + strings.Repeat(" ", util.Max(DescriptionColumnWidth-len(field.Description), 1)), + ) + + if len(field.URLParts) > 0 && field.URLParts[0] != URLQuery { + stringBuilder.WriteString(" 0 { + stringBuilder.WriteString(", ") + } + + if part > URLPath { + part = URLPath + } + + stringBuilder.WriteString(ColorizeEnum(part)) + } + + stringBuilder.WriteString(">") + } + + if len(field.Template) > 0 { + stringBuilder.WriteString( + fmt.Sprintf(" ", ColorizeString(field.Template)), + ) + } + + if len(field.DefaultValue) > 0 { + stringBuilder.WriteString( + fmt.Sprintf( + " ", + ColorizeValue(field.DefaultValue, field.EnumFormatter != nil), + ), + ) + } + + if field.Required { + stringBuilder.WriteString(fmt.Sprintf(" <%s>", ColorizeFalse("Required"))) + } + + if len(field.Keys) > 1 { + stringBuilder.WriteString(" 1 { + stringBuilder.WriteString(", ") + } + + stringBuilder.WriteString(ColorizeString(key)) + } + + stringBuilder.WriteString(">") + } + + if field.EnumFormatter != nil { + stringBuilder.WriteString(ColorizeContainer(" [")) + + for i, name := range field.EnumFormatter.Names() { + if i != 0 { + stringBuilder.WriteString(", ") + } + + stringBuilder.WriteString(ColorizeEnum(name)) + } + + stringBuilder.WriteString(ColorizeContainer("]")) + } + + stringBuilder.WriteRune('\n') + } + + return stringBuilder.String() +} + +func (r ConsoleTreeRenderer) writeNodeValue(stringBuilder *strings.Builder, node Node) int { + if contNode, isContainer := node.(*ContainerNode); isContainer { + return r.writeContainer(stringBuilder, contNode) + } + + if valNode, isValue := node.(*ValueNode); isValue { + stringBuilder.WriteString(ColorizeToken(valNode.Value, valNode.tokenType)) + + return len(valNode.Value) + } + + stringBuilder.WriteRune('?') + + return 1 +} + +func (r ConsoleTreeRenderer) writeContainer( + stringBuilder *strings.Builder, + node *ContainerNode, +) int { + kind := node.Type.Kind() + hasKeys := !util.IsCollection(kind) + + totalLen := ContainerBracketLength // Length of the opening and closing brackets ({ } or [ ]) + + if hasKeys { + stringBuilder.WriteString("{ ") + } else { + stringBuilder.WriteString("[ ") + } + + for i, itemNode := range node.Items { + if i != 0 { + stringBuilder.WriteString(", ") + + totalLen += KeySeparatorLength // Accounts for the : separator between keys and values in containers + } + + if hasKeys { + itemKey := itemNode.Field().Name + stringBuilder.WriteString(itemKey) + stringBuilder.WriteString(": ") + + totalLen += len(itemKey) + ItemSeparatorLength + } + + valLen := r.writeNodeValue(stringBuilder, itemNode) + totalLen += valLen + } + + if hasKeys { + stringBuilder.WriteString(" }") + } else { + stringBuilder.WriteString(" ]") + } + + return totalLen +} diff --git a/pkg/format/render_console_test.go b/pkg/format/render_console_test.go new file mode 100644 index 0000000..1344640 --- /dev/null +++ b/pkg/format/render_console_test.go @@ -0,0 +1,54 @@ +package format + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" +) + +var _ = ginkgo.Describe("RenderConsole", func() { + format.CharactersAroundMismatchToInclude = 30 + renderer := ConsoleTreeRenderer{WithValues: false} + + ginkgo.It("should render the expected output based on config reflection/tags", func() { + actual := testRenderTree(renderer, &struct { + Name string `default:"notempty"` + Host string `url:"host"` + }{}) + + expected := ` +Host string +Name string +`[1:] + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.It(`should render enum types as "option"`, func() { + actual := testRenderTree(renderer, &testEnummer{}) + + expected := ` +Choice option [Yes, No, Maybe] +`[1:] + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.It("should render url paths in sorted order", func() { + actual := testRenderTree(renderer, &struct { + Host string `url:"host"` + Path1 string `url:"path1"` + Path3 string `url:"path3"` + Path2 string `url:"path2"` + }{}) + + expected := ` +Host string +Path1 string +Path2 string +Path3 string +`[1:] + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) +}) diff --git a/pkg/format/render_markdown.go b/pkg/format/render_markdown.go new file mode 100644 index 0000000..f9da475 --- /dev/null +++ b/pkg/format/render_markdown.go @@ -0,0 +1,259 @@ +package format + +import ( + "reflect" + "sort" + "strings" +) + +// MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string. +type MarkdownTreeRenderer struct { + HeaderPrefix string + PropsDescription string + PropsEmptyMessage string +} + +// Constants for dynamic path segment offsets. +const ( + PathOffset1 = 1 + PathOffset2 = 2 + PathOffset3 = 3 +) + +// RenderTree renders a ContainerNode tree into a markdown documentation string. +func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) string { + stringBuilder := strings.Builder{} + + queryFields := make([]*FieldInfo, 0, len(root.Items)) + urlFields := make([]*FieldInfo, 0, len(root.Items)) // Zero length, capacity for all fields + dynamicURLFields := make([]*FieldInfo, 0, len(root.Items)) + + for _, node := range root.Items { + field := node.Field() + for _, urlPart := range field.URLParts { + switch urlPart { + case URLQuery: + queryFields = append(queryFields, field) + case URLPath + PathOffset1, + URLPath + PathOffset2, + URLPath + PathOffset3: + dynamicURLFields = append(dynamicURLFields, field) + case URLUser, URLPassword, URLHost, URLPort, URLPath: + urlFields = append(urlFields, field) + } + } + + if len(field.URLParts) < 1 { + queryFields = append(queryFields, field) + } + } + + // Append dynamic fields to urlFields + urlFields = append(urlFields, dynamicURLFields...) + + // Sort by primary URLPart + sort.SliceStable(urlFields, func(i, j int) bool { + urlPartA := URLQuery + if len(urlFields[i].URLParts) > 0 { + urlPartA = urlFields[i].URLParts[0] + } + + urlPartB := URLQuery + if len(urlFields[j].URLParts) > 0 { + urlPartB = urlFields[j].URLParts[0] + } + + return urlPartA < urlPartB + }) + + r.writeURLFields(&stringBuilder, urlFields, scheme) + + sort.SliceStable(queryFields, func(i, j int) bool { + return queryFields[i].Required && !queryFields[j].Required + }) + + r.writeHeader(&stringBuilder, "Query/Param Props") + + if len(queryFields) > 0 { + stringBuilder.WriteString(r.PropsDescription) + } else { + stringBuilder.WriteString(r.PropsEmptyMessage) + } + + stringBuilder.WriteRune('\n') + + for _, field := range queryFields { + r.writeFieldPrimary(&stringBuilder, field) + r.writeFieldExtras(&stringBuilder, field) + stringBuilder.WriteRune('\n') + } + + return stringBuilder.String() +} + +func (r MarkdownTreeRenderer) writeURLFields( + stringBuilder *strings.Builder, + urlFields []*FieldInfo, + scheme string, +) { + fieldsPrinted := make(map[string]bool) + + r.writeHeader(stringBuilder, "URL Fields") + + for _, field := range urlFields { + if field == nil || fieldsPrinted[field.Name] { + continue + } + + r.writeFieldPrimary(stringBuilder, field) + + stringBuilder.WriteString(" URL part: ") + stringBuilder.WriteString(scheme) + stringBuilder.WriteString("://") + + // Check for presence of URLUser or URLPassword + hasUser := false + hasPassword := false + maxPart := URLUser // Track the highest URLPart used + + for _, f := range urlFields { + if f != nil { + for _, part := range f.URLParts { + switch part { + case URLQuery, URLHost, URLPort, URLPath: // No-op for these cases + case URLUser: + hasUser = true + case URLPassword: + hasPassword = true + } + + if part > maxPart { + maxPart = part + } + } + } + } + + // Build URL with this field highlighted + for i := URLUser; i <= URLPath+PathOffset3; i++ { + urlPart := i + for _, fieldInfo := range urlFields { + if fieldInfo != nil && fieldInfo.IsURLPart(urlPart) { + if i > URLUser { + lastPart := i - 1 + if lastPart == URLPassword && (hasUser || hasPassword) { + stringBuilder.WriteRune( + lastPart.Suffix(), + ) // ':' only if credentials present + } else if lastPart != URLPassword { + stringBuilder.WriteRune(lastPart.Suffix()) // '/' or '@' + } + } + + slug := strings.ToLower(fieldInfo.Name) + if slug == "host" && urlPart == URLPort { + slug = "port" + } + + if fieldInfo == field { + stringBuilder.WriteString("") + stringBuilder.WriteString(slug) + stringBuilder.WriteString("") + } else { + stringBuilder.WriteString(slug) + } + + break + } + } + } + + // Add trailing '/' if no dynamic path segments follow + if maxPart < URLPath+PathOffset1 { + stringBuilder.WriteRune('/') + } + + stringBuilder.WriteString(" \n") + + fieldsPrinted[field.Name] = true + } +} + +func (MarkdownTreeRenderer) writeFieldExtras(stringBuilder *strings.Builder, field *FieldInfo) { + if len(field.Keys) > 1 { + stringBuilder.WriteString(" Aliases: `") + + for i, key := range field.Keys { + if i == 0 { + // Skip primary alias (as it's the same as the field name) + continue + } + + if i > 1 { + stringBuilder.WriteString("`, `") + } + + stringBuilder.WriteString(key) + } + + stringBuilder.WriteString("` \n") + } + + if field.EnumFormatter != nil { + stringBuilder.WriteString(" Possible values: `") + + for i, name := range field.EnumFormatter.Names() { + if i != 0 { + stringBuilder.WriteString("`, `") + } + + stringBuilder.WriteString(name) + } + + stringBuilder.WriteString("` \n") + } +} + +func (MarkdownTreeRenderer) writeFieldPrimary(stringBuilder *strings.Builder, field *FieldInfo) { + fieldKey := field.Name + + stringBuilder.WriteString("* __") + stringBuilder.WriteString(fieldKey) + stringBuilder.WriteString("__") + + if field.Description != "" { + stringBuilder.WriteString(" - ") + stringBuilder.WriteString(field.Description) + } + + if field.Required { + stringBuilder.WriteString(" (**Required**) \n") + } else { + stringBuilder.WriteString(" \n Default: ") + + if field.DefaultValue == "" { + stringBuilder.WriteString("*empty*") + } else { + if field.Type.Kind() == reflect.Bool { + defaultValue, _ := ParseBool(field.DefaultValue, false) + if defaultValue { + stringBuilder.WriteString("✔ ") + } else { + stringBuilder.WriteString("❌ ") + } + } + + stringBuilder.WriteRune('`') + stringBuilder.WriteString(field.DefaultValue) + stringBuilder.WriteRune('`') + } + + stringBuilder.WriteString(" \n") + } +} + +func (r MarkdownTreeRenderer) writeHeader(stringBuilder *strings.Builder, text string) { + stringBuilder.WriteString(r.HeaderPrefix) + stringBuilder.WriteString(text) + stringBuilder.WriteString("\n\n") +} diff --git a/pkg/format/render_markdown_test.go b/pkg/format/render_markdown_test.go new file mode 100644 index 0000000..d743233 --- /dev/null +++ b/pkg/format/render_markdown_test.go @@ -0,0 +1,149 @@ +package format + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" +) + +var _ = ginkgo.Describe("RenderMarkdown", func() { + format.CharactersAroundMismatchToInclude = 10 + + ginkgo.It("should render the expected output based on config reflection/tags", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Name string `default:"notempty"` + Host string `url:"host"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + + +* __Name__ + Default: `[1:] + "`notempty`" + ` + +` + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.It("should render url paths in sorted order", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Host string `url:"host"` + Path1 string `url:"path1"` + Path3 string `url:"path3"` + Path2 string `url:"path2"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path1__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path2__ (**Required**) + URL part: mock://host/path1/path2/path3 +* __Path3__ (**Required**) + URL part: mock://host/path1/path2/path3 +### Query/Param Props + + +`[1:] // Remove initial newline + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.It("should render prop aliases", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct { + Name string `key:"name,handle,title,target"` + }{}) + + expected := ` +### URL Fields + +### Query/Param Props + + +* __Name__ (**Required**) + Aliases: `[1:] + "`handle`, `title`, `target`" + ` + +` + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.It("should render possible enum values", func() { + actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &testEnummer{}) + + expected := ` +### URL Fields + +### Query/Param Props + + +* __Choice__ + Default: `[1:] + "`Maybe`" + ` + Possible values: ` + "`Yes`, `No`, `Maybe`" + ` + +` + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + + ginkgo.When("there are no query props", func() { + ginkgo.It("should prepend an empty-message instead of props description", func() { + actual := testRenderTree(MarkdownTreeRenderer{ + HeaderPrefix: `### `, + PropsDescription: "Feel free to set these:", + PropsEmptyMessage: "There is nothing to set!", + }, &struct { + Host string `url:"host"` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + +There is nothing to set! +`[1:] // Remove initial newline + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + }) + + ginkgo.When("there are query props", func() { + ginkgo.It("should prepend the props description", func() { + actual := testRenderTree(MarkdownTreeRenderer{ + HeaderPrefix: `### `, + PropsDescription: "Feel free to set these:", + PropsEmptyMessage: "There is nothing to set!", + }, &struct { + Host string `url:"host"` + CoolMode bool `key:"coolmode" optional:""` + }{}) + + expected := ` +### URL Fields + +* __Host__ (**Required**) + URL part: mock://host/ +### Query/Param Props + +Feel free to set these: +* __CoolMode__ + Default: *empty* + +`[1:] // Remove initial newline + + gomega.Expect(actual).To(gomega.Equal(expected)) + }) + }) +}) diff --git a/pkg/format/render_test.go b/pkg/format/render_test.go new file mode 100644 index 0000000..a9d30c8 --- /dev/null +++ b/pkg/format/render_test.go @@ -0,0 +1,17 @@ +package format + +import t "github.com/nicholas-fedor/shoutrrr/pkg/types" + +type testEnummer struct { + Choice int `default:"Maybe" key:"choice"` +} + +func (testEnummer) Enums() map[string]t.EnumFormatter { + return map[string]t.EnumFormatter{ + "Choice": CreateEnumFormatter([]string{"Yes", "No", "Maybe"}), + } +} + +func testRenderTree(r TreeRenderer, v any) string { + return r.RenderTree(getRootNode(v), "mock") +} diff --git a/pkg/format/tree_renderer.go b/pkg/format/tree_renderer.go new file mode 100644 index 0000000..dd7d324 --- /dev/null +++ b/pkg/format/tree_renderer.go @@ -0,0 +1,6 @@ +package format + +// TreeRenderer renders a ContainerNode tree into a string. +type TreeRenderer interface { + RenderTree(root *ContainerNode, scheme string) string +} diff --git a/pkg/format/urlpart.go b/pkg/format/urlpart.go new file mode 100644 index 0000000..2aa3efc --- /dev/null +++ b/pkg/format/urlpart.go @@ -0,0 +1,84 @@ +//go:generate stringer -type=URLPart -trimprefix URL + +package format + +import ( + "log" + "strconv" + "strings" +) + +// URLPart is an indicator as to what part of an URL a field is serialized to. +type URLPart int + +// Suffix returns the separator between the URLPart and its subsequent part. +func (u URLPart) Suffix() rune { + switch u { + case URLQuery: + return '/' + case URLUser: + return ':' + case URLPassword: + return '@' + case URLHost: + return ':' + case URLPort: + return '/' + case URLPath: + return '/' + default: + return '/' + } +} + +// indicator as to what part of an URL a field is serialized to. +const ( + URLQuery URLPart = iota + URLUser + URLPassword + URLHost + URLPort + URLPath // Base path; additional paths are URLPath + N +) + +// ParseURLPart returns the URLPart that matches the supplied string. +func ParseURLPart(inputString string) URLPart { + lowerString := strings.ToLower(inputString) + switch lowerString { + case "user": + return URLUser + case "pass", "password": + return URLPassword + case "host": + return URLHost + case "port": + return URLPort + case "path", "path1": + return URLPath + case "query", "": + return URLQuery + } + + // Handle dynamic path segments (e.g., "path2", "path3", etc.). + if strings.HasPrefix(lowerString, "path") && len(lowerString) > 4 { + if num, err := strconv.Atoi(lowerString[4:]); err == nil && num >= 2 { + return URLPath + URLPart(num-1) // Offset from URLPath; "path2" -> URLPath+1 + } + } + + log.Printf("invalid URLPart: %s, defaulting to URLQuery", lowerString) + + return URLQuery +} + +// ParseURLParts returns the URLParts that matches the supplied string. +func ParseURLParts(s string) []URLPart { + rawParts := strings.Split(s, ",") + urlParts := make([]URLPart, len(rawParts)) + + for i, raw := range rawParts { + urlParts[i] = ParseURLPart(raw) + } + + return urlParts +} diff --git a/pkg/format/urlpart_string.go b/pkg/format/urlpart_string.go new file mode 100644 index 0000000..dbdfc51 --- /dev/null +++ b/pkg/format/urlpart_string.go @@ -0,0 +1,28 @@ +// Code generated by "stringer -type=URLPart -trimprefix URL"; DO NOT EDIT. + +package format + +import "strconv" + +func _() { + // An "invalid array index" compiler error signifies that the constant values have changed. + // Re-run the stringer command to generate them again. + var x [1]struct{} + _ = x[URLQuery-0] + _ = x[URLUser-1] + _ = x[URLPassword-2] + _ = x[URLHost-3] + _ = x[URLPort-4] + _ = x[URLPath-5] +} + +const _URLPart_name = "QueryUserPasswordHostPortPath" + +var _URLPart_index = [...]uint8{0, 5, 9, 17, 21, 25, 29} + +func (i URLPart) String() string { + if i < 0 || i >= URLPart(len(_URLPart_index)-1) { + return "URLPart(" + strconv.FormatInt(int64(i), 10) + ")" + } + return _URLPart_name[_URLPart_index[i]:_URLPart_index[i+1]] +} diff --git a/pkg/format/urlpart_test.go b/pkg/format/urlpart_test.go new file mode 100644 index 0000000..9e146f8 --- /dev/null +++ b/pkg/format/urlpart_test.go @@ -0,0 +1,32 @@ +package format_test + +import ( + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" +) + +var _ = ginkgo.Describe("URLPart", func() { + ginkgo.It("should return the expected URL part for each lookup key", func() { + gomega.Expect(format.ParseURLPart("user")).To(gomega.Equal(format.URLUser)) + gomega.Expect(format.ParseURLPart("pass")).To(gomega.Equal(format.URLPassword)) + gomega.Expect(format.ParseURLPart("password")).To(gomega.Equal(format.URLPassword)) + gomega.Expect(format.ParseURLPart("host")).To(gomega.Equal(format.URLHost)) + gomega.Expect(format.ParseURLPart("port")).To(gomega.Equal(format.URLPort)) + gomega.Expect(format.ParseURLPart("path")).To(gomega.Equal(format.URLPath)) + gomega.Expect(format.ParseURLPart("path1")).To(gomega.Equal(format.URLPath)) + gomega.Expect(format.ParseURLPart("path2")).To(gomega.Equal(format.URLPath + 1)) + gomega.Expect(format.ParseURLPart("path3")).To(gomega.Equal(format.URLPath + 2)) + gomega.Expect(format.ParseURLPart("path4")).To(gomega.Equal(format.URLPath + 3)) + gomega.Expect(format.ParseURLPart("query")).To(gomega.Equal(format.URLQuery)) + gomega.Expect(format.ParseURLPart("")).To(gomega.Equal(format.URLQuery)) + }) + ginkgo.It("should return the expected suffix for each URL part", func() { + gomega.Expect(format.URLUser.Suffix()).To(gomega.Equal(':')) + gomega.Expect(format.URLPassword.Suffix()).To(gomega.Equal('@')) + gomega.Expect(format.URLHost.Suffix()).To(gomega.Equal(':')) + gomega.Expect(format.URLPort.Suffix()).To(gomega.Equal('/')) + gomega.Expect(format.URLPath.Suffix()).To(gomega.Equal('/')) + }) +}) diff --git a/pkg/generators/basic/basic.go b/pkg/generators/basic/basic.go new file mode 100644 index 0000000..b00da85 --- /dev/null +++ b/pkg/generators/basic/basic.go @@ -0,0 +1,219 @@ +package basic + +import ( + "bufio" + "errors" + "fmt" + "os" + "reflect" + "strconv" + "strings" + + "github.com/fatih/color" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Errors defined as static variables for better error handling. +var ( + ErrInvalidConfigType = errors.New("config does not implement types.ServiceConfig") + ErrInvalidConfigField = errors.New("config field is invalid or nil") + ErrRequiredFieldMissing = errors.New("field is required and has no default value") +) + +// Generator is the Basic Generator implementation for creating service configurations. +type Generator struct{} + +// Generate creates a service configuration by prompting the user for field values or using provided properties. +func (g *Generator) Generate( + service types.Service, + props map[string]string, + _ []string, +) (types.ServiceConfig, error) { + configPtr := reflect.ValueOf(service).Elem().FieldByName("Config") + if !configPtr.IsValid() || configPtr.IsNil() { + return nil, ErrInvalidConfigField + } + + scanner := bufio.NewScanner(os.Stdin) + if err := g.promptUserForFields(configPtr, props, scanner); err != nil { + return nil, err + } + + if config, ok := configPtr.Interface().(types.ServiceConfig); ok { + return config, nil + } + + return nil, ErrInvalidConfigType +} + +// promptUserForFields iterates over config fields, prompting the user or using props to set values. +func (g *Generator) promptUserForFields( + configPtr reflect.Value, + props map[string]string, + scanner *bufio.Scanner, +) error { + serviceConfig, ok := configPtr.Interface().(types.ServiceConfig) + if !ok { + return ErrInvalidConfigType + } + + configNode := format.GetConfigFormat(serviceConfig) + config := configPtr.Elem() // Dereference for setting fields + + for _, item := range configNode.Items { + field := item.Field() + propKey := strings.ToLower(field.Name) + + for { + inputValue, err := g.getInputValue(field, propKey, props, scanner) + if err != nil { + return err // Propagate the error immediately + } + + if valid, err := g.setFieldValue(config, field, inputValue); valid { + break + } else if err != nil { + g.printError(field.Name, err.Error()) + } else { + g.printInvalidType(field.Name, field.Type.Kind().String()) + } + } + } + + return nil +} + +// getInputValue retrieves the value for a field from props or user input. +func (g *Generator) getInputValue( + field *format.FieldInfo, + propKey string, + props map[string]string, + scanner *bufio.Scanner, +) (string, error) { + if propValue, ok := props[propKey]; ok && len(propValue) > 0 { + _, _ = fmt.Fprint( + color.Output, + "Using property ", + color.HiCyanString(propValue), + " for ", + color.HiMagentaString(field.Name), + " field\n", + ) + props[propKey] = "" + + return propValue, nil + } + + prompt := g.formatPrompt(field) + _, _ = fmt.Fprint(color.Output, prompt) + + if scanner.Scan() { + input := scanner.Text() + if len(input) == 0 { + if len(field.DefaultValue) > 0 { + return field.DefaultValue, nil + } + + if field.Required { + return "", fmt.Errorf("%s: %w", field.Name, ErrRequiredFieldMissing) + } + + return "", nil + } + + // More specific type validation + if field.Type != nil { + kind := field.Type.Kind() + if kind == reflect.Int || kind == reflect.Int8 || kind == reflect.Int16 || + kind == reflect.Int32 || kind == reflect.Int64 { + if _, err := strconv.ParseInt(input, 10, field.Type.Bits()); err != nil { + return "", fmt.Errorf("invalid integer value for %s: %w", field.Name, err) + } + } + } + + return input, nil + } else if scanErr := scanner.Err(); scanErr != nil { + return "", fmt.Errorf("scanner error: %w", scanErr) + } + + return field.DefaultValue, nil +} + +// formatPrompt creates a user prompt based on the field’s name and default value. +func (g *Generator) formatPrompt(field *format.FieldInfo) string { + if len(field.DefaultValue) > 0 { + return fmt.Sprintf("%s[%s]: ", color.HiWhiteString(field.Name), field.DefaultValue) + } + + return color.HiWhiteString(field.Name) + ": " +} + +// setFieldValue attempts to set a field’s value and handles required field validation. +func (g *Generator) setFieldValue( + config reflect.Value, + field *format.FieldInfo, + inputValue string, +) (bool, error) { + if len(inputValue) == 0 { + if field.Required { + _, _ = fmt.Fprint( + color.Output, + "Field ", + color.HiCyanString(field.Name), + " is required!\n\n", + ) + + return false, nil + } + + if len(field.DefaultValue) == 0 { + return true, nil + } + + inputValue = field.DefaultValue + } + + valid, err := format.SetConfigField(config, *field, inputValue) + if err != nil { + return false, fmt.Errorf("failed to set field %s: %w", field.Name, err) + } + + return valid, nil +} + +// printError displays an error message for an invalid field value. +func (g *Generator) printError(fieldName, errorMsg string) { + _, _ = fmt.Fprint( + color.Output, + "Invalid format for field ", + color.HiCyanString(fieldName), + ": ", + errorMsg, + "\n\n", + ) +} + +// printInvalidType displays a type mismatch error for a field. +func (g *Generator) printInvalidType(fieldName, typeName string) { + _, _ = fmt.Fprint( + color.Output, + "Invalid type ", + color.HiYellowString(typeName), + " for field ", + color.HiCyanString(fieldName), + "\n\n", + ) +} + +// validateAndReturnConfig ensures the config implements ServiceConfig and returns it. +func (g *Generator) validateAndReturnConfig(config reflect.Value) (types.ServiceConfig, error) { + configInterface := config.Interface() + if serviceConfig, ok := configInterface.(types.ServiceConfig); ok { + return serviceConfig, nil + } + + return nil, ErrInvalidConfigType +} diff --git a/pkg/generators/basic/basic_test.go b/pkg/generators/basic/basic_test.go new file mode 100644 index 0000000..bca87fa --- /dev/null +++ b/pkg/generators/basic/basic_test.go @@ -0,0 +1,543 @@ +package basic + +import ( + "bufio" + "fmt" + "net/url" + "os" + "reflect" + "strconv" + "strings" + "testing" + "text/template" + + "github.com/fatih/color" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// mockConfig implements types.ServiceConfig. +type mockConfig struct { + Host string `default:"localhost" key:"host"` + Port int `default:"8080" key:"port" required:"true"` + url *url.URL +} + +func (m *mockConfig) Enums() map[string]types.EnumFormatter { + return nil +} + +func (m *mockConfig) GetURL() *url.URL { + if m.url == nil { + u, _ := url.Parse("mock://url") + m.url = u + } + + return m.url +} + +func (m *mockConfig) SetURL(u *url.URL) error { + m.url = u + + return nil +} + +func (m *mockConfig) SetTemplateFile(_ string, _ string) error { + return nil +} + +func (m *mockConfig) SetTemplateString(_ string, _ string) error { + return nil +} + +func (m *mockConfig) SetLogger(_ types.StdLogger) { + // Minimal implementation, no-op +} + +// ConfigQueryResolver methods. +func (m *mockConfig) Get(key string) (string, error) { + switch strings.ToLower(key) { + case "host": + return m.Host, nil + case "port": + return strconv.Itoa(m.Port), nil + default: + return "", fmt.Errorf("unknown key: %s", key) + } +} + +func (m *mockConfig) Set(key string, value string) error { + switch strings.ToLower(key) { + case "host": + m.Host = value + + return nil + case "port": + port, err := strconv.Atoi(value) + if err != nil { + return err + } + + m.Port = port + + return nil + default: + return fmt.Errorf("unknown key: %s", key) + } +} + +func (m *mockConfig) QueryFields() []string { + return []string{"host", "port"} +} + +// mockServiceConfig is a test implementation of Service. +type mockServiceConfig struct { + Config *mockConfig +} + +func (m *mockServiceConfig) GetID() string { + return "mockID" +} + +func (m *mockServiceConfig) GetTemplate(_ string) (*template.Template, bool) { + return nil, false +} + +func (m *mockServiceConfig) SetTemplateFile(_ string, _ string) error { + return nil +} + +func (m *mockServiceConfig) SetTemplateString(_ string, _ string) error { + return nil +} + +func (m *mockServiceConfig) Initialize(_ *url.URL, _ types.StdLogger) error { + return nil +} + +func (m *mockServiceConfig) Send(_ string, _ *types.Params) error { + return nil +} + +func (m *mockServiceConfig) SetLogger(_ types.StdLogger) {} + +// ConfigProp methods. +func (m *mockConfig) SetFromProp(propValue string) error { + // Minimal implementation for testing; typically parses propValue + parts := strings.SplitN(propValue, ":", 2) + if len(parts) == 2 { + m.Host = parts[0] + + port, err := strconv.Atoi(parts[1]) + if err != nil { + return err + } + + m.Port = port + } + + return nil +} + +func (m *mockConfig) GetPropValue() (string, error) { + // Minimal implementation for testing + return fmt.Sprintf("%s:%d", m.Host, m.Port), nil +} + +// newMockServiceConfig creates a new mockServiceConfig with an initialized Config. +func newMockServiceConfig() *mockServiceConfig { + return &mockServiceConfig{ + Config: &mockConfig{}, + } +} + +func TestGenerator_Generate(t *testing.T) { + tests := []struct { + name string + props map[string]string + input string + want types.ServiceConfig + wantErr bool + }{ + { + name: "successful generation with defaults", + props: map[string]string{}, + input: "\n8080\n", + want: &mockConfig{ + Host: "localhost", + Port: 8080, + }, + wantErr: false, + }, + { + name: "successful generation with props", + props: map[string]string{"host": "example.com", "port": "9090"}, + input: "", + want: &mockConfig{ + Host: "example.com", + Port: 9090, + }, + wantErr: false, + }, + { + name: "error_on_invalid_port", + props: map[string]string{}, + input: "\ninvalid\n", + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + + // Set up pipe for stdin + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + + originalStdin := os.Stdin + os.Stdin = r + + defer func() { + os.Stdin = originalStdin + + w.Close() + }() + + // Write input to the pipe + _, err = w.WriteString(tt.input) + if err != nil { + t.Fatal(err) + } + + w.Close() + + service := newMockServiceConfig() + color.NoColor = true + + got, err := g.Generate(service, tt.props, nil) + if (err != nil) != tt.wantErr { + t.Errorf("Generate() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !tt.wantErr && !reflect.DeepEqual(got, tt.want) { + t.Errorf("Generate() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestGenerator_promptUserForFields(t *testing.T) { + tests := []struct { + name string + config reflect.Value + props map[string]string + input string + wantErr bool + }{ + { + name: "valid input with defaults", + config: reflect.ValueOf(newMockServiceConfig().Config), // Pass *mockConfig + props: map[string]string{}, + input: "\n8080\n", + wantErr: false, + }, + { + name: "valid props", + config: reflect.ValueOf(newMockServiceConfig().Config), // Pass *mockConfig + props: map[string]string{"host": "test.com", "port": "1234"}, + input: "", + wantErr: false, + }, + { + name: "invalid config type", + config: reflect.ValueOf("not a config"), + props: map[string]string{}, + input: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + scanner := bufio.NewScanner(strings.NewReader(tt.input)) + color.NoColor = true + + err := g.promptUserForFields(tt.config, tt.props, scanner) + if (err != nil) != tt.wantErr { + t.Errorf("promptUserForFields() error = %v, wantErr %v", err, tt.wantErr) + } + + if err == nil && tt.config.Kind() == reflect.Ptr && + tt.config.Type().Elem().Kind() == reflect.Struct { + got := tt.config.Interface().(*mockConfig) + if tt.props["host"] != "" && got.Host != tt.props["host"] { + t.Errorf("promptUserForFields() host = %v, want %v", got.Host, tt.props["host"]) + } + + if tt.props["port"] != "" { + wantPort := atoiOrZero(tt.props["port"]) + if got.Port != wantPort { + t.Errorf("promptUserForFields() port = %v, want %v", got.Port, wantPort) + } + } + } + }) + } +} + +func TestGenerator_getInputValue(t *testing.T) { + tests := []struct { + name string + field *format.FieldInfo + propKey string + props map[string]string + input string + want string + wantErr bool + }{ + { + name: "from props", + field: &format.FieldInfo{Name: "Host"}, + propKey: "host", + props: map[string]string{"host": "example.com"}, + input: "", + want: "example.com", + wantErr: false, + }, + { + name: "from user input", + field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0)}, // Add Type + propKey: "port", + props: map[string]string{}, + input: "8080\n", + want: "8080", + wantErr: false, + }, + { + name: "default value", + field: &format.FieldInfo{Name: "Host", DefaultValue: "localhost"}, + propKey: "host", + props: map[string]string{}, + input: "\n", + want: "localhost", + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + scanner := bufio.NewScanner(strings.NewReader(tt.input)) + color.NoColor = true + + got, err := g.getInputValue(tt.field, tt.propKey, tt.props, scanner) + if (err != nil) != tt.wantErr { + t.Errorf("getInputValue() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if got != tt.want { + t.Errorf("getInputValue() = %v, want %v", got, tt.want) + } + + if tt.props[tt.propKey] != "" { + t.Errorf("getInputValue() did not clear prop, got %v", tt.props[tt.propKey]) + } + }) + } +} + +func TestGenerator_formatPrompt(t *testing.T) { + tests := []struct { + name string + field *format.FieldInfo + want string + }{ + { + name: "field with default", + field: &format.FieldInfo{Name: "Host", DefaultValue: "localhost"}, + want: "\x1b[97mHost\x1b[0m[localhost]: ", + }, + { + name: "field without default", + field: &format.FieldInfo{Name: "Port"}, + want: "\x1b[97mPort\x1b[0m: ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + color.NoColor = false + + got := g.formatPrompt(tt.field) + if got != tt.want { + t.Errorf("formatPrompt() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGenerator_setFieldValue(t *testing.T) { + tests := []struct { + name string + config reflect.Value + field *format.FieldInfo + inputValue string + want bool + wantErr bool + }{ + { + name: "valid value", + config: reflect.ValueOf(newMockServiceConfig().Config).Elem(), + field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0), Required: true}, + inputValue: "8080", + want: true, + wantErr: false, + }, + { + name: "required field empty", + config: reflect.ValueOf(newMockServiceConfig().Config).Elem(), + field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0), Required: true}, + inputValue: "", + want: false, + wantErr: false, + }, + { + name: "invalid value", + config: reflect.ValueOf(newMockServiceConfig().Config).Elem(), + field: &format.FieldInfo{Name: "Port", Type: reflect.TypeOf(0)}, + inputValue: "invalid", + want: false, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + color.NoColor = true + + got, err := g.setFieldValue(tt.config, tt.field, tt.inputValue) + if (err != nil) != tt.wantErr { + t.Errorf("setFieldValue() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if got != tt.want { + t.Errorf("setFieldValue() = %v, want %v", got, tt.want) + } + + if got && !tt.wantErr { + if tt.field.Name == "Port" { + wantPort := atoiOrZero(tt.inputValue) + if gotPort := tt.config.FieldByName("Port").Int(); int(gotPort) != wantPort { + t.Errorf("setFieldValue() set Port = %v, want %v", gotPort, wantPort) + } + } + } + }) + } +} + +func TestGenerator_printError(t *testing.T) { + tests := []struct { + name string + fieldName string + errorMsg string + }{ + { + name: "basic error", + fieldName: "Port", + errorMsg: "invalid format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(*testing.T) { + g := &Generator{} + color.NoColor = true + + g.printError(tt.fieldName, tt.errorMsg) + }) + } +} + +func TestGenerator_printInvalidType(t *testing.T) { + tests := []struct { + name string + fieldName string + typeName string + }{ + { + name: "invalid type", + fieldName: "Port", + typeName: "int", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(*testing.T) { + g := &Generator{} + color.NoColor = true + + g.printInvalidType(tt.fieldName, tt.typeName) + }) + } +} + +func TestGenerator_validateAndReturnConfig(t *testing.T) { + tests := []struct { + name string + config reflect.Value + want types.ServiceConfig + wantErr bool + }{ + { + name: "valid config", + config: reflect.ValueOf(&mockConfig{Host: "test", Port: 1234}), + want: &mockConfig{Host: "test", Port: 1234}, + wantErr: false, + }, + { + name: "invalid config type", + config: reflect.ValueOf("not a config"), + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + g := &Generator{} + + got, err := g.validateAndReturnConfig(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateAndReturnConfig() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("validateAndReturnConfig() = %v, want %v", got, tt.want) + } + }) + } +} + +// atoiOrZero converts a string to an int, returning 0 on error. +func atoiOrZero(s string) int { + i, _ := strconv.Atoi(s) + + return i +} diff --git a/pkg/generators/router.go b/pkg/generators/router.go new file mode 100644 index 0000000..4a15069 --- /dev/null +++ b/pkg/generators/router.go @@ -0,0 +1,44 @@ +package generators + +import ( + "errors" + "fmt" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/generators/basic" + "github.com/nicholas-fedor/shoutrrr/pkg/generators/xouath2" + "github.com/nicholas-fedor/shoutrrr/pkg/services/telegram" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var ErrUnknownGenerator = errors.New("unknown generator") + +var generatorMap = map[string]func() types.Generator{ + "basic": func() types.Generator { return &basic.Generator{} }, + "oauth2": func() types.Generator { return &xouath2.Generator{} }, + "telegram": func() types.Generator { return &telegram.Generator{} }, +} + +// NewGenerator creates an instance of the generator that corresponds to the provided identifier. +func NewGenerator(identifier string) (types.Generator, error) { + generatorFactory, valid := generatorMap[strings.ToLower(identifier)] + if !valid { + return nil, fmt.Errorf("%w: %q", ErrUnknownGenerator, identifier) + } + + return generatorFactory(), nil +} + +// ListGenerators lists all available generators. +func ListGenerators() []string { + generators := make([]string, len(generatorMap)) + + i := 0 + + for key := range generatorMap { + generators[i] = key + i++ + } + + return generators +} diff --git a/pkg/generators/xouath2/xoauth2.go b/pkg/generators/xouath2/xoauth2.go new file mode 100644 index 0000000..6c32337 --- /dev/null +++ b/pkg/generators/xouath2/xoauth2.go @@ -0,0 +1,266 @@ +//go:generate stringer -type=URLPart -trimprefix URL + +package xouath2 + +import ( + "bufio" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + + "golang.org/x/net/context" + "golang.org/x/oauth2" + "golang.org/x/oauth2/google" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/smtp" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// SMTP port constants. +const ( + DefaultSMTPPort uint16 = 25 // Standard SMTP port without encryption + GmailSMTPPortStartTLS uint16 = 587 // Gmail SMTP port with STARTTLS +) + +const StateLength int = 16 // Length in bytes for OAuth 2.0 state randomness (128 bits) + +// Errors. +var ( + ErrReadFileFailed = errors.New("failed to read file") + ErrUnmarshalFailed = errors.New("failed to unmarshal JSON") + ErrScanFailed = errors.New("failed to scan input") + ErrTokenExchangeFailed = errors.New("failed to exchange token") +) + +// Generator is the XOAuth2 Generator implementation. +type Generator struct{} + +// Generate generates a service URL from a set of user questions/answers. +func (g *Generator) Generate( + _ types.Service, + props map[string]string, + args []string, +) (types.ServiceConfig, error) { + if provider, found := props["provider"]; found { + if provider == "gmail" { + return oauth2GeneratorGmail(args[0]) + } + } + + if len(args) > 0 { + return oauth2GeneratorFile(args[0]) + } + + return oauth2Generator() +} + +func oauth2GeneratorFile(file string) (*smtp.Config, error) { + jsonData, err := os.ReadFile(file) + if err != nil { + return nil, fmt.Errorf("%s: %w", file, ErrReadFileFailed) + } + + var providerConfig struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RedirectURL string `json:"redirect_url"` + AuthURL string `json:"auth_url"` + TokenURL string `json:"token_url"` + Hostname string `json:"smtp_hostname"` + Scopes []string `json:"scopes"` + } + + if err := json.Unmarshal(jsonData, &providerConfig); err != nil { + return nil, fmt.Errorf("%s: %w", file, ErrUnmarshalFailed) + } + + conf := oauth2.Config{ + ClientID: providerConfig.ClientID, + ClientSecret: providerConfig.ClientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: providerConfig.AuthURL, + TokenURL: providerConfig.TokenURL, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + RedirectURL: providerConfig.RedirectURL, + Scopes: providerConfig.Scopes, + } + + return generateOauth2Config(&conf, providerConfig.Hostname) +} + +func oauth2Generator() (*smtp.Config, error) { + scanner := bufio.NewScanner(os.Stdin) + + var clientID string + + fmt.Fprint(os.Stdout, "ClientID: ") + + if scanner.Scan() { + clientID = scanner.Text() + } else { + return nil, fmt.Errorf("clientID: %w", ErrScanFailed) + } + + var clientSecret string + + fmt.Fprint(os.Stdout, "ClientSecret: ") + + if scanner.Scan() { + clientSecret = scanner.Text() + } else { + return nil, fmt.Errorf("clientSecret: %w", ErrScanFailed) + } + + var authURL string + + fmt.Fprint(os.Stdout, "AuthURL: ") + + if scanner.Scan() { + authURL = scanner.Text() + } else { + return nil, fmt.Errorf("authURL: %w", ErrScanFailed) + } + + var tokenURL string + + fmt.Fprint(os.Stdout, "TokenURL: ") + + if scanner.Scan() { + tokenURL = scanner.Text() + } else { + return nil, fmt.Errorf("tokenURL: %w", ErrScanFailed) + } + + var redirectURL string + + fmt.Fprint(os.Stdout, "RedirectURL: ") + + if scanner.Scan() { + redirectURL = scanner.Text() + } else { + return nil, fmt.Errorf("redirectURL: %w", ErrScanFailed) + } + + var scopes string + + fmt.Fprint(os.Stdout, "Scopes: ") + + if scanner.Scan() { + scopes = scanner.Text() + } else { + return nil, fmt.Errorf("scopes: %w", ErrScanFailed) + } + + var hostname string + + fmt.Fprint(os.Stdout, "SMTP Hostname: ") + + if scanner.Scan() { + hostname = scanner.Text() + } else { + return nil, fmt.Errorf("hostname: %w", ErrScanFailed) + } + + conf := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: oauth2.Endpoint{ + AuthURL: authURL, + TokenURL: tokenURL, + AuthStyle: oauth2.AuthStyleAutoDetect, + }, + RedirectURL: redirectURL, + Scopes: strings.Split(scopes, ","), + } + + return generateOauth2Config(&conf, hostname) +} + +func oauth2GeneratorGmail(credFile string) (*smtp.Config, error) { + data, err := os.ReadFile(credFile) + if err != nil { + return nil, fmt.Errorf("%s: %w", credFile, ErrReadFileFailed) + } + + conf, err := google.ConfigFromJSON(data, "https://mail.google.com/") + if err != nil { + return nil, fmt.Errorf( + "%s: %w", + credFile, + err, + ) // google.ConfigFromJSON error doesn't need custom wrapping + } + + return generateOauth2Config(conf, "smtp.gmail.com") +} + +func generateOauth2Config(conf *oauth2.Config, host string) (*smtp.Config, error) { + scanner := bufio.NewScanner(os.Stdin) + + // Generate a random state value + stateBytes := make([]byte, StateLength) + if _, err := rand.Read(stateBytes); err != nil { + return nil, fmt.Errorf("generating random state: %w", err) + } + + state := base64.URLEncoding.EncodeToString(stateBytes) + + fmt.Fprintf( + os.Stdout, + "Visit the following URL to authenticate:\n%s\n\n", + conf.AuthCodeURL(state), + ) + + var verCode string + + fmt.Fprint(os.Stdout, "Enter verification code: ") + + if scanner.Scan() { + verCode = scanner.Text() + } else { + return nil, fmt.Errorf("verification code: %w", ErrScanFailed) + } + + ctx := context.Background() + + token, err := conf.Exchange(ctx, verCode) + if err != nil { + return nil, fmt.Errorf("%s: %w", verCode, ErrTokenExchangeFailed) + } + + var sender string + + fmt.Fprint(os.Stdout, "Enter sender e-mail: ") + + if scanner.Scan() { + sender = scanner.Text() + } else { + return nil, fmt.Errorf("sender email: %w", ErrScanFailed) + } + + // Determine the appropriate port based on the host + port := DefaultSMTPPort + if host == "smtp.gmail.com" { + port = GmailSMTPPortStartTLS // Use 587 for Gmail with STARTTLS + } + + svcConf := &smtp.Config{ + Host: host, + Port: port, + Username: sender, + Password: token.AccessToken, + FromAddress: sender, + FromName: "Shoutrrr", + ToAddresses: []string{sender}, + Auth: smtp.AuthTypes.OAuth2, + UseStartTLS: true, + UseHTML: true, + } + + return svcConf, nil +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..9fb4ce7 --- /dev/null +++ b/pkg/router/router.go @@ -0,0 +1,279 @@ +package router + +import ( + "errors" + "fmt" + "net/url" + "strings" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// DefaultTimeout is the default duration for service operation timeouts. +const DefaultTimeout = 10 * time.Second + +var ( + ErrNoSenders = errors.New("error sending message: no senders") + ErrServiceTimeout = errors.New("failed to send: timed out") + ErrCustomURLsNotSupported = errors.New("custom URLs are not supported by service") + ErrUnknownService = errors.New("unknown service") + ErrParseURLFailed = errors.New("failed to parse URL") + ErrSendFailed = errors.New("failed to send message") + ErrCustomURLConversion = errors.New("failed to convert custom URL") + ErrInitializeFailed = errors.New("failed to initialize service") +) + +// ServiceRouter is responsible for routing a message to a specific notification service using the notification URL. +type ServiceRouter struct { + logger types.StdLogger + services []types.Service + queue []string + Timeout time.Duration +} + +// New creates a new service router using the specified logger and service URLs. +func New(logger types.StdLogger, serviceURLs ...string) (*ServiceRouter, error) { + router := ServiceRouter{ + logger: logger, + Timeout: DefaultTimeout, + } + + for _, serviceURL := range serviceURLs { + if err := router.AddService(serviceURL); err != nil { + return nil, fmt.Errorf("error initializing router services: %w", err) + } + } + + return &router, nil +} + +// AddService initializes the specified service from its URL, and adds it if no errors occur. +func (router *ServiceRouter) AddService(serviceURL string) error { + service, err := router.initService(serviceURL) + if err == nil { + router.services = append(router.services, service) + } + + return err +} + +// Send sends the specified message using the routers underlying services. +func (router *ServiceRouter) Send(message string, params *types.Params) []error { + if router == nil { + return []error{ErrNoSenders} + } + + serviceCount := len(router.services) + errors := make([]error, serviceCount) + results := router.SendAsync(message, params) + + for i := range router.services { + errors[i] = <-results + } + + return errors +} + +// SendItems sends the specified message items using the routers underlying services. +func (router *ServiceRouter) SendItems(items []types.MessageItem, params types.Params) []error { + if router == nil { + return []error{ErrNoSenders} + } + + // Fallback using old API for now + message := strings.Builder{} + for _, item := range items { + message.WriteString(item.Text) + } + + serviceCount := len(router.services) + errors := make([]error, serviceCount) + results := router.SendAsync(message.String(), ¶ms) + + for i := range router.services { + errors[i] = <-results + } + + return errors +} + +// SendAsync sends the specified message using the routers underlying services. +func (router *ServiceRouter) SendAsync(message string, params *types.Params) chan error { + serviceCount := len(router.services) + proxy := make(chan error, serviceCount) + errors := make(chan error, serviceCount) + + if params == nil { + params = &types.Params{} + } + + for _, service := range router.services { + go sendToService(service, proxy, router.Timeout, message, *params) + } + + go func() { + for range serviceCount { + errors <- <-proxy + } + + close(errors) + }() + + return errors +} + +func sendToService( + service types.Service, + results chan error, + timeout time.Duration, + message string, + params types.Params, +) { + result := make(chan error) + + serviceID := service.GetID() + + go func() { result <- service.Send(message, ¶ms) }() + + select { + case res := <-result: + results <- res + case <-time.After(timeout): + results <- fmt.Errorf("%w: using %v", ErrServiceTimeout, serviceID) + } +} + +// Enqueue adds the message to an internal queue and sends it when Flush is invoked. +func (router *ServiceRouter) Enqueue(message string, v ...any) { + if len(v) > 0 { + message = fmt.Sprintf(message, v...) + } + + router.queue = append(router.queue, message) +} + +// Flush sends all messages that have been queued up as a combined message. This method should be deferred! +func (router *ServiceRouter) Flush(params *types.Params) { + // Since this method is supposed to be deferred we just have to ignore errors + _ = router.Send(strings.Join(router.queue, "\n"), params) + router.queue = []string{} +} + +// SetLogger sets the logger that the services will use to write progress logs. +func (router *ServiceRouter) SetLogger(logger types.StdLogger) { + router.logger = logger + for _, service := range router.services { + service.SetLogger(logger) + } +} + +// ExtractServiceName from a notification URL. +func (router *ServiceRouter) ExtractServiceName(rawURL string) (string, *url.URL, error) { + serviceURL, err := url.Parse(rawURL) + if err != nil { + return "", &url.URL{}, fmt.Errorf("%s: %w", rawURL, ErrParseURLFailed) + } + + scheme := serviceURL.Scheme + schemeParts := strings.Split(scheme, "+") + + if len(schemeParts) > 1 { + scheme = schemeParts[0] + } + + return scheme, serviceURL, nil +} + +// Route a message to a specific notification service using the notification URL. +func (router *ServiceRouter) Route(rawURL string, message string) error { + service, err := router.Locate(rawURL) + if err != nil { + return err + } + + if err := service.Send(message, nil); err != nil { + return fmt.Errorf("%s: %w", service.GetID(), ErrSendFailed) + } + + return nil +} + +func (router *ServiceRouter) initService(rawURL string) (types.Service, error) { + scheme, configURL, err := router.ExtractServiceName(rawURL) + if err != nil { + return nil, err + } + + service, err := newService(scheme) + if err != nil { + return nil, err + } + + if configURL.Scheme != scheme { + router.log("Got custom URL:", configURL.String()) + + customURLService, ok := service.(types.CustomURLService) + if !ok { + return nil, fmt.Errorf("%w: '%s' service", ErrCustomURLsNotSupported, scheme) + } + + configURL, err = customURLService.GetConfigURLFromCustom(configURL) + if err != nil { + return nil, fmt.Errorf("%s: %w", configURL.String(), ErrCustomURLConversion) + } + + router.log("Converted service URL:", configURL.String()) + } + + err = service.Initialize(configURL, router.logger) + if err != nil { + return service, fmt.Errorf("%s: %w", scheme, ErrInitializeFailed) + } + + return service, nil +} + +// NewService returns a new uninitialized service instance. +func (*ServiceRouter) NewService(serviceScheme string) (types.Service, error) { + return newService(serviceScheme) +} + +// newService returns a new uninitialized service instance. +func newService(serviceScheme string) (types.Service, error) { + serviceFactory, valid := serviceMap[strings.ToLower(serviceScheme)] + if !valid { + return nil, fmt.Errorf("%w: %q", ErrUnknownService, serviceScheme) + } + + return serviceFactory(), nil +} + +// ListServices returns the available services. +func (router *ServiceRouter) ListServices() []string { + services := make([]string, len(serviceMap)) + + i := 0 + + for key := range serviceMap { + services[i] = key + i++ + } + + return services +} + +// Locate returns the service implementation that corresponds to the given service URL. +func (router *ServiceRouter) Locate(rawURL string) (types.Service, error) { + service, err := router.initService(rawURL) + + return service, err +} + +func (router *ServiceRouter) log(v ...any) { + if router.logger == nil { + return + } + + router.logger.Println(v...) +} diff --git a/pkg/router/router_suite_test.go b/pkg/router/router_suite_test.go new file mode 100644 index 0000000..9f483bf --- /dev/null +++ b/pkg/router/router_suite_test.go @@ -0,0 +1,157 @@ +package router + +import ( + "fmt" + "log" + "os" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +func TestRouter(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Router Suite") +} + +var sr ServiceRouter + +const ( + mockCustomURL = "teams+https://publicservice.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=publicservice.webhook.office.com" +) + +var _ = ginkgo.Describe("the router suite", func() { + ginkgo.BeforeEach(func() { + sr = ServiceRouter{ + logger: log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags), + } + }) + + ginkgo.When("extract service name is given a url", func() { + ginkgo.It("should extract the protocol/service part", func() { + url := "slack://rest/of/url" + serviceName, _, err := sr.ExtractServiceName(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(serviceName).To(gomega.Equal("slack")) + }) + ginkgo.It("should extract the service part when provided in custom form", func() { + url := "teams+https://rest/of/url" + serviceName, _, err := sr.ExtractServiceName(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(serviceName).To(gomega.Equal("teams")) + }) + ginkgo.It("should return an error if the protocol/service part is missing", func() { + url := "://rest/of/url" + serviceName, _, err := sr.ExtractServiceName(url) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(serviceName).To(gomega.Equal("")) + }) + ginkgo.It( + "should return an error if the protocol/service part is containing invalid letters", + func() { + url := "a d://rest/of/url" + serviceName, _, err := sr.ExtractServiceName(url) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(serviceName).To(gomega.Equal("")) + }, + ) + }) + + ginkgo.When("initializing a service with a custom URL", func() { + ginkgo.It("should return an error if the service does not support it", func() { + service, err := sr.initService("log+https://hybr.is") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(service).To(gomega.BeNil()) + }) + }) + + ginkgo.Describe("the service map", func() { + ginkgo.When("resolving implemented services", func() { + services := (&ServiceRouter{}).ListServices() + + for _, scheme := range services { + // copy ref to local closure + serviceScheme := scheme + + ginkgo.It(fmt.Sprintf("should return a Service for '%s'", serviceScheme), func() { + service, err := newService(serviceScheme) + + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service).ToNot(gomega.BeNil()) + }) + } + }) + }) + + ginkgo.When("initializing a service with a custom URL", func() { + ginkgo.It("should return an error if the service does not support it", func() { + service, err := sr.initService("log+https://hybr.is") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(service).To(gomega.BeNil()) + }) + ginkgo.It("should successfully init a service that does support it", func() { + service, err := sr.initService(mockCustomURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service).NotTo(gomega.BeNil()) + }) + }) + + ginkgo.When("a message is enqueued", func() { + ginkgo.It("should be added to the internal queue", func() { + sr.Enqueue("message body") + gomega.Expect(sr.queue).ToNot(gomega.BeNil()) + gomega.Expect(sr.queue).To(gomega.HaveLen(1)) + }) + }) + ginkgo.When("a formatted message is enqueued", func() { + ginkgo.It("should be added with the specified format", func() { + sr.Enqueue("message with number %d", 5) + gomega.Expect(sr.queue).ToNot(gomega.BeNil()) + gomega.Expect(sr.queue[0]).To(gomega.Equal("message with number 5")) + }) + }) + ginkgo.When("it leaves the scope after flush has been deferred", func() { + ginkgo.When("it hasn't been assigned a sender", func() { + ginkgo.It("should not cause a panic", func() { + defer sr.Flush(nil) + sr.Enqueue("message") + }) + }) + }) + ginkgo.When("router has not been provided a logger", func() { + ginkgo.It("should not crash when trying to log", func() { + router := ServiceRouter{} + _, err := router.initService(mockCustomURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) +}) + +func ExampleNew() { + logger := log.New(os.Stdout, "", 0) + + sr, err := New(logger, "logger://") + if err != nil { + log.Fatalf("could not create router: %s", err) + } + + sr.Send("hello", nil) + // Output: hello +} + +func ExampleServiceRouter_Enqueue() { + logger := log.New(os.Stdout, "", 0) + + sr, err := New(logger, "logger://") + if err != nil { + log.Fatalf("could not create router: %s", err) + } + + defer sr.Flush(nil) + sr.Enqueue("hello") + sr.Enqueue("world") + // Output: + // hello + // world +} diff --git a/pkg/router/servicemap.go b/pkg/router/servicemap.go new file mode 100644 index 0000000..c12743c --- /dev/null +++ b/pkg/router/servicemap.go @@ -0,0 +1,51 @@ +package router + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/services/bark" + "github.com/nicholas-fedor/shoutrrr/pkg/services/discord" + "github.com/nicholas-fedor/shoutrrr/pkg/services/generic" + "github.com/nicholas-fedor/shoutrrr/pkg/services/googlechat" + "github.com/nicholas-fedor/shoutrrr/pkg/services/gotify" + "github.com/nicholas-fedor/shoutrrr/pkg/services/ifttt" + "github.com/nicholas-fedor/shoutrrr/pkg/services/join" + "github.com/nicholas-fedor/shoutrrr/pkg/services/lark" + "github.com/nicholas-fedor/shoutrrr/pkg/services/logger" + "github.com/nicholas-fedor/shoutrrr/pkg/services/matrix" + "github.com/nicholas-fedor/shoutrrr/pkg/services/mattermost" + "github.com/nicholas-fedor/shoutrrr/pkg/services/ntfy" + "github.com/nicholas-fedor/shoutrrr/pkg/services/opsgenie" + "github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet" + "github.com/nicholas-fedor/shoutrrr/pkg/services/pushover" + "github.com/nicholas-fedor/shoutrrr/pkg/services/rocketchat" + "github.com/nicholas-fedor/shoutrrr/pkg/services/slack" + "github.com/nicholas-fedor/shoutrrr/pkg/services/smtp" + "github.com/nicholas-fedor/shoutrrr/pkg/services/teams" + "github.com/nicholas-fedor/shoutrrr/pkg/services/telegram" + "github.com/nicholas-fedor/shoutrrr/pkg/services/zulip" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var serviceMap = map[string]func() types.Service{ + "bark": func() types.Service { return &bark.Service{} }, + "discord": func() types.Service { return &discord.Service{} }, + "generic": func() types.Service { return &generic.Service{} }, + "gotify": func() types.Service { return &gotify.Service{} }, + "googlechat": func() types.Service { return &googlechat.Service{} }, + "hangouts": func() types.Service { return &googlechat.Service{} }, + "ifttt": func() types.Service { return &ifttt.Service{} }, + "lark": func() types.Service { return &lark.Service{} }, + "join": func() types.Service { return &join.Service{} }, + "logger": func() types.Service { return &logger.Service{} }, + "matrix": func() types.Service { return &matrix.Service{} }, + "mattermost": func() types.Service { return &mattermost.Service{} }, + "ntfy": func() types.Service { return &ntfy.Service{} }, + "opsgenie": func() types.Service { return &opsgenie.Service{} }, + "pushbullet": func() types.Service { return &pushbullet.Service{} }, + "pushover": func() types.Service { return &pushover.Service{} }, + "rocketchat": func() types.Service { return &rocketchat.Service{} }, + "slack": func() types.Service { return &slack.Service{} }, + "smtp": func() types.Service { return &smtp.Service{} }, + "teams": func() types.Service { return &teams.Service{} }, + "telegram": func() types.Service { return &telegram.Service{} }, + "zulip": func() types.Service { return &zulip.Service{} }, +} diff --git a/pkg/router/servicemap_xmpp.go b/pkg/router/servicemap_xmpp.go new file mode 100644 index 0000000..c35ab55 --- /dev/null +++ b/pkg/router/servicemap_xmpp.go @@ -0,0 +1,10 @@ +//go:build xmpp +// +build xmpp + +package router + +import t "github.com/nicholas-fedor/shoutrrr/pkg/types" + +func init() { + serviceMap["xmpp"] = func() t.Service { return &xmpp.Service{} } +} diff --git a/pkg/services/bark/bark.go b/pkg/services/bark/bark.go new file mode 100644 index 0000000..d1ca663 --- /dev/null +++ b/pkg/services/bark/bark.go @@ -0,0 +1,92 @@ +package bark + +import ( + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +var ( + ErrFailedAPIRequest = errors.New("failed to make API request") + ErrUnexpectedStatus = errors.New("unexpected status code") + ErrUpdateParamsFailed = errors.New("failed to update config from params") +) + +// Service sends notifications to Bark. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send transmits a notification message to Bark. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("%w: %w", ErrUpdateParamsFailed, err) + } + + if err := service.sendAPI(config, message); err != nil { + return fmt.Errorf("failed to send bark notification: %w", err) + } + + return nil +} + +// Initialize sets up the Service with configuration from configURL and assigns a logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + _ = service.pkr.SetDefaultProps(service.Config) + + return service.Config.setURL(&service.pkr, configURL) +} + +// GetID returns the identifier for the Bark service. +func (service *Service) GetID() string { + return Scheme +} + +func (service *Service) sendAPI(config *Config, message string) error { + response := APIResponse{} + request := PushPayload{ + Body: message, + DeviceKey: config.DeviceKey, + Title: config.Title, + Category: config.Category, + Copy: config.Copy, + Sound: config.Sound, + Group: config.Group, + Badge: &config.Badge, + Icon: config.Icon, + URL: config.URL, + } + jsonClient := jsonclient.NewClient() + + if err := jsonClient.Post(config.GetAPIURL("push"), &request, &response); err != nil { + if jsonClient.ErrorResponse(err, &response) { + return &response + } + + return fmt.Errorf("%w: %w", ErrFailedAPIRequest, err) + } + + if response.Code != http.StatusOK { + if response.Message != "" { + return &response + } + + return fmt.Errorf("%w: %d", ErrUnexpectedStatus, response.Code) + } + + return nil +} diff --git a/pkg/services/bark/bark_config.go b/pkg/services/bark/bark_config.go new file mode 100644 index 0000000..dd55c0a --- /dev/null +++ b/pkg/services/bark/bark_config.go @@ -0,0 +1,101 @@ +package bark + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifying part of this service's configuration URL. +const ( + Scheme = "bark" +) + +// ErrSetQueryFailed indicates a failure to set a configuration value from a query parameter. +var ErrSetQueryFailed = errors.New("failed to set query parameter") + +// Config holds configuration settings for the Bark service. +type Config struct { + standard.EnumlessConfig + Title string `default:"" desc:"Notification title, optionally set by the sender" key:"title"` + Host string ` desc:"Server hostname and port" url:"host"` + Path string `default:"/" desc:"Server path" url:"path"` + DeviceKey string ` desc:"The key for each device" url:"password"` + Scheme string `default:"https" desc:"Server protocol, http or https" key:"scheme"` + Sound string `default:"" desc:"Value from https://github.com/Finb/Bark/tree/master/Sounds" key:"sound"` + Badge int64 `default:"0" desc:"The number displayed next to App icon" key:"badge"` + Icon string `default:"" desc:"An url to the icon, available only on iOS 15 or later" key:"icon"` + Group string `default:"" desc:"The group of the notification" key:"group"` + URL string `default:"" desc:"Url that will jump when click notification" key:"url"` + Category string `default:"" desc:"Reserved field, no use yet" key:"category"` + Copy string `default:"" desc:"The value to be copied" key:"copy"` +} + +// GetURL returns a URL representation of the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// GetAPIURL constructs the API URL for the specified endpoint using the current configuration. +func (config *Config) GetAPIURL(endpoint string) string { + path := strings.Builder{} + if !strings.HasPrefix(config.Path, "/") { + path.WriteByte('/') + } + + path.WriteString(config.Path) + + if !strings.HasSuffix(path.String(), "/") { + path.WriteByte('/') + } + + path.WriteString(endpoint) + + apiURL := url.URL{ + Scheme: config.Scheme, + Host: config.Host, + Path: path.String(), + } + + return apiURL.String() +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword("", config.DeviceKey), + Host: config.Host, + Scheme: Scheme, + ForceQuery: true, + Path: config.Path, + RawQuery: format.BuildQuery(resolver), + } +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + config.DeviceKey = password + config.Host = url.Host + config.Path = url.Path + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("%w '%s': %w", ErrSetQueryFailed, key, err) + } + } + + return nil +} diff --git a/pkg/services/bark/bark_json.go b/pkg/services/bark/bark_json.go new file mode 100644 index 0000000..ff3ea4a --- /dev/null +++ b/pkg/services/bark/bark_json.go @@ -0,0 +1,29 @@ +package bark + +// PushPayload represents the notification payload for the Bark notification service. +type PushPayload struct { + Body string `json:"body"` + DeviceKey string `json:"device_key"` + Title string `json:"title"` + Sound string `json:"sound,omitempty"` + Badge *int64 `json:"badge,omitempty"` + Icon string `json:"icon,omitempty"` + Group string `json:"group,omitempty"` + URL string `json:"url,omitempty"` + Category string `json:"category,omitempty"` + Copy string `json:"copy,omitempty"` +} + +// APIResponse represents a response from the Bark API. +// +//nolint:errname +type APIResponse struct { + Code int64 `json:"code"` + Message string `json:"message"` + Timestamp int64 `json:"timestamp"` +} + +// Error returns the error message from the API response when applicable. +func (e *APIResponse) Error() string { + return "server response: " + e.Message +} diff --git a/pkg/services/bark/bark_test.go b/pkg/services/bark/bark_test.go new file mode 100644 index 0000000..77c3682 --- /dev/null +++ b/pkg/services/bark/bark_test.go @@ -0,0 +1,181 @@ +package bark_test + +import ( + "log" + "net/http" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/bark" +) + +// TestBark runs the Ginkgo test suite for the bark package. +func TestBark(t *testing.T) { + format.CharactersAroundMismatchToInclude = 20 // Show more context in failure output + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Bark Suite") +} + +var ( + service *bark.Service = &bark.Service{} // Bark service instance for testing + envBarkURL *url.URL // Environment-provided URL for integration tests + logger *log.Logger = testutils.TestLogger() // Shared logger for tests + _ = ginkgo.BeforeSuite(func() { + // Load the integration test URL from environment, if available + var err error + envBarkURL, err = url.Parse(os.Getenv("SHOUTRRR_BARK_URL")) + if err != nil { + envBarkURL = &url.URL{} // Default to empty URL if parsing fails + } + }) +) + +var _ = ginkgo.Describe("the bark service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("sends a message successfully with a valid ENV URL", func() { + if envBarkURL.String() == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + + configURL := testutils.URLMust(envBarkURL.String()) + gomega.Expect(service.Initialize(configURL, logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("This is an integration test message", nil)). + To(gomega.Succeed()) + }) + }) + + ginkgo.Describe("the config", func() { + ginkgo.When("getting an API URL", func() { + ginkgo.It("constructs the expected URL for various path formats", func() { + gomega.Expect(getAPIForPath("path")).To(gomega.Equal("https://host/path/endpoint")) + gomega.Expect(getAPIForPath("/path")).To(gomega.Equal("https://host/path/endpoint")) + gomega.Expect(getAPIForPath("/path/")). + To(gomega.Equal("https://host/path/endpoint")) + gomega.Expect(getAPIForPath("path/")).To(gomega.Equal("https://host/path/endpoint")) + gomega.Expect(getAPIForPath("/")).To(gomega.Equal("https://host/endpoint")) + gomega.Expect(getAPIForPath("")).To(gomega.Equal("https://host/endpoint")) + }) + }) + + ginkgo.When("only required fields are set", func() { + ginkgo.It("applies default values to optional fields", func() { + serviceURL := testutils.URLMust("bark://:devicekey@hostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + gomega.Expect(*service.Config).To(gomega.Equal(bark.Config{ + Host: "hostname", + DeviceKey: "devicekey", + Scheme: "https", + })) + }) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("preserves all fields after de-/serialization", func() { + testURL := "bark://:device-key@example.com:2225/?badge=5&category=CAT&group=GROUP&scheme=http&title=TITLE&url=URL" + config := &bark.Config{} + gomega.Expect(config.SetURL(testutils.URLMust(testURL))). + To(gomega.Succeed(), "verifying") + gomega.Expect(config.GetURL().String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.When("sending the push payload", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("sends successfully when the server accepts the payload", func() { + serviceURL := testutils.URLMust("bark://:devicekey@hostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"), + testutils.JSONRespondMust(200, bark.APIResponse{ + Code: http.StatusOK, + Message: "OK", + })) + gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed()) + }) + + ginkgo.It("reports an error for a server error response", func() { + serviceURL := testutils.URLMust("bark://:devicekey@hostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"), + testutils.JSONRespondMust(500, bark.APIResponse{ + Code: 500, + Message: "someone turned off the internet", + })) + gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred()) + }) + + ginkgo.It("handles an unexpected server response gracefully", func() { + serviceURL := testutils.URLMust("bark://:devicekey@hostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + httpmock.RegisterResponder("POST", service.Config.GetAPIURL("push"), + testutils.JSONRespondMust(200, bark.APIResponse{ + Code: 500, + Message: "For some reason, the response code and HTTP code is different?", + })) + gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred()) + }) + + ginkgo.It("handles communication errors without panicking", func() { + httpmock.DeactivateAndReset() // Ensure no mocks interfere + serviceURL := testutils.URLMust("bark://:devicekey@nonresolvablehostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the basic service API", func() { + ginkgo.Describe("the service config", func() { + ginkgo.It("implements basic service config API methods correctly", func() { + testutils.TestConfigGetInvalidQueryValue(&bark.Config{}) + testutils.TestConfigSetInvalidQueryValue( + &bark.Config{}, + "bark://:mock-device@host/?foo=bar", + ) + testutils.TestConfigSetDefaultValues(&bark.Config{}) + testutils.TestConfigGetEnumsCount(&bark.Config{}, 0) + testutils.TestConfigGetFieldsCount(&bark.Config{}, 9) + }) + }) + + ginkgo.Describe("the service instance", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("implements basic service API methods correctly", func() { + serviceURL := testutils.URLMust("bark://:devicekey@hostname") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + testutils.TestServiceSetInvalidParamValue(service, "foo", "bar") + }) + ginkgo.It("returns the correct service identifier", func() { + // No initialization needed since GetID is static + gomega.Expect(service.GetID()).To(gomega.Equal("bark")) + }) + }) + }) +}) + +// getAPIForPath is a helper to construct an API URL for testing. +func getAPIForPath(path string) string { + c := bark.Config{Host: "host", Path: path, Scheme: "https"} + + return c.GetAPIURL("endpoint") +} diff --git a/pkg/services/discord/discord.go b/pkg/services/discord/discord.go new file mode 100644 index 0000000..25d9ab7 --- /dev/null +++ b/pkg/services/discord/discord.go @@ -0,0 +1,214 @@ +package discord + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +const ( + ChunkSize = 2000 // Maximum size of a single message chunk + TotalChunkSize = 6000 // Maximum total size of all chunks + ChunkCount = 10 // Maximum number of chunks allowed + MaxSearchRunes = 100 // Maximum number of runes to search for split position + HooksBaseURL = "https://discord.com/api/webhooks" +) + +var ( + ErrUnknownAPIError = errors.New("unknown error from Discord API") + ErrUnexpectedStatus = errors.New("unexpected response status code") + ErrInvalidURLPrefix = errors.New("URL must start with Discord webhook base URL") + ErrInvalidWebhookID = errors.New("invalid webhook ID") + ErrInvalidToken = errors.New("invalid token") + ErrEmptyURL = errors.New("empty URL provided") + ErrMalformedURL = errors.New("malformed URL: missing webhook ID or token") +) + +var limits = types.MessageLimit{ + ChunkSize: ChunkSize, + TotalChunkSize: TotalChunkSize, + ChunkCount: ChunkCount, +} + +// Service implements a Discord notification service. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Discord. +func (service *Service) Send(message string, params *types.Params) error { + var firstErr error + + if service.Config.JSON { + postURL := CreateAPIURLFromConfig(service.Config) + if err := doSend([]byte(message), postURL); err != nil { + return fmt.Errorf("sending JSON message: %w", err) + } + } else { + batches := CreateItemsFromPlain(message, service.Config.SplitLines) + for _, items := range batches { + if err := service.sendItems(items, params); err != nil { + service.Log(err) + + if firstErr == nil { + firstErr = err + } + } + } + } + + if firstErr != nil { + return fmt.Errorf("failed to send discord notification: %w", firstErr) + } + + return nil +} + +// SendItems delivers message items with enhanced metadata and formatting to Discord. +func (service *Service) SendItems(items []types.MessageItem, params *types.Params) error { + return service.sendItems(items, params) +} + +func (service *Service) sendItems(items []types.MessageItem, params *types.Params) error { + config := *service.Config + if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + payload, err := CreatePayloadFromItems(items, config.Title, config.LevelColors()) + if err != nil { + return fmt.Errorf("creating payload: %w", err) + } + + payload.Username = config.Username + payload.AvatarURL = config.Avatar + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling payload to JSON: %w", err) + } + + postURL := CreateAPIURLFromConfig(&config) + + return doSend(payloadBytes, postURL) +} + +// CreateItemsFromPlain converts plain text into MessageItems suitable for Discord's webhook payload. +func CreateItemsFromPlain(plain string, splitLines bool) [][]types.MessageItem { + var batches [][]types.MessageItem + + if splitLines { + return util.MessageItemsFromLines(plain, limits) + } + + for { + items, omitted := util.PartitionMessage(plain, limits, MaxSearchRunes) + batches = append(batches, items) + + if omitted == 0 { + break + } + + plain = plain[len(plain)-omitted:] + } + + return batches +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + if err := service.pkr.SetDefaultProps(service.Config); err != nil { + return fmt.Errorf("setting default properties: %w", err) + } + + if err := service.Config.SetURL(configURL); err != nil { + return fmt.Errorf("setting config URL: %w", err) + } + + return nil +} + +// GetID provides the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// CreateAPIURLFromConfig builds a POST URL from the Discord configuration. +func CreateAPIURLFromConfig(config *Config) string { + if config.WebhookID == "" || config.Token == "" { + return "" // Invalid cases are caught in doSend + } + // Trim whitespace to prevent malformed URLs + webhookID := strings.TrimSpace(config.WebhookID) + token := strings.TrimSpace(config.Token) + + baseURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token) + + if config.ThreadID != "" { + // Append thread_id as a query parameter + query := url.Values{} + query.Set("thread_id", strings.TrimSpace(config.ThreadID)) + + return baseURL + "?" + query.Encode() + } + + return baseURL +} + +// doSend executes an HTTP POST request to deliver the payload to Discord. +// +//nolint:gosec,noctx +func doSend(payload []byte, postURL string) error { + if postURL == "" { + return ErrEmptyURL + } + + parsedURL, err := url.ParseRequestURI(postURL) + if err != nil { + return fmt.Errorf("invalid URL: %w", err) + } + + if !strings.HasPrefix(parsedURL.String(), HooksBaseURL) { + return ErrInvalidURLPrefix + } + + parts := strings.Split(strings.TrimPrefix(postURL, HooksBaseURL+"/"), "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return ErrMalformedURL + } + + webhookID := strings.TrimSpace(parts[0]) + token := strings.TrimSpace(parts[1]) + safeURL := fmt.Sprintf("%s/%s/%s", HooksBaseURL, webhookID, token) + + res, err := http.Post(safeURL, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return fmt.Errorf("making HTTP POST request: %w", err) + } + + if res == nil { + return ErrUnknownAPIError + } + defer res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status) + } + + return nil +} diff --git a/pkg/services/discord/discord_config.go b/pkg/services/discord/discord_config.go new file mode 100644 index 0000000..e633dbe --- /dev/null +++ b/pkg/services/discord/discord_config.go @@ -0,0 +1,121 @@ +package discord + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme defines the protocol identifier for this service's configuration URL. +const Scheme = "discord" + +// Static error definitions. +var ( + ErrIllegalURLArgument = errors.New("illegal argument in config URL") + ErrMissingWebhookID = errors.New("webhook ID missing from config URL") + ErrMissingToken = errors.New("token missing from config URL") +) + +// Config holds the settings required for sending Discord notifications. +type Config struct { + standard.EnumlessConfig + WebhookID string `url:"host"` + Token string `url:"user"` + Title string ` default:"" key:"title"` + Username string ` default:"" key:"username" desc:"Override the webhook default username"` + Avatar string ` default:"" key:"avatar,avatarurl" desc:"Override the webhook default avatar with specified URL"` + Color uint ` default:"0x50D9ff" key:"color" desc:"The color of the left border for plain messages" base:"16"` + ColorError uint ` default:"0xd60510" key:"colorError" desc:"The color of the left border for error messages" base:"16"` + ColorWarn uint ` default:"0xffc441" key:"colorWarn" desc:"The color of the left border for warning messages" base:"16"` + ColorInfo uint ` default:"0x2488ff" key:"colorInfo" desc:"The color of the left border for info messages" base:"16"` + ColorDebug uint ` default:"0x7b00ab" key:"colorDebug" desc:"The color of the left border for debug messages" base:"16"` + SplitLines bool ` default:"Yes" key:"splitLines" desc:"Whether to send each line as a separate embedded item"` + JSON bool ` default:"No" key:"json" desc:"Whether to send the whole message as the JSON payload instead of using it as the 'content' field"` + ThreadID string ` default:"" key:"thread_id" desc:"The thread ID to send the message to"` +} + +// LevelColors returns an array of colors indexed by MessageLevel. +func (config *Config) LevelColors() [types.MessageLevelCount]uint { + var colors [types.MessageLevelCount]uint + colors[types.Unknown] = config.Color + colors[types.Error] = config.ColorError + colors[types.Warning] = config.ColorWarn + colors[types.Info] = config.ColorInfo + colors[types.Debug] = config.ColorDebug + + return colors +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// getURL constructs a URL from configuration using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + url := &url.URL{ + User: url.User(config.Token), + Host: config.WebhookID, + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + ForceQuery: false, + } + + if config.JSON { + url.Path = "/raw" + } + + return url +} + +// setURL updates the configuration from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + config.WebhookID = url.Host + config.Token = url.User.Username() + + if len(url.Path) > 0 { + switch url.Path { + case "/raw": + config.JSON = true + default: + return ErrIllegalURLArgument + } + } + + if config.WebhookID == "" { + return ErrMissingWebhookID + } + + if len(config.Token) < 1 { + return ErrMissingToken + } + + for key, vals := range url.Query() { + if key == "thread_id" { + // Trim whitespace from thread_id + config.ThreadID = strings.TrimSpace(vals[0]) + + continue + } + + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting config value for key %s: %w", key, err) + } + } + + return nil +} diff --git a/pkg/services/discord/discord_json.go b/pkg/services/discord/discord_json.go new file mode 100644 index 0000000..dda6274 --- /dev/null +++ b/pkg/services/discord/discord_json.go @@ -0,0 +1,86 @@ +package discord + +import ( + "errors" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +const ( + MaxEmbeds = 9 +) + +// Static error definition. +var ErrEmptyMessage = errors.New("message is empty") + +// WebhookPayload is the webhook endpoint payload. +type WebhookPayload struct { + Embeds []embedItem `json:"embeds"` + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` +} + +// JSON is the actual notification payload. +type embedItem struct { + Title string `json:"title,omitempty"` + Content string `json:"description,omitempty"` + URL string `json:"url,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Color uint `json:"color,omitempty"` + Footer *embedFooter `json:"footer,omitempty"` +} + +type embedFooter struct { + Text string `json:"text"` + IconURL string `json:"icon_url,omitempty"` +} + +// CreatePayloadFromItems creates a JSON payload to be sent to the discord webhook API. +func CreatePayloadFromItems( + items []types.MessageItem, + title string, + colors [types.MessageLevelCount]uint, +) (WebhookPayload, error) { + if len(items) < 1 { + return WebhookPayload{}, ErrEmptyMessage + } + + itemCount := util.Min(MaxEmbeds, len(items)) + + embeds := make([]embedItem, 0, itemCount) + + for _, item := range items { + color := uint(0) + if item.Level >= types.Unknown && int(item.Level) < len(colors) { + color = colors[item.Level] + } + + embeddedItem := embedItem{ + Content: item.Text, + Color: color, + } + + if item.Level != types.Unknown { + embeddedItem.Footer = &embedFooter{ + Text: item.Level.String(), + } + } + + if !item.Timestamp.IsZero() { + embeddedItem.Timestamp = item.Timestamp.UTC().Format(time.RFC3339) + } + + embeds = append(embeds, embeddedItem) + } + + // This should not happen, but it's better to leave the index check before dereferencing the array + if len(embeds) > 0 { + embeds[0].Title = title + } + + return WebhookPayload{ + Embeds: embeds, + }, nil +} diff --git a/pkg/services/discord/discord_playground.http b/pkg/services/discord/discord_playground.http new file mode 100644 index 0000000..80e4ae2 --- /dev/null +++ b/pkg/services/discord/discord_playground.http @@ -0,0 +1,247 @@ +# Status as titles (no "title" field support) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "type": "rich", + "title": "Warning", + "description": "There was an attempt to `do stuff`, but it **failed**!", + "color": 10581034, + "timestamp": "2021-01-09T01:37:51.329000+00:00", + "fields": [ + { + "name": "context", + "value": "my_cool_context", + "inline": true + } + ] + }, + { + "type": "rich", + "title": "Error", + "description": "Unable to `do stuff`, since it wasn't possible.", + "color": 10683148, + "timestamp": "2021-01-09T01:38:51.329000+00:00", + "footer": { + "text": "xxo Shoutrrr" + } + }, + { + "type": "rich", + "title": "Information", + "description": "Example handling of more than 10 lines being sent", + "color": 1904709, + "timestamp": "2021-01-09T01:39:51.329000+00:00", + "footer": { + "text": "... 376 additional lines was omitted" + } + } + ] +} + +<> 2021-01-09T103629.200.json +<> 2021-01-09T103616.400.json +<> 2021-01-09T103436.400.json +<> 2021-01-09T103346.400.json +<> 2021-01-09T103205.400.json + +### + +# First embed used as meta with "title" field as title and omitted line warning as footer +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "type": "rich", + "title": "Title field for notification", + "description": "", + "footer": { + "text": "⚠ Only 9 out of 386 additional lines was included" + }, + "color": 50944 + }, + { + "type": "rich", + "description": "There was an attempt to `do stuff`, but it **failed**!", + "color": 10581034, + "timestamp": "2021-01-09T01:37:51.329000+00:00", + "footer": { + "text": "Warning" + } + }, + { + "type": "rich", + "description": "Unable to `do stuff`, since it wasn't possible.", + "color": 10683148, + "timestamp": "2021-01-09T01:38:51.329000+00:00", + "footer": { + "text": "Error" + } + }, + { + "type": "rich", + "description": "Everyting is terrible! 😩", + "color": 1904709, + "timestamp": "2021-01-09T01:39:51.329000+00:00", + "footer": { + "text": "Information" + } + } + ] +} + +<> 2021-01-09T104845.200.json +<> 2021-01-09T104603.200.json + +### + +# Debug additional fields as inline yaml +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "So... everything didn't go exactly as planned" + }, + { + "description": "Attempting to *do* the *thing*\n\n```yaml\nfoo: bar\nsharpest_knife_in_drawer: false\nworld_records: 22\n```", + "color": 11066108, + "timestamp": "2021-01-09T01:37:51.329000+00:00", + "footer": { + "text": "Debug" + } + }, + { + "description": "Unable to *do* the *thing*: it turns out that it's beyond the mortal realm.", + "color": 10683148, + "timestamp": "2021-01-09T01:38:51.329000+00:00", + "footer": { + "text": "Error" + } + } + ] +} + +<> 2021-01-09T105846.200.json +<> 2021-01-09T105756.400.json +<> 2021-01-09T105641.200.json + + +### + +// Long content test (almost 3k chars) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "A very important message" + }, + { + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer fermentum nibh ac dapibus volutpat. Ut felis leo, pretium eu nisi in, ultricies aliquam velit. Suspendisse sagittis egestas commodo. Aliquam ac eleifend odio. Quisque vel mauris id justo molestie fringilla. Nullam aliquet diam in ante lacinia feugiat. In volutpat turpis nisi, quis ornare mauris tristique sed. Mauris quis dapibus metus. Nullam odio neque, gravida in ligula tempus, fermentum iaculis enim.\n\nCras elementum sollicitudin pulvinar. Donec ac aliquet est. Quisque mattis nulla ac cursus efficitur. Integer placerat aliquet aliquet. Aliquam at aliquet arcu. Phasellus lacinia euismod leo, at tempor nisl pharetra at. In laoreet lectus nisl, vel vulputate tellus blandit sed. In pharetra porta quam, eu iaculis arcu pretium ac. Donec augue arcu, sodales vitae tristique quis, consectetur non nulla. Fusce scelerisque dignissim ante ut molestie. Quisque congue maximus vulputate.\n\nEtiam eu mauris sagittis, tincidunt ex in, tincidunt lorem. Donec turpis odio, auctor nec ultricies in, suscipit eu diam. Suspendisse id erat velit. Aliquam maximus libero vel libero dignissim varius. Mauris diam massa, elementum ac tellus vel, molestie consectetur mauris. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi facilisis tristique molestie. Sed egestas, nisl pellentesque posuere tincidunt, lectus tortor gravida nulla, ut semper nibh metus sit amet tellus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Vivamus ut vulputate dui, ac dignissim quam.\n\nSed in purus eros. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent auctor fermentum felis, vitae sollicitudin turpis rhoncus eu. Nullam hendrerit dolor quis odio hendrerit, ac faucibus tortor luctus. Vivamus mollis nibh felis, vitae vulputate ex ullamcorper at. Donec convallis orci sed tortor suscipit sollicitudin. Donec et velit dui. Donec commodo ut magna ut fermentum. Suspendisse sapien dolor, aliquam ut cursus mollis, pretium et lorem.\n\nEtiam pretium vel sapien quis condimentum. Suspendisse sit amet viverra ipsum. Vivamus lobortis nisi non justo volutpat, et rhoncus ex varius. Donec tristique urna mattis, aliquet nulla eu, convallis erat. Integer ut ullamcorper tortor. Vestibulum maximus porta tortor. Aliquam mauris odio, accumsan sit amet lacinia eget, volutpat non magna. Proin vel mi eu est commodo hendrerit. Cras condimentum justo erat, ac euismod nisl cursus vel. Maecenas nisi justo, efficitur in rutrum eget, feugiat non diam. Nunc in turpis vel eros tempor elementum quis nec neque. Nullam commodo nunc vitae lectus malesuada sollicitudin. Donec sollicitudin purus a purus dignissim, sit amet egestas metus euismod. Aenean semper nulla sit amet ligula convallis, nec fringilla dolor feugiat. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.", + "color": 11066108, + "timestamp": "2021-01-09T01:37:51.329000+00:00", + "footer": { + "text": "Debug" + } + } + ] +} + +<> 2021-01-09T111009.400.json + +### + +// Long content test (almost 2k chars) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "A very important message" + }, + { + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer fermentum nibh ac dapibus volutpat. Ut felis leo, pretium eu nisi in, ultricies aliquam velit. Suspendisse sagittis egestas commodo. Aliquam ac eleifend odio. Quisque vel mauris id justo molestie fringilla. Nullam aliquet diam in ante lacinia feugiat. In volutpat turpis nisi, quis ornare mauris tristique sed. Mauris quis dapibus metus. Nullam odio neque, gravida in ligula tempus, fermentum iaculis enim.\n\nCras elementum sollicitudin pulvinar. Donec ac aliquet est. Quisque mattis nulla ac cursus efficitur. Integer placerat aliquet aliquet. Aliquam at aliquet arcu. Phasellus lacinia euismod leo, at tempor nisl pharetra at. In laoreet lectus nisl, vel vulputate tellus blandit sed. In pharetra porta quam, eu iaculis arcu pretium ac. Donec augue arcu, sodales vitae tristique quis, consectetur non nulla. Fusce scelerisque dignissim ante ut molestie. Quisque congue maximus vulputate.\n\nEtiam eu mauris sagittis, tincidunt ex in, tincidunt lorem. Donec turpis odio, auctor nec ultricies in, suscipit eu diam. Suspendisse id erat velit. Aliquam maximus libero vel libero dignissim varius. Mauris diam massa, elementum ac tellus vel, molestie consectetur mauris. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi facilisis tristique molestie. Sed egestas, nisl pellentesque posuere tincidunt, lectus tortor gravida nulla, ut semper nibh metus sit amet tellus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Vivamus ut vulputate dui, ac dignissim quam.\n\nSed in purus eros. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent auctor fermentum felis, vitae sollicitudin turpis rhoncus eu. Nullam hendrerit dolor quis odio hendrerit, ac faucibus tortor luctus. Vivamus mollis nibh felis, vitae vulputate ex ullamcorper at. Donec convallis orci sed tortor suscipit sollicitudin.", + "color": 11066108 + }, + { + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer fermentum nibh ac dapibus volutpat. Ut felis leo, pretium eu nisi in, ultricies aliquam velit. Suspendisse sagittis egestas commodo. Aliquam ac eleifend odio. Quisque vel mauris id justo molestie fringilla. Nullam aliquet diam in ante lacinia feugiat. In volutpat turpis nisi, quis ornare mauris tristique sed. Mauris quis dapibus metus. Nullam odio neque, gravida in ligula tempus, fermentum iaculis enim.\n\nCras elementum sollicitudin pulvinar. Donec ac aliquet est. Quisque mattis nulla ac cursus efficitur. Integer placerat aliquet aliquet. Aliquam at aliquet arcu. Phasellus lacinia euismod leo, at tempor nisl pharetra at. In laoreet lectus nisl, vel vulputate tellus blandit sed. In pharetra porta quam, eu iaculis arcu pretium ac. Donec augue arcu, sodales vitae tristique quis, consectetur non nulla. Fusce scelerisque dignissim ante ut molestie. Quisque congue maximus vulputate.\n\nEtiam eu mauris sagittis, tincidunt ex in, tincidunt lorem. Donec turpis odio, auctor nec ultricies in, suscipit eu diam. Suspendisse id erat velit. Aliquam maximus libero vel libero dignissim varius. Mauris diam massa, elementum ac tellus vel, molestie consectetur mauris. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi facilisis tristique molestie. Sed egestas, nisl pellentesque posuere tincidunt, lectus tortor gravida nulla, ut semper nibh metus sit amet tellus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Vivamus ut vulputate dui, ac dignissim quam.\n\nSed in purus eros. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent auctor fermentum felis, vitae sollicitudin turpis rhoncus eu. Nullam hendrerit dolor quis odio hendrerit, ac faucibus tortor luctus. Vivamus mollis nibh felis, vitae vulputate ex ullamcorper at. Donec convallis orci sed tortor suscipit sollicitudin.", + "color": 11066108 + }, + { + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer fermentum nibh ac dapibus volutpat. Ut felis leo, pretium eu nisi in, ultricies aliquam velit. Suspendisse sagittis egestas commodo. Aliquam ac eleifend odio. Quisque vel mauris id justo molestie fringilla. Nullam aliquet diam in ante lacinia feugiat. In volutpat turpis nisi, quis ornare mauris tristique sed. Mauris quis dapibus metus. Nullam odio neque, gravida in ligula tempus, fermentum iaculis enim.\n\nCras elementum sollicitudin pulvinar. Donec ac aliquet est. Quisque mattis nulla ac cursus efficitur. Integer placerat aliquet aliquet. Aliquam at aliquet arcu. Phasellus lacinia euismod leo, at tempor nisl pharetra at. In laoreet lectus nisl, vel vulputate tellus blandit sed. In pharetra porta quam, eu iaculis arcu pretium ac. Donec augue arcu, sodales vitae tristique quis, consectetur non nulla. Fusce scelerisque dignissim ante ut molestie. Quisque congue maximus vulputate.\n\nEtiam eu mauris sagittis, tincidunt ex in, tincidunt lorem. Donec turpis odio, auctor nec ultricies in, suscipit eu diam. Suspendisse id erat velit. Aliquam maximus libero vel libero dignissim varius. Mauris diam massa, elementum ac tellus vel, molestie consectetur mauris. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Morbi facilisis tristique molestie. Sed egestas, nisl pellentesque posuere tincidunt, lectus tortor gravida nulla, ut semper nibh metus sit amet tellus. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Vivamus ut vulputate dui, ac dignissim quam.\n\nSed in purus eros. Orci varius natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Praesent auctor fermentum felis, vitae sollicitudin turpis rhoncus eu. Nullam hendrerit dolor quis odio hendrerit, ac faucibus tortor luctus. Vivamus mollis nibh felis, vitae vulputate ex ullamcorper at. Donec convallis orci sed tortor suscipit sollicitudin.", + "color": 11066108 + } + ] +} + +<> 2021-01-09T111359.200.json +<> 2021-01-09T111332.400.json +<> 2021-01-09T111247.200.json + +### + +// Test if maximum characters are in Unicode code points (including modifiers) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "1999 glyphs (@ 4cp), 7992 code points (@ 10B), 9995 UTF-16 units, 19990 UTF-8 units (bytes)", + "description": "🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️🤷‍♀️" + } + ] +} + +### + +// Test if maximum characters are in Unicode code points (excluding modifiers) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "1999 code points (@ 4B), 3998 UTF-16 units, 7996 UTF-8 units (bytes)", + "description": "🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷🤷" + } + ] +} + +### + +// Test if maximum characters are in Unicode code points (non-emoji) +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "1999 code points (4 bytes), 4098 UTF-16 units, 8196 UTF-8 units (bytes)", + "description": "𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞𡑞" + } + ] +} + +### + +// Test if maximum characters are in Unicode code points +POST {{webhookURL}}?wait=true +Content-Type: application/json + +{ + "embeds": [ + { + "title": "1999 code points (4 bytes), 4098 UTF-16 units, 8196 UTF-8 units (bytes)", + "description": "🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨🤨" + } + ] +} diff --git a/pkg/services/discord/discord_test.go b/pkg/services/discord/discord_test.go new file mode 100644 index 0000000..65d2b55 --- /dev/null +++ b/pkg/services/discord/discord_test.go @@ -0,0 +1,332 @@ +package discord_test + +import ( + "fmt" + "log" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/discord" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// TestDiscord runs the Discord service test suite using Ginkgo. +func TestDiscord(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Discord Suite") +} + +var ( + dummyColors = [types.MessageLevelCount]uint{} + service *discord.Service + envDiscordURL *url.URL + logger *log.Logger + _ = ginkgo.BeforeSuite(func() { + service = &discord.Service{} + envDiscordURL, _ = url.Parse(os.Getenv("SHOUTRRR_DISCORD_URL")) + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + }) +) + +var _ = ginkgo.Describe("the discord service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should work without errors", func() { + if envDiscordURL.String() == "" { + return + } + + serviceURL, _ := url.Parse(envDiscordURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Send("this is an integration test", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + ginkgo.Describe("the service", func() { + ginkgo.It("should implement Service interface", func() { + var impl types.Service = service + gomega.Expect(impl).ToNot(gomega.BeNil()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("discord")) + }) + }) + ginkgo.Describe("creating a config", func() { + ginkgo.When("given a URL and a message", func() { + ginkgo.It("should return an error if no arguments are supplied", func() { + serviceURL, _ := url.Parse("discord://") + err := service.Initialize(serviceURL, nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("should not return an error if exactly two arguments are given", func() { + serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel") + err := service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not return an error when given the raw path parameter", func() { + serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw") + err := service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should set the JSON flag when given the raw path parameter", func() { + serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/raw") + config := discord.Config{} + err := config.SetURL(serviceURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.JSON).To(gomega.BeTrue()) + }) + ginkgo.It("should not set the JSON flag when not provided raw path parameter", func() { + serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel") + config := discord.Config{} + err := config.SetURL(serviceURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.JSON).NotTo(gomega.BeTrue()) + }) + ginkgo.It("should return an error if more than two arguments are given", func() { + serviceURL, _ := url.Parse("discord://dummyToken@dummyChannel/illegal-argument") + err := service.Initialize(serviceURL, nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + testURL := "discord://token@channel?avatar=TestBot.jpg&color=0x112233&colordebug=0x223344&colorerror=0x334455&colorinfo=0x445566&colorwarn=0x556677&splitlines=No&title=Test+Title&username=TestBot" + + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + config := &discord.Config{} + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + ginkgo.It("should include thread_id in URL after de-/serialization", func() { + testURL := "discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title" + + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + config := &discord.Config{} + resolver := format.NewPropKeyResolver(config) + err = resolver.SetDefaultProps(config) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults") + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + gomega.Expect(config.ThreadID).To(gomega.Equal("123456789")) + + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + ginkgo.It("should handle thread_id with whitespace correctly", func() { + testURL := "discord://token@channel?color=0x50d9ff&thread_id=%20%20123456789%20%20&title=Test+Title" + expectedThreadID := "123456789" + + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + config := &discord.Config{} + resolver := format.NewPropKeyResolver(config) + err = resolver.SetDefaultProps(config) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults") + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + gomega.Expect(config.ThreadID).To(gomega.Equal(expectedThreadID)) + gomega.Expect(config.GetURL().Query().Get("thread_id")). + To(gomega.Equal(expectedThreadID)) + gomega.Expect(config.GetURL().String()). + To(gomega.Equal("discord://token@channel?color=0x50d9ff&thread_id=123456789&title=Test+Title")) + }) + ginkgo.It("should not include thread_id in URL when empty", func() { + config := &discord.Config{} + resolver := format.NewPropKeyResolver(config) + err := resolver.SetDefaultProps(config) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting defaults") + + serviceURL, _ := url.Parse("discord://token@channel?title=Test+Title") + err = config.SetURL(serviceURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "setting URL") + + outputURL := config.GetURL() + gomega.Expect(outputURL.Query().Get("thread_id")).To(gomega.BeEmpty()) + gomega.Expect(outputURL.String()). + To(gomega.Equal("discord://token@channel?color=0x50d9ff&title=Test+Title")) + }) + }) + }) + ginkgo.Describe("creating a json payload", func() { + ginkgo.When("given a blank message", func() { + ginkgo.When("split lines is enabled", func() { + ginkgo.It("should return an error", func() { + items := []types.MessageItem{} + gomega.Expect(items).To(gomega.BeEmpty()) + _, err := discord.CreatePayloadFromItems(items, "title", dummyColors) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("split lines is disabled", func() { + ginkgo.It("should return an error", func() { + batches := discord.CreateItemsFromPlain("", false) + items := batches[0] + gomega.Expect(items).To(gomega.BeEmpty()) + _, err := discord.CreatePayloadFromItems(items, "title", dummyColors) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) + ginkgo.When("given a message that exceeds the max length", func() { + ginkgo.It("should return a payload with chunked messages", func() { + payload, err := buildPayloadFromHundreds(42, "Title", dummyColors) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + items := payload.Embeds + gomega.Expect(items).To(gomega.HaveLen(3)) + gomega.Expect(items[0].Content).To(gomega.HaveLen(1994)) + gomega.Expect(items[1].Content).To(gomega.HaveLen(1999)) + gomega.Expect(items[2].Content).To(gomega.HaveLen(205)) + }) + ginkgo.It("omit characters above total max", func() { + payload, err := buildPayloadFromHundreds(62, "", dummyColors) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + items := payload.Embeds + gomega.Expect(items).To(gomega.HaveLen(4)) + gomega.Expect(items[0].Content).To(gomega.HaveLen(1994)) + gomega.Expect(items[1].Content).To(gomega.HaveLen(1999)) + gomega.Expect(items[2].Content).To(gomega.HaveLen(1999)) + gomega.Expect(items[3].Content).To(gomega.HaveLen(5)) + }) + ginkgo.When("no title is supplied and content fits", func() { + ginkgo.It("should return a payload without a meta chunk", func() { + payload, err := buildPayloadFromHundreds(42, "", dummyColors) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(payload.Embeds[0].Footer).To(gomega.BeNil()) + gomega.Expect(payload.Embeds[0].Title).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("title is supplied, but content fits", func() { + ginkgo.It("should return a payload with a meta chunk", func() { + payload, err := buildPayloadFromHundreds(42, "Title", dummyColors) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(payload.Embeds[0].Title).ToNot(gomega.BeEmpty()) + }) + }) + ginkgo.It("rich test 1", func() { + testTime, _ := time.Parse(time.RFC3339, time.RFC3339) + items := []types.MessageItem{ + { + Text: "Message", + Timestamp: testTime, + Level: types.Warning, + }, + } + payload, err := discord.CreatePayloadFromItems(items, "Title", dummyColors) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + item := payload.Embeds[0] + gomega.Expect(payload.Embeds).To(gomega.HaveLen(1)) + gomega.Expect(item.Footer.Text).To(gomega.Equal(types.Warning.String())) + gomega.Expect(item.Title).To(gomega.Equal("Title")) + gomega.Expect(item.Color).To(gomega.Equal(dummyColors[types.Warning])) + }) + }) + }) + ginkgo.Describe("sending the payload", func() { + dummyConfig := discord.Config{ + WebhookID: "1", + Token: "dummyToken", + } + var service discord.Service + ginkgo.BeforeEach(func() { + httpmock.Activate() + service = discord.Service{} + if err := service.Initialize(dummyConfig.GetURL(), logger); err != nil { + panic(fmt.Errorf("service initialization failed: %w", err)) + } + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + setupResponder(&dummyConfig, 204) + gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed()) + }) + ginkgo.It("should report an error if the server response is not OK", func() { + setupResponder(&dummyConfig, 400) + gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed()) + }) + ginkgo.It("should report an error if the message is empty", func() { + setupResponder(&dummyConfig, 204) + gomega.Expect(service.Initialize(dummyConfig.GetURL(), logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("", nil)).NotTo(gomega.Succeed()) + }) + ginkgo.When("using a custom json payload", func() { + ginkgo.It("should report an error if the server response is not OK", func() { + config := dummyConfig + config.JSON = true + setupResponder(&config, 400) + gomega.Expect(service.Initialize(config.GetURL(), logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("Message", nil)).NotTo(gomega.Succeed()) + }) + }) + ginkgo.It("should trim whitespace from thread_id in API URL", func() { + config := discord.Config{ + WebhookID: "1", + Token: "dummyToken", + ThreadID: " 123456789 ", + } + service := discord.Service{} + err := service.Initialize(config.GetURL(), logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + setupResponder(&config, 204) + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + // Verify the API URL used in the HTTP request + targetURL := discord.CreateAPIURLFromConfig(&config) + gomega.Expect(targetURL). + To(gomega.Equal("https://discord.com/api/webhooks/1/dummyToken?thread_id=123456789")) + }) + }) +}) + +// buildPayloadFromHundreds creates a Discord webhook payload from a repeated 100-character string. +func buildPayloadFromHundreds( + hundreds int, + title string, + colors [types.MessageLevelCount]uint, +) (discord.WebhookPayload, error) { + hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error" + builder := strings.Builder{} + + for range hundreds { + builder.WriteString(hundredChars) + } + + batches := discord.CreateItemsFromPlain( + builder.String(), + false, + ) // SplitLines is always false in these tests + items := batches[0] + + return discord.CreatePayloadFromItems(items, title, colors) +} + +// setupResponder configures an HTTP mock responder for a Discord webhook URL with the given status code. +func setupResponder(config *discord.Config, code int) { + targetURL := discord.CreateAPIURLFromConfig(config) + httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(code, "")) +} diff --git a/pkg/services/generic/custom_query.go b/pkg/services/generic/custom_query.go new file mode 100644 index 0000000..418e454 --- /dev/null +++ b/pkg/services/generic/custom_query.go @@ -0,0 +1,74 @@ +package generic + +import ( + "net/url" + "strings" +) + +// Constants for character values and offsets. +const ( + ExtraPrefixChar = '$' // Prefix for extra data in query parameters + HeaderPrefixChar = '@' // Prefix for header values in query parameters + CaseOffset = 'a' - 'A' // Offset between lowercase and uppercase letters + UppercaseA = 'A' // ASCII value for uppercase A + UppercaseZ = 'Z' // ASCII value for uppercase Z + DashChar = '-' // Dash character for header formatting + HeaderCapacityFactor = 2 // Estimated capacity multiplier for header string builder +) + +func normalizedHeaderKey(key string) string { + stringBuilder := strings.Builder{} + stringBuilder.Grow(len(key) * HeaderCapacityFactor) + + for i, c := range key { + if UppercaseA <= c && c <= UppercaseZ { + // Char is uppercase + if i > 0 && key[i-1] != DashChar { + // Add missing dash + stringBuilder.WriteRune(DashChar) + } + } else if i == 0 || key[i-1] == DashChar { + // First char, or previous was dash + c -= CaseOffset + } + + stringBuilder.WriteRune(c) + } + + return stringBuilder.String() +} + +func appendCustomQueryValues( + query url.Values, + headers map[string]string, + extraData map[string]string, +) { + for key, value := range headers { + query.Set(string(HeaderPrefixChar)+key, value) + } + + for key, value := range extraData { + query.Set(string(ExtraPrefixChar)+key, value) + } +} + +func stripCustomQueryValues(query url.Values) (map[string]string, map[string]string) { + headers := make(map[string]string) + extraData := make(map[string]string) + + for key, values := range query { + switch key[0] { + case HeaderPrefixChar: + headerKey := normalizedHeaderKey(key[1:]) + headers[headerKey] = values[0] + case ExtraPrefixChar: + extraData[key[1:]] = values[0] + default: + continue + } + + delete(query, key) + } + + return headers, extraData +} diff --git a/pkg/services/generic/generic.go b/pkg/services/generic/generic.go new file mode 100644 index 0000000..64f470d --- /dev/null +++ b/pkg/services/generic/generic.go @@ -0,0 +1,181 @@ +package generic + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// JSONTemplate identifies the JSON format for webhook payloads. +const ( + JSONTemplate = "JSON" +) + +// ErrSendFailed indicates a failure to send a notification to the generic webhook. +var ( + ErrSendFailed = errors.New("failed to send notification to generic webhook") + ErrUnexpectedStatus = errors.New("server returned unexpected response status code") + ErrTemplateNotLoaded = errors.New("template has not been loaded") +) + +// Service implements a generic notification service for custom webhooks. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to a generic webhook endpoint. +func (service *Service) Send(message string, paramsPtr *types.Params) error { + config := *service.Config + + var params types.Params + if paramsPtr == nil { + params = types.Params{} + } else { + params = *paramsPtr + } + + if err := service.pkr.UpdateConfigFromParams(&config, ¶ms); err != nil { + service.Logf("Failed to update params: %v", err) + } + + sendParams := createSendParams(&config, params, message) + if err := service.doSend(&config, sendParams); err != nil { + return fmt.Errorf("%w: %s", ErrSendFailed, err.Error()) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + + config, pkr := DefaultConfig() + service.Config = config + service.pkr = pkr + + return service.Config.setURL(&service.pkr, configURL) +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// GetConfigURLFromCustom converts a custom webhook URL into a standard service URL. +func (*Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) { + webhookURL := *customURL + if strings.HasPrefix(webhookURL.Scheme, Scheme) { + webhookURL.Scheme = webhookURL.Scheme[len(Scheme)+1:] + } + + config, pkr, err := ConfigFromWebhookURL(webhookURL) + if err != nil { + return nil, err + } + + return config.getURL(&pkr), nil +} + +// doSend executes the HTTP request to send a notification to the webhook. +func (service *Service) doSend(config *Config, params types.Params) error { + postURL := config.WebhookURL().String() + + payload, err := service.GetPayload(config, params) + if err != nil { + return err + } + + ctx := context.Background() + + req, err := http.NewRequestWithContext(ctx, config.RequestMethod, postURL, payload) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", config.ContentType) + req.Header.Set("Accept", config.ContentType) + + for key, value := range config.headers { + req.Header.Set(key, value) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending HTTP request: %w", err) + } + + if res != nil && res.Body != nil { + defer res.Body.Close() + + if body, err := io.ReadAll(res.Body); err == nil { + service.Log("Server response: ", string(body)) + } + } + + if res.StatusCode >= http.StatusMultipleChoices { + return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status) + } + + return nil +} + +// GetPayload prepares the request payload based on the configured template. +func (service *Service) GetPayload(config *Config, params types.Params) (io.Reader, error) { + switch config.Template { + case "": + return bytes.NewBufferString(params[config.MessageKey]), nil + case "json", JSONTemplate: + for key, value := range config.extraData { + params[key] = value + } + + jsonBytes, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("marshaling params to JSON: %w", err) + } + + return bytes.NewBuffer(jsonBytes), nil + } + + tpl, found := service.GetTemplate(config.Template) + if !found { + return nil, fmt.Errorf("%w: %q", ErrTemplateNotLoaded, config.Template) + } + + bb := &bytes.Buffer{} + if err := tpl.Execute(bb, params); err != nil { + return nil, fmt.Errorf("executing template %q: %w", config.Template, err) + } + + return bb, nil +} + +// createSendParams constructs parameters for sending a notification. +func createSendParams(config *Config, params types.Params, message string) types.Params { + sendParams := types.Params{} + + for key, val := range params { + if key == types.TitleKey { + key = config.TitleKey + } + + sendParams[key] = val + } + + sendParams[config.MessageKey] = message + + return sendParams +} diff --git a/pkg/services/generic/generic_config.go b/pkg/services/generic/generic_config.go new file mode 100644 index 0000000..e97b801 --- /dev/null +++ b/pkg/services/generic/generic_config.go @@ -0,0 +1,123 @@ +package generic + +import ( + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme identifies this service in configuration URLs. +const ( + Scheme = "generic" + DefaultWebhookScheme = "https" +) + +// Config holds settings for the generic notification service. +type Config struct { + standard.EnumlessConfig + webhookURL *url.URL + headers map[string]string + extraData map[string]string + ContentType string `default:"application/json" desc:"The value of the Content-Type header" key:"contenttype"` + DisableTLS bool `default:"No" key:"disabletls"` + Template string ` desc:"The template used for creating the request payload" key:"template" optional:""` + Title string `default:"" key:"title"` + TitleKey string `default:"title" desc:"The key that will be used for the title value" key:"titlekey"` + MessageKey string `default:"message" desc:"The key that will be used for the message value" key:"messagekey"` + RequestMethod string `default:"POST" key:"method"` +} + +// DefaultConfig creates a new Config with default values and its associated PropKeyResolver. +func DefaultConfig() (*Config, format.PropKeyResolver) { + config := &Config{} + pkr := format.NewPropKeyResolver(config) + _ = pkr.SetDefaultProps(config) + + return config, pkr +} + +// ConfigFromWebhookURL constructs a Config from a parsed webhook URL. +func ConfigFromWebhookURL(webhookURL url.URL) (*Config, format.PropKeyResolver, error) { + config, pkr := DefaultConfig() + + webhookQuery := webhookURL.Query() + headers, extraData := stripCustomQueryValues(webhookQuery) + escapedQuery := url.Values{} + + for key, values := range webhookQuery { + if len(values) > 0 { + escapedQuery.Set(format.EscapeKey(key), values[0]) + } + } + + _, err := format.SetConfigPropsFromQuery(&pkr, escapedQuery) + if err != nil { + return nil, pkr, fmt.Errorf("setting config properties from query: %w", err) + } + + webhookURL.RawQuery = webhookQuery.Encode() + config.webhookURL = &webhookURL + config.headers = headers + config.extraData = extraData + config.DisableTLS = webhookURL.Scheme == "http" + + return config, pkr, nil +} + +// WebhookURL returns the configured webhook URL, adjusted for TLS settings. +func (config *Config) WebhookURL() *url.URL { + webhookURL := *config.webhookURL + webhookURL.Scheme = DefaultWebhookScheme + + if config.DisableTLS { + webhookURL.Scheme = "http" // Truncate to "http" if TLS is disabled + } + + return &webhookURL +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a service URL. +func (config *Config) SetURL(serviceURL *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, serviceURL) +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + serviceURL := *config.webhookURL + webhookQuery := config.webhookURL.Query() + serviceQuery := format.BuildQueryWithCustomFields(resolver, webhookQuery) + appendCustomQueryValues(serviceQuery, config.headers, config.extraData) + serviceURL.RawQuery = serviceQuery.Encode() + serviceURL.Scheme = Scheme + + return &serviceURL +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error { + webhookURL := *serviceURL + serviceQuery := serviceURL.Query() + headers, extraData := stripCustomQueryValues(serviceQuery) + + customQuery, err := format.SetConfigPropsFromQuery(resolver, serviceQuery) + if err != nil { + return fmt.Errorf("setting config properties from service URL query: %w", err) + } + + webhookURL.RawQuery = customQuery.Encode() + config.webhookURL = &webhookURL + config.headers = headers + config.extraData = extraData + + return nil +} diff --git a/pkg/services/generic/generic_test.go b/pkg/services/generic/generic_test.go new file mode 100644 index 0000000..8c810d4 --- /dev/null +++ b/pkg/services/generic/generic_test.go @@ -0,0 +1,359 @@ +package generic_test + +import ( + "errors" + "io" + "log" + "net/http" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/generic" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Test constants. +const ( + TestWebhookURL = "https://host.tld/webhook" // Default test webhook URL +) + +// TestGeneric runs the Ginkgo test suite for the generic package. +func TestGeneric(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Generic Webhook Suite") +} + +var ( + service *generic.Service + logger *log.Logger + envGenericURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &generic.Service{} + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + var err error + envGenericURL, err = url.Parse(os.Getenv("SHOUTRRR_GENERIC_URL")) + if err != nil { + envGenericURL = &url.URL{} // Default to empty URL if parsing fails + } + }) +) + +var _ = ginkgo.Describe("the generic service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("sends a message successfully with a valid ENV URL", func() { + if envGenericURL.String() == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + serviceURL := testutils.URLMust(envGenericURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the service", func() { + ginkgo.BeforeEach(func() { + service = &generic.Service{} + service.SetLogger(logger) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("generic")) + }) + }) + + ginkgo.When("parsing a custom URL", func() { + ginkgo.BeforeEach(func() { + service = &generic.Service{} + service.SetLogger(logger) + }) + ginkgo.It("correctly sets webhook URL from custom URL", func() { + customURL := testutils.URLMust("generic+https://test.tld") + serviceURL, err := service.GetConfigURLFromCustom(customURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.WebhookURL().String()).To(gomega.Equal("https://test.tld")) + }) + + ginkgo.When("a HTTP URL is provided via query parameter", func() { + ginkgo.It("disables TLS", func() { + config := &generic.Config{} + err := config.SetURL(testutils.URLMust("generic://example.com?disabletls=yes")) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.DisableTLS).To(gomega.BeTrue()) + }) + }) + ginkgo.When("a HTTPS URL is provided", func() { + ginkgo.It("enables TLS", func() { + config := &generic.Config{} + err := config.SetURL(testutils.URLMust("generic://example.com")) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.DisableTLS).To(gomega.BeFalse()) + }) + }) + ginkgo.It("escapes conflicting custom query keys", func() { + serviceURL := testutils.URLMust("generic://example.com/?__template=passed") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Template).NotTo(gomega.Equal("passed")) + whURL := service.Config.WebhookURL().String() + gomega.Expect(whURL).To(gomega.Equal("https://example.com/?template=passed")) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(serviceURL.String())) + }) + ginkgo.It("handles both escaped and service prop versions of keys", func() { + serviceURL := testutils.URLMust( + "generic://example.com/?__template=passed&template=captured", + ) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Template).To(gomega.Equal("captured")) + whURL := service.Config.WebhookURL().String() + gomega.Expect(whURL).To(gomega.Equal("https://example.com/?template=passed")) + }) + }) + + ginkgo.When("retrieving the webhook URL", func() { + ginkgo.BeforeEach(func() { + service = &generic.Service{} + service.SetLogger(logger) + }) + ginkgo.It("builds a valid webhook URL", func() { + serviceURL := testutils.URLMust("generic://example.com/path?foo=bar") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.WebhookURL().String()). + To(gomega.Equal("https://example.com/path?foo=bar")) + }) + + ginkgo.When("TLS is disabled", func() { + ginkgo.It("uses http scheme", func() { + serviceURL := testutils.URLMust("generic://test.tld?disabletls=yes") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.WebhookURL().Scheme).To(gomega.Equal("http")) + }) + }) + ginkgo.When("TLS is not disabled", func() { + ginkgo.It("uses https scheme", func() { + serviceURL := testutils.URLMust("generic://test.tld") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.WebhookURL().Scheme).To(gomega.Equal("https")) + }) + }) + }) + + ginkgo.Describe("the generic config", func() { + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("is identical after de-/serialization", func() { + testURL := "generic://user:pass@host.tld/api/v1/webhook?$context=inside-joke&@Authorization=frend&__title=w&contenttype=a%2Fb&template=f&title=t" + expectedURL := "generic://user:pass@host.tld/api/v1/webhook?%24context=inside-joke&%40Authorization=frend&__title=w&contenttype=a%2Fb&template=f&title=t" + serviceURL := testutils.URLMust(testURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(expectedURL)) + }) + }) + }) + + ginkgo.Describe("building the payload", func() { + ginkgo.BeforeEach(func() { + service = &generic.Service{} + service.SetLogger(logger) + }) + ginkgo.When("no template is specified", func() { + ginkgo.It("uses the message as payload", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + payload, err := service.GetPayload( + service.Config, + types.Params{"message": "test message"}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + contents, err := io.ReadAll(payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(contents)).To(gomega.Equal("test message")) + }) + }) + ginkgo.When("template is specified as `JSON`", func() { + ginkgo.It("creates a JSON object as the payload", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?template=JSON") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + params := types.Params{"title": "test title", "message": "test message"} + payload, err := service.GetPayload(service.Config, params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + contents, err := io.ReadAll(payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(contents)).To(gomega.MatchJSON(`{ + "title": "test title", + "message": "test message" + }`)) + }) + ginkgo.When("alternate keys are specified", func() { + ginkgo.It("creates a JSON object using the specified keys", func() { + serviceURL := testutils.URLMust( + "generic://host.tld/webhook?template=JSON&messagekey=body&titlekey=header", + ) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + params := types.Params{"header": "test title", "body": "test message"} + payload, err := service.GetPayload(service.Config, params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + contents, err := io.ReadAll(payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(contents)).To(gomega.MatchJSON(`{ + "header": "test title", + "body": "test message" + }`)) + }) + }) + }) + ginkgo.When("a valid template is specified", func() { + ginkgo.It("applies the template to the message payload", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?template=news") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.SetTemplateString("news", `{{.title}} ==> {{.message}}`) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + params := types.Params{"title": "BREAKING NEWS", "message": "it's today!"} + payload, err := service.GetPayload(service.Config, params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + contents, err := io.ReadAll(payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(contents)).To(gomega.Equal("BREAKING NEWS ==> it's today!")) + }) + ginkgo.When("given nil params", func() { + ginkgo.It("applies template with message data", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?template=arrows") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.SetTemplateString("arrows", `==> {{.message}} <==`) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + payload, err := service.GetPayload( + service.Config, + types.Params{"message": "LOOK AT ME"}, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + contents, err := io.ReadAll(payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(contents)).To(gomega.Equal("==> LOOK AT ME <==")) + }) + }) + }) + ginkgo.When("an unknown template is specified", func() { + ginkgo.It("returns an error", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?template=missing") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + _, err = service.GetPayload(service.Config, nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) + + ginkgo.Describe("sending the payload", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + service = &generic.Service{} + service.SetLogger(logger) + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.When("sending via webhook URL", func() { + ginkgo.It("succeeds if the server accepts the payload", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + TestWebhookURL, + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("reports an error if sending fails", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + TestWebhookURL, + httpmock.NewErrorResponder(errors.New("dummy error")), + ) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("includes custom headers in the request", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?@authorization=frend") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder("POST", TestWebhookURL, + func(req *http.Request) (*http.Response, error) { + gomega.Expect(req.Header.Get("Authorization")).To(gomega.Equal("frend")) + + return httpmock.NewStringResponse(200, ""), nil + }) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("includes extra data in JSON payload", func() { + serviceURL := testutils.URLMust( + "generic://host.tld/webhook?template=json&$context=inside+joke", + ) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder("POST", TestWebhookURL, + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(body)). + To(gomega.MatchJSON(`{"message":"Message","context":"inside joke"}`)) + + return httpmock.NewStringResponse(200, ""), nil + }) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("uses the configured HTTP method", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?method=GET") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "GET", + TestWebhookURL, + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("does not mutate the given params", func() { + serviceURL := testutils.URLMust("generic://host.tld/webhook?method=GET") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "GET", + TestWebhookURL, + httpmock.NewStringResponder(200, ""), + ) + params := types.Params{"title": "TITLE"} + err = service.Send("Message", ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(params).To(gomega.Equal(types.Params{"title": "TITLE"})) + }) + }) + }) +}) diff --git a/pkg/services/googlechat/googlechat.go b/pkg/services/googlechat/googlechat.go new file mode 100644 index 0000000..9c3b801 --- /dev/null +++ b/pkg/services/googlechat/googlechat.go @@ -0,0 +1,87 @@ +package googlechat + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// ErrUnexpectedStatus indicates an unexpected HTTP status code from the Google Chat API. +var ErrUnexpectedStatus = errors.New("google chat api returned unexpected http status code") + +// Service implements a Google Chat notification service. +type Service struct { + standard.Standard + Config *Config +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + + return service.Config.SetURL(configURL) +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to Google Chat. +func (service *Service) Send(message string, _ *types.Params) error { + config := service.Config + + jsonBody, err := json.Marshal(JSON{Text: message}) + if err != nil { + return fmt.Errorf("marshaling message to JSON: %w", err) + } + + postURL := getAPIURL(config) + jsonBuffer := bytes.NewBuffer(jsonBody) + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + postURL.String(), + jsonBuffer, + ) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending notification to Google Chat: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("%w: %d", ErrUnexpectedStatus, resp.StatusCode) + } + + return nil +} + +// getAPIURL constructs the API URL for Google Chat notifications. +func getAPIURL(config *Config) *url.URL { + query := url.Values{} + query.Set("key", config.Key) + query.Set("token", config.Token) + + return &url.URL{ + Path: config.Path, + Host: config.Host, + Scheme: "https", + RawQuery: query.Encode(), + } +} diff --git a/pkg/services/googlechat/googlechat_config.go b/pkg/services/googlechat/googlechat_config.go new file mode 100644 index 0000000..7b54880 --- /dev/null +++ b/pkg/services/googlechat/googlechat_config.go @@ -0,0 +1,73 @@ +package googlechat + +import ( + "errors" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + Scheme = "googlechat" +) + +// Static error definitions. +var ( + ErrMissingKey = errors.New("missing field 'key'") + ErrMissingToken = errors.New("missing field 'token'") +) + +type Config struct { + standard.EnumlessConfig + Host string `default:"chat.googleapis.com"` + Path string + Token string + Key string +} + +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +func (config *Config) setURL(_ types.ConfigQueryResolver, serviceURL *url.URL) error { + config.Host = serviceURL.Host + config.Path = serviceURL.Path + + query := serviceURL.Query() + config.Key = query.Get("key") + config.Token = query.Get("token") + + // Only enforce if explicitly provided but empty + if query.Has("key") && config.Key == "" { + return ErrMissingKey + } + + if query.Has("token") && config.Token == "" { + return ErrMissingToken + } + + return nil +} + +func (config *Config) getURL(_ types.ConfigQueryResolver) *url.URL { + query := url.Values{} + query.Set("key", config.Key) + query.Set("token", config.Token) + + return &url.URL{ + Host: config.Host, + Path: config.Path, + RawQuery: query.Encode(), + Scheme: Scheme, + } +} diff --git a/pkg/services/googlechat/googlechat_json.go b/pkg/services/googlechat/googlechat_json.go new file mode 100644 index 0000000..dbade82 --- /dev/null +++ b/pkg/services/googlechat/googlechat_json.go @@ -0,0 +1,6 @@ +package googlechat + +// JSON is the actual payload being sent to the Google Chat API. +type JSON struct { + Text string `json:"text"` +} diff --git a/pkg/services/googlechat/googlechat_test.go b/pkg/services/googlechat/googlechat_test.go new file mode 100644 index 0000000..76a5b17 --- /dev/null +++ b/pkg/services/googlechat/googlechat_test.go @@ -0,0 +1,220 @@ +package googlechat_test + +import ( + "errors" + "io" + "log" + "net/http" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/googlechat" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// TestGooglechat runs the Ginkgo test suite for the Google Chat package. +func TestGooglechat(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Google Chat Suite") +} + +var ( + service *googlechat.Service + logger *log.Logger + envGooglechatURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &googlechat.Service{} + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + var err error + envGooglechatURL, err = url.Parse(os.Getenv("SHOUTRRR_GOOGLECHAT_URL")) + if err != nil { + envGooglechatURL = &url.URL{} // Default to empty URL if parsing fails + } + }) +) + +var _ = ginkgo.Describe("Google Chat Service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("sends a message successfully with a valid ENV URL", func() { + if envGooglechatURL.String() == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + serviceURL := testutils.URLMust(envGooglechatURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the service", func() { + ginkgo.BeforeEach(func() { + service = &googlechat.Service{} + service.SetLogger(logger) + }) + ginkgo.It("implements Service interface", func() { + var impl types.Service = service + gomega.Expect(impl).ToNot(gomega.BeNil()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("googlechat")) + }) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.BeforeEach(func() { + service = &googlechat.Service{} + service.SetLogger(logger) + }) + ginkgo.It("builds a valid Google Chat Incoming Webhook URL", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String())) + }) + ginkgo.It("is identical after de-/serialization", func() { + testURL := "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz" + serviceURL := testutils.URLMust(testURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL)) + }) + ginkgo.It("returns an error if key is present but empty", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).To(gomega.MatchError("missing field 'key'")) + }) + ginkgo.It("returns an error if token is present but empty", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).To(gomega.MatchError("missing field 'token'")) + }) + }) + + ginkgo.Describe("sending the payload", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + service = &googlechat.Service{} + service.SetLogger(logger) + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.When("sending via webhook URL", func() { + ginkgo.It("does not report an error if the server accepts the payload", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("reports an error if the server rejects the payload", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + httpmock.NewStringResponder(400, "Bad Request"), + ) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("marshals the payload correctly with the message", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(body)).To(gomega.MatchJSON(`{"text":"Test Message"}`)) + + return httpmock.NewStringResponse(200, ""), nil + }, + ) + err = service.Send("Test Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("sends the POST request with correct URL and content type", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + func(req *http.Request) (*http.Response, error) { + gomega.Expect(req.Method).To(gomega.Equal("POST")) + gomega.Expect(req.Header.Get("Content-Type")). + To(gomega.Equal("application/json")) + + return httpmock.NewStringResponse(200, ""), nil + }, + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns marshal error if JSON marshaling fails", func() { + // Note: Current JSON struct (string) can't fail marshaling naturally + // This test is a placeholder for future complex payload changes + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Valid Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns formatted error if HTTP POST fails", func() { + configURL := testutils.URLMust( + "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + httpmock.NewErrorResponder(errors.New("network failure")), + ) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.MatchError( + "sending notification to Google Chat: Post \"https://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz\": network failure", + )) + }) + }) + }) +}) diff --git a/pkg/services/gotify/gotify.go b/pkg/services/gotify/gotify.go new file mode 100644 index 0000000..bf02915 --- /dev/null +++ b/pkg/services/gotify/gotify.go @@ -0,0 +1,148 @@ +package gotify + +import ( + "crypto/tls" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +const ( + // HTTPTimeout defines the HTTP client timeout in seconds. + HTTPTimeout = 10 + TokenLength = 15 + // TokenChars specifies the valid characters for a Gotify token. + TokenChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_" +) + +// ErrInvalidToken indicates an invalid Gotify token format or content. +var ErrInvalidToken = errors.New("invalid gotify token") + +// Service implements a Gotify notification service. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver + httpClient *http.Client + client jsonclient.Client +} + +// Initialize configures the service with a URL and logger. +// +//nolint:gosec +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{ + Title: "Shoutrrr notification", + } + service.pkr = format.NewPropKeyResolver(service.Config) + + err := service.Config.SetURL(configURL) + if err != nil { + return err + } + + service.httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + // InsecureSkipVerify disables TLS certificate verification when true. + // This is set to Config.DisableTLS to support HTTP or self-signed certificate setups, + // but it reduces security by allowing potential man-in-the-middle attacks. + InsecureSkipVerify: service.Config.DisableTLS, + }, + }, + Timeout: HTTPTimeout * time.Second, + } + if service.Config.DisableTLS { + service.Log("Warning: TLS verification is disabled, making connections insecure") + } + + service.client = jsonclient.NewWithHTTPClient(service.httpClient) + + return nil +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// isTokenValid checks if a Gotify token meets length and character requirements. +// Rules are based on Gotify's token validation logic. +func isTokenValid(token string) bool { + if len(token) != TokenLength || token[0] != 'A' { + return false + } + + for _, c := range token { + if !strings.ContainsRune(TokenChars, c) { + return false + } + } + + return true +} + +// buildURL constructs the Gotify API URL with scheme, host, path, and token. +func buildURL(config *Config) (string, error) { + token := config.Token + if !isTokenValid(token) { + return "", fmt.Errorf("%w: %q", ErrInvalidToken, token) + } + + scheme := "https" + if config.DisableTLS { + scheme = "http" // Use HTTP if TLS is disabled + } + + return fmt.Sprintf("%s://%s%s/message?token=%s", scheme, config.Host, config.Path, token), nil +} + +// Send delivers a notification message to Gotify. +func (service *Service) Send(message string, params *types.Params) error { + if params == nil { + params = &types.Params{} + } + + config := service.Config + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + service.Logf("Failed to update params: %v", err) + } + + postURL, err := buildURL(config) + if err != nil { + return err + } + + request := &messageRequest{ + Message: message, + Title: config.Title, + Priority: config.Priority, + } + response := &messageResponse{} + + err = service.client.Post(postURL, request, response) + if err != nil { + errorRes := &responseError{} + if service.client.ErrorResponse(err, errorRes) { + return errorRes + } + + return fmt.Errorf("failed to send notification to Gotify: %w", err) + } + + return nil +} + +// GetHTTPClient returns the HTTP client for testing purposes. +func (service *Service) GetHTTPClient() *http.Client { + return service.httpClient +} diff --git a/pkg/services/gotify/gotify_config.go b/pkg/services/gotify/gotify_config.go new file mode 100644 index 0000000..195491d --- /dev/null +++ b/pkg/services/gotify/gotify_config.go @@ -0,0 +1,76 @@ +package gotify + +import ( + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme identifies this service in configuration URLs. +const ( + Scheme = "gotify" +) + +// Config holds settings for the Gotify notification service. +type Config struct { + standard.EnumlessConfig + Token string `desc:"Application token" required:"" url:"path2"` + Host string `desc:"Server hostname (and optionally port)" required:"" url:"host,port"` + Path string `desc:"Server subpath" url:"path1" optional:""` + Priority int ` default:"0" key:"priority"` + Title string ` default:"Shoutrrr notification" key:"title"` + DisableTLS bool ` default:"No" key:"disabletls"` +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + Host: config.Host, + Scheme: Scheme, + ForceQuery: false, + Path: config.Path + config.Token, + RawQuery: format.BuildQuery(resolver), + } +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + path := url.Path + if len(path) > 0 && path[len(path)-1] == '/' { + path = path[:len(path)-1] + } + + tokenIndex := strings.LastIndex(path, "/") + 1 + + config.Path = path[:tokenIndex] + if config.Path == "/" { + config.Path = config.Path[1:] + } + + config.Host = url.Host + config.Token = path[tokenIndex:] + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting config property %q from URL query: %w", key, err) + } + } + + return nil +} diff --git a/pkg/services/gotify/gotify_json.go b/pkg/services/gotify/gotify_json.go new file mode 100644 index 0000000..eb6a231 --- /dev/null +++ b/pkg/services/gotify/gotify_json.go @@ -0,0 +1,27 @@ +package gotify + +import "fmt" + +// messageRequest is the actual payload being sent to the Gotify API. +type messageRequest struct { + Message string `json:"message"` + Title string `json:"title"` + Priority int `json:"priority"` +} + +type messageResponse struct { + messageRequest + ID uint64 `json:"id"` + AppID uint64 `json:"appid"` + Date string `json:"date"` +} + +type responseError struct { + Name string `json:"error"` + Code uint64 `json:"errorCode"` + Description string `json:"errorDescription"` +} + +func (er *responseError) Error() string { + return fmt.Sprintf("server respondend with %v (%v): %v", er.Name, er.Code, er.Description) +} diff --git a/pkg/services/gotify/gotify_test.go b/pkg/services/gotify/gotify_test.go new file mode 100644 index 0000000..8a25d8c --- /dev/null +++ b/pkg/services/gotify/gotify_test.go @@ -0,0 +1,246 @@ +package gotify_test + +import ( + "bytes" + "errors" + "log" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/gotify" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Test constants. +const ( + TargetURL = "https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd" +) + +// TestGotify runs the Ginkgo test suite for the Gotify package. +func TestGotify(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Gotify Suite") +} + +var ( + service *gotify.Service + logger *log.Logger + envGotifyURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &gotify.Service{} + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + var err error + envGotifyURL, err = url.Parse(os.Getenv("SHOUTRRR_GOTIFY_URL")) + if err != nil { + envGotifyURL = &url.URL{} // Default to empty URL if parsing fails + } + }) +) + +var _ = ginkgo.Describe("the Gotify service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("sends a message successfully with a valid ENV URL", func() { + if envGotifyURL.String() == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + serviceURL := testutils.URLMust(envGotifyURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the service", func() { + ginkgo.BeforeEach(func() { + service = &gotify.Service{} + service.SetLogger(logger) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("gotify")) + }) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.BeforeEach(func() { + service = &gotify.Service{} + service.SetLogger(logger) + }) + ginkgo.It("builds a valid Gotify URL without path", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String())) + }) + ginkgo.When("TLS is disabled", func() { + ginkgo.It("uses http scheme", func() { + configURL := testutils.URLMust( + "gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=yes", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.DisableTLS).To(gomega.BeTrue()) + }) + }) + ginkgo.When("a custom path is provided", func() { + ginkgo.It("includes the path in the URL", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(configURL.String())) + }) + }) + ginkgo.When("the token has an invalid length", func() { + ginkgo.It("reports an error during send", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/short") // Length < 15 + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"short\"")) + }) + }) + ginkgo.When("the token has an invalid prefix", func() { + ginkgo.It("reports an error during send", func() { + configURL := testutils.URLMust( + "gotify://my.gotify.tld/Chwbsdyhwwgarxd", + ) // Starts with 'C', not 'A' + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Message", nil) + gomega.Expect(err). + To(gomega.MatchError("invalid gotify token: \"Chwbsdyhwwgarxd\"")) + }) + }) + ginkgo.It("is identical after de-/serialization with path", func() { + testURL := "gotify://my.gotify.tld/gotify/Aaa.bbb.ccc.ddd?title=Test+title" + serviceURL := testutils.URLMust(testURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL)) + }) + ginkgo.It("is identical after de-/serialization without path", func() { + testURL := "gotify://my.gotify.tld/Aaa.bbb.ccc.ddd?disabletls=Yes&priority=1&title=Test+title" + serviceURL := testutils.URLMust(testURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.GetURL().String()).To(gomega.Equal(testURL)) + }) + ginkgo.It("allows slash at the end of the token", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd/") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd")) + }) + ginkgo.It("allows slash at the end of the token with additional path", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/path/to/gotify/Aaa.bbb.ccc.ddd/") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Token).To(gomega.Equal("Aaa.bbb.ccc.ddd")) + }) + ginkgo.It("does not crash on empty token or path slash", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld//") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Token).To(gomega.Equal("")) + }) + }) + + ginkgo.When("the token contains invalid characters", func() { + ginkgo.It("reports an error during send", func() { + configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.dd!") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.MatchError("invalid gotify token: \"Aaa.bbb.ccc.dd!\"")) + }) + }) + + ginkgo.Describe("sending the payload", func() { + ginkgo.BeforeEach(func() { + service = &gotify.Service{} + service.SetLogger(logger) + configURL := testutils.URLMust("gotify://my.gotify.tld/Aaa.bbb.ccc.ddd") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.ActivateNonDefault(service.GetHTTPClient()) + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.When("sending via webhook URL", func() { + ginkgo.It("does not report an error if the server accepts the payload", func() { + httpmock.RegisterResponder( + "POST", + TargetURL, + testutils.JSONRespondMust(200, map[string]any{ + "id": float64(1), + "appid": float64(1), + "message": "Message", + "title": "Shoutrrr notification", + "priority": float64(0), + "date": "2023-01-01T00:00:00Z", + }), + ) + err := service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It( + "reports an error if the server rejects the payload with an error response", + func() { + httpmock.RegisterResponder( + "POST", + TargetURL, + testutils.JSONRespondMust(401, map[string]any{ + "error": "Unauthorized", + "errorCode": float64(401), + "errorDescription": "you need to provide a valid access token or user credentials to access this api", + }), + ) + err := service.Send("Message", nil) + gomega.Expect(err). + To(gomega.MatchError("server respondend with Unauthorized (401): you need to provide a valid access token or user credentials to access this api")) + }, + ) + ginkgo.It("reports an error if sending fails with a network error", func() { + httpmock.RegisterResponder( + "POST", + TargetURL, + httpmock.NewErrorResponder(errors.New("network failure")), + ) + err := service.Send("Message", nil) + gomega.Expect(err). + To(gomega.MatchError("failed to send notification to Gotify: sending POST request to \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": Post \"https://my.gotify.tld/message?token=Aaa.bbb.ccc.ddd\": network failure")) + }) + ginkgo.It("logs an error if params update fails", func() { + var logBuffer bytes.Buffer + service.SetLogger(log.New(&logBuffer, "Test", log.LstdFlags)) + httpmock.RegisterResponder( + "POST", + TargetURL, + testutils.JSONRespondMust(200, map[string]any{ + "id": float64(1), + "appid": float64(1), + "message": "Message", + "title": "Shoutrrr notification", + "priority": float64(0), + "date": "2023-01-01T00:00:00Z", + }), + ) + params := types.Params{"priority": "invalid"} + err := service.Send("Message", ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(logBuffer.String()). + To(gomega.ContainSubstring("Failed to update params")) + }) + }) + }) +}) diff --git a/pkg/services/ifttt/ifttt.go b/pkg/services/ifttt/ifttt.go new file mode 100644 index 0000000..a809c8f --- /dev/null +++ b/pkg/services/ifttt/ifttt.go @@ -0,0 +1,106 @@ +package ifttt + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// apiURLFormat defines the IFTTT webhook URL template. +const ( + apiURLFormat = "https://maker.ifttt.com/trigger/%s/with/key/%s" +) + +// ErrSendFailed indicates a failure to send an IFTTT event notification. +var ( + ErrSendFailed = errors.New("failed to send IFTTT event") + ErrUnexpectedStatus = errors.New("got unexpected response status code") +) + +// Service sends notifications to an IFTTT webhook. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{ + UseMessageAsValue: DefaultMessageValue, + } + service.pkr = format.NewPropKeyResolver(service.Config) + + if err := service.Config.setURL(&service.pkr, configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to an IFTTT webhook. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + payload, err := createJSONToSend(config, message, params) + if err != nil { + return err + } + + for _, event := range config.Events { + apiURL := service.createAPIURLForEvent(event) + if err := doSend(payload, apiURL); err != nil { + return fmt.Errorf("%w: event %q: %w", ErrSendFailed, event, err) + } + } + + return nil +} + +// createAPIURLForEvent builds an IFTTT webhook URL for a specific event. +func (service *Service) createAPIURLForEvent(event string) string { + return fmt.Sprintf(apiURLFormat, event, service.Config.WebHookID) +} + +// doSend executes an HTTP POST request to send the payload to the IFTTT webhook. +func doSend(payload []byte, postURL string) error { + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + postURL, + bytes.NewBuffer(payload), + ) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending HTTP request to IFTTT webhook: %w", err) + } + defer res.Body.Close() + + if res.StatusCode < 200 || res.StatusCode >= 300 { + return fmt.Errorf("%w: %s", ErrUnexpectedStatus, res.Status) + } + + return nil +} diff --git a/pkg/services/ifttt/ifttt_config.go b/pkg/services/ifttt/ifttt_config.go new file mode 100644 index 0000000..d319da5 --- /dev/null +++ b/pkg/services/ifttt/ifttt_config.go @@ -0,0 +1,107 @@ +package ifttt + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + Scheme = "ifttt" // Scheme identifies this service in configuration URLs. + DefaultMessageValue = 2 // Default value field (1-3) for the notification message + DisabledValue = 0 // Value to disable title assignment + MinValueField = 1 // Minimum valid value field (Value1) + MaxValueField = 3 // Maximum valid value field (Value3) + MinLength = 1 // Minimum length for required fields like Events and WebHookID +) + +var ( + ErrInvalidMessageValue = errors.New( + "invalid value for messagevalue: only values 1-3 are supported", + ) + ErrInvalidTitleValue = errors.New( + "invalid value for titlevalue: only values 1-3 or 0 (for disabling) are supported", + ) + ErrTitleMessageConflict = errors.New("titlevalue cannot use the same number as messagevalue") + ErrMissingEvents = errors.New("events missing from config URL") + ErrMissingWebhookID = errors.New("webhook ID missing from config URL") +) + +// Config holds settings for the IFTTT notification service. +type Config struct { + standard.EnumlessConfig + WebHookID string `required:"true" url:"host"` + Events []string `required:"true" key:"events"` + Value1 string ` key:"value1" optional:""` + Value2 string ` key:"value2" optional:""` + Value3 string ` key:"value3" optional:""` + UseMessageAsValue uint8 ` key:"messagevalue" default:"2" desc:"Sets the corresponding value field to the notification message"` + UseTitleAsValue uint8 ` key:"titlevalue" default:"0" desc:"Sets the corresponding value field to the notification title"` + Title string ` key:"title" default:"" desc:"Notification title, optionally set by the sender"` +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + Host: config.WebHookID, + Path: "/", + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + } +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + if config.UseMessageAsValue == DisabledValue { + config.UseMessageAsValue = DefaultMessageValue + } + + config.WebHookID = url.Hostname() + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting config property %q from URL query: %w", key, err) + } + } + + if config.UseMessageAsValue > MaxValueField || config.UseMessageAsValue < MinValueField { + return ErrInvalidMessageValue + } + + if config.UseTitleAsValue > MaxValueField { + return ErrInvalidTitleValue + } + + if config.UseTitleAsValue != DisabledValue && + config.UseTitleAsValue == config.UseMessageAsValue { + return ErrTitleMessageConflict + } + + if url.String() != "ifttt://dummy@dummy.com" { + if len(config.Events) < MinLength { + return ErrMissingEvents + } + + if len(config.WebHookID) < MinLength { + return ErrMissingWebhookID + } + } + + return nil +} diff --git a/pkg/services/ifttt/ifttt_json.go b/pkg/services/ifttt/ifttt_json.go new file mode 100644 index 0000000..7c9dd86 --- /dev/null +++ b/pkg/services/ifttt/ifttt_json.go @@ -0,0 +1,61 @@ +package ifttt + +import ( + "encoding/json" + "fmt" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// ValueFieldOne represents the Value1 field in the IFTTT payload. +const ( + ValueFieldOne = 1 // Represents Value1 field + ValueFieldTwo = 2 // Represents Value2 field + ValueFieldThree = 3 // Represents Value3 field +) + +// jsonPayload represents the notification payload sent to the IFTTT webhook API. +type jsonPayload struct { + Value1 string `json:"value1"` + Value2 string `json:"value2"` + Value3 string `json:"value3"` +} + +// createJSONToSend generates a JSON payload for the IFTTT webhook API. +func createJSONToSend(config *Config, message string, params *types.Params) ([]byte, error) { + payload := jsonPayload{ + Value1: config.Value1, + Value2: config.Value2, + Value3: config.Value3, + } + + if params != nil { + if value, found := (*params)["value1"]; found { + payload.Value1 = value + } + + if value, found := (*params)["value2"]; found { + payload.Value2 = value + } + + if value, found := (*params)["value3"]; found { + payload.Value3 = value + } + } + + switch config.UseMessageAsValue { + case ValueFieldOne: + payload.Value1 = message + case ValueFieldTwo: + payload.Value2 = message + case ValueFieldThree: + payload.Value3 = message + } + + jsonBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshaling IFTTT payload to JSON: %w", err) + } + + return jsonBytes, nil +} diff --git a/pkg/services/ifttt/ifttt_test.go b/pkg/services/ifttt/ifttt_test.go new file mode 100644 index 0000000..b756142 --- /dev/null +++ b/pkg/services/ifttt/ifttt_test.go @@ -0,0 +1,335 @@ +package ifttt_test + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/ifttt" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// TestIFTTT runs the Ginkgo test suite for the IFTTT package. +func TestIFTTT(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr IFTTT Suite") +} + +var ( + service *ifttt.Service + logger *log.Logger + envTestURL string + _ = ginkgo.BeforeSuite(func() { + service = &ifttt.Service{} + logger = testutils.TestLogger() + envTestURL = os.Getenv("SHOUTRRR_IFTTT_URL") + }) +) + +var _ = ginkgo.Describe("the IFTTT service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("sends a message successfully with a valid ENV URL", func() { + if envTestURL == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + serviceURL := testutils.URLMust(envTestURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the service", func() { + ginkgo.BeforeEach(func() { + service = &ifttt.Service{} + service.SetLogger(logger) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("ifttt")) + }) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.BeforeEach(func() { + service = &ifttt.Service{} + service.SetLogger(logger) + }) + ginkgo.It("returns an error if no arguments are supplied", func() { + serviceURL := testutils.URLMust("ifttt://") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error if no webhook ID is given", func() { + serviceURL := testutils.URLMust("ifttt:///?events=event1") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error if no events are given", func() { + serviceURL := testutils.URLMust("ifttt://dummyID") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error when an invalid query key is given", func() { // Line 54 + serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&badquery=foo") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error if message value is above 3", func() { + serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&messagevalue=8") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error if message value is below 1", func() { // Line 60 + serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&messagevalue=0") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It( + "does not return an error if webhook ID and at least one event are given", + func() { + serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }, + ) + ginkgo.It("returns an error if titlevalue is invalid", func() { // Line 78 + serviceURL := testutils.URLMust("ifttt://dummyID/?events=event1&titlevalue=4") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err). + To(gomega.MatchError("invalid value for titlevalue: only values 1-3 or 0 (for disabling) are supported")) + }) + ginkgo.It("returns an error if titlevalue equals messagevalue", func() { // Line 82 + serviceURL := testutils.URLMust( + "ifttt://dummyID/?events=event1&messagevalue=2&titlevalue=2", + ) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err). + To(gomega.MatchError("titlevalue cannot use the same number as messagevalue")) + }) + }) + + ginkgo.When("serializing a config to URL", func() { + ginkgo.BeforeEach(func() { + service = &ifttt.Service{} + service.SetLogger(logger) + }) + ginkgo.When("given multiple events", func() { + ginkgo.It("returns an URL with all events comma-separated", func() { + configURL := testutils.URLMust("ifttt://dummyID/?events=foo%2Cbar%2Cbaz") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + resultURL := service.Config.GetURL().String() + gomega.Expect(resultURL).To(gomega.Equal(configURL.String())) + }) + }) + ginkgo.When("given values", func() { + ginkgo.It("returns an URL with all values", func() { + configURL := testutils.URLMust( + "ifttt://dummyID/?events=event1&value1=v1&value2=v2&value3=v3", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + resultURL := service.Config.GetURL().String() + gomega.Expect(resultURL).To(gomega.Equal(configURL.String())) + }) + }) + }) + + ginkgo.Describe("sending a message", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + service = &ifttt.Service{} + service.SetLogger(logger) + }) + + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("errors if the response code is not 200-299", func() { + configURL := testutils.URLMust("ifttt://dummy/?events=foo") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/foo/with/key/dummy", + httpmock.NewStringResponder(404, ""), + ) + err = service.Send("hello", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("does not error if the response code is 200", func() { + configURL := testutils.URLMust("ifttt://dummy/?events=foo") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/foo/with/key/dummy", + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("hello", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns an error if params update fails", func() { // Line 55 + configURL := testutils.URLMust("ifttt://dummy/?events=event1") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + params := types.Params{"messagevalue": "invalid"} + err = service.Send("hello", ¶ms) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.DescribeTable("sets message to correct value field based on messagevalue", + func(messageValue int, expectedField string) { // Lines 30, 32, 34 + configURL := testutils.URLMust( + fmt.Sprintf("ifttt://dummy/?events=event1&messagevalue=%d", messageValue), + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event1/with/key/dummy", + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var payload jsonPayload + err = json.Unmarshal(body, &payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + switch expectedField { + case "Value1": + gomega.Expect(payload.Value1).To(gomega.Equal("hello")) + gomega.Expect(payload.Value2).To(gomega.Equal("")) + gomega.Expect(payload.Value3).To(gomega.Equal("")) + case "Value2": + gomega.Expect(payload.Value1).To(gomega.Equal("")) + gomega.Expect(payload.Value2).To(gomega.Equal("hello")) + gomega.Expect(payload.Value3).To(gomega.Equal("")) + case "Value3": + gomega.Expect(payload.Value1).To(gomega.Equal("")) + gomega.Expect(payload.Value2).To(gomega.Equal("")) + gomega.Expect(payload.Value3).To(gomega.Equal("hello")) + } + + return httpmock.NewStringResponse(200, ""), nil + }, + ) + err = service.Send("hello", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }, + ginkgo.Entry("messagevalue=1 sets Value1", 1, "Value1"), + ginkgo.Entry("messagevalue=2 sets Value2", 2, "Value2"), + ginkgo.Entry("messagevalue=3 sets Value3", 3, "Value3"), + ) + ginkgo.It("overrides Value2 with params when messagevalue is 1", func() { // Line 36 + configURL := testutils.URLMust("ifttt://dummy/?events=event1&messagevalue=1") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event1/with/key/dummy", + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var payload jsonPayload + err = json.Unmarshal(body, &payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(payload.Value1).To(gomega.Equal("hello")) + gomega.Expect(payload.Value2).To(gomega.Equal("y")) + gomega.Expect(payload.Value3).To(gomega.Equal("")) + + return httpmock.NewStringResponse(200, ""), nil + }, + ) + params := types.Params{ + "value2": "y", + } + err = service.Send("hello", ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("overrides payload values with params", func() { // Lines 17, 21, 25 + configURL := testutils.URLMust( + "ifttt://dummy/?events=event1&value1=a&value2=b&value3=c&messagevalue=2", + ) + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event1/with/key/dummy", + func(req *http.Request) (*http.Response, error) { + body, err := io.ReadAll(req.Body) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + var payload jsonPayload + err = json.Unmarshal(body, &payload) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(payload.Value1).To(gomega.Equal("x")) + gomega.Expect(payload.Value2).To(gomega.Equal("hello")) + gomega.Expect(payload.Value3).To(gomega.Equal("z")) + + return httpmock.NewStringResponse(200, ""), nil + }, + ) + params := types.Params{ + "value1": "x", + // "value2": "y", // Omitted to let message override + "value3": "z", + } + err = service.Send("hello", ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should fail with multiple events when one errors", func() { + configURL := testutils.URLMust("ifttt://dummy/?events=event1,event2") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event1/with/key/dummy", + httpmock.NewStringResponder(200, ""), + ) + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event2/with/key/dummy", + httpmock.NewStringResponder(404, "Not Found"), + ) + + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.MatchError( + `failed to send IFTTT event: event "event2": got unexpected response status code: 404 Not Found`, + )) + }) + + ginkgo.It("should fail with network error", func() { + configURL := testutils.URLMust("ifttt://dummy/?events=event1") + err := service.Initialize(configURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + "https://maker.ifttt.com/trigger/event1/with/key/dummy", + httpmock.NewErrorResponder(errors.New("network failure")), + ) + + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.MatchError( + `failed to send IFTTT event: event "event1": sending HTTP request to IFTTT webhook: Post "https://maker.ifttt.com/trigger/event1/with/key/dummy": network failure`, + )) + }) + }) +}) + +type jsonPayload struct { + Value1 string `json:"value1"` + Value2 string `json:"value2"` + Value3 string `json:"value3"` +} diff --git a/pkg/services/join/join.go b/pkg/services/join/join.go new file mode 100644 index 0000000..0a655db --- /dev/null +++ b/pkg/services/join/join.go @@ -0,0 +1,119 @@ +package join + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + // hookURL defines the Join API endpoint for sending push notifications. + hookURL = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush" + contentType = "text/plain" +) + +// ErrSendFailed indicates a failure to send a notification to Join devices. +var ErrSendFailed = errors.New("failed to send notification to join devices") + +// Service sends notifications to Join devices. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Join devices. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + + if params == nil { + params = &types.Params{} + } + + title, found := (*params)["title"] + if !found { + title = config.Title + } + + icon, found := (*params)["icon"] + if !found { + icon = config.Icon + } + + devices := strings.Join(config.Devices, ",") + + return service.sendToDevices(devices, message, title, icon) +} + +func (service *Service) sendToDevices(devices, message, title, icon string) error { + config := service.Config + + apiURL, err := url.Parse(hookURL) + if err != nil { + return fmt.Errorf("parsing Join API URL: %w", err) + } + + data := url.Values{} + data.Set("deviceIds", devices) + data.Set("apikey", config.APIKey) + data.Set("text", message) + + if len(title) > 0 { + data.Set("title", title) + } + + if len(icon) > 0 { + data.Set("icon", icon) + } + + apiURL.RawQuery = data.Encode() + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + apiURL.String(), + nil, + ) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + + res, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("sending HTTP request to Join: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %q, response status %q", ErrSendFailed, devices, res.Status) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + if err := service.Config.setURL(&service.pkr, configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} diff --git a/pkg/services/join/join_config.go b/pkg/services/join/join_config.go new file mode 100644 index 0000000..b88aff0 --- /dev/null +++ b/pkg/services/join/join_config.go @@ -0,0 +1,79 @@ +package join + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme identifies this service in configuration URLs. +const Scheme = "join" + +// ErrDevicesMissing indicates that no devices are specified in the configuration. +var ( + ErrDevicesMissing = errors.New("devices missing from config URL") + ErrAPIKeyMissing = errors.New("API key missing from config URL") +) + +// Config holds settings for the Join notification service. +type Config struct { + APIKey string `url:"pass"` + Devices []string ` desc:"Comma separated list of device IDs" key:"devices"` + Title string ` desc:"If set creates a notification" key:"title" optional:""` + Icon string ` desc:"Icon URL" key:"icon" optional:""` +} + +// Enums returns the fields that should use an EnumFormatter for their values. +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword("Token", config.APIKey), + Host: "join", + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + config.APIKey = password + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting config property %q from URL query: %w", key, err) + } + } + + if url.String() != "join://dummy@dummy.com" { + if len(config.Devices) < 1 { + return ErrDevicesMissing + } + + if len(config.APIKey) < 1 { + return ErrAPIKeyMissing + } + } + + return nil +} diff --git a/pkg/services/join/join_errors.go b/pkg/services/join/join_errors.go new file mode 100644 index 0000000..32df81d --- /dev/null +++ b/pkg/services/join/join_errors.go @@ -0,0 +1,12 @@ +package join + +// ErrorMessage for error events within the pushover service. +type ErrorMessage string + +const ( + // APIKeyMissing should be used when a config URL is missing a token. + APIKeyMissing ErrorMessage = "API key missing from config URL" //nolint:gosec // false positive + + // DevicesMissing should be used when a config URL is missing devices. + DevicesMissing ErrorMessage = "devices missing from config URL" +) diff --git a/pkg/services/join/join_test.go b/pkg/services/join/join_test.go new file mode 100644 index 0000000..9172df5 --- /dev/null +++ b/pkg/services/join/join_test.go @@ -0,0 +1,173 @@ +package join_test + +import ( + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/join" +) + +func TestJoin(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Join Suite") +} + +var ( + service *join.Service + config *join.Config + pkr format.PropKeyResolver + envJoinURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &join.Service{} + envJoinURL, _ = url.Parse(os.Getenv("SHOUTRRR_JOIN_URL")) + }) +) + +var _ = ginkgo.Describe("the join service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should work", func() { + if envJoinURL.String() == "" { + return + } + serviceURL, _ := url.Parse(envJoinURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("this is an integration test", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("join")) + }) + }) +}) + +var _ = ginkgo.Describe("the join config", func() { + ginkgo.BeforeEach(func() { + config = &join.Config{} + pkr = format.NewPropKeyResolver(config) + }) + ginkgo.When("updating it using an url", func() { + ginkgo.It("should update the API key using the password part of the url", func() { + url := createURL("dummy", "TestToken", "testDevice") + err := config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.APIKey).To(gomega.Equal("TestToken")) + }) + ginkgo.It("should error if supplied with an empty token", func() { + url := createURL("user", "", "testDevice") + expectErrorMessageGivenURL(join.APIKeyMissing, url) + }) + }) + ginkgo.When("getting the current config", func() { + ginkgo.It("should return the config that is currently set as an url", func() { + config.APIKey = "test-token" + + url := config.GetURL() + password, _ := url.User.Password() + gomega.Expect(password).To(gomega.Equal(config.APIKey)) + gomega.Expect(url.Scheme).To(gomega.Equal("join")) + }) + }) + ginkgo.When("setting a config key", func() { + ginkgo.It("should split it by commas if the key is devices", func() { + err := pkr.Set("devices", "a,b,c,d") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Devices).To(gomega.Equal([]string{"a", "b", "c", "d"})) + }) + ginkgo.It("should update icon when an icon is supplied", func() { + err := pkr.Set("icon", "https://example.com/icon.png") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Icon).To(gomega.Equal("https://example.com/icon.png")) + }) + ginkgo.It("should update the title when it is supplied", func() { + err := pkr.Set("title", "new title") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Title).To(gomega.Equal("new title")) + }) + ginkgo.It("should return an error if the key is not recognized", func() { + err := pkr.Set("devicey", "a,b,c,d") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("getting a config key", func() { + ginkgo.It("should join it with commas if the key is devices", func() { + config.Devices = []string{"a", "b", "c"} + value, err := pkr.Get("devices") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(value).To(gomega.Equal("a,b,c")) + }) + ginkgo.It("should return an error if the key is not recognized", func() { + _, err := pkr.Get("devicey") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("listing the query fields", func() { + ginkgo.It( + "should return the keys \"devices\", \"icon\", \"title\" in alphabetical order", + func() { + fields := pkr.QueryFields() + gomega.Expect(fields).To(gomega.Equal([]string{"devices", "icon", "title"})) + }, + ) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + input := "join://Token:apikey@join?devices=dev1%2Cdev2&icon=warning&title=hey" + config := &join.Config{} + gomega.Expect(config.SetURL(testutils.URLMust(input))).To(gomega.Succeed()) + gomega.Expect(config.GetURL().String()).To(gomega.Equal(input)) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + config := join.Config{ + APIKey: "apikey", + Devices: []string{"dev1"}, + } + serviceURL := config.GetURL() + service := join.Service{} + err = service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1/sendPush", + httpmock.NewStringResponder(200, ``), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) +}) + +func createURL(username string, token string, devices string) *url.URL { + return &url.URL{ + User: url.UserPassword("Token", token), + Host: username, + RawQuery: "devices=" + devices, + } +} + +func expectErrorMessageGivenURL(msg join.ErrorMessage, url *url.URL) { + err := config.SetURL(url) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.Equal(string(msg))) +} diff --git a/pkg/services/lark/lark_config.go b/pkg/services/lark/lark_config.go new file mode 100644 index 0000000..c550ef0 --- /dev/null +++ b/pkg/services/lark/lark_config.go @@ -0,0 +1,74 @@ +package lark + +import ( + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifier for the Lark service protocol. +const Scheme = "lark" + +// Config represents the configuration for the Lark service. +type Config struct { + Host string `default:"open.larksuite.com" desc:"Custom bot URL Host" url:"Host"` + Secret string `default:"" desc:"Custom bot secret" key:"secret"` + Path string ` desc:"Custom bot token" url:"Path"` + Title string `default:"" desc:"Message Title" key:"title"` + Link string `default:"" desc:"Optional link URL" key:"link"` +} + +// Enums returns a map of enum formatters (none for this service). +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL constructs a URL from the Config fields. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// getURL constructs a URL using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + Host: config.Host, + Path: "/" + config.Path, + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} + +// SetURL updates the Config from a URL. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// setURL updates the Config from a URL using the provided resolver. +// It sets the host, path, and query parameters, validating host and path, and returns an error if parsing or validation fails. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + config.Host = url.Host + if config.Host != larkHost && config.Host != feishuHost { + return ErrInvalidHost + } + + config.Path = strings.Trim(url.Path, "/") + if config.Path == "" { + return ErrNoPath + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q: %w", key, err) + } + } + + return nil +} diff --git a/pkg/services/lark/lark_message.go b/pkg/services/lark/lark_message.go new file mode 100644 index 0000000..86cae67 --- /dev/null +++ b/pkg/services/lark/lark_message.go @@ -0,0 +1,59 @@ +package lark + +// RequestBody represents the payload sent to the Lark API. +type RequestBody struct { + MsgType MsgType `json:"msg_type"` + Content Content `json:"content"` + Timestamp string `json:"timestamp,omitempty"` + Sign string `json:"sign,omitempty"` +} + +// MsgType defines the type of message to send. +type MsgType string + +// Constants for message types supported by Lark. +const ( + MsgTypeText MsgType = "text" + MsgTypePost MsgType = "post" +) + +// Content holds the message content, supporting text or post formats. +type Content struct { + Text string `json:"text,omitempty"` + Post *Post `json:"post,omitempty"` +} + +// Post represents a rich post message with language-specific content. +type Post struct { + Zh *Message `json:"zh_cn,omitempty"` // Chinese content + En *Message `json:"en_us,omitempty"` // English content +} + +// Message defines the structure of a post message. +type Message struct { + Title string `json:"title"` + Content [][]Item `json:"content"` +} + +// Item represents a content element within a post message. +type Item struct { + Tag TagValue `json:"tag"` + Text string `json:"text,omitempty"` + Link string `json:"href,omitempty"` +} + +// TagValue specifies the type of content item. +type TagValue string + +// Constants for tag values supported by Lark. +const ( + TagValueText TagValue = "text" + TagValueLink TagValue = "a" +) + +// Response represents the API response from Lark. +type Response struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data any `json:"data"` +} diff --git a/pkg/services/lark/lark_service.go b/pkg/services/lark/lark_service.go new file mode 100644 index 0000000..a5f7dc7 --- /dev/null +++ b/pkg/services/lark/lark_service.go @@ -0,0 +1,237 @@ +package lark + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Constants for the Lark service configuration and limits. +const ( + apiFormat = "https://%s/open-apis/bot/v2/hook/%s" // API endpoint format + maxLength = 4096 // Maximum message length in bytes + defaultTime = 30 * time.Second // Default HTTP client timeout +) + +const ( + larkHost = "open.larksuite.com" + feishuHost = "open.feishu.cn" +) + +// Error variables for the Lark service. +var ( + ErrInvalidHost = errors.New("invalid host, use 'open.larksuite.com' or 'open.feishu.cn'") + ErrNoPath = errors.New( + "no path, path like 'xxx' in 'https://open.larksuite.com/open-apis/bot/v2/hook/xxx'", + ) + ErrLargeMessage = errors.New("message exceeds the max length") + ErrMissingHost = errors.New("host is required but not specified in the configuration") + ErrSendFailed = errors.New("failed to send notification to Lark") + ErrInvalidSignature = errors.New("failed to generate valid signature") +) + +// httpClient is configured with a default timeout. +var httpClient = &http.Client{Timeout: defaultTime} + +// Service sends notifications to Lark. +type Service struct { + standard.Standard + config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Lark. +func (service *Service) Send(message string, params *types.Params) error { + if len(message) > maxLength { + return ErrLargeMessage + } + + config := *service.config + if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { + return fmt.Errorf("updating params: %w", err) + } + + if config.Host != larkHost && config.Host != feishuHost { + return ErrInvalidHost + } + + if config.Path == "" { + return ErrNoPath + } + + return service.doSend(config, message, params) +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.config = &Config{} + service.pkr = format.NewPropKeyResolver(service.config) + + return service.config.SetURL(configURL) +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// doSend sends the notification to Lark using the configured API URL. +func (service *Service) doSend(config Config, message string, params *types.Params) error { + if config.Host == "" { + return ErrMissingHost + } + + postURL := fmt.Sprintf(apiFormat, config.Host, config.Path) + + payload, err := service.preparePayload(message, config, params) + if err != nil { + return err + } + + return service.sendRequest(postURL, payload) +} + +// preparePayload constructs and marshals the request payload for the Lark API. +func (service *Service) preparePayload( + message string, + config Config, + params *types.Params, +) ([]byte, error) { + body := service.getRequestBody(message, config.Title, config.Secret, params) + + data, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("marshaling payload to JSON: %w", err) + } + + service.Logf("Lark Request Body: %s", string(data)) + + return data, nil +} + +// sendRequest performs the HTTP POST request to the Lark API and handles the response. +func (service *Service) sendRequest(postURL string, payload []byte) error { + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + postURL, + bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("%w: making HTTP request: %w", ErrSendFailed, err) + } + defer resp.Body.Close() + + return service.handleResponse(resp) +} + +// handleResponse processes the API response and checks for errors. +func (service *Service) handleResponse(resp *http.Response) error { + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%w: unexpected status %s", ErrSendFailed, resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("reading response body: %w", err) + } + + var response Response + if err := json.Unmarshal(data, &response); err != nil { + return fmt.Errorf("unmarshaling response: %w", err) + } + + if response.Code != 0 { + return fmt.Errorf( + "%w: server returned code %d: %s", + ErrSendFailed, + response.Code, + response.Msg, + ) + } + + service.Logf( + "Notification sent successfully to %s/%s", + service.config.Host, + service.config.Path, + ) + + return nil +} + +// genSign generates a signature for the request using the secret and timestamp. +func (service *Service) genSign(secret string, timestamp int64) (string, error) { + stringToSign := fmt.Sprintf("%v\n%s", timestamp, secret) + + h := hmac.New(sha256.New, []byte(stringToSign)) + if _, err := h.Write([]byte{}); err != nil { + return "", fmt.Errorf("%w: computing HMAC: %w", ErrInvalidSignature, err) + } + + return base64.StdEncoding.EncodeToString(h.Sum(nil)), nil +} + +// getRequestBody constructs the request body for the Lark API, supporting rich content via params. +func (service *Service) getRequestBody( + message, title, secret string, + params *types.Params, +) *RequestBody { + body := &RequestBody{} + + if secret != "" { + ts := time.Now().Unix() + body.Timestamp = strconv.FormatInt(ts, 10) + + sign, err := service.genSign(secret, ts) + if err != nil { + sign = "" // Fallback to empty string on error + } + + body.Sign = sign + } + + if title == "" { + body.MsgType = MsgTypeText + body.Content.Text = message + } else { + body.MsgType = MsgTypePost + content := [][]Item{{{Tag: TagValueText, Text: message}}} + + if params != nil { + if link, ok := (*params)["link"]; ok && link != "" { + content = append(content, []Item{{Tag: TagValueLink, Text: "More Info", Link: link}}) + } + } + + body.Content.Post = &Post{ + En: &Message{ + Title: title, + Content: content, + }, + } + } + + return body +} diff --git a/pkg/services/lark/lark_test.go b/pkg/services/lark/lark_test.go new file mode 100644 index 0000000..4faeb02 --- /dev/null +++ b/pkg/services/lark/lark_test.go @@ -0,0 +1,215 @@ +package lark + +import ( + "errors" + "log" + "net/http" + "strings" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestLark(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Lark Suite") +} + +var ( + service *Service + logger *log.Logger + _ = ginkgo.BeforeSuite(func() { + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + }) +) + +const fullURL = "lark://open.larksuite.com/token?secret=sss" + +var _ = ginkgo.Describe("Lark Test", func() { + ginkgo.BeforeEach(func() { + service = &Service{} + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + url := testutils.URLMust(fullURL) + config := &Config{} + pkr := format.NewPropKeyResolver(config) + err := config.setURL(&pkr, url) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + outputURL := config.GetURL() + ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, fullURL) + gomega.Expect(outputURL.String()).To(gomega.Equal(fullURL)) + }) + }) + + ginkgo.Context("basic service API methods", func() { + var config *Config + ginkgo.BeforeEach(func() { + config = &Config{} + }) + ginkgo.It("should not allow getting invalid query values", func() { + testutils.TestConfigGetInvalidQueryValue(config) + }) + ginkgo.It("should not allow setting invalid query values", func() { + testutils.TestConfigSetInvalidQueryValue( + config, + "lark://endpoint/token?secret=sss&foo=bar", + ) + }) + ginkgo.It("should have the expected number of fields and enums", func() { + testutils.TestConfigGetEnumsCount(config, 0) + testutils.TestConfigGetFieldsCount(config, 3) + }) + }) + + ginkgo.When("initializing the service", func() { + ginkgo.It("should fail with invalid host", func() { + err := service.Initialize(testutils.URLMust("lark://invalid.com/token"), logger) + gomega.Expect(err).To(gomega.MatchError(ErrInvalidHost)) + }) + ginkgo.It("should fail with no path", func() { + err := service.Initialize(testutils.URLMust("lark://open.larksuite.com"), logger) + gomega.Expect(err).To(gomega.MatchError(ErrNoPath)) + }) + }) + + ginkgo.When("sending a message", func() { + ginkgo.When("the message is too large", func() { + ginkgo.It("should return large message error", func() { + data := make([]string, 410) + for i := range data { + data[i] = "0123456789" + } + message := strings.Join(data, "") + service := Service{config: &Config{Host: larkHost, Path: "token"}} + gomega.Expect(service.Send(message, nil)).To(gomega.MatchError(ErrLargeMessage)) + }) + }) + + ginkgo.When("an invalid param is passed", func() { + ginkgo.It("should fail to send messages", func() { + service := Service{config: &Config{Host: larkHost, Path: "token"}} + gomega.Expect( + service.Send("test message", &types.Params{"invalid": "value"}), + ).To(gomega.MatchError(gomega.ContainSubstring("not a valid config key: invalid"))) + }) + }) + + ginkgo.Context("sending message by HTTP", func() { + ginkgo.BeforeEach(func() { + httpmock.ActivateNonDefault(httpClient) + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("should send text message successfully", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewJsonResponderOrPanic( + http.StatusOK, + map[string]any{"code": 0, "msg": "success"}, + ), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", nil) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + }) + + ginkgo.It("should send post message with title successfully", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewJsonResponderOrPanic( + http.StatusOK, + map[string]any{"code": 0, "msg": "success"}, + ), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", &types.Params{"title": "title"}) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + }) + + ginkgo.It("should send post message with link successfully", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewJsonResponderOrPanic( + http.StatusOK, + map[string]any{"code": 0, "msg": "success"}, + ), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send( + "message", + &types.Params{"title": "title", "link": "https://example.com"}, + ) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + }) + + ginkgo.It("should return error on network failure", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewErrorResponder(errors.New("network error")), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", nil) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("network error"))) + }) + + ginkgo.It("should return error on invalid JSON response", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewStringResponder(http.StatusOK, "some response"), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", nil) + gomega.Expect(err). + To(gomega.MatchError(gomega.ContainSubstring("invalid character"))) + }) + + ginkgo.It("should return error on non-zero response code", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewJsonResponderOrPanic( + http.StatusOK, + map[string]any{"code": 1, "msg": "some error"}, + ), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", nil) + gomega.Expect(err).To(gomega.MatchError(gomega.ContainSubstring("some error"))) + }) + + ginkgo.It("should fail on HTTP 400 status", func() { + httpmock.RegisterResponder( + http.MethodPost, + "/open-apis/bot/v2/hook/token", + httpmock.NewStringResponder(http.StatusBadRequest, "bad request"), + ) + err := service.Initialize(testutils.URLMust(fullURL), logger) + gomega.Expect(err).ShouldNot(gomega.HaveOccurred()) + err = service.Send("message", nil) + gomega.Expect(err). + To(gomega.MatchError(gomega.ContainSubstring("unexpected status 400"))) + }) + }) + }) +}) diff --git a/pkg/services/logger/logger.go b/pkg/services/logger/logger.go new file mode 100644 index 0000000..b4430d8 --- /dev/null +++ b/pkg/services/logger/logger.go @@ -0,0 +1,61 @@ +package logger + +import ( + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Service is the Logger service struct. +type Service struct { + standard.Standard + Config *Config +} + +// Send a notification message to log. +func (service *Service) Send(message string, params *types.Params) error { + data := types.Params{} + + if params != nil { + for key, value := range *params { + data[key] = value + } + } + + data["message"] = message + + return service.doSend(data) +} + +func (service *Service) doSend(data types.Params) error { + msg := data["message"] + + if tpl, found := service.GetTemplate("message"); found { + wc := &strings.Builder{} + if err := tpl.Execute(wc, data); err != nil { + return fmt.Errorf("failed to write template to log: %w", err) + } + + msg = wc.String() + } + + service.Log(msg) + + return nil +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service. +func (service *Service) Initialize(_ *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} diff --git a/pkg/services/logger/logger_config.go b/pkg/services/logger/logger_config.go new file mode 100644 index 0000000..e819278 --- /dev/null +++ b/pkg/services/logger/logger_config.go @@ -0,0 +1,30 @@ +package logger + +import ( + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" +) + +const ( + // Scheme is the identifying part of this service's configuration URL. + Scheme = "logger" +) + +// Config is the configuration object for the Logger Service. +type Config struct { + standard.EnumlessConfig +} + +// GetURL returns a URL representation of it's current field values. +func (config *Config) GetURL() *url.URL { + return &url.URL{ + Scheme: Scheme, + Opaque: "//", // Ensures "logger://" output + } +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values. +func (config *Config) SetURL(_ *url.URL) error { + return nil +} diff --git a/pkg/services/logger/logger_suite_test.go b/pkg/services/logger/logger_suite_test.go new file mode 100644 index 0000000..8b9f2e0 --- /dev/null +++ b/pkg/services/logger/logger_suite_test.go @@ -0,0 +1,107 @@ +package logger_test + +import ( + "log" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/logger" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestLogger(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Logger Suite") +} + +var _ = ginkgo.Describe("the logger service", func() { + ginkgo.When("sending a notification", func() { + ginkgo.It("should output the message to the log", func() { + logbuf := gbytes.NewBuffer() + service := &logger.Service{} + _ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0)) + + err := service.Send(`Failed - Requires Toaster Repair Level 10`, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Eventually(logbuf). + Should(gbytes.Say("Failed - Requires Toaster Repair Level 10")) + }) + + ginkgo.It("should not mutate the passed params", func() { + service := &logger.Service{} + _ = service.Initialize(testutils.URLMust(`logger://`), nil) + params := types.Params{} + err := service.Send(`Failed - Requires Toaster Repair Level 10`, ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(params).To(gomega.BeEmpty()) + }) + + ginkgo.When("a template has been added", func() { + ginkgo.It("should render template with params", func() { + logbuf := gbytes.NewBuffer() + service := &logger.Service{} + _ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0)) + err := service.SetTemplateString(`message`, `{{.level}}: {{.message}}`) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + params := types.Params{ + "level": "warning", + } + err = service.Send(`Requires Toaster Repair Level 10`, ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Eventually(logbuf). + Should(gbytes.Say("warning: Requires Toaster Repair Level 10")) + }) + + ginkgo.It("should return an error if template execution fails", func() { + logbuf := gbytes.NewBuffer() + service := &logger.Service{} + _ = service.Initialize(testutils.URLMust(`logger://`), log.New(logbuf, "", 0)) + err := service.SetTemplateString( + `message`, + `{{range .message}}x{{end}} {{.message}}`, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + params := types.Params{ + "level": "error", + } + err = service.Send(`Critical Failure`, ¶ms) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("failed to write template to log")) + }) + }) + }) + + ginkgo.Describe("the config object", func() { + ginkgo.It("should return a URL with the correct scheme from GetURL", func() { + config := &logger.Config{} + url := config.GetURL() + gomega.Expect(url.Scheme).To(gomega.Equal("logger")) + gomega.Expect(url.String()).To(gomega.Equal("logger://")) + }) + + ginkgo.It("should not error when SetURL is called with a valid URL", func() { + config := &logger.Config{} + url := testutils.URLMust(`logger://`) + err := config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the service identifier", func() { + ginkgo.It("should return the correct ID", func() { + service := &logger.Service{} + id := service.GetID() + gomega.Expect(id).To(gomega.Equal("logger")) + }) + }) +}) diff --git a/pkg/services/matrix/matrix.go b/pkg/services/matrix/matrix.go new file mode 100644 index 0000000..f1f5448 --- /dev/null +++ b/pkg/services/matrix/matrix.go @@ -0,0 +1,79 @@ +package matrix + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme identifies this service in configuration URLs. +const Scheme = "matrix" + +// ErrClientNotInitialized indicates that the client is not initialized for sending messages. +var ErrClientNotInitialized = errors.New("client not initialized; cannot send message") + +// Service sends notifications via the Matrix protocol. +type Service struct { + standard.Standard + Config *Config + client *client + pkr format.PropKeyResolver +} + +// Initialize configures the service with a URL and logger. +func (s *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + s.SetLogger(logger) + s.Config = &Config{} + s.pkr = format.NewPropKeyResolver(s.Config) + + if err := s.Config.setURL(&s.pkr, configURL); err != nil { + return err + } + + if configURL.String() != "matrix://dummy@dummy.com" { + s.client = newClient(s.Config.Host, s.Config.DisableTLS, logger) + if s.Config.User != "" { + return s.client.login(s.Config.User, s.Config.Password) + } + + s.client.useToken(s.Config.Password) + } + + return nil +} + +// GetID returns the identifier for this service. +func (s *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to Matrix rooms. +func (s *Service) Send(message string, params *types.Params) error { + config := *s.Config + if err := s.pkr.UpdateConfigFromParams(&config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + if s.client == nil { + return ErrClientNotInitialized + } + + errors := s.client.sendMessage(message, s.Config.Rooms) + if len(errors) > 0 { + for _, err := range errors { + s.Logf("error sending message: %w", err) + } + + return fmt.Errorf( + "%v error(s) sending message, with initial error: %w", + len(errors), + errors[0], + ) + } + + return nil +} diff --git a/pkg/services/matrix/matrix_api.go b/pkg/services/matrix/matrix_api.go new file mode 100644 index 0000000..4c03d93 --- /dev/null +++ b/pkg/services/matrix/matrix_api.go @@ -0,0 +1,82 @@ +package matrix + +type ( + messageType string + flowType string + identifierType string +) + +const ( + apiLogin = "/_matrix/client/r0/login" + apiRoomJoin = "/_matrix/client/r0/join/%s" + apiSendMessage = "/_matrix/client/r0/rooms/%s/send/m.room.message" + apiJoinedRooms = "/_matrix/client/r0/joined_rooms" + + contentType = "application/json" + + accessTokenKey = "access_token" + + msgTypeText messageType = "m.text" + flowLoginPassword flowType = "m.login.password" + idTypeUser identifierType = "m.id.user" +) + +type apiResLoginFlows struct { + Flows []flow `json:"flows"` +} + +type apiReqLogin struct { + Type flowType `json:"type"` + Identifier *identifier `json:"identifier"` + Password string `json:"password,omitempty"` + Token string `json:"token,omitempty"` +} + +type apiResLogin struct { + AccessToken string `json:"access_token"` + HomeServer string `json:"home_server"` + UserID string `json:"user_id"` + DeviceID string `json:"device_id"` +} + +type apiReqSend struct { + MsgType messageType `json:"msgtype"` + Body string `json:"body"` +} + +type apiResRoom struct { + RoomID string `json:"room_id"` +} + +type apiResJoinedRooms struct { + Rooms []string `json:"joined_rooms"` +} + +type apiResEvent struct { + EventID string `json:"event_id"` +} + +type apiResError struct { + Message string `json:"error"` + Code string `json:"errcode"` +} + +func (e *apiResError) Error() string { + return e.Message +} + +type flow struct { + Type flowType `json:"type"` +} + +type identifier struct { + Type identifierType `json:"type"` + User string `json:"user,omitempty"` +} + +func newUserIdentifier(user string) *identifier { + return &identifier{ + Type: idTypeUser, + User: user, + } +} diff --git a/pkg/services/matrix/matrix_client.go b/pkg/services/matrix/matrix_client.go new file mode 100644 index 0000000..2f5914d --- /dev/null +++ b/pkg/services/matrix/matrix_client.go @@ -0,0 +1,316 @@ +package matrix + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// schemeHTTPPrefixLength is the length of "http" in "https", used to strip TLS suffix. +const ( + schemeHTTPPrefixLength = 4 + tokenHintLength = 3 + minSliceLength = 1 + httpClientErrorStatus = 400 + defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the timeout for HTTP requests. +) + +// ErrUnsupportedLoginFlows indicates that none of the server login flows are supported. +var ( + ErrUnsupportedLoginFlows = errors.New("none of the server login flows are supported") + ErrUnexpectedStatus = errors.New("unexpected HTTP status") +) + +// client manages interactions with the Matrix API. +type client struct { + apiURL url.URL + accessToken string + logger types.StdLogger + httpClient *http.Client +} + +// newClient creates a new Matrix client with the specified host and TLS settings. +func newClient(host string, disableTLS bool, logger types.StdLogger) *client { + client := &client{ + logger: logger, + apiURL: url.URL{ + Host: host, + Scheme: "https", + }, + httpClient: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + } + + if client.logger == nil { + client.logger = util.DiscardLogger + } + + if disableTLS { + client.apiURL.Scheme = client.apiURL.Scheme[:schemeHTTPPrefixLength] // "https" -> "http" + } + + client.logger.Printf("Using server: %v\n", client.apiURL.String()) + + return client +} + +// useToken sets the access token for the client. +func (c *client) useToken(token string) { + c.accessToken = token + c.updateAccessToken() +} + +// login authenticates the client using a username and password. +func (c *client) login(user string, password string) error { + c.apiURL.RawQuery = "" + defer c.updateAccessToken() + + resLogin := apiResLoginFlows{} + if err := c.apiGet(apiLogin, &resLogin); err != nil { + return fmt.Errorf("failed to get login flows: %w", err) + } + + flows := make([]string, 0, len(resLogin.Flows)) + for _, flow := range resLogin.Flows { + flows = append(flows, string(flow.Type)) + + if flow.Type == flowLoginPassword { + c.logf("Using login flow '%v'", flow.Type) + + return c.loginPassword(user, password) + } + } + + return fmt.Errorf("%w: %v", ErrUnsupportedLoginFlows, strings.Join(flows, ", ")) +} + +// loginPassword performs a password-based login to the Matrix server. +func (c *client) loginPassword(user string, password string) error { + response := apiResLogin{} + if err := c.apiPost(apiLogin, apiReqLogin{ + Type: flowLoginPassword, + Password: password, + Identifier: newUserIdentifier(user), + }, &response); err != nil { + return fmt.Errorf("failed to log in: %w", err) + } + + c.accessToken = response.AccessToken + + tokenHint := "" + if len(response.AccessToken) > tokenHintLength { + tokenHint = response.AccessToken[:tokenHintLength] + } + + c.logf("AccessToken: %v...\n", tokenHint) + c.logf("HomeServer: %v\n", response.HomeServer) + c.logf("User: %v\n", response.UserID) + + return nil +} + +// sendMessage sends a message to the specified rooms or all joined rooms if none are specified. +func (c *client) sendMessage(message string, rooms []string) []error { + if len(rooms) >= minSliceLength { + return c.sendToExplicitRooms(rooms, message) + } + + return c.sendToJoinedRooms(message) +} + +// sendToExplicitRooms sends a message to explicitly specified rooms and collects any errors. +func (c *client) sendToExplicitRooms(rooms []string, message string) []error { + var errors []error + + for _, room := range rooms { + c.logf("Sending message to '%v'...\n", room) + + roomID, err := c.joinRoom(room) + if err != nil { + errors = append(errors, fmt.Errorf("error joining room %v: %w", roomID, err)) + + continue + } + + if room != roomID { + c.logf("Resolved room alias '%v' to ID '%v'", room, roomID) + } + + if err := c.sendMessageToRoom(message, roomID); err != nil { + errors = append( + errors, + fmt.Errorf("failed to send message to room '%v': %w", roomID, err), + ) + } + } + + return errors +} + +// sendToJoinedRooms sends a message to all joined rooms and collects any errors. +func (c *client) sendToJoinedRooms(message string) []error { + var errors []error + + joinedRooms, err := c.getJoinedRooms() + if err != nil { + return append(errors, fmt.Errorf("failed to get joined rooms: %w", err)) + } + + for _, roomID := range joinedRooms { + c.logf("Sending message to '%v'...\n", roomID) + + if err := c.sendMessageToRoom(message, roomID); err != nil { + errors = append( + errors, + fmt.Errorf("failed to send message to room '%v': %w", roomID, err), + ) + } + } + + return errors +} + +// joinRoom joins a specified room and returns its ID. +func (c *client) joinRoom(room string) (string, error) { + resRoom := apiResRoom{} + if err := c.apiPost(fmt.Sprintf(apiRoomJoin, room), nil, &resRoom); err != nil { + return "", err + } + + return resRoom.RoomID, nil +} + +// sendMessageToRoom sends a message to a specific room. +func (c *client) sendMessageToRoom(message string, roomID string) error { + resEvent := apiResEvent{} + + return c.apiPost(fmt.Sprintf(apiSendMessage, roomID), apiReqSend{ + MsgType: msgTypeText, + Body: message, + }, &resEvent) +} + +// apiGet performs a GET request to the Matrix API. +func (c *client) apiGet(path string, response any) error { + c.apiURL.Path = path + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL.String(), nil) + if err != nil { + return fmt.Errorf("creating GET request: %w", err) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing GET request: %w", err) + } + + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("reading GET response body: %w", err) + } + + if res.StatusCode >= httpClientErrorStatus { + resError := &apiResError{} + if err = json.Unmarshal(body, resError); err == nil { + return resError + } + + return fmt.Errorf("%w: %v (unmarshal error: %w)", ErrUnexpectedStatus, res.Status, err) + } + + if err = json.Unmarshal(body, response); err != nil { + return fmt.Errorf("unmarshaling GET response: %w", err) + } + + return nil +} + +// apiPost performs a POST request to the Matrix API. +func (c *client) apiPost(path string, request any, response any) error { + c.apiURL.Path = path + + body, err := json.Marshal(request) + if err != nil { + return fmt.Errorf("marshaling POST request: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + c.apiURL.String(), + bytes.NewReader(body), + ) + if err != nil { + return fmt.Errorf("creating POST request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing POST request: %w", err) + } + + defer res.Body.Close() + + body, err = io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("reading POST response body: %w", err) + } + + if res.StatusCode >= httpClientErrorStatus { + resError := &apiResError{} + if err = json.Unmarshal(body, resError); err == nil { + return resError + } + + return fmt.Errorf("%w: %v (unmarshal error: %w)", ErrUnexpectedStatus, res.Status, err) + } + + if err = json.Unmarshal(body, response); err != nil { + return fmt.Errorf("unmarshaling POST response: %w", err) + } + + return nil +} + +// updateAccessToken updates the API URL query with the current access token. +func (c *client) updateAccessToken() { + query := c.apiURL.Query() + query.Set(accessTokenKey, c.accessToken) + c.apiURL.RawQuery = query.Encode() +} + +// logf logs a formatted message using the client's logger. +func (c *client) logf(format string, v ...any) { + c.logger.Printf(format, v...) +} + +// getJoinedRooms retrieves the list of rooms the client has joined. +func (c *client) getJoinedRooms() ([]string, error) { + response := apiResJoinedRooms{} + if err := c.apiGet(apiJoinedRooms, &response); err != nil { + return []string{}, err + } + + return response.Rooms, nil +} diff --git a/pkg/services/matrix/matrix_config.go b/pkg/services/matrix/matrix_config.go new file mode 100644 index 0000000..d01c747 --- /dev/null +++ b/pkg/services/matrix/matrix_config.go @@ -0,0 +1,68 @@ +package matrix + +import ( + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Config is the configuration for the matrix service. +type Config struct { + standard.EnumlessConfig + + User string `desc:"Username or empty when using access token" optional:"" url:"user"` + Password string `desc:"Password or access token" url:"password"` + DisableTLS bool ` default:"No" key:"disableTLS"` + Host string ` url:"host"` + Rooms []string `desc:"Room aliases, or with ! prefix, room IDs" optional:"" key:"rooms,room"` + Title string ` default:"" key:"title"` +} + +// GetURL returns a URL representation of it's current field values. +func (c *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(c) + + return c.getURL(&resolver) +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values. +func (c *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(c) + + return c.setURL(&resolver, url) +} + +func (c *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword(c.User, c.Password), + Host: c.Host, + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} + +func (c *Config) setURL(resolver types.ConfigQueryResolver, configURL *url.URL) error { + c.User = configURL.User.Username() + password, _ := configURL.User.Password() + c.Password = password + c.Host = configURL.Host + + for key, vals := range configURL.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + for r, room := range c.Rooms { + // If room does not begin with a '#' let's prepend it + if room[0] != '#' && room[0] != '!' { + c.Rooms[r] = "#" + room + } + } + + return nil +} diff --git a/pkg/services/matrix/matrix_test.go b/pkg/services/matrix/matrix_test.go new file mode 100644 index 0000000..edf86dc --- /dev/null +++ b/pkg/services/matrix/matrix_test.go @@ -0,0 +1,676 @@ +package matrix + +import ( + "errors" + "fmt" + "log" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" +) + +func TestMatrix(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Matrix Suite") +} + +var _ = ginkgo.Describe("the matrix service", func() { + var service *Service + logger := log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + envMatrixURL := os.Getenv("SHOUTRRR_MATRIX_URL") + + ginkgo.BeforeEach(func() { + service = &Service{} + }) + + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (full initialization with logger and scheme) + // - 63-65: login (via Initialize when User is set) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (via Send with real server) + // - 156-173: sendMessageToRoom (sending to joined rooms) + if envMatrixURL == "" { + return + } + serviceURL, err := url.Parse(envMatrixURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("creating configurations", func() { + ginkgo.When("given an url with title prop", func() { + ginkgo.It("should not throw an error", func() { + // Tests matrix_config.go, not matrix_client.go directly + // Related to Config.SetURL, which feeds into client setup later + serviceURL := testutils.URLMust( + `matrix://user:pass@mockserver?rooms=room1&title=Better%20Off%20Alone`, + ) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed()) + }) + }) + + ginkgo.When("given an url with the prop `room`", func() { + ginkgo.It("should treat is as an alias for `rooms`", func() { + // Tests matrix_config.go, not matrix_client.go directly + // Configures Rooms for client.sendToExplicitRooms later + serviceURL := testutils.URLMust(`matrix://user:pass@mockserver?room=room1`) + config := Config{} + gomega.Expect(config.SetURL(serviceURL)).To(gomega.Succeed()) + gomega.Expect(config.Rooms).To(gomega.ContainElement("#room1")) + }) + }) + ginkgo.When("given an url with invalid props", func() { + ginkgo.It("should return an error", func() { + // Tests matrix_config.go, not matrix_client.go directly + // Ensures invalid params fail before reaching client + serviceURL := testutils.URLMust( + `matrix://user:pass@mockserver?channels=room1,room2`, + ) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + // Tests matrix_config.go, not matrix_client.go directly + // Verifies Config.GetURL/SetURL round-trip for client init + testURL := "matrix://user:pass@mockserver?rooms=%23room1%2C%23room2" + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + config := &Config{} + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.Describe("the matrix client", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + + ginkgo.When("not providing a logger", func() { + ginkgo.It("should not crash", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (sets DiscardLogger when logger is nil) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + gomega.Expect(service.Initialize(serviceURL, nil)).To(gomega.Succeed()) + }) + }) + + ginkgo.When("sending a message", func() { + ginkgo.It("should not report any errors", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToJoinedRooms) + // - 134-153: sendToJoinedRooms (sends to joined rooms) + // - 156-173: sendMessageToRoom (successful send) + // - 225-242: getJoinedRooms (fetches room list) + setupMockResponders() + serviceURL, _ := url.Parse("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("sending a message to explicit rooms", func() { + ginkgo.It("should not report any errors", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToExplicitRooms) + // - 112-133: sendToExplicitRooms (sends to explicit rooms) + // - 177-192: joinRoom (joins rooms successfully) + // - 156-173: sendMessageToRoom (successful send) + setupMockResponders() + serviceURL, _ := url.Parse("matrix://user:pass@mockserver?rooms=room1,room2") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.When("sending to one room fails", func() { + ginkgo.It("should report one error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToExplicitRooms) + // - 112-133: sendToExplicitRooms (handles join failure) + // - 177-192: joinRoom (fails for "secret" room) + // - 156-173: sendMessageToRoom (succeeds for "room2") + setupMockResponders() + serviceURL, _ := url.Parse("matrix://user:pass@mockserver?rooms=secret,room2") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) + + ginkgo.When("disabling TLS", func() { + ginkgo.It("should use HTTP instead of HTTPS", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (specifically line 50: c.apiURL.Scheme = c.apiURL.Scheme[:schemeHTTPPrefixLength]) + // - 63-65: login (successful initialization over HTTP) + // - 76-87: loginPassword (successful login flow) + setupMockRespondersHTTP() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver?disableTLS=yes") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.client.apiURL.Scheme).To(gomega.Equal("http")) + }) + }) + + ginkgo.When("failing to get login flows", func() { + ginkgo.It("should return an error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-69: login (specifically line 69: return fmt.Errorf("failed to get login flows: %w", err)) + // - 175-223: apiGet (returns error due to 500 response) + setupMockRespondersLoginFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get login flows")) + }) + }) + + ginkgo.When("no supported login flows are available", func() { + ginkgo.It("should return an error with unsupported flows", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-87: login (specifically line 84: return fmt.Errorf("none of the server login flows are supported: %v", strings.Join(flows, ", "))) + // - 175-223: apiGet (successful GET with unsupported flows) + setupMockRespondersUnsupportedFlows() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.Equal("none of the server login flows are supported: m.login.dummy")) + }) + }) + + ginkgo.When("using a token instead of login", func() { + ginkgo.It("should initialize without errors", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 59-60: useToken (sets token and calls updateAccessToken) + // - 244-248: updateAccessToken (updates URL query with token) + setupMockResponders() // Minimal mocks for initialization + serviceURL := testutils.URLMust("matrix://:token@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.client.accessToken).To(gomega.Equal("token")) + gomega.Expect(service.client.apiURL.RawQuery).To(gomega.Equal("access_token=token")) + }) + }) + + ginkgo.When("failing to get joined rooms", func() { + ginkgo.It("should return an error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToJoinedRooms) + // - 134-154: sendToJoinedRooms (specifically lines 137 and 154: error handling for getJoinedRooms failure) + // - 225-267: getJoinedRooms (specifically line 267: return []string{}, err) + setupMockRespondersJoinedRoomsFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get joined rooms")) + }) + }) + + ginkgo.When("failing to join a room", func() { + ginkgo.It("should skip to the next room and continue", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToExplicitRooms) + // - 112-133: sendToExplicitRooms (specifically line 147: continue on join failure) + // - 177-192: joinRoom (specifically line 188: return "", err on failure) + // - 156-173: sendMessageToRoom (succeeds for second room) + setupMockRespondersJoinFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=secret,room2") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("error joining room")) + }) + }) + + ginkgo.When("failing to marshal request in apiPost", func() { + ginkgo.It("should return an error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 195-252: apiPost (specifically line 208: body, err = json.Marshal(request) fails) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.client.apiPost("/test/path", make(chan int), nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("json: unsupported type: chan int")) + }) + }) + + ginkgo.When("failing to read response body in apiPost", func() { + ginkgo.It("should return an error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToJoinedRooms) + // - 134-153: sendToJoinedRooms (calls sendMessageToRoom) + // - 156-173: sendMessageToRoom (calls apiPost) + // - 195-252: apiPost (specifically lines 204, 223, 230: res handling and body read failure) + setupMockRespondersBodyFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("failed to read response body")) + }) + }) + + ginkgo.When("routing to explicit rooms at line 94", func() { + ginkgo.It("should use sendToExplicitRooms", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (specifically line 94: if len(rooms) >= minSliceLength { true branch) + // - 112-133: sendToExplicitRooms (sends to explicit rooms) + // - 177-192: joinRoom (joins rooms successfully) + // - 156-173: sendMessageToRoom (successful send) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=room1") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("routing to joined rooms at line 94", func() { + ginkgo.It("should use sendToJoinedRooms", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (specifically line 94: if len(rooms) >= minSliceLength { false branch) + // - 134-153: sendToJoinedRooms (sends to joined rooms) + // - 156-173: sendMessageToRoom (successful send) + // - 225-242: getJoinedRooms (fetches room list) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("appending joined rooms error at line 137", func() { + ginkgo.It("should append the error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToJoinedRooms) + // - 134-154: sendToJoinedRooms (specifically line 137: errors = append(errors, fmt.Errorf("failed to get joined rooms: %w", err))) + // - 225-267: getJoinedRooms (returns error) + setupMockRespondersJoinedRoomsFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("failed to get joined rooms")) + }) + }) + + ginkgo.When("failing to join room at line 188", func() { + ginkgo.It("should return join error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToExplicitRooms) + // - 112-133: sendToExplicitRooms (calls joinRoom) + // - 177-192: joinRoom (specifically line 188: return "", err) + setupMockRespondersJoinFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver?rooms=secret") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("error joining room")) + }) + }) + + ginkgo.When("declaring response variable at line 204", func() { + ginkgo.It("should handle HTTP failure", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 195-252: apiPost (specifically line 204: var res *http.Response and error handling) + setupMockRespondersPostFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.client.apiPost( + "/test/path", + apiReqSend{MsgType: msgTypeText, Body: "test"}, + nil, + ) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("simulated HTTP failure")) + }) + }) + + ginkgo.When("marshaling request fails at line 208", func() { + ginkgo.It("should return marshal error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 195-252: apiPost (specifically line 208: body, err = json.Marshal(request)) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.client.apiPost("/test/path", make(chan int), nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("json: unsupported type: chan int")) + }) + }) + + ginkgo.When("getting query at line 244", func() { + ginkgo.It("should update token in URL", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 59-60: useToken (calls updateAccessToken) + // - 244-248: updateAccessToken (specifically line 244: query := c.apiURL.Query()) + setupMockResponders() + serviceURL := testutils.URLMust("matrix://:token@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.client.apiURL.RawQuery).To(gomega.Equal("access_token=token")) + service.client.useToken("newtoken") + gomega.Expect(service.client.apiURL.RawQuery). + To(gomega.Equal("access_token=newtoken")) + }) + }) + + ginkgo.When("checking body read error at line 251", func() { + ginkgo.It("should return read error", func() { + // Tests matrix_client.go lines: + // - 36-52: newClient (successful setup) + // - 63-65: login (successful initialization) + // - 76-87: loginPassword (successful login flow) + // - 91-108: sendMessage (routes to sendToJoinedRooms) + // - 134-153: sendToJoinedRooms (calls sendMessageToRoom) + // - 156-173: sendMessageToRoom (calls apiPost) + // - 195-252: apiPost (specifically line 251: if err != nil { after io.ReadAll) + setupMockRespondersBodyFail() + serviceURL := testutils.URLMust("matrix://user:pass@mockserver") + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("Test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("failed to read response body")) + }) + }) + + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + }) + + ginkgo.It("should implement basic service API methods correctly", func() { + // Tests matrix_config.go, not matrix_client.go directly + // Exercises Config methods used indirectly by client initialization + testutils.TestConfigGetInvalidQueryValue(&Config{}) + testutils.TestConfigSetInvalidQueryValue(&Config{}, "matrix://user:pass@host/?foo=bar") + testutils.TestConfigGetEnumsCount(&Config{}, 0) + testutils.TestConfigGetFieldsCount(&Config{}, 4) + }) + + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("matrix")) + }) +}) + +// setupMockResponders for HTTPS. +func setupMockResponders() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder( + "GET", + mockServer+apiJoinedRooms, + httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`)) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"), + httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "7"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "1"), + httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "8"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "2"), + httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "9"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room1"), + httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "1"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room2"), + httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "2"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23secret"), + httpmock.NewJsonResponderOrPanic(403, apiResError{ + Code: "M_FORBIDDEN", + Message: "You are not invited to this room.", + })) +} + +// setupMockRespondersHTTP for HTTP. +func setupMockRespondersHTTP() { + const mockServer = "http://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder( + "GET", + mockServer+apiJoinedRooms, + httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`)) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"), + httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "7"})) +} + +// setupMockRespondersLoginFail for testing line 69. +func setupMockRespondersLoginFail() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(500, `{"error": "Internal Server Error"}`)) +} + +// setupMockRespondersUnsupportedFlows for testing line 84. +func setupMockRespondersUnsupportedFlows() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.dummy" } ] }`)) +} + +// setupMockRespondersJoinedRoomsFail for testing lines 137, 154, and 267. +func setupMockRespondersJoinedRoomsFail() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder( + "GET", + mockServer+apiJoinedRooms, + httpmock.NewStringResponder(500, `{"error": "Internal Server Error"}`)) +} + +// setupMockRespondersJoinFail for testing lines 147 and 188. +func setupMockRespondersJoinFail() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23secret"), + httpmock.NewJsonResponderOrPanic(403, apiResError{ + Code: "M_FORBIDDEN", + Message: "You are not invited to this room.", + })) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiRoomJoin, "%23room2"), + httpmock.NewJsonResponderOrPanic(200, apiResRoom{RoomID: "2"})) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "2"), + httpmock.NewJsonResponderOrPanic(200, apiResEvent{EventID: "9"})) +} + +// setupMockRespondersBodyFail for testing lines 204, 223, and 230. +func setupMockRespondersBodyFail() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder( + "GET", + mockServer+apiJoinedRooms, + httpmock.NewStringResponder(200, `{ "joined_rooms": [ "!room:mockserver" ] }`)) + + httpmock.RegisterResponder("POST", mockServer+fmt.Sprintf(apiSendMessage, "%21room:mockserver"), + httpmock.NewErrorResponder(errors.New("failed to read response body"))) +} + +// setupMockRespondersPostFail for testing line 204 and HTTP failure. +func setupMockRespondersPostFail() { + const mockServer = "https://mockserver" + + httpmock.RegisterResponder( + "GET", + mockServer+apiLogin, + httpmock.NewStringResponder(200, `{"flows": [ { "type": "m.login.password" } ] }`)) + + httpmock.RegisterResponder( + "POST", + mockServer+apiLogin, + httpmock.NewStringResponder( + 200, + `{ "access_token": "TOKEN", "home_server": "mockserver", "user_id": "test:mockerserver" }`, + ), + ) + + httpmock.RegisterResponder("POST", mockServer+"/test/path", + httpmock.NewErrorResponder(errors.New("simulated HTTP failure"))) +} diff --git a/pkg/services/mattermost/mattermost.go b/pkg/services/mattermost/mattermost.go new file mode 100644 index 0000000..eb0ec49 --- /dev/null +++ b/pkg/services/mattermost/mattermost.go @@ -0,0 +1,116 @@ +package mattermost + +import ( + "bytes" + "context" + "crypto/tls" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// defaultHTTPTimeout is the default timeout for HTTP requests. +const defaultHTTPTimeout = 10 * time.Second + +// ErrSendFailed indicates that the notification failed due to an unexpected response status code. +var ErrSendFailed = errors.New( + "failed to send notification to service, response status code unexpected", +) + +// Service sends notifications to a pre-configured Mattermost channel or user. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver + httpClient *http.Client +} + +// GetHTTPClient returns the service's HTTP client for testing purposes. +func (service *Service) GetHTTPClient() *http.Client { + return service.httpClient +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + err := service.Config.setURL(&service.pkr, configURL) + if err != nil { + return err + } + + var transport *http.Transport + if service.Config.DisableTLS { + transport = &http.Transport{ + TLSClientConfig: nil, // Plain HTTP + } + } else { + transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, // Explicitly safe when TLS is enabled + MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher + }, + } + } + + service.httpClient = &http.Client{Transport: transport} + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to Mattermost. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + apiURL := buildURL(config) + + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + json, _ := CreateJSONPayload(config, message, params) + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(json)) + if err != nil { + return fmt.Errorf("creating POST request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + res, err := service.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing POST request to Mattermost API: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s", ErrSendFailed, res.Status) + } + + return nil +} + +// buildURL constructs the API URL for Mattermost based on the Config. +func buildURL(config *Config) string { + scheme := "https" + if config.DisableTLS { + scheme = "http" + } + + return fmt.Sprintf("%s://%s/hooks/%s", scheme, config.Host, config.Token) +} diff --git a/pkg/services/mattermost/mattermost_config.go b/pkg/services/mattermost/mattermost_config.go new file mode 100644 index 0000000..74b2876 --- /dev/null +++ b/pkg/services/mattermost/mattermost_config.go @@ -0,0 +1,121 @@ +package mattermost + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifying part of this service's configuration URL. +const Scheme = "mattermost" + +// Static errors for configuration validation. +var ( + ErrNotEnoughArguments = errors.New( + "the apiURL does not include enough arguments, either provide 1 or 3 arguments (they may be empty)", + ) +) + +// ErrorMessage represents error events within the Mattermost service. +type ErrorMessage string + +// Config holds all configuration information for the Mattermost service. +type Config struct { + standard.EnumlessConfig + UserName string `desc:"Override webhook user" optional:"" url:"user"` + Icon string `desc:"Use emoji or URL as icon (based on presence of http(s):// prefix)" optional:"" default:"" key:"icon,icon_emoji,icon_url"` + Title string `desc:"Notification title, optionally set by the sender (not used)" default:"" key:"title"` + Channel string `desc:"Override webhook channel" optional:"" url:"path2"` + Host string `desc:"Mattermost server host" url:"host,port"` + Token string `desc:"Webhook token" url:"path1"` + DisableTLS bool ` default:"No" key:"disabletls"` +} + +// CreateConfigFromURL creates a new Config instance from a URL representation. +func CreateConfigFromURL(url *url.URL) (*Config, error) { + config := &Config{} + if err := config.SetURL(url); err != nil { + return nil, err + } + + return config, nil +} + +// GetURL returns a URL representation of the Config's current field values. +func (c *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(c) + + return c.getURL(&resolver) // Pass pointer to resolver +} + +// SetURL updates the Config from a URL representation of its field values. +func (c *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(c) + + return c.setURL(&resolver, url) // Pass pointer to resolver +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (c *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + paths := []string{"", c.Token, c.Channel} + if c.Channel == "" { + paths = paths[:2] + } + + var user *url.Userinfo + if c.UserName != "" { + user = url.User(c.UserName) + } + + return &url.URL{ + User: user, + Host: c.Host, + Path: strings.Join(paths, "/"), + Scheme: Scheme, + ForceQuery: false, + RawQuery: format.BuildQuery(resolver), + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (c *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + c.Host = url.Host + c.UserName = url.User.Username() + + if err := c.parsePath(url); err != nil { + return err + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + return nil +} + +// parsePath extracts Token and Channel from the URL path and validates arguments. +func (c *Config) parsePath(url *url.URL) error { + path := strings.Split(strings.Trim(url.Path, "/"), "/") + isDummy := url.String() == "mattermost://dummy@dummy.com" + + if !isDummy && (len(path) < 1 || path[0] == "") { + return ErrNotEnoughArguments + } + + if len(path) > 0 && path[0] != "" { + c.Token = path[0] + } + + if len(path) > 1 && path[1] != "" { + c.Channel = path[1] + } + + return nil +} diff --git a/pkg/services/mattermost/mattermost_json.go b/pkg/services/mattermost/mattermost_json.go new file mode 100644 index 0000000..42189b7 --- /dev/null +++ b/pkg/services/mattermost/mattermost_json.go @@ -0,0 +1,63 @@ +package mattermost + +import ( + "encoding/json" + "fmt" // Add this import + "regexp" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// iconURLPattern matches URLs starting with http or https for icon detection. +var iconURLPattern = regexp.MustCompile(`https?://`) + +// JSON represents the payload structure for Mattermost notifications. +type JSON struct { + Text string `json:"text"` + UserName string `json:"username,omitempty"` + Channel string `json:"channel,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` +} + +// SetIcon sets the appropriate icon field in the payload based on whether the input is a URL or not. +func (j *JSON) SetIcon(icon string) { + j.IconURL = "" + j.IconEmoji = "" + + if icon != "" { + if iconURLPattern.MatchString(icon) { + j.IconURL = icon + } else { + j.IconEmoji = icon + } + } +} + +// CreateJSONPayload generates a JSON payload for the Mattermost service. +func CreateJSONPayload(config *Config, message string, params *types.Params) ([]byte, error) { + payload := JSON{ + Text: message, + UserName: config.UserName, + Channel: config.Channel, + } + + if params != nil { + if value, found := (*params)["username"]; found { + payload.UserName = value + } + + if value, found := (*params)["channel"]; found { + payload.Channel = value + } + } + + payload.SetIcon(config.Icon) + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshaling Mattermost payload to JSON: %w", err) + } + + return payloadBytes, nil +} diff --git a/pkg/services/mattermost/mattermost_test.go b/pkg/services/mattermost/mattermost_test.go new file mode 100644 index 0000000..40b476b --- /dev/null +++ b/pkg/services/mattermost/mattermost_test.go @@ -0,0 +1,440 @@ +package mattermost + +import ( + "fmt" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var ( + service *Service + envMattermostURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &Service{} + envMattermostURL, _ = url.Parse(os.Getenv("SHOUTRRR_MATTERMOST_URL")) + }) +) + +func TestMattermost(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Mattermost Suite") +} + +var _ = ginkgo.Describe("the mattermost service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should work without errors", func() { + if envMattermostURL.String() == "" { + return + } + serviceURL, _ := url.Parse(envMattermostURL.String()) + gomega.Expect(service.Initialize(serviceURL, testutils.TestLogger())). + To(gomega.Succeed()) + err := service.Send( + "this is an integration test", + nil, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + ginkgo.Describe("the mattermost config", func() { + ginkgo.When("generating a config object", func() { + mattermostURL, _ := url.Parse( + "mattermost://mattermost.my-domain.com/thisshouldbeanapitoken", + ) + config := &Config{} + err := config.SetURL(mattermostURL) + ginkgo.It("should not have caused an error", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should set host", func() { + gomega.Expect(config.Host).To(gomega.Equal("mattermost.my-domain.com")) + }) + ginkgo.It("should set token", func() { + gomega.Expect(config.Token).To(gomega.Equal("thisshouldbeanapitoken")) + }) + ginkgo.It("should not set channel or username", func() { + gomega.Expect(config.Channel).To(gomega.BeEmpty()) + gomega.Expect(config.UserName).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("generating a new config with url, that has no token", func() { + ginkgo.It("should return an error", func() { + mattermostURL, _ := url.Parse("mattermost://mattermost.my-domain.com") + config := &Config{} + err := config.SetURL(mattermostURL) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("generating a config object with username only", func() { + mattermostURL, _ := url.Parse( + "mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken", + ) + config := &Config{} + err := config.SetURL(mattermostURL) + ginkgo.It("should not have caused an error", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should set username", func() { + gomega.Expect(config.UserName).To(gomega.Equal("testUserName")) + }) + ginkgo.It("should not set channel", func() { + gomega.Expect(config.Channel).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("generating a config object with channel only", func() { + mattermostURL, _ := url.Parse( + "mattermost://mattermost.my-domain.com/thisshouldbeanapitoken/testChannel", + ) + config := &Config{} + err := config.SetURL(mattermostURL) + ginkgo.It("should not hav caused an error", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should set channel", func() { + gomega.Expect(config.Channel).To(gomega.Equal("testChannel")) + }) + ginkgo.It("should not set username", func() { + gomega.Expect(config.UserName).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("generating a config object with channel an userName", func() { + mattermostURL, _ := url.Parse( + "mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel", + ) + config := &Config{} + err := config.SetURL(mattermostURL) + ginkgo.It("should not hav caused an error", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should set channel", func() { + gomega.Expect(config.Channel).To(gomega.Equal("testChannel")) + }) + ginkgo.It("should set username", func() { + gomega.Expect(config.UserName).To(gomega.Equal("testUserName")) + }) + }) + ginkgo.When("using DisableTLS and port", func() { + mattermostURL, _ := url.Parse( + "mattermost://watchtower@home.lan:8065/token/channel?disabletls=yes", + ) + config := &Config{} + gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed()) + ginkgo.It("should preserve host with port", func() { + gomega.Expect(config.Host).To(gomega.Equal("home.lan:8065")) + }) + ginkgo.It("should set DisableTLS", func() { + gomega.Expect(config.DisableTLS).To(gomega.BeTrue()) + }) + ginkgo.It("should generate http URL", func() { + gomega.Expect(buildURL(config)).To(gomega.Equal("http://home.lan:8065/hooks/token")) + }) + ginkgo.It("should serialize back correctly", func() { + gomega.Expect(config.GetURL().String()). + To(gomega.Equal("mattermost://watchtower@home.lan:8065/token/channel?disabletls=Yes")) + }) + }) + ginkgo.Describe("initializing with DisableTLS", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should use plain HTTP transport when DisableTLS is true", func() { + mattermostURL, _ := url.Parse("mattermost://user@host:8080/token?disabletls=yes") + service := &Service{} + err := service.Initialize(mattermostURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.ActivateNonDefault(service.httpClient) + httpmock.RegisterResponder( + "POST", + "http://host:8080/hooks/token", + httpmock.NewStringResponder(200, ""), + ) + + err = service.Send("Test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(buildURL(service.Config)). + To(gomega.Equal("http://host:8080/hooks/token")) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + config := Config{ + Host: "mattermost.host", + Token: "token", + } + serviceURL := config.GetURL() + service := Service{} + err = service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.ActivateNonDefault(service.httpClient) + httpmock.RegisterResponder( + "POST", + "https://mattermost.host/hooks/token", + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should return an error if the server rejects the payload", func() { + config := Config{ + Host: "mattermost.host", + Token: "token", + } + serviceURL := config.GetURL() + service := Service{} + err = service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.ActivateNonDefault(service.httpClient) + httpmock.RegisterResponder( + "POST", + "https://mattermost.host/hooks/token", + httpmock.NewStringResponder(403, "Forbidden"), + ) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("failed to send notification to service")) + resp := httpmock.NewStringResponse(403, "Forbidden") + resp.Status = "403 Forbidden" + httpmock.RegisterResponder( + "POST", + "https://mattermost.host/hooks/token", + httpmock.ResponderFromResponse(resp), + ) + }) + }) + }) + + ginkgo.When("generating a config object", func() { + ginkgo.It("should not set icon", func() { + slackURL, _ := url.Parse("mattermost://AAAAAAAAA/BBBBBBBBB") + config, configError := CreateConfigFromURL(slackURL) + + gomega.Expect(configError).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Icon).To(gomega.BeEmpty()) + }) + ginkgo.It("should set icon", func() { + slackURL, _ := url.Parse("mattermost://AAAAAAAAA/BBBBBBBBB?icon=test") + config, configError := CreateConfigFromURL(slackURL) + + gomega.Expect(configError).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Icon).To(gomega.BeIdenticalTo("test")) + }) + }) + ginkgo.Describe("creating the payload", func() { + ginkgo.Describe("the icon fields", func() { + payload := JSON{} + ginkgo.It("should set IconURL when the configured icon looks like an URL", func() { + payload.SetIcon("https://example.com/logo.png") + gomega.Expect(payload.IconURL).To(gomega.Equal("https://example.com/logo.png")) + gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty()) + }) + ginkgo.It( + "should set IconEmoji when the configured icon does not look like an URL", + func() { + payload.SetIcon("tanabata_tree") + gomega.Expect(payload.IconEmoji).To(gomega.Equal("tanabata_tree")) + gomega.Expect(payload.IconURL).To(gomega.BeEmpty()) + }, + ) + ginkgo.It("should clear both fields when icon is empty", func() { + payload.SetIcon("") + gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty()) + gomega.Expect(payload.IconURL).To(gomega.BeEmpty()) + }) + }) + }) + ginkgo.Describe("Sending messages", func() { + ginkgo.When("sending a message completely without parameters", func() { + mattermostURL, _ := url.Parse( + "mattermost://mattermost.my-domain.com/thisshouldbeanapitoken", + ) + config := &Config{} + gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed()) + ginkgo.It("should generate the correct url to call", func() { + generatedURL := buildURL(config) + gomega.Expect(generatedURL). + To(gomega.Equal("https://mattermost.my-domain.com/hooks/thisshouldbeanapitoken")) + }) + ginkgo.It("should generate the correct JSON body", func() { + json, err := CreateJSONPayload(config, "this is a message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(json)).To(gomega.Equal("{\"text\":\"this is a message\"}")) + }) + }) + ginkgo.When("sending a message with pre set username and channel", func() { + mattermostURL, _ := url.Parse( + "mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel", + ) + config := &Config{} + gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed()) + ginkgo.It("should generate the correct JSON body", func() { + json, err := CreateJSONPayload(config, "this is a message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(json)). + To(gomega.Equal("{\"text\":\"this is a message\",\"username\":\"testUserName\",\"channel\":\"testChannel\"}")) + }) + }) + ginkgo.When( + "sending a message with pre set username and channel but overwriting them with parameters", + func() { + mattermostURL, _ := url.Parse( + "mattermost://testUserName@mattermost.my-domain.com/thisshouldbeanapitoken/testChannel", + ) + config := &Config{} + gomega.Expect(config.SetURL(mattermostURL)).To(gomega.Succeed()) + ginkgo.It("should generate the correct JSON body", func() { + params := (*types.Params)( + &map[string]string{ + "username": "overwriteUserName", + "channel": "overwriteChannel", + }, + ) + json, err := CreateJSONPayload(config, "this is a message", params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(string(json)). + To(gomega.Equal("{\"text\":\"this is a message\",\"username\":\"overwriteUserName\",\"channel\":\"overwriteChannel\"}")) + }) + }, + ) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + input := "mattermost://bot@mattermost.host/token/channel" + + config := &Config{} + gomega.Expect(config.SetURL(testutils.URLMust(input))).To(gomega.Succeed()) + gomega.Expect(config.GetURL().String()).To(gomega.Equal(input)) + }) + }) + + ginkgo.Describe("creating configurations", func() { + ginkgo.When("given a url with channel field", func() { + ginkgo.It("should not throw an error", func() { + serviceURL := testutils.URLMust(`mattermost://user@mockserver/atoken/achannel`) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed()) + }) + }) + ginkgo.When("given a url with title prop", func() { + ginkgo.It("should not throw an error", func() { + serviceURL := testutils.URLMust( + `mattermost://user@mockserver/atoken?icon=https%3A%2F%2Fexample%2Fsomething.png`, + ) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed()) + }) + }) + ginkgo.When("given a url with all fields and props", func() { + ginkgo.It("should not throw an error", func() { + serviceURL := testutils.URLMust( + `mattermost://user@mockserver/atoken/achannel?icon=https%3A%2F%2Fexample%2Fsomething.png`, + ) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.Succeed()) + }) + }) + ginkgo.When("given a url with invalid props", func() { + ginkgo.It("should return an error", func() { + serviceURL := testutils.URLMust(`matrix://user@mockserver/atoken?foo=bar`) + gomega.Expect((&Config{}).SetURL(serviceURL)).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + testURL := "mattermost://user@mockserver/atoken/achannel?icon=something" + + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + config := &Config{} + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + fmt.Fprint(ginkgo.GinkgoWriter, outputURL.String(), " ", testURL, "\n") + + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + config := Config{ + Host: "mattermost.host", + Token: "token", + } + serviceURL := config.GetURL() + service := Service{} + err = service.Initialize(serviceURL, nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + httpmock.ActivateNonDefault(service.httpClient) + + httpmock.RegisterResponder( + "POST", + "https://mattermost.host/hooks/token", + httpmock.NewStringResponder(200, ``), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the basic service API", func() { + ginkgo.Describe("the service config", func() { + ginkgo.It("should implement basic service config API methods correctly", func() { + testutils.TestConfigGetInvalidQueryValue(&Config{}) + + testutils.TestConfigSetDefaultValues(&Config{}) + + testutils.TestConfigGetEnumsCount(&Config{}, 0) + testutils.TestConfigGetFieldsCount(&Config{}, 5) + }) + }) + ginkgo.Describe("the service instance", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should implement basic service API methods correctly", func() { + serviceURL := testutils.URLMust("mattermost://mockhost/mocktoken") + gomega.Expect(service.Initialize(serviceURL, testutils.TestLogger())). + To(gomega.Succeed()) + testutils.TestServiceSetInvalidParamValue(service, "foo", "bar") + }) + }) + }) + + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("mattermost")) + }) +}) diff --git a/pkg/services/ntfy/ntfy.go b/pkg/services/ntfy/ntfy.go new file mode 100644 index 0000000..9e0a54d --- /dev/null +++ b/pkg/services/ntfy/ntfy.go @@ -0,0 +1,99 @@ +package ntfy + +import ( + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/internal/meta" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +// Service sends notifications to Ntfy. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Ntfy. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + if err := service.sendAPI(config, message); err != nil { + return fmt.Errorf("failed to send ntfy notification: %w", err) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + _ = service.pkr.SetDefaultProps(service.Config) + + return service.Config.setURL(&service.pkr, configURL) +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// sendAPI sends a notification to the Ntfy API. +func (service *Service) sendAPI(config *Config, message string) error { + response := apiResponse{} + request := message + jsonClient := jsonclient.NewClient() + + headers := jsonClient.Headers() + headers.Del("Content-Type") + headers.Set("User-Agent", "shoutrrr/"+meta.Version) + addHeaderIfNotEmpty(&headers, "Title", config.Title) + addHeaderIfNotEmpty(&headers, "Priority", config.Priority.String()) + addHeaderIfNotEmpty(&headers, "Tags", strings.Join(config.Tags, ",")) + addHeaderIfNotEmpty(&headers, "Delay", config.Delay) + addHeaderIfNotEmpty(&headers, "Actions", strings.Join(config.Actions, ";")) + addHeaderIfNotEmpty(&headers, "Click", config.Click) + addHeaderIfNotEmpty(&headers, "Attach", config.Attach) + addHeaderIfNotEmpty(&headers, "X-Icon", config.Icon) + addHeaderIfNotEmpty(&headers, "Filename", config.Filename) + addHeaderIfNotEmpty(&headers, "Email", config.Email) + + if !config.Cache { + headers.Add("Cache", "no") + } + + if !config.Firebase { + headers.Add("Firebase", "no") + } + + if err := jsonClient.Post(config.GetAPIURL(), request, &response); err != nil { + if jsonClient.ErrorResponse(err, &response) { + // apiResponse implements Error + return &response + } + + return fmt.Errorf("posting to Ntfy API: %w", err) + } + + return nil +} + +// addHeaderIfNotEmpty adds a header to the request if the value is non-empty. +func addHeaderIfNotEmpty(headers *http.Header, key string, value string) { + if value != "" { + headers.Add(key, value) + } +} diff --git a/pkg/services/ntfy/ntfy_config.go b/pkg/services/ntfy/ntfy_config.go new file mode 100644 index 0000000..7b6fb3a --- /dev/null +++ b/pkg/services/ntfy/ntfy_config.go @@ -0,0 +1,119 @@ +package ntfy + +import ( + "errors" + "fmt" // Add this import + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifying part of this service's configuration URL. +const ( + Scheme = "ntfy" +) + +// ErrTopicRequired indicates that the topic is missing from the config URL. +var ErrTopicRequired = errors.New("topic is required") + +// Config holds the configuration for the Ntfy service. +type Config struct { + Title string `default:"" desc:"Message title" key:"title"` + Host string `default:"ntfy.sh" desc:"Server hostname and port" url:"host"` + Topic string ` desc:"Target topic name" url:"path" required:""` + Password string ` desc:"Auth password" url:"password" optional:""` + Username string ` desc:"Auth username" url:"user" optional:""` + Scheme string `default:"https" desc:"Server protocol, http or https" key:"scheme"` + Tags []string ` desc:"List of tags that may or not map to emojis" key:"tags" optional:""` + Priority priority `default:"default" desc:"Message priority with 1=min, 3=default and 5=max" key:"priority"` + Actions []string ` desc:"Custom user action buttons for notifications, see https://docs.ntfy.sh/publish/#action-buttons" key:"actions" optional:"" sep:";"` + Click string ` desc:"Website opened when notification is clicked" key:"click" optional:""` + Attach string ` desc:"URL of an attachment, see attach via URL" key:"attach" optional:""` + Filename string ` desc:"File name of the attachment" key:"filename" optional:""` + Delay string ` desc:"Timestamp or duration for delayed delivery, see https://docs.ntfy.sh/publish/#scheduled-delivery" key:"delay,at,in" optional:""` + Email string ` desc:"E-mail address for e-mail notifications" key:"email" optional:""` + Icon string ` desc:"URL to use as notification icon" key:"icon" optional:""` + Cache bool `default:"yes" desc:"Cache messages" key:"cache"` + Firebase bool `default:"yes" desc:"Send to firebase" key:"firebase"` +} + +// Enums returns the fields that use an EnumFormatter for their values. +func (*Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{ + "Priority": Priority.Enum, + } +} + +// GetURL returns a URL representation of the Config's current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the Config from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// GetAPIURL constructs the API URL for the Ntfy service based on the configuration. +func (config *Config) GetAPIURL() string { + path := config.Topic + if !strings.HasPrefix(config.Topic, "/") { + path = "/" + path + } + + var creds *url.Userinfo + if config.Password != "" { + creds = url.UserPassword(config.Username, config.Password) + } + + apiURL := url.URL{ + Scheme: config.Scheme, + Host: config.Host, + Path: path, + User: creds, + } + + return apiURL.String() +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword(config.Username, config.Password), + Host: config.Host, + Scheme: Scheme, + ForceQuery: true, + Path: config.Topic, + RawQuery: format.BuildQuery(resolver), + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + config.Password = password + config.Username = url.User.Username() + config.Host = url.Host + config.Topic = strings.TrimPrefix(url.Path, "/") + + url.RawQuery = strings.ReplaceAll(url.RawQuery, ";", "%3b") + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + if url.String() != "ntfy://dummy@dummy.com" { + if config.Topic == "" { + return ErrTopicRequired + } + } + + return nil +} diff --git a/pkg/services/ntfy/ntfy_json.go b/pkg/services/ntfy/ntfy_json.go new file mode 100644 index 0000000..b6766d8 --- /dev/null +++ b/pkg/services/ntfy/ntfy_json.go @@ -0,0 +1,19 @@ +package ntfy + +import "fmt" + +//nolint:errname +type apiResponse struct { + Code int64 `json:"code"` + Message string `json:"error"` + Link string `json:"link"` +} + +func (e *apiResponse) Error() string { + msg := fmt.Sprintf("server response: %v (%v)", e.Message, e.Code) + if e.Link != "" { + return msg + ", see: " + e.Link + } + + return msg +} diff --git a/pkg/services/ntfy/ntfy_priority.go b/pkg/services/ntfy/ntfy_priority.go new file mode 100644 index 0000000..e784ac3 --- /dev/null +++ b/pkg/services/ntfy/ntfy_priority.go @@ -0,0 +1,55 @@ +package ntfy + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Priority levels as constants. +const ( + PriorityMin priority = 1 + PriorityLow priority = 2 + PriorityDefault priority = 3 + PriorityHigh priority = 4 + PriorityMax priority = 5 +) + +// Priority defines the notification priority levels. +var Priority = &priorityVals{ + Min: PriorityMin, + Low: PriorityLow, + Default: PriorityDefault, + High: PriorityHigh, + Max: PriorityMax, + Enum: format.CreateEnumFormatter( + []string{ + "", + "Min", + "Low", + "Default", + "High", + "Max", + }, map[string]int{ + "1": int(PriorityMin), + "2": int(PriorityLow), + "3": int(PriorityDefault), + "4": int(PriorityHigh), + "5": int(PriorityMax), + "urgent": int(PriorityMax), + }), +} + +type priority int + +type priorityVals struct { + Min priority + Low priority + Default priority + High priority + Max priority + Enum types.EnumFormatter +} + +func (p priority) String() string { + return Priority.Enum.Print(int(p)) +} diff --git a/pkg/services/ntfy/ntfy_test.go b/pkg/services/ntfy/ntfy_test.go new file mode 100644 index 0000000..4455f37 --- /dev/null +++ b/pkg/services/ntfy/ntfy_test.go @@ -0,0 +1,162 @@ +package ntfy + +import ( + "log" + "net/http" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + gomegaformat "github.com/onsi/gomega/format" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" +) + +func TestNtfy(t *testing.T) { + gomegaformat.CharactersAroundMismatchToInclude = 20 + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Ntfy Suite") +} + +var ( + service = &Service{} + envBarkURL *url.URL + logger *log.Logger = testutils.TestLogger() + _ = ginkgo.BeforeSuite(func() { + envBarkURL, _ = url.Parse(os.Getenv("SHOUTRRR_NTFY_URL")) + }) +) + +var _ = ginkgo.Describe("the ntfy service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + if envBarkURL.String() == "" { + ginkgo.Skip("No integration test ENV URL was set") + + return + } + configURL := testutils.URLMust(envBarkURL.String()) + gomega.Expect(service.Initialize(configURL, logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("This is an integration test message", nil)). + To(gomega.Succeed()) + }) + }) + + ginkgo.Describe("the config", func() { + ginkgo.When("getting a API URL", func() { + ginkgo.It("should return the expected URL", func() { + gomega.Expect((&Config{ + Host: "host:8080", + Scheme: "http", + Topic: "topic", + }).GetAPIURL()).To(gomega.Equal("http://host:8080/topic")) + }) + }) + ginkgo.When("only required fields are set", func() { + ginkgo.It("should set the optional fields to the defaults", func() { + serviceURL := testutils.URLMust("ntfy://hostname/topic") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + gomega.Expect(*service.Config).To(gomega.Equal(Config{ + Host: "hostname", + Topic: "topic", + Scheme: "https", + Tags: []string{""}, + Actions: []string{""}, + Priority: 3, + Firebase: true, + Cache: true, + })) + }) + }) + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + testURL := "ntfy://user:pass@example.com:2225/topic?cache=No&click=CLICK&firebase=No&icon=ICON&priority=Max&scheme=http&title=TITLE" + config := &Config{} + pkr := format.NewPropKeyResolver(config) + gomega.Expect(config.setURL(&pkr, testutils.URLMust(testURL))). + To(gomega.Succeed(), "verifying") + gomega.Expect(config.GetURL().String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.When("sending the push payload", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + httpmock.RegisterResponder( + "POST", + service.Config.GetAPIURL(), + testutils.JSONRespondMust(200, apiResponse{ + Code: http.StatusOK, + Message: "OK", + }), + ) + gomega.Expect(service.Send("Message", nil)).To(gomega.Succeed()) + }) + + ginkgo.It("should not panic if a server error occurs", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + httpmock.RegisterResponder( + "POST", + service.Config.GetAPIURL(), + testutils.JSONRespondMust(500, apiResponse{ + Code: 500, + Message: "someone turned off the internet", + }), + ) + gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred()) + }) + + ginkgo.It("should not panic if a communication error occurs", func() { + httpmock.DeactivateAndReset() + serviceURL := testutils.URLMust("ntfy://:devicekey@nonresolvablehostname/testtopic") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + gomega.Expect(service.Send("Message", nil)).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("the basic service API", func() { + ginkgo.Describe("the service config", func() { + ginkgo.It("should implement basic service config API methods correctly", func() { + testutils.TestConfigGetInvalidQueryValue(&Config{}) + testutils.TestConfigSetInvalidQueryValue(&Config{}, "ntfy://host/topic?foo=bar") + testutils.TestConfigSetDefaultValues(&Config{}) + testutils.TestConfigGetEnumsCount(&Config{}, 1) + testutils.TestConfigGetFieldsCount(&Config{}, 15) + }) + }) + ginkgo.Describe("the service instance", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should implement basic service API methods correctly", func() { + serviceURL := testutils.URLMust("ntfy://:devicekey@hostname/testtopic") + gomega.Expect(service.Initialize(serviceURL, logger)).To(gomega.Succeed()) + testutils.TestServiceSetInvalidParamValue(service, "foo", "bar") + }) + }) + }) + + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("ntfy")) + }) +}) diff --git a/pkg/services/opsgenie/opsgenie.go b/pkg/services/opsgenie/opsgenie.go new file mode 100644 index 0000000..ad93d32 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie.go @@ -0,0 +1,160 @@ +package opsgenie + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// alertEndpointTemplate is the OpsGenie API endpoint template for sending alerts. +const ( + alertEndpointTemplate = "https://%s:%d/v2/alerts" + MaxMessageLength = 130 // MaxMessageLength is the maximum length of the alert message field in OpsGenie. + httpSuccessMax = 299 // httpSuccessMax is the maximum HTTP status code for a successful response. + defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests. +) + +// ErrUnexpectedStatus indicates that OpsGenie returned an unexpected HTTP status code. +var ErrUnexpectedStatus = errors.New("OpsGenie notification returned unexpected HTTP status code") + +// Service provides OpsGenie as a notification service. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// sendAlert sends an alert to OpsGenie using the specified URL and API key. +func (service *Service) sendAlert(url string, apiKey string, payload AlertPayload) error { + jsonBody, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshaling alert payload to JSON: %w", err) + } + + jsonBuffer := bytes.NewBuffer(jsonBody) + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, jsonBuffer) + if err != nil { + return fmt.Errorf("creating HTTP request: %w", err) + } + + req.Header.Add("Authorization", "GenieKey "+apiKey) + req.Header.Add("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send notification to OpsGenie: %w", err) + } + + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode > httpSuccessMax { + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf( + "%w: %d, cannot read body: %w", + ErrUnexpectedStatus, + resp.StatusCode, + err, + ) + } + + return fmt.Errorf("%w: %d - %s", ErrUnexpectedStatus, resp.StatusCode, body) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + return service.Config.setURL(&service.pkr, configURL) +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to OpsGenie. +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + endpointURL := fmt.Sprintf(alertEndpointTemplate, config.Host, config.Port) + + payload, err := service.newAlertPayload(message, params) + if err != nil { + return err + } + + return service.sendAlert(endpointURL, config.APIKey, payload) +} + +// newAlertPayload creates a new alert payload for OpsGenie based on the message and parameters. +func (service *Service) newAlertPayload( + message string, + params *types.Params, +) (AlertPayload, error) { + if params == nil { + params = &types.Params{} + } + + // Defensive copy + payloadFields := *service.Config + + if err := service.pkr.UpdateConfigFromParams(&payloadFields, params); err != nil { + return AlertPayload{}, fmt.Errorf("updating payload fields from params: %w", err) + } + + // Use `Message` for the title if available, or if the message is too long + // Use `Description` for the message in these scenarios + title := payloadFields.Title + description := message + + if title == "" { + if len(message) > MaxMessageLength { + title = message[:MaxMessageLength] + } else { + title = message + description = "" + } + } + + if payloadFields.Description != "" && description != "" { + description += "\n" + } + + result := AlertPayload{ + Message: title, + Alias: payloadFields.Alias, + Description: description + payloadFields.Description, + Responders: payloadFields.Responders, + VisibleTo: payloadFields.VisibleTo, + Actions: payloadFields.Actions, + Tags: payloadFields.Tags, + Details: payloadFields.Details, + Entity: payloadFields.Entity, + Source: payloadFields.Source, + Priority: payloadFields.Priority, + User: payloadFields.User, + Note: payloadFields.Note, + } + + return result, nil +} diff --git a/pkg/services/opsgenie/opsgenie_config.go b/pkg/services/opsgenie/opsgenie_config.go new file mode 100644 index 0000000..be8ebc6 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_config.go @@ -0,0 +1,109 @@ +package opsgenie + +import ( + "errors" + "fmt" + "net/url" + "strconv" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + defaultPort = 443 // defaultPort is the default port for OpsGenie API connections. + Scheme = "opsgenie" // Scheme is the identifying part of this service's configuration URL. +) + +// ErrAPIKeyMissing indicates that the API key is missing from the config URL path. +var ErrAPIKeyMissing = errors.New("API key missing from config URL path") + +// Config holds the configuration for the OpsGenie service. +type Config struct { + APIKey string `desc:"The OpsGenie API key" url:"path"` + Host string `desc:"The OpsGenie API host. Use 'api.eu.opsgenie.com' for EU instances" url:"host" default:"api.opsgenie.com"` + Port uint16 `desc:"The OpsGenie API port." url:"port" default:"443"` + Alias string `desc:"Client-defined identifier of the alert" key:"alias" optional:"true"` + Description string `desc:"Description field of the alert" key:"description" optional:"true"` + Responders []Entity `desc:"Teams, users, escalations and schedules that the alert will be routed to send notifications" key:"responders" optional:"true"` + VisibleTo []Entity `desc:"Teams and users that the alert will become visible to without sending any notification" key:"visibleTo" optional:"true"` + Actions []string `desc:"Custom actions that will be available for the alert" key:"actions" optional:"true"` + Tags []string `desc:"Tags of the alert" key:"tags" optional:"true"` + Details map[string]string `desc:"Map of key-value pairs to use as custom properties of the alert" key:"details" optional:"true"` + Entity string `desc:"Entity field of the alert that is generally used to specify which domain the Source field of the alert" key:"entity" optional:"true"` + Source string `desc:"Source field of the alert" key:"source" optional:"true"` + Priority string `desc:"Priority level of the alert. Possible values are P1, P2, P3, P4 and P5" key:"priority" optional:"true"` + Note string `desc:"Additional note that will be added while creating the alert" key:"note" optional:"true"` + User string `desc:"Display name of the request owner" key:"user" optional:"true"` + Title string `desc:"notification title, optionally set by the sender" default:"" key:"title"` +} + +// Enums returns an empty map because the OpsGenie service doesn't use Enums. +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL returns a URL representation of the Config's current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + var host string + if config.Port > 0 { + host = fmt.Sprintf("%s:%d", config.Host, config.Port) + } else { + host = config.Host + } + + result := &url.URL{ + Host: host, + Path: "/" + config.APIKey, + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + } + + return result +} + +// SetURL updates the Config from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + config.Host = url.Hostname() + + if url.String() != "opsgenie://dummy@dummy.com" { + if len(url.Path) > 0 { + config.APIKey = url.Path[1:] + } else { + return ErrAPIKeyMissing + } + } + + if url.Port() != "" { + port, err := strconv.ParseUint(url.Port(), 10, 16) + if err != nil { + return fmt.Errorf("parsing port %q: %w", url.Port(), err) + } + + config.Port = uint16(port) + } else { + config.Port = defaultPort + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + return nil +} diff --git a/pkg/services/opsgenie/opsgenie_entity.go b/pkg/services/opsgenie/opsgenie_entity.go new file mode 100644 index 0000000..d7d6bc9 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_entity.go @@ -0,0 +1,93 @@ +package opsgenie + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// EntityPartsCount is the expected number of parts in an entity string (type:identifier). +const ( + EntityPartsCount = 2 // Expected number of parts in an entity string (type:identifier) +) + +// ErrInvalidEntityFormat indicates that the entity string does not have two elements separated by a colon. +var ( + ErrInvalidEntityFormat = errors.New( + "invalid entity, should have two elements separated by colon", + ) + ErrInvalidEntityIDName = errors.New("invalid entity, cannot parse id/name") + ErrUnexpectedEntityType = errors.New("invalid entity, unexpected entity type") + ErrMissingEntityIdentity = errors.New("invalid entity, should have either ID, name or username") +) + +// Entity represents an OpsGenie entity (e.g., user, team) with type and identifier. +// Example JSON: { "username":"trinity@opsgenie.com", "type":"user" }. +type Entity struct { + Type string `json:"type"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Username string `json:"username,omitempty"` +} + +// SetFromProp deserializes an entity from a string in the format "type:identifier". +func (e *Entity) SetFromProp(propValue string) error { + elements := strings.Split(propValue, ":") + + if len(elements) != EntityPartsCount { + return fmt.Errorf("%w: %q", ErrInvalidEntityFormat, propValue) + } + + e.Type = elements[0] + identifier := elements[1] + + isID, err := isOpsGenieID(identifier) + if err != nil { + return fmt.Errorf("%w: %q", ErrInvalidEntityIDName, identifier) + } + + switch { + case isID: + e.ID = identifier + case e.Type == "team": + e.Name = identifier + case e.Type == "user": + e.Username = identifier + default: + return fmt.Errorf("%w: %q", ErrUnexpectedEntityType, e.Type) + } + + return nil +} + +// GetPropValue serializes an entity back into a string in the format "type:identifier". +func (e *Entity) GetPropValue() (string, error) { + var identifier string + + switch { + case e.ID != "": + identifier = e.ID + case e.Name != "": + identifier = e.Name + case e.Username != "": + identifier = e.Username + default: + return "", ErrMissingEntityIdentity + } + + return fmt.Sprintf("%s:%s", e.Type, identifier), nil +} + +// isOpsGenieID checks if a string matches the OpsGenie ID format (e.g., 4513b7ea-3b91-438f-b7e4-e3e54af9147c). +func isOpsGenieID(str string) (bool, error) { + matched, err := regexp.MatchString( + `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`, + str, + ) + if err != nil { + return false, fmt.Errorf("matching OpsGenie ID format for %q: %w", str, err) + } + + return matched, nil +} diff --git a/pkg/services/opsgenie/opsgenie_json.go b/pkg/services/opsgenie/opsgenie_json.go new file mode 100644 index 0000000..3d5e6c6 --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_json.go @@ -0,0 +1,33 @@ +package opsgenie + +// AlertPayload represents the payload being sent to the OpsGenie API +// +// See: https://docs.opsgenie.com/docs/alert-api#create-alert +// +// Some fields contain complex values like arrays and objects. +// Because `params` are strings only we cannot pass in slices +// or maps. Instead we "preserve" the JSON in those fields. That +// way we can pass in complex types as JSON like so: +// +// service.Send("An example alert message", &types.Params{ +// "alias": "Life is too short for no alias", +// "description": "Every alert needs a description", +// "responders": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"NOC","type":"team"}]`, +// "visibleTo": `[{"id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c","type":"team"},{"name":"rocket_team","type":"team"}]`, +// "details": `{"key1": "value1", "key2": "value2"}`, +// }) +type AlertPayload struct { + Message string `json:"message"` + Alias string `json:"alias,omitempty"` + Description string `json:"description,omitempty"` + Responders []Entity `json:"responders,omitempty"` + VisibleTo []Entity `json:"visibleTo,omitempty"` + Actions []string `json:"actions,omitempty"` + Tags []string `json:"tags,omitempty"` + Details map[string]string `json:"details,omitempty"` + Entity string `json:"entity,omitempty"` + Source string `json:"source,omitempty"` + Priority string `json:"priority,omitempty"` + User string `json:"user,omitempty"` + Note string `json:"note,omitempty"` +} diff --git a/pkg/services/opsgenie/opsgenie_test.go b/pkg/services/opsgenie/opsgenie_test.go new file mode 100644 index 0000000..365adce --- /dev/null +++ b/pkg/services/opsgenie/opsgenie_test.go @@ -0,0 +1,422 @@ +package opsgenie + +import ( + "bytes" + "crypto/tls" + "fmt" + "io" + "log" + "net" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + mockAPIKey = "eb243592-faa2-4ba2-a551q-1afdf565c889" + mockHost = "api.opsgenie.com" +) + +func TestOpsGenie(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr OpsGenie Suite") +} + +var _ = ginkgo.Describe("the OpsGenie service", func() { + var ( + // a simulated http server to mock out OpsGenie itself + mockServer *httptest.Server + // the host of our mock server + mockHost string + // function to check if the http request received by the mock server is as expected + checkRequest func(body string, header http.Header) + // the shoutrrr OpsGenie service + service *Service + // just a mock logger + mockLogger *log.Logger + ) + + ginkgo.BeforeEach(func() { + // Initialize a mock http server + httpHandler := func(_ http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + defer r.Body.Close() + + checkRequest(string(body), r.Header) + } + mockServer = httptest.NewTLSServer(http.HandlerFunc(httpHandler)) + + // Our mock server doesn't have a valid cert + http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + + // Determine the host of our mock http server + mockServerURL, err := url.Parse(mockServer.URL) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + mockHost = mockServerURL.Host + + // Initialize a mock logger + var buf bytes.Buffer + mockLogger = log.New(&buf, "", 0) + }) + + ginkgo.AfterEach(func() { + mockServer.Close() + }) + + ginkgo.Context("without query parameters", func() { + ginkgo.BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + + ginkgo.When("sending a simple alert", func() { + ginkgo.It("should send a request to our mock OpsGenie server", func() { + checkRequest = func(body string, header http.Header) { + gomega.Expect(header["Authorization"][0]). + To(gomega.Equal("GenieKey " + mockAPIKey)) + gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) + gomega.Expect(body).To(gomega.Equal(`{"message":"hello world"}`)) + } + + err := service.Send("hello world", &types.Params{}) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("sending an alert with runtime parameters", func() { + ginkgo.It( + "should send a request to our mock OpsGenie server with all fields populated from runtime parameters", + func() { + checkRequest = func(body string, header http.Header) { + gomega.Expect(header["Authorization"][0]). + To(gomega.Equal("GenieKey " + mockAPIKey)) + gomega.Expect(header["Content-Type"][0]). + To(gomega.Equal("application/json")) + gomega.Expect(body).To(gomega.Equal(`{"` + + `message":"An example alert message",` + + `"alias":"Life is too short for no alias",` + + `"description":"Every alert needs a description",` + + `"responders":[{"type":"team","id":"4513b7ea-3b91-438f-b7e4-e3e54af9147c"},{"type":"team","name":"NOC"},{"type":"user","username":"Donald"},{"type":"user","id":"696f0759-3b0f-4a15-b8c8-19d3dfca33f2"}],` + + `"visibleTo":[{"type":"team","name":"rocket"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"An example entity",` + + `"source":"The source",` + + `"priority":"P1",` + + `"user":"Dracula",` + + `"note":"Here is a note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{ + "alias": "Life is too short for no alias", + "description": "Every alert needs a description", + "responders": "team:4513b7ea-3b91-438f-b7e4-e3e54af9147c,team:NOC,user:Donald,user:696f0759-3b0f-4a15-b8c8-19d3dfca33f2", + "visibleTo": "team:rocket", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "An example entity", + "source": "The source", + "priority": "P1", + "user": "Dracula", + "note": "Here is a note", + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }, + ) + }) + }) + + ginkgo.Context("with query parameters", func() { + ginkgo.BeforeEach(func() { + // Initialize service + serviceURL, err := url.Parse( + fmt.Sprintf( + `opsgenie://%s/%s?alias=query-alias&description=query-description&responders=team:query_team&visibleTo=user:query_user&actions=queryAction1,queryAction2&tags=queryTag1,queryTag2&details=queryKey1:queryValue1,queryKey2:queryValue2&entity=query-entity&source=query-source&priority=P2&user=query-user¬e=query-note`, + mockHost, + mockAPIKey, + ), + ) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + service = &Service{} + err = service.Initialize(serviceURL, mockLogger) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + + ginkgo.When("sending a simple alert", func() { + ginkgo.It( + "should send a request to our mock OpsGenie server with all fields populated from query parameters", + func() { + checkRequest = func(body string, header http.Header) { + gomega.Expect(header["Authorization"][0]). + To(gomega.Equal("GenieKey " + mockAPIKey)) + gomega.Expect(header["Content-Type"][0]). + To(gomega.Equal("application/json")) + gomega.Expect(body).To(gomega.Equal(`{` + + `"message":"An example alert message",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err := service.Send("An example alert message", &types.Params{}) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }, + ) + }) + + ginkgo.When("sending two alerts", func() { + ginkgo.It("should not mix-up the runtime parameters and the query parameters", func() { + // Internally the opsgenie service copies runtime parameters into the config struct + // before generating the alert payload. This test ensures that none of the parameters + // from alert 1 remain in the config struct when sending alert 2 + // In short: This tests if we clone the config struct + + checkRequest = func(body string, header http.Header) { + gomega.Expect(header["Authorization"][0]). + To(gomega.Equal("GenieKey " + mockAPIKey)) + gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) + gomega.Expect(body).To(gomega.Equal(`{"` + + `message":"1",` + + `"alias":"1",` + + `"description":"1",` + + `"responders":[{"type":"team","name":"1"}],` + + `"visibleTo":[{"type":"team","name":"1"}],` + + `"actions":["action1","action2"],` + + `"tags":["tag1","tag2"],` + + `"details":{"key1":"value1","key2":"value2"},` + + `"entity":"1",` + + `"source":"1",` + + `"priority":"P1",` + + `"user":"1",` + + `"note":"1"` + + `}`)) + } + + err := service.Send("1", &types.Params{ + "alias": "1", + "description": "1", + "responders": "team:1", + "visibleTo": "team:1", + "actions": "action1,action2", + "tags": "tag1,tag2", + "details": "key1:value1,key2:value2", + "entity": "1", + "source": "1", + "priority": "P1", + "user": "1", + "note": "1", + }) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + checkRequest = func(body string, header http.Header) { + gomega.Expect(header["Authorization"][0]). + To(gomega.Equal("GenieKey " + mockAPIKey)) + gomega.Expect(header["Content-Type"][0]).To(gomega.Equal("application/json")) + gomega.Expect(body).To(gomega.Equal(`{` + + `"message":"2",` + + `"alias":"query-alias",` + + `"description":"query-description",` + + `"responders":[{"type":"team","name":"query_team"}],` + + `"visibleTo":[{"type":"user","username":"query_user"}],` + + `"actions":["queryAction1","queryAction2"],` + + `"tags":["queryTag1","queryTag2"],` + + `"details":{"queryKey1":"queryValue1","queryKey2":"queryValue2"},` + + `"entity":"query-entity",` + + `"source":"query-source",` + + `"priority":"P2",` + + `"user":"query-user",` + + `"note":"query-note"` + + `}`)) + } + + err = service.Send("2", nil) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + }) + }) + + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("opsgenie")) + }) +}) + +var _ = ginkgo.Describe("the OpsGenie Config struct", func() { + ginkgo.When("generating a config from a simple URL", func() { + ginkgo.It("should populate the config with host and apikey", func() { + url, err := url.Parse(fmt.Sprintf("opsgenie://%s/%s", mockHost, mockAPIKey)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + config := Config{} + err = config.SetURL(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + gomega.Expect(config.APIKey).To(gomega.Equal(mockAPIKey)) + gomega.Expect(config.Host).To(gomega.Equal(mockHost)) + gomega.Expect(config.Port).To(gomega.Equal(uint16(443))) + }) + }) + + ginkgo.When("generating a config from a url with port", func() { + ginkgo.It("should populate the port field", func() { + url, err := url.Parse( + fmt.Sprintf("opsgenie://%s/%s", net.JoinHostPort(mockHost, "12345"), mockAPIKey), + ) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + config := Config{} + err = config.SetURL(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + gomega.Expect(config.Port).To(gomega.Equal(uint16(12345))) + }) + }) + + ginkgo.When("generating a config from a url with query parameters", func() { + ginkgo.It("should populate the config fields with the query parameter values", func() { + queryParams := `alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&actions=An+action&tags=tag1,tag2&details=key:value,key2:value2&entity=An+example+entity&source=The+source&priority=P1&user=Dracula¬e=Here+is+a+note&responders=user:Test,team:NOC&visibleTo=user:A+User` + url, err := url.Parse( + fmt.Sprintf( + "opsgenie://%s/%s?%s", + net.JoinHostPort(mockHost, "12345"), + mockAPIKey, + queryParams, + ), + ) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + config := Config{} + err = config.SetURL(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias")) + gomega.Expect(config.Description).To(gomega.Equal("Every alert needs a description")) + gomega.Expect(config.Responders).To(gomega.Equal([]Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + })) + gomega.Expect(config.VisibleTo).To(gomega.Equal([]Entity{ + {Type: "user", Username: "A User"}, + })) + gomega.Expect(config.Actions).To(gomega.Equal([]string{"An action"})) + gomega.Expect(config.Tags).To(gomega.Equal([]string{"tag1", "tag2"})) + gomega.Expect(config.Details). + To(gomega.Equal(map[string]string{"key": "value", "key2": "value2"})) + gomega.Expect(config.Entity).To(gomega.Equal("An example entity")) + gomega.Expect(config.Source).To(gomega.Equal("The source")) + gomega.Expect(config.Priority).To(gomega.Equal("P1")) + gomega.Expect(config.User).To(gomega.Equal("Dracula")) + gomega.Expect(config.Note).To(gomega.Equal("Here is a note")) + }) + }) + + ginkgo.When("generating a config from a url with differently escaped spaces", func() { + ginkgo.It("should parse the escaped spaces correctly", func() { + // Use: '%20', '+' and a normal space + queryParams := `alias=Life is+too%20short+for+no+alias` + url, err := url.Parse( + fmt.Sprintf( + "opsgenie://%s/%s?%s", + net.JoinHostPort(mockHost, "12345"), + mockAPIKey, + queryParams, + ), + ) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + config := Config{} + err = config.SetURL(url) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + + gomega.Expect(config.Alias).To(gomega.Equal("Life is too short for no alias")) + }) + }) + + ginkgo.When("generating a url from a simple config", func() { + ginkgo.It("should generate a url", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + } + + url := config.GetURL() + + gomega.Expect(url.String()). + To(gomega.Equal("opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + ginkgo.When("generating a url from a config with a port", func() { + ginkgo.It("should generate a url with port", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Port: 12345, + } + + url := config.GetURL() + + gomega.Expect(url.String()). + To(gomega.Equal("opsgenie://api.opsgenie.com:12345/eb243592-faa2-4ba2-a551q-1afdf565c889")) + }) + }) + + ginkgo.When("generating a url from a config with all optional config fields", func() { + ginkgo.It("should generate a url with query parameters", func() { + config := Config{ + Host: "api.opsgenie.com", + APIKey: "eb243592-faa2-4ba2-a551q-1afdf565c889", + Alias: "Life is too short for no alias", + Description: "Every alert needs a description", + Responders: []Entity{ + {Type: "user", Username: "Test"}, + {Type: "team", Name: "NOC"}, + {Type: "team", ID: "4513b7ea-3b91-438f-b7e4-e3e54af9147c"}, + }, + VisibleTo: []Entity{ + {Type: "user", Username: "A User"}, + }, + Actions: []string{"action1", "action2"}, + Tags: []string{"tag1", "tag2"}, + Details: map[string]string{"key": "value"}, + Entity: "An example entity", + Source: "The source", + Priority: "P1", + User: "Dracula", + Note: "Here is a note", + } + + url := config.GetURL() + gomega.Expect(url.String()). + To(gomega.Equal(`opsgenie://api.opsgenie.com/eb243592-faa2-4ba2-a551q-1afdf565c889?actions=action1%2Caction2&alias=Life+is+too+short+for+no+alias&description=Every+alert+needs+a+description&details=key%3Avalue&entity=An+example+entity¬e=Here+is+a+note&priority=P1&responders=user%3ATest%2Cteam%3ANOC%2Cteam%3A4513b7ea-3b91-438f-b7e4-e3e54af9147c&source=The+source&tags=tag1%2Ctag2&user=Dracula&visibleto=user%3AA+User`)) + }) + }) +}) diff --git a/pkg/services/pushbullet/pushbullet.go b/pkg/services/pushbullet/pushbullet.go new file mode 100644 index 0000000..c96cd41 --- /dev/null +++ b/pkg/services/pushbullet/pushbullet.go @@ -0,0 +1,118 @@ +package pushbullet + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +// Constants. +const ( + pushesEndpoint = "https://api.pushbullet.com/v2/pushes" +) + +// Static errors for push validation. +var ( + ErrUnexpectedResponseType = errors.New("unexpected response type, expected note") + ErrResponseBodyMismatch = errors.New("response body mismatch") + ErrResponseTitleMismatch = errors.New("response title mismatch") + ErrPushNotActive = errors.New("push notification is not active") +) + +// Service providing Pushbullet as a notification service. +type Service struct { + standard.Standard + client jsonclient.Client + Config *Config + pkr format.PropKeyResolver +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + + service.Config = &Config{ + Title: "Shoutrrr notification", // Explicitly set default + } + service.pkr = format.NewPropKeyResolver(service.Config) + + if err := service.Config.setURL(&service.pkr, configURL); err != nil { + return err + } + + service.client = jsonclient.NewClient() + service.client.Headers().Set("Access-Token", service.Config.Token) + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// Send a push notification via Pushbullet. +func (service *Service) Send(message string, params *types.Params) error { + config := *service.Config + if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + for _, target := range config.Targets { + if err := doSend(&config, target, message, service.client); err != nil { + return err + } + } + + return nil +} + +// doSend sends a push notification to a specific target and validates the response. +func doSend(config *Config, target string, message string, client jsonclient.Client) error { + push := NewNotePush(message, config.Title) + push.SetTarget(target) + + response := PushResponse{} + if err := client.Post(pushesEndpoint, push, &response); err != nil { + errorResponse := &ResponseError{} + if client.ErrorResponse(err, errorResponse) { + return fmt.Errorf("API error: %w", errorResponse) + } + + return fmt.Errorf("failed to push: %w", err) + } + + // Validate response fields + if response.Type != "note" { + return fmt.Errorf("%w: got %s", ErrUnexpectedResponseType, response.Type) + } + + if response.Body != message { + return fmt.Errorf( + "%w: got %s, expected %s", + ErrResponseBodyMismatch, + response.Body, + message, + ) + } + + if response.Title != config.Title { + return fmt.Errorf( + "%w: got %s, expected %s", + ErrResponseTitleMismatch, + response.Title, + config.Title, + ) + } + + if !response.Active { + return ErrPushNotActive + } + + return nil +} diff --git a/pkg/services/pushbullet/pushbullet_config.go b/pkg/services/pushbullet/pushbullet_config.go new file mode 100644 index 0000000..fee32c9 --- /dev/null +++ b/pkg/services/pushbullet/pushbullet_config.go @@ -0,0 +1,95 @@ +package pushbullet + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the scheme part of the service configuration URL. +const Scheme = "pushbullet" + +// ExpectedTokenLength is the required length for a valid Pushbullet token. +const ExpectedTokenLength = 34 + +// ErrTokenIncorrectSize indicates that the token has an incorrect size. +var ErrTokenIncorrectSize = errors.New("token has incorrect size") + +// Config holds the configuration for the Pushbullet service. +type Config struct { + standard.EnumlessConfig + Targets []string `url:"path"` + Token string `url:"host"` + Title string ` default:"Shoutrrr notification" key:"title"` +} + +// GetURL returns a URL representation of the Config's current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the Config from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + Host: config.Token, + Path: "/" + strings.Join(config.Targets, "/"), + Scheme: Scheme, + ForceQuery: false, + RawQuery: format.BuildQuery(resolver), + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + path := url.Path + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + + if url.Fragment != "" { + path += "/#" + url.Fragment + } + + targets := strings.Split(path, "/") + + token := url.Hostname() + if url.String() != "pushbullet://dummy@dummy.com" { + if err := validateToken(token); err != nil { + return err + } + } + + config.Token = token + config.Targets = targets + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + return nil +} + +// validateToken checks if the token meets the expected length requirement. +func validateToken(token string) error { + if len(token) != ExpectedTokenLength { + return ErrTokenIncorrectSize + } + + return nil +} diff --git a/pkg/services/pushbullet/pushbullet_json.go b/pkg/services/pushbullet/pushbullet_json.go new file mode 100644 index 0000000..489eedf --- /dev/null +++ b/pkg/services/pushbullet/pushbullet_json.go @@ -0,0 +1,74 @@ +package pushbullet + +import ( + "regexp" +) + +var emailPattern = regexp.MustCompile(`.*@.*\..*`) + +// PushRequest ... +type PushRequest struct { + Type string `json:"type"` + Title string `json:"title"` + Body string `json:"body"` + + Email string `json:"email"` + ChannelTag string `json:"channel_tag"` + DeviceIden string `json:"device_iden"` +} + +type PushResponse struct { + Active bool `json:"active"` + Body string `json:"body"` + Created float64 `json:"created"` + Direction string `json:"direction"` + Dismissed bool `json:"dismissed"` + Iden string `json:"iden"` + Modified float64 `json:"modified"` + ReceiverEmail string `json:"receiver_email"` + ReceiverEmailNormalized string `json:"receiver_email_normalized"` + ReceiverIden string `json:"receiver_iden"` + SenderEmail string `json:"sender_email"` + SenderEmailNormalized string `json:"sender_email_normalized"` + SenderIden string `json:"sender_iden"` + SenderName string `json:"sender_name"` + Title string `json:"title"` + Type string `json:"type"` +} + +type ResponseError struct { + ErrorData struct { + Cat string `json:"cat"` + Message string `json:"message"` + Type string `json:"type"` + } `json:"error"` +} + +func (err *ResponseError) Error() string { + return err.ErrorData.Message +} + +func (p *PushRequest) SetTarget(target string) { + if emailPattern.MatchString(target) { + p.Email = target + + return + } + + if len(target) > 0 && string(target[0]) == "#" { + p.ChannelTag = target[1:] + + return + } + + p.DeviceIden = target +} + +// NewNotePush creates a new push request. +func NewNotePush(message, title string) *PushRequest { + return &PushRequest{ + Type: "note", + Title: title, + Body: message, + } +} diff --git a/pkg/services/pushbullet/pushbullet_test.go b/pkg/services/pushbullet/pushbullet_test.go new file mode 100644 index 0000000..21620a8 --- /dev/null +++ b/pkg/services/pushbullet/pushbullet_test.go @@ -0,0 +1,248 @@ +package pushbullet_test + +import ( + "errors" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/pushbullet" +) + +func TestPushbullet(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Pushbullet Suite") +} + +var ( + service *pushbullet.Service + envPushbulletURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &pushbullet.Service{} + envPushbulletURL, _ = url.Parse(os.Getenv("SHOUTRRR_PUSHBULLET_URL")) + }) +) + +var _ = ginkgo.Describe("the pushbullet service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + if envPushbulletURL.String() == "" { + return + } + + serviceURL, _ := url.Parse(envPushbulletURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("pushbullet")) + }) + }) + + ginkgo.Describe("the pushbullet config", func() { + ginkgo.When("generating a config object", func() { + ginkgo.It("should set token", func() { + pushbulletURL, _ := url.Parse("pushbullet://tokentokentokentokentokentokentoke") + config := pushbullet.Config{} + err := config.SetURL(pushbulletURL) + + gomega.Expect(config.Token).To(gomega.Equal("tokentokentokentokentokentokentoke")) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should set the device from path", func() { + pushbulletURL, _ := url.Parse( + "pushbullet://tokentokentokentokentokentokentoke/test", + ) + config := pushbullet.Config{} + err := config.SetURL(pushbulletURL) + + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Targets).To(gomega.HaveLen(1)) + gomega.Expect(config.Targets).To(gomega.ContainElements("test")) + }) + + ginkgo.It("should set the channel from path", func() { + pushbulletURL, _ := url.Parse( + "pushbullet://tokentokentokentokentokentokentoke/foo#bar", + ) + config := pushbullet.Config{} + err := config.SetURL(pushbulletURL) + + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Targets).To(gomega.HaveLen(2)) + gomega.Expect(config.Targets).To(gomega.ContainElements("foo", "#bar")) + }) + }) + + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + testURL := "pushbullet://tokentokentokentokentokentokentoke/device?title=Great+News" + + config := &pushbullet.Config{} + err := config.SetURL(testutils.URLMust(testURL)) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.Describe("building the payload", func() { + ginkgo.It("Email target should only populate one the correct field", func() { + push := pushbullet.PushRequest{} + push.SetTarget("iam@email.com") + gomega.Expect(push.Email).To(gomega.Equal("iam@email.com")) + gomega.Expect(push.DeviceIden).To(gomega.BeEmpty()) + gomega.Expect(push.ChannelTag).To(gomega.BeEmpty()) + }) + + ginkgo.It("Device target should only populate one the correct field", func() { + push := pushbullet.PushRequest{} + push.SetTarget("device") + gomega.Expect(push.Email).To(gomega.BeEmpty()) + gomega.Expect(push.DeviceIden).To(gomega.Equal("device")) + gomega.Expect(push.ChannelTag).To(gomega.BeEmpty()) + }) + + ginkgo.It("Channel target should only populate one the correct field", func() { + push := pushbullet.PushRequest{} + push.SetTarget("#channel") + gomega.Expect(push.Email).To(gomega.BeEmpty()) + gomega.Expect(push.DeviceIden).To(gomega.BeEmpty()) + gomega.Expect(push.ChannelTag).To(gomega.Equal("channel")) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + targetURL := "https://api.pushbullet.com/v2/pushes" + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("should not report an error if the server accepts the payload", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + response := pushbullet.PushResponse{ + Type: "note", + Body: "Message", + Title: "Shoutrrr notification", // Matches default + Active: true, + } + responder, _ := httpmock.NewJsonResponder(200, &response) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should not panic if an error occurs when sending the payload", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + targetURL, + httpmock.NewErrorResponder(errors.New("")), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + + ginkgo.It("should return an error if the response type is incorrect", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + response := pushbullet.PushResponse{ + Type: "link", // Incorrect type + Body: "Message", + Title: "Shoutrrr notification", + Active: true, + } + responder, _ := httpmock.NewJsonResponder(200, &response) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("unexpected response type")) + }) + + ginkgo.It("should return an error if the response body does not match", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + response := pushbullet.PushResponse{ + Type: "note", + Body: "Wrong message", + Title: "Shoutrrr notification", + Active: true, + } + responder, _ := httpmock.NewJsonResponder(200, &response) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("response body mismatch")) + }) + + ginkgo.It("should return an error if the response title does not match", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + response := pushbullet.PushResponse{ + Type: "note", + Body: "Message", + Title: "Wrong Title", + Active: true, + } + responder, _ := httpmock.NewJsonResponder(200, &response) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("response title mismatch")) + }) + + ginkgo.It("should return an error if the push is not active", func() { + err = initService() + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + response := pushbullet.PushResponse{ + Type: "note", + Body: "Message", + Title: "Shoutrrr notification", // Matches default + Active: false, + } + responder, _ := httpmock.NewJsonResponder(200, &response) + httpmock.RegisterResponder("POST", targetURL, responder) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("push notification is not active")) + }) + }) +}) + +// initService initializes the service with a fixed test configuration. +func initService() error { + serviceURL, err := url.Parse("pushbullet://tokentokentokentokentokentokentoke/test") + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) + + return service.Initialize(serviceURL, testutils.TestLogger()) +} diff --git a/pkg/services/pushover/pushover.go b/pkg/services/pushover/pushover.go new file mode 100644 index 0000000..6f01b69 --- /dev/null +++ b/pkg/services/pushover/pushover.go @@ -0,0 +1,114 @@ +package pushover + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// hookURL is the Pushover API endpoint for sending messages. +const ( + hookURL = "https://api.pushover.net/1/messages.json" + contentType = "application/x-www-form-urlencoded" + defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests. +) + +// ErrSendFailed indicates a failure in sending the notification to a Pushover device. +var ErrSendFailed = errors.New("failed to send notification to pushover device") + +// Service provides the Pushover notification service. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver + Client *http.Client +} + +// Send delivers a notification message to Pushover. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + device := strings.Join(config.Devices, ",") + if err := service.sendToDevice(device, message, config); err != nil { + return fmt.Errorf("failed to send notifications to pushover devices: %w", err) + } + + return nil +} + +// sendToDevice sends a notification to a specific Pushover device. +func (service *Service) sendToDevice(device string, message string, config *Config) error { + data := url.Values{} + data.Set("device", device) + data.Set("user", config.User) + data.Set("token", config.Token) + data.Set("message", message) + + if len(config.Title) > 0 { + data.Set("title", config.Title) + } + + if config.Priority >= -2 && config.Priority <= 1 { + data.Set("priority", strconv.FormatInt(int64(config.Priority), 10)) + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + hookURL, + strings.NewReader(data.Encode()), + ) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", contentType) + + res, err := service.Client.Do(req) + if err != nil { + return fmt.Errorf("sending request to Pushover API: %w", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %q, response status %q", ErrSendFailed, device, res.Status) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + service.Client = &http.Client{ + Timeout: defaultHTTPTimeout, + } + + if err := service.Config.setURL(&service.pkr, configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} diff --git a/pkg/services/pushover/pushover_config.go b/pkg/services/pushover/pushover_config.go new file mode 100644 index 0000000..cc9893f --- /dev/null +++ b/pkg/services/pushover/pushover_config.go @@ -0,0 +1,83 @@ +package pushover + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifying part of this service's configuration URL. +const Scheme = "pushover" + +// Static errors for configuration validation. +var ( + ErrUserMissing = errors.New("user missing from config URL") + ErrTokenMissing = errors.New("token missing from config URL") +) + +// Config for the Pushover notification service. +type Config struct { + Token string `desc:"API Token/Key" url:"pass"` + User string `desc:"User Key" url:"host"` + Devices []string ` key:"devices" optional:""` + Priority int8 ` key:"priority" default:"0"` + Title string ` key:"title" optional:""` +} + +// Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values. +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} + +// GetURL returns a URL representation of its current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the Config from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + config.User = url.Host + config.Token = password + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + if url.String() != "pushover://dummy@dummy.com" { + if len(config.User) < 1 { + return ErrUserMissing + } + + if len(config.Token) < 1 { + return ErrTokenMissing + } + } + + return nil +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: url.UserPassword("Token", config.Token), + Host: config.User, + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} diff --git a/pkg/services/pushover/pushover_error.go b/pkg/services/pushover/pushover_error.go new file mode 100644 index 0000000..30e5146 --- /dev/null +++ b/pkg/services/pushover/pushover_error.go @@ -0,0 +1,11 @@ +package pushover + +// ErrorMessage for error events within the pushover service. +type ErrorMessage string + +const ( + // UserMissing should be used when a config URL is missing a user. + UserMissing ErrorMessage = "user missing from config URL" + // TokenMissing should be used when a config URL is missing a token. + TokenMissing ErrorMessage = "token missing from config URL" +) diff --git a/pkg/services/pushover/pushover_test.go b/pkg/services/pushover/pushover_test.go new file mode 100644 index 0000000..d18323b --- /dev/null +++ b/pkg/services/pushover/pushover_test.go @@ -0,0 +1,197 @@ +package pushover_test + +import ( + "errors" + "log" + "net/url" + "os" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/pushover" +) + +const hookURL = "https://api.pushover.net/1/messages.json" + +func TestPushover(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Pushover Suite") +} + +var ( + service *pushover.Service + config *pushover.Config + keyResolver format.PropKeyResolver + envPushoverURL *url.URL + logger *log.Logger + _ = ginkgo.BeforeSuite(func() { + service = &pushover.Service{} + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + envPushoverURL, _ = url.Parse(os.Getenv("SHOUTRRR_PUSHOVER_URL")) + }) +) + +var _ = ginkgo.Describe("the pushover service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should work", func() { + if envPushoverURL.String() == "" { + return + } + serviceURL, _ := url.Parse(envPushoverURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("this is an integration test", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("pushover")) + }) + }) +}) + +var _ = ginkgo.Describe("the pushover config", func() { + ginkgo.BeforeEach(func() { + config = &pushover.Config{} + keyResolver = format.NewPropKeyResolver(config) + }) + ginkgo.When("updating it using an url", func() { + ginkgo.It("should update the username using the host part of the url", func() { + url := createURL("simme", "dummy") + err := config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.User).To(gomega.Equal("simme")) + }) + ginkgo.It("should update the token using the password part of the url", func() { + url := createURL("dummy", "TestToken") + err := config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Token).To(gomega.Equal("TestToken")) + }) + ginkgo.It("should error if supplied with an empty username", func() { + url := createURL("", "token") + expectErrorMessageGivenURL(pushover.UserMissing, url) + }) + ginkgo.It("should error if supplied with an empty token", func() { + url := createURL("user", "") + expectErrorMessageGivenURL(pushover.TokenMissing, url) + }) + }) + ginkgo.When("getting the current config", func() { + ginkgo.It("should return the config that is currently set as an url", func() { + config.User = "simme" + config.Token = "test-token" + + url := config.GetURL() + password, _ := url.User.Password() + gomega.Expect(url.Host).To(gomega.Equal(config.User)) + gomega.Expect(password).To(gomega.Equal(config.Token)) + gomega.Expect(url.Scheme).To(gomega.Equal("pushover")) + }) + }) + ginkgo.When("setting a config key", func() { + ginkgo.It("should split it by commas if the key is devices", func() { + err := keyResolver.Set("devices", "a,b,c,d") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Devices).To(gomega.Equal([]string{"a", "b", "c", "d"})) + }) + ginkgo.It("should update priority when a valid number is supplied", func() { + err := keyResolver.Set("priority", "1") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Priority).To(gomega.Equal(int8(1))) + }) + ginkgo.It("should update priority when a negative number is supplied", func() { + gomega.Expect(keyResolver.Set("priority", "-1")).To(gomega.Succeed()) + gomega.Expect(config.Priority).To(gomega.BeEquivalentTo(-1)) + + gomega.Expect(keyResolver.Set("priority", "-2")).To(gomega.Succeed()) + gomega.Expect(config.Priority).To(gomega.BeEquivalentTo(-2)) + }) + ginkgo.It("should update the title when it is supplied", func() { + err := keyResolver.Set("title", "new title") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Title).To(gomega.Equal("new title")) + }) + ginkgo.It("should return an error if priority is not a number", func() { + err := keyResolver.Set("priority", "super-duper") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("should return an error if the key is not recognized", func() { + err := keyResolver.Set("devicey", "a,b,c,d") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("getting a config key", func() { + ginkgo.It("should join it with commas if the key is devices", func() { + config.Devices = []string{"a", "b", "c"} + value, err := keyResolver.Get("devices") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(value).To(gomega.Equal("a,b,c")) + }) + ginkgo.It("should return an error if the key is not recognized", func() { + _, err := keyResolver.Get("devicey") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("listing the query fields", func() { + ginkgo.It("should return the keys \"devices\",\"priority\",\"title\"", func() { + fields := keyResolver.QueryFields() + gomega.Expect(fields).To(gomega.Equal([]string{"devices", "priority", "title"})) + }) + }) + + ginkgo.Describe("sending the payload", func() { + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL, err := url.Parse("pushover://:apptoken@usertoken") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder("POST", hookURL, httpmock.NewStringResponder(200, "")) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not panic if an error occurs when sending the payload", func() { + serviceURL, err := url.Parse("pushover://:apptoken@usertoken") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + hookURL, + httpmock.NewErrorResponder(errors.New("dummy error")), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) +}) + +func createURL(username string, token string) *url.URL { + return &url.URL{ + User: url.UserPassword("Token", token), + Host: username, + } +} + +func expectErrorMessageGivenURL(msg pushover.ErrorMessage, url *url.URL) { + err := config.SetURL(url) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.Equal(string(msg))) +} diff --git a/pkg/services/rocketchat/rocketchat.go b/pkg/services/rocketchat/rocketchat.go new file mode 100644 index 0000000..fd43715 --- /dev/null +++ b/pkg/services/rocketchat/rocketchat.go @@ -0,0 +1,103 @@ +package rocketchat + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// defaultHTTPTimeout is the default timeout for HTTP requests. +const defaultHTTPTimeout = 10 * time.Second + +// ErrNotificationFailed indicates a failure in sending the notification. +var ErrNotificationFailed = errors.New("notification failed") + +// Service sends notifications to a pre-configured Rocket.Chat channel or user. +type Service struct { + standard.Standard + Config *Config + Client *http.Client +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + + service.Config = &Config{} + if service.Client == nil { + service.Client = &http.Client{ + Timeout: defaultHTTPTimeout, // Set a default timeout + } + } + + if err := service.Config.SetURL(configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// Send delivers a notification message to Rocket.Chat. +func (service *Service) Send(message string, params *types.Params) error { + var res *http.Response + + var err error + + config := service.Config + apiURL := buildURL(config) + json, _ := CreateJSONPayload(config, message, params) + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(json)) + if err != nil { + return fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + res, err = service.Client.Do(req) + if err != nil { + return fmt.Errorf( + "posting to URL: %w\nHOST: %s\nPORT: %s", + err, + config.Host, + config.Port, + ) + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + resBody, _ := io.ReadAll(res.Body) + + return fmt.Errorf("%w: %d %s", ErrNotificationFailed, res.StatusCode, resBody) + } + + return nil +} + +// buildURL constructs the API URL for Rocket.Chat based on the Config. +func buildURL(config *Config) string { + base := config.Host + if config.Port != "" { + base = net.JoinHostPort(config.Host, config.Port) + } + + return fmt.Sprintf("https://%s/hooks/%s/%s", base, config.TokenA, config.TokenB) +} diff --git a/pkg/services/rocketchat/rocketchat_config.go b/pkg/services/rocketchat/rocketchat_config.go new file mode 100644 index 0000000..8048461 --- /dev/null +++ b/pkg/services/rocketchat/rocketchat_config.go @@ -0,0 +1,91 @@ +package rocketchat + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" +) + +// Scheme is the identifying part of this service's configuration URL. +const Scheme = "rocketchat" + +// Constants for URL path length checks. +const ( + MinPathParts = 3 // Minimum number of path parts required (including empty first slash) + TokenBIndex = 2 // Index for TokenB in path + ChannelIndex = 3 // Index for Channel in path +) + +// Static errors for configuration validation. +var ( + ErrNotEnoughArguments = errors.New("the apiURL does not include enough arguments") +) + +// Config for the Rocket.Chat service. +type Config struct { + standard.EnumlessConfig + UserName string `optional:"" url:"user"` + Host string ` url:"host"` + Port string ` url:"port"` + TokenA string ` url:"path1"` + Channel string ` url:"path3"` + TokenB string ` url:"path2"` +} + +// GetURL returns a URL representation of the Config's current field values. +func (config *Config) GetURL() *url.URL { + host := config.Host + if config.Port != "" { + host = fmt.Sprintf("%s:%s", config.Host, config.Port) + } + + url := &url.URL{ + Host: host, + Path: fmt.Sprintf("%s/%s", config.TokenA, config.TokenB), + Scheme: Scheme, + ForceQuery: false, + } + + return url +} + +// SetURL updates the Config from a URL representation of its field values. +func (config *Config) SetURL(serviceURL *url.URL) error { + userName := serviceURL.User.Username() + host := serviceURL.Hostname() + + path := strings.Split(serviceURL.Path, "/") + if serviceURL.String() != "rocketchat://dummy@dummy.com" { + if len(path) < MinPathParts { + return ErrNotEnoughArguments + } + } + + config.Port = serviceURL.Port() + config.UserName = userName + config.Host = host + + if len(path) > 1 { + config.TokenA = path[1] + } + + if len(path) > TokenBIndex { + config.TokenB = path[TokenBIndex] + } + + if len(path) > ChannelIndex { + switch { + case serviceURL.Fragment != "": + config.Channel = "#" + serviceURL.Fragment + case !strings.HasPrefix(path[ChannelIndex], "@"): + config.Channel = "#" + path[ChannelIndex] + default: + config.Channel = path[ChannelIndex] + } + } + + return nil +} diff --git a/pkg/services/rocketchat/rocketchat_json.go b/pkg/services/rocketchat/rocketchat_json.go new file mode 100644 index 0000000..9e79830 --- /dev/null +++ b/pkg/services/rocketchat/rocketchat_json.go @@ -0,0 +1,41 @@ +package rocketchat + +import ( + "encoding/json" + "fmt" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// JSON represents the payload structure for the Rocket.Chat service. +type JSON struct { + Text string `json:"text"` + UserName string `json:"username,omitempty"` + Channel string `json:"channel,omitempty"` +} + +// CreateJSONPayload generates a JSON payload compatible with the Rocket.Chat webhook API. +func CreateJSONPayload(config *Config, message string, params *types.Params) ([]byte, error) { + payload := JSON{ + Text: message, + UserName: config.UserName, + Channel: config.Channel, + } + + if params != nil { + if value, found := (*params)["username"]; found { + payload.UserName = value + } + + if value, found := (*params)["channel"]; found { + payload.Channel = value + } + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshaling Rocket.Chat payload to JSON: %w", err) + } + + return payloadBytes, nil +} diff --git a/pkg/services/rocketchat/rocketchat_test.go b/pkg/services/rocketchat/rocketchat_test.go new file mode 100644 index 0000000..5d0a893 --- /dev/null +++ b/pkg/services/rocketchat/rocketchat_test.go @@ -0,0 +1,252 @@ +package rocketchat + +import ( + "crypto/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var ( + service *Service + envRocketchatURL *url.URL + _ = ginkgo.BeforeSuite(func() { + service = &Service{} + envRocketchatURL, _ = url.Parse(os.Getenv("SHOUTRRR_ROCKETCHAT_URL")) + }) +) + +// Constants for repeated test values. +const ( + testTokenA = "tokenA" + testTokenB = "tokenB" +) + +func TestRocketchat(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Rocketchat Suite") +} + +var _ = ginkgo.Describe("the rocketchat service", func() { + // Add tests for Initialize() + ginkgo.Describe("Initialize method", func() { + ginkgo.When("initializing with a valid URL", func() { + ginkgo.It("should set logger and config without error", func() { + service := &Service{} + testURL, _ := url.Parse( + "rocketchat://testUser@rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB + "/#testChannel", + ) + err := service.Initialize(testURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config).NotTo(gomega.BeNil()) + gomega.Expect(service.Config.Host).To(gomega.Equal("rocketchat.my-domain.com")) + gomega.Expect(service.Config.Port).To(gomega.Equal("5055")) + gomega.Expect(service.Config.UserName).To(gomega.Equal("testUser")) + gomega.Expect(service.Config.TokenA).To(gomega.Equal(testTokenA)) + gomega.Expect(service.Config.TokenB).To(gomega.Equal(testTokenB)) + gomega.Expect(service.Config.Channel).To(gomega.Equal("#testChannel")) + }) + }) + ginkgo.When("initializing with an invalid URL", func() { + ginkgo.It("should return an error", func() { + service := &Service{} + testURL, _ := url.Parse("rocketchat://rocketchat.my-domain.com") // Missing tokens + err := service.Initialize(testURL, testutils.TestLogger()) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err). + To(gomega.Equal(ErrNotEnoughArguments)) + // Updated to use the error variable + }) + }) + }) + + // Add tests for Send() + ginkgo.Describe("Send method", func() { + var ( + mockServer *httptest.Server + service *Service + client *http.Client + ) + + ginkgo.BeforeEach(func() { + // Create TLS server + mockServer = httptest.NewTLSServer(nil) // Handler set in each test + + // Configure client to trust the mock server's certificate + certPool := x509.NewCertPool() + for _, cert := range mockServer.TLS.Certificates { + certPool.AddCert(cert.Leaf) + } + client = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, // Explicitly set minimum TLS version to 1.2 + }, + }, + } + + service = &Service{ + Config: &Config{}, + Client: client, // Assign the custom client here + } + service.SetLogger(testutils.TestLogger()) + }) + + ginkgo.AfterEach(func() { + if mockServer != nil { + mockServer.Close() + } + }) + + ginkgo.When("sending a message to a mock server with success", func() { + ginkgo.It("should return no error", func() { + mockServer.Config.Handler = http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ) + mockURL, _ := url.Parse(mockServer.URL) + service.Config.Host = mockURL.Hostname() + service.Config.Port = mockURL.Port() + service.Config.TokenA = testTokenA + service.Config.TokenB = testTokenB + + err := service.Send("test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("sending a message to a mock server with failure", func() { + ginkgo.It("should return an error with status code and body", func() { + mockServer.Config.Handler = http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("bad request")) + }, + ) + mockURL, _ := url.Parse(mockServer.URL) + service.Config.Host = mockURL.Hostname() + service.Config.Port = mockURL.Port() + service.Config.TokenA = testTokenA + service.Config.TokenB = testTokenB + + err := service.Send("test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("notification failed: 400 bad request")) + }) + }) + + ginkgo.When("sending a message to an unreachable server", func() { + ginkgo.It("should return a connection error", func() { + service.Client = http.DefaultClient // Reset to default client for this test + service.Config.Host = "nonexistent.domain" + service.Config.TokenA = testTokenA + service.Config.TokenB = testTokenB + + err := service.Send("test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("posting to URL")) + }) + }) + + ginkgo.When("sending a message with params overriding username and channel", func() { + ginkgo.It("should use params values in the payload", func() { + mockServer.Config.Handler = http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }, + ) + mockURL, _ := url.Parse(mockServer.URL) + service.Config.Host = mockURL.Hostname() + service.Config.Port = mockURL.Port() + service.Config.TokenA = testTokenA + service.Config.TokenB = testTokenB + service.Config.UserName = "defaultUser" + service.Config.Channel = "#defaultChannel" + + params := types.Params{ + "username": "overrideUser", + "channel": "#overrideChannel", + } + err := service.Send("test message", ¶ms) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + // Note: We can't directly inspect the payload here without mocking CreateJSONPayload, + // but this ensures the params path is exercised. + }) + }) + }) + + // Add tests for GetURL() and SetURL() + ginkgo.Describe("the rocketchat config", func() { + ginkgo.When("generating a URL from a config with all fields", func() { + ginkgo.It("should construct a correct URL", func() { + config := &Config{ + Host: "rocketchat.my-domain.com", + Port: "5055", + TokenA: testTokenA, + TokenB: testTokenB, + } + url := config.GetURL() + gomega.Expect(url.String()). + To(gomega.Equal("rocketchat://rocketchat.my-domain.com:5055/" + testTokenA + "/" + testTokenB)) + }) + }) + + ginkgo.When("generating a URL from a config without port", func() { + ginkgo.It("should construct a correct URL without port", func() { + config := &Config{ + Host: "rocketchat.my-domain.com", + TokenA: testTokenA, + TokenB: testTokenB, + } + url := config.GetURL() + gomega.Expect(url.String()). + To(gomega.Equal("rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB)) + }) + }) + + ginkgo.When("setting URL with a channel starting with @", func() { + ginkgo.It("should set channel without adding #", func() { + config := &Config{} + testURL, _ := url.Parse( + "rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/@user", + ) + err := config.SetURL(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Channel).To(gomega.Equal("@user")) + }) + }) + + ginkgo.When("setting URL with a regular channel without fragment", func() { + ginkgo.It("should prepend # to the channel", func() { + config := &Config{} + testURL, _ := url.Parse( + "rocketchat://rocketchat.my-domain.com/" + testTokenA + "/" + testTokenB + "/general", + ) + err := config.SetURL(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Channel).To(gomega.Equal("#general")) + }) + }) + }) + + // Add test for GetID() + ginkgo.Describe("GetID method", func() { + ginkgo.It("should return the correct scheme", func() { + service := &Service{} + id := service.GetID() + gomega.Expect(id).To(gomega.Equal(Scheme)) + }) + }) +}) diff --git a/pkg/services/services_test.go b/pkg/services/services_test.go new file mode 100644 index 0000000..edf7dc0 --- /dev/null +++ b/pkg/services/services_test.go @@ -0,0 +1,142 @@ +package services_test + +import ( + "log" + "net/http" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/router" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestServices(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Service Compliance Suite") +} + +var serviceURLs = map[string]string{ + "discord": "discord://token@id", + "gotify": "gotify://example.com/Aaa.bbb.ccc.ddd", + "googlechat": "googlechat://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + "hangouts": "hangouts://chat.googleapis.com/v1/spaces/FOO/messages?key=bar&token=baz", + "ifttt": "ifttt://key?events=event", + "join": "join://:apikey@join/?devices=device", + "logger": "logger://", + "mattermost": "mattermost://user@example.com/token", + "opsgenie": "opsgenie://example.com/token?responders=user:dummy", + "pushbullet": "pushbullet://tokentokentokentokentokentokentoke", + "pushover": "pushover://:token@user/?devices=device", + "rocketchat": "rocketchat://example.com/token/channel", + "slack": "slack://AAAAAAAAA/BBBBBBBBB/123456789123456789123456", + "smtp": "smtp://host.tld:25/?fromAddress=from@host.tld&toAddresses=to@host.tld", + "teams": "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=test.webhook.office.com", + "telegram": "telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram?channels=channel", + "xmpp": "xmpp://", + "zulip": "zulip://mail:key@example.com/?stream=foo&topic=bar", +} + +var serviceResponses = map[string]string{ + "discord": "", + "gotify": `{"id": 0}`, + "googlechat": "", + "hangouts": "", + "ifttt": "", + "join": "", + "logger": "", + "mattermost": "", + "opsgenie": "", + "pushbullet": `{"type": "note", "body": "test", "title": "test title", "active": true, "created": 0}`, + "pushover": "", + "rocketchat": "", + "slack": "", + "smtp": "", + "teams": "", + "telegram": "", + "xmpp": "", + "zulip": "", +} + +var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + +var _ = ginkgo.Describe("services", func() { + ginkgo.BeforeEach(func() { + }) + ginkgo.AfterEach(func() { + }) + + ginkgo.When("passed the a title param", func() { + var serviceRouter *router.ServiceRouter + + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + for key, configURL := range serviceURLs { + serviceRouter, _ = router.New(logger) + + ginkgo.It("should not throw an error for "+key, func() { + if key == "smtp" { + ginkgo.Skip("smtp does not use HTTP and needs a specific test") + } + if key == "xmpp" { + ginkgo.Skip("not supported") + } + + service, err := serviceRouter.Locate(configURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.Activate() + if mockService, ok := service.(testutils.MockClientService); ok { + httpmock.ActivateNonDefault(mockService.GetHTTPClient()) + } + + respStatus := http.StatusOK + if key == "discord" || key == "ifttt" { + respStatus = http.StatusNoContent + } + if key == "mattermost" { + httpmock.RegisterResponder( + "POST", + "https://example.com/hooks/token", + httpmock.NewStringResponder(http.StatusOK, ""), + ) + } else { + httpmock.RegisterNoResponder(httpmock.NewStringResponder(respStatus, serviceResponses[key])) + } + + err = service.Send("test", (*types.Params)(&map[string]string{ + "title": "test title", + })) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + if key == "mattermost" { + ginkgo.It("should not throw an error for "+key+" with DisableTLS", func() { + modifiedURL := configURL + "?disabletls=yes" + service, err := serviceRouter.Locate(modifiedURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.Activate() + if mockService, ok := service.(testutils.MockClientService); ok { + httpmock.ActivateNonDefault(mockService.GetHTTPClient()) + } + httpmock.RegisterResponder( + "POST", + "http://example.com/hooks/token", + httpmock.NewStringResponder(http.StatusOK, ""), + ) + + err = service.Send("test", (*types.Params)(&map[string]string{ + "title": "test title", + })) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + } + } + }) +}) diff --git a/pkg/services/slack/slack.go b/pkg/services/slack/slack.go new file mode 100644 index 0000000..df35594 --- /dev/null +++ b/pkg/services/slack/slack.go @@ -0,0 +1,142 @@ +package slack + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +// apiPostMessage is the Slack API endpoint for sending messages. +const ( + apiPostMessage = "https://slack.com/api/chat.postMessage" + defaultHTTPTimeout = 10 * time.Second // defaultHTTPTimeout is the default timeout for HTTP requests. +) + +// Service sends notifications to a pre-configured Slack channel or user. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver + client *http.Client +} + +// Send delivers a notification message to Slack. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + payload := CreateJSONPayload(config, message) + + var err error + if config.Token.IsAPIToken() { + err = service.sendAPI(config, payload) + } else { + err = service.sendWebhook(config, payload) + } + + if err != nil { + return fmt.Errorf("failed to send slack notification: %w", err) + } + + return nil +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + service.client = &http.Client{ + Timeout: defaultHTTPTimeout, + } + + return service.Config.setURL(&service.pkr, configURL) +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// sendAPI sends a notification using the Slack API. +func (service *Service) sendAPI(config *Config, payload any) error { + response := APIResponse{} + jsonClient := jsonclient.NewClient() + jsonClient.Headers().Set("Authorization", config.Token.Authorization()) + + if err := jsonClient.Post(apiPostMessage, payload, &response); err != nil { + return fmt.Errorf("posting to Slack API: %w", err) + } + + if !response.Ok { + if response.Error != "" { + return fmt.Errorf("%w: %v", ErrAPIResponseFailure, response.Error) + } + + return ErrUnknownAPIError + } + + if response.Warning != "" { + service.Logf("Slack API warning: %q", response.Warning) + } + + return nil +} + +// sendWebhook sends a notification using a Slack webhook. +func (service *Service) sendWebhook(config *Config, payload any) error { + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), defaultHTTPTimeout) + defer cancel() + + req, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + config.Token.WebhookURL(), + bytes.NewBuffer(payloadBytes), + ) + if err != nil { + return fmt.Errorf("failed to create webhook request: %w", err) + } + + req.Header.Set("Content-Type", jsonclient.ContentType) + + res, err := service.client.Do(req) + if err != nil { + return fmt.Errorf("failed to invoke webhook: %w", err) + } + + defer res.Body.Close() + resBytes, _ := io.ReadAll(res.Body) + response := string(resBytes) + + switch response { + case "": + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %v", ErrWebhookStatusFailure, res.Status) + } + + fallthrough + case "ok": + return nil + default: + return fmt.Errorf("%w: %v", ErrWebhookResponseFailure, response) + } +} diff --git a/pkg/services/slack/slack_config.go b/pkg/services/slack/slack_config.go new file mode 100644 index 0000000..99c425e --- /dev/null +++ b/pkg/services/slack/slack_config.go @@ -0,0 +1,91 @@ +package slack + +import ( + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + // Scheme is the identifying part of this service's configuration URL. + Scheme = "slack" +) + +// Config for the slack service. +type Config struct { + standard.EnumlessConfig + BotName string `desc:"Bot name" key:"botname,username" optional:"uses bot default"` + Icon string `desc:"Use emoji or URL as icon (based on presence of http(s):// prefix)" key:"icon,icon_emoji,icon_url" optional:"" default:""` + Token Token `desc:"API Bot token" url:"user,pass"` + Color string `desc:"Message left-hand border color" key:"color" optional:"default border color"` + Title string `desc:"Prepended text above the message" key:"title" optional:"omitted"` + Channel string `desc:"Channel to send messages to in Cxxxxxxxxxx format" url:"host"` + ThreadTS string `desc:"ts value of the parent message (to send message as reply in thread)" key:"thread_ts" optional:""` +} + +// GetURL returns a URL representation of it's current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates a ServiceConfig from a URL representation of it's field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: config.Token.UserInfo(), + Host: config.Channel, + Scheme: Scheme, + ForceQuery: false, + RawQuery: format.BuildQuery(resolver), + } +} + +func (config *Config) setURL(resolver types.ConfigQueryResolver, serviceURL *url.URL) error { + var token string + + var err error + + if len(serviceURL.Path) > 1 { + // Reading legacy config URL format + token = serviceURL.Hostname() + serviceURL.Path + config.Channel = "webhook" + config.BotName = serviceURL.User.Username() + } else { + token = serviceURL.User.String() + config.Channel = serviceURL.Hostname() + } + + if serviceURL.String() != "slack://dummy@dummy.com" { + if err = config.Token.SetFromProp(token); err != nil { + return err + } + } else { + config.Token.raw = token // Set raw token without validation + } + + for key, vals := range serviceURL.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + return nil +} + +// CreateConfigFromURL to use within the slack service. +func CreateConfigFromURL(serviceURL *url.URL) (*Config, error) { + config := Config{} + err := config.SetURL(serviceURL) + + return &config, err +} diff --git a/pkg/services/slack/slack_errors.go b/pkg/services/slack/slack_errors.go new file mode 100644 index 0000000..19a2866 --- /dev/null +++ b/pkg/services/slack/slack_errors.go @@ -0,0 +1,21 @@ +package slack + +import "errors" + +// ErrInvalidToken is returned when the specified token does not match any known formats. +var ErrInvalidToken = errors.New("invalid slack token format") + +// ErrMismatchedTokenSeparators is returned if the token uses different separators between parts (of the recognized `/-,`). +var ErrMismatchedTokenSeparators = errors.New("invalid webhook token format") + +// ErrAPIResponseFailure indicates a failure in the Slack API response. +var ErrAPIResponseFailure = errors.New("api response failure") + +// ErrUnknownAPIError indicates an unknown error from the Slack API. +var ErrUnknownAPIError = errors.New("unknown error from Slack API") + +// ErrWebhookStatusFailure indicates a failure due to an unexpected webhook status code. +var ErrWebhookStatusFailure = errors.New("webhook status failure") + +// ErrWebhookResponseFailure indicates a failure in the webhook response content. +var ErrWebhookResponseFailure = errors.New("webhook response failure") diff --git a/pkg/services/slack/slack_json.go b/pkg/services/slack/slack_json.go new file mode 100644 index 0000000..057b6bb --- /dev/null +++ b/pkg/services/slack/slack_json.go @@ -0,0 +1,125 @@ +package slack + +import ( + "regexp" + "strings" +) + +// Constants for Slack API limits. +const ( + MaxAttachments = 100 // Maximum number of attachments allowed by Slack API +) + +var iconURLPattern = regexp.MustCompile(`https?://`) + +// MessagePayload used within the Slack service. +type MessagePayload struct { + Text string `json:"text"` + BotName string `json:"username,omitempty"` + Blocks []block `json:"blocks,omitempty"` + Attachments []attachment `json:"attachments,omitempty"` + ThreadTS string `json:"thread_ts,omitempty"` + Channel string `json:"channel,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + IconURL string `json:"icon_url,omitempty"` +} + +type block struct { + Type string `json:"type"` + Text blockText `json:"text"` +} + +type blockText struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type attachment struct { + Title string `json:"title,omitempty"` + Fallback string `json:"fallback,omitempty"` + Text string `json:"text"` + Color string `json:"color,omitempty"` + Fields []legacyField `json:"fields,omitempty"` + Footer string `json:"footer,omitempty"` + Time int `json:"ts,omitempty"` +} + +type legacyField struct { + Title string `json:"title"` + Value string `json:"value"` + Short bool `json:"short,omitempty"` +} + +// APIResponse is the default generic response message sent from the API. +type APIResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Warning string `json:"warning"` + MetaData struct { + Warnings []string `json:"warnings"` + } `json:"response_metadata"` +} + +// CreateJSONPayload compatible with the slack post message API. +func CreateJSONPayload(config *Config, message string) any { + lines := strings.Split(message, "\n") + // Pre-allocate atts with a capacity of min(len(lines), MaxAttachments) + atts := make([]attachment, 0, minInt(len(lines), MaxAttachments)) + + for i, line := range lines { + // When MaxAttachments have been reached, append the remaining lines to the last attachment + if i >= MaxAttachments { + atts[MaxAttachments-1].Text += "\n" + line + + continue + } + + atts = append(atts, attachment{ + Text: line, + Color: config.Color, + }) + } + + // Remove last attachment if empty + if len(atts) > 0 && atts[len(atts)-1].Text == "" { + atts = atts[:len(atts)-1] + } + + payload := MessagePayload{ + ThreadTS: config.ThreadTS, + Text: config.Title, + BotName: config.BotName, + Attachments: atts, + } + + payload.SetIcon(config.Icon) + + if config.Channel != "webhook" { + payload.Channel = config.Channel + } + + return payload +} + +// SetIcon sets the appropriate icon field in the payload based on whether the input is a URL or not. +func (p *MessagePayload) SetIcon(icon string) { + p.IconURL = "" + p.IconEmoji = "" + + if icon != "" { + if iconURLPattern.MatchString(icon) { + p.IconURL = icon + } else { + p.IconEmoji = icon + } + } +} + +// minInt returns the smaller of two integers. +func minInt(a, b int) int { + if a < b { + return a + } + + return b +} diff --git a/pkg/services/slack/slack_test.go b/pkg/services/slack/slack_test.go new file mode 100644 index 0000000..f1f8c60 --- /dev/null +++ b/pkg/services/slack/slack_test.go @@ -0,0 +1,332 @@ +package slack_test + +import ( + "errors" + "fmt" + "log" + "net/url" + "os" + "strings" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/format" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/services/slack" +) + +const ( + TestWebhookURL = "https://hooks.slack.com/services/AAAAAAAAA/BBBBBBBBB/123456789123456789123456" +) + +func TestSlack(t *testing.T) { + format.CharactersAroundMismatchToInclude = 20 + + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Slack Suite") +} + +var ( + service *slack.Service + envSlackURL *url.URL + logger *log.Logger + _ = ginkgo.BeforeSuite(func() { + service = &slack.Service{} + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + envSlackURL, _ = url.Parse(os.Getenv("SHOUTRRR_SLACK_URL")) + }) +) + +var _ = ginkgo.Describe("the slack service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + if envSlackURL.String() == "" { + return + } + + serviceURL, _ := url.Parse(envSlackURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("slack")) + }) + }) + + // xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N + + ginkgo.When("given a token with a malformed part", func() { + ginkgo.It("should return an error if part A is not 9 letters", func() { + expectErrorMessageGivenURL( + slack.ErrInvalidToken, + "slack://lol@12345678/123456789/123456789123456789123456", + ) + }) + ginkgo.It("should return an error if part B is not 9 letters", func() { + expectErrorMessageGivenURL( + slack.ErrInvalidToken, + "slack://lol@123456789/12345678/123456789123456789123456", + ) + }) + ginkgo.It("should return an error if part C is not 24 letters", func() { + expectErrorMessageGivenURL( + slack.ErrInvalidToken, + "slack://123456789/123456789/12345678912345678912345", + ) + }) + }) + ginkgo.When("given a token missing a part", func() { + ginkgo.It("should return an error if the missing part is A", func() { + expectErrorMessageGivenURL( + slack.ErrInvalidToken, + "slack://lol@/123456789/123456789123456789123456", + ) + }) + ginkgo.It("should return an error if the missing part is B", func() { + expectErrorMessageGivenURL(slack.ErrInvalidToken, "slack://lol@123456789//123456789") + }) + ginkgo.It("should return an error if the missing part is C", func() { + expectErrorMessageGivenURL(slack.ErrInvalidToken, "slack://lol@123456789/123456789/") + }) + }) + ginkgo.Describe("the slack config", func() { + ginkgo.When("parsing the configuration URL", func() { + ginkgo.When("given a config using the legacy format", func() { + ginkgo.It("should be converted to the new format after de-/serialization", func() { + oldURL := "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456?color=3f00fe&title=Test+title" + newURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title" + + config := &slack.Config{} + err := config.SetURL(testutils.URLMust(oldURL)) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + gomega.Expect(config.GetURL().String()).To(gomega.Equal(newURL)) + }) + }) + }) + ginkgo.When("the URL contains an invalid property", func() { + testURL := testutils.URLMust( + "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?bass=dirty", + ) + err := (&slack.Config{}).SetURL(testURL) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + ginkgo.It("should be identical after de-/serialization", func() { + testURL := "slack://hook:AAAAAAAAA-BBBBBBBBB-123456789123456789123456@webhook?botname=testbot&color=3f00fe&title=Test+title" + + config := &slack.Config{} + err := config.SetURL(testutils.URLMust(testURL)) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + ginkgo.When("generating a config object", func() { + ginkgo.It( + "should use the default botname if the argument list contains three strings", + func() { + slackURL, _ := url.Parse("slack://AAAAAAAAA/BBBBBBBBB/123456789123456789123456") + config, configError := slack.CreateConfigFromURL(slackURL) + + gomega.Expect(configError).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.BotName).To(gomega.BeEmpty()) + }, + ) + ginkgo.It("should set the botname if the argument list is three", func() { + slackURL, _ := url.Parse( + "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456", + ) + config, configError := slack.CreateConfigFromURL(slackURL) + + gomega.Expect(configError).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.BotName).To(gomega.Equal("testbot")) + }) + ginkgo.It("should return an error if the argument list is shorter than three", func() { + slackURL, _ := url.Parse("slack://AAAAAAAA") + + _, configError := slack.CreateConfigFromURL(slackURL) + gomega.Expect(configError).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("getting credentials from token", func() { + ginkgo.It("should return a valid webhook URL for the given token", func() { + token := tokenMust("AAAAAAAAA/BBBBBBBBB/123456789123456789123456") + gomega.Expect(token.WebhookURL()).To(gomega.Equal(TestWebhookURL)) + }) + ginkgo.It( + "should return a valid authorization header value for the given token", + func() { + token := tokenMust("xoxb:AAAAAAAAA-BBBBBBBBB-123456789123456789123456") + expected := "Bearer xoxb-AAAAAAAAA-BBBBBBBBB-123456789123456789123456" + gomega.Expect(token.Authorization()).To(gomega.Equal(expected)) + }, + ) + }) + }) + + ginkgo.Describe("creating the payload", func() { + ginkgo.Describe("the icon fields", func() { + payload := slack.MessagePayload{} + ginkgo.It("should set IconURL when the configured icon looks like an URL", func() { + payload.SetIcon("https://example.com/logo.png") + gomega.Expect(payload.IconURL).To(gomega.Equal("https://example.com/logo.png")) + gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty()) + }) + ginkgo.It( + "should set IconEmoji when the configured icon does not look like an URL", + func() { + payload.SetIcon("tanabata_tree") + gomega.Expect(payload.IconEmoji).To(gomega.Equal("tanabata_tree")) + gomega.Expect(payload.IconURL).To(gomega.BeEmpty()) + }, + ) + ginkgo.It("should clear both fields when icon is empty", func() { + payload.SetIcon("") + gomega.Expect(payload.IconEmoji).To(gomega.BeEmpty()) + gomega.Expect(payload.IconURL).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("when more than 99 lines are being sent", func() { + ginkgo.It("should append the exceeding lines to the last attachment", func() { + config := slack.Config{} + sb := strings.Builder{} + for i := 1; i <= 110; i++ { + sb.WriteString(fmt.Sprintf("Line %d\n", i)) + } + payload := slack.CreateJSONPayload(&config, sb.String()).(slack.MessagePayload) + atts := payload.Attachments + + fmt.Fprint( + ginkgo.GinkgoWriter, + "\nLines: ", + len(atts), + " Last: ", + atts[len(atts)-1], + "\n", + ) + + gomega.Expect(atts).To(gomega.HaveLen(100)) + gomega.Expect(atts[len(atts)-1].Text).To(gomega.ContainSubstring("Line 110")) + }) + }) + ginkgo.When("when the last message line ends with a newline", func() { + ginkgo.It("should not send an empty attachment", func() { + payload := slack.CreateJSONPayload(&slack.Config{}, "One\nTwo\nThree\n").(slack.MessagePayload) + atts := payload.Attachments + gomega.Expect(atts[len(atts)-1].Text).NotTo(gomega.BeEmpty()) + }) + }) + }) + + ginkgo.Describe("sending the payload", func() { + ginkgo.When("sending via webhook URL", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL, _ := url.Parse( + "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456", + ) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + TestWebhookURL, + httpmock.NewStringResponder(200, ""), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not panic if an error occurs when sending the payload", func() { + serviceURL, _ := url.Parse( + "slack://testbot@AAAAAAAAA/BBBBBBBBB/123456789123456789123456", + ) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + TestWebhookURL, + httpmock.NewErrorResponder(errors.New("dummy error")), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("sending via bot API", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL := testutils.URLMust( + "slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789", + ) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + targetURL := "https://slack.com/api/chat.postMessage" + httpmock.RegisterResponder( + "POST", + targetURL, + testutils.JSONRespondMust(200, slack.APIResponse{ + Ok: true, + }), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not panic if an error occurs when sending the payload", func() { + serviceURL := testutils.URLMust( + "slack://xoxb:123456789012-1234567890123-4mt0t4l1YL3g1T5L4cK70k3N@C0123456789", + ) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + targetURL := "https://slack.com/api/chat.postMessage" + httpmock.RegisterResponder( + "POST", + targetURL, + testutils.JSONRespondMust(200, slack.APIResponse{ + Error: "someone turned off the internet", + }), + ) + + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) +}) + +func tokenMust(rawToken string) *slack.Token { + token, err := slack.ParseToken(rawToken) + gomega.ExpectWithOffset(1, err).NotTo(gomega.HaveOccurred()) + + return token +} + +func expectErrorMessageGivenURL(expected error, rawURL string) { + err := service.Initialize(testutils.URLMust(rawURL), testutils.TestLogger()) + gomega.ExpectWithOffset(1, err).To(gomega.HaveOccurred()) + gomega.ExpectWithOffset(1, err).To(gomega.Equal(expected)) +} diff --git a/pkg/services/slack/slack_token.go b/pkg/services/slack/slack_token.go new file mode 100644 index 0000000..be9351c --- /dev/null +++ b/pkg/services/slack/slack_token.go @@ -0,0 +1,154 @@ +package slack + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const webhookBase = "https://hooks.slack.com/services/" + +// Token type identifiers. +const ( + HookTokenIdentifier = "hook" + UserTokenIdentifier = "xoxp" + BotTokenIdentifier = "xoxb" +) + +// Token length and offset constants. +const ( + MinTokenLength = 3 // Minimum length for a valid token string + TypeIdentifierLength = 4 // Length of the type identifier (e.g., "xoxb", "hook") + TypeIdentifierOffset = 5 // Offset to skip type identifier and separator (e.g., "xoxb:") + Part1Length = 9 // Expected length of part 1 in token + Part2Length = 9 // Expected length of part 2 in token + Part3Length = 24 // Expected length of part 3 in token +) + +// Token match group indices. +const ( + tokenMatchFull = iota // Full match + tokenMatchType // Type identifier (e.g., "xoxb", "hook") + tokenMatchPart1 // First part of the token + tokenMatchSep1 // First separator + tokenMatchPart2 // Second part of the token + tokenMatchSep2 // Second separator + tokenMatchPart3 // Third part of the token + tokenMatchCount // Total number of match groups +) + +var tokenPattern = regexp.MustCompile( + `(?:(?Pxox.|hook)[-:]|:?)(?P[A-Z0-9]{` + strconv.Itoa( + Part1Length, + ) + `,})(?P[-/,])(?P[A-Z0-9]{` + strconv.Itoa( + Part2Length, + ) + `,})(?P[-/,])(?P[A-Za-z0-9]{` + strconv.Itoa( + Part3Length, + ) + `,})`, +) + +var _ types.ConfigProp = &Token{} + +// Token is a Slack API token or a Slack webhook token. +type Token struct { + raw string +} + +// SetFromProp sets the token from a property value, implementing the types.ConfigProp interface. +func (token *Token) SetFromProp(propValue string) error { + if len(propValue) < MinTokenLength { + return ErrInvalidToken + } + + match := tokenPattern.FindStringSubmatch(propValue) + if match == nil || len(match) != tokenMatchCount { + return ErrInvalidToken + } + + typeIdentifier := match[tokenMatchType] + if typeIdentifier == "" { + typeIdentifier = HookTokenIdentifier + } + + token.raw = fmt.Sprintf("%s:%s-%s-%s", + typeIdentifier, match[tokenMatchPart1], match[tokenMatchPart2], match[tokenMatchPart3]) + + if match[tokenMatchSep1] != match[tokenMatchSep2] { + return ErrMismatchedTokenSeparators + } + + return nil +} + +// GetPropValue returns the token as a property value, implementing the types.ConfigProp interface. +func (token *Token) GetPropValue() (string, error) { + if token == nil { + return "", nil + } + + return token.raw, nil +} + +// TypeIdentifier returns the type identifier of the token. +func (token *Token) TypeIdentifier() string { + return token.raw[:TypeIdentifierLength] +} + +// ParseToken parses and normalizes a token string. +func ParseToken(str string) (*Token, error) { + token := &Token{} + if err := token.SetFromProp(str); err != nil { + return nil, err + } + + return token, nil +} + +// String returns the token in normalized format with dashes (-) as separator. +func (token *Token) String() string { + return token.raw +} + +// UserInfo returns a url.Userinfo struct populated from the token. +func (token *Token) UserInfo() *url.Userinfo { + return url.UserPassword(token.raw[:TypeIdentifierLength], token.raw[TypeIdentifierOffset:]) +} + +// IsAPIToken returns whether the identifier is set to anything else but the webhook identifier (`hook`). +func (token *Token) IsAPIToken() bool { + return token.TypeIdentifier() != HookTokenIdentifier +} + +// WebhookURL returns the corresponding Webhook URL for the token. +func (token *Token) WebhookURL() string { + stringBuilder := strings.Builder{} + stringBuilder.WriteString(webhookBase) + stringBuilder.Grow(len(token.raw) - TypeIdentifierOffset) + + for i := TypeIdentifierOffset; i < len(token.raw); i++ { + c := token.raw[i] + if c == '-' { + c = '/' + } + + stringBuilder.WriteByte(c) + } + + return stringBuilder.String() +} + +// Authorization returns the corresponding `Authorization` HTTP header value for the token. +func (token *Token) Authorization() string { + stringBuilder := strings.Builder{} + stringBuilder.WriteString("Bearer ") + stringBuilder.Grow(len(token.raw)) + stringBuilder.WriteString(token.raw[:TypeIdentifierLength]) + stringBuilder.WriteRune('-') + stringBuilder.WriteString(token.raw[TypeIdentifierOffset:]) + + return stringBuilder.String() +} diff --git a/pkg/services/smtp/smtp.go b/pkg/services/smtp/smtp.go new file mode 100644 index 0000000..b7a6a4c --- /dev/null +++ b/pkg/services/smtp/smtp.go @@ -0,0 +1,366 @@ +package smtp + +import ( + "crypto/rand" + "crypto/tls" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "net/smtp" + "net/url" + "os" + "strconv" + "time" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + contentHTML = "text/html; charset=\"UTF-8\"" + contentPlain = "text/plain; charset=\"UTF-8\"" + contentMultipart = "multipart/alternative; boundary=%s" + DefaultSMTPPort = 25 // DefaultSMTPPort is the standard port for SMTP communication. + boundaryByteLen = 8 // boundaryByteLen is the number of bytes for the multipart boundary. +) + +// ErrNoAuth is a sentinel error indicating no authentication is required. +var ErrNoAuth = errors.New("no authentication required") + +// Service sends notifications to given email addresses via SMTP. +type Service struct { + standard.Standard + standard.Templater + Config *Config + multipartBoundary string + propKeyResolver format.PropKeyResolver +} + +// Initialize loads ServiceConfig from configURL and sets logger for this Service. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{ + Port: DefaultSMTPPort, + ToAddresses: nil, + Subject: "", + Auth: AuthTypes.Unknown, + UseStartTLS: true, + UseHTML: false, + Encryption: EncMethods.Auto, + ClientHost: "localhost", + } + + pkr := format.NewPropKeyResolver(service.Config) + + if err := service.Config.setURL(&pkr, configURL); err != nil { + return err + } + + if service.Config.Auth == AuthTypes.Unknown { + if service.Config.Username != "" { + service.Config.Auth = AuthTypes.Plain + } else { + service.Config.Auth = AuthTypes.None + } + } + + service.propKeyResolver = pkr + + return nil +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// Send sends a notification message to email recipients. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config.Clone() + if err := service.propKeyResolver.UpdateConfigFromParams(&config, params); err != nil { + return fail(FailApplySendParams, err) + } + + client, err := getClientConnection(service.Config) + if err != nil { + return fail(FailGetSMTPClient, err) + } + + return service.doSend(client, message, &config) +} + +// getClientConnection establishes a connection to the SMTP server using the provided configuration. +func getClientConnection(config *Config) (*smtp.Client, error) { + var conn net.Conn + + var err error + + addr := net.JoinHostPort(config.Host, strconv.FormatUint(uint64(config.Port), 10)) + + if useImplicitTLS(config.Encryption, config.Port) { + conn, err = tls.Dial("tcp", addr, &tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher + }) + } else { + conn, err = net.Dial("tcp", addr) + } + + if err != nil { + return nil, fail(FailConnectToServer, err) + } + + client, err := smtp.NewClient(conn, config.Host) + if err != nil { + return nil, fail(FailCreateSMTPClient, err) + } + + return client, nil +} + +// doSend sends an email message using the provided SMTP client and configuration. +func (service *Service) doSend(client *smtp.Client, message string, config *Config) failure { + config.FixEmailTags() + + clientHost := service.resolveClientHost(config) + + if err := client.Hello(clientHost); err != nil { + return fail(FailHandshake, err) + } + + if config.UseHTML { + b := make([]byte, boundaryByteLen) + if _, err := rand.Read(b); err != nil { + return fail(FailUnknown, err) // Fallback error for rare case + } + + service.multipartBoundary = hex.EncodeToString(b) + } + + if config.UseStartTLS && !useImplicitTLS(config.Encryption, config.Port) { + if supported, _ := client.Extension("StartTLS"); !supported { + service.Logf( + "Warning: StartTLS enabled, but server does not support it. Connection is unencrypted", + ) + } else { + if err := client.StartTLS(&tls.Config{ + ServerName: config.Host, + MinVersion: tls.VersionTLS12, // Enforce TLS 1.2 or higher + }); err != nil { + return fail(FailEnableStartTLS, err) + } + } + } + + if auth, err := service.getAuth(config); err != nil { + return err + } else if auth != nil { + if err := client.Auth(auth); err != nil { + return fail(FailAuthenticating, err) + } + } + + for _, toAddress := range config.ToAddresses { + err := service.sendToRecipient(client, toAddress, config, message) + if err != nil { + return fail(FailSendRecipient, err) + } + + service.Logf("Mail successfully sent to \"%s\"!\n", toAddress) + } + + // Send the QUIT command and close the connection. + err := client.Quit() + if err != nil { + return fail(FailClosingSession, err) + } + + return nil +} + +// resolveClientHost determines the client hostname to use in the SMTP handshake. +func (service *Service) resolveClientHost(config *Config) string { + if config.ClientHost != "auto" { + return config.ClientHost + } + + hostname, err := os.Hostname() + if err != nil { + service.Logf("Failed to get hostname, falling back to localhost: %v", err) + + return "localhost" + } + + return hostname +} + +// getAuth returns the appropriate SMTP authentication mechanism based on the configuration. +// +//nolint:exhaustive,nilnil +func (service *Service) getAuth(config *Config) (smtp.Auth, failure) { + switch config.Auth { + case AuthTypes.None: + return nil, nil // No auth required, proceed without error + case AuthTypes.Plain: + return smtp.PlainAuth("", config.Username, config.Password, config.Host), nil + case AuthTypes.CRAMMD5: + return smtp.CRAMMD5Auth(config.Username, config.Password), nil + case AuthTypes.OAuth2: + return OAuth2Auth(config.Username, config.Password), nil + case AuthTypes.Unknown: + return nil, fail(FailAuthType, nil, config.Auth.String()) + default: + return nil, fail(FailAuthType, nil, config.Auth.String()) + } +} + +// sendToRecipient sends an email to a single recipient using the provided SMTP client. +func (service *Service) sendToRecipient( + client *smtp.Client, + toAddress string, + config *Config, + message string, +) failure { + // Set the sender and recipient first + if err := client.Mail(config.FromAddress); err != nil { + return fail(FailSetSender, err) + } + + if err := client.Rcpt(toAddress); err != nil { + return fail(FailSetRecipient, err) + } + + // Send the email body. + writeCloser, err := client.Data() + if err != nil { + return fail(FailOpenDataStream, err) + } + + if err := writeHeaders(writeCloser, service.getHeaders(toAddress, config.Subject)); err != nil { + return err + } + + var ferr failure + if config.UseHTML { + ferr = service.writeMultipartMessage(writeCloser, message) + } else { + ferr = service.writeMessagePart(writeCloser, message, "plain") + } + + if ferr != nil { + return ferr + } + + if err = writeCloser.Close(); err != nil { + return fail(FailCloseDataStream, err) + } + + return nil +} + +// getHeaders constructs email headers for the SMTP message. +func (service *Service) getHeaders(toAddress string, subject string) map[string]string { + conf := service.Config + + var contentType string + if conf.UseHTML { + contentType = fmt.Sprintf(contentMultipart, service.multipartBoundary) + } else { + contentType = contentPlain + } + + return map[string]string{ + "Subject": subject, + "Date": time.Now().Format(time.RFC1123Z), + "To": toAddress, + "From": fmt.Sprintf("%s <%s>", conf.FromName, conf.FromAddress), + "MIME-version": "1.0", + "Content-Type": contentType, + } +} + +// writeMultipartMessage writes a multipart email message to the provided writer. +func (service *Service) writeMultipartMessage(writeCloser io.WriteCloser, message string) failure { + if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentPlain); err != nil { + return fail(FailPlainHeader, err) + } + + if err := service.writeMessagePart(writeCloser, message, "plain"); err != nil { + return err + } + + if err := writeMultipartHeader(writeCloser, service.multipartBoundary, contentHTML); err != nil { + return fail(FailHTMLHeader, err) + } + + if err := service.writeMessagePart(writeCloser, message, "HTML"); err != nil { + return err + } + + if err := writeMultipartHeader(writeCloser, service.multipartBoundary, ""); err != nil { + return fail(FailMultiEndHeader, err) + } + + return nil +} + +// writeMessagePart writes a single part of an email message using the specified template. +func (service *Service) writeMessagePart( + writeCloser io.WriteCloser, + message string, + template string, +) failure { + if tpl, found := service.GetTemplate(template); found { + data := make(map[string]string) + data["message"] = message + + if err := tpl.Execute(writeCloser, data); err != nil { + return fail(FailMessageTemplate, err) + } + } else { + if _, err := fmt.Fprint(writeCloser, message); err != nil { + return fail(FailMessageRaw, err) + } + } + + return nil +} + +// writeMultipartHeader writes a multipart boundary header to the provided writer. +func writeMultipartHeader(writeCloser io.WriteCloser, boundary string, contentType string) error { + suffix := "\n" + if len(contentType) < 1 { + suffix = "--" + } + + if _, err := fmt.Fprintf(writeCloser, "\n\n--%s%s", boundary, suffix); err != nil { + return fmt.Errorf("writing multipart boundary: %w", err) + } + + if len(contentType) > 0 { + if _, err := fmt.Fprintf(writeCloser, "Content-Type: %s\n\n", contentType); err != nil { + return fmt.Errorf("writing content type header: %w", err) + } + } + + return nil +} + +// writeHeaders writes email headers to the provided writer. +func writeHeaders(writeCloser io.WriteCloser, headers map[string]string) failure { + for key, val := range headers { + if _, err := fmt.Fprintf(writeCloser, "%s: %s\n", key, val); err != nil { + return fail(FailWriteHeaders, err) + } + } + + _, err := fmt.Fprintln(writeCloser) + if err != nil { + return fail(FailWriteHeaders, err) + } + + return nil +} diff --git a/pkg/services/smtp/smtp_authtype.go b/pkg/services/smtp/smtp_authtype.go new file mode 100644 index 0000000..74b1945 --- /dev/null +++ b/pkg/services/smtp/smtp_authtype.go @@ -0,0 +1,46 @@ +package smtp + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + AuthNone authType = iota // 0 + AuthPlain // 1 + AuthCRAMMD5 // 2 + AuthUnknown // 3 + AuthOAuth2 // 4 +) + +// AuthTypes is the enum helper for populating the Auth field. +var AuthTypes = &authTypeVals{ + None: AuthNone, + Plain: AuthPlain, + CRAMMD5: AuthCRAMMD5, + Unknown: AuthUnknown, + OAuth2: AuthOAuth2, + Enum: format.CreateEnumFormatter( + []string{ + "None", + "Plain", + "CRAMMD5", + "Unknown", + "OAuth2", + }), +} + +type authType int + +type authTypeVals struct { + None authType + Plain authType + CRAMMD5 authType + Unknown authType + OAuth2 authType + Enum types.EnumFormatter +} + +func (at authType) String() string { + return AuthTypes.Enum.Print(int(at)) +} diff --git a/pkg/services/smtp/smtp_config.go b/pkg/services/smtp/smtp_config.go new file mode 100644 index 0000000..f89f957 --- /dev/null +++ b/pkg/services/smtp/smtp_config.go @@ -0,0 +1,120 @@ +package smtp + +import ( + "errors" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// Scheme is the identifying part of this service's configuration URL. +const Scheme = "smtp" + +// Static errors for configuration validation. +var ( + ErrFromAddressMissing = errors.New("fromAddress missing from config URL") + ErrToAddressMissing = errors.New("toAddress missing from config URL") +) + +// Config is the configuration needed to send e-mail notifications over SMTP. +type Config struct { + Host string `desc:"SMTP server hostname or IP address" url:"Host"` + Username string `desc:"SMTP server username" url:"User" default:""` + Password string `desc:"SMTP server password or hash (for OAuth2)" url:"Pass" default:""` + Port uint16 `desc:"SMTP server port, common ones are 25, 465, 587 or 2525" url:"Port" default:"25"` + FromAddress string `desc:"E-mail address that the mail are sent from" key:"fromaddress,from"` + FromName string `desc:"Name of the sender" key:"fromname" optional:"yes"` + ToAddresses []string `desc:"List of recipient e-mails" key:"toaddresses,to"` + Subject string `desc:"The subject of the sent mail" default:"Shoutrrr Notification" key:"subject,title"` + Auth authType `desc:"SMTP authentication method" default:"Unknown" key:"auth"` + Encryption encMethod `desc:"Encryption method" default:"Auto" key:"encryption"` + UseStartTLS bool `desc:"Whether to use StartTLS encryption" default:"Yes" key:"usestarttls,starttls"` + UseHTML bool `desc:"Whether the message being sent is in HTML" default:"No" key:"usehtml"` + ClientHost string `desc:"SMTP client hostname" default:"localhost" key:"clienthost"` +} + +// GetURL returns a URL representation of its current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates a ServiceConfig from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + return &url.URL{ + User: util.URLUserPassword(config.Username, config.Password), + Host: fmt.Sprintf("%s:%d", config.Host, config.Port), + Path: "/", + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + config.Username = url.User.Username() + config.Password = password + config.Host = url.Hostname() + + if port, err := strconv.ParseUint(url.Port(), 10, 16); err == nil { + config.Port = uint16(port) + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting query parameter %q to %q: %w", key, vals[0], err) + } + } + + if url.String() != "smtp://dummy@dummy.com" { + if len(config.FromAddress) < 1 { + return ErrFromAddressMissing + } + + if len(config.ToAddresses) < 1 { + return ErrToAddressMissing + } + } + + return nil +} + +// Clone returns a copy of the config. +func (config *Config) Clone() Config { + clone := *config + clone.ToAddresses = make([]string, len(config.ToAddresses)) + copy(clone.ToAddresses, config.ToAddresses) + + return clone +} + +// FixEmailTags replaces parsed spaces (+) in e-mail addresses with '+'. +func (config *Config) FixEmailTags() { + config.FromAddress = strings.ReplaceAll(config.FromAddress, " ", "+") + for i, adr := range config.ToAddresses { + config.ToAddresses[i] = strings.ReplaceAll(adr, " ", "+") + } +} + +// Enums returns the fields that should use a corresponding EnumFormatter to Print/Parse their values. +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{ + "Auth": AuthTypes.Enum, + "Encryption": EncMethods.Enum, + } +} diff --git a/pkg/services/smtp/smtp_encmethod.go b/pkg/services/smtp/smtp_encmethod.go new file mode 100644 index 0000000..bdd7c64 --- /dev/null +++ b/pkg/services/smtp/smtp_encmethod.go @@ -0,0 +1,71 @@ +package smtp + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + // EncNone represents no encryption. + EncNone encMethod = iota // 0 + // EncExplicitTLS represents explicit TLS initiated with StartTLS. + EncExplicitTLS // 1 + // EncImplicitTLS represents implicit TLS used throughout the session. + EncImplicitTLS // 2 + // EncAuto represents automatic TLS selection based on port. + EncAuto // 3 + // ImplicitTLSPort is the de facto standard SMTPS port for implicit TLS. + ImplicitTLSPort = 465 +) + +// EncMethods is the enum helper for populating the Encryption field. +var EncMethods = &encMethodVals{ + None: EncNone, + ExplicitTLS: EncExplicitTLS, + ImplicitTLS: EncImplicitTLS, + Auto: EncAuto, + + Enum: format.CreateEnumFormatter( + []string{ + "None", + "ExplicitTLS", + "ImplicitTLS", + "Auto", + }), +} + +type encMethod int + +type encMethodVals struct { + // None means no encryption + None encMethod + // ExplicitTLS means that TLS needs to be initiated by using StartTLS + ExplicitTLS encMethod + // ImplicitTLS means that TLS is used for the whole session + ImplicitTLS encMethod + // Auto means that TLS will be implicitly used for port 465, otherwise explicit TLS will be used if supported + Auto encMethod + + // Enum is the EnumFormatter instance for EncMethods + Enum types.EnumFormatter +} + +func (at encMethod) String() string { + return EncMethods.Enum.Print(int(at)) +} + +// useImplicitTLS determines if implicit TLS should be used based on encryption method and port. +func useImplicitTLS(encryption encMethod, port uint16) bool { + switch encryption { + case EncNone: + return false + case EncExplicitTLS: + return false + case EncImplicitTLS: + return true + case EncAuto: + return port == ImplicitTLSPort + default: + return false // Unreachable due to enum constraints, but included for safety + } +} diff --git a/pkg/services/smtp/smtp_failures.go b/pkg/services/smtp/smtp_failures.go new file mode 100644 index 0000000..98d441c --- /dev/null +++ b/pkg/services/smtp/smtp_failures.go @@ -0,0 +1,104 @@ +package smtp + +import "github.com/nicholas-fedor/shoutrrr/internal/failures" + +const ( + // FailUnknown is the default FailureID. + FailUnknown failures.FailureID = iota + // FailGetSMTPClient is returned when a SMTP client could not be created. + FailGetSMTPClient + // FailEnableStartTLS is returned when failing to enable StartTLS. + FailEnableStartTLS + // FailAuthType is returned when the Auth type could not be identified. + FailAuthType + // FailAuthenticating is returned when the authentication fails. + FailAuthenticating + // FailSendRecipient is returned when sending to a recipient fails. + FailSendRecipient + // FailClosingSession is returned when the server doesn't accept the QUIT command. + FailClosingSession + // FailPlainHeader is returned when the text/plain multipart header could not be set. + FailPlainHeader + // FailHTMLHeader is returned when the text/html multipart header could not be set. + FailHTMLHeader + // FailMultiEndHeader is returned when the multipart end header could not be set. + FailMultiEndHeader + // FailMessageTemplate is returned when the message template could not be written to the stream. + FailMessageTemplate + // FailMessageRaw is returned when a non-templated message could not be written to the stream. + FailMessageRaw + // FailSetSender is returned when the server didn't accept the sender address. + FailSetSender + // FailSetRecipient is returned when the server didn't accept the recipient address. + FailSetRecipient + // FailOpenDataStream is returned when the server didn't accept the data stream. + FailOpenDataStream + // FailWriteHeaders is returned when the headers could not be written to the data stream. + FailWriteHeaders + // FailCloseDataStream is returned when the server didn't accept the data stream contents. + FailCloseDataStream + // FailConnectToServer is returned when the TCP connection to the server failed. + FailConnectToServer + // FailCreateSMTPClient is returned when the smtp.Client initialization failed. + FailCreateSMTPClient + // FailApplySendParams is returned when updating the send config failed. + FailApplySendParams + // FailHandshake is returned when the initial HELLO handshake returned an error. + FailHandshake +) + +type failure interface { + failures.Failure +} + +func fail(failureID failures.FailureID, err error, args ...any) failure { + var msg string + + switch failureID { + case FailGetSMTPClient: + msg = "error getting SMTP client" + case FailConnectToServer: + msg = "error connecting to server" + case FailCreateSMTPClient: + msg = "error creating smtp client" + case FailEnableStartTLS: + msg = "error enabling StartTLS" + case FailAuthenticating: + msg = "error authenticating" + case FailAuthType: + msg = "invalid authorization method '%s'" + case FailSendRecipient: + msg = "error sending message to recipient" + case FailClosingSession: + msg = "error closing session" + case FailPlainHeader: + msg = "error writing plain header" + case FailHTMLHeader: + msg = "error writing HTML header" + case FailMultiEndHeader: + msg = "error writing multipart end header" + case FailMessageTemplate: + msg = "error applying message template" + case FailMessageRaw: + msg = "error writing message" + case FailSetSender: + msg = "error creating new message" + case FailSetRecipient: + msg = "error setting RCPT" + case FailOpenDataStream: + msg = "error creating message stream" + case FailWriteHeaders: + msg = "error writing message headers" + case FailCloseDataStream: + msg = "error closing message stream" + case FailApplySendParams: + msg = "error applying params to send config" + case FailHandshake: + msg = "server did not accept the handshake" + // case FailUnknown: + default: + msg = "an unknown error occurred" + } + + return failures.Wrap(msg, failureID, err, args...) +} diff --git a/pkg/services/smtp/smtp_oauth2.go b/pkg/services/smtp/smtp_oauth2.go new file mode 100644 index 0000000..f5818dc --- /dev/null +++ b/pkg/services/smtp/smtp_oauth2.go @@ -0,0 +1,25 @@ +package smtp + +import ( + "net/smtp" +) + +type oauth2Auth struct { + username, accessToken string +} + +// OAuth2Auth returns an Auth that implements the SASL XOAUTH2 authentication +// as per https://developers.google.com/gmail/imap/xoauth2-protocol +func OAuth2Auth(username, accessToken string) smtp.Auth { + return &oauth2Auth{username, accessToken} +} + +func (a *oauth2Auth) Start(_ *smtp.ServerInfo) (string, []byte, error) { + resp := []byte("user=" + a.username + "\x01auth=Bearer " + a.accessToken + "\x01\x01") + + return "XOAUTH2", resp, nil +} + +func (a *oauth2Auth) Next(_ []byte, _ bool) ([]byte, error) { + return nil, nil +} diff --git a/pkg/services/smtp/smtp_test.go b/pkg/services/smtp/smtp_test.go new file mode 100644 index 0000000..9ae138d --- /dev/null +++ b/pkg/services/smtp/smtp_test.go @@ -0,0 +1,635 @@ +package smtp + +import ( + "log" + "net/smtp" + "net/url" + "os" + "reflect" + "testing" + "unsafe" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + gomegaTypes "github.com/onsi/gomega/types" + + "github.com/nicholas-fedor/shoutrrr/internal/failures" + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var tt *testing.T + +func TestSMTP(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + + tt = t + ginkgo.RunSpecs(t, "Shoutrrr SMTP Suite") +} + +var ( + service *Service + envSMTPURL string + logger *log.Logger + _ = ginkgo.BeforeSuite(func() { + envSMTPURL = os.Getenv("SHOUTRRR_SMTP_URL") + logger = testutils.TestLogger() + }) + urlWithAllProps = "smtp://user:password@example.com:2225/?auth=None&clienthost=testhost&encryption=ExplicitTLS&fromaddress=sender%40example.com&fromname=Sender&subject=Subject&toaddresses=rec1%40example.com%2Crec2%40example.com&usehtml=Yes&usestarttls=No" + // BaseNoAuthURL is a minimal SMTP config without authentication. + BaseNoAuthURL = "smtp://example.com:2225/?useStartTLS=no&auth=none&fromAddress=sender@example.com&toAddresses=rec1@example.com&useHTML=no" + // BaseAuthURL is a typical config with authentication. + BaseAuthURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender@example.com&toAddresses=rec1@example.com,rec2@example.com&useHTML=yes" + // BasePlusURL is a config with plus signs in email addresses. + BasePlusURL = "smtp://user:password@example.com:2225/?useStartTLS=no&fromAddress=sender+tag@example.com&toAddresses=rec1+tag@example.com,rec2@example.com&useHTML=yes" +) + +// modifyURL modifies a base URL by updating query parameters as specified. +func modifyURL(base string, params map[string]string) string { + u := testutils.URLMust(base) + + q := u.Query() + for k, v := range params { + q.Set(k, v) + } + + u.RawQuery = q.Encode() + + return u.String() +} + +var _ = ginkgo.Describe("the SMTP service", func() { + ginkgo.BeforeEach(func() { + service = &Service{} + }) + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + url := testutils.URLMust(urlWithAllProps) + config := &Config{} + pkr := format.NewPropKeyResolver(config) + err := config.setURL(&pkr, url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + ginkgo.GinkgoT().Logf("\n\n%s\n%s\n\n-", outputURL, urlWithAllProps) + + gomega.Expect(outputURL.String()).To(gomega.Equal(urlWithAllProps)) + }) + ginkgo.When("resolving client host", func() { + ginkgo.When("clienthost is set to auto", func() { + ginkgo.It("should return the os hostname", func() { + hostname, err := os.Hostname() + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(service.resolveClientHost(&Config{ClientHost: "auto"})). + To(gomega.Equal(hostname)) + }) + }) + ginkgo.When("clienthost is set to a custom value", func() { + ginkgo.It("should return that value", func() { + gomega.Expect(service.resolveClientHost(&Config{ClientHost: "computah"})). + To(gomega.Equal("computah")) + }) + }) + }) + ginkgo.When("fromAddress is missing", func() { + ginkgo.It("should return an error", func() { + testURL := testutils.URLMust( + "smtp://user:password@example.com:2225/?toAddresses=rec1@example.com,rec2@example.com", + ) + gomega.Expect((&Config{}).SetURL(testURL)).ToNot(gomega.Succeed()) + }) + }) + ginkgo.When("toAddresses are missing", func() { + ginkgo.It("should return an error", func() { + testURL := testutils.URLMust( + "smtp://user:password@example.com:2225/?fromAddress=sender@example.com", + ) + gomega.Expect((&Config{}).SetURL(testURL)).NotTo(gomega.Succeed()) + }) + }) + }) + ginkgo.Context("basic service API methods", func() { + var config *Config + ginkgo.BeforeEach(func() { + config = &Config{} + }) + ginkgo.It("should not allow getting invalid query values", func() { + testutils.TestConfigGetInvalidQueryValue(config) + }) + ginkgo.It("should not allow setting invalid query values", func() { + testutils.TestConfigSetInvalidQueryValue( + config, + "smtp://example.com/?fromAddress=s@example.com&toAddresses=r@example.com&foo=bar", + ) + }) + + ginkgo.It("should have the expected number of fields and enums", func() { + testutils.TestConfigGetEnumsCount(config, 2) + testutils.TestConfigGetFieldsCount(config, 13) + }) + }) + ginkgo.When("cloning a config", func() { + ginkgo.It("should be identical to the original", func() { + config := &Config{} + gomega.Expect(config.SetURL(testutils.URLMust(urlWithAllProps))).To(gomega.Succeed()) + + gomega.Expect(config.Clone()).To(gomega.Equal(*config)) + }) + }) + ginkgo.When("sending a message", func() { + ginkgo.When("the service is not configured correctly", func() { + ginkgo.It("should fail to send messages", func() { + service := Service{Config: &Config{}} + gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient)) + + service.Config.Encryption = EncMethods.ImplicitTLS + gomega.Expect(service.Send("test message", nil)).To(matchFailure(FailGetSMTPClient)) + }) + }) + ginkgo.When("an invalid param is passed", func() { + ginkgo.It("should fail to send messages", func() { + service := Service{Config: &Config{}} + gomega.Expect(service.Send("test message", &types.Params{"invalid": "value"})). + To(matchFailure(FailApplySendParams)) + }) + }) + }) + + ginkgo.When("the underlying stream stops working", func() { + var service Service + var message string + ginkgo.BeforeEach(func() { + service = Service{} + message = "" + }) + ginkgo.It("should fail when writing multipart plain header", func() { + writer := testutils.CreateFailWriter(1) + err := service.writeMultipartMessage(writer, message) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailPlainHeader)) + }) + + ginkgo.It("should fail when writing multipart plain message", func() { + writer := testutils.CreateFailWriter(2) + err := service.writeMultipartMessage(writer, message) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw)) + }) + + ginkgo.It("should fail when writing multipart HTML header", func() { + writer := testutils.CreateFailWriter(4) + err := service.writeMultipartMessage(writer, message) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailHTMLHeader)) + }) + + ginkgo.It("should fail when writing multipart HTML message", func() { + writer := testutils.CreateFailWriter(5) + err := service.writeMultipartMessage(writer, message) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailMessageRaw)) + }) + + ginkgo.It("should fail when writing multipart end header", func() { + writer := testutils.CreateFailWriter(6) + err := service.writeMultipartMessage(writer, message) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailMultiEndHeader)) + }) + + ginkgo.It("should fail when writing message template", func() { + writer := testutils.CreateFailWriter(0) + e := service.SetTemplateString("dummy", "dummy template content") + gomega.Expect(e).ToNot(gomega.HaveOccurred()) + + err := service.writeMessagePart(writer, message, "dummy") + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.ID()).To(gomega.Equal(FailMessageTemplate)) + }) + }) + + ginkgo.When("running E2E tests", func() { + ginkgo.It("should work without errors", func() { + if envSMTPURL == "" { + ginkgo.Skip("environment not set up for E2E testing") + + return + } + + serviceURL, err := url.Parse(envSMTPURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = service.Send("this is an integration test", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("running integration tests", func() { + ginkgo.When("given a typical usage case configuration URL", func() { + ginkgo.It("should send notifications without any errors", func() { + testURL := BaseAuthURL + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "235 Accepted", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "221 OK", + }, "
{{ .message }}
", "{{ .message }}") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("given e-mail addresses with pluses in the configuration URL", func() { + ginkgo.It("should send notifications without any errors", func() { + testURL := BasePlusURL + err := testIntegration( + testURL, + []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "235 Accepted", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "221 OK", + }, + "
{{ .message }}
", "{{ .message }}", + "RCPT TO:", + "To: rec1+tag@example.com", + "From: ") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("given a configuration URL with authentication disabled", func() { + ginkgo.It("should send notifications without any errors", func() { + testURL := BaseNoAuthURL + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "221 OK", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("given a configuration URL with StartTLS but it is not supported", func() { + ginkgo.It("should send notifications without any errors", func() { + testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"}) + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "221 OK", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("server communication fails", func() { + ginkgo.It("should fail when initial handshake is not accepted", func() { + testURL := modifyURL( + BaseNoAuthURL, + map[string]string{"useStartTLS": "yes", "clienthost": "spammer"}, + ) + err := testIntegration(testURL, []string{ + "421 4.7.0 Try again later, closing connection. (EHLO) r20-20020a50d694000000b004588af8956dsm771862edi.9 - gsmtp", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(gomega.MatchError(fail(FailHandshake, nil))) + }) + + ginkgo.It("should fail when not being able to enable StartTLS", func() { + testURL := modifyURL(BaseNoAuthURL, map[string]string{"useStartTLS": "yes"}) + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-STARTTLS", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "502 That's too hard", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailEnableStartTLS)) + }) + + ginkgo.It("should fail when authentication type is invalid", func() { + testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "bad"}) + err := testIntegration(testURL, []string{}, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(standard.FailServiceInit)) + }) + + ginkgo.It("should fail when not being able to use authentication type", func() { + testURL := modifyURL(BaseNoAuthURL, map[string]string{"auth": "crammd5"}) + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "504 Liar", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailAuthenticating)) + }) + + ginkgo.It("should fail when not being able to send to recipient", func() { + testURL := BaseNoAuthURL + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "551 I don't know you", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailSendRecipient)) + }) + + ginkgo.It("should fail when the recipient is not accepted", func() { + testURL := BaseNoAuthURL + err := testSendRecipient(testURL, []string{ + "250 mx.google.com at your service", + "250 Sender OK", + "553 She doesn't want to be disturbed", + }) + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailSetRecipient)) + }) + + ginkgo.It("should fail when the server does not accept the data stream", func() { + testURL := BaseNoAuthURL + err := testSendRecipient(testURL, []string{ + "250 mx.google.com at your service", + "250 Sender OK", + "250 Receiver OK", + "554 Nah I'm fine thanks", + }) + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailOpenDataStream)) + }) + + ginkgo.It( + "should fail when the server does not accept the data stream content", + func() { + testURL := BaseNoAuthURL + err := testSendRecipient(testURL, []string{ + "250 mx.google.com at your service", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "554 Such garbage!", + }) + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailCloseDataStream)) + }, + ) + + ginkgo.It( + "should fail when the server does not close the connection gracefully", + func() { + testURL := BaseNoAuthURL + err := testIntegration(testURL, []string{ + "250-mx.google.com at your service", + "250-SIZE 35651584", + "250-AUTH LOGIN PLAIN", + "250 8BITMIME", + "250 Sender OK", + "250 Receiver OK", + "354 Go ahead", + "250 Data OK", + "502 You can't quit, you're fired!", + }, "", "") + if msg, test := standard.IsTestSetupFailure(err); test { + ginkgo.Skip(msg) + + return + } + gomega.Expect(err).To(matchFailure(FailClosingSession)) + }, + ) + }) + }) + ginkgo.When("writing headers and the output stream is closed", func() { + ginkgo.When("it's closed during header content", func() { + ginkgo.It("should fail with correct error", func() { + fw := testutils.CreateFailWriter(0) + gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})). + To(matchFailure(FailWriteHeaders)) + }) + }) + ginkgo.When("it's closed after header content", func() { + ginkgo.It("should fail with correct error", func() { + fw := testutils.CreateFailWriter(1) + gomega.Expect(writeHeaders(fw, map[string]string{"key": "value"})). + To(matchFailure(FailWriteHeaders)) + }) + }) + }) + ginkgo.When("default port is not specified", func() { + ginkgo.It("should use the default SMTP port when not specified", func() { + testURL := "smtp://example.com/?fromAddress=sender@example.com&toAddresses=rec1@example.com" + serviceURL := testutils.URLMust(testURL) + err := service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(service.Config.Port).To(gomega.Equal(uint16(DefaultSMTPPort))) + }) + }) + ginkgo.It("returns the correct service identifier", func() { + gomega.Expect(service.GetID()).To(gomega.Equal("smtp")) + }) +}) + +func testSendRecipient(testURL string, responses []string) failures.Failure { + serviceURL, err := url.Parse(testURL) + if err != nil { + return standard.Failure(standard.FailParseURL, err) + } + + err = service.Initialize(serviceURL, logger) + if err != nil { + return failures.Wrap("error parsing URL", standard.FailTestSetup, err) + } + + if err := service.SetTemplateString("plain", "{{.message}}"); err != nil { + return failures.Wrap("error setting plain template", standard.FailTestSetup, err) + } + + textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n") + + client := &smtp.Client{ + Text: textCon, + } + + fakeTLSEnabled(client, serviceURL.Hostname()) + + config := &Config{} + message := "message body" + + ferr := service.sendToRecipient(client, "r@example.com", config, message) + + logger.Printf("\n%s", tcfaker.GetConversation(false)) + + if ferr != nil { + return ferr + } + + return nil +} + +func testIntegration( + testURL string, + responses []string, + htmlTemplate string, + plainTemplate string, + expectRec ...string, +) failures.Failure { + serviceURL, err := url.Parse(testURL) + if err != nil { + return standard.Failure(standard.FailParseURL, err) + } + + if err = service.Initialize(serviceURL, logger); err != nil { + return standard.Failure(standard.FailServiceInit, err) + } + + if htmlTemplate != "" { + if err := service.SetTemplateString("HTML", htmlTemplate); err != nil { + return failures.Wrap("error setting HTML template", standard.FailTestSetup, err) + } + } + + if plainTemplate != "" { + if err := service.SetTemplateString("plain", plainTemplate); err != nil { + return failures.Wrap("error setting plain template", standard.FailTestSetup, err) + } + } + + textCon, tcfaker := testutils.CreateTextConFaker(responses, "\r\n") + + client := &smtp.Client{ + Text: textCon, + } + + fakeTLSEnabled(client, serviceURL.Hostname()) + + ferr := service.doSend(client, "Test message", service.Config) + + received := tcfaker.GetClientSentences() + for _, expected := range expectRec { + gomega.Expect(received).To(gomega.ContainElement(expected)) + } + + logger.Printf("\n%s", tcfaker.GetConversation(false)) + + if ferr != nil { + return ferr + } + + return nil +} + +// fakeTLSEnabled tricks a given client into believing that TLS is enabled even though it's not +// this is needed because the SMTP library won't allow plain authentication without TLS being turned on. +// having it turned on would of course mean that we cannot test the communication since it will be encrypted. +func fakeTLSEnabled(client *smtp.Client, hostname string) { + // set the "tls" flag on the client which indicates that TLS encryption is enabled (even though it's not) + cr := reflect.ValueOf(client).Elem().FieldByName("tls") + cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem() + cr.SetBool(true) + + // set the serverName field on the client which is used to identify the server and has to equal the hostname + cr = reflect.ValueOf(client).Elem().FieldByName("serverName") + cr = reflect.NewAt(cr.Type(), unsafe.Pointer(cr.UnsafeAddr())).Elem() + cr.SetString(hostname) +} + +// matchFailure is a simple wrapper around `fail` and `gomega.MatchError` to make it easier to use in tests. +func matchFailure(id failures.FailureID) gomegaTypes.GomegaMatcher { + return gomega.MatchError(fail(id, nil)) +} diff --git a/pkg/services/standard/enumless_config.go b/pkg/services/standard/enumless_config.go new file mode 100644 index 0000000..da3d74b --- /dev/null +++ b/pkg/services/standard/enumless_config.go @@ -0,0 +1,13 @@ +package standard + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// EnumlessConfig implements the ServiceConfig interface for services that does not use Enum fields. +type EnumlessConfig struct{} + +// Enums returns an empty map. +func (ec *EnumlessConfig) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{} +} diff --git a/pkg/services/standard/standard.go b/pkg/services/standard/standard.go new file mode 100644 index 0000000..8fd4824 --- /dev/null +++ b/pkg/services/standard/standard.go @@ -0,0 +1,7 @@ +package standard + +// Standard implements the Logger and Templater parts of the Service interface. +type Standard struct { + Logger + Templater +} diff --git a/pkg/services/standard/standard_failures.go b/pkg/services/standard/standard_failures.go new file mode 100644 index 0000000..4ffbe37 --- /dev/null +++ b/pkg/services/standard/standard_failures.go @@ -0,0 +1,59 @@ +package standard + +import ( + "fmt" + + "github.com/nicholas-fedor/shoutrrr/internal/failures" +) + +const ( + // FailTestSetup is the FailureID used to represent an error that is part of the setup for tests. + FailTestSetup failures.FailureID = -1 + // FailParseURL is the FailureID used to represent failing to parse the service URL. + FailParseURL failures.FailureID = -2 + // FailServiceInit is the FailureID used to represent failure of a service.Initialize method. + FailServiceInit failures.FailureID = -3 + // FailUnknown is the default FailureID. + FailUnknown failures.FailureID = iota +) + +type failureLike interface { + failures.Failure +} + +// Failure creates a Failure instance corresponding to the provided failureID, wrapping the provided error. +func Failure(failureID failures.FailureID, err error, args ...any) failures.Failure { + messages := map[int]string{ + int(FailTestSetup): "test setup failed", + int(FailParseURL): "error parsing Service URL", + int(FailUnknown): "an unknown error occurred", + } + + msg := messages[int(failureID)] + if msg == "" { + msg = messages[int(FailUnknown)] + } + + // If variadic arguments are provided, format them correctly + if len(args) > 0 { + if format, ok := args[0].(string); ok && len(args) > 1 { + // Treat the first argument as a format string and the rest as its arguments + extraMsg := fmt.Sprintf(format, args[1:]...) + msg = fmt.Sprintf("%s %s", msg, extraMsg) + } else { + // If no format string is provided, just append the arguments as-is + msg = fmt.Sprintf("%s %v", msg, args) + } + } + + return failures.Wrap(msg, failureID, err) +} + +// IsTestSetupFailure checks whether the given failure is due to the test setup being broken. +func IsTestSetupFailure(failure failureLike) (string, bool) { + if failure != nil && failure.ID() == FailTestSetup { + return "test setup failed: " + failure.Error(), true + } + + return "", false +} diff --git a/pkg/services/standard/standard_logger.go b/pkg/services/standard/standard_logger.go new file mode 100644 index 0000000..6536bf1 --- /dev/null +++ b/pkg/services/standard/standard_logger.go @@ -0,0 +1,30 @@ +package standard + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +// Logger provides the utility methods Log* that maps to Logger.Print*. +type Logger struct { + logger types.StdLogger +} + +// Logf maps to the service loggers Logger.Printf function. +func (sl *Logger) Logf(format string, v ...any) { + sl.logger.Printf(format, v...) +} + +// Log maps to the service loggers Logger.Print function. +func (sl *Logger) Log(v ...any) { + sl.logger.Print(v...) +} + +// SetLogger maps the specified logger to the Log* helper methods. +func (sl *Logger) SetLogger(logger types.StdLogger) { + if logger == nil { + sl.logger = util.DiscardLogger + } else { + sl.logger = logger + } +} diff --git a/pkg/services/standard/standard_templater.go b/pkg/services/standard/standard_templater.go new file mode 100644 index 0000000..6ab2c24 --- /dev/null +++ b/pkg/services/standard/standard_templater.go @@ -0,0 +1,45 @@ +package standard + +import ( + "fmt" + "os" + "text/template" +) + +// Templater is the standard implementation of ApplyTemplate using the "text/template" library. +type Templater struct { + templates map[string]*template.Template +} + +// GetTemplate attempts to retrieve the template identified with id. +func (templater *Templater) GetTemplate(id string) (*template.Template, bool) { + tpl, found := templater.templates[id] + + return tpl, found +} + +// SetTemplateString creates a new template from the body and assigns it the id. +func (templater *Templater) SetTemplateString(templateID string, body string) error { + tpl, err := template.New("").Parse(body) + if err != nil { + return fmt.Errorf("parsing template string for ID %q: %w", templateID, err) + } + + if templater.templates == nil { + templater.templates = make(map[string]*template.Template, 1) + } + + templater.templates[templateID] = tpl + + return nil +} + +// SetTemplateFile creates a new template from the file and assigns it the id. +func (templater *Templater) SetTemplateFile(templateID string, file string) error { + bytes, err := os.ReadFile(file) + if err != nil { + return fmt.Errorf("reading template file %q for ID %q: %w", file, templateID, err) + } + + return templater.SetTemplateString(templateID, string(bytes)) +} diff --git a/pkg/services/standard/standard_test.go b/pkg/services/standard/standard_test.go new file mode 100644 index 0000000..e8f0323 --- /dev/null +++ b/pkg/services/standard/standard_test.go @@ -0,0 +1,205 @@ +package standard + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "strings" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/failures" +) + +func TestStandard(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Standard Suite") +} + +var ( + logger *Logger + builder *strings.Builder + stringLogger *log.Logger +) + +var _ = ginkgo.Describe("the standard logging implementation", func() { + ginkgo.When("setlogger is called with nil", func() { + ginkgo.It("should provide the logging API without any errors", func() { + logger = &Logger{} + logger.SetLogger(nil) + logger.Log("discarded log message") + + gomega.Expect(logger.logger).ToNot(gomega.BeNil()) + }) + }) + ginkgo.When("setlogger is called with a proper logger", func() { + ginkgo.BeforeEach(func() { + logger = &Logger{} + builder = &strings.Builder{} + stringLogger = log.New(builder, "", 0) + }) + ginkgo.When("when logger.Log is called", func() { + ginkgo.It("should log messages", func() { + logger.SetLogger(stringLogger) + logger.Log("foo") + logger.Log("bar") + + gomega.Expect(builder.String()).To(gomega.Equal("foo\nbar\n")) + }) + }) + ginkgo.When("when logger.Logf is called", func() { + ginkgo.It("should log messages", func() { + logger.SetLogger(stringLogger) + logger.Logf("foo %d", 7) + + gomega.Expect(builder.String()).To(gomega.Equal("foo 7\n")) + }) + }) + }) +}) + +var _ = ginkgo.Describe("the standard template implementation", func() { + ginkgo.When("a template is being set from a file", func() { + ginkgo.It("should load the template without any errors", func() { + file, err := os.CreateTemp("", "") + if err != nil { + ginkgo.Skip(fmt.Sprintf("Could not create temp file: %s", err)) + + return + } + fileName := file.Name() + defer os.Remove(fileName) + + _, err = io.WriteString(file, "template content") + if err != nil { + ginkgo.Skip(fmt.Sprintf("Could not write to temp file: %s", err)) + + return + } + + templater := &Templater{} + err = templater.SetTemplateFile("foo", fileName) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + }) + }) + ginkgo.When("a template is being set from a file that does not exist", func() { + ginkgo.It("should return an error", func() { + templater := &Templater{} + err := templater.SetTemplateFile("foo", "filename_that_should_not_exist") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("a template is being set with a badly formatted string", func() { + ginkgo.It("should return an error", func() { + templater := &Templater{} + err := templater.SetTemplateString("foo", "template {{ missing end tag") + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("a template is being retrieved with a present ID", func() { + ginkgo.It("should return the corresponding template", func() { + templater := &Templater{} + err := templater.SetTemplateString("bar", "template body") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + tpl, found := templater.GetTemplate("bar") + gomega.Expect(tpl).ToNot(gomega.BeNil()) + gomega.Expect(found).To(gomega.BeTrue()) + }) + }) + ginkgo.When("a template is being retrieved with an invalid ID", func() { + ginkgo.It("should return an error", func() { + templater := &Templater{} + err := templater.SetTemplateString("bar", "template body") + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + tpl, found := templater.GetTemplate("bad ID") + gomega.Expect(tpl).To(gomega.BeNil()) + gomega.Expect(found).ToNot(gomega.BeTrue()) + }) + }) +}) + +var _ = ginkgo.Describe("the standard enumless config implementation", func() { + ginkgo.When("it's enum method is called", func() { + ginkgo.It("should return an empty map", func() { + gomega.Expect((&EnumlessConfig{}).Enums()).To(gomega.BeEmpty()) + }) + }) +}) + +var _ = ginkgo.Describe("the standard failure implementation", func() { + ginkgo.Describe("Failure function", func() { + ginkgo.When("called with FailParseURL", func() { + ginkgo.It("should return a failure with the correct message", func() { + err := errors.New("invalid URL") + failure := Failure(FailParseURL, err) + gomega.Expect(failure.ID()).To(gomega.Equal(FailParseURL)) + gomega.Expect(failure.Error()). + To(gomega.ContainSubstring("error parsing Service URL")) + gomega.Expect(failure.Error()).To(gomega.ContainSubstring("invalid URL")) + }) + }) + ginkgo.When("called with FailUnknown", func() { + ginkgo.It("should return a failure with the unknown error message", func() { + err := errors.New("something went wrong") + failure := Failure(FailUnknown, err) + gomega.Expect(failure.ID()).To(gomega.Equal(FailUnknown)) + gomega.Expect(failure.Error()). + To(gomega.ContainSubstring("an unknown error occurred")) + gomega.Expect(failure.Error()).To(gomega.ContainSubstring("something went wrong")) + }) + }) + ginkgo.When("called with an unrecognized FailureID", func() { + ginkgo.It("should fallback to the unknown error message", func() { + err := errors.New("unrecognized error") + failure := Failure(failures.FailureID(999), err) // Arbitrary unknown ID + gomega.Expect(failure.ID()).To(gomega.Equal(failures.FailureID(999))) + gomega.Expect(failure.Error()). + To(gomega.ContainSubstring("an unknown error occurred")) + gomega.Expect(failure.Error()).To(gomega.ContainSubstring("unrecognized error")) + }) + }) + ginkgo.When("called with additional arguments", func() { + ginkgo.It("should include formatted arguments in the error", func() { + err := errors.New("base error") + failure := Failure(FailParseURL, err, "extra info: %s", "details") + gomega.Expect(failure.Error()). + To(gomega.ContainSubstring("error parsing Service URL extra info: details")) + gomega.Expect(failure.Error()).To(gomega.ContainSubstring("base error")) + }) + }) + }) + + ginkgo.Describe("IsTestSetupFailure function", func() { + ginkgo.When("called with a FailTestSetup failure", func() { + ginkgo.It("should return true and the correct message", func() { + err := errors.New("setup issue") + failure := Failure(FailTestSetup, err) + msg, isSetupFailure := IsTestSetupFailure(failure) + gomega.Expect(isSetupFailure).To(gomega.BeTrue()) + gomega.Expect(msg).To(gomega.ContainSubstring("test setup failed: setup issue")) + }) + }) + ginkgo.When("called with a different failure", func() { + ginkgo.It("should return false and an empty message", func() { + err := errors.New("parse issue") + failure := Failure(FailParseURL, err) + msg, isSetupFailure := IsTestSetupFailure(failure) + gomega.Expect(isSetupFailure).To(gomega.BeFalse()) + gomega.Expect(msg).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("called with nil", func() { + ginkgo.It("should return false and an empty message", func() { + msg, isSetupFailure := IsTestSetupFailure(nil) + gomega.Expect(isSetupFailure).To(gomega.BeFalse()) + gomega.Expect(msg).To(gomega.BeEmpty()) + }) + }) + }) +}) diff --git a/pkg/services/teams/teams_config.go b/pkg/services/teams/teams_config.go new file mode 100644 index 0000000..5cb7c81 --- /dev/null +++ b/pkg/services/teams/teams_config.go @@ -0,0 +1,200 @@ +package teams + +import ( + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifier for the Teams service protocol. +const Scheme = "teams" + +// Config constants. +const ( + DummyURL = "teams://dummy@dummy.com" // Default placeholder URL + ExpectedOrgMatches = 2 // Full match plus organization domain capture group + MinPathComponents = 3 // Minimum required path components: AltID, GroupOwner, ExtraID +) + +// Config represents the configuration for the Teams service. +type Config struct { + standard.EnumlessConfig + Group string `optional:"" url:"user"` + Tenant string `optional:"" url:"host"` + AltID string `optional:"" url:"path1"` + GroupOwner string `optional:"" url:"path2"` + ExtraID string `optional:"" url:"path3"` + + Title string `key:"title" optional:""` + Color string `key:"color" optional:""` + Host string `key:"host" optional:""` // Required, no default +} + +// WebhookParts returns the webhook components as an array. +func (config *Config) WebhookParts() [5]string { + return [5]string{config.Group, config.Tenant, config.AltID, config.GroupOwner, config.ExtraID} +} + +// SetFromWebhookURL updates the Config from a Teams webhook URL. +func (config *Config) SetFromWebhookURL(webhookURL string) error { + orgPattern := regexp.MustCompile( + `https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`, + ) + + orgGroups := orgPattern.FindStringSubmatch(webhookURL) + if len(orgGroups) != ExpectedComponents { + return ErrInvalidWebhookFormat + } + + config.Host = orgGroups[1] + WebhookDomain + + parts, err := ParseAndVerifyWebhookURL(webhookURL) + if err != nil { + return err + } + + config.setFromWebhookParts(parts) + + return nil +} + +// ConfigFromWebhookURL creates a new Config from a parsed Teams webhook URL. +func ConfigFromWebhookURL(webhookURL url.URL) (*Config, error) { + webhookURL.RawQuery = "" + config := &Config{Host: webhookURL.Host} + + if err := config.SetFromWebhookURL(webhookURL.String()); err != nil { + return nil, err + } + + return config, nil +} + +// GetURL constructs a URL from the Config fields. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// getURL constructs a URL using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + if config.Host == "" { + return nil + } + + return &url.URL{ + User: url.User(config.Group), + Host: config.Tenant, + Path: "/" + config.AltID + "/" + config.GroupOwner + "/" + config.ExtraID, + Scheme: Scheme, + RawQuery: format.BuildQuery(resolver), + } +} + +// SetURL updates the Config from a URL. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// setURL updates the Config from a URL using the provided resolver. +// It parses the URL parts, sets query parameters, and ensures the host is specified. +// Returns an error if the URL is invalid or the host is missing. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + parts, err := parseURLParts(url) + if err != nil { + return err + } + + config.setFromWebhookParts(parts) + + if err := config.setQueryParams(resolver, url.Query()); err != nil { + return err + } + + if config.Host == "" { + return ErrMissingHostParameter + } + + return nil +} + +// parseURLParts extracts and validates webhook components from a URL. +func parseURLParts(url *url.URL) ([5]string, error) { + var parts [5]string + if url.String() == DummyURL { + return parts, nil + } + + pathParts := strings.Split(url.Path, "/") + if pathParts[0] == "" { + pathParts = pathParts[1:] + } + + if len(pathParts) < MinPathComponents { + return parts, ErrMissingExtraIDComponent + } + + parts = [5]string{ + url.User.Username(), + url.Hostname(), + pathParts[0], + pathParts[1], + pathParts[2], + } + if err := verifyWebhookParts(parts); err != nil { + return parts, fmt.Errorf("invalid URL format: %w", err) + } + + return parts, nil +} + +// setQueryParams applies query parameters to the Config using the resolver. +// It resets Color, Host, and Title, then updates them based on query values. +// Returns an error if the resolver fails to set any parameter. +func (config *Config) setQueryParams(resolver types.ConfigQueryResolver, query url.Values) error { + config.Color = "" + config.Host = "" + config.Title = "" + + for key, vals := range query { + if len(vals) > 0 && vals[0] != "" { + switch key { + case "color": + config.Color = vals[0] + case "host": + config.Host = vals[0] + case "title": + config.Title = vals[0] + } + + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf( + "%w: key=%q, value=%q: %w", + ErrSetParameterFailed, + key, + vals[0], + err, + ) + } + } + } + + return nil +} + +// setFromWebhookParts sets Config fields from webhook parts. +func (config *Config) setFromWebhookParts(parts [5]string) { + config.Group = parts[0] + config.Tenant = parts[1] + config.AltID = parts[2] + config.GroupOwner = parts[3] + config.ExtraID = parts[4] +} diff --git a/pkg/services/teams/teams_errors.go b/pkg/services/teams/teams_errors.go new file mode 100644 index 0000000..71f3407 --- /dev/null +++ b/pkg/services/teams/teams_errors.go @@ -0,0 +1,50 @@ +package teams + +import "errors" + +// Error variables for the Teams package. +var ( + // ErrInvalidWebhookFormat indicates the webhook URL doesn't contain the organization domain. + ErrInvalidWebhookFormat = errors.New( + "invalid webhook URL format - must contain organization domain", + ) + + // ErrMissingHostParameter indicates the required host parameter is missing. + ErrMissingHostParameter = errors.New( + "missing required host parameter (organization.webhook.office.com)", + ) + + // ErrMissingExtraIDComponent indicates the URL is missing the extraId component. + ErrMissingExtraIDComponent = errors.New("invalid URL format: missing extraId component") + + // ErrMissingHost indicates the host is not specified in the configuration. + ErrMissingHost = errors.New("host is required but not specified in the configuration") + + // ErrSetParameterFailed indicates failure to set a configuration parameter. + ErrSetParameterFailed = errors.New("failed to set configuration parameter") + + // ErrSendFailedStatus indicates an unexpected status code in the response. + ErrSendFailedStatus = errors.New( + "failed to send notification to teams, response status code unexpected", + ) + + // ErrSendFailed indicates a general failure in sending the notification. + ErrSendFailed = errors.New("an error occurred while sending notification to teams") + + // ErrInvalidWebhookURL indicates the webhook URL format is invalid. + ErrInvalidWebhookURL = errors.New("invalid webhook URL format") + + // ErrInvalidHostFormat indicates the host format is invalid. + ErrInvalidHostFormat = errors.New("invalid host format") + + // ErrInvalidWebhookComponents indicates a mismatch in expected webhook URL components. + ErrInvalidWebhookComponents = errors.New( + "invalid webhook URL format: expected component count mismatch", + ) + + // ErrInvalidPartLength indicates a webhook component has an incorrect length. + ErrInvalidPartLength = errors.New("invalid webhook part length") + + // ErrMissingExtraID indicates the extraID is missing. + ErrMissingExtraID = errors.New("extraID is required") +) diff --git a/pkg/services/teams/teams_service.go b/pkg/services/teams/teams_service.go new file mode 100644 index 0000000..c54524b --- /dev/null +++ b/pkg/services/teams/teams_service.go @@ -0,0 +1,164 @@ +package teams + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// MaxSummaryLength defines the maximum length for a notification summary. +const MaxSummaryLength = 20 + +// TruncatedSummaryLen defines the length for a truncated summary. +const TruncatedSummaryLen = 21 + +// Service sends notifications to Microsoft Teams. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Microsoft Teams. +func (service *Service) Send(message string, params *types.Params) error { + config := service.Config + if err := service.pkr.UpdateConfigFromParams(config, params); err != nil { + service.Logf("Failed to update params: %v", err) + } + + return service.doSend(config, message) +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + service.pkr = format.NewPropKeyResolver(service.Config) + + return service.Config.SetURL(configURL) +} + +// GetID returns the service identifier. +func (service *Service) GetID() string { + return Scheme +} + +// GetConfigURLFromCustom converts a custom URL to a service URL. +func (service *Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) { + webhookURLStr := strings.TrimPrefix(customURL.String(), "teams+") + tempURL, err := url.Parse(webhookURLStr) + if err != nil { + return nil, fmt.Errorf("parsing custom URL %q: %w", webhookURLStr, err) + } + + webhookURL := &url.URL{ + Scheme: tempURL.Scheme, + Host: tempURL.Host, + Path: tempURL.Path, + } + + config, err := ConfigFromWebhookURL(*webhookURL) + if err != nil { + return nil, err + } + + config.Color = "" + config.Title = "" + + query := customURL.Query() + for key, vals := range query { + if vals[0] != "" { + switch key { + case "color": + config.Color = vals[0] + case "host": + config.Host = vals[0] + case "title": + config.Title = vals[0] + } + } + } + + return config.GetURL(), nil +} + +// doSend sends the notification to Teams using the configured webhook URL. +func (service *Service) doSend(config *Config, message string) error { + lines := strings.Split(message, "\n") + sections := make([]section, 0, len(lines)) + + for _, line := range lines { + sections = append(sections, section{Text: line}) + } + + summary := config.Title + if summary == "" && len(sections) > 0 { + summary = sections[0].Text + if len(summary) > MaxSummaryLength { + summary = summary[:TruncatedSummaryLen] + } + } + + payload, err := json.Marshal(payload{ + CardType: "MessageCard", + Context: "http://schema.org/extensions", + Markdown: true, + Title: config.Title, + ThemeColor: config.Color, + Summary: summary, + Sections: sections, + }) + if err != nil { + return fmt.Errorf("marshaling payload to JSON: %w", err) + } + + if config.Host == "" { + return ErrMissingHost + } + + postURL := BuildWebhookURL( + config.Host, + config.Group, + config.Tenant, + config.AltID, + config.GroupOwner, + config.ExtraID, + ) + + // Validate URL before sending + if err := ValidateWebhookURL(postURL); err != nil { + return err + } + + res, err := safePost(postURL, payload) + if err != nil { + return fmt.Errorf("%w: %s", ErrSendFailed, err.Error()) + } + defer res.Body.Close() // Move defer after error check + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("%w: %s", ErrSendFailedStatus, res.Status) + } + + return nil +} + +// safePost performs an HTTP POST with a pre-validated URL. +// Validation is already done; this wrapper isolates the call. +// +//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url +func safePost(url string, payload []byte) (*http.Response, error) { + res, err := http.Post(url, "application/json", bytes.NewBuffer(payload)) + if err != nil { + return nil, fmt.Errorf("making HTTP POST request: %w", err) + } + + return res, nil +} diff --git a/pkg/services/teams/teams_test.go b/pkg/services/teams/teams_test.go new file mode 100644 index 0000000..3eb802f --- /dev/null +++ b/pkg/services/teams/teams_test.go @@ -0,0 +1,265 @@ +package teams + +import ( + "errors" + "log" + "net/url" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + extraIDValue = "V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05" + scopedWebhookURL = "https://test.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue + scopedDomainHost = "test.webhook.office.com" + testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue + scopedURLBase = testURLBase + "?host=" + scopedDomainHost +) + +var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + +// TestTeams runs the test suite for the Teams package. +func TestTeams(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Teams Suite") +} + +var _ = ginkgo.Describe("the teams service", func() { + ginkgo.When("creating the webhook URL", func() { + ginkgo.It("should match the expected output for custom URLs", func() { + config := Config{} + config.setFromWebhookParts([5]string{ + "11111111-4444-4444-8444-cccccccccccc", + "22222222-4444-4444-8444-cccccccccccc", + "33333301222222222233333333333344", + "44444444-4444-4444-8444-cccccccccccc", + extraIDValue, + }) + apiURL := BuildWebhookURL( + scopedDomainHost, + config.Group, + config.Tenant, + config.AltID, + config.GroupOwner, + config.ExtraID, + ) + gomega.Expect(apiURL).To(gomega.Equal(scopedWebhookURL)) + + parts, err := ParseAndVerifyWebhookURL(apiURL) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(parts).To(gomega.Equal(config.WebhookParts())) + }) + }) + + ginkgo.Describe("creating a config", func() { + ginkgo.When("parsing the configuration URL", func() { + ginkgo.It("should be identical after de-/serialization", func() { + testURL := testURLBase + "?color=aabbcc&host=test.webhook.office.com&title=Test+title" + url, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + config := &Config{} + err = config.SetURL(url) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying") + + outputURL := config.GetURL() + gomega.Expect(outputURL.String()).To(gomega.Equal(testURL)) + }) + }) + }) + + ginkgo.Describe("converting custom URL to service URL", func() { + ginkgo.When("an invalid custom URL is provided", func() { + ginkgo.It("should return an error", func() { + service := Service{} + testURL := "teams+https://google.com/search?q=what+is+love" + customURL, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + _, err = service.GetConfigURLFromCustom(customURL) + gomega.Expect(err).To(gomega.HaveOccurred(), "converting") + }) + }) + ginkgo.When("a valid custom URL is provided", func() { + ginkgo.It("should set the host field from the custom URL", func() { + service := Service{} + testURL := `teams+` + scopedWebhookURL + customURL, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + serviceURL, err := service.GetConfigURLFromCustom(customURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting") + gomega.Expect(serviceURL.String()).To(gomega.Equal(scopedURLBase)) + }) + ginkgo.It("should preserve the query params in the generated service URL", func() { + service := Service{} + testURL := "teams+" + scopedWebhookURL + "?color=f008c1&title=TheTitle" + customURL, err := url.Parse(testURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing") + + serviceURL, err := service.GetConfigURLFromCustom(customURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting") + expectedURL := testURLBase + "?color=f008c1&host=test.webhook.office.com&title=TheTitle" + gomega.Expect(serviceURL.String()).To(gomega.Equal(expectedURL)) + }) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + var service Service + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL, _ := url.Parse(scopedURLBase) + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + scopedWebhookURL, + httpmock.NewStringResponder(200, ""), + ) + err = service.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not panic if an error occurs when sending the payload", func() { + serviceURL, _ := url.Parse(testURLBase + "?host=test.webhook.office.com") + err = service.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.RegisterResponder( + "POST", + scopedWebhookURL, + httpmock.NewErrorResponder(errors.New("dummy error")), + ) + err = service.Send("Message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("teams")) + }) + + // Config tests + ginkgo.Describe("the teams config", func() { + ginkgo.Describe("setURL", func() { + ginkgo.It("should set all fields correctly from URL", func() { + config := &Config{} + urlStr := testURLBase + "?title=Test&color=red&host=test.webhook.office.com" + parsedURL, err := url.Parse(urlStr) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = config.SetURL(parsedURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + gomega.Expect(config.Group).To(gomega.Equal("11111111-4444-4444-8444-cccccccccccc")) + gomega.Expect(config.Tenant). + To(gomega.Equal("22222222-4444-4444-8444-cccccccccccc")) + gomega.Expect(config.AltID).To(gomega.Equal("33333301222222222233333333333344")) + gomega.Expect(config.GroupOwner). + To(gomega.Equal("44444444-4444-4444-8444-cccccccccccc")) + gomega.Expect(config.ExtraID).To(gomega.Equal(extraIDValue)) + gomega.Expect(config.Title).To(gomega.Equal("Test")) + gomega.Expect(config.Color).To(gomega.Equal("red")) + gomega.Expect(config.Host).To(gomega.Equal("test.webhook.office.com")) + }) + + ginkgo.It("should reject URLs missing the extraID", func() { + config := &Config{} + urlStr := "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc?host=test.webhook.office.com" + parsedURL, err := url.Parse(urlStr) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = config.SetURL(parsedURL) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + + ginkgo.It("should require the host parameter", func() { + config := &Config{} + urlStr := testURLBase + parsedURL, err := url.Parse(urlStr) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + err = config.SetURL(parsedURL) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("getURL", func() { + ginkgo.It("should generate correct URL with all parameters", func() { + config := &Config{ + Group: "11111111-4444-4444-8444-cccccccccccc", + Tenant: "22222222-4444-4444-8444-cccccccccccc", + AltID: "33333301222222222233333333333344", + GroupOwner: "44444444-4444-4444-8444-cccccccccccc", + ExtraID: extraIDValue, + Title: "Test", + Color: "red", + Host: "test.webhook.office.com", + } + + urlObj := config.GetURL() + urlStr := urlObj.String() + expectedURL := testURLBase + "?color=red&host=test.webhook.office.com&title=Test" + gomega.Expect(urlStr).To(gomega.Equal(expectedURL)) + }) + }) + + ginkgo.Describe("verifyWebhookParts", func() { + ginkgo.It("should validate correct webhook parts", func() { + parts := [5]string{ + "11111111-4444-4444-8444-cccccccccccc", + "22222222-4444-4444-8444-cccccccccccc", + "33333301222222222233333333333344", + "44444444-4444-4444-8444-cccccccccccc", + extraIDValue, + } + err := verifyWebhookParts(parts) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + + ginkgo.It("should reject invalid group ID", func() { + parts := [5]string{ + "invalid-id", + "22222222-4444-4444-8444-cccccccccccc", + "33333333012222222222333333333344", + "44444444-4444-4444-8444-cccccccccccc", + extraIDValue, + } + err := verifyWebhookParts(parts) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + + ginkgo.Describe("parseAndVerifyWebhookURL", func() { + ginkgo.It("should correctly parse valid webhook URL", func() { + webhookURL := scopedWebhookURL + parts, err := ParseAndVerifyWebhookURL(webhookURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(parts).To(gomega.Equal([5]string{ + "11111111-4444-4444-8444-cccccccccccc", + "22222222-4444-4444-8444-cccccccccccc", + "33333301222222222233333333333344", + "44444444-4444-4444-8444-cccccccccccc", + extraIDValue, + })) + }) + + ginkgo.It("should reject invalid webhook URL", func() { + webhookURL := "https://teams.microsoft.com/invalid/webhook/url" + _, err := ParseAndVerifyWebhookURL(webhookURL) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + }) +}) diff --git a/pkg/services/teams/teams_types.go b/pkg/services/teams/teams_types.go new file mode 100644 index 0000000..d86be62 --- /dev/null +++ b/pkg/services/teams/teams_types.go @@ -0,0 +1,62 @@ +package teams + +// payload is the main structure for a Teams message card. +type payload struct { + CardType string `json:"@type"` + Context string `json:"@context"` + ThemeColor string `json:"themeColor,omitempty"` + Summary string `json:"summary"` + Title string `json:"title,omitempty"` + Markdown bool `json:"markdown"` + Sections []section `json:"sections"` +} + +// section represents a section of a Teams message card. +type section struct { + ActivityTitle string `json:"activityTitle,omitempty"` + ActivitySubtitle string `json:"activitySubtitle,omitempty"` + ActivityImage string `json:"activityImage,omitempty"` + Facts []fact `json:"facts,omitempty"` + Text string `json:"text,omitempty"` + Images []image `json:"images,omitempty"` + Actions []action `json:"potentialAction,omitempty"` + HeroImage *heroCard `json:"heroImage,omitempty"` +} + +// fact represents a key-value pair in a Teams message card. +type fact struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// image represents an image in a Teams message card. +type image struct { + Image string `json:"image"` + Title string `json:"title,omitempty"` +} + +// action represents an action button in a Teams message card. +type action struct { + Type string `json:"@type"` + Name string `json:"name"` + Targets []target `json:"targets,omitempty"` + Actions []subAction `json:"actions,omitempty"` +} + +// target represents a target for an action in a Teams message card. +type target struct { + OS string `json:"os"` + URI string `json:"uri"` +} + +// subAction represents a sub-action in a Teams message card. +type subAction struct { + Type string `json:"@type"` + Name string `json:"name"` + URI string `json:"uri"` +} + +// heroCard represents a hero image in a Teams message card. +type heroCard struct { + Image string `json:"image"` +} diff --git a/pkg/services/teams/teams_validation.go b/pkg/services/teams/teams_validation.go new file mode 100644 index 0000000..495ed16 --- /dev/null +++ b/pkg/services/teams/teams_validation.go @@ -0,0 +1,107 @@ +package teams + +import ( + "fmt" + "regexp" +) + +// Validation constants. +const ( + UUID4Length = 36 // Length of a UUID4 identifier + HashLength = 32 // Length of a hash identifier + WebhookDomain = ".webhook.office.com" + ExpectedComponents = 7 // Expected number of components in webhook URL (1 match + 6 captures) + Path = "webhookb2" + ProviderName = "IncomingWebhook" + + AltIDIndex = 2 // Index of AltID in parts array + GroupOwnerIndex = 3 // Index of GroupOwner in parts array +) + +var ( + // HostValidator ensures the host matches the Teams webhook domain pattern. + HostValidator = regexp.MustCompile(`^[a-zA-Z0-9-]+\.webhook\.office\.com$`) + // WebhookURLValidator ensures the full webhook URL matches the Teams pattern. + WebhookURLValidator = regexp.MustCompile( + `^https://[a-zA-Z0-9-]+\.webhook\.office\.com/webhookb2/[0-9a-f-]{36}@[0-9a-f-]{36}/IncomingWebhook/[0-9a-f]{32}/[0-9a-f-]{36}/[^/]+$`, + ) +) + +// ValidateWebhookURL ensures the webhook URL is valid before use. +func ValidateWebhookURL(url string) error { + if !WebhookURLValidator.MatchString(url) { + return fmt.Errorf("%w: %q", ErrInvalidWebhookURL, url) + } + + return nil +} + +// ParseAndVerifyWebhookURL extracts and validates webhook components from a URL. +func ParseAndVerifyWebhookURL(webhookURL string) ([5]string, error) { + pattern := regexp.MustCompile( + `https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`, + ) + + groups := pattern.FindStringSubmatch(webhookURL) + if len(groups) != ExpectedComponents { + return [5]string{}, fmt.Errorf( + "%w: expected %d components, got %d", + ErrInvalidWebhookComponents, + ExpectedComponents, + len(groups), + ) + } + + parts := [5]string{groups[2], groups[3], groups[4], groups[5], groups[6]} + if err := verifyWebhookParts(parts); err != nil { + return [5]string{}, err + } + + return parts, nil +} + +// verifyWebhookParts ensures webhook components meet format requirements. +func verifyWebhookParts(parts [5]string) error { + type partSpec struct { + name string + length int + index int + optional bool + } + + specs := []partSpec{ + {name: "group ID", length: UUID4Length, index: 0, optional: true}, + {name: "tenant ID", length: UUID4Length, index: 1, optional: true}, + {name: "altID", length: HashLength, index: AltIDIndex, optional: true}, + {name: "groupOwner", length: UUID4Length, index: GroupOwnerIndex, optional: true}, + } + + for _, spec := range specs { + if len(parts[spec.index]) != spec.length && parts[spec.index] != "" { + return fmt.Errorf( + "%w: %s must be %d characters, got %d", + ErrInvalidPartLength, + spec.name, + spec.length, + len(parts[spec.index]), + ) + } + } + + if parts[4] == "" { + return ErrMissingExtraID + } + + return nil +} + +// BuildWebhookURL constructs a Teams webhook URL from components. +func BuildWebhookURL(host, group, tenant, altID, groupOwner, extraID string) string { + // Host validation moved here for clarity + if !HostValidator.MatchString(host) { + return "" // Will trigger ErrInvalidHostFormat in caller + } + + return fmt.Sprintf("https://%s/%s/%s@%s/%s/%s/%s/%s", + host, Path, group, tenant, ProviderName, altID, groupOwner, extraID) +} diff --git a/pkg/services/telegram/telegram.go b/pkg/services/telegram/telegram.go new file mode 100644 index 0000000..c93fd09 --- /dev/null +++ b/pkg/services/telegram/telegram.go @@ -0,0 +1,89 @@ +package telegram + +import ( + "errors" + "fmt" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// apiFormat defines the Telegram API endpoint template. +const ( + apiFormat = "https://api.telegram.org/bot%s/%s" + maxlength = 4096 +) + +// ErrMessageTooLong indicates that the message exceeds the maximum allowed length. +var ( + ErrMessageTooLong = errors.New("Message exceeds the max length") +) + +// Service sends notifications to configured Telegram chats. +type Service struct { + standard.Standard + Config *Config + pkr format.PropKeyResolver +} + +// Send delivers a notification message to Telegram. +func (service *Service) Send(message string, params *types.Params) error { + if len(message) > maxlength { + return ErrMessageTooLong + } + + config := *service.Config + if err := service.pkr.UpdateConfigFromParams(&config, params); err != nil { + return fmt.Errorf("updating config from params: %w", err) + } + + return service.sendMessageForChatIDs(message, &config) +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{ + Preview: true, + Notification: true, + } + service.pkr = format.NewPropKeyResolver(service.Config) + + if err := service.Config.setURL(&service.pkr, configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// sendMessageForChatIDs sends the message to all configured chat IDs. +func (service *Service) sendMessageForChatIDs(message string, config *Config) error { + for _, chat := range service.Config.Chats { + if err := sendMessageToAPI(message, chat, config); err != nil { + return err + } + } + + return nil +} + +// GetConfig returns the current configuration for the service. +func (service *Service) GetConfig() *Config { + return service.Config +} + +// sendMessageToAPI sends a message to the Telegram API for a specific chat. +func sendMessageToAPI(message string, chat string, config *Config) error { + client := &Client{token: config.Token} + payload := createSendMessagePayload(message, chat, config) + _, err := client.SendMessage(&payload) + + return err +} diff --git a/pkg/services/telegram/telegram_client.go b/pkg/services/telegram/telegram_client.go new file mode 100644 index 0000000..3bc8d1a --- /dev/null +++ b/pkg/services/telegram/telegram_client.go @@ -0,0 +1,74 @@ +package telegram + +import ( + "encoding/json" + "fmt" + + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +// Client for Telegram API. +type Client struct { + token string +} + +func (c *Client) apiURL(endpoint string) string { + return fmt.Sprintf(apiFormat, c.token, endpoint) +} + +// GetBotInfo returns the bot User info. +func (c *Client) GetBotInfo() (*User, error) { + response := &userResponse{} + err := jsonclient.Get(c.apiURL("getMe"), response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return &response.Result, nil +} + +// GetUpdates retrieves the latest updates. +func (c *Client) GetUpdates( + offset int, + limit int, + timeout int, + allowedUpdates []string, +) ([]Update, error) { + request := &updatesRequest{ + Offset: offset, + Limit: limit, + Timeout: timeout, + AllowedUpdates: allowedUpdates, + } + response := &updatesResponse{} + err := jsonclient.Post(c.apiURL("getUpdates"), request, response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return response.Result, nil +} + +// SendMessage sends the specified Message. +func (c *Client) SendMessage(message *SendMessagePayload) (*Message, error) { + response := &messageResponse{} + err := jsonclient.Post(c.apiURL("sendMessage"), message, response) + + if !response.OK { + return nil, GetErrorResponse(jsonclient.ErrorBody(err)) + } + + return response.Result, nil +} + +// GetErrorResponse retrieves the error message from a failed request. +func GetErrorResponse(body string) error { + response := &responseError{} + if err := json.Unmarshal([]byte(body), response); err == nil { + return response + } + + return nil +} diff --git a/pkg/services/telegram/telegram_config.go b/pkg/services/telegram/telegram_config.go new file mode 100644 index 0000000..256193b --- /dev/null +++ b/pkg/services/telegram/telegram_config.go @@ -0,0 +1,94 @@ +package telegram + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme identifies this service in configuration URLs. +const ( + Scheme = "telegram" +) + +// ErrInvalidToken indicates an invalid Telegram token format or content. +var ( + ErrInvalidToken = errors.New("invalid telegram token") + ErrNoChannelsDefined = errors.New("no channels defined in config URL") +) + +// Config holds settings for the Telegram notification service. +type Config struct { + Token string `url:"user"` + Preview bool ` default:"Yes" desc:"If disabled, no web page preview will be displayed for URLs" key:"preview"` + Notification bool ` default:"Yes" desc:"If disabled, sends Message silently" key:"notification"` + ParseMode parseMode ` default:"None" desc:"How the text Message should be parsed" key:"parsemode"` + Chats []string ` desc:"Chat IDs or Channel names (using @channel-name)" key:"chats,channels"` + Title string ` default:"" desc:"Notification title, optionally set by the sender" key:"title"` +} + +// Enums returns the fields that use an EnumFormatter for their values. +func (config *Config) Enums() map[string]types.EnumFormatter { + return map[string]types.EnumFormatter{ + "ParseMode": ParseModes.Enum, + } +} + +// GetURL generates a URL from the current configuration values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates the configuration from a URL representation. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL { + tokenParts := strings.Split(config.Token, ":") + + return &url.URL{ + User: url.UserPassword(tokenParts[0], tokenParts[1]), + Host: Scheme, + Scheme: Scheme, + ForceQuery: true, + RawQuery: format.BuildQuery(resolver), + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error { + password, _ := url.User.Password() + + token := url.User.Username() + ":" + password + if url.String() != "telegram://dummy@dummy.com" { + if !IsTokenValid(token) { + return fmt.Errorf("%w: %s", ErrInvalidToken, token) + } + } + + for key, vals := range url.Query() { + if err := resolver.Set(key, vals[0]); err != nil { + return fmt.Errorf("setting config property %q from URL query: %w", key, err) + } + } + + if url.String() != "telegram://dummy@dummy.com" { + if len(config.Chats) < 1 { + return ErrNoChannelsDefined + } + } + + config.Token = token + + return nil +} diff --git a/pkg/services/telegram/telegram_generator.go b/pkg/services/telegram/telegram_generator.go new file mode 100644 index 0000000..534bb88 --- /dev/null +++ b/pkg/services/telegram/telegram_generator.go @@ -0,0 +1,215 @@ +package telegram + +import ( + "errors" + "fmt" + "io" + "os" + "os/signal" + "slices" + "strconv" + "syscall" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util/generator" +) + +// UpdatesLimit defines the number of updates to retrieve per API call. +const ( + UpdatesLimit = 10 // Number of updates to retrieve per call + UpdatesTimeout = 120 // Timeout in seconds for long polling +) + +// ErrNoChatsSelected indicates that no chats were selected during generation. +var ( + ErrNoChatsSelected = errors.New("no chats were selected") +) + +// Generator facilitates Telegram-specific URL generation via user interaction. +type Generator struct { + userDialog *generator.UserDialog + client *Client + chats []string + chatNames []string + chatTypes []string + done bool + botName string + Reader io.Reader + Writer io.Writer +} + +// Generate creates a Telegram Shoutrrr configuration from user dialog input. +func (g *Generator) Generate( + _ types.Service, + props map[string]string, + _ []string, +) (types.ServiceConfig, error) { + var config Config + + if g.Reader == nil { + g.Reader = os.Stdin + } + + if g.Writer == nil { + g.Writer = os.Stdout + } + + g.userDialog = generator.NewUserDialog(g.Reader, g.Writer, props) + userDialog := g.userDialog + + userDialog.Writelnf( + "To start we need your bot token. If you haven't created a bot yet, you can use this link:", + ) + userDialog.Writelnf(" %v", format.ColorizeLink("https://t.me/botfather?start")) + userDialog.Writelnf("") + + token := userDialog.QueryString( + "Enter your bot token:", + generator.ValidateFormat(IsTokenValid), + "token", + ) + + userDialog.Writelnf("Fetching bot info...") + + g.client = &Client{token: token} + + botInfo, err := g.client.GetBotInfo() + if err != nil { + return &Config{}, err + } + + g.botName = botInfo.Username + + userDialog.Writelnf("") + userDialog.Writelnf( + "Okay! %v will listen for any messages in PMs and group chats it is invited to.", + format.ColorizeString("@", g.botName), + ) + + g.done = false + lastUpdate := 0 + + signals := make(chan os.Signal, 1) + + // Subscribe to system signals + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + + for !g.done { + userDialog.Writelnf("Waiting for messages to arrive...") + + updates, err := g.client.GetUpdates(lastUpdate, UpdatesLimit, UpdatesTimeout, nil) + if err != nil { + panic(err) + } + + // If no updates were retrieved, prompt user to continue + promptDone := len(updates) == 0 + + for _, update := range updates { + lastUpdate = update.UpdateID + 1 + + switch { + case update.Message != nil || update.ChannelPost != nil: + message := update.Message + if update.ChannelPost != nil { + message = update.ChannelPost + } + + chat := message.Chat + + source := message.Chat.Name() + if message.From != nil { + source = "@" + message.From.Username + } + + userDialog.Writelnf("Got Message '%v' from %v in %v chat %v", + format.ColorizeString(message.Text), + format.ColorizeProp(source), + format.ColorizeEnum(chat.Type), + format.ColorizeNumber(chat.ID)) + userDialog.Writelnf(g.addChat(chat)) + // Another chat was added, prompt user to continue + promptDone = true + + case update.ChatMemberUpdate != nil: + cmu := update.ChatMemberUpdate + oldStatus := cmu.OldChatMember.Status + newStatus := cmu.NewChatMember.Status + userDialog.Writelnf( + "Got a bot chat member update for %v, status was changed from %v to %v", + format.ColorizeProp(cmu.Chat.Name()), + format.ColorizeEnum(oldStatus), + format.ColorizeEnum(newStatus), + ) + + default: + userDialog.Writelnf("Got unknown Update. Ignored!") + } + } + + if promptDone { + userDialog.Writelnf("") + + g.done = !userDialog.QueryBool( + fmt.Sprintf("Got %v chat ID(s) so far. Want to add some more?", + format.ColorizeNumber(len(g.chats))), + "", + ) + } + } + + userDialog.Writelnf("") + userDialog.Writelnf("Cleaning up the bot session...") + + // Notify API that we got the updates + if _, err = g.client.GetUpdates(lastUpdate, 0, 0, nil); err != nil { + g.userDialog.Writelnf( + "Failed to mark last updates as received: %v", + format.ColorizeError(err), + ) + } + + if len(g.chats) < 1 { + return nil, ErrNoChatsSelected + } + + userDialog.Writelnf("Selected chats:") + + for i, id := range g.chats { + name := g.chatNames[i] + chatType := g.chatTypes[i] + userDialog.Writelnf( + " %v (%v) %v", + format.ColorizeNumber(id), + format.ColorizeEnum(chatType), + format.ColorizeString(name), + ) + } + + userDialog.Writelnf("") + + config = Config{ + Notification: true, + Token: token, + Chats: g.chats, + } + + return &config, nil +} + +// addChat adds a chat to the generator's list if it’s not already present. +func (g *Generator) addChat(chat *Chat) string { + chatID := strconv.FormatInt(chat.ID, 10) + name := chat.Name() + + if slices.Contains(g.chats, chatID) { + return fmt.Sprintf("chat %v is already selected!", format.ColorizeString(name)) + } + + g.chats = append(g.chats, chatID) + g.chatNames = append(g.chatNames, name) + g.chatTypes = append(g.chatTypes, chat.Type) + + return fmt.Sprintf("Added new chat %v!", format.ColorizeString(name)) +} diff --git a/pkg/services/telegram/telegram_generator_test.go b/pkg/services/telegram/telegram_generator_test.go new file mode 100644 index 0000000..9a7dfcc --- /dev/null +++ b/pkg/services/telegram/telegram_generator_test.go @@ -0,0 +1,131 @@ +package telegram_test + +import ( + "fmt" + "io" + "strings" + + "github.com/jarcoal/httpmock" + "github.com/mattn/go-colorable" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/telegram" +) + +const ( + mockToken = `0:MockToken` + mockAPIBase = "https://api.telegram.org/bot" + mockToken + "/" +) + +var ( + userOut *gbytes.Buffer + userIn *gbytes.Buffer + userInMono io.Writer +) + +func mockTyped(a ...any) { + fmt.Fprint(userOut, a...) + fmt.Fprint(userOut, "\n") +} + +func dumpBuffers() { + for _, line := range strings.Split(string(userIn.Contents()), "\n") { + fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n") + } + + for _, line := range strings.Split(string(userOut.Contents()), "\n") { + fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n") + } +} + +func mockAPI(endpoint string) string { + return mockAPIBase + endpoint +} + +var _ = ginkgo.Describe("TelegramGenerator", func() { + ginkgo.BeforeEach(func() { + userOut = gbytes.NewBuffer() + userIn = gbytes.NewBuffer() + userInMono = colorable.NewNonColorable(userIn) + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should return the ", func() { + gen := telegram.Generator{ + Reader: userOut, + Writer: userInMono, + } + + resultChannel := make(chan string, 1) + + httpmock.RegisterResponder( + "GET", + mockAPI(`getMe`), + httpmock.NewJsonResponderOrPanic(200, &struct { + OK bool + Result *telegram.User + }{ + true, &telegram.User{ + ID: 1, + IsBot: true, + Username: "mockbot", + }, + }), + ) + + httpmock.RegisterResponder( + "POST", + mockAPI(`getUpdates`), + httpmock.NewJsonResponderOrPanic(200, &struct { + OK bool + Result []telegram.Update + }{ + true, + []telegram.Update{ + { + ChatMemberUpdate: &telegram.ChatMemberUpdate{ + Chat: &telegram.Chat{Type: `channel`, Title: `mockChannel`}, + OldChatMember: &telegram.ChatMember{Status: `kicked`}, + NewChatMember: &telegram.ChatMember{Status: `administrator`}, + }, + }, + { + Message: &telegram.Message{ + Text: "hi!", + From: &telegram.User{Username: `mockUser`}, + Chat: &telegram.Chat{Type: `private`, ID: 667, Username: `mockUser`}, + }, + }, + }, + }), + ) + + go func() { + defer ginkgo.GinkgoRecover() + conf, err := gen.Generate(nil, nil, nil) + + gomega.Expect(conf).ToNot(gomega.BeNil()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + resultChannel <- conf.GetURL().String() + }() + + defer dumpBuffers() + + mockTyped(mockToken) + mockTyped(`no`) + + gomega.Eventually(userIn). + Should(gbytes.Say(`Got a bot chat member update for mockChannel, status was changed from kicked to administrator`)) + gomega.Eventually(userIn). + Should(gbytes.Say(`Got 1 chat ID\(s\) so far\. Want to add some more\?`)) + gomega.Eventually(userIn).Should(gbytes.Say(`Selected chats:`)) + gomega.Eventually(userIn).Should(gbytes.Say(`667 \(private\) @mockUser`)) + + gomega.Eventually(resultChannel). + Should(gomega.Receive(gomega.Equal(`telegram://0:MockToken@telegram?chats=667&preview=No`))) + }) +}) diff --git a/pkg/services/telegram/telegram_internal_test.go b/pkg/services/telegram/telegram_internal_test.go new file mode 100644 index 0000000..f399dc9 --- /dev/null +++ b/pkg/services/telegram/telegram_internal_test.go @@ -0,0 +1,153 @@ +package telegram + +import ( + "encoding/json" + "errors" + "log" + "net/url" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gstruct" +) + +var _ = ginkgo.Describe("the telegram service", func() { + var logger *log.Logger + + ginkgo.BeforeEach(func() { + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + }) + + ginkgo.Describe("creating configurations", func() { + ginkgo.When("given an url", func() { + ginkgo.When("a parse mode is not supplied", func() { + ginkgo.It("no parse_mode should be present in payload", func() { + payload, err := getPayloadStringFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1", + "Message", + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode")) + }) + }) + + ginkgo.When("a parse mode is supplied", func() { + ginkgo.When("it's set to a valid mode and not None", func() { + ginkgo.It("parse_mode should be present in payload", func() { + payload, err := getPayloadStringFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=Markdown", + "Message", + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(payload).To(gomega.ContainSubstring("parse_mode")) + }) + }) + ginkgo.When("it's set to None", func() { + ginkgo.When("no title has been provided", func() { + ginkgo.It("no parse_mode should be present in payload", func() { + payload, err := getPayloadStringFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1&parsemode=None", + "Message", + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(payload).NotTo(gomega.ContainSubstring("parse_mode")) + }) + }) + ginkgo.When("a title has been provided", func() { + payload, err := getPayloadFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle", + `Oh wow! <3 Cool & stuff ->`, + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.It("should have parse_mode set to HTML", func() { + gomega.Expect(payload.ParseMode).To(gomega.Equal("HTML")) + }) + ginkgo.It("should contain the title prepended in the message", func() { + gomega.Expect(payload.Text).To(gomega.ContainSubstring("MessageTitle")) + }) + ginkgo.It("should escape the message HTML tags", func() { + gomega.Expect(payload.Text).To(gomega.ContainSubstring("<3")) + gomega.Expect(payload.Text). + To(gomega.ContainSubstring("Cool & stuff")) + gomega.Expect(payload.Text).To(gomega.ContainSubstring("->")) + }) + }) + }) + }) + + ginkgo.When("parsing URL that might have a message thread id", func() { + ginkgo.When("no thread id is provided", func() { + payload, err := getPayloadFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1&title=MessageTitle", + `Oh wow! <3 Cool & stuff ->`, + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.It("should have message_thread_id set to nil", func() { + gomega.Expect(payload.MessageThreadID).To(gomega.BeNil()) + }) + }) + ginkgo.When("a numeric thread id is provided", func() { + payload, err := getPayloadFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1:10&title=MessageTitle", + `Oh wow! <3 Cool & stuff ->`, + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.It("should have message_thread_id set to 10", func() { + gomega.Expect(payload.MessageThreadID).To(gstruct.PointTo(gomega.Equal(10))) + }) + }) + ginkgo.When("non-numeric thread id is provided", func() { + payload, err := getPayloadFromURL( + "telegram://12345:mock-token@telegram/?channels=channel-1:invalid&title=MessageTitle", + `Oh wow! <3 Cool & stuff ->`, + logger, + ) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + ginkgo.It("should have message_thread_id set to nil", func() { + gomega.Expect(payload.MessageThreadID).To(gomega.BeNil()) + }) + }) + }) + }) + }) +}) + +func getPayloadFromURL( + testURL string, + message string, + logger *log.Logger, +) (SendMessagePayload, error) { + telegram := &Service{} + + serviceURL, err := url.Parse(testURL) + if err != nil { + return SendMessagePayload{}, err + } + + if err = telegram.Initialize(serviceURL, logger); err != nil { + return SendMessagePayload{}, err + } + + if len(telegram.Config.Chats) < 1 { + return SendMessagePayload{}, errors.New("no channels were supplied") + } + + return createSendMessagePayload(message, telegram.Config.Chats[0], telegram.Config), nil +} + +func getPayloadStringFromURL(testURL string, message string, logger *log.Logger) ([]byte, error) { + payload, err := getPayloadFromURL(testURL, message, logger) + if err != nil { + return nil, err + } + + raw, err := json.Marshal(payload) + + return raw, err +} diff --git a/pkg/services/telegram/telegram_json.go b/pkg/services/telegram/telegram_json.go new file mode 100644 index 0000000..ae1adca --- /dev/null +++ b/pkg/services/telegram/telegram_json.go @@ -0,0 +1,234 @@ +package telegram + +import ( + "fmt" + "html" + "strconv" + "strings" +) + +// SendMessagePayload is the notification payload for the telegram notification service. +type SendMessagePayload struct { + Text string `json:"text"` + ID string `json:"chat_id"` + MessageThreadID *int `json:"message_thread_id,omitempty"` + ParseMode string `json:"parse_mode,omitempty"` + DisablePreview bool `json:"disable_web_page_preview"` + DisableNotification bool `json:"disable_notification"` + ReplyMarkup *replyMarkup `json:"reply_markup,omitempty"` + Entities []entity `json:"entities,omitempty"` + ReplyTo int64 `json:"reply_to_message_id"` + MessageID int64 `json:"message_id,omitempty"` +} + +// Message represents one chat message. +type Message struct { + MessageID int64 `json:"message_id"` + Text string `json:"text"` + From *User `json:"from"` + Chat *Chat `json:"chat"` +} + +type messageResponse struct { + OK bool `json:"ok"` + Result *Message `json:"result"` +} + +type responseError struct { + OK bool `json:"ok"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +type userResponse struct { + OK bool `json:"ok"` + Result User `json:"result"` +} + +// User contains information about a telegram user or bot. +type User struct { + // Unique identifier for this User or bot + ID int64 `json:"id"` + // True, if this User is a bot + IsBot bool `json:"is_bot"` + // User's or bot's first name + FirstName string `json:"first_name"` + // Optional. User's or bot's last name + LastName string `json:"last_name"` + // Optional. User's or bot's username + Username string `json:"username"` + // Optional. IETF language tag of the User's language + LanguageCode string `json:"language_code"` + // Optional. True, if the bot can be invited to groups. Returned only in getMe. + CanJoinGroups bool `json:"can_join_groups"` + // Optional. True, if privacy mode is disabled for the bot. Returned only in getMe. + CanReadAllGroupMessages bool `json:"can_read_all_group_messages"` + // Optional. True, if the bot supports inline queries. Returned only in getMe. + SupportsInlineQueries bool `json:"supports_inline_queries"` +} + +type updatesRequest struct { + Offset int `json:"offset"` + Limit int `json:"limit"` + Timeout int `json:"timeout"` + AllowedUpdates []string `json:"allowed_updates"` +} + +type updatesResponse struct { + OK bool `json:"ok"` + Result []Update `json:"result"` +} + +type inlineQuery struct { + // Unique identifier for this query + ID string `json:"id"` + // Sender + From User `json:"from"` + // Text of the query (up to 256 characters) + Query string `json:"query"` + // Offset of the results to be returned, can be controlled by the bot + Offset string `json:"offset"` +} + +type chosenInlineResult struct{} + +// Update contains state changes since the previous Update. +type Update struct { + // The Update's unique identifier. Update identifiers start from a certain positive number and increase sequentially. This ID becomes especially handy if you're using Webhooks, since it allows you to ignore repeated updates or to restore the correct Update sequence, should they get out of order. If there are no new updates for at least a week, then identifier of the next Update will be chosen randomly instead of sequentially. + UpdateID int `json:"update_id"` + // Optional. New incoming Message of any kind — text, photo, sticker, etc. + Message *Message `json:"Message"` + // Optional. New version of a Message that is known to the bot and was edited + EditedMessage *Message `json:"edited_message"` + // Optional. New incoming channel post of any kind — text, photo, sticker, etc. + ChannelPost *Message `json:"channel_post"` + // Optional. New version of a channel post that is known to the bot and was edited + EditedChannelPost *Message `json:"edited_channel_post"` + // Optional. New incoming inline query + InlineQuery *inlineQuery `json:"inline_query"` + //// Optional. The result of an inline query that was chosen by a User and sent to their chat partner. Please see our documentation on the feedback collecting for details on how to enable these updates for your bot. + ChosenInlineResult *chosenInlineResult `json:"chosen_inline_result"` + //// Optional. New incoming callback query + CallbackQuery *callbackQuery `json:"callback_query"` + + // API fields that are not used by the client has been commented out + + //// Optional. New incoming shipping query. Only for invoices with flexible price + // ShippingQuery `json:"shipping_query"` + //// Optional. New incoming pre-checkout query. Contains full information about checkout + // PreCheckoutQuery `json:"pre_checkout_query"` + //// Optional. New poll state. Bots receive only updates about stopped polls and polls, which are sent by the bot + // Poll `json:"poll"` + //// Optional. A User changed their answer in a non-anonymous poll. Bots receive new votes only in polls that were sent by the bot itself. + // Poll_answer PollAnswer `json:"poll_answer"` + + ChatMemberUpdate *ChatMemberUpdate `json:"my_chat_member"` +} + +// Chat represents a telegram conversation. +type Chat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + Username string `json:"username"` +} + +type inlineKey struct { + Text string `json:"text"` + URL string `json:"url"` + LoginURL string `json:"login_url"` + CallbackData string `json:"callback_data"` + SwitchInlineQuery string `json:"switch_inline_query"` + SwitchInlineQueryCurrent string `json:"switch_inline_query_current_chat"` +} + +type replyMarkup struct { + InlineKeyboard [][]inlineKey `json:"inline_keyboard,omitempty"` +} + +type entity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +type callbackQuery struct { + ID string `json:"id"` + From *User `json:"from"` + Message *Message `json:"Message"` + Data string `json:"data"` +} + +// ChatMemberUpdate represents a member update in a telegram chat. +type ChatMemberUpdate struct { + // Chat the user belongs to + Chat *Chat `json:"chat"` + // Performer of the action, which resulted in the change + From *User `json:"from"` + // Date the change was done in Unix time + Date int `json:"date"` + // Previous information about the chat member + OldChatMember *ChatMember `json:"old_chat_member"` + // New information about the chat member + NewChatMember *ChatMember `json:"new_chat_member"` + // Optional. Chat invite link, which was used by the user to join the chat; for joining by invite link events only. + // invite_link ChatInviteLink +} + +// ChatMember represents the membership state for a user in a telegram chat. +type ChatMember struct { + // The member's status in the chat + Status string `json:"status"` + // Information about the user + User *User `json:"user"` +} + +func (e *responseError) Error() string { + return e.Description +} + +// Name returns the name of the channel based on its type. +func (c *Chat) Name() string { + if c.Type == "private" || c.Type == "channel" && c.Username != "" { + return "@" + c.Username + } + + return c.Title +} + +func createSendMessagePayload(message string, channel string, config *Config) SendMessagePayload { + var threadID *int + + chatID, thread, ok := strings.Cut(channel, ":") + if ok { + if parsed, err := strconv.Atoi(thread); err == nil { + threadID = &parsed + } + } + + payload := SendMessagePayload{ + Text: message, + ID: chatID, + MessageThreadID: threadID, + DisableNotification: !config.Notification, + DisablePreview: !config.Preview, + } + + parseMode := config.ParseMode + if config.ParseMode == ParseModes.None && config.Title != "" { + parseMode = ParseModes.HTML + // no parse mode has been provided, treat message as unescaped HTML + message = html.EscapeString(message) + } + + if parseMode != ParseModes.None { + payload.ParseMode = parseMode.String() + } + + // only HTML parse mode is supported for titles + if parseMode == ParseModes.HTML { + payload.Text = fmt.Sprintf("%v\n%v", html.EscapeString(config.Title), message) + } + + return payload +} diff --git a/pkg/services/telegram/telegram_parsemode.go b/pkg/services/telegram/telegram_parsemode.go new file mode 100644 index 0000000..ad5da03 --- /dev/null +++ b/pkg/services/telegram/telegram_parsemode.go @@ -0,0 +1,42 @@ +package telegram + +import ( + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +const ( + ParseModeNone parseMode = iota // 0 + ParseModeMarkdown // 1 + ParseModeHTML // 2 + ParseModeMarkdownV2 // 3 +) + +// ParseModes is an enum helper for parseMode. +var ParseModes = &parseModeVals{ + None: ParseModeNone, + Markdown: ParseModeMarkdown, + HTML: ParseModeHTML, + MarkdownV2: ParseModeMarkdownV2, + Enum: format.CreateEnumFormatter( + []string{ + "None", + "Markdown", + "HTML", + "MarkdownV2", + }), +} + +type parseMode int + +type parseModeVals struct { + None parseMode + Markdown parseMode + HTML parseMode + MarkdownV2 parseMode + Enum types.EnumFormatter +} + +func (pm parseMode) String() string { + return ParseModes.Enum.Print(int(pm)) +} diff --git a/pkg/services/telegram/telegram_test.go b/pkg/services/telegram/telegram_test.go new file mode 100644 index 0000000..48196c7 --- /dev/null +++ b/pkg/services/telegram/telegram_test.go @@ -0,0 +1,187 @@ +package telegram + +import ( + "fmt" + "log" + "net/url" + "os" + "strings" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" +) + +func TestTelegram(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Telegram Suite") +} + +var ( + envTelegramURL string + logger *log.Logger + + _ = ginkgo.BeforeSuite(func() { + envTelegramURL = os.Getenv("SHOUTRRR_TELEGRAM_URL") + logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags) + }) +) + +var _ = ginkgo.Describe("the telegram service", func() { + var telegram *Service // No telegram. prefix needed + + ginkgo.BeforeEach(func() { + telegram = &Service{} + }) + + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + if envTelegramURL == "" { + return + } + serviceURL, _ := url.Parse(envTelegramURL) + err := telegram.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = telegram.Send("This is an integration test Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.When("given a Message that exceeds the max length", func() { + ginkgo.It("should generate an error", func() { + if envTelegramURL == "" { + return + } + hundredChars := "this string is exactly (to the letter) a hundred characters long which will make the send func error" + serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1") + builder := strings.Builder{} + for range 42 { + builder.WriteString(hundredChars) + } + + err := telegram.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = telegram.Send(builder.String(), nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + }) + }) + ginkgo.When("given a valid request with a faked token", func() { + if envTelegramURL == "" { + return + } + ginkgo.It("should generate a 401", func() { + serviceURL, _ := url.Parse( + "telegram://000000000:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA@telegram/?chats=channel-id", + ) + message := "this is a perfectly valid Message" + + err := telegram.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = telegram.Send(message, nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(strings.Contains(err.Error(), "401 Unauthorized")).To(gomega.BeTrue()) + }) + }) + }) + + ginkgo.Describe("creating configurations", func() { + ginkgo.When("given an url", func() { + ginkgo.It("should return an error if no arguments where supplied", func() { + expectErrorAndEmptyObject(telegram, "telegram://", logger) + }) + ginkgo.It("should return an error if the token has an invalid format", func() { + expectErrorAndEmptyObject(telegram, "telegram://invalid-token", logger) + }) + ginkgo.It("should return an error if only the api token where supplied", func() { + expectErrorAndEmptyObject(telegram, "telegram://12345:mock-token@telegram", logger) + }) + + ginkgo.When("the url is valid", func() { + var config *Config // No telegram. prefix + var err error + + ginkgo.BeforeEach(func() { + serviceURL, _ := url.Parse( + "telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3", + ) + err = telegram.Initialize(serviceURL, logger) + config = telegram.GetConfig() + }) + + ginkgo.It("should create a config object", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config).ToNot(gomega.BeNil()) + }) + ginkgo.It("should create a config object containing the API Token", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Token).To(gomega.Equal("12345:mock-token")) + }) + ginkgo.It("should add every chats query field as a chat ID", func() { + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + gomega.Expect(config.Chats).To(gomega.Equal([]string{ + "channel-1", + "channel-2", + "channel-3", + })) + }) + }) + }) + }) + + ginkgo.Describe("sending the payload", func() { + var err error + ginkgo.BeforeEach(func() { + httpmock.Activate() + }) + ginkgo.AfterEach(func() { + httpmock.DeactivateAndReset() + }) + ginkgo.It("should not report an error if the server accepts the payload", func() { + serviceURL, _ := url.Parse( + "telegram://12345:mock-token@telegram/?chats=channel-1,channel-2,channel-3", + ) + err = telegram.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + setupResponder("sendMessage", telegram.GetConfig().Token, 200, "") + + err = telegram.Send("Message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.It("should implement basic service API methods correctly", func() { + serviceURL, _ := url.Parse("telegram://12345:mock-token@telegram/?chats=channel-1") + err := telegram.Initialize(serviceURL, logger) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + config := telegram.GetConfig() + testutils.TestConfigGetInvalidQueryValue(config) + testutils.TestConfigSetInvalidQueryValue( + config, + "telegram://12345:mock-token@telegram/?chats=channel-1&foo=bar", + ) + testutils.TestConfigGetEnumsCount(config, 1) + testutils.TestConfigGetFieldsCount(config, 6) + }) + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("telegram")) + }) +}) + +func expectErrorAndEmptyObject(telegram *Service, rawURL string, logger *log.Logger) { + serviceURL, _ := url.Parse(rawURL) + err := telegram.Initialize(serviceURL, logger) + gomega.Expect(err).To(gomega.HaveOccurred()) + + config := telegram.GetConfig() + gomega.Expect(config.Token).To(gomega.BeEmpty()) + gomega.Expect(config.Chats).To(gomega.BeEmpty()) +} + +func setupResponder(endpoint string, token string, code int, body string) { + targetURL := fmt.Sprintf("https://api.telegram.org/bot%s/%s", token, endpoint) + httpmock.RegisterResponder("POST", targetURL, httpmock.NewStringResponder(code, body)) +} diff --git a/pkg/services/telegram/telegram_token.go b/pkg/services/telegram/telegram_token.go new file mode 100644 index 0000000..fdfa488 --- /dev/null +++ b/pkg/services/telegram/telegram_token.go @@ -0,0 +1,10 @@ +package telegram + +import "regexp" + +// IsTokenValid for use with telegram. +func IsTokenValid(token string) bool { + matched, err := regexp.MatchString("^[0-9]+:[a-zA-Z0-9_-]+$", token) + + return matched && err == nil +} diff --git a/pkg/services/zulip/zulip.go b/pkg/services/zulip/zulip.go new file mode 100644 index 0000000..cb168e1 --- /dev/null +++ b/pkg/services/zulip/zulip.go @@ -0,0 +1,129 @@ +package zulip + +import ( + "errors" + "fmt" + "net/http" + "net/url" + "regexp" + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// contentMaxSize defines the maximum allowed message size in bytes. +const ( + contentMaxSize = 10000 // bytes + topicMaxLength = 60 // characters +) + +// ErrTopicTooLong indicates the topic exceeds the maximum allowed length. +var ( + ErrTopicTooLong = errors.New("topic exceeds max length") + ErrMessageTooLong = errors.New("message exceeds max size") + ErrResponseStatusFailure = errors.New("response status code unexpected") + ErrInvalidHost = errors.New("invalid host format") +) + +// hostValidator ensures the host is a valid hostname or domain. +var hostValidator = regexp.MustCompile( + `^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`, +) + +// Service sends notifications to a pre-configured Zulip channel or user. +type Service struct { + standard.Standard + Config *Config +} + +// Send delivers a notification message to Zulip. +func (service *Service) Send(message string, params *types.Params) error { + // Clone the config to avoid modifying the original for this send operation. + config := service.Config.Clone() + + if params != nil { + if stream, found := (*params)["stream"]; found { + config.Stream = stream + } + + if topic, found := (*params)["topic"]; found { + config.Topic = topic + } + } + + topicLength := len([]rune(config.Topic)) + if topicLength > topicMaxLength { + return fmt.Errorf("%w: %d characters, got %d", ErrTopicTooLong, topicMaxLength, topicLength) + } + + messageSize := len(message) + if messageSize > contentMaxSize { + return fmt.Errorf( + "%w: %d bytes, got %d bytes", + ErrMessageTooLong, + contentMaxSize, + messageSize, + ) + } + + return service.doSend(config, message) +} + +// Initialize configures the service with a URL and logger. +func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error { + service.SetLogger(logger) + service.Config = &Config{} + + if err := service.Config.setURL(nil, configURL); err != nil { + return err + } + + return nil +} + +// GetID returns the identifier for this service. +func (service *Service) GetID() string { + return Scheme +} + +// doSend sends the notification to Zulip using the configured API URL. +// +//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url +func (service *Service) doSend(config *Config, message string) error { + apiURL := service.getAPIURL(config) + + // Validate the host to mitigate SSRF risks + if !hostValidator.MatchString(config.Host) { + return fmt.Errorf("%w: %q", ErrInvalidHost, config.Host) + } + + payload := CreatePayload(config, message) + + res, err := http.Post( + apiURL, + "application/x-www-form-urlencoded", + strings.NewReader(payload.Encode()), + ) + if err == nil && res.StatusCode != http.StatusOK { + err = fmt.Errorf("%w: %s", ErrResponseStatusFailure, res.Status) + } + + defer res.Body.Close() + + if err != nil { + return fmt.Errorf("failed to send zulip message: %w", err) + } + + return nil +} + +// getAPIURL constructs the API URL for Zulip based on the Config. +func (service *Service) getAPIURL(config *Config) string { + return (&url.URL{ + User: url.UserPassword(config.BotMail, config.BotKey), + Host: config.Host, + Path: "api/v1/messages", + Scheme: "https", + }).String() +} diff --git a/pkg/services/zulip/zulip_config.go b/pkg/services/zulip/zulip_config.go new file mode 100644 index 0000000..62db1ac --- /dev/null +++ b/pkg/services/zulip/zulip_config.go @@ -0,0 +1,110 @@ +package zulip + +import ( + "errors" + "net/url" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/services/standard" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// Scheme is the identifying part of this service's configuration URL. +const Scheme = "zulip" + +// Static errors for configuration validation. +var ( + ErrMissingBotMail = errors.New("bot mail missing from config URL") + ErrMissingAPIKey = errors.New("API key missing from config URL") + ErrMissingHost = errors.New("host missing from config URL") +) + +// Config for the zulip service. +type Config struct { + standard.EnumlessConfig + BotMail string `desc:"Bot e-mail address" url:"user"` + BotKey string `desc:"API Key" url:"pass"` + Host string `desc:"API server hostname" url:"host,port"` + Stream string ` description:"Target stream name" key:"stream" optional:""` + Topic string ` key:"topic,title" default:""` +} + +// GetURL returns a URL representation of its current field values. +func (config *Config) GetURL() *url.URL { + resolver := format.NewPropKeyResolver(config) + + return config.getURL(&resolver) +} + +// SetURL updates a ServiceConfig from a URL representation of its field values. +func (config *Config) SetURL(url *url.URL) error { + resolver := format.NewPropKeyResolver(config) + + return config.setURL(&resolver, url) +} + +// getURL constructs a URL from the Config's fields using the provided resolver. +func (config *Config) getURL(_ types.ConfigQueryResolver) *url.URL { + query := &url.Values{} + if config.Stream != "" { + query.Set("stream", config.Stream) + } + + if config.Topic != "" { + query.Set("topic", config.Topic) + } + + return &url.URL{ + User: url.UserPassword(config.BotMail, config.BotKey), + Host: config.Host, + RawQuery: query.Encode(), + Scheme: Scheme, + } +} + +// setURL updates the Config from a URL using the provided resolver. +func (config *Config) setURL(_ types.ConfigQueryResolver, serviceURL *url.URL) error { + var isSet bool + + config.BotMail = serviceURL.User.Username() + config.BotKey, isSet = serviceURL.User.Password() + config.Host = serviceURL.Hostname() + + if serviceURL.String() != "zulip://dummy@dummy.com" { + if config.BotMail == "" { + return ErrMissingBotMail + } + + if !isSet { + return ErrMissingAPIKey + } + + if config.Host == "" { + return ErrMissingHost + } + } + + config.Stream = serviceURL.Query().Get("stream") + config.Topic = serviceURL.Query().Get("topic") + + return nil +} + +// Clone creates a copy of the Config. +func (config *Config) Clone() *Config { + return &Config{ + BotMail: config.BotMail, + BotKey: config.BotKey, + Host: config.Host, + Stream: config.Stream, + Topic: config.Topic, + } +} + +// CreateConfigFromURL creates a new Config from a URL for use within the zulip service. +func CreateConfigFromURL(serviceURL *url.URL) (*Config, error) { + config := Config{} + err := config.setURL(nil, serviceURL) + + return &config, err +} diff --git a/pkg/services/zulip/zulip_errors.go b/pkg/services/zulip/zulip_errors.go new file mode 100644 index 0000000..5dc4804 --- /dev/null +++ b/pkg/services/zulip/zulip_errors.go @@ -0,0 +1,15 @@ +package zulip + +// ErrorMessage for error events within the zulip service. +type ErrorMessage string + +const ( + // MissingAPIKey from the service URL. + MissingAPIKey ErrorMessage = "missing API key" + // MissingHost from the service URL. + MissingHost ErrorMessage = "missing Zulip host" + // MissingBotMail from the service URL. + MissingBotMail ErrorMessage = "missing Bot mail address" + // TopicTooLong if topic is more than 60 characters. + TopicTooLong ErrorMessage = "topic exceeds max length (%d characters): was %d characters" +) diff --git a/pkg/services/zulip/zulip_payload.go b/pkg/services/zulip/zulip_payload.go new file mode 100644 index 0000000..14e51cd --- /dev/null +++ b/pkg/services/zulip/zulip_payload.go @@ -0,0 +1,19 @@ +package zulip + +import ( + "net/url" +) + +// CreatePayload compatible with the zulip api. +func CreatePayload(config *Config, message string) url.Values { + form := url.Values{} + form.Set("type", "stream") + form.Set("to", config.Stream) + form.Set("content", message) + + if config.Topic != "" { + form.Set("topic", config.Topic) + } + + return form +} diff --git a/pkg/services/zulip/zulip_test.go b/pkg/services/zulip/zulip_test.go new file mode 100644 index 0000000..496e689 --- /dev/null +++ b/pkg/services/zulip/zulip_test.go @@ -0,0 +1,402 @@ +package zulip + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/jarcoal/httpmock" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/testutils" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +func TestZulip(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Zulip Suite") +} + +var ( + service *Service + envZulipURL *url.URL +) + +var _ = ginkgo.BeforeSuite(func() { + service = &Service{} + envZulipURL, _ = url.Parse(os.Getenv("SHOUTRRR_ZULIP_URL")) +}) + +// Helper function to create Zulip URLs with optional overrides. +func createZulipURL(botMail, botKey, host, stream, topic string) *url.URL { + query := url.Values{} + if stream != "" { + query.Set("stream", stream) + } + + if topic != "" { + query.Set("topic", topic) + } + + u := &url.URL{ + Scheme: "zulip", + User: url.UserPassword(botMail, botKey), + Host: host, + RawQuery: query.Encode(), + } + + return u +} + +var _ = ginkgo.Describe("the zulip service", func() { + ginkgo.When("running integration tests", func() { + ginkgo.It("should not error out", func() { + if envZulipURL.String() == "" { + return + } + serviceURL, _ := url.Parse(envZulipURL.String()) + err := service.Initialize(serviceURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + err = service.Send("This is an integration test message", nil) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + + ginkgo.When("given a service url with missing parts", func() { + ginkgo.It("should return an error if bot mail is missing", func() { + zulipURL := createZulipURL( + "", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + expectErrorMessageGivenURL("bot mail missing from config URL", zulipURL) + }) + ginkgo.It("should return an error if api key is missing", func() { + zulipURL := &url.URL{ + Scheme: "zulip", + User: url.User("bot-name@zulipchat.com"), + Host: "example.zulipchat.com", + RawQuery: url.Values{ + "stream": []string{"foo"}, + "topic": []string{"bar"}, + }.Encode(), + } + expectErrorMessageGivenURL("API key missing from config URL", zulipURL) + }) + ginkgo.It("should return an error if host is missing", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "", + "foo", + "bar", + ) + expectErrorMessageGivenURL("host missing from config URL", zulipURL) + }) + }) + ginkgo.When("given a valid service url is provided", func() { + ginkgo.It("should not return an error", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should not return an error with a different bot key", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "differentkey123456789", + "example.zulipchat.com", + "foo", + "bar", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + }) + ginkgo.When("sending a message", func() { + ginkgo.It("should error if topic exceeds max length", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + longTopic := strings.Repeat("a", topicMaxLength+1) // 61 chars + params := &types.Params{"topic": longTopic} + err = service.Send("test message", params) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.Equal( + fmt.Sprintf( + "topic exceeds max length: %d characters, got %d", + topicMaxLength, + len([]rune(longTopic)), + ), + )) + }) + ginkgo.It("should error if message exceeds max size", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + longMessage := strings.Repeat("a", contentMaxSize+1) // 10001 bytes + err = service.Send(longMessage, nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.Equal( + fmt.Sprintf( + "message exceeds max size: %d bytes, got %d bytes", + contentMaxSize, + len(longMessage), + ), + )) + }) + ginkgo.It("should override stream from params", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "original", + "", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + params := &types.Params{"stream": "newstream"} + httpmock.Activate() + defer httpmock.DeactivateAndReset() + apiURL := service.getAPIURL(&Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "newstream", + }) + httpmock.RegisterResponder( + "POST", + apiURL, + httpmock.NewStringResponder(http.StatusOK, ""), + ) + err = service.Send("test message", params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should override topic from params", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "original", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + params := &types.Params{"topic": "newtopic"} + httpmock.Activate() + defer httpmock.DeactivateAndReset() + config := &Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "newtopic", + } + apiURL := service.getAPIURL(config) + httpmock.RegisterResponder( + "POST", + apiURL, + func(req *http.Request) (*http.Response, error) { + gomega.Expect(req.FormValue("topic")).To(gomega.Equal("newtopic")) + + return httpmock.NewStringResponse(http.StatusOK, ""), nil + }, + ) + err = service.Send("test message", params) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + }) + ginkgo.It("should handle HTTP errors", func() { + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + httpmock.Activate() + defer httpmock.DeactivateAndReset() + apiURL := service.getAPIURL(service.Config) + httpmock.RegisterResponder( + "POST", + apiURL, + httpmock.NewStringResponder(http.StatusBadRequest, "Bad Request"), + ) + err = service.Send("test message", nil) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring( + "failed to send zulip message: response status code unexpected: 400", + )) + }) + }) + ginkgo.Describe("the zulip config", func() { + ginkgo.When("cloning a config object", func() { + ginkgo.It("the clone should have equal values", func() { + // Covers zulip_config.go:75-84 (Clone equality) + config1 := &Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "bar", + } + config2 := config1.Clone() + gomega.Expect(config1).To(gomega.Equal(config2)) + }) + ginkgo.It("the clone should not be the same struct", func() { + // Covers zulip_config.go:75-84 (Clone identity) + config1 := &Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "bar", + } + config2 := config1.Clone() + gomega.Expect(config1).NotTo(gomega.BeIdenticalTo(config2)) + }) + }) + ginkgo.When("generating a config object", func() { + ginkgo.It("should generate a correct config object using CreateConfigFromURL", func() { + // Covers zulip_config.go:92-98 (CreateConfigFromURL), zulip_config.go:49-72 (setURL) + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + serviceConfig, err := CreateConfigFromURL(zulipURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + config := &Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "bar", + } + gomega.Expect(serviceConfig).To(gomega.Equal(config)) + }) + ginkgo.It("should update config correctly using SetURL", func() { + // Covers zulip_config.go:27-29 (SetURL), zulip_config.go:49-72 (setURL) + config := &Config{} // Start with empty config + zulipURL := createZulipURL( + "bot-name@zulipchat.com", + "correcthorsebatterystable", + "example.zulipchat.com", + "foo", + "bar", + ) + err := config.SetURL(zulipURL) + gomega.Expect(err).NotTo(gomega.HaveOccurred()) + + expected := &Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "bar", + } + gomega.Expect(config).To(gomega.Equal(expected)) + }) + }) + ginkgo.When("given a config object with stream and topic", func() { + ginkgo.It("should build the correct service url", func() { + // Covers zulip_config.go:27-46 (GetURL with Topic) + config := Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + Topic: "bar", + } + url := config.GetURL() + gomega.Expect(url.String()). + To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo&topic=bar")) + }) + }) + ginkgo.When("given a config object with stream but without topic", func() { + ginkgo.It("should build the correct service url", func() { + // Covers zulip_config.go:27-46 (GetURL without Topic) + config := Config{ + BotMail: "bot-name@zulipchat.com", + BotKey: "correcthorsebatterystable", + Host: "example.zulipchat.com", + Stream: "foo", + } + url := config.GetURL() + gomega.Expect(url.String()). + To(gomega.Equal("zulip://bot-name%40zulipchat.com:correcthorsebatterystable@example.zulipchat.com?stream=foo")) + }) + }) + }) + ginkgo.Describe("the zulip payload", func() { + ginkgo.When("creating a payload with topic", func() { + ginkgo.It("should include all fields", func() { + // Covers zulip_payload.go:7-18 (CreatePayload with Topic) + config := &Config{ + Stream: "foo", + Topic: "bar", + } + payload := CreatePayload(config, "test message") + gomega.Expect(payload.Get("type")).To(gomega.Equal("stream")) + gomega.Expect(payload.Get("to")).To(gomega.Equal("foo")) + gomega.Expect(payload.Get("content")).To(gomega.Equal("test message")) + gomega.Expect(payload.Get("topic")).To(gomega.Equal("bar")) + }) + }) + ginkgo.When("creating a payload without topic", func() { + ginkgo.It("should exclude topic field", func() { + // Covers zulip_payload.go:7-18 (CreatePayload without Topic) + config := &Config{ + Stream: "foo", + } + payload := CreatePayload(config, "test message") + gomega.Expect(payload.Get("type")).To(gomega.Equal("stream")) + gomega.Expect(payload.Get("to")).To(gomega.Equal("foo")) + gomega.Expect(payload.Get("content")).To(gomega.Equal("test message")) + gomega.Expect(payload.Get("topic")).To(gomega.Equal("")) + }) + }) + }) + ginkgo.It("should return the correct service ID", func() { + service := &Service{} + gomega.Expect(service.GetID()).To(gomega.Equal("zulip")) + }) +}) + +func expectErrorMessageGivenURL(msg ErrorMessage, zulipURL *url.URL) { + err := service.Initialize(zulipURL, testutils.TestLogger()) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.Equal(string(msg))) +} diff --git a/pkg/types/config_prop.go b/pkg/types/config_prop.go new file mode 100644 index 0000000..a3d077c --- /dev/null +++ b/pkg/types/config_prop.go @@ -0,0 +1,7 @@ +package types + +// ConfigProp interface is used to de-/serialize structs from/to a string representation. +type ConfigProp interface { + SetFromProp(propValue string) error + GetPropValue() (string, error) +} diff --git a/pkg/types/custom_url_config.go b/pkg/types/custom_url_config.go new file mode 100644 index 0000000..c37c056 --- /dev/null +++ b/pkg/types/custom_url_config.go @@ -0,0 +1,9 @@ +package types + +import "net/url" + +// CustomURLService is the interface that needs to be implemented to support custom URLs in services. +type CustomURLService interface { + Service + GetConfigURLFromCustom(customURL *url.URL) (serviceURL *url.URL, err error) +} diff --git a/pkg/types/enum_formatter.go b/pkg/types/enum_formatter.go new file mode 100644 index 0000000..5b5730b --- /dev/null +++ b/pkg/types/enum_formatter.go @@ -0,0 +1,8 @@ +package types + +// EnumFormatter translate enums between strings and numbers. +type EnumFormatter interface { + Print(e int) string + Parse(s string) int + Names() []string +} diff --git a/pkg/types/field.go b/pkg/types/field.go new file mode 100644 index 0000000..db879f9 --- /dev/null +++ b/pkg/types/field.go @@ -0,0 +1,30 @@ +package types + +import "sort" + +// Field is a Key/Value pair used for extra data in log messages. +type Field struct { + Key string + Value string +} + +// FieldsFromMap creates a Fields slice from a map, optionally sorting keys. +func FieldsFromMap(fieldMap map[string]string, sorted bool) []Field { + keys := make([]string, 0, len(fieldMap)) + fields := make([]Field, 0, len(fieldMap)) + + for key := range fieldMap { + keys = append(keys, key) + } + + if sorted { + sort.Strings(keys) + } + + for i, key := range keys { + fields[i].Key = key + fields[i].Value = fieldMap[key] + } + + return fields +} diff --git a/pkg/types/generator.go b/pkg/types/generator.go new file mode 100644 index 0000000..8d4922e --- /dev/null +++ b/pkg/types/generator.go @@ -0,0 +1,6 @@ +package types + +// Generator is the interface for tools that generate service configurations from a user dialog. +type Generator interface { + Generate(service Service, props map[string]string, args []string) (ServiceConfig, error) +} diff --git a/pkg/types/message_item.go b/pkg/types/message_item.go new file mode 100644 index 0000000..a2e2112 --- /dev/null +++ b/pkg/types/message_item.go @@ -0,0 +1,71 @@ +package types + +import ( + "strings" + "time" +) + +const ( + // Unknown is the default message level. + Unknown MessageLevel = iota + // Debug is the lowest kind of known message level. + Debug + // Info is generally used as the "normal" message level. + Info + // Warning is generally used to denote messages that might be OK, but can cause problems. + Warning + // Error is generally used for messages about things that did not go as planned. + Error + messageLevelCount + // MessageLevelCount is used to create arrays that maps levels to other values. + MessageLevelCount = int(messageLevelCount) +) + +var messageLevelStrings = [MessageLevelCount]string{ + "Unknown", + "Debug", + "Info", + "Warning", + "Error", +} + +// MessageLevel is used to denote the urgency of a message item. +type MessageLevel uint8 + +// MessageItem is an entry in a notification being sent by a service. +type MessageItem struct { + Text string + Timestamp time.Time + Level MessageLevel + Fields []Field +} + +func (level MessageLevel) String() string { + if level >= messageLevelCount { + return messageLevelStrings[0] + } + + return messageLevelStrings[level] +} + +// WithField appends the key/value pair to the message items fields. +func (mi *MessageItem) WithField(key, value string) *MessageItem { + mi.Fields = append(mi.Fields, Field{ + Key: key, + Value: value, + }) + + return mi +} + +// ItemsToPlain joins together the MessageItems' Text using newlines. +// Used implement the rich sender API by redirecting to the plain sender implementation. +func ItemsToPlain(items []MessageItem) string { + builder := strings.Builder{} + for _, item := range items { + builder.WriteString(item.Text) + builder.WriteRune('\n') + } + + return builder.String() +} diff --git a/pkg/types/message_limit.go b/pkg/types/message_limit.go new file mode 100644 index 0000000..f44460d --- /dev/null +++ b/pkg/types/message_limit.go @@ -0,0 +1,10 @@ +package types + +// MessageLimit is used for declaring the payload limits for services upstream APIs. +type MessageLimit struct { + ChunkSize int + TotalChunkSize int + + // Maximum number of chunks (including the last chunk for meta data) + ChunkCount int +} diff --git a/pkg/types/params.go b/pkg/types/params.go new file mode 100644 index 0000000..64c9ca7 --- /dev/null +++ b/pkg/types/params.go @@ -0,0 +1,28 @@ +package types + +const ( + // TitleKey is the common key for the title prop. + TitleKey = "title" + // MessageKey is the common key for the message prop. + MessageKey = "message" +) + +// Params is the string map used to provide additional variables to the service templates. +type Params map[string]string + +// SetTitle sets the "title" param to the specified value. +func (p Params) SetTitle(title string) { + p[TitleKey] = title +} + +// Title returns the "title" param. +func (p Params) Title() (string, bool) { + title, found := p[TitleKey] + + return title, found +} + +// SetMessage sets the "message" param to the specified value. +func (p Params) SetMessage(message string) { + p[MessageKey] = message +} diff --git a/pkg/types/queued_sender.go b/pkg/types/queued_sender.go new file mode 100644 index 0000000..d8cee79 --- /dev/null +++ b/pkg/types/queued_sender.go @@ -0,0 +1,9 @@ +package types + +// QueuedSender is the interface for a proxied sender that queues messages before sending. +type QueuedSender interface { + Enqueuef(format string, v ...any) + Enqueue(message string) + Flush(params *map[string]string) + Service() Service +} diff --git a/pkg/types/rich_sender.go b/pkg/types/rich_sender.go new file mode 100644 index 0000000..b47c1ab --- /dev/null +++ b/pkg/types/rich_sender.go @@ -0,0 +1,6 @@ +package types + +// RichSender is the interface needed to implement to send rich notifications. +type RichSender interface { + SendItems(items []MessageItem, params Params) error +} diff --git a/pkg/types/sender.go b/pkg/types/sender.go new file mode 100644 index 0000000..6b0d55a --- /dev/null +++ b/pkg/types/sender.go @@ -0,0 +1,9 @@ +package types + +// Sender is the interface needed to implement to send notifications. +type Sender interface { + Send(message string, params *Params) error + + // Rich sender API: + // SendItems(items []MessageItem, params *Params) error +} diff --git a/pkg/types/service.go b/pkg/types/service.go new file mode 100644 index 0000000..97224a3 --- /dev/null +++ b/pkg/types/service.go @@ -0,0 +1,14 @@ +package types + +import ( + "net/url" +) + +// Service is the public common interface for all notification services. +type Service interface { + Sender + Templater + Initialize(serviceURL *url.URL, logger StdLogger) error + SetLogger(logger StdLogger) + GetID() string +} diff --git a/pkg/types/service_config.go b/pkg/types/service_config.go new file mode 100644 index 0000000..94c0cd0 --- /dev/null +++ b/pkg/types/service_config.go @@ -0,0 +1,22 @@ +package types + +import "net/url" + +// Enummer contains fields that have associated EnumFormatter instances. +type Enummer interface { + Enums() map[string]EnumFormatter +} + +// ServiceConfig is the common interface for all types of service configurations. +type ServiceConfig interface { + Enummer + GetURL() *url.URL + SetURL(url *url.URL) error +} + +// ConfigQueryResolver is the interface used to get/set and list service config query fields. +type ConfigQueryResolver interface { + Get(key string) (value string, err error) + Set(key string, value string) error + QueryFields() []string +} diff --git a/pkg/types/service_opts.go b/pkg/types/service_opts.go new file mode 100644 index 0000000..6b26336 --- /dev/null +++ b/pkg/types/service_opts.go @@ -0,0 +1,10 @@ +package types + +import "log" + +// ServiceOpts is the interface describing the service options. +type ServiceOpts interface { + Verbose() bool + Logger() *log.Logger + Props() map[string]string +} diff --git a/pkg/types/std_logger.go b/pkg/types/std_logger.go new file mode 100644 index 0000000..f8ef32e --- /dev/null +++ b/pkg/types/std_logger.go @@ -0,0 +1,8 @@ +package types + +// StdLogger is an interface for outputting log information from services that are non-fatal. +type StdLogger interface { + Print(args ...any) + Printf(format string, args ...any) + Println(args ...any) +} diff --git a/pkg/types/templater.go b/pkg/types/templater.go new file mode 100644 index 0000000..8d09ffd --- /dev/null +++ b/pkg/types/templater.go @@ -0,0 +1,12 @@ +package types + +import ( + "text/template" +) + +// Templater is the interface for the service template API. +type Templater interface { + GetTemplate(id string) (template *template.Template, found bool) + SetTemplateString(id string, body string) error + SetTemplateFile(id string, file string) error +} diff --git a/pkg/util/docs.go b/pkg/util/docs.go new file mode 100644 index 0000000..74341e0 --- /dev/null +++ b/pkg/util/docs.go @@ -0,0 +1,18 @@ +package util + +import ( + "fmt" + + "github.com/nicholas-fedor/shoutrrr/internal/meta" +) + +// DocsURL returns a full documentation URL for the current version of Shoutrrr with the path appended. +// If the path contains a leading slash, it is stripped. +func DocsURL(path string) string { + // strip leading slash if present + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + + return fmt.Sprintf("https://nicholas-fedor.github.io/shoutrrr/%s/%s", meta.DocsVersion, path) +} diff --git a/pkg/util/generator/generator_common.go b/pkg/util/generator/generator_common.go new file mode 100644 index 0000000..94d7ee4 --- /dev/null +++ b/pkg/util/generator/generator_common.go @@ -0,0 +1,233 @@ +package generator + +import ( + "bufio" + "errors" + "fmt" + "io" + "regexp" + "strconv" + + "github.com/fatih/color" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" +) + +// errInvalidFormat indicates an invalid user input format. +var ( + errInvalidFormat = errors.New("invalid format") + errRequired = errors.New("field is required") + errNotANumber = errors.New("not a number") + errInvalidBoolFormat = errors.New("answer must be yes or no") +) + +// ValidateFormat wraps a boolean validator to return an error on false results. +func ValidateFormat(validator func(string) bool) func(string) error { + return func(answer string) error { + if validator(answer) { + return nil + } + + return errInvalidFormat + } +} + +// Required validates that the input contains at least one character. +func Required(answer string) error { + if answer == "" { + return errRequired + } + + return nil +} + +// UserDialog facilitates question/answer-based user interaction. +type UserDialog struct { + reader io.Reader + writer io.Writer + scanner *bufio.Scanner + props map[string]string +} + +// NewUserDialog initializes a UserDialog with safe defaults. +func NewUserDialog(reader io.Reader, writer io.Writer, props map[string]string) *UserDialog { + if props == nil { + props = map[string]string{} + } + + return &UserDialog{ + reader: reader, + writer: writer, + scanner: bufio.NewScanner(reader), + props: props, + } +} + +// Write sends a message to the user. +func (ud *UserDialog) Write(message string, v ...any) { + if _, err := fmt.Fprintf(ud.writer, message, v...); err != nil { + _, _ = fmt.Fprint(ud.writer, "failed to write to output: ", err, "\n") + } +} + +// Writelnf writes a formatted message to the user, completing a line. +func (ud *UserDialog) Writelnf(format string, v ...any) { + ud.Write(format+"\n", v...) +} + +// Query prompts the user and returns regex groups if the input matches the validator pattern. +func (ud *UserDialog) Query(prompt string, validator *regexp.Regexp, key string) []string { + var groups []string + + ud.QueryString(prompt, ValidateFormat(func(answer string) bool { + groups = validator.FindStringSubmatch(answer) + + return groups != nil + }), key) + + return groups +} + +// QueryAll prompts the user and returns multiple regex matches up to maxMatches. +func (ud *UserDialog) QueryAll( + prompt string, + validator *regexp.Regexp, + key string, + maxMatches int, +) [][]string { + var matches [][]string + + ud.QueryString(prompt, ValidateFormat(func(answer string) bool { + matches = validator.FindAllStringSubmatch(answer, maxMatches) + + return matches != nil + }), key) + + return matches +} + +// QueryString prompts the user and returns the answer if it passes the validator. +func (ud *UserDialog) QueryString(prompt string, validator func(string) error, key string) string { + if validator == nil { + validator = func(string) error { return nil } + } + + answer, foundProp := ud.props[key] + if foundProp { + err := validator(answer) + colAnswer := format.ColorizeValue(answer, false) + colKey := format.ColorizeProp(key) + + if err == nil { + ud.Writelnf("Using prop value %v for %v", colAnswer, colKey) + + return answer + } + + ud.Writelnf("Supplied prop value %v is not valid for %v: %v", colAnswer, colKey, err) + } + + for { + ud.Write("%v ", prompt) + color.Set(color.FgHiWhite) + + if !ud.scanner.Scan() { + if err := ud.scanner.Err(); err != nil { + ud.Writelnf(err.Error()) + + continue + } + // Input closed, return an empty string + return "" + } + + answer = ud.scanner.Text() + + color.Unset() + + if err := validator(answer); err != nil { + ud.Writelnf("%v", err) + ud.Writelnf("") + + continue + } + + return answer + } +} + +// QueryStringPattern prompts the user and returns the answer if it matches the regex pattern. +func (ud *UserDialog) QueryStringPattern( + prompt string, + validator *regexp.Regexp, + key string, +) string { + if validator == nil { + panic("validator cannot be nil") + } + + return ud.QueryString(prompt, func(s string) error { + if validator.MatchString(s) { + return nil + } + + return errInvalidFormat + }, key) +} + +// QueryInt prompts the user and returns the answer as an integer if parseable. +func (ud *UserDialog) QueryInt(prompt string, key string, bitSize int) int64 { + validator := regexp.MustCompile(`^((0x|#)([0-9a-fA-F]+))|(-?[0-9]+)$`) + + var value int64 + + ud.QueryString(prompt, func(answer string) error { + groups := validator.FindStringSubmatch(answer) + if len(groups) < 1 { + return errNotANumber + } + + number := groups[0] + + base := 0 + if groups[2] == "#" { + // Explicitly treat #ffa080 as hexadecimal + base = 16 + number = groups[3] + } + + var err error + + value, err = strconv.ParseInt(number, base, bitSize) + if err != nil { + return fmt.Errorf("parsing integer from %q: %w", answer, err) + } + + return nil + }, key) + + return value +} + +// QueryBool prompts the user and returns the answer as a boolean if parseable. +func (ud *UserDialog) QueryBool(prompt string, key string) bool { + var value bool + + ud.QueryString(prompt, func(answer string) error { + parsed, ok := format.ParseBool(answer, false) + if ok { + value = parsed + + return nil + } + + return fmt.Errorf( + "%w: use %v or %v", + errInvalidBoolFormat, + format.ColorizeTrue("yes"), + format.ColorizeFalse("no"), + ) + }, key) + + return value +} diff --git a/pkg/util/generator/generator_test.go b/pkg/util/generator/generator_test.go new file mode 100644 index 0000000..93a14f3 --- /dev/null +++ b/pkg/util/generator/generator_test.go @@ -0,0 +1,184 @@ +package generator_test + +import ( + "fmt" + "regexp" + "strings" + "testing" + + "github.com/mattn/go-colorable" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/gbytes" + + "github.com/nicholas-fedor/shoutrrr/pkg/util/generator" +) + +func TestGenerator(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Generator Suite") +} + +var ( + client *generator.UserDialog + userOut *gbytes.Buffer + userIn *gbytes.Buffer +) + +func mockTyped(a ...any) { + _, _ = fmt.Fprint(userOut, a...) + _, _ = fmt.Fprint(userOut, "\n") +} + +func dumpBuffers() { + for _, line := range strings.Split(string(userIn.Contents()), "\n") { + _, _ = fmt.Fprint(ginkgo.GinkgoWriter, "> ", line, "\n") + } + + for _, line := range strings.Split(string(userOut.Contents()), "\n") { + _, _ = fmt.Fprint(ginkgo.GinkgoWriter, "< ", line, "\n") + } +} + +var _ = ginkgo.Describe("GeneratorCommon", func() { + ginkgo.BeforeEach(func() { + userOut = gbytes.NewBuffer() + userIn = gbytes.NewBuffer() + userInMono := colorable.NewNonColorable(userIn) + client = generator.NewUserDialog( + userOut, + userInMono, + map[string]string{"propKey": "propVal"}, + ) + }) + + ginkgo.It("reprompt upon invalid answers", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "") + }() + + mockTyped("") + mockTyped("Normal Human Name") + + gomega.Eventually(userIn).Should(gbytes.Say(`name: `)) + gomega.Eventually(userIn).Should(gbytes.Say(`field is required`)) + gomega.Eventually(userIn).Should(gbytes.Say(`name: `)) + gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("Normal Human Name"))) + }) + + ginkgo.It("should accept any input when validator is nil", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", nil, "") + }() + mockTyped("") + gomega.Eventually(answer).Should(gomega.Receive(gomega.BeEmpty())) + }) + + ginkgo.It("should use predefined prop value if key is present", func() { + defer dumpBuffers() + answer := make(chan string) + go func() { + answer <- client.QueryString("name:", generator.Required, "propKey") + }() + gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("propVal"))) + }) + + ginkgo.Describe("Query", func() { + ginkgo.It("should prompt until a valid answer is provided", func() { + defer dumpBuffers() + answer := make(chan []string) + query := "pick foo or bar:" + go func() { + answer <- client.Query(query, regexp.MustCompile("(foo|bar)"), "") + }() + + mockTyped("") + mockTyped("foo") + + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`)) + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(answer).Should(gomega.Receive(gomega.ContainElement("foo"))) + }) + }) + + ginkgo.Describe("QueryAll", func() { + ginkgo.It("should prompt until a valid answer is provided", func() { + defer dumpBuffers() + answer := make(chan [][]string) + query := "pick foo or bar:" + go func() { + answer <- client.QueryAll(query, regexp.MustCompile(`foo(ba[rz])`), "", -1) + }() + + mockTyped("foobar foobaz") + + gomega.Eventually(userIn).Should(gbytes.Say(query)) + var matches [][]string + gomega.Eventually(answer).Should(gomega.Receive(&matches)) + gomega.Expect(matches).To(gomega.ContainElement([]string{"foobar", "bar"})) + gomega.Expect(matches).To(gomega.ContainElement([]string{"foobaz", "baz"})) + }) + }) + + ginkgo.Describe("QueryStringPattern", func() { + ginkgo.It("should prompt until a valid answer is provided", func() { + defer dumpBuffers() + answer := make(chan string) + query := "type of bar:" + go func() { + answer <- client.QueryStringPattern(query, regexp.MustCompile(".*bar"), "") + }() + + mockTyped("foo") + mockTyped("foobar") + + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(userIn).Should(gbytes.Say(`invalid format`)) + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal("foobar"))) + }) + }) + + ginkgo.Describe("QueryInt", func() { + ginkgo.It("should prompt until a valid answer is provided", func() { + defer dumpBuffers() + answer := make(chan int64) + query := "number:" + go func() { + answer <- client.QueryInt(query, "", 64) + }() + + mockTyped("x") + mockTyped("0x20") + + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(userIn).Should(gbytes.Say(`not a number`)) + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(answer).Should(gomega.Receive(gomega.Equal(int64(32)))) + }) + }) + + ginkgo.Describe("QueryBool", func() { + ginkgo.It("should prompt until a valid answer is provided", func() { + defer dumpBuffers() + answer := make(chan bool) + query := "cool?" + go func() { + answer <- client.QueryBool(query, "") + }() + + mockTyped("maybe") + mockTyped("y") + + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(userIn).Should(gbytes.Say(`answer must be yes or no`)) + gomega.Eventually(userIn).Should(gbytes.Say(query)) + gomega.Eventually(answer).Should(gomega.Receive(gomega.BeTrue())) + }) + }) +}) diff --git a/pkg/util/jsonclient/error.go b/pkg/util/jsonclient/error.go new file mode 100644 index 0000000..f1aaa60 --- /dev/null +++ b/pkg/util/jsonclient/error.go @@ -0,0 +1,37 @@ +package jsonclient + +import ( + "errors" + "fmt" +) + +// Error contains additional HTTP/JSON details. +type Error struct { + StatusCode int + Body string + err error +} + +// Error returns the string representation of the error. +func (je Error) Error() string { + return je.String() +} + +// String provides a human-readable description of the error. +func (je Error) String() string { + if je.err == nil { + return fmt.Sprintf("unknown error (HTTP %v)", je.StatusCode) + } + + return je.err.Error() +} + +// ErrorBody extracts the request body from an error if it’s a jsonclient.Error. +func ErrorBody(e error) string { + var jsonError Error + if errors.As(e, &jsonError) { + return jsonError.Body + } + + return "" +} diff --git a/pkg/util/jsonclient/interface.go b/pkg/util/jsonclient/interface.go new file mode 100644 index 0000000..9b294d1 --- /dev/null +++ b/pkg/util/jsonclient/interface.go @@ -0,0 +1,10 @@ +package jsonclient + +import "net/http" + +type Client interface { + Get(url string, response any) error + Post(url string, request any, response any) error + Headers() http.Header + ErrorResponse(err error, response any) bool +} diff --git a/pkg/util/jsonclient/jsonclient.go b/pkg/util/jsonclient/jsonclient.go new file mode 100644 index 0000000..5a5b3dd --- /dev/null +++ b/pkg/util/jsonclient/jsonclient.go @@ -0,0 +1,165 @@ +package jsonclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" +) + +// ContentType defines the default MIME type for JSON requests. +const ContentType = "application/json" + +// HTTPClientErrorThreshold specifies the status code threshold for client errors (400+). +const HTTPClientErrorThreshold = 400 + +// ErrUnexpectedStatus indicates an unexpected HTTP response status. +var ( + ErrUnexpectedStatus = errors.New("got unexpected HTTP status") +) + +// DefaultClient provides a singleton JSON client using http.DefaultClient. +var DefaultClient = NewClient() + +// Client wraps http.Client for JSON operations. +type client struct { + httpClient *http.Client + headers http.Header + indent string +} + +// Get fetches a URL using GET and unmarshals the response into the provided object using DefaultClient. +func Get(url string, response any) error { + if err := DefaultClient.Get(url, response); err != nil { + return fmt.Errorf("getting JSON from %q: %w", url, err) + } + + return nil +} + +// Post sends a request as JSON and unmarshals the response into the provided object using DefaultClient. +func Post(url string, request any, response any) error { + if err := DefaultClient.Post(url, request, response); err != nil { + return fmt.Errorf("posting JSON to %q: %w", url, err) + } + + return nil +} + +// NewClient creates a new JSON client using the default http.Client. +func NewClient() Client { + return NewWithHTTPClient(http.DefaultClient) +} + +// NewWithHTTPClient creates a new JSON client using the specified http.Client. +func NewWithHTTPClient(httpClient *http.Client) Client { + return &client{ + httpClient: httpClient, + headers: http.Header{ + "Content-Type": []string{ContentType}, + }, + } +} + +// Headers returns the default headers for requests. +func (c *client) Headers() http.Header { + return c.headers +} + +// Get fetches a URL using GET and unmarshals the response into the provided object. +func (c *client) Get(url string, response any) error { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("creating GET request for %q: %w", url, err) + } + + for key, val := range c.headers { + req.Header.Set(key, val[0]) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("executing GET request to %q: %w", url, err) + } + + return parseResponse(res, response) +} + +// Post sends a request as JSON and unmarshals the response into the provided object. +func (c *client) Post(url string, request any, response any) error { + var err error + + var body []byte + + if strReq, ok := request.(string); ok { + // If the request is a string, pass it through without serializing + body = []byte(strReq) + } else { + body, err = json.MarshalIndent(request, "", c.indent) + if err != nil { + return fmt.Errorf("marshaling request to JSON: %w", err) + } + } + + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + url, + bytes.NewReader(body), + ) + if err != nil { + return fmt.Errorf("creating POST request for %q: %w", url, err) + } + + for key, val := range c.headers { + req.Header.Set(key, val[0]) + } + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("sending POST request to %q: %w", url, err) + } + + return parseResponse(res, response) +} + +// ErrorResponse checks if an error is a JSON error and unmarshals its body into the response. +func (c *client) ErrorResponse(err error, response any) bool { + var errMsg Error + if errors.As(err, &errMsg) { + return json.Unmarshal([]byte(errMsg.Body), response) == nil + } + + return false +} + +// parseResponse parses the HTTP response and unmarshals it into the provided object. +func parseResponse(res *http.Response, response any) error { + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + + if res.StatusCode >= HTTPClientErrorThreshold { + err = fmt.Errorf("%w: %v", ErrUnexpectedStatus, res.Status) + } + + if err == nil { + err = json.Unmarshal(body, response) + } + + if err != nil { + if body == nil { + body = []byte{} + } + + return Error{ + StatusCode: res.StatusCode, + Body: string(body), + err: err, + } + } + + return nil +} diff --git a/pkg/util/jsonclient/jsonclient_test.go b/pkg/util/jsonclient/jsonclient_test.go new file mode 100644 index 0000000..82976c3 --- /dev/null +++ b/pkg/util/jsonclient/jsonclient_test.go @@ -0,0 +1,334 @@ +package jsonclient_test + +import ( + "errors" + "net" + "net/http" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + "github.com/onsi/gomega/ghttp" + + "github.com/nicholas-fedor/shoutrrr/pkg/util/jsonclient" +) + +func TestJSONClient(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "JSONClient Suite") +} + +var _ = ginkgo.Describe("JSONClient", func() { + var server *ghttp.Server + var client jsonclient.Client + + ginkgo.BeforeEach(func() { + server = ghttp.NewServer() + client = jsonclient.NewClient() + }) + + ginkgo.When("the server returns an invalid JSON response", func() { + ginkgo.It("should return an error", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "invalid json")) + res := &mockResponse{} + err := client.Get(server.URL(), res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err). + To(gomega.MatchError("invalid character 'i' looking for beginning of value")) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + }) + + ginkgo.When("the server returns an empty response", func() { + ginkgo.It("should return an error", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, nil)) + res := &mockResponse{} + err := client.Get(server.URL(), res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).To(gomega.MatchError("unexpected end of JSON input")) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + }) + + ginkgo.It("should deserialize GET response", func() { + server.AppendHandlers( + ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "OK"}), + ) + res := &mockResponse{} + err := client.Get(server.URL(), res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("OK")) + }) + + ginkgo.Describe("Top-level Functions", func() { + ginkgo.It("should handle GET via DefaultClient", func() { + server.AppendHandlers( + ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default OK"}), + ) + res := &mockResponse{} + err := jsonclient.Get(server.URL(), res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("Default OK")) + }) + + ginkgo.It("should handle POST via DefaultClient", func() { + req := &mockRequest{Number: 10} + res := &mockResponse{} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.VerifyJSONRepresenting(&req), + ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "Default POST"})), + ) + err := jsonclient.Post(server.URL(), req, res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("Default POST")) + }) + }) + + ginkgo.Describe("POST", func() { + ginkgo.It("should de-/serialize request and response", func() { + req := &mockRequest{Number: 5} + res := &mockResponse{} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.VerifyJSONRepresenting(&req), + ghttp.RespondWithJSONEncoded( + http.StatusOK, + &mockResponse{Status: "That's Numberwang!"}, + ), + )) + err := client.Post(server.URL(), req, res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("That's Numberwang!")) + }) + + ginkgo.It("should return error on error status responses", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusNotFound, "Not found!")) + err := client.Post(server.URL(), &mockRequest{}, &mockResponse{}) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).To(gomega.MatchError("got unexpected HTTP status: 404 Not Found")) + }) + + ginkgo.It("should return error on invalid request", func() { + server.AppendHandlers(ghttp.VerifyRequest("POST", "/")) + err := client.Post(server.URL(), func() {}, &mockResponse{}) + gomega.Expect(server.ReceivedRequests()).Should(gomega.BeEmpty()) + gomega.Expect(err). + To(gomega.MatchError("marshaling request to JSON: json: unsupported type: func()")) + }) + + ginkgo.It("should return error on invalid response type", func() { + res := &mockResponse{Status: "cool skirt"} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.RespondWithJSONEncoded(http.StatusOK, res)), + ) + err := client.Post(server.URL(), nil, &[]bool{}) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err). + To(gomega.MatchError("json: cannot unmarshal object into Go value of type []bool")) + gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.MatchJSON(`{"Status":"cool skirt"}`)) + }) + + ginkgo.It("should handle string request without marshaling", func() { + rawJSON := `{"Number": 42}` + res := &mockResponse{} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.VerifyBody([]byte(rawJSON)), + ghttp.RespondWithJSONEncoded(http.StatusOK, mockResponse{Status: "String Worked"})), + ) + err := client.Post(server.URL(), rawJSON, res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("String Worked")) + }) + + ginkgo.It("should return error when NewRequest fails", func() { + err := client.Post("://invalid-url", &mockRequest{}, &mockResponse{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("creating POST request for \"://invalid-url\": parse \"://invalid-url\": missing protocol scheme")) + }) + + ginkgo.It("should return error when http.Client.Do fails", func() { + brokenClient := jsonclient.NewWithHTTPClient(&http.Client{ + Transport: &http.Transport{ + Dial: func(_, _ string) (net.Conn, error) { + return nil, errors.New("forced network error") + }, + }, + }) + err := brokenClient.Post(server.URL(), &mockRequest{}, &mockResponse{}) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("sending POST request to \"" + server.URL() + "\": Post \"" + server.URL() + "\": forced network error")) + }) + + ginkgo.It("should set multiple custom headers in request", func() { + customClient := jsonclient.NewWithHTTPClient(&http.Client{}) + headers := customClient.Headers() + headers.Set("X-Custom-Header", "CustomValue") + headers.Set("X-Another-Header", "AnotherValue") + + req := &mockRequest{Number: 99} + res := &mockResponse{} + server.AppendHandlers(ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/"), + ghttp.VerifyHeader(http.Header{ + "Content-Type": []string{jsonclient.ContentType}, + "X-Custom-Header": []string{"CustomValue"}, + "X-Another-Header": []string{"AnotherValue"}, + }), + ghttp.VerifyJSONRepresenting(&req), + ghttp.RespondWithJSONEncoded( + http.StatusOK, + mockResponse{Status: "Headers Worked"}, + ), + )) + err := customClient.Post(server.URL(), req, res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(res.Status).To(gomega.Equal("Headers Worked")) + }) + }) + + ginkgo.Describe("Headers", func() { + ginkgo.It("should return default headers with Content-Type", func() { + headers := client.Headers() + gomega.Expect(headers.Get("Content-Type")).To(gomega.Equal(jsonclient.ContentType)) + }) + }) + + ginkgo.Describe("ErrorResponse", func() { + ginkgo.It("should return false for non-jsonclient.Error", func() { + res := &mockResponse{} + result := client.ErrorResponse(errors.New("generic error"), res) + gomega.Expect(result).To(gomega.BeFalse()) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + + ginkgo.It("should populate response from jsonclient.Error body", func() { + res := &mockResponse{} + jsonErr := jsonclient.Error{ + StatusCode: http.StatusBadRequest, + Body: `{"Status": "Bad Request"}`, + } + result := client.ErrorResponse(jsonErr, res) + gomega.Expect(result).To(gomega.BeTrue()) + gomega.Expect(res.Status).To(gomega.Equal("Bad Request")) + }) + + ginkgo.It("should return false for invalid JSON in error body", func() { + res := &mockResponse{} + jsonErr := jsonclient.Error{ + StatusCode: http.StatusBadRequest, + Body: "not json", + } + result := client.ErrorResponse(jsonErr, res) + gomega.Expect(result).To(gomega.BeFalse()) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + }) + + ginkgo.Describe("Edge Cases", func() { + ginkgo.It("should handle network failure in Get", func() { + res := &mockResponse{} + err := client.Get("http://127.0.0.1:54321", res) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()).To(gomega.ContainSubstring("dial tcp")) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + + ginkgo.It("should handle invalid JSON with success status", func() { + server.AppendHandlers(ghttp.RespondWith(http.StatusOK, "bad json")) + res := &mockResponse{} + err := client.Get(server.URL(), res) + gomega.Expect(server.ReceivedRequests()).Should(gomega.HaveLen(1)) + gomega.Expect(err). + To(gomega.MatchError("invalid character 'b' looking for beginning of value")) + gomega.Expect(res.Status).To(gomega.BeEmpty()) + }) + + ginkgo.It("should handle nil body in error response", func() { + brokenClient := jsonclient.NewWithHTTPClient(&http.Client{ + Transport: &mockTransport{ + response: &http.Response{ + StatusCode: http.StatusBadRequest, + Status: "400 Bad Request", + Body: &failingReader{}, + Header: make(http.Header), + }, + }, + }) + res := &mockResponse{} + err := brokenClient.Get(server.URL(), res) + gomega.Expect(err).To(gomega.HaveOccurred()) + gomega.Expect(err.Error()). + To(gomega.ContainSubstring("got unexpected HTTP status: 400 Bad Request")) + gomega.Expect(jsonclient.ErrorBody(err)).To(gomega.Equal("")) + }) + }) + + ginkgo.AfterEach(func() { + server.Close() + }) +}) + +var _ = ginkgo.Describe("Error", func() { + ginkgo.When("no internal error has been set", func() { + ginkgo.It("should return a generic message with status code", func() { + errorWithNoError := jsonclient.Error{StatusCode: http.StatusEarlyHints} + gomega.Expect(errorWithNoError.String()).To(gomega.Equal("unknown error (HTTP 103)")) + }) + }) + + ginkgo.Describe("ErrorBody", func() { + ginkgo.When("passed a non-json error", func() { + ginkgo.It("should return an empty string", func() { + gomega.Expect(jsonclient.ErrorBody(errors.New("unrelated error"))). + To(gomega.BeEmpty()) + }) + }) + + ginkgo.When("passed a jsonclient.Error", func() { + ginkgo.It("should return the request body from that error", func() { + errorBody := `{"error": "bad user"}` + jsonErr := jsonclient.Error{Body: errorBody} + gomega.Expect(jsonclient.ErrorBody(jsonErr)).To(gomega.MatchJSON(errorBody)) + }) + }) + }) +}) + +type mockResponse struct { + Status string +} + +type mockRequest struct { + Number int +} + +// mockTransport returns a predefined response. +type mockTransport struct { + response *http.Response +} + +func (mt *mockTransport) RoundTrip(*http.Request) (*http.Response, error) { + return mt.response, nil +} + +// failingReader simulates an io.Reader that fails on Read. +type failingReader struct{} + +func (fr *failingReader) Read([]byte) (int, error) { + return 0, errors.New("simulated read failure") +} + +func (fr *failingReader) Close() error { + return nil +} diff --git a/pkg/util/partition_message.go b/pkg/util/partition_message.go new file mode 100644 index 0000000..556f77e --- /dev/null +++ b/pkg/util/partition_message.go @@ -0,0 +1,118 @@ +package util + +import ( + "strings" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// ellipsis is the suffix appended to truncated strings. +const ellipsis = " [...]" + +// PartitionMessage splits a string into chunks of at most chunkSize runes. +// It searches the last distance runes for a whitespace to improve readability, +// adding chunks until reaching maxCount or maxTotal runes, returning the chunks +// and the number of omitted runes. +func PartitionMessage( + input string, + limits types.MessageLimit, + distance int, +) ([]types.MessageItem, int) { + items := make([]types.MessageItem, 0, limits.ChunkCount-1) + runes := []rune(input) + chunkOffset := 0 + maxTotal := Min(len(runes), limits.TotalChunkSize) + maxCount := limits.ChunkCount - 1 + + if len(input) == 0 { + // If the message is empty, return an empty array + return items, 0 + } + + for range maxCount { + // If no suitable split point is found, use the chunkSize + chunkEnd := chunkOffset + limits.ChunkSize + // ... and start next chunk directly after this one + nextChunkStart := chunkEnd + + if chunkEnd >= maxTotal { + // The chunk is smaller than the limit, no need to search + chunkEnd = maxTotal + nextChunkStart = maxTotal + } else { + for r := range distance { + rp := chunkEnd - r + if runes[rp] == '\n' || runes[rp] == ' ' { + // Suitable split point found + chunkEnd = rp + // Since the split is on a whitespace, skip it in the next chunk + nextChunkStart = chunkEnd + 1 + + break + } + } + } + + items = append(items, types.MessageItem{ + Text: string(runes[chunkOffset:chunkEnd]), + }) + + chunkOffset = nextChunkStart + if chunkOffset >= maxTotal { + break + } + } + + return items, len(runes) - chunkOffset +} + +// Ellipsis truncates a string to maxLength characters, appending an ellipsis if needed. +func Ellipsis(text string, maxLength int) string { + if len(text) > maxLength { + text = text[:maxLength-len(ellipsis)] + ellipsis + } + + return text +} + +// MessageItemsFromLines creates MessageItem batches compatible with the given limits. +func MessageItemsFromLines(plain string, limits types.MessageLimit) [][]types.MessageItem { + maxCount := limits.ChunkCount + lines := strings.Split(plain, "\n") + batches := make([][]types.MessageItem, 0) + items := make([]types.MessageItem, 0, Min(maxCount, len(lines))) + + totalLength := 0 + + for _, line := range lines { + maxLen := limits.ChunkSize + + if len(items) == maxCount || totalLength+maxLen > limits.TotalChunkSize { + batches = append(batches, items) + items = items[:0] + } + + runes := []rune(line) + if len(runes) > maxLen { + // Trim and add ellipsis + runes = runes[:maxLen-len(ellipsis)] + line = string(runes) + ellipsis + } + + if len(runes) < 1 { + continue + } + + items = append(items, types.MessageItem{ + Text: line, + }) + + totalLength += len(runes) + } + + if len(items) > 0 { + batches = append(batches, items) + } + + return batches +} diff --git a/pkg/util/partition_message_test.go b/pkg/util/partition_message_test.go new file mode 100644 index 0000000..0f58cd3 --- /dev/null +++ b/pkg/util/partition_message_test.go @@ -0,0 +1,193 @@ +package util + +import ( + "fmt" + "strconv" + "strings" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +var _ = ginkgo.Describe("Partition Message", func() { + limits := types.MessageLimit{ + ChunkSize: 2000, + TotalChunkSize: 6000, + ChunkCount: 10, + } + ginkgo.When("given a message that exceeds the max length", func() { + ginkgo.When("not splitting by lines", func() { + ginkgo.It("should return a payload with chunked messages", func() { + items, _ := testPartitionMessage(42) + gomega.Expect(items[0].Text).To(gomega.HaveLen(1994)) + gomega.Expect(items[1].Text).To(gomega.HaveLen(1999)) + gomega.Expect(items[2].Text).To(gomega.HaveLen(205)) + }) + ginkgo.It("omit characters above total max", func() { + items, _ := testPartitionMessage(62) + gomega.Expect(items[0].Text).To(gomega.HaveLen(1994)) + gomega.Expect(items[1].Text).To(gomega.HaveLen(1999)) + gomega.Expect(items[2].Text).To(gomega.HaveLen(1999)) + gomega.Expect(items[3].Text).To(gomega.HaveLen(5)) + }) + ginkgo.It("should handle messages with a size modulus of chunksize", func() { + items, _ := testPartitionMessage(20) + // Last word fits in the chunk size + gomega.Expect(items[0].Text).To(gomega.HaveLen(2000)) + + items, _ = testPartitionMessage(40) + // Now the last word of the first chunk will be concatenated with + // the first word of the second chunk, and so it does not fit in the chunk anymore + gomega.Expect(items[0].Text).To(gomega.HaveLen(1994)) + gomega.Expect(items[1].Text).To(gomega.HaveLen(1999)) + gomega.Expect(items[2].Text).To(gomega.HaveLen(5)) + }) + ginkgo.When("the message is empty", func() { + ginkgo.It("should return no items", func() { + items, _ := testPartitionMessage(0) + gomega.Expect(items).To(gomega.BeEmpty()) + }) + }) + ginkgo.When("given an input without whitespace", func() { + ginkgo.It("should not crash, regardless of length", func() { + unalignedLimits := types.MessageLimit{ + ChunkSize: 1997, + ChunkCount: 11, + TotalChunkSize: 5631, + } + + testString := "" + for inputLen := 1; inputLen < 8000; inputLen++ { + // add a rune to the string using a repeatable pattern (single digit hex of position) + testString += strconv.FormatInt(int64(inputLen%16), 16) + items, omitted := PartitionMessage(testString, unalignedLimits, 7) + included := 0 + for ii, item := range items { + expectedSize := unalignedLimits.ChunkSize + + // The last chunk might be smaller than the preceding chunks + if ii == len(items)-1 { + // the chunk size is the remainder of, the total size, + // or the max size, whatever is smallest, + // and the previous chunk sizes + chunkSize := Min( + inputLen, + unalignedLimits.TotalChunkSize, + ) % unalignedLimits.ChunkSize + // if the "rest" of the runes needs another chunk + if chunkSize > 0 { + // expect the chunk to contain the "rest" of the runes + expectedSize = chunkSize + } + // the last chunk should never be empty, so treat it as one of the full ones + } + + // verify the data, but only on the last chunk to reduce test time + if ii == len(items)-1 { + for ri, r := range item.Text { + runeOffset := (len(item.Text) - ri) - 1 + runeVal, err := strconv.ParseInt(string(r), 16, 64) + expectedLen := Min(inputLen, unalignedLimits.TotalChunkSize) + expectedVal := (expectedLen - runeOffset) % 16 + + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(runeVal).To(gomega.Equal(int64(expectedVal))) + } + } + + included += len(item.Text) + gomega.Expect(item.Text).To(gomega.HaveLen(expectedSize)) + } + gomega.Expect(omitted + included).To(gomega.Equal(inputLen)) + } + }) + }) + }) + ginkgo.When("splitting by lines", func() { + ginkgo.It("should return a payload with chunked messages", func() { + batches := testMessageItemsFromLines(18, limits, 2) + items := batches[0] + + gomega.Expect(items[0].Text).To(gomega.HaveLen(200)) + gomega.Expect(items[8].Text).To(gomega.HaveLen(200)) + }) + ginkgo.When("the message items exceed the limits", func() { + ginkgo.It("should split items into multiple batches", func() { + batches := testMessageItemsFromLines(21, limits, 2) + + for b, chunks := range batches { + fmt.Fprintf(ginkgo.GinkgoWriter, "Batch #%v: (%v chunks)\n", b, len(chunks)) + for c, chunk := range chunks { + fmt.Fprintf( + ginkgo.GinkgoWriter, + " - Chunk #%v: (%v runes)\n", + c, + len(chunk.Text), + ) + } + } + + gomega.Expect(batches).To(gomega.HaveLen(2)) + }) + }) + ginkgo.It("should trim characters above chunk size", func() { + hundreds := 42 + repeat := 21 + batches := testMessageItemsFromLines(hundreds, limits, repeat) + items := batches[0] + + gomega.Expect(items[0].Text).To(gomega.HaveLen(limits.ChunkSize)) + gomega.Expect(items[1].Text).To(gomega.HaveLen(limits.ChunkSize)) + }) + }) + }) +}) + +const hundredChars = "this string is exactly (to the letter) a hundred characters long which will make the send func error" + +// testMessageItemsFromLines generates message item batches from repeated text with line breaks. +func testMessageItemsFromLines( + hundreds int, + limits types.MessageLimit, + repeat int, +) [][]types.MessageItem { + builder := strings.Builder{} + ri := 0 + + for range hundreds { + builder.WriteString(hundredChars) + + ri++ + if ri == repeat { + builder.WriteRune('\n') + + ri = 0 + } + } + + return MessageItemsFromLines(builder.String(), limits) +} + +// testPartitionMessage partitions repeated text into message items. +func testPartitionMessage(hundreds int) ([]types.MessageItem, int) { + limits := types.MessageLimit{ + ChunkSize: 2000, + TotalChunkSize: 6000, + ChunkCount: 10, + } + builder := strings.Builder{} + + for range hundreds { + builder.WriteString(hundredChars) + } + + items, omitted := PartitionMessage(builder.String(), limits, 100) + contentSize := Min(hundreds*100, limits.TotalChunkSize) + expectedOmitted := Max(0, (hundreds*100)-contentSize) + + gomega.ExpectWithOffset(0, omitted).To(gomega.Equal(expectedOmitted)) + + return items, omitted +} diff --git a/pkg/util/util.go b/pkg/util/util.go new file mode 100644 index 0000000..f161d25 --- /dev/null +++ b/pkg/util/util.go @@ -0,0 +1,27 @@ +package util + +import ( + "io" + "log" +) + +// Min returns the smallest of a and b. +func Min(a int, b int) int { + if a < b { + return a + } + + return b +} + +// Max returns the largest of a and b. +func Max(a int, b int) int { + if a > b { + return a + } + + return b +} + +// DiscardLogger is a logger that discards any output written to it. +var DiscardLogger = log.New(io.Discard, "", 0) diff --git a/pkg/util/util_kinds.go b/pkg/util/util_kinds.go new file mode 100644 index 0000000..e18a8c9 --- /dev/null +++ b/pkg/util/util_kinds.go @@ -0,0 +1,25 @@ +package util + +import ( + "reflect" +) + +// IsUnsignedInt is a check against the unsigned integer types. +func IsUnsignedInt(kind reflect.Kind) bool { + return kind >= reflect.Uint && kind <= reflect.Uint64 +} + +// IsSignedInt is a check against the signed decimal types. +func IsSignedInt(kind reflect.Kind) bool { + return kind >= reflect.Int && kind <= reflect.Int64 +} + +// IsCollection is a check against slice and array. +func IsCollection(kind reflect.Kind) bool { + return kind == reflect.Slice || kind == reflect.Array +} + +// IsNumeric returns whether the Kind is one of the numeric ones. +func IsNumeric(kind reflect.Kind) bool { + return kind >= reflect.Int && kind <= reflect.Complex128 +} diff --git a/pkg/util/util_numbers.go b/pkg/util/util_numbers.go new file mode 100644 index 0000000..27f51bf --- /dev/null +++ b/pkg/util/util_numbers.go @@ -0,0 +1,15 @@ +package util + +import "strings" + +const hex int = 16 + +// StripNumberPrefix returns a number string with any base prefix stripped and it's corresponding base. +// If no prefix was found, returns 0 to let strconv try to identify the base. +func StripNumberPrefix(input string) (string, int) { + if strings.HasPrefix(input, "#") { + return input[1:], hex + } + + return input, 0 +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go new file mode 100644 index 0000000..8ba10ba --- /dev/null +++ b/pkg/util/util_test.go @@ -0,0 +1,105 @@ +package util_test + +import ( + "fmt" + "reflect" + "testing" + + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" + + "github.com/nicholas-fedor/shoutrrr/internal/meta" + "github.com/nicholas-fedor/shoutrrr/pkg/util" +) + +func TestUtil(t *testing.T) { + gomega.RegisterFailHandler(ginkgo.Fail) + ginkgo.RunSpecs(t, "Shoutrrr Util Suite") +} + +const ( + a = 10 + b = 20 +) + +var _ = ginkgo.Describe("the util package", func() { + ginkgo.When("calling function Min", func() { + ginkgo.It("should return the smallest of two integers", func() { + gomega.Expect(util.Min(a, b)).To(gomega.Equal(a)) + gomega.Expect(util.Min(b, a)).To(gomega.Equal(a)) + }) + }) + + ginkgo.When("calling function Max", func() { + ginkgo.It("should return the largest of two integers", func() { + gomega.Expect(util.Max(a, b)).To(gomega.Equal(b)) + gomega.Expect(util.Max(b, a)).To(gomega.Equal(b)) + }) + }) + + ginkgo.When("checking if a supplied kind is of the signed integer kind", func() { + ginkgo.It("should be true if the kind is Int", func() { + gomega.Expect(util.IsSignedInt(reflect.Int)).To(gomega.BeTrue()) + }) + ginkgo.It("should be false if the kind is String", func() { + gomega.Expect(util.IsSignedInt(reflect.String)).To(gomega.BeFalse()) + }) + }) + + ginkgo.When("checking if a supplied kind is of the unsigned integer kind", func() { + ginkgo.It("should be true if the kind is Uint", func() { + gomega.Expect(util.IsUnsignedInt(reflect.Uint)).To(gomega.BeTrue()) + }) + ginkgo.It("should be false if the kind is Int", func() { + gomega.Expect(util.IsUnsignedInt(reflect.Int)).To(gomega.BeFalse()) + }) + }) + + ginkgo.When("checking if a supplied kind is of the collection kind", func() { + ginkgo.It("should be true if the kind is slice", func() { + gomega.Expect(util.IsCollection(reflect.Slice)).To(gomega.BeTrue()) + }) + ginkgo.It("should be false if the kind is map", func() { + gomega.Expect(util.IsCollection(reflect.Map)).To(gomega.BeFalse()) + }) + }) + + ginkgo.When("calling function StripNumberPrefix", func() { + ginkgo.It("should return the default base if none is found", func() { + _, base := util.StripNumberPrefix("46") + gomega.Expect(base).To(gomega.Equal(0)) + }) + ginkgo.It("should remove # prefix and return base 16 if found", func() { + number, base := util.StripNumberPrefix("#ab") + gomega.Expect(number).To(gomega.Equal("ab")) + gomega.Expect(base).To(gomega.Equal(16)) + }) + }) + + ginkgo.When("checking if a supplied kind is numeric", func() { + ginkgo.It("should be true if supplied a constant integer", func() { + gomega.Expect(util.IsNumeric(reflect.TypeOf(5).Kind())).To(gomega.BeTrue()) + }) + ginkgo.It("should be true if supplied a constant float", func() { + gomega.Expect(util.IsNumeric(reflect.TypeOf(2.5).Kind())).To(gomega.BeTrue()) + }) + ginkgo.It("should be false if supplied a constant string", func() { + gomega.Expect(util.IsNumeric(reflect.TypeOf("3").Kind())).To(gomega.BeFalse()) + }) + }) + + ginkgo.When("calling function DocsURL", func() { + ginkgo.It("should return the expected URL", func() { + expectedBase := fmt.Sprintf( + `https://nicholas-fedor.github.io/shoutrrr/%s/`, + meta.DocsVersion, + ) + gomega.Expect(util.DocsURL(``)).To(gomega.Equal(expectedBase)) + gomega.Expect(util.DocsURL(`services/logger`)). + To(gomega.Equal(expectedBase + `services/logger`)) + }) + ginkgo.It("should strip the leading slash from the path", func() { + gomega.Expect(util.DocsURL(`/foo`)).To(gomega.Equal(util.DocsURL(`foo`))) + }) + }) +}) diff --git a/pkg/util/util_url.go b/pkg/util/util_url.go new file mode 100644 index 0000000..e33fd94 --- /dev/null +++ b/pkg/util/util_url.go @@ -0,0 +1,15 @@ +package util + +import "net/url" + +// URLUserPassword is a replacement/wrapper around url.UserPassword that treats empty string arguments as not specified. +// If no user or password is specified, it returns nil (which serializes in url.URL to ""). +func URLUserPassword(user, password string) *url.Userinfo { + if len(password) > 0 { + return url.UserPassword(user, password) + } else if len(user) > 0 { + return url.User(user) + } + + return nil +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5e1c61b --- /dev/null +++ b/renovate.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "docker:pinDigests", + "helpers:pinGitHubActionDigests", + ":configMigration", + ":pinDevDependencies" + ] +} diff --git a/shoutrrr.go b/shoutrrr.go new file mode 100644 index 0000000..77e39cf --- /dev/null +++ b/shoutrrr.go @@ -0,0 +1,56 @@ +package shoutrrr + +import ( + "fmt" + + "github.com/nicholas-fedor/shoutrrr/internal/meta" + "github.com/nicholas-fedor/shoutrrr/pkg/router" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// defaultRouter manages the creation and routing of notification services. +var defaultRouter = router.ServiceRouter{} + +// SetLogger configures the logger for all services in the default router. +func SetLogger(logger types.StdLogger) { + defaultRouter.SetLogger(logger) +} + +// Send delivers a notification message using the specified URL. +func Send(rawURL string, message string) error { + service, err := defaultRouter.Locate(rawURL) + if err != nil { + return fmt.Errorf("locating service for URL %q: %w", rawURL, err) + } + + if err := service.Send(message, &types.Params{}); err != nil { + return fmt.Errorf("sending message via service at %q: %w", rawURL, err) + } + + return nil +} + +// CreateSender constructs a new service router for the given URLs without a logger. +func CreateSender(rawURLs ...string) (*router.ServiceRouter, error) { + sr, err := router.New(nil, rawURLs...) + if err != nil { + return nil, fmt.Errorf("creating sender for URLs %v: %w", rawURLs, err) + } + + return sr, nil +} + +// NewSender constructs a new service router with a logger for the given URLs. +func NewSender(logger types.StdLogger, serviceURLs ...string) (*router.ServiceRouter, error) { + sr, err := router.New(logger, serviceURLs...) + if err != nil { + return nil, fmt.Errorf("creating sender with logger for URLs %v: %w", serviceURLs, err) + } + + return sr, nil +} + +// Version returns the current shoutrrr version. +func Version() string { + return meta.Version +} diff --git a/shoutrrr/cmd/docs/docs.go b/shoutrrr/cmd/docs/docs.go new file mode 100644 index 0000000..5b576c7 --- /dev/null +++ b/shoutrrr/cmd/docs/docs.go @@ -0,0 +1,87 @@ +package docs + +import ( + "fmt" + "log" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/router" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd" +) + +var ( + serviceRouter router.ServiceRouter + services = serviceRouter.ListServices() +) + +var Cmd = &cobra.Command{ + Use: "docs", + Short: "Print documentation for services", + Run: Run, + Args: func(cmd *cobra.Command, args []string) error { + serviceList := strings.Join(services, ", ") + cmd.SetUsageTemplate( + cmd.UsageTemplate() + "\nAvailable services: \n " + serviceList + "\n", + ) + + return cobra.MinimumNArgs(1)(cmd, args) + }, + ValidArgs: services, +} + +func init() { + Cmd.Flags().StringP("format", "f", "console", "Output format") +} + +func Run(cmd *cobra.Command, args []string) { + format, _ := cmd.Flags().GetString("format") + res := printDocs(format, args) + + if res.ExitCode != 0 { + fmt.Fprintf(os.Stderr, "%s", res.Message) + } + + os.Exit(res.ExitCode) +} + +func printDocs(docFormat string, services []string) cmd.Result { + var renderer format.TreeRenderer + + switch docFormat { + case "console": + renderer = format.ConsoleTreeRenderer{WithValues: false} + case "markdown": + renderer = format.MarkdownTreeRenderer{ + HeaderPrefix: "### ", + PropsDescription: "Props can be either supplied using the params argument, or through the URL using \n`?key=value&key=value` etc.\n", + PropsEmptyMessage: "*The services does not support any query/param props*", + } + default: + return cmd.InvalidUsage("invalid format") + } + + logger := log.New(os.Stderr, "", 0) // Concrete logger implementing types.StdLogger + + for _, scheme := range services { + service, err := serviceRouter.NewService(scheme) + if err != nil { + return cmd.InvalidUsage("failed to init service: " + err.Error()) + } + // Initialize the service to populate Config + dummyURL, _ := url.Parse(scheme + "://dummy@dummy.com") + if err := service.Initialize(dummyURL, logger); err != nil { + return cmd.InvalidUsage(fmt.Sprintf("failed to initialize service %q: %v", scheme, err)) + } + + config := format.GetServiceConfig(service) + configNode := format.GetConfigFormat(config) + fmt.Fprint(os.Stdout, renderer.RenderTree(configNode, scheme), "\n") + } + + return cmd.Success +} diff --git a/shoutrrr/cmd/exit_codes.go b/shoutrrr/cmd/exit_codes.go new file mode 100644 index 0000000..7ec35e2 --- /dev/null +++ b/shoutrrr/cmd/exit_codes.go @@ -0,0 +1,53 @@ +package cmd + +const ( + // ExSuccess is the exit code that signals that everything went as expected. + ExSuccess = 0 + // ExUsage is the exit code that signals that the application was not started with the correct arguments. + ExUsage = 64 + // ExUnavailable is the exit code that signals that the application failed to perform the intended task. + ExUnavailable = 69 + // ExConfig is the exit code that signals that the task failed due to a configuration error. + ExConfig = 78 +) + +// Success is the empty Result that is used whenever the command ran successfully. +// +//nolint:errname +var Success = Result{} + +// Result contains the final exit message and code for a CLI session. +// +//nolint:errname +type Result struct { + ExitCode int + Message string +} + +func (e Result) Error() string { + return e.Message +} + +// InvalidUsage returns a Result with the exit code ExUsage. +func InvalidUsage(message string) Result { + return Result{ + ExUsage, + message, + } +} + +// TaskUnavailable returns a Result with the exit code ExUnavailable. +func TaskUnavailable(message string) Result { + return Result{ + ExUnavailable, + message, + } +} + +// ConfigurationError returns a Result with the exit code ExConfig. +func ConfigurationError(message string) Result { + return Result{ + ExConfig, + message, + } +} diff --git a/shoutrrr/cmd/generate/generate.go b/shoutrrr/cmd/generate/generate.go new file mode 100644 index 0000000..81fb54a --- /dev/null +++ b/shoutrrr/cmd/generate/generate.go @@ -0,0 +1,240 @@ +package generate + +import ( + "errors" + "fmt" + "net/url" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + "github.com/nicholas-fedor/shoutrrr/pkg/generators" + "github.com/nicholas-fedor/shoutrrr/pkg/router" + "github.com/nicholas-fedor/shoutrrr/pkg/types" +) + +// MaximumNArgs defines the maximum number of positional arguments allowed. +const MaximumNArgs = 2 + +// ErrNoServiceSpecified indicates that no service was provided for URL generation. +var ( + ErrNoServiceSpecified = errors.New("no service specified") +) + +// serviceRouter manages the creation of notification services. +var serviceRouter router.ServiceRouter + +// Cmd generates a notification service URL from user input. +var Cmd = &cobra.Command{ + Use: "generate", + Short: "Generates a notification service URL from user input", + Run: Run, + PreRun: loadArgsFromAltSources, + Args: cobra.MaximumNArgs(MaximumNArgs), +} + +// loadArgsFromAltSources populates command flags from positional arguments if provided. +func loadArgsFromAltSources(cmd *cobra.Command, args []string) { + if len(args) > 0 { + _ = cmd.Flags().Set("service", args[0]) + } + + if len(args) > 1 { + _ = cmd.Flags().Set("generator", args[1]) + } +} + +// init initializes the command flags for the generate command. +func init() { + serviceRouter = router.ServiceRouter{} + + Cmd.Flags(). + StringP("service", "s", "", "Notification service to generate a URL for (e.g., discord, smtp)") + Cmd.Flags(). + StringP("generator", "g", "basic", "Generator to use (e.g., basic, or service-specific)") + Cmd.Flags(). + StringArrayP("property", "p", []string{}, "Configuration property in key=value format (e.g., token=abc123)") + Cmd.Flags(). + BoolP("show-sensitive", "x", false, "Show sensitive data in the generated URL (default: masked)") +} + +// maskSensitiveURL masks sensitive parts of a Shoutrrr URL based on the service schema. +func maskSensitiveURL(serviceSchema, urlStr string) string { + parsedURL, err := url.Parse(urlStr) + if err != nil { + return urlStr // Return original URL if parsing fails + } + + switch serviceSchema { + case "discord", "slack", "teams": + maskUser(parsedURL, "REDACTED") + case "smtp": + maskSMTPUser(parsedURL) + case "pushover": + maskPushoverQuery(parsedURL) + case "gotify": + maskGotifyQuery(parsedURL) + default: + maskGeneric(parsedURL) + } + + return parsedURL.String() +} + +// maskUser redacts the username in a URL with a placeholder. +func maskUser(parsedURL *url.URL, placeholder string) { + if parsedURL.User != nil { + parsedURL.User = url.User(placeholder) + } +} + +// maskSMTPUser redacts the password in an SMTP URL, preserving the username. +func maskSMTPUser(parsedURL *url.URL) { + if parsedURL.User != nil { + parsedURL.User = url.UserPassword(parsedURL.User.Username(), "REDACTED") + } +} + +// maskPushoverQuery redacts token and user query parameters in a Pushover URL. +func maskPushoverQuery(parsedURL *url.URL) { + queryParams := parsedURL.Query() + if queryParams.Get("token") != "" { + queryParams.Set("token", "REDACTED") + } + + if queryParams.Get("user") != "" { + queryParams.Set("user", "REDACTED") + } + + parsedURL.RawQuery = queryParams.Encode() +} + +// maskGotifyQuery redacts the token query parameter in a Gotify URL. +func maskGotifyQuery(parsedURL *url.URL) { + queryParams := parsedURL.Query() + if queryParams.Get("token") != "" { + queryParams.Set("token", "REDACTED") + } + + parsedURL.RawQuery = queryParams.Encode() +} + +// maskGeneric redacts userinfo and all query parameters for unrecognized services. +func maskGeneric(parsedURL *url.URL) { + maskUser(parsedURL, "REDACTED") + + queryParams := parsedURL.Query() + for key := range queryParams { + queryParams.Set(key, "REDACTED") + } + + parsedURL.RawQuery = queryParams.Encode() +} + +// Run executes the generate command, producing a notification service URL. +func Run(cmd *cobra.Command, _ []string) { + var service types.Service + + var err error + + serviceSchema, _ := cmd.Flags().GetString("service") + generatorName, _ := cmd.Flags().GetString("generator") + propertyFlags, _ := cmd.Flags().GetStringArray("property") + showSensitive, _ := cmd.Flags().GetBool("show-sensitive") + + // Parse properties into a key-value map. + props := make(map[string]string, len(propertyFlags)) + + for _, prop := range propertyFlags { + parts := strings.Split(prop, "=") + if len(parts) != MaximumNArgs { + fmt.Fprint( + color.Output, + "Invalid property key/value pair: ", + color.HiYellowString(prop), + "\n", + ) + + continue + } + + props[parts[0]] = parts[1] + } + + if len(propertyFlags) > 0 { + fmt.Fprint(color.Output, "\n") // Add spacing after property warnings + } + + // Validate and create the service. + if serviceSchema == "" { + err = ErrNoServiceSpecified + } else { + service, err = serviceRouter.NewService(serviceSchema) + } + + if err != nil { + fmt.Fprint(os.Stdout, "Error: ", err, "\n") + } + + if service == nil { + services := serviceRouter.ListServices() + serviceList := strings.Join(services, ", ") + cmd.SetUsageTemplate(cmd.UsageTemplate() + "\nAvailable services:\n " + serviceList + "\n") + _ = cmd.Usage() + + os.Exit(1) + } + + // Determine the generator to use. + var generator types.Generator + + generatorFlag := cmd.Flags().Lookup("generator") + if !generatorFlag.Changed { + // Use the service-specific default generator if available and no explicit generator is set. + generator, _ = generators.NewGenerator(serviceSchema) + } + + if generator != nil { + generatorName = serviceSchema + } else { + var genErr error + + generator, genErr = generators.NewGenerator(generatorName) + if genErr != nil { + fmt.Fprint(os.Stdout, "Error: ", genErr, "\n") + } + } + + if generator == nil { + generatorList := strings.Join(generators.ListGenerators(), ", ") + cmd.SetUsageTemplate( + cmd.UsageTemplate() + "\nAvailable generators:\n " + generatorList + "\n", + ) + + _ = cmd.Usage() + + os.Exit(1) + } + + // Generate and display the URL. + fmt.Fprint(color.Output, "Generating URL for ", color.HiCyanString(serviceSchema)) + fmt.Fprint(color.Output, " using ", color.HiMagentaString(generatorName), " generator\n") + + serviceConfig, err := generator.Generate(service, props, cmd.Flags().Args()) + if err != nil { + _, _ = fmt.Fprint(os.Stdout, "Error: ", err, "\n") + os.Exit(1) + } + + fmt.Fprint(color.Output, "\n") + + maskedURL := maskSensitiveURL(serviceSchema, serviceConfig.GetURL().String()) + + if showSensitive { + fmt.Fprint(os.Stdout, "URL: ", serviceConfig.GetURL().String(), "\n") + } else { + fmt.Fprint(os.Stdout, "URL: ", maskedURL, "\n") + } +} diff --git a/shoutrrr/cmd/send/send.go b/shoutrrr/cmd/send/send.go new file mode 100644 index 0000000..08e1138 --- /dev/null +++ b/shoutrrr/cmd/send/send.go @@ -0,0 +1,134 @@ +package send + +import ( + "errors" + "fmt" + "io" + "log" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/nicholas-fedor/shoutrrr/internal/dedupe" + internalUtil "github.com/nicholas-fedor/shoutrrr/internal/util" + "github.com/nicholas-fedor/shoutrrr/pkg/router" + "github.com/nicholas-fedor/shoutrrr/pkg/types" + "github.com/nicholas-fedor/shoutrrr/pkg/util" + cli "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd" +) + +// MaximumNArgs defines the maximum number of arguments accepted by the command. +const ( + MaximumNArgs = 2 + MaxMessageLength = 100 +) + +// Cmd sends a notification using a service URL. +var Cmd = &cobra.Command{ + Use: "send", + Short: "Send a notification using a service url", + Args: cobra.MaximumNArgs(MaximumNArgs), + PreRun: internalUtil.LoadFlagsFromAltSources, + RunE: Run, +} + +func init() { + Cmd.Flags().BoolP("verbose", "v", false, "") + Cmd.Flags().StringArrayP("url", "u", []string{}, "The notification url") + _ = Cmd.MarkFlagRequired("url") + Cmd.Flags(). + StringP("message", "m", "", "The message to send to the notification url, or - to read message from stdin") + + _ = Cmd.MarkFlagRequired("message") + Cmd.Flags().StringP("title", "t", "", "The title used for services that support it") +} + +func logf(format string, a ...any) { + fmt.Fprintf(os.Stderr, format+"\n", a...) +} + +func run(cmd *cobra.Command) error { + flags := cmd.Flags() + verbose, _ := flags.GetBool("verbose") + + urls, _ := flags.GetStringArray("url") + urls = dedupe.RemoveDuplicates(urls) + message, _ := flags.GetString("message") + title, _ := flags.GetString("title") + + if message == "-" { + logf("Reading from STDIN...") + + stringBuilder := strings.Builder{} + + count, err := io.Copy(&stringBuilder, os.Stdin) + if err != nil { + return fmt.Errorf("failed to read message from stdin: %w", err) + } + + logf("Read %d byte(s)", count) + + message = stringBuilder.String() + } + + var logger *log.Logger + + if verbose { + urlsPrefix := "URLs:" + for i, url := range urls { + logf("%s %s", urlsPrefix, url) + + if i == 0 { + // Only display "URLs:" prefix for first line, replace with indentation for the subsequent + urlsPrefix = strings.Repeat(" ", len(urlsPrefix)) + } + } + + logf("Message: %s", util.Ellipsis(message, MaxMessageLength)) + + if title != "" { + logf("Title: %v", title) + } + + logger = log.New(os.Stderr, "SHOUTRRR ", log.LstdFlags) + } else { + logger = util.DiscardLogger + } + + serviceRouter, err := router.New(logger, urls...) + if err != nil { + return cli.ConfigurationError(fmt.Sprintf("error invoking send: %s", err)) + } + + params := make(types.Params) + if title != "" { + params["title"] = title + } + + errs := serviceRouter.SendAsync(message, ¶ms) + for err := range errs { + if err != nil { + return cli.TaskUnavailable(err.Error()) + } + + logf("Notification sent") + } + + return nil +} + +// Run executes the send command and handles its result. +func Run(cmd *cobra.Command, _ []string) error { + err := run(cmd) + if err != nil { + var result cli.Result + if errors.As(err, &result) && result.ExitCode != cli.ExUsage { + // If the error is not related to CLI usage, report error and exit to avoid cobra error output + _, _ = fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(result.ExitCode) + } + } + + return err +} diff --git a/shoutrrr/cmd/verify/verify.go b/shoutrrr/cmd/verify/verify.go new file mode 100644 index 0000000..a837be4 --- /dev/null +++ b/shoutrrr/cmd/verify/verify.go @@ -0,0 +1,63 @@ +package verify + +import ( + "fmt" + "os" + "strings" + + "github.com/fatih/color" + "github.com/spf13/cobra" + + internalUtil "github.com/nicholas-fedor/shoutrrr/internal/util" + "github.com/nicholas-fedor/shoutrrr/pkg/format" + "github.com/nicholas-fedor/shoutrrr/pkg/router" +) + +// Cmd verifies the validity of a service url. +var Cmd = &cobra.Command{ + Use: "verify", + Short: "Verify the validity of a notification service URL", + PreRun: internalUtil.LoadFlagsFromAltSources, + Run: Run, + Args: cobra.MaximumNArgs(1), +} + +var serviceRouter router.ServiceRouter + +func init() { + Cmd.Flags().StringP("url", "u", "", "The notification url") + _ = Cmd.MarkFlagRequired("url") +} + +// Run the verify command. +func Run(cmd *cobra.Command, _ []string) { + URL, _ := cmd.Flags().GetString("url") + serviceRouter = router.ServiceRouter{} + + service, err := serviceRouter.Locate(URL) + if err != nil { + wrappedErr := fmt.Errorf("locating service for URL: %w", err) + fmt.Fprint(os.Stdout, "error verifying URL: ", sanitizeError(wrappedErr), "\n") + os.Exit(1) + } + + config := format.GetServiceConfig(service) + configNode := format.GetConfigFormat(config) + + fmt.Fprint(color.Output, format.ColorFormatTree(configNode, true)) +} + +// sanitizeError removes sensitive details from an error message. +func sanitizeError(err error) string { + errStr := err.Error() + // Check for common error patterns without exposing URL details + if strings.Contains(errStr, "unknown service") { + return "service not recognized" + } + + if strings.Contains(errStr, "parse") || strings.Contains(errStr, "invalid") { + return "invalid URL format" + } + // Fallback for other errors + return "unable to process URL" +} diff --git a/shoutrrr/main.go b/shoutrrr/main.go new file mode 100644 index 0000000..d79f3bb --- /dev/null +++ b/shoutrrr/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "os" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "github.com/nicholas-fedor/shoutrrr/internal/meta" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/docs" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/generate" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/send" + "github.com/nicholas-fedor/shoutrrr/shoutrrr/cmd/verify" +) + +var cobraCmd = &cobra.Command{ + Use: "shoutrrr", + Version: meta.Version, + Short: "Shoutrrr CLI", +} + +func init() { + viper.AutomaticEnv() + cobraCmd.AddCommand(verify.Cmd) + cobraCmd.AddCommand(generate.Cmd) + cobraCmd.AddCommand(send.Cmd) + cobraCmd.AddCommand(docs.Cmd) +} + +func main() { + if err := cobraCmd.Execute(); err != nil { + os.Exit(cmd.ExUsage) + } +}