Skip to content

Commit dae661b

Browse files
authored
Merge pull request #1948 from RedwindA/feat/gotify
feat: Add Gotify Notification Channel for Quota Alerts
2 parents 649a520 + d6db10b commit dae661b

File tree

8 files changed

+352
-7
lines changed

8 files changed

+352
-7
lines changed

controller/user.go

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,9 @@ type UpdateUserSettingRequest struct {
11021102
WebhookSecret string `json:"webhook_secret,omitempty"`
11031103
NotificationEmail string `json:"notification_email,omitempty"`
11041104
BarkUrl string `json:"bark_url,omitempty"`
1105+
GotifyUrl string `json:"gotify_url,omitempty"`
1106+
GotifyToken string `json:"gotify_token,omitempty"`
1107+
GotifyPriority int `json:"gotify_priority,omitempty"`
11051108
AcceptUnsetModelRatioModel bool `json:"accept_unset_model_ratio_model"`
11061109
RecordIpLog bool `json:"record_ip_log"`
11071110
}
@@ -1117,7 +1120,7 @@ func UpdateUserSetting(c *gin.Context) {
11171120
}
11181121

11191122
// 验证预警类型
1120-
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
1123+
if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark && req.QuotaWarningType != dto.NotifyTypeGotify {
11211124
c.JSON(http.StatusOK, gin.H{
11221125
"success": false,
11231126
"message": "无效的预警类型",
@@ -1192,6 +1195,40 @@ func UpdateUserSetting(c *gin.Context) {
11921195
}
11931196
}
11941197

1198+
// 如果是Gotify类型,验证Gotify URL和Token
1199+
if req.QuotaWarningType == dto.NotifyTypeGotify {
1200+
if req.GotifyUrl == "" {
1201+
c.JSON(http.StatusOK, gin.H{
1202+
"success": false,
1203+
"message": "Gotify服务器地址不能为空",
1204+
})
1205+
return
1206+
}
1207+
if req.GotifyToken == "" {
1208+
c.JSON(http.StatusOK, gin.H{
1209+
"success": false,
1210+
"message": "Gotify令牌不能为空",
1211+
})
1212+
return
1213+
}
1214+
// 验证URL格式
1215+
if _, err := url.ParseRequestURI(req.GotifyUrl); err != nil {
1216+
c.JSON(http.StatusOK, gin.H{
1217+
"success": false,
1218+
"message": "无效的Gotify服务器地址",
1219+
})
1220+
return
1221+
}
1222+
// 检查是否是HTTP或HTTPS
1223+
if !strings.HasPrefix(req.GotifyUrl, "https://") && !strings.HasPrefix(req.GotifyUrl, "http://") {
1224+
c.JSON(http.StatusOK, gin.H{
1225+
"success": false,
1226+
"message": "Gotify服务器地址必须以http://或https://开头",
1227+
})
1228+
return
1229+
}
1230+
}
1231+
11951232
userId := c.GetInt("id")
11961233
user, err := model.GetUserById(userId, true)
11971234
if err != nil {
@@ -1225,6 +1262,18 @@ func UpdateUserSetting(c *gin.Context) {
12251262
settings.BarkUrl = req.BarkUrl
12261263
}
12271264

1265+
// 如果是Gotify类型,添加Gotify配置到设置中
1266+
if req.QuotaWarningType == dto.NotifyTypeGotify {
1267+
settings.GotifyUrl = req.GotifyUrl
1268+
settings.GotifyToken = req.GotifyToken
1269+
// Gotify优先级范围0-10,超出范围则使用默认值5
1270+
if req.GotifyPriority < 0 || req.GotifyPriority > 10 {
1271+
settings.GotifyPriority = 5
1272+
} else {
1273+
settings.GotifyPriority = req.GotifyPriority
1274+
}
1275+
}
1276+
12281277
// 更新用户设置
12291278
user.SetSetting(settings)
12301279
if err := user.Update(false); err != nil {

dto/user_settings.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ type UserSetting struct {
77
WebhookSecret string `json:"webhook_secret,omitempty"` // WebhookSecret webhook密钥
88
NotificationEmail string `json:"notification_email,omitempty"` // NotificationEmail 通知邮箱地址
99
BarkUrl string `json:"bark_url,omitempty"` // BarkUrl Bark推送URL
10+
GotifyUrl string `json:"gotify_url,omitempty"` // GotifyUrl Gotify服务器地址
11+
GotifyToken string `json:"gotify_token,omitempty"` // GotifyToken Gotify应用令牌
12+
GotifyPriority int `json:"gotify_priority"` // GotifyPriority Gotify消息优先级
1013
AcceptUnsetRatioModel bool `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
1114
RecordIpLog bool `json:"record_ip_log,omitempty"` // 是否记录请求和错误日志IP
1215
SidebarModules string `json:"sidebar_modules,omitempty"` // SidebarModules 左侧边栏模块配置
@@ -16,4 +19,5 @@ var (
1619
NotifyTypeEmail = "email" // Email 邮件
1720
NotifyTypeWebhook = "webhook" // Webhook
1821
NotifyTypeBark = "bark" // Bark 推送
22+
NotifyTypeGotify = "gotify" // Gotify 推送
1923
)

service/quota.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,8 +549,11 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
549549
// Bark推送使用简短文本,不支持HTML
550550
content = "{{value}},剩余额度:{{value}},请及时充值"
551551
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
552+
} else if notifyType == dto.NotifyTypeGotify {
553+
content = "{{value}},当前剩余额度为 {{value}},请及时充值。"
554+
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
552555
} else {
553-
// 默认内容格式,适用于Email和Webhook
556+
// 默认内容格式,适用于Email和Webhook(支持HTML)
554557
content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
555558
values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
556559
}

service/user_notify.go

Lines changed: 112 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package service
22

33
import (
4+
"bytes"
5+
"encoding/json"
46
"fmt"
57
"net/http"
68
"net/url"
@@ -37,13 +39,16 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
3739

3840
switch notifyType {
3941
case dto.NotifyTypeEmail:
40-
// check setting email
41-
userEmail = userSetting.NotificationEmail
42-
if userEmail == "" {
42+
// 优先使用设置中的通知邮箱,如果为空则使用用户的默认邮箱
43+
emailToUse := userSetting.NotificationEmail
44+
if emailToUse == "" {
45+
emailToUse = userEmail
46+
}
47+
if emailToUse == "" {
4348
common.SysLog(fmt.Sprintf("user %d has no email, skip sending email", userId))
4449
return nil
4550
}
46-
return sendEmailNotify(userEmail, data)
51+
return sendEmailNotify(emailToUse, data)
4752
case dto.NotifyTypeWebhook:
4853
webhookURLStr := userSetting.WebhookUrl
4954
if webhookURLStr == "" {
@@ -61,6 +66,14 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
6166
return nil
6267
}
6368
return sendBarkNotify(barkURL, data)
69+
case dto.NotifyTypeGotify:
70+
gotifyUrl := userSetting.GotifyUrl
71+
gotifyToken := userSetting.GotifyToken
72+
if gotifyUrl == "" || gotifyToken == "" {
73+
common.SysLog(fmt.Sprintf("user %d has no gotify url or token, skip sending gotify", userId))
74+
return nil
75+
}
76+
return sendGotifyNotify(gotifyUrl, gotifyToken, userSetting.GotifyPriority, data)
6477
}
6578
return nil
6679
}
@@ -144,3 +157,98 @@ func sendBarkNotify(barkURL string, data dto.Notify) error {
144157

145158
return nil
146159
}
160+
161+
func sendGotifyNotify(gotifyUrl string, gotifyToken string, priority int, data dto.Notify) error {
162+
// 处理占位符
163+
content := data.Content
164+
for _, value := range data.Values {
165+
content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
166+
}
167+
168+
// 构建完整的 Gotify API URL
169+
// 确保 URL 以 /message 结尾
170+
finalURL := strings.TrimSuffix(gotifyUrl, "/") + "/message?token=" + url.QueryEscape(gotifyToken)
171+
172+
// Gotify优先级范围0-10,如果超出范围则使用默认值5
173+
if priority < 0 || priority > 10 {
174+
priority = 5
175+
}
176+
177+
// 构建 JSON payload
178+
type GotifyMessage struct {
179+
Title string `json:"title"`
180+
Message string `json:"message"`
181+
Priority int `json:"priority"`
182+
}
183+
184+
payload := GotifyMessage{
185+
Title: data.Title,
186+
Message: content,
187+
Priority: priority,
188+
}
189+
190+
// 序列化为 JSON
191+
payloadBytes, err := json.Marshal(payload)
192+
if err != nil {
193+
return fmt.Errorf("failed to marshal gotify payload: %v", err)
194+
}
195+
196+
var req *http.Request
197+
var resp *http.Response
198+
199+
if system_setting.EnableWorker() {
200+
// 使用worker发送请求
201+
workerReq := &WorkerRequest{
202+
URL: finalURL,
203+
Key: system_setting.WorkerValidKey,
204+
Method: http.MethodPost,
205+
Headers: map[string]string{
206+
"Content-Type": "application/json; charset=utf-8",
207+
"User-Agent": "OneAPI-Gotify-Notify/1.0",
208+
},
209+
Body: payloadBytes,
210+
}
211+
212+
resp, err = DoWorkerRequest(workerReq)
213+
if err != nil {
214+
return fmt.Errorf("failed to send gotify request through worker: %v", err)
215+
}
216+
defer resp.Body.Close()
217+
218+
// 检查响应状态
219+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
220+
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
221+
}
222+
} else {
223+
// SSRF防护:验证Gotify URL(非Worker模式)
224+
fetchSetting := system_setting.GetFetchSetting()
225+
if err := common.ValidateURLWithFetchSetting(finalURL, fetchSetting.EnableSSRFProtection, fetchSetting.AllowPrivateIp, fetchSetting.DomainFilterMode, fetchSetting.IpFilterMode, fetchSetting.DomainList, fetchSetting.IpList, fetchSetting.AllowedPorts, fetchSetting.ApplyIPFilterForDomain); err != nil {
226+
return fmt.Errorf("request reject: %v", err)
227+
}
228+
229+
// 直接发送请求
230+
req, err = http.NewRequest(http.MethodPost, finalURL, bytes.NewBuffer(payloadBytes))
231+
if err != nil {
232+
return fmt.Errorf("failed to create gotify request: %v", err)
233+
}
234+
235+
// 设置请求头
236+
req.Header.Set("Content-Type", "application/json; charset=utf-8")
237+
req.Header.Set("User-Agent", "NewAPI-Gotify-Notify/1.0")
238+
239+
// 发送请求
240+
client := GetHttpClient()
241+
resp, err = client.Do(req)
242+
if err != nil {
243+
return fmt.Errorf("failed to send gotify request: %v", err)
244+
}
245+
defer resp.Body.Close()
246+
247+
// 检查响应状态
248+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
249+
return fmt.Errorf("gotify request failed with status code: %d", resp.StatusCode)
250+
}
251+
}
252+
253+
return nil
254+
}

web/src/components/settings/PersonalSetting.jsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ const PersonalSetting = () => {
8181
webhookSecret: '',
8282
notificationEmail: '',
8383
barkUrl: '',
84+
gotifyUrl: '',
85+
gotifyToken: '',
86+
gotifyPriority: 5,
8487
acceptUnsetModelRatioModel: false,
8588
recordIpLog: false,
8689
});
@@ -149,6 +152,12 @@ const PersonalSetting = () => {
149152
webhookSecret: settings.webhook_secret || '',
150153
notificationEmail: settings.notification_email || '',
151154
barkUrl: settings.bark_url || '',
155+
gotifyUrl: settings.gotify_url || '',
156+
gotifyToken: settings.gotify_token || '',
157+
gotifyPriority:
158+
settings.gotify_priority !== undefined
159+
? settings.gotify_priority
160+
: 5,
152161
acceptUnsetModelRatioModel:
153162
settings.accept_unset_model_ratio_model || false,
154163
recordIpLog: settings.record_ip_log || false,
@@ -406,6 +415,12 @@ const PersonalSetting = () => {
406415
webhook_secret: notificationSettings.webhookSecret,
407416
notification_email: notificationSettings.notificationEmail,
408417
bark_url: notificationSettings.barkUrl,
418+
gotify_url: notificationSettings.gotifyUrl,
419+
gotify_token: notificationSettings.gotifyToken,
420+
gotify_priority: (() => {
421+
const parsed = parseInt(notificationSettings.gotifyPriority);
422+
return isNaN(parsed) ? 5 : parsed;
423+
})(),
409424
accept_unset_model_ratio_model:
410425
notificationSettings.acceptUnsetModelRatioModel,
411426
record_ip_log: notificationSettings.recordIpLog,

0 commit comments

Comments
 (0)