diff --git a/README.md b/README.md index 7e0c0e3..c4b95c2 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. +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). @@ -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. diff --git a/plugin.go b/plugin.go index 8f94539..c409e67 100644 --- a/plugin.go +++ b/plugin.go @@ -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" @@ -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 1 && i < totalChunks-1 { + time.Sleep(500 * time.Millisecond) + } } } @@ -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") } @@ -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) @@ -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 @@ -161,7 +245,6 @@ func (p *Plugin) Enable() error { return nil } -// Disable implements plugin.Plugin func (p *Plugin) Disable() error { if p.ws != nil { p.ws.Close() @@ -169,14 +252,10 @@ func (p *Plugin) Disable() error { 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")) -} +} \ No newline at end of file