Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions backend/internal/hub/models/notifications.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const (
NotificationTypeDiscord NotificationType = "discord"
NotificationTypeGotify NotificationType = "gotify"
NotificationTypeSlack NotificationType = "slack"
NotificationTypeTeams NotificationType = "teams"
NotificationTypeWebhook NotificationType = "webhook"
NotificationTypeCustom NotificationType = "custom"
)
Expand Down
1 change: 1 addition & 0 deletions backend/internal/hub/notifications/provider/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func init() {
Register(models.NotificationTypeDiscord, DiscordProvider{})
Register(models.NotificationTypeGotify, GotifyProvider{})
Register(models.NotificationTypeSlack, SlackProvider{})
Register(models.NotificationTypeTeams, TeamsProvider{})
Register(models.NotificationTypeWebhook, WebhookProvider{})
Register(models.NotificationTypeCustom, CustomProvider{})
}
Expand Down
55 changes: 55 additions & 0 deletions backend/internal/hub/notifications/provider/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package provider

import (
"errors"
"fmt"
"net/url"
"strings"

"github.com/nicholas-fedor/shoutrrr/pkg/services/chat/teams"
)

type TeamsProvider struct{}

type teamsConfig struct {
Host string `json:"host"`
Title string `json:"title"`
}

func (TeamsProvider) BuildShoutrrrUrls(rawConfig string) ([]string, error) {
trimmed, err := normalizeRawConfig(rawConfig)
if err != nil {
return nil, err
}

cfg, err := decodeConfigJSON[teamsConfig](trimmed, "invalid JSON teams config")
if err != nil {
return nil, err
}

host := strings.TrimSpace(cfg.Host)
if host == "" {
return nil, errors.New("teams config requires host")
}

webhookURL, err := url.Parse(host)
if err != nil {
return nil, fmt.Errorf("invalid teams host: %w", err)
}
if webhookURL.Scheme != "https" {
return nil, errors.New("teams host must be a valid HTTPS Teams webhook URL")
}

serviceConfig, err := teams.ConfigFromWebhookURL(webhookURL)
if err != nil {
return nil, fmt.Errorf("invalid teams host: %w", err)
}
serviceConfig.Title = strings.TrimSpace(cfg.Title)

Comment thread
alex289 marked this conversation as resolved.
Outdated
serviceURL := serviceConfig.GetURL()
if serviceURL == nil {
return nil, errors.New("invalid teams host")
}

return []string{serviceURL.String()}, nil
}
146 changes: 146 additions & 0 deletions backend/internal/hub/notifications/provider/teams_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package provider

import (
"net/url"
"strings"
"testing"

"github.com/nicholas-fedor/shoutrrr"
)

const validTeamsWebhookURL = "https://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"

func TestTeamsProviderBuildShoutrrrUrls(t *testing.T) {
tests := []struct {
name string
raw string
wantErr string
}{
{
name: "empty config",
raw: " \n ",
wantErr: "notification config is empty",
},
{
name: "invalid json object",
raw: "{",
wantErr: "invalid JSON teams config",
},
{
name: "missing host",
raw: `{"title":"Deploy done"}`,
wantErr: "teams config requires host",
},
{
name: "non-https scheme must be rejected",
raw: `{"host":"http://contoso.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`,
wantErr: "teams host must be a valid HTTPS Teams webhook URL",
},
{
name: "invalid webhook domain",
raw: `{"host":"https://example.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"}`,
wantErr: "invalid teams host",
},
{ //nolint:gosec
name: "direct target string instead of json",
raw: "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05?host=contoso.webhook.office.com",
wantErr: "invalid JSON teams config",
},
{ //nolint:gosec
name: "json array instead of object",
raw: `[{"host":"` + validTeamsWebhookURL + `"}]`,
wantErr: "invalid JSON teams config",
},
}

provider := TeamsProvider{}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := provider.BuildShoutrrrUrls(tt.raw)
if tt.wantErr != "" {
if err == nil {
t.Fatalf("expected error containing %q", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
}
return
}

if err != nil {
t.Fatalf("BuildShoutrrrUrls() error = %v", err)
}
if len(got) != 1 {
t.Fatalf("expected one URL, got %v", got)
}
if !strings.HasPrefix(got[0], "teams://") {
t.Fatalf("expected teams URL, got %q", got[0])
}
})
}
}

