Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions alert/sender/provider/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ func init() {
DefaultRegistry.Register(&TencentVoiceProvider{})
DefaultRegistry.Register(&AliyunSmsProvider{})
DefaultRegistry.Register(&AliyunVoiceProvider{})
DefaultRegistry.Register(&PlivoSmsProvider{})
DefaultRegistry.Register(&PlivoVoiceProvider{})
DefaultRegistry.Register(&PagerDutyProvider{})
DefaultRegistry.Register(&ScriptProvider{})
DefaultRegistry.Register(&EmailProvider{})
Expand Down
122 changes: 122 additions & 0 deletions alert/sender/provider/plivo_common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package provider

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"

"github.com/ccfos/nightingale/v6/models"
"github.com/toolkits/pkg/logger"
)

const plivoAPIBase = "https://api.plivo.com/v1/Account"

// normalizePlivoNumber strips a single leading "+" so operators may enter a
// number in either form. Plivo accepts the bare-digit E.164 form, and building
// the request body in code (rather than through a template) means the "+" is
// never HTML-escaped the way the generic HTTP body renderer would escape it.
func normalizePlivoNumber(n string) string {
return strings.TrimPrefix(strings.TrimSpace(n), "+")
}

// plivoContent extracts the rendered alert text from the notification template.
func plivoContent(tpl map[string]interface{}) string {
if tpl == nil {
return ""
}
if v, ok := tpl["content"]; ok {
return fmt.Sprintf("%v", v)
}
return ""
}

// postPlivoJSON POSTs a JSON payload to a Plivo endpoint with HTTP Basic auth
// and returns a "status_code:.., response:.." summary. A non-2xx status is
// returned as an error alongside the summary. Network errors are retried.
func postPlivoJSON(ctx context.Context, client *http.Client, cfg *models.PlivoRequestConfig,
endpoint string, payload map[string]interface{}) (string, error) {

if client == nil {
return "", fmt.Errorf("http client not found")
}

body, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("failed to marshal request body: %w", err)
}

retryTimes := 1
if cfg.RetryTimes > 0 {
retryTimes = cfg.RetryTimes
}
retrySleep := 200 * time.Millisecond
if cfg.RetrySleep > 0 {
retrySleep = time.Duration(cfg.RetrySleep) * time.Millisecond
}

auth := "Basic " + base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))

var lastErr error
for i := 0; i < retryTimes; i++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", auth)

resp, err := client.Do(req)
if err != nil {
lastErr = err
logger.Errorf("send_plivo: http_call=fail url=%s error=%v times=%d", endpoint, err, i+1)
time.Sleep(retrySleep)
continue
}

respBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
summary := fmt.Sprintf("status_code:%d, response:%s", resp.StatusCode, string(respBody))
logger.Infof("send_plivo: http_call=succ url=%s response_code=%d body=%s", endpoint, resp.StatusCode, string(respBody))

if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return summary, nil
}
return summary, fmt.Errorf("plivo returned status %d: %s", resp.StatusCode, string(respBody))
}

return fmt.Sprintf("request failed: %v", lastErr), fmt.Errorf("all retries failed, last error: %v", lastErr)
}

// notifyPlivo sends the given payload to every destination in sendtos and
// aggregates the per-target responses and errors, matching the multi-target
// pattern used by the other providers.
func notifyPlivo(ctx context.Context, req *NotifyRequest, endpoint string,
buildPayload func(dst string) map[string]interface{}) *NotifyResult {

cfg := req.Config.RequestConfig.PlivoRequestConfig
var responses, failed []string
for _, to := range req.Sendtos {
dst := normalizePlivoNumber(to)
resp, err := postPlivoJSON(ctx, req.HttpClient, cfg, endpoint, buildPayload(dst))
responses = append(responses, fmt.Sprintf("%s: %s", dst, resp))
if err != nil {
failed = append(failed, fmt.Sprintf("%s: %v", dst, err))
}
}

var aggErr error
if len(failed) > 0 {
aggErr = fmt.Errorf("%s", strings.Join(failed, " | "))
}
return &NotifyResult{
Target: strings.Join(req.Sendtos, ","),
Response: strings.Join(responses, "; "),
Err: aggErr,
}
}
46 changes: 46 additions & 0 deletions alert/sender/provider/plivo_sms_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package provider

import (
"context"
"errors"
"fmt"

"github.com/ccfos/nightingale/v6/models"
)

const PlivoSmsIdent = "plivo-sms"

