Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Gotify 2 Telegram
This Gotify plugin forwards all received messages to Telegram through the Telegram bot.
This Gotify plugin forwards all received messages to Telegram through the Telegram bot with support for MarkdownV2 formatting.

## 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).
Expand Down Expand Up @@ -36,6 +36,13 @@ 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.

## Please note
I am not a developer. In fact, the implementation for MarkdownV2 formatting support was entirely written by Claude AI (as ChatGPT was unable to figure it out). I have tested it and it works perfectly for my use case: forwarding notifications from my Proxmox instance to Telegram.

The code properly handles large messages by intelligently splitting them while preserving MarkdownV2 formatting. It also splits messages at natural boundaries (such as newlines and words) whenever possible to avoid exceeding Telegram's message size limit. Each part of a split message is labeled with "(n/N)" at the top, making it easier to track the message parts. Additionally, the code includes a small delay between messages to prevent hitting Telegram's rate limits.

I have built the plugin for Gotify V2.6.1 for AMD64, ARM7, and ARM64 platforms and added them as a release to this fork.

## Appendix
Mandatory secrets.

Expand Down
189 changes: 134 additions & 55 deletions plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/http/httputil"
"os"
"strings"
"time"

"github.com/gotify/plugin-api"
Expand All @@ -17,82 +19,162 @@ import (
// 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.2",
Author: "Anh Bui",
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;
debugLogger *log.Logger;
chatid string;
telegram_bot_token string;
gotify_host string;
ws *websocket.Conn
msgHandler plugin.MessageHandler
debugLogger *log.Logger
chatid string
telegram_bot_token string
gotify_host string
}

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"`
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
}

// escapeMarkdownV2 escapes special characters for Telegram's MarkdownV2 format
func escapeMarkdownV2(text string) string {
// Special characters that need to be escaped in MarkdownV2
specialChars := []string{"_", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"}

// Don't escape characters inside code blocks
parts := strings.Split(text, "```")
for i := 0; i < len(parts); i++ {
if i%2 == 0 { // Outside code block
for _, char := range specialChars {
parts[i] = strings.ReplaceAll(parts[i], char, "\\"+char)
}
}
}

return strings.Join(parts, "```")
}

// splitMessage splits a message into chunks while preserving code blocks
func (p *Plugin) splitMessage(msg string, maxSize int) []string {
if len(msg) <= maxSize {
return []string{msg}
}

var chunks []string
lines := strings.Split(msg, "\n")
currentChunk := ""
inCodeBlock := false

for _, line := range lines {
// Check for code block markers
if strings.HasPrefix(strings.TrimSpace(line), "```") {
inCodeBlock = !inCodeBlock
}

// If adding this line would exceed the limit
if len(currentChunk)+len(line)+1 > maxSize {
// If we're in a code block, close it in current chunk and reopen in next
if inCodeBlock {
currentChunk += "```"
chunks = append(chunks, currentChunk)
currentChunk = "```\n" + line + "\n"
} else {
chunks = append(chunks, currentChunk)
currentChunk = line + "\n"
}
} else {
currentChunk += line + "\n"
}
}

// Add the final chunk if there's content
if len(currentChunk) > 0 {
chunks = append(chunks, currentChunk)
}

// Trim chunks and ensure code blocks are properly closed
for i := range chunks {
chunks[i] = strings.TrimSpace(chunks[i])

// Count backticks to check if code block is properly closed
count := strings.Count(chunks[i], "```")
if count%2 == 1 {
chunks[i] += "\n```"
}
}

return chunks
}

func (p *Plugin) send_msg_to_telegram(msg string) {
step_size := 4090
sending_message := ""
// Telegram's maximum message length is 4096 characters
// We use 3800 to leave room for formatting
const maxMessageSize = 3800

// Split the message into chunks
messageChunks := p.splitMessage(msg, maxMessageSize)

totalChunks := len(messageChunks)

for i, chunk := range messageChunks {
// Add part number for multi-part messages
var messageText string
if totalChunks > 1 {
messageText = fmt.Sprintf("(%d/%d)\n%s", i+1, totalChunks, chunk)
} else {
messageText = chunk
}

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)]
}
// Escape markdown characters
escapedText := escapeMarkdownV2(messageText)