func TestTeamsProviderBuildsStructuredURL(t *testing.T) {
provider := TeamsProvider{}

urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `","title":"Deploy done"}`)
if err != nil {
t.Fatalf("BuildShoutrrrUrls() error = %v", err)
}
if len(urls) != 1 {
t.Fatalf("expected 1 URL, got %d", len(urls))
}

parsed, err := url.Parse(urls[0])
if err != nil {
t.Fatalf("failed to parse URL %q: %v", urls[0], err)
}

if parsed.Scheme != "teams" {
t.Fatalf("expected scheme teams, got %q", parsed.Scheme)
}
if parsed.User == nil || parsed.User.Username() != "11111111-4444-4444-8444-cccccccccccc" {
t.Fatalf("expected group in URL user, got %v", parsed.User)
}
if parsed.Host != "22222222-4444-4444-8444-cccccccccccc" {
t.Fatalf("expected tenant in URL host, got %q", parsed.Host)
}
if parsed.Path != "/33333333012222222222333333333344/44444444-4444-4444-8444-cccccccccccc/V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05" {
t.Fatalf("expected webhook path components, got %q", parsed.Path)
}

query := parsed.Query()
if query.Get("host") != "contoso.webhook.office.com" {
t.Fatalf("expected host query parameter, got %q", query.Get("host"))
}
if query.Get("title") != "Deploy done" {
t.Fatalf("expected title query parameter, got %q", query.Get("title"))
}

if _, err := shoutrrr.CreateSender(urls...); err != nil {
t.Fatalf("expected Shoutrrr to accept Teams URL: %v", err)
}
}

