diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..e9073f5 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,7 @@ +{ + "extends": ["@commitlint/config-conventional"], + "rules": { + "scope-case": [2, "always", "lower-case"], + "subject-case": [2, "never", ["start-case", "pascal-case", "upper-case"]] + } +} diff --git a/.example.env b/.example.env new file mode 100644 index 0000000..159eb1f --- /dev/null +++ b/.example.env @@ -0,0 +1,5 @@ +TG_PLUGIN__GOTIFY_CLIENT_TOKEN=abc123 +TG_PLUGIN__GOTIFY_URL="http://localhost:80" +TG_PLUGIN__LOG_LEVEL="debug" +TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS="1234567890,9876543210" +TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN="1234567890:abcdefghijklmn" diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..07c54d9 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,122 @@ +name: Build and Release + +on: + push: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 'stable' + + - name: Download tools + run: make download-tools + + - name: Run tests + run: make test + + create-tag: + needs: test + runs-on: ubuntu-latest + permissions: + contents: write + outputs: + new_version: ${{ steps.tag_version.outputs.new_version }} + tag: ${{ steps.tag_version.outputs.new_tag }} + supported_versions: ${{ steps.versions.outputs.versions }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read versions file + id: versions + run: | + VERSIONS=$(cat SUPPORTED_GOTIFY_VERSIONS.txt | jq -R -s -c 'split("\n")[:-1]') + echo "versions=$VERSIONS" >> $GITHUB_OUTPUT + + - name: Create Tag + id: tag_version + uses: mathieudutour/github-tag-action@v6.1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + default_bump: patch + + build: + needs: create-tag + runs-on: ubuntu-latest + strategy: + matrix: + gotify_version: ${{ fromJson(needs.create-tag.outputs.supported_versions) }} + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 'stable' + + - name: Download tools + run: make download-tools + + - name: Build plugin + run: >- + make + GOTIFY_VERSION="${{ matrix.gotify_version }}" + FILE_SUFFIX="-v${{ needs.create-tag.outputs.new_version }}-for-gotify-${{ matrix.gotify_version }}" + LD_FLAGS="-X main.Version=${{ needs.create-tag.outputs.new_version }}" + build + + - name: Upload artifacts + uses: actions/upload-artifact@v3 + with: + name: plugin-${{ matrix.gotify_version }} + path: build/*.so + + release: + needs: + - create-tag + - build + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Generate version list + id: versions + run: | + version_list=$(cat SUPPORTED_GOTIFY_VERSIONS.txt | sed 's/^/- /') + echo "version_list<> $GITHUB_ENV + echo "$version_list" >> $GITHUB_ENV + echo "EOF" >> $GITHUB_ENV + + - name: Download all artifacts + uses: actions/download-artifact@v3 + + - name: Display structure of downloaded files + run: ls -R + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ needs.create-tag.outputs.tag }} + name: Release ${{ needs.create-tag.outputs.tag }} + files: plugin-*/gotify-to-telegram-*.so + generate_release_notes: true + body: | + ## Supported Gotify Versions + ${{ env.version_list }} + + ## Installation + Download the appropriate plugin file for your architecture and Gotify version: + - AMD64: `gotify-to-telegram-linux-amd64-v${{ needs.create-tag.outputs.new_version }}-for-gotify-*.so` + - ARM64: `gotify-to-telegram-linux-arm64-v${{ needs.create-tag.outputs.new_version }}-for-gotify-*.so` + - ARM7: `gotify-to-telegram-linux-arm-7-v${{ needs.create-tag.outputs.new_version }}-for-gotify-*.so` diff --git a/.github/workflows/pr-title.yaml b/.github/workflows/pr-title.yaml new file mode 100644 index 0000000..228b1cc --- /dev/null +++ b/.github/workflows/pr-title.yaml @@ -0,0 +1,57 @@ +name: PR Title Lint + +on: + pull_request: + types: + - opened + - edited + - synchronize + - reopened + +permissions: + pull-requests: read + statuses: write + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + feat + fix + docs + style + refactor + perf + test + build + ci + chore + revert + # Configure that a scope must always be provided + requireScope: false + # Configure additional validation for the subject based on a regex. + # This example ensures the subject starts with lowercase. + subjectPattern: ^(?![A-Z]).+$ + # If `subjectPattern` is configured, you can use this property to override + # the default error message that is shown when the pattern doesn't match. + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + starts with a lowercase character. + # For work-in-progress PRs you can typically use draft pull requests + # from GitHub. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the + # validation of the PR title and the pull request checks remain pending. + # Note that a second check will be reported if this is enabled. + wip: true + # When using "Squash and merge" on a PR with only one commit, GitHub + # will suggest using that commit message instead of the PR title for the + # merge commit, and it's easy to commit this by mistake. Enable this option + # to also validate the commit message for one-commit PRs. + validateSingleCommit: true diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..04e47a7 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,24 @@ +name: Test + +on: + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 'stable' + + - name: Download tools + run: make download-tools + + - name: Run tests + run: make test + diff --git a/.gitignore b/.gitignore index 4c21388..ace0916 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ .idea vendor build -*.so \ No newline at end of file +*.so +.DS_Store + +.env + diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..904c573 --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,9 @@ +MD013: + line_length: 120 + heading_line_length: 120 + code_block_line_length: 120 + code_blocks: true + tables: true + headings: true + strict: false + stern: false diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0611e10..0000000 --- a/.travis.yml +++ /dev/null @@ -1,34 +0,0 @@ -language: go -go: - - "1.21" - -services: - - docker - -notifications: - email: false - -env: - - GO111MODULE=on GOTIFY_VERSIONS="v2.4.0" - -before_install: - - make download-tools - - go get -d - -script: - - go test ./... - -before_deploy: - - > - for TARGET in $GOTIFY_VERSIONS; do - make GOTIFY_VERSION="$TARGET" FILE_SUFFIX="-for-gotify-$TARGET" build; - done - -deploy: - - provider: releases - api_key: $GH_TOKEN - file_glob: true - file: build/*.so - skip_cleanup: true - on: - tags: true diff --git a/Makefile b/Makefile index 84ceeaf..9a1b2ad 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,24 @@ BUILDDIR=./build -GOTIFY_VERSION=master -PLUGIN_NAME=myplugin +PLUGINDIR=./plugins +GOTIFY_VERSION=v2.6.1 +PLUGIN_NAME=gotify-to-telegram PLUGIN_ENTRY=plugin.go GO_VERSION=`cat $(BUILDDIR)/gotify-server-go-version` DOCKER_BUILD_IMAGE=gotify/build DOCKER_WORKDIR=/proj DOCKER_RUN=docker run --rm -v "$$PWD/.:${DOCKER_WORKDIR}" -v "`go env GOPATH`/pkg/mod/.:/go/pkg/mod:ro" -w ${DOCKER_WORKDIR} DOCKER_GO_BUILD=go build -mod=readonly -a -installsuffix cgo -ldflags "$$LD_FLAGS" -buildmode=plugin +GOMOD_CAP=go run github.com/gotify/plugin-api/cmd/gomod-cap download-tools: - GO111MODULE=off go get -u github.com/gotify/plugin-api/cmd/gomod-cap + go install github.com/gotify/plugin-api/cmd/gomod-cap@latest create-build-dir: mkdir -p ${BUILDDIR} || true update-go-mod: create-build-dir wget -LO ${BUILDDIR}/gotify-server.mod https://raw.githubusercontent.com/gotify/server/${GOTIFY_VERSION}/go.mod - gomod-cap -from ${BUILDDIR}/gotify-server.mod -to go.mod + $(GOMOD_CAP) -from ${BUILDDIR}/gotify-server.mod -to go.mod rm ${BUILDDIR}/gotify-server.mod || true go mod tidy @@ -35,4 +37,54 @@ build-linux-arm64: get-gotify-server-go-version update-go-mod build: build-linux-arm-7 build-linux-amd64 build-linux-arm64 -.PHONY: build +check-env: + @if [ ! -f .env ]; then \ + echo "Creating .env from .example.env..."; \ + cp .example.env .env; \ + fi + +compose-up: check-env + docker compose up -d + +compose-down: + docker compose down --volumes + +test: + go test -v ./... + +create-plugin-dir: + mkdir -p ${PLUGINDIR} + +move-plugin-arm64: create-plugin-dir build-linux-arm64 + cp ${BUILDDIR}/${PLUGIN_NAME}-linux-arm64${FILE_SUFFIX}.so ${PLUGINDIR} + +move-plugin-amd64: create-plugin-dir build-linux-amd64 + cp ${BUILDDIR}/${PLUGIN_NAME}-linux-amd64${FILE_SUFFIX}.so ${PLUGINDIR} + +setup-gotify: compose-up + @echo "Setting up Gotify..." + @for i in 1 2 3 4 5; do \ + echo "Attempt $$i of 5..."; \ + sleep 5; \ + NEW_TOKEN=$$(curl -s -f -X POST \ + -u admin:admin \ + -H "Content-Type: application/json" \ + -d '{"name":"test-client"}' \ + http://localhost:8888/client \ + | jq -r '.token'); \ + if [ -n "$$NEW_TOKEN" ]; then \ + sed -i '' 's/^TG_PLUGIN__GOTIFY_CLIENT_TOKEN=.*/TG_PLUGIN__GOTIFY_CLIENT_TOKEN='$$NEW_TOKEN'/' .env && \ + echo "TG_PLUGIN__GOTIFY_CLIENT_TOKEN updated in .env. Restarting gotify..." && \ + docker compose down && docker compose up -d && \ + exit 0; \ + fi; \ + echo "Attempt $$i failed. Retrying..."; \ + done; \ + echo "Failed to get token from Gotify after 5 attempts"; \ + exit 1 + +test-plugin-arm64: move-plugin-arm64 setup-gotify + +test-plugin-amd64: move-plugin-amd64 setup-gotify + +.PHONY: build check-env compose-up compose-down test diff --git a/README.md b/README.md index d503cc4..bfe1d81 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,225 @@ -# gotify/plugin-template [![](https://travis-ci.org/gotify/plugin-template.svg?branch=master)](https://travis-ci.org/gotify/plugin-template) +# gotify-to-telegram -A plugin template for [gotify/server](https://github.com/gotify/server) -using [gotify/plugin-api](https://github.com/gotify/plugin-api). +This plugin routes [Gotify](https://gotify.net/) messages to [Telegram](https://telegram.org/). -## Getting Started +## Features -1. Clone, fork or copy this repository. -1. Change `PLUGIN_NAME` in [Makefile](Makefile). -1. Setup building on every release - * Enable travis-ci in your repository. - * Add `GH_TOKEN` environment variable see [travis-ci docs](https://docs.travis-ci.com/user/deployment/pages/#setting-the-github-token). -1. Implement your plugin. See [plugin docs](https://gotify.net/docs/plugin). -1. Create a release to automatically build the plugin. +- Support for forwarding messages to multiple telegram bots/chat ids +- Configurable message formatting options per bot +- Configuration via environment variables or yaml config file via Gotify UI -*When you're done, feel free to add your plugin to [gotify/contrib](https://github.com/gotify/contrib).* +## Installation -## Building +### Download pre-built plugins -For building the plugin gotify/build docker images are used to ensure compatibility with -[gotify/server](https://github.com/gotify/server). +You can download pre-built plugins for your specific architecture and gotify version from the +[releases page](https://github.com/0xpetersatoshi/gotify-to-telegram/releases). -`GOTIFY_VERSION` can be a tag, commit or branch from the gotify/server repository. +### Build from source + +Clone the repository and run: + +```bash +make GOTIFY_VERSION="v2.6.1" FILE_SUFFIX="for-gotify-v2.6.1" build +``` + +> **Note**: Specify the `GOTIFY_VERSION` you want to build the plugin for. + +### Gotify Setup + +Copy the plugin shared object file into your Gotify plugins directory (configured as `pluginsdir` in your Gotify +config file). Further documentation can be found [here](https://gotify.net/docs/plugin-deploy#deploying). + +## Configuration + +### Prequisites + +There are four required configuration settings needed to start using this plugin: + +1. A Telegram bot token +2. A Telegram chat id +3. A Gotify server url +4. A Gotify server client token + +#### Telegram + +By default, all gotify messages will be sent to this default bot, though you can configure multiple different bots and +specify which gotify messages are routed to which bot. You can read +[this](https://sendpulse.com/knowledge-base/chatbot/telegram/create-telegram-chatbot#create-bot) for more info on how +to create a telegram bot. + +#### Gotify + +Additionally, the plugin needs the gotify server url and a client token to be able to create a websocket connection to +the gotify server and listen for new messages. A client token can be created in the Gotify UI from the "Clients" tab. + +### Getting Started + +You can configure the plugin in one of the following ways: + +1. Only using environment variables (limited configuration options) +2. Only using the yaml editor from the Gotify UI (full configuration options) accessible from the Plugins > Details > + Configurer section +3. Using both environment variables and the yaml editor with environment variables taking precedence over any values set + in the yaml editor. You can later decide to ignore the environment variables and only use the yaml editor by either + unsetting the environment variables or setting the option `ignore_env_vars: true` in the yaml editor. + +#### Environment Variables + +The plugin can be configured using environment variables. All variables are prefixed with `TG_PLUGIN__` +(note the double underscore!). + +##### Logging Settings + +| Variable | Type | Default | Description | +| ---------------------- | ------ | -------- | -------------------------------------------- | +| `TG_PLUGIN__LOG_LEVEL` | string | `"info"` | Log level (`debug`, `info`, `warn`, `error`) | + +##### Gotify Server Settings + +| Variable | Type | Default | Description | +| -------------------------------- | ------ | ----------------------- | ------------------------------------ | +| `TG_PLUGIN__GOTIFY_URL` | string | `"http://localhost:80"` | URL of your Gotify server (required) | +| `TG_PLUGIN__GOTIFY_CLIENT_TOKEN` | string | `""` | Client token from Gotify (required) | + +##### Telegram Bot Settings + +| Variable | Type | Default | Description | +| --------------------------------------- | ------ | ------- | ------------------------------------------- | +| `TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN` | string | `""` | Default Telegram bot token (required) | +| `TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS` | string | `""` | Comma-separated list of chat IDs (required) | + +##### Message Formatting Settings + +| Variable | Type | Default | Description | +| --------------------------------------- | ------- | -------------- | -------------------------------------------- | +| `TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME` | boolean | `false` | Include Gotify app name in the message title | +| `TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP` | boolean | `false` | Include timestamp | +| `TG_PLUGIN__MESSAGE_INCLUDE_EXTRAS` | boolean | `false` | Include message extras | +| `TG_PLUGIN__MESSAGE_PARSE_MODE` | string | `"MarkdownV2"` | Message parse mode | +| `TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY` | boolean | `false` | Show priority indicators emojis | +| `TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD` | integer | `0` | Priority indicator threshold | + +##### Priority Indicators + +When `TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY` is enabled, messages include these indicator emojis based on priority: + +- šŸ”“ Critical Priority (ā‰„8) +- šŸŸ  High Priority (ā‰„6) +- šŸŸ” Medium Priority (ā‰„4) +- šŸŸ¢ Low Priority (<4) + +##### Example Configuration + +```env +# Logging +TG_PLUGIN__LOG_LEVEL=debug + +# Gotify Server +TG_PLUGIN__GOTIFY_URL="http://gotify.example.com" +TG_PLUGIN__GOTIFY_CLIENT_TOKEN="ABC123..." + +# Telegram Settings +TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN="123456:ABC-DEF..." +TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS="123456789,987654321" + +# Message Formatting +TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME=true +TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP=true +TG_PLUGIN__MESSAGE_INCLUDE_EXTRAS=false +TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY=true +TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD=5 + +``` + +#### Yaml configuration + +You can also configure the plugin using the yaml editor from the Gotify UI. This unlocks more granular configuration +including specifying multiple telegram bots, differing formatting options for each bot, and specific Gotify application +IDs that should be routed to a specific bot. + +Here is an example yaml configuration: + +```yaml +settings: + ignore_env_vars: false + log_options: + log_level: debug + gotify_server: + url: http://localhost:80 + client_token: CzV6.mP4r3r1yoA + websocket: + handshake_timeout: 10 + telegram: + default_bot_token: 123456789:ABC-DEF-GHI-JKL-MNO + default_chat_ids: + - "123456789" + - "987654321" + bots: + example_bot: + token: 987654321:XYZ-ABC-DEF-GHI-JKL-MNO + chat_ids: + - "445566778" + - "223344556" + gotify_app_ids: + - 10 + - 23 + message_format_options: + include_app_name: true + include_timestamp: true + include_extras: false + parse_mode: MarkdownV2 + include_priority: false + priority_threshold: 0 + another_bot: + token: 678901234:JKL-MNO-PQR-STU-VWX + chat_ids: + - "889900112" + gotify_app_ids: + - 5 + - 6 + - 7 + message_format_options: + include_app_name: false + include_timestamp: true + include_extras: false + parse_mode: MarkdownV2 + include_priority: true + priority_threshold: 4 + default_message_format_options: + include_app_name: false # example: [Jellyseer] Movie Request Approved + include_timestamp: false + include_extras: false + parse_mode: MarkdownV2 + include_priority: false + priority_threshold: 0 +``` + +In this example, there are two additional bots configured: `example_bot` and `another_bot`. Both bots have different +message formatting options. Messages from gotify application IDs 5, 6, and 7 will be sent to the `another_bot` and +messages from gotify application IDs 10 and 23 will be sent to the `example_bot`. All other messages will be sent to +the default bot. + +## Development + +You can run and test this plugin in a docker container by running: + +```bash + +# if you are on an arm machine +make test-plugin-arm64 + +# if you are on a x86 machine +make test-plugin-amd64 +``` + +This will build the shared objects file for your architecture and spin up a gotify docker container with the plugin +loaded onto it. + +> **NOTE**: The gotify docker container uses the default username and password (admin/admin). + +Additionally, you can run tests with: -This command builds the plugin for amd64, arm-7 and arm64. -The resulting shared object will be compatible with gotify/server version 2.0.20. ```bash -$ make GOTIFY_VERSION="v2.0.20" FILE_SUFFIX="for-gotify-v2.0.20" build +make test ``` diff --git a/SUPPORTED_GOTIFY_VERSIONS.txt b/SUPPORTED_GOTIFY_VERSIONS.txt new file mode 100644 index 0000000..d8726b4 --- /dev/null +++ b/SUPPORTED_GOTIFY_VERSIONS.txt @@ -0,0 +1,2 @@ +v2.6.0 +v2.6.1 diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..5c84193 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,17 @@ +services: + gotify: + image: gotify/server:2.6.1 + container_name: gotify + restart: unless-stopped + ports: + - 8888:80 + env_file: + - .env + volumes: + - gotify_data:/app/data + - ./plugins/:/app/data/plugins + +volumes: + gotify_data: + driver: local + diff --git a/go.mod b/go.mod index beb0fed..7f4a213 100644 --- a/go.mod +++ b/go.mod @@ -1,41 +1,49 @@ -module github.com/gotify/plugin-template +module github.com/0xPeterSatoshi/gotify-to-telegram -go 1.18 +go 1.23 require ( - github.com/gin-gonic/gin v1.9.1 + github.com/caarlos0/env/v11 v11.3.1 + github.com/gorilla/websocket v1.5.3 github.com/gotify/plugin-api v1.0.0 - github.com/stretchr/testify v1.8.4 + github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/rs/zerolog v1.33.0 + github.com/stretchr/testify v1.9.0 ) require ( - github.com/bytedance/sonic v1.9.1 // indirect - github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gin-contrib/sse v0.1.0 // indirect + github.com/gin-gonic/gin v1.10.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.15.4 // indirect + github.com/go-playground/validator/v10 v10.22.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/kr/pretty v0.3.0 // indirect - github.com/leodido/go-urn v1.2.4 // indirect - github.com/mattn/go-isatty v0.0.19 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/pelletier/go-toml/v2 v2.0.8 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rogpeppe/go-internal v1.8.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect - github.com/ugorji/go/codec v1.2.11 // indirect - golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.13.0 // indirect - golang.org/x/net v0.10.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/text v0.13.0 // indirect - google.golang.org/protobuf v1.30.0 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.29.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index cff80e0..1226c68 100644 --- a/go.sum +++ b/go.sum @@ -1,43 +1,52 @@ -github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= -github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= -github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= -github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= -github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= -github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gin-contrib/sse v0.0.0-20170109093832-22d885f9ecc7/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-gonic/gin v1.3.0/go.mod h1:7cKuhb5qV2ggCFctp2fJQ+ErvciLZrIeoOSOm6mUr7Y= -github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= -github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.15.4 h1:zMXza4EpOdooxPel5xDqXEdXG5r+WggpvnAKMsalBjs= -github.com/go-playground/validator/v10 v10.15.4/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA= +github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotify/plugin-api v1.0.0 h1:kab40p2TEPLzjmcafOc7JOz75aTsYQyS2PXtElH8xmI= github.com/gotify/plugin-api v1.0.0/go.mod h1:xZfEyqVK/Zvu3RwA/CtpuiwFmzFDxifrrqMaH9BHnyU= github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= -github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= @@ -46,63 +55,75 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= -github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= -github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= -github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.3/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v0.0.0-20181209151446-772ced7fd4c2/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= -github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= -github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= -golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= +golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/net v0.0.0-20190110200230-915654e7eabc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= -google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= @@ -114,4 +135,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..149df71 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,348 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + "time" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/logger" + "github.com/gorilla/websocket" + "github.com/patrickmn/go-cache" + "github.com/rs/zerolog" +) + +type Message struct { + Id uint32 + AppID uint32 + AppName string + AppDescription string + Message string + Title string + Priority uint32 + Extras map[string]interface{} + Date time.Time +} + +type Application struct { + ID uint32 `json:"id"` + Token string `json:"token"` + Name string `json:"name"` + Description string `json:"description"` + Internal bool `json:"internal"` + Image string `json:"image"` + DefaultPriority uint32 `json:"defaultPriority"` + LastUsed string `json:"lastUsed"` +} + +// Client is a gotify API client +type Client struct { + serverURL *url.URL + clientToken string + conn *websocket.Conn + logger *zerolog.Logger + cache *cache.Cache + messages chan<- Message + errChan chan<- error + ctx context.Context + mu sync.Mutex + isConnected bool + handshakeTimeout int +} + +type Config struct { + Url *url.URL + ClientToken string + HandshakeTimeout int + Messages chan<- Message + ErrChan chan<- error +} + +// NewClient creates a new gotify API client +func NewClient(ctx context.Context, c Config) *Client { + cache := cache.New(60*time.Minute, 120*time.Minute) + + if c.Url == nil || (c.Url != nil && c.Url.Hostname() == "") { + // if no url is provided, default to localhost + parsedURL, _ := url.Parse(config.DefaultURL) + c.Url = parsedURL + } + + return &Client{ + serverURL: c.Url, + clientToken: c.ClientToken, + logger: logger.WithComponent("api"), + messages: c.Messages, + errChan: c.ErrChan, + cache: cache, + ctx: ctx, + } +} + +// connect connects to the gotify API +func (c *Client) connect() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.isConnected { + c.logger.Debug().Msg("already connected to gotify server") + return nil + } + + if c.serverURL.Host == "" { + return errors.New("gotify host is not set") + } + + if c.clientToken == "" { + return errors.New("gotify client token is not set") + } + + protocol := "ws://" + if c.serverURL.Scheme == "https" { + protocol = "wss://" + } + endpoint := protocol + c.serverURL.Host + "/stream?token=" + c.clientToken + + dialer := websocket.Dialer{ + HandshakeTimeout: time.Duration(c.handshakeTimeout) * time.Second, + } + + conn, _, err := dialer.DialContext(c.ctx, endpoint, nil) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + + c.conn = conn + c.isConnected = true + + c.logger.Info(). + Str("protocol", protocol). + Str("host", c.serverURL.Host). + Msg("connected to gotify server") + + return nil +} + +// Start establishes a websocket connection and starts reading incoming messages +func (c *Client) Start() { + c.logger.Info().Msg("starting new gotify websocket connection") + + for { + select { + case <-c.ctx.Done(): + c.logger.Debug(). + Err(c.ctx.Err()). + Msg("stopping client...") + return + default: + if err := c.connect(); err != nil { + c.logger.Error().Err(err).Msg("failed to connect") + select { + case <-c.ctx.Done(): + c.logger.Debug(). + Err(c.ctx.Err()). + Msg("closing websocket connection") + if err := c.Close(); err != nil { + c.logger.Error().Err(err).Msg("error closing connection") + c.errChan <- err + return + } + return + case <-time.After(5 * time.Second): + continue + } + } + + // Start message reading + if err := c.readMessages(); err != nil { + if !errors.Is(err, context.Canceled) { + c.logger.Error().Err(err).Msg("error reading messages") + } + } + + // Reset connection state + c.mu.Lock() + c.isConnected = false + c.mu.Unlock() + } + } +} + +// Close closes the gotify websocket connection +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn != nil && c.isConnected { + // Send close message + err := c.conn.WriteMessage( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + ) + if err != nil { + c.logger.Warn().Err(err).Msg("error sending close message") + } + + if err := c.conn.Close(); err != nil { + return fmt.Errorf("error closing connection: %w", err) + } + c.isConnected = false + c.logger.Debug().Msg("websocket connection closed") + } + + return nil +} + +// readMessages reads messages received from the gotify server and sends them to the messages channel +func (c *Client) readMessages() error { + // Create channels for the reader goroutine + msgChan := make(chan Message) + errChan := make(chan error) + + // Start a separate goroutine for reading + go func() { + for { + var msg Message + if err := c.conn.ReadJSON(&msg); err != nil { + c.mu.Lock() + c.isConnected = false + c.mu.Unlock() + + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { + errChan <- fmt.Errorf("websocket error: %w", err) + return + } + errChan <- err + return + } + msgChan <- msg + } + }() + + c.logger.Info().Msg("listening for new messages") + for { + select { + case <-c.ctx.Done(): + c.logger.Debug(). + Err(c.ctx.Err()). + Msg("stopping read event loop") + // Close the websocket connection to unblock the reader goroutine + if err := c.Close(); err != nil { + c.logger.Error().Err(err).Msg("error closing websocket connection") + } + return c.ctx.Err() + + case err := <-errChan: + return err + + case msg := <-msgChan: + if err := c.processMessage(msg); err != nil { + c.logger.Error().Err(err).Msg("failed to process message") + continue + } + } + } +} + +func (c *Client) processMessage(msg Message) error { + c.logger.Debug().Msg("processing new message") + appItem, found := c.cache.Get(fmt.Sprintf("%d", msg.AppID)) + if found { + app := appItem.(Application) + msg.AppName = app.Name + msg.AppDescription = app.Description + } else { + app, err := c.getApplicationByID(msg.AppID) + if err != nil { + return fmt.Errorf("failed to get application: %w", err) + } + c.cache.SetDefault(fmt.Sprintf("%d", msg.AppID), *app) + msg.AppName = app.Name + msg.AppDescription = app.Description + } + + select { + case <-c.ctx.Done(): + c.logger.Debug(). + Err(c.ctx.Err()). + Msg("stopping message processing") + return c.ctx.Err() + case c.messages <- msg: + c.logger.Info().Msg("message sent to message channel") + } + + return nil +} + +// makeRequest makes a request to the gotify API and returns the raw response +func (c *Client) makeRequest(method string, endpoint string, body *bytes.Buffer) (*http.Response, error) { + // Create request body if provided + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonBody) + } + req, err := http.NewRequest(method, endpoint, reqBody) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json") + + c.logger.Debug().Msgf("making request to %s", endpoint) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + if res.StatusCode != http.StatusOK { + return nil, err + } + + return res, nil +} + +// getApplications returns a list of applications +func (c *Client) getApplications() ([]Application, error) { + endpoint := c.serverURL.String() + "/application?token=" + c.clientToken + + res, err := c.makeRequest("GET", endpoint, nil) + if err != nil { + return nil, err + } + + defer res.Body.Close() + + var applications []Application + if err := json.NewDecoder(res.Body).Decode(&applications); err != nil { + return nil, err + } + + return applications, nil +} + +// getApplicationByID returns an application by id +func (c *Client) getApplicationByID(id uint32) (*Application, error) { + applications, err := c.getApplications() + if err != nil { + return nil, err + } + + for _, application := range applications { + if application.ID == id { + return &application, nil + } + } + + return nil, fmt.Errorf("application with id %d not found", id) +} diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..8051741 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,285 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var mockApps = []Application{ + { + ID: 1, + Token: "test-token", + Name: "Test App", + Description: "Test Description", + }, + { + ID: 2, + Token: "test-token-2", + Name: "Test App 2", + Description: "Test Description 2", + }, +} + +func setupTestServer(t *testing.T) (*httptest.Server, *websocket.Upgrader) { + upgrader := &websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { return true }, + } + + // Create test HTTP server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/stream": + // Handle WebSocket connection + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Logf("Failed to upgrade connection: %v", err) + return + } + defer conn.Close() + + // Keep connection alive + for { + select { + case <-r.Context().Done(): + return + } + } + + case "/application": + // Return mock applications + json.NewEncoder(w).Encode(mockApps) + + default: + http.NotFound(w, r) + } + })) + + return server, upgrader +} + +func TestNewClient(t *testing.T) { + tests := []struct { + name string + config Config + wantURL string + wantErrors bool + }{ + { + name: "valid configuration", + config: Config{ + Url: &url.URL{Scheme: "http", Host: "example.com"}, + ClientToken: "test-token", + HandshakeTimeout: 10, + }, + wantURL: "http://example.com", + wantErrors: false, + }, + { + name: "nil URL defaults to localhost", + config: Config{ + ClientToken: "test-token", + HandshakeTimeout: 10, + }, + wantURL: "http://localhost:80", + wantErrors: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + messages := make(chan Message, 1) + errChan := make(chan error, 1) + + tt.config.Messages = messages + tt.config.ErrChan = errChan + + client := NewClient(ctx, tt.config) + + assert.NotNil(t, client) + assert.Equal(t, tt.wantURL, client.serverURL.String()) + assert.Equal(t, tt.config.ClientToken, client.clientToken) + }) + } +} + +func TestClientStruct_connect(t *testing.T) { + server, _ := setupTestServer(t) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + tests := []struct { + name string + clientToken string + wantError bool + }{ + { + name: "successful connection", + clientToken: "valid-token", + wantError: false, + }, + { + name: "empty client token", + clientToken: "", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + messages := make(chan Message, 1) + errChan := make(chan error, 1) + + client := NewClient(ctx, Config{ + Url: serverURL, + ClientToken: tt.clientToken, + HandshakeTimeout: 1, + Messages: messages, + ErrChan: errChan, + }) + + err := client.connect() + + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.True(t, client.isConnected) + assert.NotNil(t, client.conn) + } + + client.Close() + }) + } +} + +func TestClientStruct_processMessage(t *testing.T) { + server, _ := setupTestServer(t) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + ctx := context.Background() + messages := make(chan Message, 1) + errChan := make(chan error, 1) + + client := NewClient(ctx, Config{ + Url: serverURL, + ClientToken: "test-token", + HandshakeTimeout: 1, + Messages: messages, + ErrChan: errChan, + }) + + msg := Message{ + Id: 1, + AppID: 1, + Message: "Test Message", + Title: "Test Title", + Priority: 1, + Date: time.Now(), + } + + err = client.processMessage(msg) + require.NoError(t, err) + + // Verify the message was processed and sent to the channel + select { + case receivedMsg := <-messages: + assert.Equal(t, msg.Id, receivedMsg.Id) + assert.Equal(t, "Test App", receivedMsg.AppName) + assert.Equal(t, "Test Description", receivedMsg.AppDescription) + case <-time.After(time.Second): + t.Fatal("Timeout waiting for message") + } +} + +func TestClientStruct_getApplications(t *testing.T) { + server, _ := setupTestServer(t) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + ctx := context.Background() + messages := make(chan Message, 1) + errChan := make(chan error, 1) + + client := NewClient(ctx, Config{ + Url: serverURL, + ClientToken: "test-token", + HandshakeTimeout: 1, + Messages: messages, + ErrChan: errChan, + }) + + apps, err := client.getApplications() + require.NoError(t, err) + assert.Equal(t, mockApps, apps) +} + +func TestClientStruct_getApplicationByID(t *testing.T) { + server, _ := setupTestServer(t) + defer server.Close() + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + ctx := context.Background() + messages := make(chan Message, 1) + errChan := make(chan error, 1) + + client := NewClient(ctx, Config{ + Url: serverURL, + ClientToken: "test-token", + HandshakeTimeout: 1, + Messages: messages, + ErrChan: errChan, + }) + + tests := []struct { + name string + appID uint32 + wantError bool + }{ + { + name: "existing application", + appID: 1, + wantError: false, + }, + { + name: "non-existent application", + appID: 999, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + app, err := client.getApplicationByID(tt.appID) + if tt.wantError { + assert.Error(t, err) + assert.Nil(t, app) + } else { + assert.NoError(t, err) + assert.NotNil(t, app) + assert.Equal(t, tt.appID, app.ID) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..dbf494d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,276 @@ +package config + +import ( + "errors" + "net/url" + "strings" + + "github.com/caarlos0/env/v11" + "github.com/rs/zerolog" +) + +const DefaultURL = "http://localhost:80" + +// Settings represents global plugin settings +type Settings struct { + // Ignores env variables when true + IgnoreEnvVars bool `yaml:"ignore_env_vars"` + // Log options + LogOptions LogOptions `yaml:"log_options"` + // Gotify server settings + GotifyServer GotifyServer `yaml:"gotify_server"` + // Telegram settings + Telegram Telegram `yaml:"telegram"` +} + +// Log options +type LogOptions struct { + // LogLevel can be "debug", "info", "warn", "error" + LogLevel string `yaml:"log_level" env:"TG_PLUGIN__LOG_LEVEL" envDefault:"info"` +} + +// Message formatting options +type MessageFormatOptions struct { + // Whether to include app name in message + IncludeAppName bool `yaml:"include_app_name" env:"TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME" envDefault:"false"` + // Whether to include timestamp in message + IncludeTimestamp bool `yaml:"include_timestamp" env:"TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP" envDefault:"false"` + // Whether to include message extras in message + IncludeExtras bool `yaml:"include_extras" env:"TG_PLUGIN__MESSAGE_INCLUDE_EXTRAS" envDefault:"false"` + // Telegram parse mode (Markdown, MarkdownV2, HTML) + ParseMode string `yaml:"parse_mode" env:"TG_PLUGIN__MESSAGE_PARSE_MODE" envDefault:"MarkdownV2"` + // Whether to include the message priority in the message + IncludePriority bool `yaml:"include_priority" env:"TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY" envDefault:"false"` + // Whether to include the message priority above a certain level + PriorityThreshold int `yaml:"priority_threshold" env:"TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD" envDefault:"0"` +} + +// Websocket settings +type Websocket struct { + // Timeout for initial connection (in seconds) + HandshakeTimeout int `yaml:"handshake_timeout" env:"TG_PLUGIN__WS_HANDSHAKE_TIMEOUT" envDefault:"10"` +} + +// GotifyServer settings +type GotifyServer struct { + // Gotify server in url.URL format + Url *url.URL `yaml:"-"` + // Gotify server URL + RawUrl string `yaml:"url" env:"TG_PLUGIN__GOTIFY_URL" envDefault:"http://localhost:80"` + // Gotify client token + ClientToken string `yaml:"client_token" env:"TG_PLUGIN__GOTIFY_CLIENT_TOKEN" envDefault:""` + // Websocket settings + Websocket Websocket `yaml:"websocket"` +} + +// Url returns the parsed Gotify server URL +func (g *GotifyServer) URL() *url.URL { + if g.Url == nil { + if parsedURL, err := url.Parse(g.RawUrl); err == nil { + g.Url = parsedURL + } else { + // Fallback to default if parsing fails + defaultURL, _ := url.Parse(DefaultURL) + g.Url = defaultURL + } + } + return g.Url +} + +// Telegram settings +type Telegram struct { + // Default bot token + DefaultBotToken string `yaml:"default_bot_token" env:"TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN" envDefault:""` + // Default chat ID + DefaultChatIDs []string `yaml:"default_chat_ids" env:"TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS" envDefault:""` + // Mapping of bot names to bot tokens/chat IDs + Bots map[string]TelegramBot `yaml:"bots"` + // Message formatting options + MessageFormatOptions MessageFormatOptions `yaml:"default_message_format_options"` +} + +// TelegramBot settings +type TelegramBot struct { + // Bot token + Token string `yaml:"token"` + // Chat IDs + ChatIDs []string `yaml:"chat_ids"` + // Gotify app ids + AppIDs []uint32 `yaml:"gotify_app_ids"` + // Bot message formatting options + MessageFormatOptions *MessageFormatOptions `yaml:"message_format_options"` +} + +// Plugin settings +type Plugin struct { + Settings Settings `yaml:"settings"` +} + +// Validate validates that required fields are set and valid +func (p *Plugin) Validate() error { + if p.Settings.Telegram.DefaultBotToken == "" { + return errors.New("settings.telegram.default_bot_token is required") + } + + if len(p.Settings.Telegram.DefaultChatIDs) == 0 { + return errors.New("settings.telegram.default_chat_ids is required") + } + + if p.Settings.GotifyServer.RawUrl == "" { + return errors.New("settings.gotify_server.url is required") + } + + parsedURL, err := url.Parse(p.Settings.GotifyServer.RawUrl) + if err != nil { + return err + } + + p.Settings.GotifyServer.Url = parsedURL + + if p.Settings.GotifyServer.Url == nil || p.Settings.GotifyServer.Url.Hostname() == "" { + return errors.New("settings.gotify_server.url is invalid. Should be in format http://localhost:80 or http://example.com") + } + + if p.Settings.GotifyServer.ClientToken == "" { + return errors.New("settings.gotify_server.client_token is required") + } + + return nil +} + +func CreateDefaultPluginConfig() *Plugin { + URL, _ := url.Parse(DefaultURL) + bot := TelegramBot{ + Token: "example_token", + ChatIDs: []string{ + "123456789", + "987654321", + }, + AppIDs: []uint32{ + 123456789, + 987654321, + }, + MessageFormatOptions: &MessageFormatOptions{ + IncludeAppName: false, + IncludeTimestamp: true, + ParseMode: "MarkdownV2", + }, + } + + botMap := make(map[string]TelegramBot) + botMap["example_bot"] = bot + + telegram := Telegram{ + DefaultBotToken: "", + DefaultChatIDs: []string{}, + Bots: botMap, + MessageFormatOptions: MessageFormatOptions{ + IncludeAppName: false, + IncludeTimestamp: false, + ParseMode: "MarkdownV2", + }, + } + + gotifyServer := GotifyServer{ + Url: URL, + RawUrl: DefaultURL, + ClientToken: "", + Websocket: Websocket{ + HandshakeTimeout: 10, + }, + } + + settings := Settings{ + LogOptions: LogOptions{LogLevel: "info"}, + Telegram: telegram, + GotifyServer: gotifyServer, + } + return &Plugin{ + Settings: settings, + } +} + +// GetZerologLevel converts string log level to zerolog level +func (l *LogOptions) GetZerologLevel() zerolog.Level { + switch strings.ToLower(l.LogLevel) { + case "debug": + return zerolog.DebugLevel + case "info": + return zerolog.InfoLevel + case "warn": + return zerolog.WarnLevel + case "error": + return zerolog.ErrorLevel + default: + return zerolog.InfoLevel + } +} + +func ParseEnvVars() (*Plugin, error) { + cfg := &Plugin{} + if err := env.Parse(cfg); err != nil { + return nil, err + } + + cfg.Settings.GotifyServer.Url = cfg.Settings.GotifyServer.URL() + + // Handle invalid URL by setting default + if cfg.Settings.GotifyServer.Url.Hostname() == "" { + defaultURL, _ := url.Parse(DefaultURL) + cfg.Settings.GotifyServer.Url = defaultURL + } + + return cfg, nil +} + +// MergeWithEnvVars applies environment variable values over the existing config +func MergeWithEnvVars(cfg *Plugin) error { + // Create a new config from env vars + envConfig, err := ParseEnvVars() + if err != nil { + return err + } + + // Only override non-zero/non-empty values from environment + if envConfig.Settings.LogOptions.LogLevel != "" { + cfg.Settings.LogOptions.LogLevel = envConfig.Settings.LogOptions.LogLevel + } + + // Gotify server settings + if envConfig.Settings.GotifyServer.RawUrl != "" { + cfg.Settings.GotifyServer.RawUrl = envConfig.Settings.GotifyServer.RawUrl + parsedURL, err := url.Parse(envConfig.Settings.GotifyServer.RawUrl) + if err != nil { + return err + } + cfg.Settings.GotifyServer.Url = parsedURL + } + if envConfig.Settings.GotifyServer.ClientToken != "" { + cfg.Settings.GotifyServer.ClientToken = envConfig.Settings.GotifyServer.ClientToken + } + + // Telegram settings + if envConfig.Settings.Telegram.DefaultBotToken != "" { + cfg.Settings.Telegram.DefaultBotToken = envConfig.Settings.Telegram.DefaultBotToken + } + if len(envConfig.Settings.Telegram.DefaultChatIDs) > 0 { + cfg.Settings.Telegram.DefaultChatIDs = envConfig.Settings.Telegram.DefaultChatIDs + } + + // Message format options + opts := &cfg.Settings.Telegram.MessageFormatOptions + envOpts := &envConfig.Settings.Telegram.MessageFormatOptions + + opts.IncludeAppName = envOpts.IncludeAppName + opts.IncludeTimestamp = envOpts.IncludeTimestamp + opts.IncludeExtras = envOpts.IncludeExtras + if envOpts.ParseMode != "" { + opts.ParseMode = envOpts.ParseMode + } + opts.IncludePriority = envOpts.IncludePriority + if envOpts.PriorityThreshold != 0 { + opts.PriorityThreshold = envOpts.PriorityThreshold + } + + return nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..3e704f9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,425 @@ +package config + +import ( + "net/url" + "os" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestParseEnvVars(t *testing.T) { + // Setup environment variables + envVars := map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "http://gotify.example.com:8080", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "some-client-token", + "TG_PLUGIN__WS_HANDSHAKE_TIMEOUT": "15", + "TG_PLUGIN__WS_PING_INTERVAL": "45", + "TG_PLUGIN__WS_PONG_WAIT": "90", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "default-bot-token", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "123,456", + "TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME": "true", + "TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP": "true", + "TG_PLUGIN__MESSAGE_PARSE_MODE": "HTML", + "TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY": "true", + "TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD": "5", + } + + // Set environment variables + for k, v := range envVars { + err := os.Setenv(k, v) + assert.NoError(t, err) + } + + // Cleanup environment variables after test + defer func() { + for k := range envVars { + os.Unsetenv(k) + } + }() + + // Parse environment variables + cfg, err := ParseEnvVars() + assert.NoError(t, err) + assert.NotNil(t, cfg) + + // Validate parsed config + expectedURL, _ := url.Parse("http://gotify.example.com:8080") + + // Log Options + assert.Equal(t, "debug", cfg.Settings.LogOptions.LogLevel) + + // Gotify Server + assert.Equal(t, expectedURL, cfg.Settings.GotifyServer.Url) + assert.Equal(t, "some-client-token", cfg.Settings.GotifyServer.ClientToken) + assert.Equal(t, 15, cfg.Settings.GotifyServer.Websocket.HandshakeTimeout) + + // Telegram + assert.Equal(t, "default-bot-token", cfg.Settings.Telegram.DefaultBotToken) + assert.Equal(t, []string{"123", "456"}, cfg.Settings.Telegram.DefaultChatIDs) + + // Message Format Options + assert.True(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeAppName) + assert.True(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeTimestamp) + assert.Equal(t, "HTML", cfg.Settings.Telegram.MessageFormatOptions.ParseMode) + assert.True(t, cfg.Settings.Telegram.MessageFormatOptions.IncludePriority) + assert.Equal(t, 5, cfg.Settings.Telegram.MessageFormatOptions.PriorityThreshold) +} + +func TestParseEnvVars_DefaultValues(t *testing.T) { + // Clear any existing environment variables that might interfere + envVars := []string{ + "TG_PLUGIN__LOG_LEVEL", + "TG_PLUGIN__GOTIFY_URL", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN", + "TG_PLUGIN__WS_HANDSHAKE_TIMEOUT", + "TG_PLUGIN__WS_PING_INTERVAL", + "TG_PLUGIN__WS_PONG_WAIT", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS", + "TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME", + "TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP", + "TG_PLUGIN__MESSAGE_PARSE_MODE", + "TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY", + "TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD", + } + + for _, env := range envVars { + os.Unsetenv(env) + } + + // Parse environment variables + cfg, err := ParseEnvVars() + assert.NoError(t, err) + assert.NotNil(t, cfg) + + // Verify default values + assert.Equal(t, "info", cfg.Settings.LogOptions.LogLevel) + assert.Equal(t, "http://localhost:80", cfg.Settings.GotifyServer.Url.String()) + assert.Equal(t, "", cfg.Settings.GotifyServer.ClientToken) + assert.Equal(t, 10, cfg.Settings.GotifyServer.Websocket.HandshakeTimeout) + assert.Equal(t, "", cfg.Settings.Telegram.DefaultBotToken) + assert.Empty(t, cfg.Settings.Telegram.DefaultChatIDs) + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeAppName) + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeTimestamp) + assert.Equal(t, "MarkdownV2", cfg.Settings.Telegram.MessageFormatOptions.ParseMode) + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludePriority) + assert.Equal(t, 0, cfg.Settings.Telegram.MessageFormatOptions.PriorityThreshold) +} + +func TestCreateDefaultPluginConfig(t *testing.T) { + cfg := CreateDefaultPluginConfig() + assert.NotNil(t, cfg) + + // Test LogOptions defaults + assert.Equal(t, "info", cfg.Settings.LogOptions.LogLevel) + + // Test GotifyServer defaults + expectedURL := &url.URL{ + Scheme: "http", + Host: "localhost:80", + } + + exampleBot := &TelegramBot{ + Token: "example_token", + ChatIDs: []string{ + "123456789", + "987654321", + }, + AppIDs: []uint32{ + 123456789, + 987654321, + }, + MessageFormatOptions: &MessageFormatOptions{ + IncludeAppName: false, + IncludeTimestamp: true, + ParseMode: "MarkdownV2", + }, + } + expectedBotMap := make(map[string]TelegramBot) + expectedBotMap["example_bot"] = *exampleBot + + assert.Equal(t, expectedURL.String(), cfg.Settings.GotifyServer.Url.String()) + assert.Equal(t, "", cfg.Settings.GotifyServer.ClientToken) + + // Test Websocket defaults + assert.Equal(t, 10, cfg.Settings.GotifyServer.Websocket.HandshakeTimeout) + + // Test Telegram defaults + assert.Equal(t, "", cfg.Settings.Telegram.DefaultBotToken) + assert.Empty(t, cfg.Settings.Telegram.DefaultChatIDs) + assert.Equal(t, expectedBotMap, cfg.Settings.Telegram.Bots) + + // Test MessageFormatOptions defaults + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeAppName) + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludeTimestamp) + assert.Equal(t, "MarkdownV2", cfg.Settings.Telegram.MessageFormatOptions.ParseMode) + assert.False(t, cfg.Settings.Telegram.MessageFormatOptions.IncludePriority) + assert.Equal(t, 0, cfg.Settings.Telegram.MessageFormatOptions.PriorityThreshold) +} + +func TestLogOptionsStruct_GetZerologLevel(t *testing.T) { + tests := []struct { + name string + logLevel string + want zerolog.Level + }{ + { + name: "debug level", + logLevel: "debug", + want: zerolog.DebugLevel, + }, + { + name: "info level", + logLevel: "info", + want: zerolog.InfoLevel, + }, + { + name: "warn level", + logLevel: "warn", + want: zerolog.WarnLevel, + }, + { + name: "error level", + logLevel: "error", + want: zerolog.ErrorLevel, + }, + { + name: "uppercase DEBUG", + logLevel: "DEBUG", + want: zerolog.DebugLevel, + }, + { + name: "mixed case DeBuG", + logLevel: "DeBuG", + want: zerolog.DebugLevel, + }, + { + name: "invalid level", + logLevel: "invalid", + want: zerolog.InfoLevel, + }, + { + name: "empty level", + logLevel: "", + want: zerolog.InfoLevel, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logOpts := &LogOptions{LogLevel: tt.logLevel} + got := logOpts.GetZerologLevel() + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConfig_URLHandling(t *testing.T) { + tests := []struct { + name string + envURL string + wantHost string + wantPort string + }{ + { + name: "default url", + envURL: "http://localhost:80", + wantHost: "localhost", + wantPort: "80", + }, + { + name: "custom port", + envURL: "http://gotify.example.com:8080", + wantHost: "gotify.example.com", + wantPort: "8080", + }, + { + name: "https url", + envURL: "https://gotify.secure.com:443", + wantHost: "gotify.secure.com", + wantPort: "443", + }, + { + name: "invalid url falls back to default", + envURL: "not-a-url", + wantHost: "localhost", + wantPort: "80", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear existing env vars + os.Unsetenv("TG_PLUGIN__GOTIFY_URL") + + // Set test env var + os.Setenv("TG_PLUGIN__GOTIFY_URL", tt.envURL) + defer os.Unsetenv("TG_PLUGIN__GOTIFY_URL") + + cfg, err := ParseEnvVars() + assert.NoError(t, err) + assert.NotNil(t, cfg) + assert.NotNil(t, cfg.Settings.GotifyServer.Url) + + parsedURL := cfg.Settings.GotifyServer.Url + assert.Equal(t, tt.wantHost, parsedURL.Hostname()) + assert.Equal(t, tt.wantPort, parsedURL.Port()) + }) + } +} + +func TestMergeWithEnvVars(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + initial *Plugin + verify func(*testing.T, *Plugin) + }{ + { + name: "override all possible values", + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "http://new.example.com", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "new-token", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "new-bot-token", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "111,222", + "TG_PLUGIN__MESSAGE_INCLUDE_APP_NAME": "true", + "TG_PLUGIN__MESSAGE_INCLUDE_TIMESTAMP": "true", + "TG_PLUGIN__MESSAGE_PARSE_MODE": "HTML", + "TG_PLUGIN__MESSAGE_INCLUDE_PRIORITY": "true", + "TG_PLUGIN__MESSAGE_PRIORITY_THRESHOLD": "5", + }, + initial: CreateDefaultPluginConfig(), + verify: func(t *testing.T, p *Plugin) { + assert.Equal(t, "debug", p.Settings.LogOptions.LogLevel) + assert.Equal(t, "http://new.example.com", p.Settings.GotifyServer.RawUrl) + assert.Equal(t, "new-token", p.Settings.GotifyServer.ClientToken) + assert.Equal(t, "new-bot-token", p.Settings.Telegram.DefaultBotToken) + assert.Equal(t, []string{"111", "222"}, p.Settings.Telegram.DefaultChatIDs) + assert.True(t, p.Settings.Telegram.MessageFormatOptions.IncludeAppName) + assert.True(t, p.Settings.Telegram.MessageFormatOptions.IncludeTimestamp) + assert.Equal(t, "HTML", p.Settings.Telegram.MessageFormatOptions.ParseMode) + assert.True(t, p.Settings.Telegram.MessageFormatOptions.IncludePriority) + assert.Equal(t, 5, p.Settings.Telegram.MessageFormatOptions.PriorityThreshold) + }, + }, + { + name: "no environment variables set", + envVars: map[string]string{}, + initial: CreateDefaultPluginConfig(), + verify: func(t *testing.T, p *Plugin) { + // Should maintain default values + assert.Equal(t, "info", p.Settings.LogOptions.LogLevel) + assert.Equal(t, DefaultURL, p.Settings.GotifyServer.RawUrl) + assert.Equal(t, "", p.Settings.GotifyServer.ClientToken) + assert.Equal(t, "", p.Settings.Telegram.DefaultBotToken) + assert.Empty(t, p.Settings.Telegram.DefaultChatIDs) + }, + }, + { + name: "partial override", + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "error", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "partial-token", + }, + initial: CreateDefaultPluginConfig(), + verify: func(t *testing.T, p *Plugin) { + assert.Equal(t, "error", p.Settings.LogOptions.LogLevel) + assert.Equal(t, "partial-token", p.Settings.Telegram.DefaultBotToken) + // Other values should remain default + assert.Equal(t, DefaultURL, p.Settings.GotifyServer.RawUrl) + assert.Empty(t, p.Settings.Telegram.DefaultChatIDs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear any existing env vars + os.Clearenv() + + // Set test environment variables + for k, v := range tt.envVars { + err := os.Setenv(k, v) + assert.NoError(t, err) + } + + // Run merge + err := MergeWithEnvVars(tt.initial) + assert.NoError(t, err) + + // Verify results + tt.verify(t, tt.initial) + }) + } +} + +func TestValidate(t *testing.T) { + tests := []struct { + name string + config *Plugin + wantError string + }{ + { + name: "empty config", + config: &Plugin{}, + wantError: "settings.telegram.default_bot_token is required", + }, + { + name: "missing default chat IDs", + config: &Plugin{ + Settings: Settings{ + Telegram: Telegram{ + DefaultBotToken: "token", + }, + }, + }, + wantError: "settings.telegram.default_chat_ids is required", + }, + { + name: "missing client token", + config: &Plugin{ + Settings: Settings{ + Telegram: Telegram{ + DefaultBotToken: "token", + DefaultChatIDs: []string{"123"}, + }, + GotifyServer: GotifyServer{ + RawUrl: "http://valid.com", + }, + }, + }, + wantError: "settings.gotify_server.client_token is required", + }, + { + name: "valid config", + config: &Plugin{ + Settings: Settings{ + Telegram: Telegram{ + DefaultBotToken: "token", + DefaultChatIDs: []string{"123"}, + }, + GotifyServer: GotifyServer{ + RawUrl: "http://valid.com", + ClientToken: "client-token", + }, + }, + }, + wantError: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.config.Validate() + if tt.wantError != "" { + assert.EqualError(t, err, tt.wantError) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..60cb4b3 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,66 @@ +package logger + +import ( + "os" + "sync" + + "github.com/gotify/plugin-api" + "github.com/rs/zerolog" +) + +var ( + globalLogger *zerolog.Logger + once sync.Once + mu sync.RWMutex +) + +// Init initializes the global logger with initial configuration +func Init(pluginName string, pluginVersion string, userCtx plugin.UserContext) *zerolog.Logger { + once.Do(func() { + logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}). + With(). + Str("plugin", pluginName). + Str("plugin_version", pluginVersion). + Uint("user_id", userCtx.ID). + Str("user_name", userCtx.Name). + Bool("is_admin", userCtx.Admin). + Caller(). + Timestamp(). + Logger() + + globalLogger = &logger + }) + + return globalLogger +} + +// Get returns the global logger instance +func Get() *zerolog.Logger { + if globalLogger == nil { + // If not initialized, create a default logger + logger := zerolog.New(zerolog.ConsoleWriter{Out: os.Stdout}). + With(). + Timestamp(). + Logger() + globalLogger = &logger + } + return globalLogger +} + +// UpdateLogLevel updates the log level of the global logger +func UpdateLogLevel(level zerolog.Level) { + mu.Lock() + defer mu.Unlock() + + if globalLogger != nil { + newLogger := globalLogger.Level(level) + globalLogger = &newLogger + } +} + +// WithComponent adds a component field to the logger +// Useful for package-specific logging +func WithComponent(component string) *zerolog.Logger { + logger := Get().With().Str("component", component).Logger() + return &logger +} diff --git a/internal/telegram/client.go b/internal/telegram/client.go new file mode 100644 index 0000000..0694729 --- /dev/null +++ b/internal/telegram/client.go @@ -0,0 +1,120 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/api" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/logger" + "github.com/rs/zerolog" +) + +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +type Payload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ParseMode string `json:"parse_mode"` +} + +type Client struct { + logger *zerolog.Logger + httpClient HTTPClient + errChan chan error +} + +// NewClient creates a new Telegram client +func NewClient(errChan chan error) *Client { + return &Client{ + logger: logger.WithComponent("telegram"), + httpClient: &http.Client{}, + errChan: errChan, + } +} + +func (c *Client) buildBotEndpoint(token string) string { + return "https://api.telegram.org/bot" + token + "/sendMessage" +} + +// Send sends a message to Telegram +func (c *Client) Send(message api.Message, token, chatID string, formatOpts config.MessageFormatOptions) { + if token == "" { + c.errChan <- fmt.Errorf("telegram bot token is empty") + return + } + if chatID == "" { + c.errChan <- fmt.Errorf("telegram chat ID is empty") + return + } + + c.logger.Debug(). + Uint32("app_id", message.AppID). + Str("app_name", message.AppName). + Str("chat_id", chatID). + Msg("preparing to send message to Telegram") + + formattedMessage := formatMessageForTelegram(message, formatOpts, c.logger) + + payload := Payload{ + ChatID: chatID, + Text: formattedMessage, + ParseMode: formatOpts.ParseMode, + } + + body, err := json.Marshal(payload) + if err != nil { + c.errChan <- fmt.Errorf("failed to marshal payload: %w", err) + return + } + + endpoint := c.buildBotEndpoint(token) + c.logger.Debug(). + Str("endpoint", strings.Replace(endpoint, token, "***", 1)). + Str("payload", string(body)). + Msg("sending request to Telegram API") + + if err := c.makeRequest(endpoint, bytes.NewBuffer(body)); err != nil { + c.errChan <- fmt.Errorf("failed to make request: %w", err) + return + } + + c.logger.Info().Msg("message successfully sent to Telegram") +} + +// makeRequest makes a request to the Telegram API +func (c *Client) makeRequest(endpoint string, body *bytes.Buffer) error { + req, err := http.NewRequest("POST", endpoint, body) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + res, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to execute request: %w", err) + } + defer res.Body.Close() + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return fmt.Errorf("failed to read response body: %w", err) + } + + if res.StatusCode != http.StatusOK { + return fmt.Errorf("telegram API error (status %d): %s", res.StatusCode, string(resBody)) + } + + c.logger.Debug(). + Str("response", string(resBody)). + Msg("received response from Telegram API") + + return nil +} diff --git a/internal/telegram/client_test.go b/internal/telegram/client_test.go new file mode 100644 index 0000000..c3d54ca --- /dev/null +++ b/internal/telegram/client_test.go @@ -0,0 +1,265 @@ +package telegram + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/api" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// MockHTTPClient is a mock HTTP client for testing +type MockHTTPClient struct { + DoFunc func(req *http.Request) (*http.Response, error) +} + +func (m *MockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return m.DoFunc(req) +} + +func TestNewClient(t *testing.T) { + errChan := make(chan error, 1) + client := NewClient(errChan) + + assert.NotNil(t, client) + assert.NotNil(t, client.httpClient) + assert.NotNil(t, client.logger) + assert.Equal(t, errChan, client.errChan) +} + +func TestClientStruct_BuildBotEndpoint(t *testing.T) { + client := NewClient(make(chan error, 1)) + + tests := []struct { + name string + token string + expected string + }{ + { + name: "valid token", + token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", + expected: "https://api.telegram.org/bot123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11/sendMessage", + }, + { + name: "empty token", + token: "", + expected: "https://api.telegram.org/bot/sendMessage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := client.buildBotEndpoint(tt.token) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestClientStruct_Send(t *testing.T) { + tests := []struct { + name string + message api.Message + token string + chatID string + formatOpts config.MessageFormatOptions + mockResponse *http.Response + mockError error + expectedError bool + expectedErrMsg string + }{ + { + name: "successful send", + message: api.Message{ + AppID: 1, + AppName: "TestApp", + Message: "Test Message", + Title: "Test Title", + Priority: 1, + }, + token: "valid-token", + chatID: "123456", + formatOpts: config.MessageFormatOptions{ + ParseMode: "MarkdownV2", + }, + mockResponse: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)), + }, + expectedError: false, + }, + { + name: "empty token", + token: "", + chatID: "123456", + expectedError: true, + expectedErrMsg: "telegram bot token is empty", + }, + { + name: "empty chat ID", + token: "valid-token", + chatID: "", + expectedError: true, + expectedErrMsg: "telegram chat ID is empty", + }, + { + name: "API error response", + token: "valid-token", + chatID: "123456", + message: api.Message{Message: "Test"}, + formatOpts: config.MessageFormatOptions{ + ParseMode: "MarkdownV2", + }, + mockResponse: &http.Response{ + StatusCode: http.StatusBadRequest, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":false,"error":"Bad Request"}`)), + }, + expectedError: true, + expectedErrMsg: "telegram API error (status 400)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errChan := make(chan error, 1) + client := NewClient(errChan) + + // Mock HTTP client if a response is provided + if tt.mockResponse != nil { + client.httpClient = &MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + if tt.mockError != nil { + return nil, tt.mockError + } + return tt.mockResponse, nil + }, + } + } + + // Send message + client.Send(tt.message, tt.token, tt.chatID, tt.formatOpts) + + // Check for errors + select { + case err := <-errChan: + if !tt.expectedError { + t.Errorf("unexpected error: %v", err) + } else if tt.expectedErrMsg != "" { + assert.Contains(t, err.Error(), tt.expectedErrMsg) + } + case <-time.After(time.Second): + if tt.expectedError { + t.Error("expected error but got none") + } + } + }) + } +} + +func TestClientStruct_MakeRequest(t *testing.T) { + tests := []struct { + name string + endpoint string + payload *bytes.Buffer + mockResponse *http.Response + mockError error + expectedError bool + expectedErrMsg string + }{ + { + name: "successful request", + endpoint: "https://api.telegram.org/bot123456:ABC/sendMessage", + payload: bytes.NewBufferString(`{"chat_id":"123","text":"test"}`), + mockResponse: &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":true}`)), + }, + expectedError: false, + }, + { + name: "invalid endpoint", + endpoint: "://invalid-url", + payload: bytes.NewBufferString(`{}`), + expectedError: true, + expectedErrMsg: "failed to create request", + }, + { + name: "server error", + endpoint: "https://api.telegram.org/bot123456:ABC/sendMessage", + payload: bytes.NewBufferString(`{}`), + mockResponse: &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString(`{"ok":false}`)), + }, + expectedError: true, + expectedErrMsg: "telegram API error (status 500)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := NewClient(make(chan error, 1)) + + if tt.mockResponse != nil { + client.httpClient = &MockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + if tt.mockError != nil { + return nil, tt.mockError + } + return tt.mockResponse, nil + }, + } + } + + err := client.makeRequest(tt.endpoint, tt.payload) + + if tt.expectedError { + require.Error(t, err) + if tt.expectedErrMsg != "" { + assert.Contains(t, err.Error(), tt.expectedErrMsg) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestPayload_Marshal(t *testing.T) { + tests := []struct { + name string + payload Payload + expected string + }{ + { + name: "basic payload", + payload: Payload{ + ChatID: "123456", + Text: "test message", + ParseMode: "MarkdownV2", + }, + expected: `{"chat_id":"123456","text":"test message","parse_mode":"MarkdownV2"}`, + }, + { + name: "empty parse mode", + payload: Payload{ + ChatID: "123456", + Text: "test message", + }, + expected: `{"chat_id":"123456","text":"test message","parse_mode":""}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data, err := json.Marshal(tt.payload) + require.NoError(t, err) + assert.Equal(t, tt.expected, string(data)) + }) + } +} diff --git a/internal/telegram/format.go b/internal/telegram/format.go new file mode 100644 index 0000000..deaaa5a --- /dev/null +++ b/internal/telegram/format.go @@ -0,0 +1,202 @@ +package telegram + +import ( + "fmt" + "regexp" + "sort" + "strings" + "time" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/api" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/rs/zerolog" +) + +// Regular expression to find markdown links +var linkRegex = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + +// processMarkdownLinks processes markdown links in the given text +func processMarkdownLinks(text string, logger *zerolog.Logger) string { + // If text doesn't contain valid links (i.e., both '[' and '](' must be present) + if !strings.Contains(text, "](") { + return escapeMarkdownV2(text) + } + + // Process all matches at once using regex + return linkRegex.ReplaceAllStringFunc(text, func(match string) string { + // Extract link text and URL using regex submatches + submatches := linkRegex.FindStringSubmatch(match) + if len(submatches) != 3 { + logger.Error().Msg("Invalid link format found") + return escapeMarkdownV2(match) + } + + linkText := submatches[1] + url := submatches[2] + + // Escape the link text and URL separately + escapedLinkText := escapeMarkdownV2(linkText) + escapedURL := escapeURLForMarkdown(url) + + // Return the properly formatted markdown link + return fmt.Sprintf("[%s](%s)", escapedLinkText, escapedURL) + }) +} + +// escapeURLForMarkdown escapes specific characters in URLs +func escapeURLForMarkdown(url string) string { + var result strings.Builder + result.Grow(len(url) * 2) // Pre-allocate space for worst case + + // Keep track of nested parentheses + parenDepth := 0 + + for _, char := range url { + switch char { + case '(': + parenDepth++ + result.WriteString("\\(") + case ')': + parenDepth-- + // Always escape a closing parenthesis + result.WriteString("\\)") + default: + result.WriteRune(char) + } + } + + return result.String() +} + +// escapeMarkdownV2 escapes specific characters in MarkdownV2 +func escapeMarkdownV2(s string) string { + // Characters that need escaping in MarkdownV2 + specialChars := []string{ + "_", "*", "[", "]", "(", ")", "~", "`", ">", + "#", "+", "-", "=", "|", "{", "}", ".", "!", + } + + result := s + for _, char := range specialChars { + result = strings.ReplaceAll(result, char, "\\"+char) + } + return result +} + +// convertImageMarkdownToURL converts image markdown to plain URLs +func convertImageMarkdownToURL(message string, logger *zerolog.Logger) string { + imgRegex := regexp.MustCompile(`!\[\]\((.*?)\)`) + return imgRegex.ReplaceAllStringFunc(message, func(match string) string { + submatches := imgRegex.FindStringSubmatch(match) + if len(submatches) != 2 { + logger.Error().Msg("Invalid image markdown format") + return match + } + return submatches[1] // Return just the URL + }) +} + +// formatTitle formats the title for Telegram +func formatTitle(msg api.Message) string { + return fmt.Sprintf("[%s] %s", msg.AppName, msg.Title) +} + +// formatExtras handles the recursive formatting of nested maps +func formatExtras(builder *strings.Builder, extras map[string]interface{}, prefix string, logger *zerolog.Logger) { + // Get keys and sort them + keys := make([]string, 0, len(extras)) + for key := range extras { + keys = append(keys, key) + } + sort.Strings(keys) + + for _, key := range keys { + value := extras[key] + escapedKey := escapeMarkdownV2(key) + + // Handle nested maps + if nestedMap, ok := value.(map[string]interface{}); ok { + builder.WriteString(fmt.Sprintf("\n%sā€¢ %s:", prefix, escapedKey)) + formatExtras(builder, nestedMap, prefix+" ", logger) // Increase indentation for nested items + } else { + // Format simple values + escapedValue := escapeMarkdownV2(fmt.Sprint(value)) + builder.WriteString(fmt.Sprintf("\n%sā€¢ %s: `%s`", prefix, escapedKey, escapedValue)) + } + } +} + +// formatMessageForTelegram formats the message for Telegram +func formatMessageForTelegram(msg api.Message, formatOpts config.MessageFormatOptions, logger *zerolog.Logger) string { + var ( + builder strings.Builder + messageTitle string + ) + + // Title in bold + if msg.Title != "" { + if formatOpts.IncludeAppName { + messageTitle = formatTitle(msg) + } else { + messageTitle = msg.Title + } + builder.WriteString(fmt.Sprintf("*%s*\n\n", escapeMarkdownV2(messageTitle))) + } + + // Process the message content + messageContent := msg.Message + + // First convert any image markdown to plain URLs + if strings.Contains(messageContent, "![](") { + messageContent = convertImageMarkdownToURL(messageContent, logger) + } + + // Then handle any regular markdown links + messageContent = processMarkdownLinks(messageContent, logger) + + // Escape any remaining special characters in the message + // But avoid escaping the already processed links + parts := strings.Split(messageContent, "\n") + for i, part := range parts { + if !strings.Contains(part, "](") { // Only escape lines that don't contain links + parts[i] = escapeMarkdownV2(part) + } + } + messageContent = strings.Join(parts, "\n") + + builder.WriteString(messageContent + "\n") + + // Priority indicator using emojis + if int(msg.Priority) > formatOpts.PriorityThreshold && formatOpts.IncludePriority { + builder.WriteString("\n") + builder.WriteString(escapeMarkdownV2(getPriorityIndicator(int(msg.Priority)))) + } + + // Add any extras if present and not empty + if len(msg.Extras) > 0 && formatOpts.IncludeExtras { + builder.WriteString("\n*Additional Info:*") + formatExtras(&builder, msg.Extras, "", logger) + } + + // Add timestamp + if formatOpts.IncludeTimestamp { + formattedTimestamp := time.Now().Format(time.RFC3339) + builder.WriteString(fmt.Sprintf("\n\ntimestamp: %s", escapeMarkdownV2(formattedTimestamp))) + } + + return builder.String() +} + +// getPriorityIndicator returns the emoji indicator for the priority +func getPriorityIndicator(priority int) string { + switch { + case priority >= 8: + return "šŸ”“ Critical Priority" + case priority >= 6: + return "šŸŸ  High Priority" + case priority >= 4: + return "šŸŸ” Medium Priority" + default: + return "šŸŸ¢ Low Priority" + } +} diff --git a/internal/telegram/format_test.go b/internal/telegram/format_test.go new file mode 100644 index 0000000..2bbff4a --- /dev/null +++ b/internal/telegram/format_test.go @@ -0,0 +1,348 @@ +package telegram + +import ( + "strings" + "testing" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/api" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func TestProcessMarkdownLinks(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "simple link", + input: "[text](https://example.com)", + expected: "[text](https://example.com)", + }, + { + name: "link with special characters", + input: "[test!](https://example.com/test!)", + expected: "[test\\!](https://example.com/test!)", + }, + { + name: "multiple links", + input: "[link1](url1) and [link2](url2)", + expected: "[link1](url1) and [link2](url2)", + }, + { + name: "invalid link format", + input: "[broken link(http://example.com)", + expected: "\\[broken link\\(http://example\\.com\\)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := processMarkdownLinks(tt.input, &logger) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEscapeURLForMarkdown(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "url with parentheses", + input: "http://example.com/path(1)", + expected: "http://example.com/path\\(1\\)", + }, + { + name: "simple url", + input: "http://example.com", + expected: "http://example.com", + }, + { + name: "url with nested parentheses", + input: "http://example.com/(test(1))", + expected: "http://example.com/\\(test\\(1\\)\\)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeURLForMarkdown(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestEscapeMarkdownV2(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "special characters", + input: "Hello *world* with _emphasis_!", + expected: "Hello \\*world\\* with \\_emphasis\\_\\!", + }, + { + name: "code and links", + input: "Check `this` and [that]", + expected: "Check \\`this\\` and \\[that\\]", + }, + { + name: "dots and dashes", + input: "Example.com - test", + expected: "Example\\.com \\- test", + }, + { + name: "no special characters", + input: "Hello world", + expected: "Hello world", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := escapeMarkdownV2(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestConvertImageMarkdownToURL(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "single image", + input: "![](https://example.com/image.jpg)", + expected: "https://example.com/image.jpg", + }, + { + name: "multiple images", + input: "![](image1.jpg)\n![](image2.jpg)", + expected: "image1.jpg\nimage2.jpg", + }, + { + name: "invalid image markdown", + input: "![broken(image.jpg)", + expected: "![broken(image.jpg)", + }, + { + name: "mixed content", + input: "Text ![](image.jpg) more text", + expected: "Text image.jpg more text", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := convertImageMarkdownToURL(tt.input, &logger) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatExtras(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + var builder strings.Builder + + tests := []struct { + name string + extras map[string]interface{} + prefix string + expected string + }{ + { + name: "simple extras", + extras: map[string]interface{}{ + "key1": "value1", + "key2": 123, + }, + prefix: "", + expected: "\nā€¢ key1: `value1`\nā€¢ key2: `123`", + }, + { + name: "nested extras", + extras: map[string]interface{}{ + "outer": map[string]interface{}{ + "inner": "value", + }, + }, + prefix: "", + expected: "\nā€¢ outer:\n ā€¢ inner: `value`", + }, + { + name: "mixed types", + extras: map[string]interface{}{ + "string": "text", + "number": 42, + "bool": true, + }, + prefix: "", + expected: "\nā€¢ bool: `true`\nā€¢ number: `42`\nā€¢ string: `text`", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder.Reset() + formatExtras(&builder, tt.extras, tt.prefix, &logger) + assert.Equal(t, tt.expected, builder.String()) + }) + } +} + +func TestFormatMessageForTelegram(t *testing.T) { + logger := zerolog.New(zerolog.NewTestWriter(t)) + + tests := []struct { + name string + message api.Message + formatOpts config.MessageFormatOptions + expected string + }{ + { + name: "basic message", + message: api.Message{ + Title: "Test Title", + Message: "Test Message", + AppName: "TestApp", + }, + formatOpts: config.MessageFormatOptions{ + IncludeAppName: true, + IncludeTimestamp: false, + }, + expected: "*\\[TestApp\\] Test Title*\n\nTest Message\n", + }, + { + name: "message with priority", + message: api.Message{ + Title: "Priority Test", + Message: "Important Message", + Priority: 8, + }, + formatOpts: config.MessageFormatOptions{ + IncludeAppName: false, + IncludePriority: true, + PriorityThreshold: 5, + }, + expected: "*Priority Test*\n\nImportant Message\n\nšŸ”“ Critical Priority", + }, + { + name: "message with extras", + message: api.Message{ + Title: "Extras Test", + Message: "Test with extras", + Extras: map[string]interface{}{ + "key": "value", + }, + }, + formatOpts: config.MessageFormatOptions{ + IncludeExtras: true, + }, + expected: "*Extras Test*\n\nTest with extras\n\n*Additional Info:*\nā€¢ key: `value`", + }, + { + name: "message with markdown", + message: api.Message{ + Title: "Markdown Test", + Message: "[link](https://example.com) and ![](image.jpg)", + }, + formatOpts: config.MessageFormatOptions{}, + expected: "*Markdown Test*\n\n[link](https://example.com) and image.jpg\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatMessageForTelegram(tt.message, tt.formatOpts, &logger) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestGetPriorityIndicator(t *testing.T) { + tests := []struct { + name string + priority int + expected string + }{ + { + name: "critical priority", + priority: 8, + expected: "šŸ”“ Critical Priority", + }, + { + name: "high priority", + priority: 6, + expected: "šŸŸ  High Priority", + }, + { + name: "medium priority", + priority: 4, + expected: "šŸŸ” Medium Priority", + }, + { + name: "low priority", + priority: 2, + expected: "šŸŸ¢ Low Priority", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getPriorityIndicator(tt.priority) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatTitle(t *testing.T) { + tests := []struct { + name string + message api.Message + expected string + }{ + { + name: "basic title", + message: api.Message{ + AppName: "TestApp", + Title: "Test Title", + }, + expected: "[TestApp] Test Title", + }, + { + name: "empty app name", + message: api.Message{ + AppName: "", + Title: "Test Title", + }, + expected: "[] Test Title", + }, + { + name: "empty title", + message: api.Message{ + AppName: "TestApp", + Title: "", + }, + expected: "[TestApp] ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatTitle(tt.message) + assert.Equal(t, tt.expected, result) + }) + } +} diff --git a/internal/utils/utils.go b/internal/utils/utils.go new file mode 100644 index 0000000..f333e95 --- /dev/null +++ b/internal/utils/utils.go @@ -0,0 +1,9 @@ +package utils + +// MaskToken masks the token +func MaskToken(token string) string { + if len(token) <= 8 { + return "***" + } + return token[:4] + "..." + token[len(token)-4:] +} diff --git a/plugin.go b/plugin.go index e0cfff9..84b3e68 100644 --- a/plugin.go +++ b/plugin.go @@ -1,46 +1,338 @@ package main import ( - "github.com/gin-gonic/gin" + "context" + "embed" + "errors" + "fmt" + "net/url" + "os" + "os/signal" + "syscall" + + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/api" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/logger" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/telegram" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/utils" "github.com/gotify/plugin-api" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +var ( + //go:embed README.md + content embed.FS + Version string = "dev" ) // GetGotifyPluginInfo returns gotify plugin info. func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ - ModulePath: "github.com/gotify/plugin-template", - Version: "1.0.0", - Author: "Your Name", + ModulePath: "github.com/0xPeterSatoshi/gotify-to-telegram", + Version: Version, + Author: "0xPeterSatoshi", Website: "https://gotify.net/docs/plugin", - Description: "An example plugin with travis-ci building", + Description: "Send gotify notifications to telegram", License: "MIT", - Name: "gotify/plugin-template", + Name: "gotify-to-telegram", } } -// MyPlugin is the gotify plugin instance. -type MyPlugin struct { +// Plugin is the gotify plugin instance. +type Plugin struct { + enabled bool + msgHandler plugin.MessageHandler + userCtx plugin.UserContext + ctx context.Context + cancel context.CancelFunc + logger *zerolog.Logger + apiclient *api.Client + tgclient *telegram.Client + config *config.Plugin + messages chan api.Message + errChan chan error } // Enable enables the plugin. -func (c *MyPlugin) Enable() error { +func (p *Plugin) Enable() error { + p.enabled = true + p.logger.Info().Msg("enabling plugin and starting services") + go p.Start() return nil } // Disable disables the plugin. -func (c *MyPlugin) Disable() error { +func (p *Plugin) Disable() error { + p.enabled = false + p.logger.Debug().Msg("disabling plugin") + p.cancel() + return nil } -// RegisterWebhook implements plugin.Webhooker. -func (c *MyPlugin) RegisterWebhook(basePath string, g *gin.RouterGroup) { +func (p *Plugin) getTelegramBotConfigForAppID(appID uint32) config.TelegramBot { + if p.config != nil { + for _, bot := range p.config.Settings.Telegram.Bots { + for _, appid := range bot.AppIDs { + if appid == appID { + return bot + } + } + } + } + + // Fallback to default if app id not found for bot config + p.logger.Warn(). + Uint32("app_id", appID). + Msgf("no rule found for app_id: %d. Using default config", appID) + return config.TelegramBot{ + Token: p.config.Settings.Telegram.DefaultBotToken, + ChatIDs: p.config.Settings.Telegram.DefaultChatIDs, + } +} + +func (p *Plugin) handleMessage(msg api.Message) { + p.logger.Debug(). + Str("app_name", msg.AppName). + Uint32("app_id", msg.AppID). + Msg("handling message") + + config := p.getTelegramBotConfigForAppID(msg.AppID) + if config.MessageFormatOptions == nil { + config.MessageFormatOptions = &p.config.Settings.Telegram.MessageFormatOptions + } + + p.logger.Debug(). + Str("bot_token", utils.MaskToken(config.Token)). + Strs("chat_id", config.ChatIDs). + Msg("using telegram config") + + for _, chatID := range config.ChatIDs { + go p.tgclient.Send(msg, config.Token, chatID, *config.MessageFormatOptions) + } +} + +// Start starts the plugin. +func (p *Plugin) Start() error { + p.logger.Info().Msg("starting plugin services") + + if p.apiclient == nil { + p.errChan <- errors.New("api client is not initialized") + } else { + p.logger.Debug().Msg("starting api client") + go p.apiclient.Start() + } + + for { + select { + case <-p.ctx.Done(): + p.logger.Info().Msg("stopping services") + return nil + + case err := <-p.errChan: + if err != nil { + p.logger.Error().Err(err).Msg("error received") + } + + case msg := <-p.messages: + p.logger.Debug(). + Interface("message", msg). + Msg("message received from gotify server") + p.handleMessage(msg) + } + } +} + +// SetMessageHandler implements plugin.Messenger +// Invoked during initialization +func (p *Plugin) SetMessageHandler(handler plugin.MessageHandler) { + p.msgHandler = handler +} + +// GetDisplay implements plugin.Displayer +// Invoked when the user views the plugin settings. Plugins do not need to be enabled to handle GetDisplay calls. +func (p *Plugin) GetDisplay(location *url.URL) string { + readme, err := content.ReadFile("README.md") + if err != nil { + p.logger.Error().Err(err).Msg("failed to read README.md") + return "Gotify to Telegram plugin - forwards Gotify messages to Telegram bots based on configurable routing rules." + } + + return string(readme) +} + +// DefaultConfig implements plugin.Configurer +// The default configuration will be provided to the user for future editing. Also used for Unmarshaling. +// Invoked whenever an unmarshaling is required. +func (p *Plugin) DefaultConfig() interface{} { + cfg := config.CreateDefaultPluginConfig() + + if !cfg.Settings.IgnoreEnvVars { + if err := config.MergeWithEnvVars(cfg); err != nil { + p.logger.Error().Err(err).Msg("failed to merge with env vars") + } + } + + if err := cfg.Validate(); err != nil { + p.logger.Error().Err(err).Msg("failed to validate default config") + } + + return cfg +} + +// ValidateAndSetConfig will be called every time the plugin is initialized or the configuration has been changed by the user. +// Plugins should check whether the configuration is valid and optionally return an error. +// Parameter is guaranteed to be the same type as the return type of DefaultConfig() +func (p *Plugin) ValidateAndSetConfig(newConfig interface{}) error { + pluginCfg, ok := newConfig.(*config.Plugin) + if !ok { + return fmt.Errorf("invalid config type: expected *config.Config, got %T", newConfig) + } + + if err := pluginCfg.Validate(); err != nil { + return err + } + + if !pluginCfg.Settings.IgnoreEnvVars { + p.logger.Info().Msg("merging config with env vars. Any env vars defined will override yaml config") + // Env vars take precedence over yaml config + if err := config.MergeWithEnvVars(pluginCfg); err != nil { + return err + } + + p.logger.Debug().Msg("re-validating config") + // re-validate after merging with env vars + if err := pluginCfg.Validate(); err != nil { + return err + } + } + + p.logger.Info().Msg("validated and setting new config") + p.config = pluginCfg + + if p.enabled { + p.logger.Info().Msg("plugin is enabled. Cancelling existing goroutines") + // Stop existing goroutines + p.cancel() + } + + updatedLogger := p.logger.Level(pluginCfg.Settings.LogOptions.GetZerologLevel()) + p.logger = &updatedLogger + + p.logger.Debug().Msg("creating new context") + ctx, cancel := context.WithCancel(context.Background()) + p.ctx = ctx + p.cancel = cancel + + if err := p.updateAPIConfig(ctx); err != nil { + return err + } + + if err := p.updateTelegramConfig(); err != nil { + return err + } + + if p.enabled { + p.logger.Info().Msg("plugin is enabled. Starting new goroutines") + go p.Start() + } + + return nil +} + +func (p *Plugin) updateAPIConfig(ctx context.Context) error { + apiConfig := api.Config{ + Url: p.config.Settings.GotifyServer.Url, + ClientToken: p.config.Settings.GotifyServer.ClientToken, + HandshakeTimeout: p.config.Settings.GotifyServer.Websocket.HandshakeTimeout, + Messages: p.messages, + ErrChan: p.errChan, + } + + p.logger.Debug().Msg("creating api client with new config") + apiclient := api.NewClient(ctx, apiConfig) + p.apiclient = apiclient + + return nil +} + +func (p *Plugin) updateTelegramConfig() error { + p.logger.Debug().Msg("updating telegram client") + p.tgclient = telegram.NewClient(p.errChan) + return nil } // NewGotifyPluginInstance creates a plugin instance for a user context. -func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin { - return &MyPlugin{} +func NewGotifyPluginInstance(userCtx plugin.UserContext) plugin.Plugin { + ctx, cancel := context.WithCancel(context.Background()) + log := logger.Init("gotify-to-telegram", Version, userCtx) + + messages := make(chan api.Message, 100) + errChan := make(chan error, 100) + + cfg, err := config.ParseEnvVars() + if err != nil { + log.Error().Err(err).Msg("failed to parse env vars. Using defaults") + cfg = config.CreateDefaultPluginConfig() + } + + logLevel := cfg.Settings.LogOptions.GetZerologLevel() + logger.UpdateLogLevel(logLevel) + + apiConfig := api.Config{ + Url: cfg.Settings.GotifyServer.Url, + ClientToken: cfg.Settings.GotifyServer.ClientToken, + HandshakeTimeout: cfg.Settings.GotifyServer.Websocket.HandshakeTimeout, + Messages: messages, + ErrChan: errChan, + } + apiclient := api.NewClient(ctx, apiConfig) + tgclient := telegram.NewClient(errChan) + + log.Info().Msg("creating new plugin instance") + + return &Plugin{ + userCtx: userCtx, + ctx: ctx, + cancel: cancel, + config: cfg, + logger: log, + apiclient: apiclient, + tgclient: tgclient, + messages: messages, + errChan: errChan, + } } func main() { - panic("this should be built as go plugin") + ctx := plugin.UserContext{ + ID: 1, + Name: "0xPeterSatoshi", + Admin: true, + } + p := NewGotifyPluginInstance(ctx) + if err := p.Enable(); err != nil { + panic(err) + } + + logger := log.Output(zerolog.ConsoleWriter{Out: os.Stdout}).With(). + Str("plugin", "gotify-to-telegram"). + Uint("user_id", ctx.ID). + Str("user_name", ctx.Name). + Bool("is_admin", ctx.Admin). + Logger() + + // Create channel to listen for interrupt signals + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + // Block until we receive a signal + <-sigChan + + // Clean shutdown + if err := p.Disable(); err != nil { + logger.Error().Err(err).Msg("failed to disable plugin") + } + logger.Info().Msg("shutdown complete") } diff --git a/plugin_test.go b/plugin_test.go index f77502e..a154c6f 100644 --- a/plugin_test.go +++ b/plugin_test.go @@ -3,11 +3,322 @@ package main import ( "testing" + "github.com/0xPeterSatoshi/gotify-to-telegram/internal/config" "github.com/gotify/plugin-api" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" ) +// Mock structs +type MockAPI struct { + mock.Mock +} + +type MockTelegram struct { + mock.Mock +} + func TestAPICompatibility(t *testing.T) { - assert.Implements(t, (*plugin.Plugin)(nil), new(MyPlugin)) + assert.Implements(t, (*plugin.Plugin)(nil), new(Plugin)) // Add other interfaces you intend to implement here } + +func TestPluginStruct_DefaultConfig(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expectError bool + validateCalls int + }{ + { + name: "successful default config", + envVars: map[string]string{}, + expectError: false, + validateCalls: 1, + }, + { + name: "valid env vars override", + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "http://example.com", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "token123", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "bot123", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "chat1,chat2", + }, + expectError: false, + validateCalls: 1, + }, + { + name: "invalid config after env vars", + envVars: map[string]string{ + "TG_PLUGIN__GOTIFY_URL": "invalid://url", + }, + expectError: true, + validateCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + for k := range tt.envVars { + t.Setenv(k, "") + } + + // Set test environment + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + // Create plugin instance + logger := zerolog.New(zerolog.NewTestWriter(t)) + p := &Plugin{ + logger: &logger, + } + + // Get default config + cfg := p.DefaultConfig() + + // Verify the config + assert.NotNil(t, cfg) + assert.IsType(t, &config.Plugin{}, cfg) + + // Verify specific values based on environment + pluginCfg := cfg.(*config.Plugin) + if len(tt.envVars) > 0 { + // Check if environment variables were applied + if v, ok := tt.envVars["TG_PLUGIN__LOG_LEVEL"]; ok { + assert.Equal(t, v, pluginCfg.Settings.LogOptions.LogLevel) + } + } + }) + } +} + +func TestPluginStruct_ValidateAndSetConfig(t *testing.T) { + tests := []struct { + name string + userConfig *config.Plugin + wantConfig *config.Plugin + envVars map[string]string + wantError bool + validateCalls int + }{ + { + name: "should create a config where the env vars take precedence", + userConfig: &config.Plugin{ + Settings: config.Settings{ + LogOptions: config.LogOptions{ + LogLevel: "info", + }, + Telegram: config.Telegram{ + DefaultBotToken: "user-provided-token", + DefaultChatIDs: []string{"chat123", "chat456"}, + Bots: map[string]config.TelegramBot{ + "bot1": { + Token: "bot1-token", + ChatIDs: []string{"chat123", "chat456"}, + AppIDs: []uint32{1, 2}, + }, + "bot2": { + Token: "bot2-token", + ChatIDs: []string{"chat789"}, + AppIDs: []uint32{3, 4}, + }, + }, + }, + GotifyServer: config.GotifyServer{ + RawUrl: "http://mydomain.com", + ClientToken: "token123", + }, + }, + }, + wantConfig: &config.Plugin{ + Settings: config.Settings{ + LogOptions: config.LogOptions{ + LogLevel: "debug", + }, + Telegram: config.Telegram{ + DefaultBotToken: "bot123", + DefaultChatIDs: []string{"chat1", "chat2"}, + Bots: map[string]config.TelegramBot{ + "bot1": { + Token: "bot1-token", + ChatIDs: []string{"chat123", "chat456"}, + AppIDs: []uint32{1, 2}, + }, + "bot2": { + Token: "bot2-token", + ChatIDs: []string{"chat789"}, + AppIDs: []uint32{3, 4}, + }, + }, + MessageFormatOptions: config.MessageFormatOptions{ + IncludeAppName: false, + IncludeTimestamp: false, + ParseMode: "MarkdownV2", + }, + }, + GotifyServer: config.GotifyServer{ + RawUrl: "http://example.com", + ClientToken: "token123", + }, + }, + }, + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "http://example.com", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "token123", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "bot123", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "chat1,chat2", + }, + wantError: false, + validateCalls: 1, + }, + { + name: "should create a config where the env vars are ignored", + userConfig: &config.Plugin{ + Settings: config.Settings{ + IgnoreEnvVars: true, + LogOptions: config.LogOptions{ + LogLevel: "info", + }, + Telegram: config.Telegram{ + DefaultBotToken: "user-provided-token", + DefaultChatIDs: []string{"chat123", "chat456"}, + Bots: map[string]config.TelegramBot{ + "bot1": { + Token: "bot1-token", + ChatIDs: []string{"chat123", "chat456"}, + AppIDs: []uint32{1, 2}, + }, + "bot2": { + Token: "bot2-token", + ChatIDs: []string{"chat789"}, + AppIDs: []uint32{3, 4}, + }, + }, + }, + GotifyServer: config.GotifyServer{ + RawUrl: "http://mydomain.com", + ClientToken: "token123", + }, + }, + }, + wantConfig: &config.Plugin{ + Settings: config.Settings{ + IgnoreEnvVars: true, + LogOptions: config.LogOptions{ + LogLevel: "info", + }, + Telegram: config.Telegram{ + DefaultBotToken: "user-provided-token", + DefaultChatIDs: []string{"chat123", "chat456"}, + Bots: map[string]config.TelegramBot{ + "bot1": { + Token: "bot1-token", + ChatIDs: []string{"chat123", "chat456"}, + AppIDs: []uint32{1, 2}, + }, + "bot2": { + Token: "bot2-token", + ChatIDs: []string{"chat789"}, + AppIDs: []uint32{3, 4}, + }, + }, + }, + GotifyServer: config.GotifyServer{ + RawUrl: "http://mydomain.com", + ClientToken: "token123", + }, + }, + }, + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "http://example.com", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "token123", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "bot123", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "chat1,chat2", + }, + wantError: false, + validateCalls: 1, + }, + { + name: "should return an error for invalid url", + userConfig: &config.Plugin{ + Settings: config.Settings{ + LogOptions: config.LogOptions{ + LogLevel: "info", + }, + Telegram: config.Telegram{ + DefaultBotToken: "user-provided-token", + DefaultChatIDs: []string{"chat123", "chat456"}, + Bots: map[string]config.TelegramBot{ + "bot1": { + Token: "bot1-token", + ChatIDs: []string{"chat123", "chat456"}, + AppIDs: []uint32{1, 2}, + }, + "bot2": { + Token: "bot2-token", + ChatIDs: []string{"chat789"}, + AppIDs: []uint32{3, 4}, + }, + }, + }, + GotifyServer: config.GotifyServer{ + RawUrl: "http://mydomain.com", + ClientToken: "token123", + }, + }, + }, + wantConfig: &config.Plugin{}, + envVars: map[string]string{ + "TG_PLUGIN__LOG_LEVEL": "debug", + "TG_PLUGIN__GOTIFY_URL": "example.com", + "TG_PLUGIN__GOTIFY_CLIENT_TOKEN": "token123", + "TG_PLUGIN__TELEGRAM_DEFAULT_BOT_TOKEN": "bot123", + "TG_PLUGIN__TELEGRAM_DEFAULT_CHAT_IDS": "chat1,chat2", + }, + wantError: true, + validateCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear environment + for k := range tt.envVars { + t.Setenv(k, "") + } + + // Set test environment + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + // Create plugin instance + logger := zerolog.New(zerolog.NewTestWriter(t)) + p := &Plugin{ + logger: &logger, + enabled: false, + } + + // Call ValidateAndSetConfig + err := p.ValidateAndSetConfig(tt.userConfig) + + p.logger.Debug().Interface("config", p.config).Msg("config") + tt.wantConfig.Settings.GotifyServer.Url = tt.wantConfig.Settings.GotifyServer.URL() + + // Verify the result + if tt.wantError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, p.config) + assert.Equal(t, tt.wantConfig, p.config) + } + }) + } +}