type PlivoSmsProvider struct{}

func (p *PlivoSmsProvider) Ident() string {
return PlivoSmsIdent
}

func (p *PlivoSmsProvider) Check(config *models.NotifyChannelConfig) error {
if config.RequestType != "plivo" {
return errors.New("plivo sms provider requires request_type: plivo")
}
return config.ValidatePlivoRequestConfig()
}

func (p *PlivoSmsProvider) Notify(ctx context.Context, req *NotifyRequest) *NotifyResult {
if req.Config.RequestConfig == nil || req.Config.RequestConfig.PlivoRequestConfig == nil {
return &NotifyResult{Err: errors.New("plivo request config not found")}
}
if len(req.Sendtos) == 0 {
return &NotifyResult{Err: errors.New("plivo sms requires at least one destination number in sendtos")}
}

cfg := req.Config.RequestConfig.PlivoRequestConfig
text := plivoContent(req.TplContent)
src := normalizePlivoNumber(cfg.SrcNumber)
endpoint := fmt.Sprintf("%s/%s/Message/", plivoAPIBase, cfg.AuthID)

return notifyPlivo(ctx, req, endpoint, func(dst string) map[string]interface{} {
return map[string]interface{}{
"src": src,
"dst": dst,
"text": text,
}
})
}
61 changes: 61 additions & 0 deletions alert/sender/provider/plivo_voice_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package provider

import (
"context"
"errors"
"fmt"
"strings"

"github.com/ccfos/nightingale/v6/models"
)

const PlivoVoiceIdent = "plivo-voice"

type PlivoVoiceProvider struct{}

func (p *PlivoVoiceProvider) Ident() string {
return PlivoVoiceIdent
}

func (p *PlivoVoiceProvider) Check(config *models.NotifyChannelConfig) error {
if config.RequestType != "plivo" {
return errors.New("plivo voice provider requires request_type: plivo")
}
if err := config.ValidatePlivoRequestConfig(); err != nil {
return err
}
if config.RequestConfig.PlivoRequestConfig.AnswerURL == "" {
return errors.New("plivo voice provider requires answer_url")
}
return nil
}

func (p *PlivoVoiceProvider) Notify(ctx context.Context, req *NotifyRequest) *NotifyResult {
if req.Config.RequestConfig == nil || req.Config.RequestConfig.PlivoRequestConfig == nil {
return &NotifyResult{Err: errors.New("plivo request config not found")}
}
if len(req.Sendtos) == 0 {
return &NotifyResult{Err: errors.New("plivo voice requires at least one destination number in sendtos")}
}

cfg := req.Config.RequestConfig.PlivoRequestConfig
if cfg.AnswerURL == "" {
return &NotifyResult{Err: errors.New("plivo voice requires answer_url in the channel config")}
}
src := normalizePlivoNumber(cfg.SrcNumber)
// Plivo defaults answer_method to POST; only override when the operator set one.
answerMethod := strings.ToUpper(strings.TrimSpace(cfg.AnswerMethod))
if answerMethod == "" {
answerMethod = "POST"
}
endpoint := fmt.Sprintf("%s/%s/Call/", plivoAPIBase, cfg.AuthID)

return notifyPlivo(ctx, req, endpoint, func(dst string) map[string]interface{} {
return map[string]interface{}{
"from": src,
"to": dst,
"answer_url": cfg.AnswerURL,
"answer_method": answerMethod,
}
})
}
14 changes: 10 additions & 4 deletions models/message_tpl.go
Original file line number Diff line number Diff line change
Expand Up @@ -267,10 +267,12 @@ const (
)

