From 6b714f15424c95995650fe959f5efc6e65d460a6 Mon Sep 17 00:00:00 2001 From: leko Date: Mon, 12 Aug 2024 00:38:45 +0800 Subject: [PATCH 1/9] feat: support more channels --- .github/workflows/ci.yml | 42 +++++++++ README.md | 43 ++++++--- config.go | 64 +++++++++++++ plugin.go | 196 ++++++++++++++++++--------------------- utils.go | 71 ++++++++++++++ 5 files changed, 298 insertions(+), 118 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 config.go create mode 100644 utils.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..107d669 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: Build + +on: + push: + branches: + - master + pull_request: + branches: + - master + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + strategy: + matrix: + go-version: [1.22.x] + os: [ubuntu-latest] + + steps: + - name: Checkout source code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Install required tools + run: | + go install github.com/gotify/plugin-api/cmd/gomod-cap@latest + + - name: Build the plugin + run: | + make build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: telegram-plugin + path: build/*.so diff --git a/README.md b/README.md index 7e0c0e3..878e147 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr 4. Restart gotify. + 5. Config the plugin. + * **Build from source** 1. Change GOTIFY_VERSION in Makefile. @@ -28,6 +30,36 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr 3. Follow instructions from step 2 in the shared object installation. +## Configuration + +The configuration contains three keys: `clients`, `gotify_host` and `token`. + +### Clients + +The `clients` configuration key describes which client(channel?) we are going to listen on and which telegram channel (and topic optionally!) we are forwarding the message to. + +```yaml +clients: + - app_id: "The Gotify App ID to be matched. use -1 for all-matching." + telegram: + chat_id: "ID of the telegram chat" + token: "The bot token" + thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic." + - app_id: "Maybe the second Gotify Client Token, yay!" + telegram: + chat_id: "ID of the telegram chat" + token: "The bot token" + thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic." +``` + +### Gotify Host + +The `gotify_host` configuration key should be set to `ws://YOUR_GOTIFY_IP` (depending on your setup, `ws://localhost:80` will likely work by default) + +### Token + +The `token` configuration key should be set to a valid token that can be created in the "Clients" tab. + ## Troubleshooting 1. When only the Gotify dashboard receives your message, but not Telegram: @@ -35,14 +67,3 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr - In the BotFather chat, list your created bots and select the respective bot for which you want to change the Group Privacy setting. - Turn off the Group Privacy setting. - -## Appendix -Mandatory secrets. - -```(shell) -GOTIFY_HOST=ws://YOUR_GOTIFY_IP (depending on your setup, "ws://localhost:80" will likely work by default) -GOTIFY_CLIENT_TOKEN=YOUR_CLIENT_TOKEN (create a new Client in Gotify and use the Token from there, or you can use an existing client) -TELEGRAM_CHAT_ID=YOUR_TELEGRAM_CHAT_ID (conversation ID from the Telegram API call above) -TELEGRAM_BOT_TOKEN=YOUR_TELEGRAM_BOT_TOKEN (API token provided by BotFather) -``` - diff --git a/config.go b/config.go new file mode 100644 index 0000000..238f104 --- /dev/null +++ b/config.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" +) + +type Telegram struct { + ChatId string `yaml:"chat_id"` + BotToken string `yaml:"token"` + ThreadId string `yaml:"thread_id"` +} + +type SubClient struct { + AppId int `yaml:"app_id"` + Telegram Telegram `yaml:"telegram"` +} + +// Config is user plugin configuration +type Config struct { + Clients []SubClient `yaml:"clients"` + GotifyHost string `yaml:"gotify_host"` + GotifyClientToken string `yaml:"token"` +} + +// DefaultConfig implements plugin.Configurer +func (c *Plugin) DefaultConfig() interface{} { + return &Config{ + Clients: []SubClient{ + SubClient{ + AppId: 0, + Telegram: Telegram{ + ChatId: "-100123456789", + BotToken: "YourBotTokenHere", + ThreadId: "OptionalThreadIdHere", + }, + }, + }, + GotifyHost: "ws://localhost:80", + GotifyClientToken: "ExampleToken", + } +} + +// ValidateAndSetConfig implements plugin.Configurer +func (c *Plugin) ValidateAndSetConfig(config interface{}) error { + newConfig := config.(*Config) + + if newConfig.GotifyClientToken == "ExampleToken" { + return fmt.Errorf("gotify client token is required") + } + for i, client := range newConfig.Clients { + if client.AppId == 0 { + return fmt.Errorf("gotify app id is required for client %d", i) + } + if client.Telegram.BotToken == "" { + return fmt.Errorf("telegram bot token is required for client %d", i) + } + if client.Telegram.ChatId == "" { + return fmt.Errorf("telegram chat id is required for client %d", i) + } + } + + c.config = newConfig + return nil +} \ No newline at end of file diff --git a/plugin.go b/plugin.go index 5da81d2..b9ddbef 100644 --- a/plugin.go +++ b/plugin.go @@ -1,152 +1,134 @@ package main import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "os" - "time" + "fmt" + "net" + "time" + "github.com/gorilla/websocket" "github.com/gotify/plugin-api" - "github.com/gorilla/websocket" ) // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ - Version: "1.0", - Author: "Anh Bui", - Name: "Gotify 2 Telegram", - Description: "Telegram message fowarder for gotify", - ModulePath: "https://github.com/anhbh310/gotify2telegram", - + Version: "1.1", + Author: "Anh Bui & Leko", + Name: "Gotify 2 Telegram", + Description: "Telegram message fowarder for gotify", + ModulePath: "https://github.com/anhbh310/gotify2telegram", } } // Plugin is the plugin instance type Plugin struct { - ws *websocket.Conn; - msgHandler plugin.MessageHandler; - chatid string; - telegram_bot_token string; - gotify_host string; + config *Config + ws *websocket.Conn + msgHandler plugin.MessageHandler + isEnabled bool } type GotifyMessage struct { - Id uint32; - Appid uint32; - Message string; - Title string; - Priority uint32; - Date string; + Id uint32 + Appid uint32 + Message string + Title string + Priority uint32 + Date string } -type Payload struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` +func (p *Plugin) connect_websocket(url string) { + for { + debug("connect_websocket:: Connecting ws.") + ws, _, err := websocket.DefaultDialer.Dial(url, nil) + if err == nil { + ws.SetCloseHandler(func(code int, text string) error { + p.ws = nil + return fmt.Errorf("WebSocket Connection Closed. Resetting plugin.ws.") + }) + p.ws = ws + + // Get Local Port + if tcpConn, ok := ws.UnderlyingConn().(*net.TCPConn); ok { + localAddr := tcpConn.LocalAddr().(*net.TCPAddr) + debug("connect_websocket:: Connection Success. Port: %d", localAddr.Port) + } else { + debug("connect_websocket:: Connection Success. Underlying connection is not a TCP connection") + } + + break + } + + fmt.Printf("Cannot connect to websocket: %v\n", err) + time.Sleep(5 * time.Second) + } } -func (p *Plugin) send_msg_to_telegram(msg string) { - step_size := 4090 - sending_message := "" - for i:=0; i" + template.HTMLEscapeString(msg.Title) + "")) + return fmt.Sprintf( + "%s\n%s\n\nDate: %s", + title, + template.HTMLEscapeString(msg.Message), + msg.Date, + ) +} + +type Payload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ThreadId string `json:"message_thread_id"` + ParseMode string `json:"parse_mode"` +} + +func send_msg_to_telegram(msg string, bot_token string, chat_id string, thread_id string) { + step_size := 4090 + sending_message := "" + for i := 0; i < len(msg); i += step_size { + if i+step_size < len(msg) { + sending_message = msg[i : i+step_size] + } else { + sending_message = msg[i:len(msg)] + } + + data := Payload{ + ChatID: chat_id, + Text: sending_message, + ThreadId: thread_id, + ParseMode: "HTML", + } + payloadBytes, err := json.Marshal(data) + if err != nil { + log.Println("Create json false") + return + } + body := bytes.NewReader(payloadBytes) + + req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+bot_token+"/sendMessage", body) + if err != nil { + log.Println("Create request false") + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Send request false: %v\n", err) + return + } + defer resp.Body.Close() + } +} From 882bafb6bc7a1a7c0cb4db5632b1398b7180f139 Mon Sep 17 00:00:00 2001 From: anh Date: Thu, 21 Nov 2024 19:42:55 +0700 Subject: [PATCH 2/9] update plugin for adapting gotify 2.6.1 --- Makefile | 2 +- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Makefile b/Makefile index d490711..ba4669a 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BUILDDIR=./build -GOTIFY_VERSION=v2.5.0 +GOTIFY_VERSION=v2.6.1 PLUGIN_NAME=telegram-plugin PLUGIN_ENTRY=plugin.go GO_VERSION=`cat $(BUILDDIR)/gotify-server-go-version` diff --git a/go.mod b/go.mod index e682fbc..69fb947 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,7 @@ require ( 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.22.0 // 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.7 // indirect @@ -31,10 +31,10 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.8.0 // indirect - golang.org/x/crypto v0.24.0 // indirect + golang.org/x/crypto v0.29.0 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.16.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 d9ae90c..058565f 100644 --- a/go.sum +++ b/go.sum @@ -23,8 +23,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o 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.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -88,8 +88,8 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 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.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= -golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= @@ -97,10 +97,10 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sys v0.0.0-20190109145017-48ac38b7c8cb/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +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= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= From c9ebf50d18c7bbc7135f5d41f591ab4da27ca04f Mon Sep 17 00:00:00 2001 From: Leko Date: Sat, 28 Dec 2024 18:36:45 +0800 Subject: [PATCH 3/9] ci: use docker build instead --- .github/workflows/ci.yml | 56 ++++++++++++++++++++++++++++++++-------- Makefile | 20 +++++++------- 2 files changed, 55 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 107d669..d2806e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,28 +15,62 @@ jobs: strategy: matrix: - go-version: [1.22.x] os: [ubuntu-latest] + gotify-version: ["2.5.0", "2.6.1"] steps: - name: Checkout source code uses: actions/checkout@v4 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Install required tools - run: | - go install github.com/gotify/plugin-api/cmd/gomod-cap@latest - - - name: Build the plugin + - name: Build Plugin + env: + GOTIFY_VERSION: "${{ matrix.gotify-version }}" run: | + make download-tools make build - name: Upload build artifacts uses: actions/upload-artifact@v4 with: - name: telegram-plugin + name: build-${{ matrix.gotify-version }} path: build/*.so + release: + name: Release Plugin + runs-on: ubuntu-latest + needs: build + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Download Build Artifacts + uses: actions/download-artifact@v4 + with: + path: build + pattern: build-* + merge-multiple: true + + - run: ls -la build + + - name: Tag the repository + id: tag + run: | + # See https://docs.github.com/en/get-started/using-git/dealing-with-special-characters-in-branch-and-tag-names + TAG=v$(date -Iseconds | sed 's/[T:\+]/-/g') + TIME=$(date '+%Y/%m/%d %H:%M') + echo "$TAG" + echo "tag=$TAG" >> $GITHUB_OUTPUT + echo "time=$TIME" >> $GITHUB_OUTPUT + git config --global user.name "${GITHUB_ACTOR}" + git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" + git tag -a $TAG -m "Published version $TAG" ${GITHUB_SHA} + git push origin $TAG + + - name: Create Release + uses: softprops/action-gh-release@v2 + with: + files: build/*.so + tag_name: ${{ steps.tag.outputs.tag }} + name: ${{ steps.tag.outputs.time }} diff --git a/Makefile b/Makefile index ba4669a..3b6a174 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ BUILDDIR=./build -GOTIFY_VERSION=v2.6.1 PLUGIN_NAME=telegram-plugin PLUGIN_ENTRY=plugin.go GO_VERSION=`cat $(BUILDDIR)/gotify-server-go-version` @@ -7,32 +6,33 @@ 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 get -u github.com/gotify/plugin-api/cmd/gomod-cap 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 + GOTIFY_COMMIT=$(shell curl -s https://api.github.com/repos/gotify/server/git/ref/tags/v${GOTIFY_VERSION} | jq -r '.object.sha') && \ + wget -O ${BUILDDIR}/gotify-server.mod https://raw.githubusercontent.com/gotify/server/$${GOTIFY_COMMIT}/go.mod + go run github.com/gotify/plugin-api/cmd/gomod-cap -from ${BUILDDIR}/gotify-server.mod -to go.mod rm ${BUILDDIR}/gotify-server.mod || true go mod tidy get-gotify-server-go-version: create-build-dir - rm ${BUILDDIR}/gotify-server-go-version || true - wget -LO ${BUILDDIR}/gotify-server-go-version https://raw.githubusercontent.com/gotify/server/${GOTIFY_VERSION}/GO_VERSION + rm -f ${BUILDDIR}/gotify-server-go-version || true + GOTIFY_COMMIT=$(shell curl -s https://api.github.com/repos/gotify/server/git/ref/tags/v${GOTIFY_VERSION} | jq -r '.object.sha') && \ + wget -O ${BUILDDIR}/gotify-server-go-version https://raw.githubusercontent.com/gotify/server/$${GOTIFY_COMMIT}/GO_VERSION build-linux-amd64: get-gotify-server-go-version update-go-mod - ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-amd64${FILE_SUFFIX}.so ${DOCKER_WORKDIR} + ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-amd64 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-amd64-v${GOTIFY_VERSION}${FILE_SUFFIX}.so ${DOCKER_WORKDIR} build-linux-arm-7: get-gotify-server-go-version update-go-mod - ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm-7 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-arm-7${FILE_SUFFIX}.so ${DOCKER_WORKDIR} + ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm-7 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-arm-7-v${GOTIFY_VERSION}${FILE_SUFFIX}.so ${DOCKER_WORKDIR} build-linux-arm64: get-gotify-server-go-version update-go-mod - ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm64 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-arm64${FILE_SUFFIX}.so ${DOCKER_WORKDIR} + ${DOCKER_RUN} ${DOCKER_BUILD_IMAGE}:$(GO_VERSION)-linux-arm64 ${DOCKER_GO_BUILD} -o ${BUILDDIR}/${PLUGIN_NAME}-linux-arm64-v${GOTIFY_VERSION}${FILE_SUFFIX}.so ${DOCKER_WORKDIR} build: build-linux-arm-7 build-linux-amd64 build-linux-arm64 From 80c9bd4c8f21500dac81ecb0c32e2be030a58243 Mon Sep 17 00:00:00 2001 From: Leko Date: Wed, 15 Oct 2025 00:20:55 +0800 Subject: [PATCH 4/9] feat: support discord webhook --- README.md | 24 ++++++-- config.go | 48 +++++++++++++--- plugin.go | 34 ++++++++--- utils.go | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 878e147..41bbe2d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# Gotify 2 Telegram -This Gotify plugin forwards all received messages to Telegram through the Telegram bot. +# Gotify 2 Telegram (and Discord) +This Gotify plugin forwards received messages to Telegram and/or Discord. ## Prerequisite - A Telegram bot, bot token, and chat ID from bot conversation. You can get that information by following this [blog](https://medium.com/linux-shots/setup-telegram-bot-to-get-alert-notifications-90be7da4444). @@ -8,7 +8,7 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr ## Installation * **By shared object** - 1. Get the compatible shared object from [release](https://github.com/anhbh310/gotify2telegram/releases). + 1. Get the compatible shared object from [release](https://github.com/lekoOwO/gotify2telegram/releases). 2. Put it into Gotify plugin folder. @@ -32,7 +32,9 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr ## Configuration -The configuration contains three keys: `clients`, `gotify_host` and `token`. +The configuration contains four keys: `clients`, `gotify_host`, `token` and `discord`. + +This plugin supports sending to Telegram, Discord, or both. Each `SubClient` may independently enable Telegram and/or Discord. ### Clients @@ -45,6 +47,10 @@ clients: chat_id: "ID of the telegram chat" token: "The bot token" thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic." + discord: + webhook_url: "https://discord.com/api/webhooks/..." + username: "Optional per-client username (falls back to global discord defaults if empty)" + avatar_url: "Optional per-client avatar URL (falls back to global defaults if empty)" - app_id: "Maybe the second Gotify Client Token, yay!" telegram: chat_id: "ID of the telegram chat" @@ -52,6 +58,16 @@ clients: thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic." ``` +### Global Discord defaults + +You can set global Discord defaults (used when per-client username/avatar are empty): + +```yaml +discord: + username: "GotifyBot" + avatar_url: "https://example.com/avatar.png" +``` + ### Gotify Host The `gotify_host` configuration key should be set to `ws://YOUR_GOTIFY_IP` (depending on your setup, `ws://localhost:80` will likely work by default) diff --git a/config.go b/config.go index 238f104..cb98413 100644 --- a/config.go +++ b/config.go @@ -10,16 +10,28 @@ type Telegram struct { ThreadId string `yaml:"thread_id"` } +type DiscordDefaults struct { + Username string `yaml:"username"` + AvatarURL string `yaml:"avatar_url"` +} + +type Discord struct { + WebhookURL string `yaml:"webhook_url"` + DiscordDefaults +} + type SubClient struct { AppId int `yaml:"app_id"` Telegram Telegram `yaml:"telegram"` + Discord Discord `yaml:"discord"` } // Config is user plugin configuration type Config struct { - Clients []SubClient `yaml:"clients"` - GotifyHost string `yaml:"gotify_host"` - GotifyClientToken string `yaml:"token"` + Clients []SubClient `yaml:"clients"` + GotifyHost string `yaml:"gotify_host"` + GotifyClientToken string `yaml:"token"` + DiscordDefaults DiscordDefaults `yaml:"discord"` } // DefaultConfig implements plugin.Configurer @@ -33,8 +45,19 @@ func (c *Plugin) DefaultConfig() interface{} { BotToken: "YourBotTokenHere", ThreadId: "OptionalThreadIdHere", }, + Discord: Discord{ + WebhookURL: "", + DiscordDefaults: DiscordDefaults{ + Username: "DefaultUsername", + AvatarURL: "DefaultAvatarURL", + }, + }, }, }, + DiscordDefaults: DiscordDefaults{ + Username: "DefaultUsername", + AvatarURL: "DefaultAvatarURL", + }, GotifyHost: "ws://localhost:80", GotifyClientToken: "ExampleToken", } @@ -51,11 +74,22 @@ func (c *Plugin) ValidateAndSetConfig(config interface{}) error { if client.AppId == 0 { return fmt.Errorf("gotify app id is required for client %d", i) } - if client.Telegram.BotToken == "" { - return fmt.Errorf("telegram bot token is required for client %d", i) + // Require at least one destination: Telegram or Discord + if client.Telegram.BotToken == "" && client.Discord.WebhookURL == "" { + return fmt.Errorf("either telegram or discord must be configured for client %d", i) + } + + if client.Telegram.BotToken != "" { + if client.Telegram.ChatId == "" { + return fmt.Errorf("telegram chat id is required for client %d", i) + } } - if client.Telegram.ChatId == "" { - return fmt.Errorf("telegram chat id is required for client %d", i) + + if client.Discord.WebhookURL != "" { + // very basic validation + if len(client.Discord.WebhookURL) < 8 { + return fmt.Errorf("discord webhook url seems invalid for client %d", i) + } } } diff --git a/plugin.go b/plugin.go index b9ddbef..3067b25 100644 --- a/plugin.go +++ b/plugin.go @@ -15,8 +15,8 @@ func GetGotifyPluginInfo() plugin.Info { Version: "1.1", Author: "Anh Bui & Leko", Name: "Gotify 2 Telegram", - Description: "Telegram message fowarder for gotify", - ModulePath: "https://github.com/anhbh310/gotify2telegram", + Description: "Forward Gotify messages to Telegram and Discord", + ModulePath: "https://github.com/lekoOwO/gotify2telegram", } } @@ -91,12 +91,30 @@ func (p *Plugin) get_websocket_msg(url string) { for _, subClient := range p.config.Clients { if subClient.AppId == int(msg.Appid) || subClient.AppId == -1 { debug("get_websocket_msg: AppId Matched! Sending to telegram...") - send_msg_to_telegram( - format_telegram_message(msg), - subClient.Telegram.BotToken, - subClient.Telegram.ChatId, - subClient.Telegram.ThreadId, - ) + // Send to Telegram if configured + if subClient.Telegram.BotToken != "" { + send_msg_to_telegram( + format_telegram_message(msg), + subClient.Telegram.BotToken, + subClient.Telegram.ChatId, + subClient.Telegram.ThreadId, + ) + } + + // Send to Discord if configured + if subClient.Discord.WebhookURL != "" { + username := subClient.Discord.Username + avatar := subClient.Discord.AvatarURL + // fallback to global defaults if empty + if username == "" && p.config != nil { + username = p.config.DiscordDefaults.Username + } + if avatar == "" && p.config != nil { + avatar = p.config.DiscordDefaults.AvatarURL + } + embeds := format_discord_embeds(msg) + send_msg_to_discord(embeds, subClient.Discord.WebhookURL, username, avatar) + } break } } diff --git a/utils.go b/utils.go index 8399cf7..45169c0 100644 --- a/utils.go +++ b/utils.go @@ -7,6 +7,9 @@ import ( "html/template" "log" "net/http" + "strconv" + "strings" + "time" ) func debug(s string, x ...interface{}) { @@ -69,3 +72,167 @@ func send_msg_to_telegram(msg string, bot_token string, chat_id string, thread_i defer resp.Body.Close() } } + +// DiscordPayload represents a Discord webhook payload that can include embeds. +type DiscordPayload struct { + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Content string `json:"content,omitempty"` + Embeds []DiscordEmbed `json:"embeds,omitempty"` +} + +type DiscordEmbed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Color int `json:"color,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Footer *DiscordEmbedFooter `json:"footer,omitempty"` +} + +type DiscordEmbedFooter struct { + Text string `json:"text,omitempty"` +} + +// format_discord_embeds builds one or more embeds from GotifyMessage. +// It will split long descriptions into multiple embeds if necessary. +func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed { + title := template.HTMLEscapeString(msg.Title) + body := template.HTMLEscapeString(msg.Message) + + // decide color by priority (example mapping) + color := 0x2ECC71 // green default + switch msg.Priority { + case 5: + color = 0xFF0000 // red + case 4: + color = 0xFFA500 // orange + case 3: + color = 0xFFFF00 // yellow + case 2: + color = 0x3498DB // blue + } + + // Discord embed description limit is 4096 chars; split into chunks safely + maxDesc := 3800 + runes := []rune(body) + var embeds []DiscordEmbed + for i := 0; i < len(runes); i += maxDesc { + end := i + maxDesc + if end > len(runes) { + end = len(runes) + } + desc := string(runes[i:end]) + embed := DiscordEmbed{ + Title: title, + Description: desc, + Color: color, + Timestamp: time.Now().UTC().Format(time.RFC3339), + Footer: &DiscordEmbedFooter{ + Text: fmt.Sprintf("Gotify Id: %d | Date: %s", msg.Id, msg.Date), + }, + } + // For subsequent chunks, omit the title to avoid repetition + if i > 0 { + embed.Title = "" + } + embeds = append(embeds, embed) + } + return embeds +} + +// send_msg_to_discord posts embeds to a Discord webhook. It will send multiple requests if given multiple embeds. +func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username string, avatarURL string) { + if webhookURL == "" { + return + } + + // Discord allows multiple embeds in one payload; however to keep payload sizes safe + // we will send up to 5 embeds per request (Discord limit is 10 embeds per request). + maxEmbedsPerRequest := 5 + client := &http.Client{Timeout: 10 * time.Second} + for start := 0; start < len(embeds); start += maxEmbedsPerRequest { + end := start + maxEmbedsPerRequest + if end > len(embeds) { + end = len(embeds) + } + + payload := DiscordPayload{ + Username: username, + AvatarURL: avatarURL, + Embeds: embeds[start:end], + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + log.Println("Create discord json false") + return + } + body := bytes.NewReader(payloadBytes) + + req, err := http.NewRequest("POST", webhookURL, body) + if err != nil { + log.Println("Create discord request false") + return + } + req.Header.Set("Content-Type", "application/json") + + // Retry loop with exponential backoff and special handling for 429 + var resp *http.Response + var attempt int + maxRetries := 5 + for attempt = 0; attempt <= maxRetries; attempt++ { + resp, err = client.Do(req) + if err != nil { + // network error, retry + backoff := time.Duration(1<= 500 && resp.StatusCode < 600 { + resp.Body.Close() + backoff := time.Duration(1< maxRetries { + fmt.Printf("Send discord request failed after %d attempts\n", maxRetries) + } + } +} From 12397052c610a6b8afd83ad50cb40c3420bca55e Mon Sep 17 00:00:00 2001 From: Leko Date: Wed, 15 Oct 2025 00:46:46 +0800 Subject: [PATCH 5/9] ci: Build for version 2.7.3 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d2806e7..8008808 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - gotify-version: ["2.5.0", "2.6.1"] + gotify-version: ["2.5.0", "2.6.1", "2.7.3"] steps: - name: Checkout source code From fa9655739abe915d346092ac2fe8a485659a9be6 Mon Sep 17 00:00:00 2001 From: Leko Date: Thu, 16 Oct 2025 01:23:19 +0800 Subject: [PATCH 6/9] feat: modify discord embeds format --- utils.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils.go b/utils.go index 45169c0..2e6355b 100644 --- a/utils.go +++ b/utils.go @@ -126,9 +126,9 @@ func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed { Title: title, Description: desc, Color: color, - Timestamp: time.Now().UTC().Format(time.RFC3339), + Timestamp: msg.Date, Footer: &DiscordEmbedFooter{ - Text: fmt.Sprintf("Gotify Id: %d | Date: %s", msg.Id, msg.Date), + Text: fmt.Sprintf("Gotify Id: %d", msg.Id), }, } // For subsequent chunks, omit the title to avoid repetition From d42854f71bf82aa0c8fdff70768c93ebc31cc299 Mon Sep 17 00:00:00 2001 From: Leko Date: Thu, 16 Oct 2025 02:08:18 +0800 Subject: [PATCH 7/9] fix: do not sanatize discord embeds as it is always parsed as markdown --- utils.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/utils.go b/utils.go index 2e6355b..269bd1d 100644 --- a/utils.go +++ b/utils.go @@ -96,8 +96,8 @@ type DiscordEmbedFooter struct { // format_discord_embeds builds one or more embeds from GotifyMessage. // It will split long descriptions into multiple embeds if necessary. func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed { - title := template.HTMLEscapeString(msg.Title) - body := template.HTMLEscapeString(msg.Message) + title := msg.Title + body := msg.Message // decide color by priority (example mapping) color := 0x2ECC71 // green default From 7aa6ce21d2f65d0844df81582ce6860bf75ab86f Mon Sep 17 00:00:00 2001 From: Leko Date: Thu, 16 Oct 2025 03:21:25 +0800 Subject: [PATCH 8/9] chore: modify plugin info --- plugin.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugin.go b/plugin.go index 3067b25..c5900b8 100644 --- a/plugin.go +++ b/plugin.go @@ -12,9 +12,9 @@ import ( // GetGotifyPluginInfo returns gotify plugin info func GetGotifyPluginInfo() plugin.Info { return plugin.Info{ - Version: "1.1", - Author: "Anh Bui & Leko", - Name: "Gotify 2 Telegram", + Version: "2.0", + Author: "Leko", + Name: "SNS Forwarder", Description: "Forward Gotify messages to Telegram and Discord", ModulePath: "https://github.com/lekoOwO/gotify2telegram", } From 0893f7e9cbffccab55217b2428ee1bdbc944c2ab Mon Sep 17 00:00:00 2001 From: Leko Date: Sat, 18 Oct 2025 08:05:10 +0800 Subject: [PATCH 9/9] feat: make send to discord more robust --- config.go | 196 ++++++++++----------- discord_utils.go | 441 ++++++++++++++++++++++++++++++++++++++++++++++ telegram_utils.go | 68 +++++++ utils.go | 247 +------------------------- utils_test.go | 189 ++++++++++++++++++++ 5 files changed, 805 insertions(+), 336 deletions(-) create mode 100644 discord_utils.go create mode 100644 telegram_utils.go create mode 100644 utils_test.go diff --git a/config.go b/config.go index cb98413..e56b63e 100644 --- a/config.go +++ b/config.go @@ -1,98 +1,98 @@ -package main - -import ( - "fmt" -) - -type Telegram struct { - ChatId string `yaml:"chat_id"` - BotToken string `yaml:"token"` - ThreadId string `yaml:"thread_id"` -} - -type DiscordDefaults struct { - Username string `yaml:"username"` - AvatarURL string `yaml:"avatar_url"` -} - -type Discord struct { - WebhookURL string `yaml:"webhook_url"` - DiscordDefaults -} - -type SubClient struct { - AppId int `yaml:"app_id"` - Telegram Telegram `yaml:"telegram"` - Discord Discord `yaml:"discord"` -} - -// Config is user plugin configuration -type Config struct { - Clients []SubClient `yaml:"clients"` - GotifyHost string `yaml:"gotify_host"` - GotifyClientToken string `yaml:"token"` - DiscordDefaults DiscordDefaults `yaml:"discord"` -} - -// DefaultConfig implements plugin.Configurer -func (c *Plugin) DefaultConfig() interface{} { - return &Config{ - Clients: []SubClient{ - SubClient{ - AppId: 0, - Telegram: Telegram{ - ChatId: "-100123456789", - BotToken: "YourBotTokenHere", - ThreadId: "OptionalThreadIdHere", - }, - Discord: Discord{ - WebhookURL: "", - DiscordDefaults: DiscordDefaults{ - Username: "DefaultUsername", - AvatarURL: "DefaultAvatarURL", - }, - }, - }, - }, - DiscordDefaults: DiscordDefaults{ - Username: "DefaultUsername", - AvatarURL: "DefaultAvatarURL", - }, - GotifyHost: "ws://localhost:80", - GotifyClientToken: "ExampleToken", - } -} - -// ValidateAndSetConfig implements plugin.Configurer -func (c *Plugin) ValidateAndSetConfig(config interface{}) error { - newConfig := config.(*Config) - - if newConfig.GotifyClientToken == "ExampleToken" { - return fmt.Errorf("gotify client token is required") - } - for i, client := range newConfig.Clients { - if client.AppId == 0 { - return fmt.Errorf("gotify app id is required for client %d", i) - } - // Require at least one destination: Telegram or Discord - if client.Telegram.BotToken == "" && client.Discord.WebhookURL == "" { - return fmt.Errorf("either telegram or discord must be configured for client %d", i) - } - - if client.Telegram.BotToken != "" { - if client.Telegram.ChatId == "" { - return fmt.Errorf("telegram chat id is required for client %d", i) - } - } - - if client.Discord.WebhookURL != "" { - // very basic validation - if len(client.Discord.WebhookURL) < 8 { - return fmt.Errorf("discord webhook url seems invalid for client %d", i) - } - } - } - - c.config = newConfig - return nil -} \ No newline at end of file +package main + +import ( + "fmt" +) + +type Telegram struct { + ChatId string `yaml:"chat_id"` + BotToken string `yaml:"token"` + ThreadId string `yaml:"thread_id"` +} + +type DiscordDefaults struct { + Username string `yaml:"username"` + AvatarURL string `yaml:"avatar_url"` +} + +type Discord struct { + WebhookURL string `yaml:"webhook_url"` + DiscordDefaults +} + +type SubClient struct { + AppId int `yaml:"app_id"` + Telegram Telegram `yaml:"telegram"` + Discord Discord `yaml:"discord"` +} + +// Config is user plugin configuration +type Config struct { + Clients []SubClient `yaml:"clients"` + GotifyHost string `yaml:"gotify_host"` + GotifyClientToken string `yaml:"token"` + DiscordDefaults DiscordDefaults `yaml:"discord"` +} + +// DefaultConfig implements plugin.Configurer +func (c *Plugin) DefaultConfig() interface{} { + return &Config{ + Clients: []SubClient{ + SubClient{ + AppId: 0, + Telegram: Telegram{ + ChatId: "-100123456789", + BotToken: "YourBotTokenHere", + ThreadId: "OptionalThreadIdHere", + }, + Discord: Discord{ + WebhookURL: "", + DiscordDefaults: DiscordDefaults{ + Username: "DefaultUsername", + AvatarURL: "DefaultAvatarURL", + }, + }, + }, + }, + DiscordDefaults: DiscordDefaults{ + Username: "DefaultUsername", + AvatarURL: "DefaultAvatarURL", + }, + GotifyHost: "ws://localhost:80", + GotifyClientToken: "ExampleToken", + } +} + +// ValidateAndSetConfig implements plugin.Configurer +func (c *Plugin) ValidateAndSetConfig(config interface{}) error { + newConfig := config.(*Config) + + if newConfig.GotifyClientToken == "ExampleToken" { + return fmt.Errorf("gotify client token is required") + } + for i, client := range newConfig.Clients { + if client.AppId == 0 { + return fmt.Errorf("gotify app id is required for client %d", i) + } + // Require at least one destination: Telegram or Discord + if client.Telegram.BotToken == "" && client.Discord.WebhookURL == "" { + return fmt.Errorf("either telegram or discord must be configured for client %d", i) + } + + if client.Telegram.BotToken != "" { + if client.Telegram.ChatId == "" { + return fmt.Errorf("telegram chat id is required for client %d", i) + } + } + + if client.Discord.WebhookURL != "" { + // very basic validation + if len(client.Discord.WebhookURL) < 8 { + return fmt.Errorf("discord webhook url seems invalid for client %d", i) + } + } + } + + c.config = newConfig + return nil +} diff --git a/discord_utils.go b/discord_utils.go new file mode 100644 index 0000000..744370f --- /dev/null +++ b/discord_utils.go @@ -0,0 +1,441 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "regexp" + "strconv" + "strings" + "time" +) + +// --------------------------------------------------------------------------- +// Embed building helpers (moved from utils.go) +// --------------------------------------------------------------------------- + +// Discord field limits (rune counts / conservative defaults) +const ( + discordTotalMax = 6000 + discordDescMax = 4096 + discordTitleMax = 256 + discordFooterMax = 2048 + overheadMargin = 200 + // maximum embeds per single Discord webhook request + maxEmbedsPerRequest = 10 +) + +// helper: choose color based on priority +func discordColorForPriority(p uint32) int { + switch p { + case 5: + return 0xFF0000 + case 4: + return 0xFFA500 + case 3: + return 0xFFFF00 + case 2: + return 0x3498DB + default: + return 0x2ECC71 + } +} + +// helper: compute allowed description rune count per embed +func allowedDescFor(title, footer string) int { + // safety: cap title/footer to their maxes for calculation + titleLen := len([]rune(title)) + if titleLen > discordTitleMax { + titleLen = discordTitleMax + } + footerLen := len([]rune(footer)) + if footerLen > discordFooterMax { + footerLen = discordFooterMax + } + + // compute remaining budget then cap to description max + allowed := discordTotalMax - titleLen - footerLen - overheadMargin + if allowed > discordDescMax { + allowed = discordDescMax + } + if allowed < 200 { + allowed = 200 + } + return allowed +} + +// truncateRunes returns the first n runes of s (or s if shorter) +func truncateRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) +} + +// balanceFences ensures that triple-backtick fences are closed when a description +// has been truncated. It appends closing backticks if necessary and also +// fixes partial fence suffixes. +func balanceFences(s string) string { + // if odd number of fences, append closing fence + if strings.Count(s, "```")%2 != 0 { + // ensure we don't leave a partial fence at the end + if strings.HasSuffix(s, "``") { + s += "`" + } else if strings.HasSuffix(s, "`") { + s += "``" + } else { + s += "\n```" + } + } + return s +} + +// chooseSplitAtNewline returns an end index inside r starting at 'i' with +// capacity 'cap'. It prefers the last newline inside the window so we split +// on line boundaries when possible (improves readability of embeds). +func chooseSplitAtNewline(r []rune, i int, cap int) int { + end := i + cap + if end >= len(r) { + return len(r) + } + // search backwards for last newline within [i, end) + for j := end; j > i; j-- { + if r[j-1] == '\n' { + return j + } + } + // no newline found, fall back to end + return end +} + +// parse message into segments of code blocks and plain text +type segment struct { + isCode bool + lang, text string +} + +func parseSegments(body string) []segment { + codeRe := regexp.MustCompile("(?s)```.*?```") + idxs := codeRe.FindAllStringIndex(body, -1) + segs := []segment{} + last := 0 + for _, id := range idxs { + if id[0] > last { + segs = append(segs, segment{isCode: false, text: body[last:id[0]]}) + } + block := body[id[0]:id[1]] + inner := strings.TrimPrefix(strings.TrimSuffix(block, "```"), "```") + lang := "" + code := inner + if n := strings.Index(inner, "\n"); n >= 0 { + lang = strings.TrimSpace(inner[:n]) + code = inner[n+1:] + } + segs = append(segs, segment{isCode: true, lang: lang, text: code}) + last = id[1] + } + if last < len(body) { + segs = append(segs, segment{isCode: false, text: body[last:]}) + } + return segs +} + +// build embeds from segments while preserving code blocks +func buildEmbedsFromSegments(title string, segs []segment, footerText string, priority uint32) []DiscordEmbed { + color := discordColorForPriority(priority) + allowed := allowedDescFor(title, footerText) + var embeds []DiscordEmbed + cur := []rune{} + hasTitle := true + // flush appends a built embed using the current accumulator. + flush := func() { + t := "" + if hasTitle { + t = title + } + // truncate fields to per-field limits to avoid Discord 400 errors + t = truncateRunes(t, discordTitleMax) + footer := truncateRunes(footerText, discordFooterMax) + desc := truncateRunes(string(cur), discordDescMax) + // ensure backtick fences are balanced after truncation + desc = balanceFences(desc) + embeds = append(embeds, DiscordEmbed{Title: t, Description: desc, Color: color, Timestamp: "", Footer: &DiscordEmbedFooter{Text: footer}}) + hasTitle = false + cur = []rune{} + allowed = allowedDescFor("", footer) + } + + for _, s := range segs { + if s.isCode { + opener := "```" + if s.lang != "" { + opener += s.lang + "\n" + } else { + opener += "\n" + } + closer := "```" + r := []rune(s.text) + i := 0 + for i < len(r) { + remaining := allowed - len(cur) - len([]rune(opener)) - len([]rune(closer)) + if remaining <= 0 { + flush() + continue + } + // prefer to split at newline boundaries inside code + end := chooseSplitAtNewline(r, i, remaining) + take := end - i + if take <= 0 { + // nothing progress (shouldn't happen), force one rune to avoid infinite loop + take = 1 + } + frag := string(r[i : i+take]) + cur = append(cur, []rune(opener+frag+"\n"+closer)...) + i += take + if i < len(r) { + flush() + } + } + } else { + r := []rune(s.text) + i := 0 + for i < len(r) { + remaining := allowed - len(cur) + if remaining <= 0 { + flush() + continue + } + // prefer to split on newline boundaries for plain text as well + end := chooseSplitAtNewline(r, i, remaining) + take := end - i + if take <= 0 { + take = 1 + } + cur = append(cur, r[i:i+take]...) + i += take + if i < len(r) { + flush() + } + } + } + } + if len(cur) > 0 || len(embeds) == 0 { + flush() + } + return embeds +} + +// DiscordPayload represents a Discord webhook payload that can include embeds. +type DiscordPayload struct { + Username string `json:"username,omitempty"` + AvatarURL string `json:"avatar_url,omitempty"` + Content string `json:"content,omitempty"` + Embeds []DiscordEmbed `json:"embeds,omitempty"` +} + +type DiscordEmbed struct { + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Color int `json:"color,omitempty"` + Timestamp string `json:"timestamp,omitempty"` + Footer *DiscordEmbedFooter `json:"footer,omitempty"` +} + +type DiscordEmbedFooter struct { + Text string `json:"text,omitempty"` +} + +// format_discord_embeds builds one or more embeds from GotifyMessage. +// It relies on smaller helpers in utils.go (parseSegments, buildEmbedsFromSegments). +func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed { + title := msg.Title + footerText := fmt.Sprintf("Gotify Id: %d", msg.Id) + segs := parseSegments(msg.Message) + embeds := buildEmbedsFromSegments(title, segs, footerText, msg.Priority) + + for i := range embeds { + embeds[i].Timestamp = msg.Date + if len(embeds) > 1 { + embeds[i].Footer = &DiscordEmbedFooter{Text: fmt.Sprintf("Gotify Id: %d (#%d)", msg.Id, i+1)} + } + } + return embeds +} + +// embedRunes computes the rune-count footprint of an embed's title+description+footer +func embedRunes(e DiscordEmbed) int { + footerLen := 0 + if e.Footer != nil { + footerLen = len([]rune(e.Footer.Text)) + } + return len([]rune(e.Title)) + len([]rune(e.Description)) + footerLen +} + +// batchEmbeds splits a list of embeds into batches so each batch has at most +// maxEmbedsPerRequest embeds and the total runes across the batch do not +// exceed discordTotalMax. Oversized single embeds are emitted alone. +func batchEmbeds(embeds []DiscordEmbed) [][]DiscordEmbed { + var batches [][]DiscordEmbed + i := 0 + for i < len(embeds) { + batch := make([]DiscordEmbed, 0, maxEmbedsPerRequest) + batchRunes := 0 + for i < len(embeds) && len(batch) < maxEmbedsPerRequest { + e := embeds[i] + eRunes := embedRunes(e) + + if eRunes > discordTotalMax { + log.Printf("discord: single embed exceeds total limit (%d runes) — title+desc+footer=%d", discordTotalMax, eRunes) + if len(batch) > 0 { + // finish current batch first + break + } + // emit oversized embed alone + batch = append(batch, e) + i++ + break + } + + if batchRunes+eRunes > discordTotalMax { + if len(batch) == 0 { + // nothing fits in empty batch (shouldn't happen if eRunes <= discordTotalMax) + batch = append(batch, e) + batchRunes += eRunes + i++ + } + break + } + + batch = append(batch, e) + batchRunes += eRunes + i++ + } + if len(batch) > 0 { + batches = append(batches, batch) + } + } + return batches +} + +// sendPayload sends a marshaled payload to the webhook with retries and +// handles rate-limiting (429) and server errors. +func sendPayload(client *http.Client, webhookURL string, payloadBytes []byte) error { + var resp *http.Response + var req *http.Request + var err error + maxRetries := 5 + + for attempt := 0; attempt <= maxRetries; attempt++ { + req, err = http.NewRequest("POST", webhookURL, bytes.NewReader(payloadBytes)) + if err != nil { + log.Println("discord: create request failed") + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err = client.Do(req) + if err != nil { + backoff := time.Duration(1<= 500 && resp.StatusCode < 600 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + resp.Body.Close() + log.Printf("discord: server error %d, body=%s", resp.StatusCode, strings.TrimSpace(string(b))) + backoff := time.Duration(1<= 300 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 2048)) + resp.Body.Close() + bodyStr := strings.TrimSpace(string(b)) + log.Printf("discord: unexpected status %d. body=%s", resp.StatusCode, bodyStr) + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + pb := payloadBytes + previewLen := 200 + if len(pb) <= previewLen*2 { + log.Printf("discord: payload (len=%d): %s", len(pb), string(pb)) + } else { + prefix := string(pb[:previewLen]) + suffix := string(pb[len(pb)-previewLen:]) + log.Printf("discord: payload len=%d; prefix=%s ... suffix=%s", len(pb), prefix, suffix) + } + } + } else { + resp.Body.Close() + } + break + } + + if err != nil { + log.Printf("discord: request failed: %v", err) + return err + } + return nil +} + +// send_msg_to_discord posts embeds to a Discord webhook. It might send multiple requests if given multiple embeds. +func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username string, avatarURL string) { + if webhookURL == "" { + return + } + + client := &http.Client{Timeout: 10 * time.Second} + + batches := batchEmbeds(embeds) + + for _, batch := range batches { + payload := DiscordPayload{ + Username: username, + AvatarURL: avatarURL, + Embeds: batch, + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + log.Println("discord: create json failed") + return + } + + if err := sendPayload(client, webhookURL, payloadBytes); err != nil { + // sendPayload already logs; stop on persistent error + return + } + } +} diff --git a/telegram_utils.go b/telegram_utils.go new file mode 100644 index 0000000..4066e51 --- /dev/null +++ b/telegram_utils.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "html/template" + "log" + "net/http" +) + +// format_telegram_message prepares an HTML-safe message for Telegram +func format_telegram_message(msg *GotifyMessage) string { + title := string(template.HTML("" + template.HTMLEscapeString(msg.Title) + "")) + return fmt.Sprintf( + "%s\n%s\n\nDate: %s", + title, + template.HTMLEscapeString(msg.Message), + msg.Date, + ) +} + +type Payload struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + ThreadId string `json:"message_thread_id"` + ParseMode string `json:"parse_mode"` +} + +// send_msg_to_telegram sends a message to Telegram, splitting long messages. +func send_msg_to_telegram(msg string, bot_token string, chat_id string, thread_id string) { + step_size := 4090 + sending_message := "" + for i := 0; i < len(msg); i += step_size { + if i+step_size < len(msg) { + sending_message = msg[i : i+step_size] + } else { + sending_message = msg[i:] + } + + data := Payload{ + ChatID: chat_id, + Text: sending_message, + ThreadId: thread_id, + ParseMode: "HTML", + } + payloadBytes, err := json.Marshal(data) + if err != nil { + log.Println("Create json false") + return + } + body := bytes.NewReader(payloadBytes) + + req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+bot_token+"/sendMessage", body) + if err != nil { + log.Println("Create request false") + return + } + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + fmt.Printf("Send request false: %v\n", err) + return + } + defer resp.Body.Close() + } +} diff --git a/utils.go b/utils.go index 269bd1d..023a8e9 100644 --- a/utils.go +++ b/utils.go @@ -1,238 +1,9 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "html/template" - "log" - "net/http" - "strconv" - "strings" - "time" -) - -func debug(s string, x ...interface{}) { - log.Printf("Gotify2Telegram::"+s, x...) -} - -func format_telegram_message(msg *GotifyMessage) string { - // HTML Should be escaped here - title := string(template.HTML("" + template.HTMLEscapeString(msg.Title) + "")) - return fmt.Sprintf( - "%s\n%s\n\nDate: %s", - title, - template.HTMLEscapeString(msg.Message), - msg.Date, - ) -} - -type Payload struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - ThreadId string `json:"message_thread_id"` - ParseMode string `json:"parse_mode"` -} - -func send_msg_to_telegram(msg string, bot_token string, chat_id string, thread_id string) { - step_size := 4090 - sending_message := "" - for i := 0; i < len(msg); i += step_size { - if i+step_size < len(msg) { - sending_message = msg[i : i+step_size] - } else { - sending_message = msg[i:len(msg)] - } - - data := Payload{ - ChatID: chat_id, - Text: sending_message, - ThreadId: thread_id, - ParseMode: "HTML", - } - payloadBytes, err := json.Marshal(data) - if err != nil { - log.Println("Create json false") - return - } - body := bytes.NewReader(payloadBytes) - - req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+bot_token+"/sendMessage", body) - if err != nil { - log.Println("Create request false") - return - } - req.Header.Set("Content-Type", "application/json") - - resp, err := http.DefaultClient.Do(req) - if err != nil { - fmt.Printf("Send request false: %v\n", err) - return - } - defer resp.Body.Close() - } -} - -// DiscordPayload represents a Discord webhook payload that can include embeds. -type DiscordPayload struct { - Username string `json:"username,omitempty"` - AvatarURL string `json:"avatar_url,omitempty"` - Content string `json:"content,omitempty"` - Embeds []DiscordEmbed `json:"embeds,omitempty"` -} - -type DiscordEmbed struct { - Title string `json:"title,omitempty"` - Description string `json:"description,omitempty"` - Color int `json:"color,omitempty"` - Timestamp string `json:"timestamp,omitempty"` - Footer *DiscordEmbedFooter `json:"footer,omitempty"` -} - -type DiscordEmbedFooter struct { - Text string `json:"text,omitempty"` -} - -// format_discord_embeds builds one or more embeds from GotifyMessage. -// It will split long descriptions into multiple embeds if necessary. -func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed { - title := msg.Title - body := msg.Message - - // decide color by priority (example mapping) - color := 0x2ECC71 // green default - switch msg.Priority { - case 5: - color = 0xFF0000 // red - case 4: - color = 0xFFA500 // orange - case 3: - color = 0xFFFF00 // yellow - case 2: - color = 0x3498DB // blue - } - - // Discord embed description limit is 4096 chars; split into chunks safely - maxDesc := 3800 - runes := []rune(body) - var embeds []DiscordEmbed - for i := 0; i < len(runes); i += maxDesc { - end := i + maxDesc - if end > len(runes) { - end = len(runes) - } - desc := string(runes[i:end]) - embed := DiscordEmbed{ - Title: title, - Description: desc, - Color: color, - Timestamp: msg.Date, - Footer: &DiscordEmbedFooter{ - Text: fmt.Sprintf("Gotify Id: %d", msg.Id), - }, - } - // For subsequent chunks, omit the title to avoid repetition - if i > 0 { - embed.Title = "" - } - embeds = append(embeds, embed) - } - return embeds -} - -// send_msg_to_discord posts embeds to a Discord webhook. It will send multiple requests if given multiple embeds. -func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username string, avatarURL string) { - if webhookURL == "" { - return - } - - // Discord allows multiple embeds in one payload; however to keep payload sizes safe - // we will send up to 5 embeds per request (Discord limit is 10 embeds per request). - maxEmbedsPerRequest := 5 - client := &http.Client{Timeout: 10 * time.Second} - for start := 0; start < len(embeds); start += maxEmbedsPerRequest { - end := start + maxEmbedsPerRequest - if end > len(embeds) { - end = len(embeds) - } - - payload := DiscordPayload{ - Username: username, - AvatarURL: avatarURL, - Embeds: embeds[start:end], - } - - payloadBytes, err := json.Marshal(payload) - if err != nil { - log.Println("Create discord json false") - return - } - body := bytes.NewReader(payloadBytes) - - req, err := http.NewRequest("POST", webhookURL, body) - if err != nil { - log.Println("Create discord request false") - return - } - req.Header.Set("Content-Type", "application/json") - - // Retry loop with exponential backoff and special handling for 429 - var resp *http.Response - var attempt int - maxRetries := 5 - for attempt = 0; attempt <= maxRetries; attempt++ { - resp, err = client.Do(req) - if err != nil { - // network error, retry - backoff := time.Duration(1<= 500 && resp.StatusCode < 600 { - resp.Body.Close() - backoff := time.Duration(1< maxRetries { - fmt.Printf("Send discord request failed after %d attempts\n", maxRetries) - } - } -} +package main + +import ( + "log" +) + +func debug(s string, x ...interface{}) { + log.Printf("Gotify2Telegram::"+s, x...) +} diff --git a/utils_test.go b/utils_test.go new file mode 100644 index 0000000..b7c1016 --- /dev/null +++ b/utils_test.go @@ -0,0 +1,189 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFormatDiscordEmbeds_SplitsPlainText(t *testing.T) { + // create a long message (~20k chars) where each line has a unique index suffix + parts := make([]string, 0, 800) + for i := 0; i < 800; i++ { + parts = append(parts, fmt.Sprintf("ABCDEFGHIJKLMNOPQRSTUVWXYZ %d\n", i)) + } + long := strings.Join(parts, "") + msg := &GotifyMessage{ + Id: 1, + Appid: 1, + Message: long, + Title: "Long Message", + Priority: 1, + Date: "2025-10-19T00:00:00Z", + } + + embeds := format_discord_embeds(msg) + if len(embeds) <= 1 { + t.Fatalf("expected multiple embeds for long message, got %d", len(embeds)) + } + + // Check embed sizes (title+desc+footer) under 6000 + footer := "Gotify Id: 1" + for i, e := range embeds { + total := len([]rune(e.Title)) + len([]rune(e.Description)) + len([]rune(footer)) + if total >= 6000 { + t.Fatalf("embed %d too large: %d runes", i, total) + } + } + + // Ensure every original numbered line appears intact inside a single embed + lines := strings.SplitAfter(long, "\n") + for idx, line := range lines { + if line == "" { + continue + } + found := false + for _, e := range embeds { + if strings.Contains(e.Description, line) { + found = true + break + } + } + if !found { + t.Fatalf("original numbered line %d not found intact in any embed: %q", idx, line) + } + } +} + +func TestSendMsgToDiscord_Batching(t *testing.T) { + // create many small embeds so batching is driven by maxEmbedsPerRequest + total := 25 + embeds := make([]DiscordEmbed, total) + for i := 0; i < total; i++ { + embeds[i] = DiscordEmbed{ + Title: fmt.Sprintf("title-%d", i+1), + Description: strings.Repeat("x", 100), + Color: 0x123456, + Footer: &DiscordEmbedFooter{Text: "footer"}, + } + } + + var received [][]DiscordEmbed + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var p DiscordPayload + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + t.Fatalf("server decode error: %v", err) + } + received = append(received, p.Embeds) + // respond with 204 No Content to indicate success + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + // call the function under test + send_msg_to_discord(embeds, srv.URL, "tester", "") + + if len(received) == 0 { + t.Fatalf("expected requests to be sent, got 0") + } + + // expected number of requests = ceil(total / maxEmbedsPerRequest) + expected := (total + maxEmbedsPerRequest - 1) / maxEmbedsPerRequest + if len(received) != expected { + t.Fatalf("expected %d requests, got %d", expected, len(received)) + } + + // check each payload obeys limits + seen := 0 + for i, batch := range received { + if len(batch) > maxEmbedsPerRequest { + t.Fatalf("batch %d has %d embeds (exceeds max %d)", i, len(batch), maxEmbedsPerRequest) + } + batchRunes := 0 + for _, e := range batch { + footerLen := 0 + if e.Footer != nil { + footerLen = len([]rune(e.Footer.Text)) + } + eRunes := len([]rune(e.Title)) + len([]rune(e.Description)) + footerLen + batchRunes += eRunes + if eRunes >= discordTotalMax { + t.Fatalf("single embed in batch %d too large: %d runes", i, eRunes) + } + seen++ + } + if batchRunes > discordTotalMax { + t.Fatalf("batch %d total runes %d exceeds discordTotalMax %d", i, batchRunes, discordTotalMax) + } + } + + if seen != total { + t.Fatalf("expected to see %d embeds across batches, saw %d", total, seen) + } +} + +func TestFormatDiscordEmbeds_PreservesCodeBlocks(t *testing.T) { + // generate a long code block where each line has a unique index suffix + parts := make([]string, 0, 2000) + for i := 0; i < 2000; i++ { + parts = append(parts, fmt.Sprintf("line_of_code(); // %d\n", i)) + } + code := strings.Join(parts, "") + body := "Some intro text\n```go\n" + code + "```\nSome outro" + msg := &GotifyMessage{ + Id: 2, + Appid: 1, + Message: body, + Title: "Code Message", + Priority: 1, + Date: "2025-10-19T00:00:00Z", + } + + embeds := format_discord_embeds(msg) + if len(embeds) <= 1 { + t.Fatalf("expected multiple embeds for long code block, got %d", len(embeds)) + } + + // Ensure each original code line appears intact inside a single embed's fenced block + codeLines := strings.SplitAfter(code, "\n") + + // build a list of code-containing strings per embed + embedCodeLines := make([][]string, len(embeds)) + for i, e := range embeds { + parts := strings.Split(e.Description, "```") + for j := 1; j+1 < len(parts); j += 2 { + inner := parts[j] + // remove language first line if present + if idx := strings.Index(inner, "\n"); idx >= 0 { + inner = inner[idx+1:] + } + // split inner into lines (preserve newline at end) + ls := strings.SplitAfter(inner, "\n") + embedCodeLines[i] = append(embedCodeLines[i], ls...) + } + } + + for idx, cl := range codeLines { + if cl == "" { + continue + } + found := false + for _, ls := range embedCodeLines { + for _, el := range ls { + if el == cl { + found = true + break + } + } + if found { + break + } + } + if !found { + t.Fatalf("code line %d not found intact in any embed's fenced block: %q", idx, cl) + } + } +}