data := Payload{
// Fill struct
ChatID: p.chatid,
Text: sending_message,
ChatID: p.chatid,
Text: escapedText,
ParseMode: "MarkdownV2", // Use MarkdownV2 instead of Markdown
}

payloadBytes, err := json.Marshal(data)
if err != nil {
p.debugLogger.Println("Create json false")
return
p.debugLogger.Printf("Create JSON failed: %v\n", err)
continue
}

body := bytes.NewBuffer(payloadBytes)
// For future debugging
backup_body := bytes.NewBuffer(body.Bytes())
backup_body := bytes.NewBuffer(payloadBytes)

req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+ p.telegram_bot_token +"/sendMessage", body)
req, err := http.NewRequest("POST", "https://api.telegram.org/bot"+p.telegram_bot_token+"/sendMessage", body)
if err != nil {
p.debugLogger.Println("Create request false")
return
p.debugLogger.Printf("Create request failed: %v\n", err)
continue
}
req.Header.Set("Content-Type", "application/json")

resp, err := http.DefaultClient.Do(req)

if err != nil {
p.debugLogger.Printf("Send request false: %v\n", err)
return
p.debugLogger.Printf("Send request failed: %v\n", err)
continue
}
p.debugLogger.Println("HTTP request was sent successfully")

if resp.StatusCode == http.StatusOK {
p.debugLogger.Println("The message was forwarded successfully to Telegram")
p.debugLogger.Printf("Part %d/%d was forwarded successfully to Telegram\n", i+1, totalChunks)
} else {
// Log infor for debugging
p.debugLogger.Println("============== Request ==============")
pretty_print, err := httputil.DumpRequest(req, true)
if err != nil {
Expand All @@ -104,10 +186,14 @@ func (p *Plugin) send_msg_to_telegram(msg string) {
p.debugLogger.Println("============== Response ==============")
bodyBytes, err := io.ReadAll(resp.Body)
p.debugLogger.Printf("%v\n", string(bodyBytes))

}

defer resp.Body.Close()
resp.Body.Close()

// Add a small delay between messages to avoid rate limiting
if totalChunks > 1 && i < totalChunks-1 {
time.Sleep(500 * time.Millisecond)
}
}
}

Expand All @@ -119,7 +205,7 @@ func (p *Plugin) connect_websocket() {
break
}
p.debugLogger.Printf("Cannot connect to websocket: %v\n", err)
time.Sleep(5)
time.Sleep(5 * time.Second)
}
p.debugLogger.Println("WebSocket connected successfully, ready for forwarding")
}
Expand All @@ -136,7 +222,7 @@ func (p *Plugin) get_websocket_msg(url string, token string) {
for {
msg := &GotifyMessage{}
if p.ws == nil {
time.Sleep(3)
time.Sleep(3 * time.Second)
continue
}
err := p.ws.ReadJSON(msg)
Expand All @@ -149,8 +235,6 @@ func (p *Plugin) get_websocket_msg(url string, token string) {
}
}

// SetMessageHandler implements plugin.Messenger
// Invoked during initialization
func (p *Plugin) SetMessageHandler(h plugin.MessageHandler) {
p.debugLogger = log.New(os.Stdout, "Gotify 2 Telegram: ", log.Lshortfile)
p.msgHandler = h
Expand All @@ -161,22 +245,17 @@ func (p *Plugin) Enable() error {
return nil
}

// Disable implements plugin.Plugin
func (p *Plugin) Disable() error {
if p.ws != nil {
p.ws.Close()
}
return nil
}

// NewGotifyPluginInstance creates a plugin instance for a user context.
func NewGotifyPluginInstance(ctx plugin.UserContext) plugin.Plugin {
return &Plugin{}
}

func main() {
panic("this should be built as go plugin")
// For testing
// p := &Plugin{nil, nil, "", "", ""}
// p.get_websocket_msg(os.Getenv("GOTIFY_HOST"), os.Getenv("GOTIFY_CLIENT_TOKEN"))
}
}