mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-08 11:10:42 +01:00
Compare commits
5 Commits
main
...
feat/teleg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4105015444 | ||
|
|
e312d010db | ||
|
|
f76ab67a90 | ||
|
|
3c1a78c85e | ||
|
|
b63ad746b2 |
@@ -37,6 +37,7 @@ components:
|
||||
opsgenie: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelOpsgenieConfig'
|
||||
pagerduty: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelPagerdutyConfig'
|
||||
slack: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelSlackConfig'
|
||||
telegram: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
webhook: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -50,6 +51,7 @@ components:
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJiraConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelJSMOpsConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelIncidentIOConfig'
|
||||
- $ref: '#/components/schemas/AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig'
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelEmailConfig:
|
||||
properties:
|
||||
@@ -159,6 +161,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- telegram
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelTelegramConfig'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
AlertmanagertypesChannelConfigVariantGithubComSigNozSignozPkgTypesAlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
kind:
|
||||
@@ -311,6 +325,7 @@ components:
|
||||
- jira
|
||||
- jsmops
|
||||
- incidentio
|
||||
- telegram
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
@@ -515,6 +530,29 @@ components:
|
||||
- title
|
||||
- value
|
||||
type: object
|
||||
AlertmanagertypesChannelTelegramConfig:
|
||||
properties:
|
||||
apiUrl:
|
||||
type: string
|
||||
botToken:
|
||||
format: password
|
||||
type: string
|
||||
chatId:
|
||||
format: int64
|
||||
type: integer
|
||||
disableNotifications:
|
||||
type: boolean
|
||||
message:
|
||||
type: string
|
||||
messageThreadId:
|
||||
type: integer
|
||||
sendResolved:
|
||||
nullable: true
|
||||
type: boolean
|
||||
required:
|
||||
- botToken
|
||||
- chatId
|
||||
type: object
|
||||
AlertmanagertypesChannelWebhookConfig:
|
||||
properties:
|
||||
bearerToken:
|
||||
|
||||
@@ -277,10 +277,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.Annotations().Map()))
|
||||
for name, value := range r.Annotations().Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/slack"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/telegram"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/webhook"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config/receiver"
|
||||
@@ -32,6 +33,7 @@ var customNotifierIntegrations = []string{
|
||||
jira.Integration,
|
||||
jsmops.Integration,
|
||||
incidentio.Integration,
|
||||
telegram.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -102,6 +104,11 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return incidentio.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.TelegramConfigs {
|
||||
add(telegram.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return telegram.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
248
pkg/alertmanager/alertmanagernotify/telegram/telegram.go
Normal file
248
pkg/alertmanager/alertmanagernotify/telegram/telegram.go
Normal file
@@ -0,0 +1,248 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2022 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package telegram delivers alerts to a Telegram chat. It follows upstream's
|
||||
// notifier for everything about the transport and adds the per-rule title and
|
||||
// body templates carried on the _title_template and _body_template
|
||||
// annotations.
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
telebot "gopkg.in/telebot.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "telegram"
|
||||
|
||||
// Telegram supports 4096 chars max - from https://limits.tginfo.me/en.
|
||||
maxMessageLenRunes = 4096
|
||||
|
||||
parseModeHTML = "HTML"
|
||||
)
|
||||
|
||||
type Notifier struct {
|
||||
conf *config.TelegramConfig
|
||||
tmpl *template.Template
|
||||
logger *slog.Logger
|
||||
client *telebot.Bot
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
func New(conf *config.TelegramConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
if conf.HTTPConfig == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram http_config is nil")
|
||||
}
|
||||
|
||||
if conf.APIUrl == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "telegram api_url is nil")
|
||||
}
|
||||
|
||||
// Rendering targets the HTML subset only. A receiver written through the v1
|
||||
// API can still carry another mode, and sending HTML-escaped text under it
|
||||
// would mangle the message, so the integration refuses to build instead.
|
||||
if conf.ParseMode != parseModeHTML {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "telegram parse_mode %q is not supported, only %s is", conf.ParseMode, parseModeHTML)
|
||||
}
|
||||
|
||||
httpclient, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client, err := telebot.NewBot(telebot.Settings{
|
||||
URL: conf.APIUrl.String(),
|
||||
ParseMode: conf.ParseMode,
|
||||
Client: httpclient,
|
||||
Offline: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
logger := n.logger.With(slog.String("group_key", key.String()))
|
||||
|
||||
messageText, err := n.buildMessage(ctx, logger, as)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
messageText, truncated := notify.TruncateInRunes(messageText, maxMessageLenRunes)
|
||||
if truncated {
|
||||
logger.WarnContext(ctx, "truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
botToken, err := n.getBotToken()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
n.client.Token = botToken
|
||||
|
||||
chatID, err := n.getChatID()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
message, err := n.client.Send(telebot.ChatID(chatID), messageText, &telebot.SendOptions{
|
||||
DisableNotification: n.conf.DisableNotifications,
|
||||
DisableWebPagePreview: true,
|
||||
ThreadID: n.conf.MessageThreadID,
|
||||
ParseMode: n.conf.ParseMode,
|
||||
})
|
||||
if err != nil {
|
||||
return true, notify.NewErrorWithReason(notify.ClientErrorReason, err)
|
||||
}
|
||||
|
||||
logger.DebugContext(ctx, "telegram message published", slog.Int("message_id", message.ID), slog.Int64("chat_id", message.Chat.ID))
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// buildMessage renders the channel's own message template unless the rule
|
||||
// carries templates of its own, in which case those replace it.
|
||||
func (n *Notifier) buildMessage(ctx context.Context, logger *slog.Logger, as []*types.Alert) (string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
|
||||
if customTitle == "" && customBody == "" {
|
||||
return n.renderChannelTemplate(ctx, as)
|
||||
}
|
||||
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
}, as)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if len(result.MissingVars) > 0 {
|
||||
logger.WarnContext(ctx, "unresolved variables in rule template", slog.Any("template.missing_vars", result.MissingVars))
|
||||
}
|
||||
|
||||
// A rule may set only one of the two, so the other half still comes from
|
||||
// the channel.
|
||||
body := strings.Join(nonEmpty(result.Body), "\n\n")
|
||||
if customBody == "" {
|
||||
body, err = n.renderChannelTemplate(ctx, as)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
} else {
|
||||
body, err = n.renderRuleTemplate(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if result.Title == "" {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
title, err := n.renderRuleTemplate(result.Title)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace("<b>" + title + "</b>\n\n" + body), nil
|
||||
}
|
||||
|
||||
// renderChannelTemplate reproduces upstream's rendering, including its choice
|
||||
// of engine under parse_mode HTML: html/template escapes every interpolated
|
||||
// value, so a channel template that does not pipe through html still sends.
|
||||
func (n *Notifier) renderChannelTemplate(ctx context.Context, as []*types.Alert) (string, error) {
|
||||
var tmplErr error
|
||||
data := notify.GetTemplateData(ctx, n.tmpl, as, n.logger)
|
||||
|
||||
message := notify.TmplHTML(n.tmpl, data, &tmplErr)(n.conf.Message)
|
||||
if tmplErr != nil {
|
||||
return "", errors.NewInvalidInputf(errors.CodeInvalidInput, "failed to execute telegram message template: %s", tmplErr.Error())
|
||||
}
|
||||
|
||||
return message, nil
|
||||
}
|
||||
|
||||
// renderRuleTemplate converts the templater's output from markdown, which is
|
||||
// the format rule templates are authored in for every channel, into Telegram's
|
||||
// HTML subset. The renderer escapes as it goes, so a stray < or & in an alert
|
||||
// value cannot fail the send with "can't parse entities".
|
||||
func (n *Notifier) renderRuleTemplate(markdown string) (string, error) {
|
||||
rendered, err := markdownrenderer.RenderTelegramHTML(markdown)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(rendered), nil
|
||||
}
|
||||
|
||||
func (n *Notifier) getBotToken() (string, error) {
|
||||
if n.conf.BotTokenFile == "" {
|
||||
return string(n.conf.BotToken), nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(n.conf.BotTokenFile)
|
||||
if err != nil {
|
||||
return "", errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.BotTokenFile)
|
||||
}
|
||||
|
||||
return strings.TrimSpace(string(content)), nil
|
||||
}
|
||||
|
||||
func (n *Notifier) getChatID() (int64, error) {
|
||||
if n.conf.ChatIDFile == "" {
|
||||
return n.conf.ChatID, nil
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(n.conf.ChatIDFile)
|
||||
if err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not read %s", n.conf.ChatIDFile)
|
||||
}
|
||||
|
||||
chatID, err := strconv.ParseInt(strings.TrimSpace(string(content)), 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.WrapInternalf(err, errors.CodeInternal, "could not parse chat_id from %s", n.conf.ChatIDFile)
|
||||
}
|
||||
|
||||
return chatID, nil
|
||||
}
|
||||
|
||||
func nonEmpty(parts []string) []string {
|
||||
kept := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
kept = append(kept, part)
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
265
pkg/alertmanager/alertmanagernotify/telegram/telegram_test.go
Normal file
265
pkg/alertmanager/alertmanagernotify/telegram/telegram_test.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
test "github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/alertmanagernotifytest"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// mockBotAPI answers sendMessage the way api.telegram.org does. telebot posts
|
||||
// every parameter as a string but parses chat.id back into an int64, so the
|
||||
// echoed id has to be a number or the send fails and the pipeline retries.
|
||||
type mockBotAPI struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
sent []map[string]string
|
||||
}
|
||||
|
||||
func newMockBotAPI(t *testing.T) *mockBotAPI {
|
||||
t.Helper()
|
||||
mock := &mockBotAPI{}
|
||||
mock.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var params map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(¶ms)
|
||||
|
||||
mock.mu.Lock()
|
||||
mock.sent = append(mock.sent, params)
|
||||
mock.mu.Unlock()
|
||||
|
||||
chatID, _ := strconv.ParseInt(params["chat_id"], 10, 64)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"ok":true,"result":{"message_id":1,"date":0,"chat":{"id":` + strconv.FormatInt(chatID, 10) + `,"type":"private"}}}`))
|
||||
}))
|
||||
t.Cleanup(mock.srv.Close)
|
||||
|
||||
return mock
|
||||
}
|
||||
|
||||
func (m *mockBotAPI) only(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
require.Len(t, m.sent, 1)
|
||||
|
||||
return m.sent[0]
|
||||
}
|
||||
|
||||
func newNotifier(t *testing.T, mock *mockBotAPI) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
apiURL, err := url.Parse(mock.srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
notifier, err := New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: "HTML",
|
||||
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
|
||||
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
|
||||
require.NoError(t, err)
|
||||
|
||||
return notifier
|
||||
}
|
||||
|
||||
func newAlert(host string, annotations model.LabelSet) *types.Alert {
|
||||
merged := model.LabelSet{"summary": "p99 is 612ms"}
|
||||
for name, value := range annotations {
|
||||
merged[name] = value
|
||||
}
|
||||
|
||||
return &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "checkout-latency-high", "severity": "warning", "host": model.LabelValue(host)},
|
||||
Annotations: merged,
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
GeneratorURL: "http://am/alerts/overview?ruleId=r1",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyBuildsTheMessage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
alerts []*types.Alert
|
||||
wantExact string
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "NoRuleTemplates_UsesChannelTemplate",
|
||||
alerts: []*types.Alert{newAlert("web-3", nil)},
|
||||
wantContains: []string{
|
||||
"<b>[FIRING:1] checkout-latency-high</b>",
|
||||
"<b>Summary:</b> p99 is 612ms",
|
||||
`<a href="http://am/alerts/overview?ruleId=r1">View in SigNoz</a>`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "TitleAndBody_ReplaceTheChannelTemplate",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
})},
|
||||
wantExact: "<b>Incident on web-3</b>\n\nfiring for checkout-latency-high",
|
||||
wantNotContains: []string{"View in SigNoz"},
|
||||
},
|
||||
{
|
||||
// $-refs are template variables, so the braced form is the real
|
||||
// syntax and the only one that reaches conditionals. A bare ref in
|
||||
// text is rewritten into an action before expansion.
|
||||
name: "BracedAndBareRefs_ExpandAlike",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "{{ $rule.name }}|$rule.name|{{ if $alert.is_firing }}up{{ end }}",
|
||||
})},
|
||||
wantExact: "checkout-latency-high|checkout-latency-high|up",
|
||||
},
|
||||
{
|
||||
name: "BodyOnly_OmitsTheTitleLine",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
})},
|
||||
wantExact: "firing for checkout-latency-high",
|
||||
},
|
||||
{
|
||||
name: "TitleOnly_KeepsTheChannelBody",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationTitleTemplate: "Incident on $labels.host",
|
||||
})},
|
||||
wantContains: []string{"<b>Incident on web-3</b>", "<b>Summary:</b> p99 is 612ms"},
|
||||
},
|
||||
{
|
||||
// Rule templates are authored in markdown for every channel, so
|
||||
// Telegram converts rather than escaping the markers away.
|
||||
name: "RuleTemplateMarkdown_RenderedToTelegramHTML",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: "**{{ $rule.name }}** on `{{ $labels.host }}` — [open](https://signoz.io/d?a=1&b=2)",
|
||||
})},
|
||||
wantExact: `<b>checkout-latency-high</b> on <code>web-3</code> — <a href="https://signoz.io/d?a=1&b=2">open</a>`,
|
||||
},
|
||||
{
|
||||
// The templater renders plain text, so anything interpolated from
|
||||
// an alert has to be escaped before a parse_mode HTML send.
|
||||
name: "RuleTemplateOutput_EscapedForHTML",
|
||||
alerts: []*types.Alert{newAlert("web-3", model.LabelSet{
|
||||
"summary": "p99 < 2s & rising",
|
||||
ruletypes.AnnotationTitleTemplate: "$annotations.summary",
|
||||
ruletypes.AnnotationBodyTemplate: "$annotations.summary",
|
||||
})},
|
||||
wantExact: "<b>p99 < 2s & rising</b>\n\np99 < 2s & rising",
|
||||
},
|
||||
{
|
||||
name: "GroupedAlerts_BodyRenderedPerAlert",
|
||||
alerts: []*types.Alert{
|
||||
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
},
|
||||
wantExact: "host web-3\n\nhost web-4",
|
||||
},
|
||||
{
|
||||
// Picking one rule's template for a mixed group would be arbitrary,
|
||||
// so the channel template is used instead.
|
||||
name: "GroupedAlerts_DisagreeingTemplates_FallBackToChannel",
|
||||
alerts: []*types.Alert{
|
||||
newAlert("web-3", model.LabelSet{ruletypes.AnnotationBodyTemplate: "host $labels.host"}),
|
||||
newAlert("web-4", model.LabelSet{ruletypes.AnnotationBodyTemplate: "something else"}),
|
||||
},
|
||||
wantContains: []string{"<b>Summary:</b> p99 is 612ms"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
|
||||
retry, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), testCase.alerts...)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
|
||||
text := mock.only(t)["text"]
|
||||
if testCase.wantExact != "" {
|
||||
assert.Equal(t, testCase.wantExact, text)
|
||||
}
|
||||
for _, want := range testCase.wantContains {
|
||||
assert.Contains(t, text, want)
|
||||
}
|
||||
for _, notWant := range testCase.wantNotContains {
|
||||
assert.NotContains(t, text, notWant)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A v1-written receiver can carry a mode the v2 spec no longer offers. Nothing
|
||||
// renders to those, so the integration refuses to build rather than sending
|
||||
// HTML-escaped text under a markdown parser.
|
||||
func TestNewRejectsAParseModeOtherThanHTML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
parseMode string
|
||||
wantErr bool
|
||||
}{
|
||||
{"HTML", "HTML", false},
|
||||
{"MarkdownV2", "MarkdownV2", true},
|
||||
{"Markdown", "Markdown", true},
|
||||
{"Empty", "", true},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
apiURL, err := url.Parse(mock.srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
tmpl := test.CreateTmpl(t)
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
_, err = New(&config.TelegramConfig{
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
APIUrl: &config.URL{URL: apiURL},
|
||||
BotToken: "123456:tok",
|
||||
ChatID: -1001234567890,
|
||||
ParseMode: testCase.parseMode,
|
||||
Message: alertmanagertypes.DefaultTelegramMessageTemplate,
|
||||
}, tmpl, logger, alertmanagertemplate.New(tmpl, logger))
|
||||
|
||||
if testCase.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifySendsTheConfiguredSendOptions(t *testing.T) {
|
||||
mock := newMockBotAPI(t)
|
||||
|
||||
_, err := newNotifier(t, mock).Notify(notify.WithGroupKey(context.Background(), "1"), newAlert("web-3", nil))
|
||||
require.NoError(t, err)
|
||||
|
||||
sent := mock.only(t)
|
||||
assert.Equal(t, "-1001234567890", sent["chat_id"])
|
||||
assert.Equal(t, "HTML", sent["parse_mode"])
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/rulestatehistorytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
@@ -253,6 +254,24 @@ func (r *BaseRule) GeneratorURL() string {
|
||||
return r.ExternalURL("alerts/overview", params)
|
||||
}
|
||||
|
||||
// ExpandAnnotations runs expand over the rule's annotations, leaving private
|
||||
// ones as they are. Those carry templates the notifier expands against its own
|
||||
// data model, and the ruler's expander rewrites every $-ref into a label
|
||||
// lookup, which blanks the ones that are not labels.
|
||||
func (r *BaseRule) ExpandAnnotations(expand func(string) string) ruletypes.Labels {
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
if alertmanagertypes.IsPrivateAnnotation(name) {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: value})
|
||||
continue
|
||||
}
|
||||
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
|
||||
return annotations
|
||||
}
|
||||
|
||||
func (r *BaseRule) ExternalURL(path string, params url.Values) string {
|
||||
u := r.externalURL.JoinPath(path)
|
||||
if len(params) > 0 {
|
||||
|
||||
@@ -833,3 +833,55 @@ func labelsKey(lbls []*qbtypes.Label) string {
|
||||
}
|
||||
return temp.Labels().String()
|
||||
}
|
||||
|
||||
func TestExpandAnnotations(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
annotations map[string]string
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "PublicAnnotation_Expanded",
|
||||
annotations: map[string]string{"summary": "observed {{$value}}"},
|
||||
want: map[string]string{"summary": "observed 0.35"},
|
||||
},
|
||||
{
|
||||
// The ruler's expander rewrites every $-ref into a label lookup, so
|
||||
// expanding these would blank the paths the notifier resolves.
|
||||
name: "PrivateAnnotation_LeftAlone",
|
||||
annotations: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
|
||||
want: map[string]string{ruletypes.AnnotationTitleTemplate: "{{ $rule.name }} on {{ $labels.host }}"},
|
||||
},
|
||||
{
|
||||
name: "MixedAnnotations_OnlyPublicExpanded",
|
||||
annotations: map[string]string{
|
||||
"summary": "observed {{$value}}",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
},
|
||||
want: map[string]string{
|
||||
"summary": "observed 0.35",
|
||||
ruletypes.AnnotationBodyTemplate: "$alert.status for $rule.name",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rule := &BaseRule{annotations: ruletypes.FromMap(testCase.annotations)}
|
||||
expand := func(text string) string {
|
||||
tmpl := ruletypes.NewTemplateExpander(
|
||||
context.Background(),
|
||||
"{{$labels := .Labels}}{{$value := .Value}}{{$threshold := .Threshold}}"+text,
|
||||
"__alert_test",
|
||||
ruletypes.AlertTemplateData(map[string]string{}, "0.35", "0.32"),
|
||||
nil,
|
||||
)
|
||||
out, err := tmpl.Expand()
|
||||
require.NoError(t, err)
|
||||
return out
|
||||
}
|
||||
|
||||
assert.Equal(t, testCase.want, rule.ExpandAnnotations(expand).Map())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,10 +211,7 @@ func (r *PromRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if result.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -295,10 +295,7 @@ func (r *ThresholdRule) Eval(ctx context.Context, ts time.Time) (int, error) {
|
||||
lb.Set(ruletypes.AlertRuleIDLabel, r.ID())
|
||||
lb.Set(ruletypes.RuleSourceLabel, r.GeneratorURL())
|
||||
|
||||
annotations := make(ruletypes.Labels, 0, len(r.annotations.Map()))
|
||||
for name, value := range r.annotations.Map() {
|
||||
annotations = append(annotations, ruletypes.Label{Name: name, Value: expand(value)})
|
||||
}
|
||||
annotations := r.ExpandAnnotations(expand)
|
||||
if smpl.IsMissing {
|
||||
lb.Set(ruletypes.AlertNameLabel, "[No data] "+r.Name())
|
||||
lb.Set(ruletypes.NoDataLabel, "true")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/telegram"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
@@ -38,6 +39,11 @@ var (
|
||||
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
|
||||
},
|
||||
}
|
||||
telegramPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(telegram.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
@@ -67,6 +73,14 @@ func RenderPlainText(markdown string) (string, error) {
|
||||
return render(md, markdown, "plain text")
|
||||
}
|
||||
|
||||
// RenderTelegramHTML converts markdown to the HTML subset Telegram's
|
||||
// parse_mode HTML accepts, flattening constructs it has no tag for.
|
||||
func RenderTelegramHTML(markdown string) (string, error) {
|
||||
md := telegramPool.Get().(goldmark.Markdown)
|
||||
defer telegramPool.Put(md)
|
||||
return render(md, markdown, "Telegram HTML")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
|
||||
360
pkg/templating/markdownrenderer/telegram/telegram.go
Normal file
360
pkg/templating/markdownrenderer/telegram/telegram.go
Normal file
@@ -0,0 +1,360 @@
|
||||
// Package telegram provides a goldmark node renderer that emits the HTML
|
||||
// subset Telegram's parse_mode HTML accepts. Telegram rejects the whole
|
||||
// message when it meets a tag outside that set, so constructs with no
|
||||
// equivalent (headings, lists, tables, images) are flattened to text rather
|
||||
// than rendered with their usual tags.
|
||||
// https://core.telegram.org/bots/api#html-style
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// textEscaper covers the three characters Telegram requires as entities in
|
||||
// message text. attrEscaper additionally covers the quote that would end an
|
||||
// href value early.
|
||||
var (
|
||||
textEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
attrEscaper = strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """)
|
||||
)
|
||||
|
||||
// Extender registers the Telegram node renderer plus the GFM extensions it
|
||||
// handles (tables, strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
|
||||
// concurrent Convert calls; callers pool one instance per goroutine.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderHeading)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlockquote)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindHTMLBlock, r.renderRawHTML)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderBlock)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderEmphasis)
|
||||
reg.Register(ast.KindImage, r.renderImage)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindString, r.renderString)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderStrikethrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
// wrap emits an opening tag on the way in and its closing tag on the way out.
|
||||
func wrap(w util.BufWriter, tag string, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString("<" + tag + ">")
|
||||
} else {
|
||||
_, _ = w.WriteString("</" + tag + ">")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.prefixes = r.prefixes[:0]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderHeading emits bold text: Telegram has no heading tag.
|
||||
func (r *nodeRenderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return wrap(w, "b", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return wrap(w, "blockquote", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderEmphasis(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
tag := "i"
|
||||
if node.(*ast.Emphasis).Level >= 2 {
|
||||
tag = "b"
|
||||
}
|
||||
return wrap(w, tag, entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderStrikethrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return wrap(w, "s", entering)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, n)
|
||||
|
||||
var body bytes.Buffer
|
||||
for i := 0; i < n.Lines().Len(); i++ {
|
||||
line := n.Lines().At(i)
|
||||
body.Write(line.Value(source))
|
||||
}
|
||||
|
||||
_, _ = w.WriteString("<pre>")
|
||||
_, _ = w.WriteString(textEscaper.Replace(strings.TrimRight(body.String(), "\n")))
|
||||
_, _ = w.WriteString("</pre>")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
var body bytes.Buffer
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
value := c.(*ast.Text).Segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
body.Write(value[:len(value)-1])
|
||||
body.WriteByte(' ')
|
||||
} else {
|
||||
body.Write(value)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = w.WriteString("<code>" + textEscaper.Replace(body.String()) + "</code>")
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderList and renderListItem mirror the plain-text renderer: Telegram has no
|
||||
// list tag, so items become prefixed lines.
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
parent := n.Parent().(*ast.List)
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, "%d. ", index)
|
||||
} else {
|
||||
_, _ = w.WriteString("• ")
|
||||
}
|
||||
r.prefixes = append(r.prefixes, " ")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("———")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
_, _ = w.WriteString(`<a href="` + attrEscaper.Replace(url) + `">` + textEscaper.Replace(url) + "</a>")
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
n := node.(*ast.Link)
|
||||
if entering {
|
||||
_, _ = w.WriteString(`<a href="` + attrEscaper.Replace(string(n.Destination)) + `">`)
|
||||
} else {
|
||||
_, _ = w.WriteString("</a>")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderImage flattens to "alt (url)": a Telegram text message cannot carry an
|
||||
// inline image, and <img> is not on the allowlist.
|
||||
func (r *nodeRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
n := node.(*ast.Image)
|
||||
if len(n.Destination) > 0 {
|
||||
_, _ = fmt.Fprintf(w, " (%s)", textEscaper.Replace(string(n.Destination)))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
n := node.(*ast.Text)
|
||||
_, _ = w.WriteString(textEscaper.Replace(string(n.Segment.Value(source))))
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.WriteString(textEscaper.Replace(string(node.(*ast.String).Value)))
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderRawHTML drops markup the author embedded by hand. Passing it through
|
||||
// would hand Telegram tags outside the allowlist and fail the whole send.
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// renderTable flattens to "cell | cell" lines: Telegram has no table tag.
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
first := true
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
first = false
|
||||
|
||||
cellFirst := true
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() != extensionast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
if !cellFirst {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
cellFirst = false
|
||||
_, _ = w.WriteString(textEscaper.Replace(extractPlainText(cc, source)))
|
||||
}
|
||||
}
|
||||
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := node.(type) {
|
||||
case *ast.Text:
|
||||
buf.Write(t.Segment.Value(source))
|
||||
case *ast.String:
|
||||
buf.Write(t.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
75
pkg/templating/markdownrenderer/telegram/telegram_test.go
Normal file
75
pkg/templating/markdownrenderer/telegram/telegram_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func render(t *testing.T, markdown string) string {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, goldmark.New(goldmark.WithExtensions(Extender)).Convert([]byte(markdown), &buf))
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func TestRenderEmitsTelegramHTML(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{"Bold", "**critical**", "<b>critical</b>"},
|
||||
{"Italic", "*degraded*", "<i>degraded</i>"},
|
||||
{"Strikethrough", "~~resolved~~", "<s>resolved</s>"},
|
||||
{"CodeSpan", "use `kubectl get pods`", "use <code>kubectl get pods</code>"},
|
||||
{"Link", "[dashboard](https://signoz.io/d?a=1&b=2)", `<a href="https://signoz.io/d?a=1&b=2">dashboard</a>`},
|
||||
{"AutoLink", "<https://signoz.io>", `<a href="https://signoz.io">https://signoz.io</a>`},
|
||||
{"Blockquote", "> escalate now", "<blockquote>escalate now</blockquote>"},
|
||||
// Telegram has no heading, list, table or image tag, so these flatten.
|
||||
{"Heading_BecomesBold", "## Incident", "<b>Incident</b>"},
|
||||
{"BulletList_BecomesLines", "- one\n- two", "• one\n• two"},
|
||||
{"OrderedList_BecomesLines", "1. one\n2. two", "1. one\n2. two"},
|
||||
{"Table_BecomesPipedLines", "| a | b |\n|---|---|\n| 1 | 2 |", "a | b\n1 | 2"},
|
||||
{"Image_BecomesTextAndURL", "", "graph (https://signoz.io/g.png)"},
|
||||
{"ThematicBreak", "a\n\n---\n\nb", "a\n\n———\n\nb"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
assert.Equal(t, testCase.want, render(t, testCase.markdown))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram drops the whole message on an unparseable entity, so anything that
|
||||
// could be read as markup has to leave as an entity.
|
||||
func TestRenderEscapesEverythingElse(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
markdown string
|
||||
want string
|
||||
}{
|
||||
{"BareSpecialChars", "p99 < 2s & rising", "p99 < 2s & rising"},
|
||||
{"InsideBold", "**a < b**", "<b>a < b</b>"},
|
||||
{"InsideCodeSpan", "`if a < b && c`", "<code>if a < b && c</code>"},
|
||||
{"InsideCodeBlock", "```\na < b\n```", "<pre>a < b</pre>"},
|
||||
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a < b</a>`},
|
||||
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q="">x</a>`},
|
||||
// Hand-written markup would hand Telegram tags outside the allowlist.
|
||||
// The tags go; the text between them stays.
|
||||
{"RawHTMLTagsDropped_TextKept", "before <marquee>x</marquee> after", "before x after"},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
rendered := render(t, testCase.markdown)
|
||||
assert.Equal(t, testCase.want, rendered)
|
||||
assert.NotContains(t, rendered, "&lt;", "double-escaped")
|
||||
})
|
||||
}
|
||||
}
|
||||
133
pkg/types/alertmanagertypes/channel_telegram.go
Normal file
133
pkg/types/alertmanagertypes/channel_telegram.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
// Telegram renders the message with parse_mode HTML, which only accepts a small
|
||||
// tag set and requires every stray <, > and & to be escaped. Each interpolated
|
||||
// value is piped through html so the template renders identically under both
|
||||
// template engines; an unescaped alert annotation would otherwise fail the
|
||||
// whole send with "can't parse entities".
|
||||
// https://core.telegram.org/bots/api#html-style
|
||||
const DefaultTelegramMessageTemplate = `<b>[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname | html }}</b>
|
||||
|
||||
{{ range .Alerts -}}
|
||||
<b>Alert:</b> {{ .Labels.alertname | html }}{{ if .Labels.severity }} ({{ .Labels.severity | html }}){{ end }}
|
||||
{{ if .Annotations.summary }}<b>Summary:</b> {{ .Annotations.summary | html }}
|
||||
{{ end }}{{ if .Annotations.description }}<b>Description:</b> {{ .Annotations.description | html }}
|
||||
{{ end }}{{ if .GeneratorURL }}<a href="{{ .GeneratorURL | html }}">View in SigNoz</a>
|
||||
{{ end }}{{ if .Annotations.related_logs }}<a href="{{ .Annotations.related_logs | html }}">View related logs</a>
|
||||
{{ end }}{{ if .Annotations.related_traces }}<a href="{{ .Annotations.related_traces | html }}">View related traces</a>
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
|
||||
// telegramParseMode is the only mode SigNoz renders for. Upstream also accepts
|
||||
// Markdown, MarkdownV2 and the empty string, but those carry per-mode escaping
|
||||
// rules that nothing renders to, so the spec does not offer them.
|
||||
const telegramParseMode = "HTML"
|
||||
|
||||
// ChannelTelegramConfig configures delivery to a Telegram chat through a bot.
|
||||
// ChatID is the numeric chat the bot posts to, negative for groups and channels;
|
||||
// Telegram's @username form is not accepted upstream.
|
||||
type ChannelTelegramConfig struct {
|
||||
SendResolved *bool `json:"sendResolved,omitempty"`
|
||||
BotToken string `json:"botToken" required:"true" format:"password"`
|
||||
ChatID int64 `json:"chatId" required:"true"`
|
||||
// APIURL points at a self-hosted Bot API server. Left empty, the global
|
||||
// telegram_api_url applies.
|
||||
APIURL string `json:"apiUrl"`
|
||||
// MessageThreadID targets a topic inside a forum group.
|
||||
MessageThreadID int `json:"messageThreadId,omitempty"`
|
||||
Message valuer.UnsetOrNonEmptyString `json:"message,omitzero"`
|
||||
DisableNotifications bool `json:"disableNotifications,omitempty"`
|
||||
}
|
||||
|
||||
func (c *ChannelTelegramConfig) UnmarshalJSON(data []byte) error {
|
||||
type alias ChannelTelegramConfig
|
||||
if err := decodeStrict(data, (*alias)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fillSendResolved(&c.SendResolved, config.DefaultTelegramConfig.VSendResolved)
|
||||
c.Message.SetIfUnset(DefaultTelegramMessageTemplate)
|
||||
|
||||
return c.Validate()
|
||||
}
|
||||
|
||||
// Validate repeats the checks upstream's TelegramConfig.UnmarshalYAML makes, so
|
||||
// a bad channel fails on the request with a field name rather than as a yaml
|
||||
// error surfacing out of the config resolve.
|
||||
func (c ChannelTelegramConfig) Validate() error {
|
||||
if c.BotToken == "" {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.botToken is required for a telegram channel")
|
||||
}
|
||||
|
||||
if c.ChatID == 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "config.spec.chatId is required for a telegram channel")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ChannelTelegramConfig) toUndefaultedReceiver(displayName string) (*Receiver, error) {
|
||||
var apiURL *config.URL
|
||||
if c.APIURL != "" {
|
||||
parsed, err := parseUpstreamURL(c.APIURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiURL = parsed
|
||||
}
|
||||
|
||||
return &Receiver{Receiver: &config.Receiver{
|
||||
Name: displayName,
|
||||
TelegramConfigs: []*config.TelegramConfig{{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: resolveSendResolved(c.SendResolved, config.DefaultTelegramConfig.VSendResolved)},
|
||||
APIUrl: apiURL,
|
||||
BotToken: config.Secret(c.BotToken),
|
||||
ChatID: c.ChatID,
|
||||
MessageThreadID: c.MessageThreadID,
|
||||
Message: c.Message.StringValue(),
|
||||
ParseMode: telegramParseMode,
|
||||
DisableNotifications: c.DisableNotifications,
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func newChannelTelegramConfigFromReceiver(name string, receiver *Receiver) (ChannelSpec, error) {
|
||||
telegram := receiver.TelegramConfigs[0]
|
||||
sendResolved := telegram.VSendResolved
|
||||
|
||||
if err := rejectAnyHTTPAuth(name, telegram.HTTPConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Both read a path on the server's filesystem, so they are refused rather
|
||||
// than silently dropped on the way back out.
|
||||
if telegram.BotTokenFile != "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets bot_token_file, which is not supported", name)
|
||||
}
|
||||
|
||||
if telegram.ChatIDFile != "" {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets chat_id_file, which is not supported", name)
|
||||
}
|
||||
|
||||
// Dropping a non-HTML parse_mode on the way out would silently rewrite the
|
||||
// channel on the next write, so it is refused instead.
|
||||
if telegram.ParseMode != telegramParseMode {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeAlertmanagerChannelInvalid, "channel %q sets parse_mode %q, only %s is supported", name, telegram.ParseMode, telegramParseMode)
|
||||
}
|
||||
|
||||
return &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: string(telegram.BotToken),
|
||||
ChatID: telegram.ChatID,
|
||||
APIURL: formatUpstreamURL(telegram.APIUrl),
|
||||
MessageThreadID: telegram.MessageThreadID,
|
||||
Message: valuer.UnsetIfEmpty(telegram.Message),
|
||||
DisableNotifications: telegram.DisableNotifications,
|
||||
}, nil
|
||||
}
|
||||
129
pkg/types/alertmanagertypes/channel_telegram_test.go
Normal file
129
pkg/types/alertmanagertypes/channel_telegram_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChannelTelegramConfigDefaults(t *testing.T) {
|
||||
var spec ChannelTelegramConfig
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"botToken":"123456:tok","chatId":-1001234567890}`), &spec))
|
||||
|
||||
require.NotNil(t, spec.SendResolved)
|
||||
assert.True(t, *spec.SendResolved, "upstream defaults telegram send_resolved on")
|
||||
assert.Equal(t, DefaultTelegramMessageTemplate, spec.Message.StringValue())
|
||||
assert.Empty(t, spec.APIURL, "the global telegram_api_url applies when none is given")
|
||||
}
|
||||
|
||||
func TestChannelTelegramConfigValidation(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
spec string
|
||||
}{
|
||||
{"BotToken_Missing", `{"chatId":1}`},
|
||||
{"ChatID_Missing", `{"botToken":"123456:tok"}`},
|
||||
{"ChatID_Username_Rejected", `{"botToken":"123456:tok","chatId":"@signoz"}`},
|
||||
{"ParseMode_NotAField", `{"botToken":"123456:tok","chatId":1,"parseMode":"HTML"}`},
|
||||
{"BotTokenFile_NotAField", `{"botToken":"123456:tok","chatId":1,"botTokenFile":"/etc/token"}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
var spec ChannelTelegramConfig
|
||||
assert.Error(t, json.Unmarshal([]byte(testCase.spec), &spec))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Settings the spec cannot represent would be dropped on read and silently
|
||||
// rewrite the channel on the next write, so the read fails instead.
|
||||
func TestDeriveChannelConfigRejectsUnsupportedTelegramSettings(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
data string
|
||||
}{
|
||||
{"BotTokenFile", `{"name":"tg","telegram_configs":[{"chat_id":1,"token_file":"/etc/token"}]}`},
|
||||
{"ChatIDFile", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat_file":"/etc/chat"}]}`},
|
||||
{"ParseMode_NotHTML", `{"name":"tg","telegram_configs":[{"token":"123456:tok","chat":1,"parse_mode":"MarkdownV2"}]}`},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
channel := Channel{DisplayName: "tg", Data: testCase.data}
|
||||
_, err := channel.deriveChannelConfig()
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTelegramMessageTemplateEscapesAlertContent(t *testing.T) {
|
||||
tmpl, err := FromGlobs([]string{})
|
||||
require.NoError(t, err)
|
||||
tmpl.ExternalURL = &url.URL{Scheme: "http", Host: "localhost:8080"}
|
||||
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "p99 < 2s & rising", "severity": "critical"},
|
||||
Annotations: model.LabelSet{"summary": "latency > 2s", "related_logs": "http://localhost:8080/logs?a=1&b=2"},
|
||||
GeneratorURL: "http://localhost:8080/alerts?a=1&b=2",
|
||||
},
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
data := tmpl.Data("__receiver", model.LabelSet{}, alert)
|
||||
|
||||
// The channel template is rendered with html/template under parse_mode HTML
|
||||
// and with text/template otherwise, so it has to escape identically under
|
||||
// both. html/template recognises the html pipeline and adds no second pass.
|
||||
renderers := map[string]func(string, any) (string, error){
|
||||
"Text": tmpl.ExecuteTextString,
|
||||
"HTML": tmpl.ExecuteHTMLString,
|
||||
}
|
||||
|
||||
for name, render := range renderers {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rendered, err := render(DefaultTelegramMessageTemplate, data)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Contains(t, rendered, "<b>[FIRING:1] p99 < 2s & rising</b>")
|
||||
assert.Contains(t, rendered, "<b>Summary:</b> latency > 2s")
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&b=2">View in SigNoz</a>`)
|
||||
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&b=2">View related logs</a>`)
|
||||
assert.NotContains(t, rendered, "&lt;", "double-escaped")
|
||||
assert.NotContains(t, rendered, "View related traces")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// APIUrl and HTTPConfig are filled from the global rather than the spec, and
|
||||
// the notifier refuses to start without them. A resolve that leaves either nil
|
||||
// fails the channel at delivery.
|
||||
func TestTelegramResolvesTheGlobalAPIURLAndHTTPConfig(t *testing.T) {
|
||||
cfg, err := NewDefaultConfig(GlobalConfig{}, RouteConfig{GroupInterval: 1 * time.Minute, GroupWait: 1 * time.Minute, RepeatInterval: 1 * time.Minute}, "org-1")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cfg.SetGlobalConfig(GlobalConfig{}))
|
||||
|
||||
spec := ChannelTelegramConfig{BotToken: "123456:tok", ChatID: -1001234567890}
|
||||
receiver, err := spec.toUndefaultedReceiver("tg")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, cfg.CreateReceiver(receiver))
|
||||
|
||||
resolved, err := cfg.Resolved()
|
||||
require.NoError(t, err)
|
||||
|
||||
resolvedReceiver, err := resolved.GetReceiver("tg")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, resolvedReceiver.TelegramConfigs, 1)
|
||||
|
||||
telegram := resolvedReceiver.TelegramConfigs[0]
|
||||
require.NotNil(t, telegram.APIUrl)
|
||||
assert.Equal(t, "https://api.telegram.org", telegram.APIUrl.String())
|
||||
assert.NotNil(t, telegram.HTTPConfig)
|
||||
}
|
||||
@@ -96,6 +96,7 @@ func (ChannelConfig) JSONSchemaOneOf() []any {
|
||||
ChannelConfigVariant[ChannelJiraConfig]{Kind: ChannelKindJira.StringValue()},
|
||||
ChannelConfigVariant[ChannelJSMOpsConfig]{Kind: ChannelKindJSMOps.StringValue()},
|
||||
ChannelConfigVariant[ChannelIncidentIOConfig]{Kind: ChannelKindIncidentIO.StringValue()},
|
||||
ChannelConfigVariant[ChannelTelegramConfig]{Kind: ChannelKindTelegram.StringValue()},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +114,7 @@ func (ChannelConfig) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
ChannelKindJira.StringValue(): channelVariantRef("ChannelJiraConfig"),
|
||||
ChannelKindJSMOps.StringValue(): channelVariantRef("ChannelJSMOpsConfig"),
|
||||
ChannelKindIncidentIO.StringValue(): channelVariantRef("ChannelIncidentIOConfig"),
|
||||
ChannelKindTelegram.StringValue(): channelVariantRef("ChannelTelegramConfig"),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -258,6 +258,28 @@ func TestDeriveChannelConfigRoundTripsEveryFieldOfEveryKind(t *testing.T) {
|
||||
Metadata: map[string]string{"team": "platform"},
|
||||
},
|
||||
},
|
||||
{
|
||||
description: "telegram",
|
||||
kind: ChannelKindTelegram,
|
||||
spec: &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: "123456:bot-token",
|
||||
ChatID: -1001234567890,
|
||||
APIURL: "https://bot-api.example.com",
|
||||
MessageThreadID: 42,
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
|
||||
DisableNotifications: true,
|
||||
},
|
||||
expectedRoundTrip: &ChannelTelegramConfig{
|
||||
SendResolved: &sendResolved,
|
||||
BotToken: "123456:bot-token",
|
||||
ChatID: -1001234567890,
|
||||
APIURL: "https://bot-api.example.com",
|
||||
MessageThreadID: 42,
|
||||
Message: valuer.MustNewUnsetOrNonEmptyString("telegram message"),
|
||||
DisableNotifications: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
@@ -396,8 +418,8 @@ func TestDeriveChannelConfigRejectsUnrepresentableChannels(t *testing.T) {
|
||||
{
|
||||
description: "notifier kind outside the supported set",
|
||||
channel: Channel{
|
||||
DisplayName: "tg",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
DisplayName: "dc",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ var (
|
||||
ChannelKindJira = ChannelKind{valuer.NewString("jira")}
|
||||
ChannelKindJSMOps = ChannelKind{valuer.NewString("jsmops")}
|
||||
ChannelKindIncidentIO = ChannelKind{valuer.NewString("incidentio")}
|
||||
ChannelKindTelegram = ChannelKind{valuer.NewString("telegram")}
|
||||
)
|
||||
|
||||
func (ChannelKind) Enum() []any {
|
||||
|
||||
@@ -74,6 +74,12 @@ var channelKinds = []channelKindEntry{
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.IncidentIOConfigs) },
|
||||
extractSpec: newChannelIncidentIOConfigFromReceiver,
|
||||
},
|
||||
{
|
||||
kind: ChannelKindTelegram,
|
||||
newEmptySpec: func() ChannelSpec { return new(ChannelTelegramConfig) },
|
||||
countConfigs: func(receiver *Receiver) int { return len(receiver.TelegramConfigs) },
|
||||
extractSpec: newChannelTelegramConfigFromReceiver,
|
||||
},
|
||||
}
|
||||
|
||||
func buildEmptyChannelSpecForKind(kind ChannelKind) (ChannelSpec, bool) {
|
||||
|
||||
@@ -43,13 +43,13 @@ func TestListChannelsParamsValidateRejectsALongQuery(t *testing.T) {
|
||||
// possible until they are migrated. One of them must not fail the whole list.
|
||||
func TestChannelToListedChannelLeavesUnmodelledKindsEmpty(t *testing.T) {
|
||||
channel := Channel{
|
||||
DisplayName: "tg",
|
||||
Name: "tg",
|
||||
Type: "telegram",
|
||||
Data: `{"name":"tg","telegram_configs":[{"chat_id":1}]}`,
|
||||
DisplayName: "dc",
|
||||
Name: "dc",
|
||||
Type: "discord",
|
||||
Data: `{"name":"dc","discord_configs":[{"webhook_url":"https://discord.com/api/webhooks/1/x"}]}`,
|
||||
}
|
||||
|
||||
listed := channel.ToListedNotificationChannel()
|
||||
assert.Equal(t, "tg", listed.Name)
|
||||
assert.Equal(t, "dc", listed.Name)
|
||||
assert.True(t, listed.Kind.IsZero())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user