Browse Source

feat: bark notification #1699

Seefs 6 months ago
parent
commit
247e029159

+ 34 - 1
controller/user.go

@@ -1097,6 +1097,7 @@ type UpdateUserSettingRequest struct {
 	WebhookUrl                 string  `json:"webhook_url,omitempty"`
 	WebhookSecret              string  `json:"webhook_secret,omitempty"`
 	NotificationEmail          string  `json:"notification_email,omitempty"`
+	BarkUrl                    string  `json:"bark_url,omitempty"`
 	AcceptUnsetModelRatioModel bool    `json:"accept_unset_model_ratio_model"`
 	RecordIpLog                bool    `json:"record_ip_log"`
 }
@@ -1112,7 +1113,7 @@ func UpdateUserSetting(c *gin.Context) {
 	}
 
 	// 验证预警类型
-	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook {
+	if req.QuotaWarningType != dto.NotifyTypeEmail && req.QuotaWarningType != dto.NotifyTypeWebhook && req.QuotaWarningType != dto.NotifyTypeBark {
 		c.JSON(http.StatusOK, gin.H{
 			"success": false,
 			"message": "无效的预警类型",
@@ -1160,6 +1161,33 @@ func UpdateUserSetting(c *gin.Context) {
 		}
 	}
 
+	// 如果是Bark类型,验证Bark URL
+	if req.QuotaWarningType == dto.NotifyTypeBark {
+		if req.BarkUrl == "" {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Bark推送URL不能为空",
+			})
+			return
+		}
+		// 验证URL格式
+		if _, err := url.ParseRequestURI(req.BarkUrl); err != nil {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "无效的Bark推送URL",
+			})
+			return
+		}
+		// 检查是否是HTTP或HTTPS
+		if !strings.HasPrefix(req.BarkUrl, "https://") && !strings.HasPrefix(req.BarkUrl, "http://") {
+			c.JSON(http.StatusOK, gin.H{
+				"success": false,
+				"message": "Bark推送URL必须以http://或https://开头",
+			})
+			return
+		}
+	}
+
 	userId := c.GetInt("id")
 	user, err := model.GetUserById(userId, true)
 	if err != nil {
@@ -1188,6 +1216,11 @@ func UpdateUserSetting(c *gin.Context) {
 		settings.NotificationEmail = req.NotificationEmail
 	}
 
+	// 如果是Bark类型,添加Bark URL到设置中
+	if req.QuotaWarningType == dto.NotifyTypeBark {
+		settings.BarkUrl = req.BarkUrl
+	}
+
 	// 更新用户设置
 	user.SetSetting(settings)
 	if err := user.Update(false); err != nil {

+ 2 - 0
dto/user_settings.go

@@ -6,6 +6,7 @@ type UserSetting struct {
 	WebhookUrl            string  `json:"webhook_url,omitempty"`                    // WebhookUrl webhook地址
 	WebhookSecret         string  `json:"webhook_secret,omitempty"`                 // WebhookSecret webhook密钥
 	NotificationEmail     string  `json:"notification_email,omitempty"`             // NotificationEmail 通知邮箱地址
+	BarkUrl               string  `json:"bark_url,omitempty"`                       // BarkUrl Bark推送URL
 	AcceptUnsetRatioModel bool    `json:"accept_unset_model_ratio_model,omitempty"` // AcceptUnsetRatioModel 是否接受未设置价格的模型
 	RecordIpLog           bool    `json:"record_ip_log,omitempty"`                  // 是否记录请求和错误日志IP
 	SidebarModules        string  `json:"sidebar_modules,omitempty"`                // SidebarModules 左侧边栏模块配置
@@ -14,4 +15,5 @@ type UserSetting struct {
 var (
 	NotifyTypeEmail   = "email"   // Email 邮件
 	NotifyTypeWebhook = "webhook" // Webhook
+	NotifyTypeBark    = "bark"    // Bark 推送
 )

+ 21 - 2
service/quota.go

@@ -535,8 +535,27 @@ func checkAndSendQuotaNotify(relayInfo *relaycommon.RelayInfo, quota int, preCon
 		if quotaTooLow {
 			prompt := "您的额度即将用尽"
 			topUpLink := fmt.Sprintf("%s/topup", setting.ServerAddress)
-			content := "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
-			err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}))
+
+			// 根据通知方式生成不同的内容格式
+			var content string
+			var values []interface{}
+
+			notifyType := userSetting.NotifyType
+			if notifyType == "" {
+				notifyType = dto.NotifyTypeEmail
+			}
+
+			if notifyType == dto.NotifyTypeBark {
+				// Bark推送使用简短文本,不支持HTML
+				content = "{{value}},剩余额度:{{value}},请及时充值"
+				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota)}
+			} else {
+				// 默认内容格式,适用于Email和Webhook
+				content = "{{value}},当前剩余额度为 {{value}},为了不影响您的使用,请及时充值。<br/>充值链接:<a href='{{value}}'>{{value}}</a>"
+				values = []interface{}{prompt, logger.FormatQuota(relayInfo.UserQuota), topUpLink, topUpLink}
+			}
+
+			err := NotifyUser(relayInfo.UserId, relayInfo.UserEmail, relayInfo.UserSetting, dto.NewNotify(dto.NotifyTypeQuotaExceed, prompt, content, values))
 			if err != nil {
 				common.SysError(fmt.Sprintf("failed to send quota notify to user %d: %s", relayInfo.UserId, err.Error()))
 			}

+ 74 - 0
service/user_notify.go

@@ -2,9 +2,12 @@ package service
 
 import (
 	"fmt"
+	"net/http"
+	"net/url"
 	"one-api/common"
 	"one-api/dto"
 	"one-api/model"
+	"one-api/setting"
 	"strings"
 )
 
@@ -51,6 +54,13 @@ func NotifyUser(userId int, userEmail string, userSetting dto.UserSetting, data
 		// 获取 webhook secret
 		webhookSecret := userSetting.WebhookSecret
 		return SendWebhookNotify(webhookURLStr, webhookSecret, data)
+	case dto.NotifyTypeBark:
+		barkURL := userSetting.BarkUrl
+		if barkURL == "" {
+			common.SysLog(fmt.Sprintf("user %d has no bark url, skip sending bark", userId))
+			return nil
+		}
+		return sendBarkNotify(barkURL, data)
 	}
 	return nil
 }
