Compare commits

...

5 Commits

Author SHA1 Message Date
Praneeth Lingam
4105015444 chore(openapi): regenerate specs for the telegram channel 2026-10-08 00:49:08 +05:30
Praneeth Lingam
e312d010db feat(alertmanager): add a telegram notifier with per-rule templates
Rule templates are authored in markdown, and Telegram's parse_mode HTML
accepts only a small tag subset, rejecting the whole message on anything
outside it. The generic HTML renderer cannot be reused, so a dedicated
one emits that subset and flattens constructs with no equivalent.
2026-10-08 00:48:38 +05:30
Praneeth Lingam
f76ab67a90 refactor(alertmanager): drop parseMode from the telegram channel spec
Rendering targets HTML only. MarkdownV2 needs context-dependent escaping
of eighteen characters, and legacy Markdown cannot express strikethrough,
blockquotes or nested entities at all, so neither is worth supporting.
2026-10-08 00:46:24 +05:30
Praneeth Lingam
3c1a78c85e fix(rules): leave private annotations unexpanded
The ruler's expander rewrites every $-ref into a label lookup, which
blanked the rule template placeholders before any notifier saw them.
2026-10-08 00:38:29 +05:30
Praneeth Lingam
b63ad746b2 feat(telegram): basic workflow 2026-10-07 13:05:03 +05:30
19 changed files with 1381 additions and 19 deletions

View File

@@ -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:

View File

@@ -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")

View File

@@ -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

View 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: &notify.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
}

View 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(&params)
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&amp;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 &lt; 2s &amp; rising</b>\n\np99 &lt; 2s &amp; 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"])
}

View File

@@ -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 {

View File

@@ -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())
})
}
}

View File

@@ -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")

View File

@@ -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")

View File

@@ -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 {

View 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("&", "&amp;", "<", "&lt;", ">", "&gt;")
attrEscaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", `"`, "&quot;")
)
// 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())
}

View 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&amp;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)", "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 &lt; 2s &amp; rising"},
{"InsideBold", "**a < b**", "<b>a &lt; b</b>"},
{"InsideCodeSpan", "`if a < b && c`", "<code>if a &lt; b &amp;&amp; c</code>"},
{"InsideCodeBlock", "```\na < b\n```", "<pre>a &lt; b</pre>"},
{"InsideLinkText", "[a < b](https://signoz.io)", `<a href="https://signoz.io">a &lt; b</a>`},
{"QuoteInHref", `[x](https://signoz.io/?q=")`, `<a href="https://signoz.io/?q=&quot;">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, "&amp;lt;", "double-escaped")
})
}
}

View 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
}

View 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 &lt; 2s &amp; rising</b>")
assert.Contains(t, rendered, "<b>Summary:</b> latency &gt; 2s")
assert.Contains(t, rendered, `<a href="http://localhost:8080/alerts?a=1&amp;b=2">View in SigNoz</a>`)
assert.Contains(t, rendered, `<a href="http://localhost:8080/logs?a=1&amp;b=2">View related logs</a>`)
assert.NotContains(t, rendered, "&amp;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)
}

View File

@@ -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"),
})
}

View File

@@ -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"}]}`,
},
},
{

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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())
}