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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
58 changes: 58 additions & 0 deletions backend/internal/hub/notifications/provider/teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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)
}

if title := strings.TrimSpace(cfg.Title); title != "" {
serviceConfig.Title = title
}

Comment thread
alex289 marked this conversation as resolved.
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": "Webhook-URL ist erforderlich",
"validationNotificationTeamsHostInvalid": "Webhook-URL muss eine gΓΌltige eingehende Microsoft-Teams-Webhook-URL sein",
"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": "Webhook URL is required",
"validationNotificationTeamsHostInvalid": "Webhook URL must be a valid Microsoft Teams incoming webhook URL",
"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