@@ -64,3 +74,67 @@ func sendEmailNotify(userEmail string, data dto.Notify) error {
 	}
 	return common.SendEmail(data.Title, userEmail, content)
 }
+
+func sendBarkNotify(barkURL string, data dto.Notify) error {
+	// 处理占位符
+	content := data.Content
+	for _, value := range data.Values {
+		content = strings.Replace(content, dto.ContentValueParam, fmt.Sprintf("%v", value), 1)
+	}
+
+	// 替换模板变量
+	finalURL := strings.ReplaceAll(barkURL, "{{title}}", url.QueryEscape(data.Title))
+	finalURL = strings.ReplaceAll(finalURL, "{{content}}", url.QueryEscape(content))
+
+	// 发送GET请求到Bark
+	var req *http.Request
+	var resp *http.Response
+	var err error
+
+	if setting.EnableWorker() {
+		// 使用worker发送请求
+		workerReq := &WorkerRequest{
+			URL:    finalURL,
+			Key:    setting.WorkerValidKey,
+			Method: http.MethodGet,
+			Headers: map[string]string{
+				"User-Agent": "OneAPI-Bark-Notify/1.0",
+			},
+		}
+
+		resp, err = DoWorkerRequest(workerReq)
+		if err != nil {
+			return fmt.Errorf("failed to send bark request through worker: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
+		}
+	} else {
+		// 直接发送请求
+		req, err = http.NewRequest(http.MethodGet, finalURL, nil)
+		if err != nil {
+			return fmt.Errorf("failed to create bark request: %v", err)
+		}
+
+		// 设置User-Agent
+		req.Header.Set("User-Agent", "OneAPI-Bark-Notify/1.0")
+
+		// 发送请求
+		client := GetHttpClient()
+		resp, err = client.Do(req)
+		if err != nil {
+			return fmt.Errorf("failed to send bark request: %v", err)
+		}
+		defer resp.Body.Close()
+
+		// 检查响应状态
+		if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+			return fmt.Errorf("bark request failed with status code: %d", resp.StatusCode)
+		}
+	}
+
+	return nil
+}