func TestTeamsProviderMinimalConfig(t *testing.T) {
provider := TeamsProvider{}

urls, err := provider.BuildShoutrrrUrls(`{"host":"` + validTeamsWebhookURL + `"}`)
if err != nil {
t.Fatalf("BuildShoutrrrUrls() error = %v", err)
}
if len(urls) != 1 {
t.Fatalf("expected 1 URL, got %d", len(urls))
}

parsed, err := url.Parse(urls[0])
if err != nil {
t.Fatalf("failed to parse URL %q: %v", urls[0], err)
}

query := parsed.Query()
if query.Get("title") != "" {
t.Fatalf("expected no title, got %q", query.Get("title"))
}
}
7 changes: 6 additions & 1 deletion frontend/messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,14 @@
"notificationSlackAppSettingsNote": "Bot-Name und Symbol kΓΆnnen in den Slack-App-Einstellungen unter api.slack.com konfiguriert werden.",
"validationNotificationSlackWebhookUrlRequired": "Webhook-URL ist erforderlich",
"validationNotificationSlackWebhookUrlInvalid": "Webhook-URL muss eine gΓΌltige Slack-Webhook-URL sein",
"title": "Titel",
"notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...",
"notificationTeamsTitlePlaceholder": "z. B. \"Deploy status\"",
"validationNotificationTeamsHostRequired": "Host ist erforderlich",
"validationNotificationTeamsHostInvalid": "Host muss eine gΓΌltige eingehende Microsoft-Teams-Webhook-URL sein",
Comment thread
alex289 marked this conversation as resolved.
Outdated
"notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook",
"notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment",
"notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done",
"notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status",
"shoutrrrUrl": "Shoutrrr-URL",
"shoutrrrDocs": "Shoutrrr-Doku",
"httpMethod": "HTTP-Methode",
Expand Down
7 changes: 6 additions & 1 deletion frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -314,9 +314,14 @@
"notificationSlackAppSettingsNote": "The bot name and icon can be configured in your Slack app settings at api.slack.com.",
"validationNotificationSlackWebhookUrlRequired": "Webhook URL is required",
"validationNotificationSlackWebhookUrlInvalid": "Webhook URL must be a valid Slack webhook URL",
"title": "Title",
"notificationTeamsHostPlaceholder": "https://contoso.webhook.office.com/webhookb2/...",
"notificationTeamsTitlePlaceholder": "e.g. \"Deploy status\"",
"validationNotificationTeamsHostRequired": "Host is required",
"validationNotificationTeamsHostInvalid": "Host must be a valid Microsoft Teams incoming webhook URL",
Comment thread
alex289 marked this conversation as resolved.
Outdated
"notificationGenericWebhookUrlPlaceholder": "https://api.example.com/webhook",
"notificationWebhookHeadersPlaceholder": "Authorization: Bearer token\nX-Orca-Event: deployment",
"notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20done",
"notificationCustomShoutrrrUrlPlaceholder": "discord://token@webhook-id?title=Deploy%20status",
"shoutrrrUrl": "Shoutrrr URL",
"shoutrrrDocs": "Shoutrrr docs",
"httpMethod": "HTTP Method",
Expand Down
53 changes: 53 additions & 0 deletions frontend/src/components/dialogs/create-notification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ import {
isValidSlackWebhookUrl,
SlackWebhookUrlField,
} from "./slack-notification-builder";
import {
buildTeamsNotificationConfig,
isValidTeamsHost,
TeamsHostField,
TeamsTitleField,
} from "./teams-notification-builder";
import {
buildGotifyNotificationConfig,
GotifyAppTokenField,
Expand Down Expand Up @@ -98,6 +104,8 @@ const notificationBaseSchema = z.object({
gotifyPriority: z.string().trim(),
gotifyCustomPath: z.string().trim(),
slackWebhookUrl: z.string().trim(),
teamsHost: z.string().trim(),
teamsTitle: z.string().trim(),
webhookUrl: z.string().trim(),
webhookHeaders: z.string(),
customShoutrrrUrl: z.string().trim(),
Expand Down Expand Up @@ -193,6 +201,22 @@ const notificationSchema = notificationBaseSchema.superRefine((value, ctx) => {
}
}

if (value.type === "teams") {
if (value.teamsHost === "") {
ctx.addIssue({
code: "custom",
path: ["teamsHost"],
message: m.validationNotificationTeamsHostRequired(),
});
} else if (!isValidTeamsHost(value.teamsHost)) {
ctx.addIssue({
code: "custom",
path: ["teamsHost"],
message: m.validationNotificationTeamsHostInvalid(),
});
}
}

if (value.type === "webhook") {
if (value.webhookUrl === "") {
ctx.addIssue({
Expand Down Expand Up @@ -262,6 +286,18 @@ function buildNotificationConfig(value: NotificationFormValues): string {
return config;
}

if (value.type === "teams") {
const config = buildTeamsNotificationConfig({
teamsHost: value.teamsHost,
teamsTitle: value.teamsTitle,
});
if (!config) {
throw new Error(m.validationNotificationTeamsHostInvalid());
}

return config;
}

if (value.type === "gotify") {
const config = buildGotifyNotificationConfig({
gotifyServerUrl: value.gotifyServerUrl,
Expand Down Expand Up @@ -317,6 +353,8 @@ function useNotificationForm() {
gotifyPriority: "5",
gotifyCustomPath: "",
slackWebhookUrl: "",
teamsHost: "",
teamsTitle: "",
webhookUrl: "",
webhookHeaders: "",
customShoutrrrUrl: "",
Expand Down Expand Up @@ -558,6 +596,19 @@ function NotificationProviderStepContent({ form }: { form: NotificationFormApi }
);
}

if (type === "teams") {
return (
<FieldGroup>
<form.Field name="teamsHost" children={(field) => <TeamsHostField field={field} />} />

<form.Field
name="teamsTitle"
children={(field) => <TeamsTitleField field={field} />}
/>
</FieldGroup>
);
}

if (type === "webhook") {
return (
<FieldGroup>
Expand Down Expand Up @@ -660,6 +711,8 @@ export default function CreateNotificationDialog() {
gotifyPriority: "5",
gotifyCustomPath: "",
slackWebhookUrl: "",
teamsHost: "",
teamsTitle: "",
webhookUrl: "",
webhookHeaders: "",
customShoutrrrUrl: "",
Expand Down
Loading
Loading