add mattermost integration

Signed-off-by: UndeadDemidov <45305584+UndeadDemidov@users.noreply.github.com>
This commit is contained in:
UndeadDemidov 2024-11-01 09:39:59 +03:00
parent d04ef60a16
commit 291e87bd8f
10 changed files with 836 additions and 2 deletions

View File

@ -163,9 +163,9 @@ var Assets = func() http.FileSystem {
"/templates/default.tmpl": &vfsgen۰CompressedFileInfo{
name: "default.tmpl",
modTime: time.Date(1970, 1, 1, 0, 0, 1, 0, time.UTC),
uncompressedSize: 8101,
uncompressedSize: 8398,
compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x59\xcf\x6f\xeb\x36\x0c\xbe\xe7\xaf\x20\xfc\x76\x68\x0e\xf5\x1b\x76\x2c\x50\x0c\x0f\xc3\x7e\x1c\xba\x61\x68\xd1\x5d\x86\x21\x50\x6d\xc6\x55\x2b\x4b\xae\x44\x27\x0d\xd2\xfc\xef\x83\x6c\xc7\x91\x2d\x27\x91\xd3\xec\xb4\xdc\x12\x99\xfc\x48\x7f\x1f\x4d\xca\xf2\x7a\x0d\x29\xce\xb9\x44\x88\x66\x33\x26\x50\x53\xce\x24\xcb\x50\x47\xb0\xd9\x7c\x73\xfe\xaf\xd7\x80\x32\x85\xcd\x66\xb2\xd7\xe5\xf1\xfe\xce\x7a\xad\xd7\x10\xff\xfc\x4e\xa8\x25\x13\x8f\xf7\x77\xb0\xd9\x7c\xfd\xf2\xb5\xb2\x33\x3f\x6a\x4c\x90\x2f\x50\xdf\x5a\xa3\xfb\xe6\x0f\x7c\x40\xa9\xc5\x5b\x89\x7a\x55\xbb\x37\x81\xba\x91\x4c\xf9\xf4\x82\x09\xd9\x08\x7f\x5b\xef\x07\x62\x54\x1a\xf8\x00\x52\x8f\x45\x81\xba\x76\xe5\x73\xc0\xb7\xf6\x62\x34\xe7\x9a\xcb\xcc\xfa\xdc\x58\x9f\xea\x86\x4c\xfc\x4b\xb5\x0a\x1f\x20\x50\xba\x11\xff\x01\x6b\xf4\xab\x56\x65\x71\xc7\x9e\x50\x98\xf8\x41\x69\xc2\xf4\x4f\xc6\xb5\x89\xff\x62\xa2\x44\x1b\xf0\x45\x71\x09\x11\x58\x54\xa8\x43\x66\x04\x57\x16\x2b\xfe\x49\xe5\xb9\x92\xb5\xf3\xb4\x59\x73\xf0\xa6\xb0\xd9\x5c\xad\xd7\xb0\xe4\xf4\xdc\x35\x8e\xef\x31\x57\x0b\xec\x46\xff\x83\xe5\x68\x1a\x46\x87\xa2\xb7\x89\x4f\xdb\x5f\x7b\x64\x4a\xd1\x24\x9a\x17\xc4\x95\x8c\x0e\x70\x4c\xf8\x4e\xb5\xa4\x33\xc1\x0d\x35\xa6\x9a\xc9\x0c\x21\x86\xcd\xa6\xce\xeb\x66\xb2\x5b\xf4\x79\xb2\xac\x5c\x57\x44\xda\xf4\xed\xbf\x5b\x68\x6f\xa0\x49\xac\x0e\xfe\x4d\x4a\x45\xcc\xe6\xd4\x81\x74\x96\x4f\xc3\x7d\x50\xa5\x4e\xf0\xa6\x16\x13\x25\x6a\x46\x4a\xd7\x95\x38\x19\x20\xea\x20\x05\xb3\x9c\xe9\xd7\x54\x2d\xa5\xc7\xc5\x24\x94\x8c\xc0\xac\x27\xe3\xe9\x08\x45\x0e\x22\x64\x32\xcc\x88\x11\x2c\x79\x8d\x53\x9c\xb3\x52\x50\x4c\x9c\x04\x36\x54\x10\xe6\x85\x60\xd4\x7d\x38\xe3\x7d\x35\xd8\xc5\x29\x8d\x6d\x0f\xf9\x10\x54\xb7\x09\x05\xe2\xcd\x99\x10\x4f\x2c\x79\xf5\xf0\x06\xd3\xb7\xa0\xf0\x01\xc7\x0c\x05\x97\xaf\xc1\x19\x24\x4d\x06\x3c\x8d\xc2\x1c\x0a\x8d\xb6\xd6\x02\xad\x9d\x84\x0e\x32\x56\xf5\xe0\xc0\x94\x79\xa2\x24\xe6\xea\x85\x47\xe1\xf6\xa5\x16\xa1\x19\x87\xdf\xdc\x5c\x29\xaa\x27\x8e\x53\x83\xae\x79\x61\x6f\x2d\x2d\x69\xd5\xba\xf8\x0d\x6d\x5c\x39\xfa\x88\x89\xe0\x28\xe9\xf4\x82\xdc\x87\xb8\x9b\x8a\xa7\x69\xe6\xe3\x72\x69\x88\xc9\x04\xcd\x00\xae\xd7\xc1\xe3\xfd\xac\xaa\xc2\x64\x28\x39\xb6\xc0\x39\x1a\xc3\xb2\xd3\x9e\x6f\x0f\xcc\x57\xa8\x19\x78\x7b\x1a\xda\xe0\x84\x9b\xf4\xe6\x6b\x67\x80\x4f\xe1\x7b\xb8\xb6\x8d\xb3\x5a\x84\x7a\xb1\x6a\x9d\x87\x19\xe9\xee\x02\xaa\x20\xd7\xce\x1d\x0d\xc4\xbb\x47\xa3\xc4\x02\xd3\x5e\xc4\xed\x72\x78\xcc\xad\x87\x17\xf5\x3a\x84\x52\x53\xf5\xf1\xf1\xd5\xd4\x51\x7d\x89\xc9\x33\xa3\xb1\x9a\x4f\x2e\xfa\x1d\xd0\xcf\xdd\x28\x3f\x6a\xe1\xe1\x0d\xea\xb3\x47\xf5\x9e\x3e\xa4\x66\x76\x58\xee\xed\xa4\xbe\x79\xc1\x34\xad\x46\xd8\x13\xcb\x42\xad\x59\x86\x92\x66\xfd\x11\xd7\xad\xaf\x05\x4f\x48\x69\x55\x98\x5d\xd9\x12\x23\x9c\x75\x0b\xed\x52\x4b\xe3\x7a\x81\xcf\x2a\x4a\xe2\xb4\x9a\xa5\xdc\x14\x82\xad\x66\x7b\x76\x53\xc7\x1b\xb7\x8f\x9c\x2b\xc9\x49\x59\x42\x66\xa4\x94\x18\x39\x12\x3b\xb3\xab\x34\xcf\x6a\x81\xfa\x0c\xfb\x47\x0f\xea\xbf\xaf\xa7\xf3\x94\x53\x78\x35\x9d\xaf\x98\xfc\x2d\xfd\x21\x26\x77\x7b\xba\x31\x33\xc5\xdd\xcd\x49\xe7\x61\xdf\xbd\xa6\x8f\x7f\x47\x70\x70\x2e\xf2\x8e\x91\xd7\x65\x91\x50\x60\xa6\x59\x3e\x44\xe5\xff\x96\x94\x94\x9b\x44\xe9\x74\xb7\x37\x57\x92\x76\xdb\x7d\xbf\x14\xfb\xf6\xa7\x37\xae\x3e\xd2\x45\x0d\xbb\xad\x78\xc2\xf7\xcb\xa3\xfe\x69\x1e\x73\x43\xc8\x72\xb7\xf9\xe6\x39\xd3\xab\x93\xea\xb4\x8f\x75\x7a\xc5\x7b\x48\xcd\x49\x40\x88\x4c\x5f\x60\x94\x50\xce\xf1\xdc\xa7\x15\x6b\x43\x87\x6a\x36\x10\xfc\x04\xf1\x16\x3f\x9c\x8f\x72\x17\xeb\x42\xfa\x10\xe9\x2f\x5c\xb3\xb3\x3c\x2e\x1d\xa0\xde\x59\xc7\x85\xf3\x49\xf5\x1a\x33\xc8\x55\xa1\xb9\xd2\xdc\xbe\xa1\x5e\x37\x6f\x3b\xdf\x6d\x97\xe0\xe6\x16\xa2\x68\xfb\x12\xb4\x3d\xff\xee\xdc\xad\xf5\x01\x00\xa8\xfc\x0c\x2e\x70\xeb\xc7\x65\x8a\xef\xdb\x23\x78\x88\xb6\x97\xa2\x8e\x07\x9f\xc3\x15\xbe\x39\x8e\x51\xa2\x39\xf1\x84\x89\x68\xda\x1a\xb6\xf0\x6d\x5a\xb7\x10\xfd\xc6\xb3\xe7\x2e\x16\x0a\x83\x15\x20\x93\x69\x1f\x75\xc9\xb4\xe4\x32\x8b\xa6\x70\x25\xd1\x01\xaa\x61\xa6\x47\x62\xfd\x8e\x29\x2f\xf3\xf0\x68\x5c\xce\x95\x0d\x65\x57\x77\xa1\x8e\x86\xb9\x53\xcb\x5e\x0c\x99\xb6\x9a\xb8\xbf\xeb\x6f\x6a\x2e\x74\xc7\xad\xab\x53\x5b\x18\x5e\xec\x51\x6a\x8d\x56\x2c\x40\xb5\xb3\x2b\x17\xa4\xde\xf9\x14\x3c\xae\x62\x5f\xc9\x63\xca\xee\x90\xfa\x57\xdd\x56\xa7\x55\xf2\x8a\xd4\x3d\x36\x3a\x79\x52\x0d\x80\x31\xc1\x99\x39\xfd\xe0\x7d\x5f\x7a\x9f\xfe\x5a\x32\x00\x7c\xf8\x73\xc9\x80\xc3\xb1\x6f\x26\x43\xc9\x7b\x1f\x4e\xfe\x0d\x00\x00\xff\xff\x74\x5d\xc4\xb5\xa5\x1f\x00\x00"),
compressedContent: []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x02\xff\xec\x59\xcf\x6f\xeb\x36\x0c\xbe\xe7\xaf\x10\xfc\x76\x68\x0e\xf5\x1b\x76\x2c\x50\x0c\x0f\xc3\x7e\x1c\xba\x61\x68\xd1\x5d\x86\x21\x50\x6d\xc6\x55\x2b\x4b\xae\x44\x27\x0d\xd2\xfc\xef\x83\x6c\xc7\x91\x2c\x3b\x91\xdd\xec\xb4\xdc\x12\x99\xfc\x48\x7f\x1f\x4d\xca\xf2\x76\x4b\x52\x58\x32\x01\x24\x5a\x2c\x28\x07\x85\x39\x15\x34\x03\x15\x91\xdd\xee\x9b\xf5\x7f\xbb\x25\x20\x52\xb2\xdb\xcd\x06\x5d\x1e\xef\xef\x8c\xd7\x76\x4b\xe2\x9f\xdf\x11\x94\xa0\xfc\xf1\xfe\x8e\xec\x76\x5f\xbf\x7c\xad\xec\xf4\x8f\x0a\x12\x60\x2b\x50\xb7\xc6\xe8\xbe\xf9\x43\x3e\x48\xa9\xf8\x5b\x09\x6a\x53\xbb\x37\x81\xdc\x48\xba\x7c\x7a\x81\x04\x4d\x84\xbf\x8d\xf7\x03\x52\x2c\x35\xf9\x20\x28\x1f\x8b\x02\x54\xed\xca\x96\x04\xde\xda\x8b\xd1\x92\x29\x26\x32\xe3\x73\x63\x7c\xaa\x1b\xd2\xf1\x2f\xd5\x2a\xf9\x20\x1c\x84\x1d\xf1\x1f\x62\x8c\x7e\x55\xb2\x2c\xee\xe8\x13\x70\x1d\x3f\x48\x85\x90\xfe\x49\x99\xd2\xf1\x5f\x94\x97\x60\x02\xbe\x48\x26\x48\x44\x0c\x2a\xa9\x43\x66\x48\xae\x0c\x56\xfc\x93\xcc\x73\x29\x6a\xe7\x79\xb3\x66\xe1\xcd\xc9\x6e\x77\xb5\xdd\x92\x35\xc3\x67\xd7\x38\xbe\x87\x5c\xae\xc0\x8d\xfe\x07\xcd\x41\x37\x8c\xf6\x45\x6f\x13\x9f\xb7\xbf\x06\x64\x4a\x41\x27\x8a\x15\xc8\xa4\x88\x8e\x70\x8c\xf0\x8e\xb5\xa4\x0b\xce\x34\x36\xa6\x8a\x8a\x0c\x48\x4c\x76\xbb\x3a\xaf\x9b\xd9\x61\xd1\xe7\xc9\xb0\x72\x5d\x11\x69\xd2\x37\xff\x6e\x49\x7b\x03\x4d\x62\x75\xf0\x6f\x42\x48\xa4\x26\x27\x07\xd2\x5a\x9e\x86\xfb\x20\x4b\x95\xc0\x4d\x2d\x26\x08\x50\x14\xa5\xaa\x2b\x71\xd6\x43\xd4\x51\x0a\x16\x39\x55\xaf\xa9\x5c\x0b\x8f\x8b\x59\x28\x19\x81\x59\xcf\xc6\xd3\x11\x8a\x1c\x44\xc8\xac\x9f\x11\xcd\x69\xf2\x1a\xa7\xb0\xa4\x25\xc7\x18\x19\x72\x68\xa8\x40\xc8\x0b\x4e\xd1\x7d\x38\xe3\xa1\x1a\x74\x71\x4a\x6d\xda\x43\xde\x07\xe5\x36\xa1\x40\xbc\x25\xe5\xfc\x89\x26\xaf\x1e\x5e\x6f\xfa\x06\x94\x7c\x90\x53\x86\x9c\x89\xd7\xe0\x0c\x92\x26\x03\x96\x46\x61\x0e\x85\x02\x53\x6b\x81\xd6\x56\x42\x47\x19\xab\x7a\x70\x60\xca\x2c\x91\x02\x72\xf9\xc2\xa2\x70\xfb\x52\xf1\xd0\x8c\xc3\x6f\x6e\x29\x25\xd6\x13\xc7\xaa\x41\xdb\xbc\x30\xb7\x96\x96\xb8\x69\x5d\xfc\x86\x36\xae\x1c\x7d\xc4\x84\x33\x10\x38\xbd\x20\x87\x10\x0f\x53\x71\x9a\x66\x3e\x2e\x13\x1a\xa9\x48\x40\xf7\xe0\x7a\x1d\x3c\x1e\x66\x55\x16\x3a\x03\xc1\xa0\x05\xce\x41\x6b\x9a\x4d\x7b\xbe\x3d\x30\x5f\xa1\x66\xe0\x0d\x34\xb4\xde\x09\x37\xeb\xcc\x57\x67\x80\xcf\xc9\xf7\xe4\xda\x34\xce\x6a\x91\xd4\x8b\x55\xeb\x3c\xce\x88\xbb\x0b\xa8\x82\x5c\x5b\x77\xd4\x13\xef\x1e\xb4\xe4\x2b\x48\x3b\x11\xf7\xcb\xe1\x31\xf7\x1e\x5e\xd4\xeb\x10\x4a\x75\xd5\xc7\xc7\x57\x93\xa3\xfa\x1a\x92\x67\x8a\x63\x35\x9f\x5d\xf4\x3b\xa2\x9f\xbd\x51\x7e\x54\xdc\xc3\xeb\xd5\x67\x40\xf5\x8e\x3e\x28\x17\x66\x58\x0e\x76\x52\xdf\xbc\xa0\x0a\x37\x23\xec\x91\x66\xa1\xd6\x34\x03\x81\x8b\xee\x88\x73\xeb\x6b\xc5\x12\x94\x4a\x16\xfa\x50\xb6\x48\x11\x16\x6e\xa1\x5d\x6a\x69\x5c\x2f\xf0\x59\x05\x81\x0c\x37\x8b\x94\xe9\x82\xd3\xcd\x62\x60\x37\x75\xba\x71\xfb\xc8\xb9\x14\x0c\xa5\x21\x64\x81\x52\xf2\x91\x23\xd1\x99\x5d\xa5\x7e\x96\x2b\x50\x67\xd8\x3f\x7a\x50\xff\x7d\x3d\x9d\xa7\x9c\xc2\xab\xe9\x7c\xc5\xe4\x6f\xe9\x8f\x31\x79\xd8\xd3\x8d\x99\x29\xf6\x6e\x4e\x58\x0f\xfb\xe1\x35\x7d\xfc\x3b\x82\x85\x73\x91\x77\x8c\xbc\x36\x8b\x08\x1c\x32\x45\xf3\x3e\x2a\xff\xb7\xa4\xa4\x4c\x27\x52\xa5\x87\xbd\xb9\x14\x78\xd8\xee\xfb\xa5\xd8\xb5\x9f\xde\xb8\xba\x48\x17\x35\xcc\xb6\xe2\x09\xde\x2f\x8f\xfa\xa7\x79\xcc\x35\x02\xcd\xed\xe6\x9b\xe7\x54\x6d\x26\xd5\x69\x17\x6b\x7a\xc5\x7b\x48\xcd\x49\x40\x88\x4c\x5f\xc8\x28\xa1\xac\xe3\xb9\x4f\x2b\xd6\x86\x0e\xd5\xac\x27\xf8\x04\xf1\x56\x3f\x9c\x8f\x72\x1b\xeb\x42\x7a\x1f\xe9\x2f\x4c\xd1\xb3\x3c\x2e\x0e\x50\xe7\xac\xe3\xc2\xf9\xac\x7a\x8d\xe9\xe5\xaa\x50\x4c\x2a\x66\xde\x50\xaf\x9b\xb7\x9d\xef\xf6\x4b\xe4\xe6\x96\x44\xd1\xfe\x25\x68\x7f\xfe\xed\xdc\xad\xf1\x21\x84\x90\xca\x4f\xc3\x0a\xf6\x7e\x4c\xa4\xf0\xbe\x3f\x82\x27\xd1\xfe\x52\xe4\x78\xb0\x25\xb9\x82\x37\xcb\x31\x4a\x14\x43\x96\x50\x1e\xcd\x5b\xc3\x16\xbe\x4d\xeb\x96\x44\xbf\xb1\xec\xd9\xc5\x02\xae\xa1\x02\xa4\x22\xed\xa2\xae\xa9\x12\x4c\x64\xd1\x9c\x5c\x09\xb0\x80\x6a\x98\xf9\x89\x58\xbf\x43\xca\xca\x3c\x3c\x1a\x13\x4b\x69\x42\x99\xd5\x43\xa8\x93\x61\xee\xe4\xba\x13\x43\xa4\xad\x26\xf6\xef\xfa\x9b\x9a\x0d\xed\xb8\xb9\x3a\xb5\x85\xe1\xc5\x1e\xa5\xd6\x68\xc5\x02\x54\x3b\xbb\x72\x41\xea\x9d\x4f\xc1\xd3\x2a\x76\x95\x3c\xa5\xec\x01\xa9\x7b\xd5\x6e\x75\x4a\x26\xaf\x80\xee\xb1\xd1\xe4\x49\xd5\x03\x46\x39\xa3\x7a\xfa\xc1\xfb\x50\x7a\x9f\xfe\x5a\xd2\x03\x7c\xfc\x73\x49\x8f\xc3\xa9\x6f\x26\x7d\xc9\x7b\x1f\x4e\x9c\x49\x4f\x11\x41\xe5\x52\xe3\x65\xd4\x7b\x63\xe7\xdf\x00\x00\x00\xff\xff\xa7\x3a\x7d\xf7\xce\x20\x00\x00"),
},
"/templates/email.tmpl": &vfsgen۰CompressedFileInfo{
name: "email.tmpl",

View File

@ -272,6 +272,9 @@ func resolveFilepaths(baseDir string, cfg *Config) {
for _, cfg := range receiver.RocketchatConfigs {
cfg.HTTPConfig.SetDirectory(baseDir)
}
for _, cfg := range receiver.MattermostConfigs {
cfg.HTTPConfig.SetDirectory(baseDir)
}
}
}
@ -606,6 +609,11 @@ func (c *Config) UnmarshalYAML(unmarshal func(interface{}) error) error {
rocketchat.TokenFile = c.Global.RocketchatTokenFile
}
}
for _, mattermost := range rcv.MattermostConfigs {
if mattermost.HTTPConfig == nil {
mattermost.HTTPConfig = c.Global.HTTPConfig
}
}
names[rcv.Name] = struct{}{}
}
@ -988,6 +996,7 @@ type Receiver struct {
MSTeamsV2Configs []*MSTeamsV2Config `yaml:"msteamsv2_configs,omitempty" json:"msteamsv2_configs,omitempty"`
JiraConfigs []*JiraConfig `yaml:"jira_configs,omitempty" json:"jira_configs,omitempty"`
RocketchatConfigs []*RocketchatConfig `yaml:"rocketchat_configs,omitempty" json:"rocketchat_configs,omitempty"`
MattermostConfigs []*MattermostConfig `yaml:"mattermost_configs,omitempty" json:"mattermost_configs,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface for Receiver.

View File

@ -203,6 +203,13 @@ var (
Description: `{{ template "jira.default.description" . }}`,
Priority: `{{ template "jira.default.priority" . }}`,
}
DefaultMattermostConfig = MattermostConfig{
NotifierConfig: NotifierConfig{
VSendResolved: true,
},
Text: `{{ template "mattermost.default.text" . }}`,
}
)
// NotifierConfig contains base options common across all notifier configurations.
@ -992,3 +999,98 @@ func (c *RocketchatConfig) UnmarshalYAML(unmarshal func(interface{}) error) erro
}
return nil
}
// MattermostPriority defines the priority for a mattermost notification.
type MattermostPriority struct {
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
RequestedAck bool `yaml:"requested_ack,omitempty" json:"requested_ack,omitempty"`
PersistentNotifications bool `yaml:"persistent_notifications,omitempty" json:"persistent_notifications,omitempty"`
}
// MattermostProps defines additional properties for a mattermost notification.
// Only 'card' property takes effect now.
type MattermostProps struct {
Card string `yaml:"card,omitempty" json:"card,omitempty"`
}
// MattermostField configures a single Mattermost field for Slack compatibility.
// See https://developers.mattermost.com/integrate/reference/message-attachments/#fields for more information.
type MattermostField struct {
Title string `yaml:"title,omitempty" json:"title,omitempty"`
Value string `yaml:"value,omitempty" json:"value,omitempty"`
Short *bool `yaml:"short,omitempty" json:"short,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface for MattermostField.
func (c *MattermostField) UnmarshalYAML(unmarshal func(interface{}) error) error {
type plain MattermostField
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.Title == "" {
return errors.New("missing title in Mattermost field configuration")
}
if c.Value == "" {
return errors.New("missing value in Mattermost field configuration")
}
return nil
}
// MattermostAttachment defines an attachment for a Mattermost notification.
// See https://developers.mattermost.com/integrate/reference/message-attachments/#fields for more information.
type MattermostAttachment struct {
Fallback string `yaml:"fallback,omitempty" json:"fallback,omitempty"`
Color string `yaml:"color,omitempty" json:"color,omitempty"`
Pretext string `yaml:"pretext,omitempty" json:"pretext,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
AuthorName string `yaml:"author_name,omitempty" json:"author_name,omitempty"`
AuthorLink string `yaml:"author_link,omitempty" json:"author_link,omitempty"`
AuthorIcon string `yaml:"author_icon,omitempty" json:"author_icon,omitempty"`
Title string `yaml:"title,omitempty" json:"title,omitempty"`
TitleLink string `yaml:"title_link,omitempty" json:"title_link,omitempty"`
Fields []*MattermostField `yaml:"fields,omitempty" json:"fields,omitempty"`
ThumbURL string `yaml:"thumb_url,omitempty" json:"thumb_url,omitempty"`
Footer string `yaml:"footer,omitempty" json:"footer,omitempty"`
FooterIcon string `yaml:"footer_icon,omitempty" json:"footer_icon,omitempty"`
ImageURL string `yaml:"image_url,omitempty" json:"image_url,omitempty"`
}
// MattermostConfig configures notifications via Mattermost.
// See https://developers.mattermost.com/integrate/webhooks/incoming/ for more information.
type MattermostConfig struct {
NotifierConfig `yaml:",inline" json:",inline"`
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
WebhookURL *SecretURL `yaml:"webhook_url,omitempty" json:"webhook_url,omitempty"`
WebhookURLFile string `yaml:"webhook_url_file,omitempty" json:"webhook_url_file,omitempty"`
Channel string `yaml:"channel,omitempty" json:"channel,omitempty"`
Username string `yaml:"username,omitempty" json:"username,omitempty"`
Text string `yaml:"text,omitempty" json:"text,omitempty"`
IconURL string `yaml:"icon_url,omitempty" json:"icon_url,omitempty"`
IconEmoji string `yaml:"icon_emoji,omitempty" json:"icon_emoji,omitempty"`
Attachments []*MattermostAttachment `yaml:"attachments,omitempty" json:"attachments,omitempty"`
Type string `yaml:"type,omitempty" json:"type,omitempty"`
Props *MattermostProps `yaml:"props,omitempty" json:"props,omitempty"`
Priority *MattermostPriority `yaml:"priority,omitempty" json:"priority,omitempty"`
}
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *MattermostConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
*c = DefaultMattermostConfig
type plain MattermostConfig
if err := unmarshal((*plain)(c)); err != nil {
return err
}
if c.WebhookURL == nil && c.WebhookURLFile == "" {
return errors.New("one of webhook_url or webhook_url_file must be configured")
}
if c.WebhookURL != nil && len(c.WebhookURLFile) > 0 {
return errors.New("at most one of webhook_url & webhook_url_file must be configured")
}
return nil
}

View File

@ -1093,3 +1093,112 @@ parse_mode: invalid
func newBoolPointer(b bool) *bool {
return &b
}
func TestMattermostField_UnmarshalYAML(t *testing.T) {
mf := []struct {
name string
in string
expected error
}{
{
name: "with title, value and short - it succeeds",
in: `
title: some title
value: some value
short: true
`,
},
{
name: "with title and value - it succeeds",
in: `
title: some title
value: some value
`,
},
{
name: "with no value - it fails",
in: `
title: some title
`,
expected: errors.New("missing value in Mattermost field configuration"),
},
{
name: "with no title - it fails",
in: `
value: some value
`,
expected: errors.New("missing title in Mattermost field configuration"),
},
}
for _, tt := range mf {
t.Run(tt.name, func(t *testing.T) {
var cfg MattermostField
err := yaml.UnmarshalStrict([]byte(tt.in), &cfg)
require.Equal(t, tt.expected, err)
})
}
}
func TestMattermostConfig_UnmarshalYAML(t *testing.T) {
mc := []struct {
name string
in string
expected error
}{
{
name: "with url and text - it succeeds",
in: `
webhook_url: http://some.url
channel: some_channel
username: some_username
text: some text
`,
},
{
name: "with url_file, attachments and props - it succeeds",
in: `
webhook_url_file: /some/url.file
channel: some_channel
username: some_username
attachments:
- text: some text
props:
card: some text
`,
},
{
name: "with url and url_file - it fails",
in: `
webhook_url: http://some.url
webhook_url_file: /some/url.file
channel: some_channel
username: some_username
attachments:
- text: some text
`,
expected: errors.New("at most one of webhook_url & webhook_url_file must be configured"),
},
{
name: "with text and attachments - it succeeds",
in: `
webhook_url: http://some.url
channel: some_channel
username: some_username
text: some text
attachments:
- text: some text
`,
},
}
for _, tt := range mc {
t.Run(tt.name, func(t *testing.T) {
var cfg MattermostConfig
err := yaml.UnmarshalStrict([]byte(tt.in), &cfg)
require.Equal(t, tt.expected, err)
})
}
}

View File

@ -23,6 +23,7 @@ import (
"github.com/prometheus/alertmanager/notify/discord"
"github.com/prometheus/alertmanager/notify/email"
"github.com/prometheus/alertmanager/notify/jira"
"github.com/prometheus/alertmanager/notify/mattermost"
"github.com/prometheus/alertmanager/notify/msteams"
"github.com/prometheus/alertmanager/notify/msteamsv2"
"github.com/prometheus/alertmanager/notify/opsgenie"
@ -104,6 +105,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg
for i, c := range nc.RocketchatConfigs {
add("rocketchat", i, c, func(l log.Logger) (notify.Notifier, error) { return rocketchat.New(c, tmpl, l, httpOpts...) })
}
for i,c:= range nc.MattermostConfigs {
add("mattermost", i, c, func(l log.Logger) (notify.Notifier, error) { return mattermost.New(c, tmpl, l, httpOpts...) })
}
if errs.Len() > 0 {
return nil, &errs

View File

@ -703,6 +703,8 @@ discord_configs:
[ - <discord_config>, ... ]
email_configs:
[ - <email_config>, ... ]
mattermost_configs:
[ - <mattermost_config>, ... ]
msteams_configs:
[ - <msteams_config>, ... ]
msteamsv2_configs:
@ -941,6 +943,93 @@ tls_config:
[ headers: { <string>: <tmpl_string>, ... } ]
```
### `<mattermost_config>`
Mattermost notifications are sent via the [Mattermost webhook API](https://developers.mattermost.com/integrate/webhooks/incoming/).
```yaml
# Whether to notify about resolved alerts.
[ send_resolved: <boolean> | default = true ]
# The Mattermost webhook URL.
# webhook_url and webhook_url_file are mutually exclusive.
webhook_url: <secret>
webhook_url_file: <filepath>
# Overrides the channel the message posts in. Use the channels name and not the display name, e.g. use town-square, not Town Square.
[ channel: <string> | default = '' ]
# Overrides the username the message posts as.
# Defaults to the username set during webhook creation; if no username was set during creation, webhook is used.
[ username: <string> | default = '' ]
# Markdown-formatted message to display in the post.
# To trigger notifications, use @<username>, @channel, and @here like you would in other Mattermost messages.
text: <tmpl_string> | default = '{{ template "mattermost.default.text" . }}'
# Overrides the profile picture the message posts with.
[ icon_url: <string> | default = '' ]
# Overrides the profile picture and icon_url parameter.
[ icon_emoji: <string> | default = '' ]
# Message attachments used for richer formatting options.
# It is for compatibility with Slack.
[ attachments: ]
[ <attachment_config> ... ]
[ props: <prop_config> ]
[ priority: <priority_config> ]
# The HTTP client's configuration.
[ http_config: <http_config> | default = global.http_config ]
```
#### `<attachment_config>`
See [Mattermost documentation](https://developers.mattermost.com/integrate/reference/message-attachments/) for more info.
```yaml
[ fallback: <string> | default = '' ]
[ color: <string> | default = '' ]
[ pretext: <string> | default = '' ]
[ text: <string> | default = '' ]
[ author_name: <string> | default = '' ]
[ author_link: <string> | default = '' ]
[ author_icon: <string> | default = '' ]
[ title: <string> | default = '' ]
[ title_link: <string> | default = '' ]
# Same as Slack fields.
[ fields: <string> | default = '' ]
[ <field_config> ... ]
[ thumb_url: <string> | default = '' ]
[ footer: <string> | default = '' ]
[ footer_icon: <string> | default = '' ]
[ image_url: <string> | default = '' ]
```
#### `<prop_config>`
```yaml
# Props card allows for extra information (Markdown-formatted text) to be sent to Mattermost that will only be displayed in the RHS panel after a user selects the info icon displayed alongside the post.
[ card: <string> | default = '' ]
```
#### `<priority_config>`
```yaml
# priority adds label to the message. Possible values are "urgent", "important" and "standard".
[ priority: <string> | default = '' ]
# If set to true, the message will be marked as requiring an acknowledgment from the users by displaying a checkmark icon next to the message. Keep in mind that this requires the message priority to be set to Important or Urgent.
# Only for enterprise version of Mattermost.
[ requested_ack: <bool> | default = false ]
# Only for Urgent messages. If set to true recipients will receive a persistent notification every five minutes until they acknowledge the message.
# Only for enterprise version of Mattermost.
[ persistent_notifications: <bool> | default = false ]
```
### `<msteams_config>`
Microsoft Teams notifications are sent via the [Incoming Webhooks](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/what-are-webhooks-and-connectors) API endpoint.

View File

@ -0,0 +1,263 @@
package mattermost
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"github.com/go-kit/log"
"github.com/go-kit/log/level"
"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"
)
// Mattermost supports 16383 chars max.
// https://developers.mattermost.com/integrate/webhooks/incoming/#tips-and-best-practices
const maxTextLenRunes = 16383
// Notifier implements a Notifier for Mattermost notifications.
type Notifier struct {
conf *config.MattermostConfig
tmpl *template.Template
logger log.Logger
client *http.Client
retrier *notify.Retrier
postJSONFunc func(ctx context.Context, client *http.Client, url string, body io.Reader) (*http.Response, error)
}
// New returns a new Mattermost notifier.
func New(c *config.MattermostConfig, t *template.Template, l log.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
client, err := commoncfg.NewClientFromConfig(*c.HTTPConfig, "mattermost", httpOpts...)
if err != nil {
return nil, err
}
return &Notifier{
conf: c,
tmpl: t,
logger: l,
client: client,
retrier: &notify.Retrier{},
postJSONFunc: notify.PostJSON,
}, nil
}
// request is the request for sending a Mattermost notification.
// https://developers.mattermost.com/integrate/webhooks/incoming/#parameters
type request struct {
Text string `json:"text"`
Channel string `json:"channel,omitempty"`
Username string `json:"username,omitempty"`
IconURL string `json:"icon_url,omitempty"`
IconEmoji string `json:"icon_emoji,omitempty"`
Attachments []attachment `json:"attachments,omitempty"`
Type string `json:"type,omitempty"`
Props *config.MattermostProps `json:"props,omitempty"`
Priority *config.MattermostPriority `json:"priority,omitempty"`
}
// attachment is used to display a richly-formatted message block for compatibility with Slack.
// https://developers.mattermost.com/integrate/reference/message-attachments/
type attachment struct {
Fallback string `json:"fallback,omitempty"`
Color string `json:"color,omitempty"`
Pretext string `json:"pretext,omitempty"`
Text string `json:"text,omitempty"`
AuthorName string `json:"author_name,omitempty"`
AuthorLink string `json:"author_link,omitempty"`
AuthorIcon string `json:"author_icon,omitempty"`
Title string `json:"title,omitempty"`
TitleLink string `json:"title_link,omitempty"`
Fields []config.MattermostField `json:"fields,omitempty"`
ThumbURL string `json:"thumb_url,omitempty"`
Footer string `json:"footer,omitempty"`
FooterIcon string `json:"footer_icon,omitempty"`
ImageURL string `json:"image_url,omitempty"`
}
// Notify implements the Notifier interface.
func (n *Notifier) Notify(ctx context.Context, alert ...*types.Alert) (bool, error) {
var (
err error
url string
data = notify.GetTemplateData(ctx, n.tmpl, alert, n.logger)
)
if n.conf.WebhookURL != nil {
url = n.conf.WebhookURL.String()
} else {
content, err := os.ReadFile(n.conf.WebhookURLFile)
if err != nil {
return false, err
}
url = strings.TrimSpace(string(content))
}
if url == "" {
return false, errors.New("webhook url missing")
}
req := n.createRequest(notify.TmplText(n.tmpl, data, &err))
if err != nil {
return false, err
}
err = n.sanitizeRequest(ctx, req)
if err != nil {
return false, err
}
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(req); err != nil {
return false, err
}
resp, err := n.postJSONFunc(ctx, n.client, url, &buf)
if err != nil {
return true, notify.RedactURL(err)
}
defer notify.Drain(resp)
// Use a retrier to generate an error message for non-200 responses and
// classify them as retriable or not.
retry, err := n.retrier.Check(resp.StatusCode, resp.Body)
if err != nil {
err = fmt.Errorf("channel %q: %w", req.Channel, err)
return retry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
}
level.Debug(n.logger).Log(
"msg", "Message sent to Mattermost successfully",
"status", resp.StatusCode)
return false, nil
}
func (n *Notifier) createRequest(tmpl func(string) string) *request {
req := &request{
Text: tmpl(n.conf.Text),
Channel: tmpl(n.conf.Channel),
Username: tmpl(n.conf.Username),
IconURL: tmpl(n.conf.IconURL),
IconEmoji: tmpl(n.conf.IconEmoji),
Type: tmpl(n.conf.Type),
}
if n.conf.Priority != nil && n.conf.Priority.Priority != "" {
req.Priority = &config.MattermostPriority{
Priority: tmpl(n.conf.Priority.Priority),
RequestedAck: n.conf.Priority.RequestedAck,
PersistentNotifications: n.conf.Priority.PersistentNotifications,
}
}
if n.conf.Props != nil && n.conf.Props.Card != "" {
req.Props = &config.MattermostProps{
Card: tmpl(n.conf.Props.Card),
}
}
lenAtt := len(n.conf.Attachments)
if lenAtt > 0 {
req.Attachments = make([]attachment, lenAtt)
for idxAtt, cfgAtt := range n.conf.Attachments {
att := attachment{
Fallback: tmpl(cfgAtt.Fallback),
Color: tmpl(cfgAtt.Color),
Pretext: tmpl(cfgAtt.Pretext),
Text: tmpl(cfgAtt.Text),
AuthorName: tmpl(cfgAtt.AuthorName),
AuthorLink: tmpl(cfgAtt.AuthorLink),
AuthorIcon: tmpl(cfgAtt.AuthorIcon),
Title: tmpl(cfgAtt.Title),
TitleLink: tmpl(cfgAtt.TitleLink),
ThumbURL: tmpl(cfgAtt.ThumbURL),
Footer: tmpl(cfgAtt.Footer),
FooterIcon: tmpl(cfgAtt.FooterIcon),
ImageURL: tmpl(cfgAtt.ImageURL),
}
lenFields := len(cfgAtt.Fields)
if lenFields > 0 {
att.Fields = make([]config.MattermostField, lenFields)
for idxField, field := range cfgAtt.Fields {
att.Fields[idxField] = config.MattermostField{
Title: tmpl(field.Title),
Value: tmpl(field.Value),
Short: field.Short,
}
}
}
req.Attachments[idxAtt] = att
}
}
return req
}
func (n *Notifier) sanitizeRequest(ctx context.Context, r *request) error {
key, err := notify.ExtractGroupKey(ctx)
if err != nil {
return err
}
// Truncate the text if it's too long.
text, truncated := notify.TruncateInRunes(r.Text, maxTextLenRunes)
if truncated {
level.Warn(n.logger).Log(
"msg", "Truncated text",
"key", key,
"max_runes", maxTextLenRunes)
r.Text = text
}
if r.Priority == nil {
return nil
}
// Check priority
const (
priorityUrgent = "urgent"
priorityImportant = "important"
priorityStandard = "standard"
)
switch strings.ToLower(r.Priority.Priority) {
case priorityUrgent, priorityImportant, priorityStandard:
r.Priority.Priority = strings.ToLower(r.Priority.Priority)
default:
level.Warn(n.logger).Log(
"msg", "Priority is set to standard due to invalid value",
"key", key,
"priority", r.Priority.Priority)
r.Priority.Priority = priorityStandard
}
// Check RequestedAck flag
if r.Priority.RequestedAck && r.Priority.Priority == priorityStandard {
level.Warn(n.logger).Log(
"msg", "RequestedAck is set to false due to priority is standard",
"key", key,
)
r.Priority.RequestedAck = false
}
// Check PersistentNotifications flag
if r.Priority.PersistentNotifications && r.Priority.Priority != priorityUrgent {
level.Warn(n.logger).Log(
"msg", "PersistentNotifications is set to false due to priority is not urgent",
"key", key,
)
r.Priority.PersistentNotifications = false
}
return nil
}

View File

@ -0,0 +1,246 @@
package mattermost
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"testing"
"time"
"github.com/go-kit/log"
"github.com/prometheus/alertmanager/config"
"github.com/prometheus/alertmanager/notify"
"github.com/prometheus/alertmanager/notify/test"
"github.com/prometheus/alertmanager/types"
commoncfg "github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
var testWebhookURL, _ = url.Parse("https://mattermost.example.com/hooks/xxxxxxxxxxxxxxxxxxxxxxxxxx")
func TestMattermostRetry(t *testing.T) {
notifier, err := New(
&config.MattermostConfig{
WebhookURL: &config.SecretURL{URL: testWebhookURL},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
log.NewNopLogger(),
)
require.NoError(t, err)
for statusCode, expected := range test.RetryTests(test.DefaultRetryCodes()) {
actual, _ := notifier.retrier.Check(statusCode, nil)
require.Equal(t, expected, actual, fmt.Sprintf("retry - error on status %d", statusCode))
}
}
func TestMattermostTemplating(t *testing.T) {
// Create a fake HTTP server to simulate the Mattermost webhook
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
dec := json.NewDecoder(r.Body)
out := make(map[string]interface{})
err := dec.Decode(&out)
if err != nil {
panic(err)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
for _, tc := range []struct {
title string
cfg *config.MattermostConfig
retry bool
errMsg string
}{
{
title: "text with default templating",
cfg: &config.DefaultMattermostConfig,
retry: false,
},
{
title: "text with templating errors",
cfg: &config.MattermostConfig{
Text: "{{ ",
},
errMsg: "template: :1: unclosed action",
},
} {
t.Run(tc.title, func(t *testing.T) {
tc.cfg.WebhookURL = &config.SecretURL{URL: u}
tc.cfg.HTTPConfig = &commoncfg.HTTPClientConfig{}
pd, err := New(tc.cfg, test.CreateTmpl(t), log.NewNopLogger())
require.NoError(t, err)
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
ok, err := pd.Notify(ctx, []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"lbl1": "val1",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}...)
if tc.errMsg == "" {
require.NoError(t, err)
} else {
require.Error(t, err)
require.Contains(t, err.Error(), tc.errMsg)
}
require.Equal(t, tc.retry, ok)
})
}
}
func TestMattermostRedactedURL(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
secret := "secret"
notifier, err := New(
&config.MattermostConfig{
WebhookURL: &config.SecretURL{URL: u},
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
log.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, secret)
}
func TestMattermostReadingURLFromFile(t *testing.T) {
ctx, u, fn := test.GetContextWithCancelingURL()
defer fn()
f, err := os.CreateTemp("", "webhook_url")
require.NoError(t, err, "creating temp file failed")
_, err = f.WriteString(u.String() + "\n")
require.NoError(t, err, "writing to temp file failed")
notifier, err := New(
&config.MattermostConfig{
WebhookURLFile: f.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
},
test.CreateTmpl(t),
log.NewNopLogger(),
)
require.NoError(t, err)
test.AssertNotifyLeaksNoSecret(ctx, t, notifier, u.String())
}
func TestMattermost_Notify(t *testing.T) {
// Create a fake HTTP server to simulate the Mattermost webhook
var resp string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Read the request as a string
body, err := io.ReadAll(r.Body)
require.NoError(t, err, "reading request body failed")
// Store the request body in the response
resp = string(body)
w.WriteHeader(http.StatusOK)
}))
// Create a temporary file to simulate the WebhookURLFile
tempFile, err := os.CreateTemp("", "webhook_url")
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, os.Remove(tempFile.Name()))
})
// Write the fake webhook URL to the temp file
_, err = tempFile.WriteString(srv.URL)
require.NoError(t, err)
// Create a context and alerts
ctx := context.Background()
ctx = notify.WithGroupKey(ctx, "1")
alerts := []*types.Alert{
{
Alert: model.Alert{
Labels: model.LabelSet{
"lbl1": "val1",
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(time.Hour),
},
},
}
type testcase struct {
name string
text string
props *config.MattermostProps
priority *config.MattermostPriority
result string
}
tests := []testcase{
{
name: "with text only",
text: "Test Text",
result: "{\"text\":\"Test Text\"}\n",
},
{
name: "with text and props",
text: "Test Text",
props: &config.MattermostProps{Card: "Test Card"},
priority: nil,
result: "{\"text\":\"Test Text\",\"props\":{\"card\":\"Test Card\"}}\n",
},
{
name: "with text and priority standard",
text: "Test Text",
props: nil,
priority: &config.MattermostPriority{Priority: "standard", RequestedAck: true, PersistentNotifications: true},
result: "{\"text\":\"Test Text\",\"priority\":{\"priority\":\"standard\"}}\n",
},
{
name: "with text, props and priority",
text: "Test Text",
props: &config.MattermostProps{Card: "Test Card"},
priority: &config.MattermostPriority{Priority: "urgent"},
result: "{\"text\":\"Test Text\",\"props\":{\"card\":\"Test Card\"},\"priority\":{\"priority\":\"urgent\"}}\n",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
// Create a MattermostConfig with the WebhookURLFile set
cfg := &config.MattermostConfig{
WebhookURLFile: tempFile.Name(),
HTTPConfig: &commoncfg.HTTPClientConfig{},
Text: tc.text,
Props: tc.props,
Priority: tc.priority,
}
// Create a new Mattermost notifier
notifier, err := New(cfg, test.CreateTmpl(t), log.NewNopLogger())
require.NoError(t, err)
// Call the Notify method
ok, err := notifier.Notify(ctx, alerts...)
require.NoError(t, err)
require.False(t, ok)
require.Equal(t, tc.result, resp)
})
}
}

View File

@ -368,6 +368,7 @@ func (m *Metrics) InitializeFor(receiver map[string][]Integration) {
"msteamsv2",
"jira",
"rocketchat",
"mattermost",
} {
m.numNotifications.WithLabelValues(integration)
m.numNotificationRequestsTotal.WithLabelValues(integration)

View File

@ -217,3 +217,14 @@ Alerts Resolved:
{{ define "rocketchat.default.emoji" }}{{ end }}
{{ define "rocketchat.default.iconurl" }}{{ end }}
{{ define "rocketchat.default.text" }}{{ end }}
{{ define "mattermost.default.text" }}
{{ if gt (len .Alerts.Firing) 0 }}
# Alerts Firing:
{{ template "__text_alert_list_markdown" .Alerts.Firing }}
{{ end }}
{{ if gt (len .Alerts.Resolved) 0 }}
# Alerts Resolved:
{{ template "__text_alert_list_markdown" .Alerts.Resolved }}
{{ end }}
{{ end }}