+ 3 - 0
web/src/components/settings/PersonalSetting.jsx

@@ -67,6 +67,7 @@ const PersonalSetting = () => {
     webhookUrl: '',
     webhookSecret: '',
     notificationEmail: '',
+    barkUrl: '',
     acceptUnsetModelRatioModel: false,
     recordIpLog: false,
   });
@@ -108,6 +109,7 @@ const PersonalSetting = () => {
         webhookUrl: settings.webhook_url || '',
         webhookSecret: settings.webhook_secret || '',
         notificationEmail: settings.notification_email || '',
+        barkUrl: settings.bark_url || '',
         acceptUnsetModelRatioModel:
           settings.accept_unset_model_ratio_model || false,
         recordIpLog: settings.record_ip_log || false,
@@ -285,6 +287,7 @@ const PersonalSetting = () => {
         webhook_url: notificationSettings.webhookUrl,
         webhook_secret: notificationSettings.webhookSecret,
         notification_email: notificationSettings.notificationEmail,
+        bark_url: notificationSettings.barkUrl,
         accept_unset_model_ratio_model:
           notificationSettings.acceptUnsetModelRatioModel,
         record_ip_log: notificationSettings.recordIpLog,

+ 53 - 0
web/src/components/settings/personal/cards/NotificationSettings.jsx

@@ -347,6 +347,7 @@ const NotificationSettings = ({
                 >
                   <Radio value='email'>{t('邮件通知')}</Radio>
                   <Radio value='webhook'>{t('Webhook通知')}</Radio>
+                  <Radio value='bark'>{t('Bark通知')}</Radio>
                 </Form.RadioGroup>
 
                 <Form.AutoComplete
@@ -483,6 +484,58 @@ const NotificationSettings = ({
                     </Form.Slot>
                   </>
                 )}
+
+                {/* Bark推送设置 */}
+                {notificationSettings.warningType === 'bark' && (
+                  <>
+                    <Form.Input
+                      field='barkUrl'
+                      label={t('Bark推送URL')}
+                      placeholder={t('请输入Bark推送URL,例如: https://api.day.app/yourkey/{{title}}/{{content}}')}
+                      onChange={(val) => handleFormChange('barkUrl', val)}
+                      prefix={<IconLink />}
+                      extraText={t(
+                        '支持HTTP和HTTPS,模板变量: {{title}} (通知标题), {{content}} (通知内容)',
+                      )}
+                      showClear
+                      rules={[
+                        {
+                          required:
+                            notificationSettings.warningType === 'bark',
+                          message: t('请输入Bark推送URL'),
+                        },
+                        {
+                          pattern: /^https?:\/\/.+/,
+                          message: t('Bark推送URL必须以http://或https://开头'),
+                        },
+                      ]}
+                    />
+
+                    <div className='mt-3 p-4 bg-gray-50/50 rounded-xl'>
+                      <div className='text-sm text-gray-700 mb-3'>
+                        <strong>{t('模板示例')}</strong>
+                      </div>
+                      <div className='text-xs text-gray-600 font-mono bg-white p-3 rounded-lg shadow-sm mb-4'>
+                        https://api.day.app/yourkey/{'{{title}}'}/{'{{content}}'}?sound=alarm&group=quota
+                      </div>
+                      <div className='text-xs text-gray-500 space-y-2'>
+                        <div>• <strong>{'title'}:</strong> {t('通知标题')}</div>
+                        <div>• <strong>{'content'}:</strong> {t('通知内容')}</div>
+                        <div className='mt-3 pt-3 border-t border-gray-200'>
+                          <span className='text-gray-400'>{t('更多参数请参考')}</span>{' '}
+                          <a 
+                            href='https://github.com/Finb/Bark' 
+                            target='_blank' 
+                            rel='noopener noreferrer'
+                            className='text-blue-500 hover:text-blue-600 font-medium'
+                          >
+                            Bark 官方文档
+                          </a>
+                        </div>
+                      </div>
+                    </div>
+                  </>
+                )}
               </div>
             </TabPane>