var NewTplMap = map[string]string{
"ali-voice": `{{$event.RuleName}}`,
"ali-sms": `{{$event.RuleName}}`,
"tx-voice": `S{{$event.Severity}}{{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}{{$event.RuleName}}`,
"tx-sms": `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}规则名称: {{$event.RuleName}}`,
"ali-voice": `{{$event.RuleName}}`,
"ali-sms": `{{$event.RuleName}}`,
"tx-voice": `S{{$event.Severity}}{{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}{{$event.RuleName}}`,
"tx-sms": `级别状态: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}规则名称: {{$event.RuleName}}`,
"plivo-voice": `S{{$event.Severity}}{{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}}{{$event.RuleName}}`,
"plivo-sms": `Level Status: S{{$event.Severity}} {{if $event.IsRecovered}}Recovered{{else}}Triggered{{end}} Rule Name: {{$event.RuleName}}`,
Dingtalk: `#### {{if $event.IsRecovered}}<font color="#008800">💚{{$event.RuleName}}</font>{{else}}<font color="#FF0000">💔{{$event.RuleName}}</font>{{end}}
---
{{$time_duration := sub now.Unix $event.FirstTriggerTime }}{{if $event.IsRecovered}}{{$time_duration = sub $event.LastEvalTime $event.FirstTriggerTime }}{{end}}
Expand Down Expand Up @@ -704,6 +706,8 @@ var MsgTplMap = []MessageTemplate{
{Name: "Discord", Ident: Discord, Weight: 11, Content: map[string]string{"content": NewTplMap[Discord]}},
{Name: "Aliyun Voice", Ident: "ali-voice", Weight: 10, Content: map[string]string{"incident": NewTplMap["ali-voice"]}},
{Name: "Aliyun SMS", Ident: "ali-sms", Weight: 9, Content: map[string]string{"incident": NewTplMap["ali-sms"]}},
{Name: "Plivo Voice", Ident: "plivo-voice", Weight: 10, Content: map[string]string{"content": NewTplMap["plivo-voice"]}},
{Name: "Plivo SMS", Ident: "plivo-sms", Weight: 9, Content: map[string]string{"content": NewTplMap["plivo-sms"]}},
{Name: "Tencent Voice", Ident: "tx-voice", Weight: 8, Content: map[string]string{"content": NewTplMap["tx-voice"]}},
{Name: "Tencent SMS", Ident: "tx-sms", Weight: 7, Content: map[string]string{"content": NewTplMap["tx-sms"]}},
{Name: "Telegram", Ident: Telegram, Weight: 6, Content: map[string]string{"content": NewTplMap[Telegram]}},
Expand Down Expand Up @@ -1077,6 +1081,8 @@ var MsgTplMapEn = []MessageTemplate{
{Name: "Discord", Ident: Discord + "-en", NotifyChannelIdent: Discord, Lang: MsgTplLangEn, Weight: 11, Content: map[string]string{"content": NewTplMap[Discord]}},
{Name: "Aliyun Voice", Ident: "ali-voice-en", NotifyChannelIdent: "ali-voice", Lang: MsgTplLangEn, Weight: 10, Content: map[string]string{"incident": NewTplMap["ali-voice"]}},
{Name: "Aliyun SMS", Ident: "ali-sms-en", NotifyChannelIdent: "ali-sms", Lang: MsgTplLangEn, Weight: 9, Content: map[string]string{"incident": NewTplMap["ali-sms"]}},
{Name: "Plivo Voice", Ident: "plivo-voice-en", NotifyChannelIdent: "plivo-voice", Lang: MsgTplLangEn, Weight: 10, Content: map[string]string{"content": NewTplMap["plivo-voice"]}},
{Name: "Plivo SMS", Ident: "plivo-sms-en", NotifyChannelIdent: "plivo-sms", Lang: MsgTplLangEn, Weight: 9, Content: map[string]string{"content": NewTplMap["plivo-sms"]}},
{Name: "Tencent Voice", Ident: "tx-voice-en", NotifyChannelIdent: "tx-voice", Lang: MsgTplLangEn, Weight: 8, Content: map[string]string{"content": NewTplMap["tx-voice"]}},
{Name: "Tencent SMS", Ident: "tx-sms-en", NotifyChannelIdent: "tx-sms", Lang: MsgTplLangEn, Weight: 7, Content: map[string]string{"content": NewTplMapEn["tx-sms"]}},
{Name: "Telegram", Ident: Telegram + "-en", NotifyChannelIdent: Telegram, Lang: MsgTplLangEn, Weight: 6, Content: map[string]string{"content": NewTplMapEn[Telegram]}},
Expand Down
36 changes: 35 additions & 1 deletion models/notify_channel.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type RequestConfig struct {
ScriptRequestConfig *ScriptRequestConfig `json:"script_request_config,omitempty" gorm:"serializer:json"`
FlashDutyRequestConfig *FlashDutyRequestConfig `json:"flashduty_request_config,omitempty" gorm:"serializer:json"`
PagerDutyRequestConfig *PagerDutyRequestConfig `json:"pagerduty_request_config,omitempty" gorm:"serializer:json"`
PlivoRequestConfig *PlivoRequestConfig `json:"plivo_request_config,omitempty" gorm:"serializer:json"`
DingtalkAppRequestConfig *DingtalkAppRequestConfig `json:"dingtalkapp_request_config,omitempty" gorm:"serializer:json"`
FeishuAppRequestConfig *FeishuAppRequestConfig `json:"feishuapp_request_config,omitempty" gorm:"serializer:json"`
WecomAppRequestConfig *WecomAppRequestConfig `json:"wecomapp_request_config,omitempty" gorm:"serializer:json"`
Expand Down Expand Up @@ -99,6 +100,20 @@ type PagerDutyRequestConfig struct {
RetrySleep int `json:"retry_sleep"` // 重试等待时间(毫秒)
}

// PlivoRequestConfig Plivo 类型的参数配置,只保存用户自有的信息,
// URL、Method、请求体由 provider 在代码中构造
type PlivoRequestConfig struct {
AuthID string `json:"auth_id"` // Plivo Auth ID
AuthToken string `json:"auth_token"` // Plivo Auth Token
SrcNumber string `json:"src_number"` // 短信发送号码 / 语音主叫号码 (E.164)
AnswerURL string `json:"answer_url"` // 语音通道使用,返回 Plivo XML 的 answer_url
AnswerMethod string `json:"answer_method"` // 语音通道使用,拉取 answer_url 的 HTTP 方法,默认 POST
Proxy string `json:"proxy"`
Timeout int `json:"timeout"` // 超时时间(毫秒)
RetryTimes int `json:"retry_times"` // 重试次数
RetrySleep int `json:"retry_sleep"` // 重试等待时间(毫秒)
}

// ParamItem 自定义参数项
type ParamItem struct {
Key string `json:"key"` // 参数键名
Expand Down Expand Up @@ -308,6 +323,10 @@ func GetHTTPClient(nc *NotifyChannelConfig) (*http.Client, error) {
if nc.RequestType == "pagerduty" && nc.RequestConfig.PagerDutyRequestConfig != nil && nc.RequestConfig.PagerDutyRequestConfig.Proxy != "" {
proxy = nc.RequestConfig.PagerDutyRequestConfig.Proxy
}
// 对于 Plivo 类型,优先使用 Plivo 配置中的代理
if nc.RequestType == "plivo" && nc.RequestConfig.PlivoRequestConfig != nil && nc.RequestConfig.PlivoRequestConfig.Proxy != "" {
proxy = nc.RequestConfig.PlivoRequestConfig.Proxy
}
// TODO(dingtalkapp): 钉钉应用本次不上线,DingtalkApp 超时/代理合并分支先注释;上线时恢复。
// if nc.RequestType == "dingtalkapp" && nc.RequestConfig.DingtalkAppRequestConfig != nil {
// dingtalkAppTimeout := nc.RequestConfig.DingtalkAppRequestConfig.Timeout
Expand Down Expand Up @@ -402,10 +421,11 @@ func (ncc *NotifyChannelConfig) Verify() error {
ncc.RequestType != "script" &&
ncc.RequestType != "flashduty" &&
ncc.RequestType != "pagerduty" &&
ncc.RequestType != "plivo" &&
// ncc.RequestType != "dingtalkapp" &&
ncc.RequestType != "feishuapp" &&
ncc.RequestType != "wecomapp" {
return errors.New("invalid request type, must be one of 'http', 'smtp', 'script', 'flashduty', 'pagerduty', 'feishuapp', 'wecomapp'")
return errors.New("invalid request type, must be one of 'http', 'smtp', 'script', 'flashduty', 'pagerduty', 'plivo', 'feishuapp', 'wecomapp'")
}

if ncc.ParamConfig != nil {
Expand Down Expand Up @@ -499,6 +519,20 @@ func (ncc *NotifyChannelConfig) ValidatePagerDutyRequestConfig() error {
return nil
}

func (ncc *NotifyChannelConfig) ValidatePlivoRequestConfig() error {
c := ncc.RequestConfig.PlivoRequestConfig
if c == nil {
return errors.New("plivo request config cannot be nil")
}
if c.AuthID == "" || c.AuthToken == "" {
return errors.New("plivo request config requires auth_id and auth_token")
}
if c.SrcNumber == "" {
return errors.New("plivo request config requires src_number")
}
return nil
}

func (ncc *NotifyChannelConfig) Update(ctx *ctx.Context, ref NotifyChannelConfig) error {
ref.ID = ncc.ID
ref.CreateAt = ncc.CreateAt
Expand Down