Skip to content

Commit 80c9bd4

Browse files
committed
feat: support discord webhook
1 parent c9ebf50 commit 80c9bd4

File tree

4 files changed

+254
-19
lines changed

4 files changed

+254
-19
lines changed

README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
# Gotify 2 Telegram
2-
This Gotify plugin forwards all received messages to Telegram through the Telegram bot.
1+
# Gotify 2 Telegram (and Discord)
2+
This Gotify plugin forwards received messages to Telegram and/or Discord.
33

44
## Prerequisite
55
- 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
88
## Installation
99
* **By shared object**
1010

11-
1. Get the compatible shared object from [release](https://github.com/anhbh310/gotify2telegram/releases).
11+
1. Get the compatible shared object from [release](https://github.com/lekoOwO/gotify2telegram/releases).
1212

1313
2. Put it into Gotify plugin folder.
1414

@@ -32,7 +32,9 @@ This Gotify plugin forwards all received messages to Telegram through the Telegr
3232
3333
## Configuration
3434
35-
The configuration contains three keys: `clients`, `gotify_host` and `token`.
35+
The configuration contains four keys: `clients`, `gotify_host`, `token` and `discord`.
36+
37+
This plugin supports sending to Telegram, Discord, or both. Each `SubClient` may independently enable Telegram and/or Discord.
3638
3739
### Clients
3840
@@ -45,13 +47,27 @@ clients:
4547
chat_id: "ID of the telegram chat"
4648
token: "The bot token"
4749
thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic."
50+
discord:
51+
webhook_url: "https://discord.com/api/webhooks/..."
52+
username: "Optional per-client username (falls back to global discord defaults if empty)"
53+
avatar_url: "Optional per-client avatar URL (falls back to global defaults if empty)"
4854
- app_id: "Maybe the second Gotify Client Token, yay!"
4955
telegram:
5056
chat_id: "ID of the telegram chat"
5157
token: "The bot token"
5258
thread_id: "Thread ID of the telegram topic. Leave it empty if we are not sending to a topic."
5359
```
5460

61+
### Global Discord defaults
62+
63+
You can set global Discord defaults (used when per-client username/avatar are empty):
64+
65+
```yaml
66+
discord:
67+
username: "GotifyBot"
68+
avatar_url: "https://example.com/avatar.png"
69+
```
70+
5571
### Gotify Host
5672
5773
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)

config.go

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,28 @@ type Telegram struct {
1010
ThreadId string `yaml:"thread_id"`
1111
}
1212

13+
type DiscordDefaults struct {
14+
Username string `yaml:"username"`
15+
AvatarURL string `yaml:"avatar_url"`
16+
}
17+
18+
type Discord struct {
19+
WebhookURL string `yaml:"webhook_url"`
20+
DiscordDefaults
21+
}
22+
1323
type SubClient struct {
1424
AppId int `yaml:"app_id"`
1525
Telegram Telegram `yaml:"telegram"`
26+
Discord Discord `yaml:"discord"`
1627
}
1728

1829
// Config is user plugin configuration
1930
type Config struct {
20-
Clients []SubClient `yaml:"clients"`
21-
GotifyHost string `yaml:"gotify_host"`
22-
GotifyClientToken string `yaml:"token"`
31+
Clients []SubClient `yaml:"clients"`
32+
GotifyHost string `yaml:"gotify_host"`
33+
GotifyClientToken string `yaml:"token"`
34+
DiscordDefaults DiscordDefaults `yaml:"discord"`
2335
}
2436

2537
// DefaultConfig implements plugin.Configurer
@@ -33,8 +45,19 @@ func (c *Plugin) DefaultConfig() interface{} {
3345
BotToken: "YourBotTokenHere",
3446
ThreadId: "OptionalThreadIdHere",
3547
},
48+
Discord: Discord{
49+
WebhookURL: "",
50+
DiscordDefaults: DiscordDefaults{
51+
Username: "DefaultUsername",
52+
AvatarURL: "DefaultAvatarURL",
53+
},
54+
},
3655
},
3756
},
57+
DiscordDefaults: DiscordDefaults{
58+
Username: "DefaultUsername",
59+
AvatarURL: "DefaultAvatarURL",
60+
},
3861
GotifyHost: "ws://localhost:80",
3962
GotifyClientToken: "ExampleToken",
4063
}
@@ -51,11 +74,22 @@ func (c *Plugin) ValidateAndSetConfig(config interface{}) error {
5174
if client.AppId == 0 {
5275
return fmt.Errorf("gotify app id is required for client %d", i)
5376
}
54-
if client.Telegram.BotToken == "" {
55-
return fmt.Errorf("telegram bot token is required for client %d", i)
77+
// Require at least one destination: Telegram or Discord
78+
if client.Telegram.BotToken == "" && client.Discord.WebhookURL == "" {
79+
return fmt.Errorf("either telegram or discord must be configured for client %d", i)
80+
}
81+
82+
if client.Telegram.BotToken != "" {
83+
if client.Telegram.ChatId == "" {
84+
return fmt.Errorf("telegram chat id is required for client %d", i)
85+
}
5686
}
57-
if client.Telegram.ChatId == "" {
58-
return fmt.Errorf("telegram chat id is required for client %d", i)
87+
88+
if client.Discord.WebhookURL != "" {
89+
// very basic validation
90+
if len(client.Discord.WebhookURL) < 8 {
91+
return fmt.Errorf("discord webhook url seems invalid for client %d", i)
92+
}
5993
}
6094
}
6195

plugin.go

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ func GetGotifyPluginInfo() plugin.Info {
1515
Version: "1.1",
1616
Author: "Anh Bui & Leko",
1717
Name: "Gotify 2 Telegram",
18-
Description: "Telegram message fowarder for gotify",
19-
ModulePath: "https://github.com/anhbh310/gotify2telegram",
18+
Description: "Forward Gotify messages to Telegram and Discord",
19+
ModulePath: "https://github.com/lekoOwO/gotify2telegram",
2020
}
2121
}
2222

@@ -91,12 +91,30 @@ func (p *Plugin) get_websocket_msg(url string) {
9191
for _, subClient := range p.config.Clients {
9292
if subClient.AppId == int(msg.Appid) || subClient.AppId == -1 {
9393
debug("get_websocket_msg: AppId Matched! Sending to telegram...")
94-
send_msg_to_telegram(
95-
format_telegram_message(msg),
96-
subClient.Telegram.BotToken,
97-
subClient.Telegram.ChatId,
98-
subClient.Telegram.ThreadId,
99-
)
94+
// Send to Telegram if configured
95+
if subClient.Telegram.BotToken != "" {
96+
send_msg_to_telegram(
97+
format_telegram_message(msg),
98+
subClient.Telegram.BotToken,
99+
subClient.Telegram.ChatId,
100+
subClient.Telegram.ThreadId,
101+
)
102+
}
103+
104+
// Send to Discord if configured
105+
if subClient.Discord.WebhookURL != "" {
106+
username := subClient.Discord.Username
107+
avatar := subClient.Discord.AvatarURL
108+
// fallback to global defaults if empty
109+
if username == "" && p.config != nil {
110+
username = p.config.DiscordDefaults.Username
111+
}
112+
if avatar == "" && p.config != nil {
113+
avatar = p.config.DiscordDefaults.AvatarURL
114+
}
115+
embeds := format_discord_embeds(msg)
116+
send_msg_to_discord(embeds, subClient.Discord.WebhookURL, username, avatar)
117+
}
100118
break
101119
}
102120
}

utils.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import (
77
"html/template"
88
"log"
99
"net/http"
10+
"strconv"
11+
"strings"
12+
"time"
1013
)
1114

1215
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
6972
defer resp.Body.Close()
7073
}
7174
}
75+
76+
// DiscordPayload represents a Discord webhook payload that can include embeds.
77+
type DiscordPayload struct {
78+
Username string `json:"username,omitempty"`
79+
AvatarURL string `json:"avatar_url,omitempty"`
80+
Content string `json:"content,omitempty"`
81+
Embeds []DiscordEmbed `json:"embeds,omitempty"`
82+
}
83+
84+
type DiscordEmbed struct {
85+
Title string `json:"title,omitempty"`
86+
Description string `json:"description,omitempty"`
87+
Color int `json:"color,omitempty"`
88+
Timestamp string `json:"timestamp,omitempty"`
89+
Footer *DiscordEmbedFooter `json:"footer,omitempty"`
90+
}
91+
92+
type DiscordEmbedFooter struct {
93+
Text string `json:"text,omitempty"`
94+
}
95+
96+
// format_discord_embeds builds one or more embeds from GotifyMessage.
97+
// It will split long descriptions into multiple embeds if necessary.
98+
func format_discord_embeds(msg *GotifyMessage) []DiscordEmbed {
99+
title := template.HTMLEscapeString(msg.Title)
100+
body := template.HTMLEscapeString(msg.Message)
101+
102+
// decide color by priority (example mapping)
103+
color := 0x2ECC71 // green default
104+
switch msg.Priority {
105+
case 5:
106+
color = 0xFF0000 // red
107+
case 4:
108+
color = 0xFFA500 // orange
109+
case 3:
110+
color = 0xFFFF00 // yellow
111+
case 2:
112+
color = 0x3498DB // blue
113+
}
114+
115+
// Discord embed description limit is 4096 chars; split into chunks safely
116+
maxDesc := 3800
117+
runes := []rune(body)
118+
var embeds []DiscordEmbed
119+
for i := 0; i < len(runes); i += maxDesc {
120+
end := i + maxDesc
121+
if end > len(runes) {
122+
end = len(runes)
123+
}
124+
desc := string(runes[i:end])
125+
embed := DiscordEmbed{
126+
Title: title,
127+
Description: desc,
128+
Color: color,
129+
Timestamp: time.Now().UTC().Format(time.RFC3339),
130+
Footer: &DiscordEmbedFooter{
131+
Text: fmt.Sprintf("Gotify Id: %d | Date: %s", msg.Id, msg.Date),
132+
},
133+
}
134+
// For subsequent chunks, omit the title to avoid repetition
135+
if i > 0 {
136+
embed.Title = ""
137+
}
138+
embeds = append(embeds, embed)
139+
}
140+
return embeds
141+
}
142+
143+
// send_msg_to_discord posts embeds to a Discord webhook. It will send multiple requests if given multiple embeds.
144+
func send_msg_to_discord(embeds []DiscordEmbed, webhookURL string, username string, avatarURL string) {
145+
if webhookURL == "" {
146+
return
147+
}
148+
149+
// Discord allows multiple embeds in one payload; however to keep payload sizes safe
150+
// we will send up to 5 embeds per request (Discord limit is 10 embeds per request).
151+
maxEmbedsPerRequest := 5
152+
client := &http.Client{Timeout: 10 * time.Second}
153+
for start := 0; start < len(embeds); start += maxEmbedsPerRequest {
154+
end := start + maxEmbedsPerRequest
155+
if end > len(embeds) {
156+
end = len(embeds)
157+
}
158+
159+
payload := DiscordPayload{
160+
Username: username,
161+
AvatarURL: avatarURL,
162+
Embeds: embeds[start:end],
163+
}
164+
165+
payloadBytes, err := json.Marshal(payload)
166+
if err != nil {
167+
log.Println("Create discord json false")
168+
return
169+
}
170+
body := bytes.NewReader(payloadBytes)
171+
172+
req, err := http.NewRequest("POST", webhookURL, body)
173+
if err != nil {
174+
log.Println("Create discord request false")
175+
return
176+
}
177+
req.Header.Set("Content-Type", "application/json")
178+
179+
// Retry loop with exponential backoff and special handling for 429
180+
var resp *http.Response
181+
var attempt int
182+
maxRetries := 5
183+
for attempt = 0; attempt <= maxRetries; attempt++ {
184+
resp, err = client.Do(req)
185+
if err != nil {
186+
// network error, retry
187+
backoff := time.Duration(1<<attempt) * 200 * time.Millisecond
188+
time.Sleep(backoff)
189+
continue
190+
}
191+
192+
// handle 429 (rate limit)
193+
if resp.StatusCode == 429 {
194+
ra := resp.Header.Get("Retry-After")
195+
resp.Body.Close()
196+
var wait time.Duration
197+
if ra != "" {
198+
// try seconds first
199+
if secs, parseErr := strconv.Atoi(strings.TrimSpace(ra)); parseErr == nil {
200+
wait = time.Duration(secs) * time.Second
201+
} else if t, parseErr2 := http.ParseTime(ra); parseErr2 == nil {
202+
wait = time.Until(t)
203+
if wait < 0 {
204+
wait = time.Second
205+
}
206+
} else {
207+
wait = time.Duration(1<<attempt) * 500 * time.Millisecond
208+
}
209+
} else {
210+
wait = time.Duration(1<<attempt) * 500 * time.Millisecond
211+
}
212+
time.Sleep(wait)
213+
continue
214+
}
215+
216+
// retry on 5xx server errors
217+
if resp.StatusCode >= 500 && resp.StatusCode < 600 {
218+
resp.Body.Close()
219+
backoff := time.Duration(1<<attempt) * 500 * time.Millisecond
220+
time.Sleep(backoff)
221+
continue
222+
}
223+
224+
// other status codes (2xx or 4xx) - do not retry
225+
resp.Body.Close()
226+
break
227+
}
228+
229+
if err != nil {
230+
fmt.Printf("Send discord request false: %v\n", err)
231+
return
232+
}
233+
// if we exhausted retries, log and continue to next batch
234+
if attempt > maxRetries {
235+
fmt.Printf("Send discord request failed after %d attempts\n", maxRetries)
236+
}
237+
}
238+
}

0 commit comments

Comments
 (0)