feat(push): add telegram bot push notification channel

Implement TelegramPusher in pkg/push, register config schema in channels_definition.go, add validation in model/push_channel.go, update task routing, and update settings-tab.tsx UI validation.
This commit is contained in:
ryan
2026-06-15 14:42:49 +08:00
parent cb018b3b60
commit de58b118b4
18 changed files with 394 additions and 60 deletions
+2 -2
View File
@@ -115,7 +115,7 @@ func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[st
content := msg.Content
level := strings.ToUpper(msg.Level)
if level == "" {
level = "INFO"
level = levelInfo
}
headerColor := "blue"
@@ -170,7 +170,7 @@ func (p *LarkPusher) Send(ctx context.Context, cfg Config, _ string, body map[st
content = strings.Join(parts, "\n")
}
level := "INFO"
level := levelInfo
if l, ok := body["level"].(string); ok && l != "" {
level = strings.ToUpper(l)
}
+4 -1
View File
@@ -10,7 +10,10 @@ import (
"sync"
)
const defaultTitle = "系统通知"
const (
defaultTitle = "系统通知"
levelInfo = "INFO"
)
// Config 基础通知渠道配置
type Config struct {
+157
View File
@@ -0,0 +1,157 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
)
func init() {
Register("telegram", &TelegramPusher{})
}
// TelegramPusher Telegram 机器人推送实现
type TelegramPusher struct{}
type telegramMessageRequest struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode,omitempty"`
}
type telegramErrorResponse struct {
Ok bool `json:"ok"`
ErrorCode int `json:"error_code"`
Description string `json:"description"`
}
// Send 执行 Telegram 消息发送
//
//nolint:cyclop
func (p *TelegramPusher) Send(ctx context.Context, cfg Config, target string, body map[string]any, template string, _ map[string]any) error {
if cfg.Secret == "" {
return errors.New("telegram: Bot Token (Secret) is required")
}
chatID := target
if chatID == "" {
chatID = cfg.Key // Use default chat ID (Key) if target is blank
}
if chatID == "" {
return errors.New("telegram: chat_id (target or default Key) is required")
}
baseURL := cfg.URL
if baseURL == "" {
baseURL = "https://api.telegram.org"
}
baseURL = strings.TrimSuffix(baseURL, "/")
title := defaultTitle
if t, ok := body["title"].(string); ok && t != "" {
title = t
}
content := ""
if c, ok := body["content"].(string); ok && c != "" {
content = c
} else {
var parts []string
for k, v := range body {
parts = append(parts, fmt.Sprintf("<b>%s</b>: %v", k, v))
}
content = strings.Join(parts, "\n")
}
level := levelInfo
if l, ok := body["level"].(string); ok && l != "" {
level = strings.ToUpper(l)
}
var text string
if template != "" {
text = ParseTemplate(template, body)
} else {
text = fmt.Sprintf("<b>[%s] %s</b>\n\n%s", escapeHTML(level), escapeHTML(title), escapeHTML(content))
}
// Try sending with HTML parse mode
err := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, text, "HTML")
if err != nil {
// Fallback: send as plain text without parse mode
plainText := text
if template == "" {
plainText = fmt.Sprintf("[%s] %s\n\n%s", level, title, content)
}
fallbackErr := p.sendMessage(ctx, baseURL, cfg.Secret, chatID, plainText, "")
if fallbackErr != nil {
return fmt.Errorf("telegram: send message failed (fallback also failed): %w (original HTML error: %v)", fallbackErr, err)
}
}
return nil
}
// ValidateConfig 校验 Telegram 配置
func (p *TelegramPusher) ValidateConfig(cfg Config) error {
if cfg.Secret == "" {
return errors.New("bot Token (Secret) is required")
}
if cfg.URL != "" {
if !strings.HasPrefix(cfg.URL, "http://") && !strings.HasPrefix(cfg.URL, "https://") {
return errors.New("API base URL must start with http:// or https://")
}
}
return nil
}
func (p *TelegramPusher) sendMessage(ctx context.Context, baseURL, token, chatID, text, parseMode string) error {
apiURL := fmt.Sprintf("%s/bot%s/sendMessage", baseURL, token)
reqPayload := telegramMessageRequest{
ChatID: chatID,
Text: text,
ParseMode: parseMode,
}
jsonData, err := json.Marshal(reqPayload)
if err != nil {
return fmt.Errorf("marshal request failed: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("create http request failed: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Do(httpReq)
if err != nil {
return fmt.Errorf("http request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
var errRes telegramErrorResponse
if decodeErr := json.NewDecoder(resp.Body).Decode(&errRes); decodeErr == nil {
return fmt.Errorf("http status %d: %s", resp.StatusCode, errRes.Description)
}
return fmt.Errorf("http status %s", resp.Status)
}
return nil
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
}
+116
View File
@@ -0,0 +1,116 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package push
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTelegramPusher_Send(t *testing.T) {
t.Run("successful send with HTML parse mode", func(t *testing.T) {
var receivedReq telegramMessageRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/botmy-token/sendMessage", r.URL.Path)
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
err := json.NewDecoder(r.Body).Decode(&receivedReq)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}))
defer server.Close()
pusher := &TelegramPusher{}
cfg := Config{
Channel: "telegram",
URL: server.URL,
Secret: "my-token",
}
body := map[string]any{
"title": "Alert",
"content": "Host down",
"level": "CRITICAL",
}
err := pusher.Send(context.Background(), cfg, "123456", body, "", nil)
require.NoError(t, err)
assert.Equal(t, "123456", receivedReq.ChatID)
assert.Contains(t, receivedReq.Text, "[CRITICAL] Alert")
assert.Contains(t, receivedReq.Text, "Host down")
assert.Equal(t, "HTML", receivedReq.ParseMode)
})
t.Run("fallback to plain text on HTML error", func(t *testing.T) {
var requests []*telegramMessageRequest
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req telegramMessageRequest
err := json.NewDecoder(r.Body).Decode(&req)
require.NoError(t, err)
requests = append(requests, &req)
if len(requests) == 1 {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"ok": false, "error_code": 400, "description": "Bad Request: can't parse entities"}`))
} else {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok": true}`))
}
}))
defer server.Close()
pusher := &TelegramPusher{}
cfg := Config{
Channel: "telegram",
URL: server.URL,
Secret: "my-token",
}
body := map[string]any{
"title": "Alert & Info",
"content": "A < B comparison",
"level": "INFO",
}
err := pusher.Send(context.Background(), cfg, "123456", body, "", nil)
require.NoError(t, err)
require.Len(t, requests, 2)
assert.Equal(t, "HTML", requests[0].ParseMode)
assert.Equal(t, "", requests[1].ParseMode)
assert.Contains(t, requests[1].Text, "[INFO] Alert & Info")
assert.Contains(t, requests[1].Text, "A < B comparison")
})
t.Run("validation error", func(t *testing.T) {
pusher := &TelegramPusher{}
cfg := Config{
Channel: "telegram",
URL: "https://api.telegram.org",
}
err := pusher.ValidateConfig(cfg)
assert.Error(t, err)
cfg = Config{
Channel: "telegram",
URL: "ftp://api.telegram.org",
Secret: "token",
}
err = pusher.ValidateConfig(cfg)
assert.Error(t, err)
cfg = Config{
Channel: "telegram",
Secret: "token",
}
err = pusher.ValidateConfig(cfg)
assert.NoError(t, err)
})
}