1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View file

@ -0,0 +1,200 @@
package teams
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// Scheme is the identifier for the Teams service protocol.
const Scheme = "teams"
// Config constants.
const (
DummyURL = "teams://dummy@dummy.com" // Default placeholder URL
ExpectedOrgMatches = 2 // Full match plus organization domain capture group
MinPathComponents = 3 // Minimum required path components: AltID, GroupOwner, ExtraID
)
// Config represents the configuration for the Teams service.
type Config struct {
standard.EnumlessConfig
Group string `optional:"" url:"user"`
Tenant string `optional:"" url:"host"`
AltID string `optional:"" url:"path1"`
GroupOwner string `optional:"" url:"path2"`
ExtraID string `optional:"" url:"path3"`
Title string `key:"title" optional:""`
Color string `key:"color" optional:""`
Host string `key:"host" optional:""` // Required, no default
}
// WebhookParts returns the webhook components as an array.
func (config *Config) WebhookParts() [5]string {
return [5]string{config.Group, config.Tenant, config.AltID, config.GroupOwner, config.ExtraID}
}
// SetFromWebhookURL updates the Config from a Teams webhook URL.
func (config *Config) SetFromWebhookURL(webhookURL string) error {
orgPattern := regexp.MustCompile(
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
)
orgGroups := orgPattern.FindStringSubmatch(webhookURL)
if len(orgGroups) != ExpectedComponents {
return ErrInvalidWebhookFormat
}
config.Host = orgGroups[1] + WebhookDomain
parts, err := ParseAndVerifyWebhookURL(webhookURL)
if err != nil {
return err
}
config.setFromWebhookParts(parts)
return nil
}
// ConfigFromWebhookURL creates a new Config from a parsed Teams webhook URL.
func ConfigFromWebhookURL(webhookURL url.URL) (*Config, error) {
webhookURL.RawQuery = ""
config := &Config{Host: webhookURL.Host}
if err := config.SetFromWebhookURL(webhookURL.String()); err != nil {
return nil, err
}
return config, nil
}
// GetURL constructs a URL from the Config fields.
func (config *Config) GetURL() *url.URL {
resolver := format.NewPropKeyResolver(config)
return config.getURL(&resolver)
}
// getURL constructs a URL using the provided resolver.
func (config *Config) getURL(resolver types.ConfigQueryResolver) *url.URL {
if config.Host == "" {
return nil
}
return &url.URL{
User: url.User(config.Group),
Host: config.Tenant,
Path: "/" + config.AltID + "/" + config.GroupOwner + "/" + config.ExtraID,
Scheme: Scheme,
RawQuery: format.BuildQuery(resolver),
}
}
// SetURL updates the Config from a URL.
func (config *Config) SetURL(url *url.URL) error {
resolver := format.NewPropKeyResolver(config)
return config.setURL(&resolver, url)
}
// setURL updates the Config from a URL using the provided resolver.
// It parses the URL parts, sets query parameters, and ensures the host is specified.
// Returns an error if the URL is invalid or the host is missing.
func (config *Config) setURL(resolver types.ConfigQueryResolver, url *url.URL) error {
parts, err := parseURLParts(url)
if err != nil {
return err
}
config.setFromWebhookParts(parts)
if err := config.setQueryParams(resolver, url.Query()); err != nil {
return err
}
if config.Host == "" {
return ErrMissingHostParameter
}
return nil
}
// parseURLParts extracts and validates webhook components from a URL.
func parseURLParts(url *url.URL) ([5]string, error) {
var parts [5]string
if url.String() == DummyURL {
return parts, nil
}
pathParts := strings.Split(url.Path, "/")
if pathParts[0] == "" {
pathParts = pathParts[1:]
}
if len(pathParts) < MinPathComponents {
return parts, ErrMissingExtraIDComponent
}
parts = [5]string{
url.User.Username(),
url.Hostname(),
pathParts[0],
pathParts[1],
pathParts[2],
}
if err := verifyWebhookParts(parts); err != nil {
return parts, fmt.Errorf("invalid URL format: %w", err)
}
return parts, nil
}
// setQueryParams applies query parameters to the Config using the resolver.
// It resets Color, Host, and Title, then updates them based on query values.
// Returns an error if the resolver fails to set any parameter.
func (config *Config) setQueryParams(resolver types.ConfigQueryResolver, query url.Values) error {
config.Color = ""
config.Host = ""
config.Title = ""
for key, vals := range query {
if len(vals) > 0 && vals[0] != "" {
switch key {
case "color":
config.Color = vals[0]
case "host":
config.Host = vals[0]
case "title":
config.Title = vals[0]
}
if err := resolver.Set(key, vals[0]); err != nil {
return fmt.Errorf(
"%w: key=%q, value=%q: %w",
ErrSetParameterFailed,
key,
vals[0],
err,
)
}
}
}
return nil
}
// setFromWebhookParts sets Config fields from webhook parts.
func (config *Config) setFromWebhookParts(parts [5]string) {
config.Group = parts[0]
config.Tenant = parts[1]
config.AltID = parts[2]
config.GroupOwner = parts[3]
config.ExtraID = parts[4]
}

View file

@ -0,0 +1,50 @@
package teams
import "errors"
// Error variables for the Teams package.
var (
// ErrInvalidWebhookFormat indicates the webhook URL doesn't contain the organization domain.
ErrInvalidWebhookFormat = errors.New(
"invalid webhook URL format - must contain organization domain",
)
// ErrMissingHostParameter indicates the required host parameter is missing.
ErrMissingHostParameter = errors.New(
"missing required host parameter (organization.webhook.office.com)",
)
// ErrMissingExtraIDComponent indicates the URL is missing the extraId component.
ErrMissingExtraIDComponent = errors.New("invalid URL format: missing extraId component")
// ErrMissingHost indicates the host is not specified in the configuration.
ErrMissingHost = errors.New("host is required but not specified in the configuration")
// ErrSetParameterFailed indicates failure to set a configuration parameter.
ErrSetParameterFailed = errors.New("failed to set configuration parameter")
// ErrSendFailedStatus indicates an unexpected status code in the response.
ErrSendFailedStatus = errors.New(
"failed to send notification to teams, response status code unexpected",
)
// ErrSendFailed indicates a general failure in sending the notification.
ErrSendFailed = errors.New("an error occurred while sending notification to teams")
// ErrInvalidWebhookURL indicates the webhook URL format is invalid.
ErrInvalidWebhookURL = errors.New("invalid webhook URL format")
// ErrInvalidHostFormat indicates the host format is invalid.
ErrInvalidHostFormat = errors.New("invalid host format")
// ErrInvalidWebhookComponents indicates a mismatch in expected webhook URL components.
ErrInvalidWebhookComponents = errors.New(
"invalid webhook URL format: expected component count mismatch",
)
// ErrInvalidPartLength indicates a webhook component has an incorrect length.
ErrInvalidPartLength = errors.New("invalid webhook part length")
// ErrMissingExtraID indicates the extraID is missing.
ErrMissingExtraID = errors.New("extraID is required")
)

View file

@ -0,0 +1,164 @@
package teams
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// MaxSummaryLength defines the maximum length for a notification summary.
const MaxSummaryLength = 20
// TruncatedSummaryLen defines the length for a truncated summary.
const TruncatedSummaryLen = 21
// Service sends notifications to Microsoft Teams.
type Service struct {
standard.Standard
Config *Config
pkr format.PropKeyResolver
}
// Send delivers a notification message to Microsoft Teams.
func (service *Service) Send(message string, params *types.Params) error {
config := service.Config
if err := service.pkr.UpdateConfigFromParams(config, params); err != nil {
service.Logf("Failed to update params: %v", err)
}
return service.doSend(config, message)
}
// Initialize configures the service with a URL and logger.
func (service *Service) Initialize(configURL *url.URL, logger types.StdLogger) error {
service.SetLogger(logger)
service.Config = &Config{}
service.pkr = format.NewPropKeyResolver(service.Config)
return service.Config.SetURL(configURL)
}
// GetID returns the service identifier.
func (service *Service) GetID() string {
return Scheme
}
// GetConfigURLFromCustom converts a custom URL to a service URL.
func (service *Service) GetConfigURLFromCustom(customURL *url.URL) (*url.URL, error) {
webhookURLStr := strings.TrimPrefix(customURL.String(), "teams+")
tempURL, err := url.Parse(webhookURLStr)
if err != nil {
return nil, fmt.Errorf("parsing custom URL %q: %w", webhookURLStr, err)
}
webhookURL := &url.URL{
Scheme: tempURL.Scheme,
Host: tempURL.Host,
Path: tempURL.Path,
}
config, err := ConfigFromWebhookURL(*webhookURL)
if err != nil {
return nil, err
}
config.Color = ""
config.Title = ""
query := customURL.Query()
for key, vals := range query {
if vals[0] != "" {
switch key {
case "color":
config.Color = vals[0]
case "host":
config.Host = vals[0]
case "title":
config.Title = vals[0]
}
}
}
return config.GetURL(), nil
}
// doSend sends the notification to Teams using the configured webhook URL.
func (service *Service) doSend(config *Config, message string) error {
lines := strings.Split(message, "\n")
sections := make([]section, 0, len(lines))
for _, line := range lines {
sections = append(sections, section{Text: line})
}
summary := config.Title
if summary == "" && len(sections) > 0 {
summary = sections[0].Text
if len(summary) > MaxSummaryLength {
summary = summary[:TruncatedSummaryLen]
}
}
payload, err := json.Marshal(payload{
CardType: "MessageCard",
Context: "http://schema.org/extensions",
Markdown: true,
Title: config.Title,
ThemeColor: config.Color,
Summary: summary,
Sections: sections,
})
if err != nil {
return fmt.Errorf("marshaling payload to JSON: %w", err)
}
if config.Host == "" {
return ErrMissingHost
}
postURL := BuildWebhookURL(
config.Host,
config.Group,
config.Tenant,
config.AltID,
config.GroupOwner,
config.ExtraID,
)
// Validate URL before sending
if err := ValidateWebhookURL(postURL); err != nil {
return err
}
res, err := safePost(postURL, payload)
if err != nil {
return fmt.Errorf("%w: %s", ErrSendFailed, err.Error())
}
defer res.Body.Close() // Move defer after error check
if res.StatusCode != http.StatusOK {
return fmt.Errorf("%w: %s", ErrSendFailedStatus, res.Status)
}
return nil
}
// safePost performs an HTTP POST with a pre-validated URL.
// Validation is already done; this wrapper isolates the call.
//
//nolint:gosec,noctx // Ignoring G107: Potential HTTP request made with variable url
func safePost(url string, payload []byte) (*http.Response, error) {
res, err := http.Post(url, "application/json", bytes.NewBuffer(payload))
if err != nil {
return nil, fmt.Errorf("making HTTP POST request: %w", err)
}
return res, nil
}

View file

@ -0,0 +1,265 @@
package teams
import (
"errors"
"log"
"net/url"
"testing"
"github.com/jarcoal/httpmock"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
const (
extraIDValue = "V2ESyij_gAljSoUQHvZoZYzlpAoAXExyOl26dlf1xHEx05"
scopedWebhookURL = "https://test.webhook.office.com/webhookb2/11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/IncomingWebhook/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
scopedDomainHost = "test.webhook.office.com"
testURLBase = "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc/" + extraIDValue
scopedURLBase = testURLBase + "?host=" + scopedDomainHost
)
var logger = log.New(ginkgo.GinkgoWriter, "Test", log.LstdFlags)
// TestTeams runs the test suite for the Teams package.
func TestTeams(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Teams Suite")
}
var _ = ginkgo.Describe("the teams service", func() {
ginkgo.When("creating the webhook URL", func() {
ginkgo.It("should match the expected output for custom URLs", func() {
config := Config{}
config.setFromWebhookParts([5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
})
apiURL := BuildWebhookURL(
scopedDomainHost,
config.Group,
config.Tenant,
config.AltID,
config.GroupOwner,
config.ExtraID,
)
gomega.Expect(apiURL).To(gomega.Equal(scopedWebhookURL))
parts, err := ParseAndVerifyWebhookURL(apiURL)
gomega.Expect(err).ToNot(gomega.HaveOccurred())
gomega.Expect(parts).To(gomega.Equal(config.WebhookParts()))
})
})
ginkgo.Describe("creating a config", func() {
ginkgo.When("parsing the configuration URL", func() {
ginkgo.It("should be identical after de-/serialization", func() {
testURL := testURLBase + "?color=aabbcc&host=test.webhook.office.com&title=Test+title"
url, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
config := &Config{}
err = config.SetURL(url)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "verifying")
outputURL := config.GetURL()
gomega.Expect(outputURL.String()).To(gomega.Equal(testURL))
})
})
})
ginkgo.Describe("converting custom URL to service URL", func() {
ginkgo.When("an invalid custom URL is provided", func() {
ginkgo.It("should return an error", func() {
service := Service{}
testURL := "teams+https://google.com/search?q=what+is+love"
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
_, err = service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).To(gomega.HaveOccurred(), "converting")
})
})
ginkgo.When("a valid custom URL is provided", func() {
ginkgo.It("should set the host field from the custom URL", func() {
service := Service{}
testURL := `teams+` + scopedWebhookURL
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
serviceURL, err := service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
gomega.Expect(serviceURL.String()).To(gomega.Equal(scopedURLBase))
})
ginkgo.It("should preserve the query params in the generated service URL", func() {
service := Service{}
testURL := "teams+" + scopedWebhookURL + "?color=f008c1&title=TheTitle"
customURL, err := url.Parse(testURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "parsing")
serviceURL, err := service.GetConfigURLFromCustom(customURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "converting")
expectedURL := testURLBase + "?color=f008c1&host=test.webhook.office.com&title=TheTitle"
gomega.Expect(serviceURL.String()).To(gomega.Equal(expectedURL))
})
})
})
ginkgo.Describe("sending the payload", func() {
var err error
var service Service
ginkgo.BeforeEach(func() {
httpmock.Activate()
})
ginkgo.AfterEach(func() {
httpmock.DeactivateAndReset()
})
ginkgo.It("should not report an error if the server accepts the payload", func() {
serviceURL, _ := url.Parse(scopedURLBase)
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
scopedWebhookURL,
httpmock.NewStringResponder(200, ""),
)
err = service.Send("Message", nil)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should not panic if an error occurs when sending the payload", func() {
serviceURL, _ := url.Parse(testURLBase + "?host=test.webhook.office.com")
err = service.Initialize(serviceURL, logger)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
httpmock.RegisterResponder(
"POST",
scopedWebhookURL,
httpmock.NewErrorResponder(errors.New("dummy error")),
)
err = service.Send("Message", nil)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.It("should return the correct service ID", func() {
service := &Service{}
gomega.Expect(service.GetID()).To(gomega.Equal("teams"))
})
// Config tests
ginkgo.Describe("the teams config", func() {
ginkgo.Describe("setURL", func() {
ginkgo.It("should set all fields correctly from URL", func() {
config := &Config{}
urlStr := testURLBase + "?title=Test&color=red&host=test.webhook.office.com"
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(config.Group).To(gomega.Equal("11111111-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.Tenant).
To(gomega.Equal("22222222-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.AltID).To(gomega.Equal("33333301222222222233333333333344"))
gomega.Expect(config.GroupOwner).
To(gomega.Equal("44444444-4444-4444-8444-cccccccccccc"))
gomega.Expect(config.ExtraID).To(gomega.Equal(extraIDValue))
gomega.Expect(config.Title).To(gomega.Equal("Test"))
gomega.Expect(config.Color).To(gomega.Equal("red"))
gomega.Expect(config.Host).To(gomega.Equal("test.webhook.office.com"))
})
ginkgo.It("should reject URLs missing the extraID", func() {
config := &Config{}
urlStr := "teams://11111111-4444-4444-8444-cccccccccccc@22222222-4444-4444-8444-cccccccccccc/33333301222222222233333333333344/44444444-4444-4444-8444-cccccccccccc?host=test.webhook.office.com"
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should require the host parameter", func() {
config := &Config{}
urlStr := testURLBase
parsedURL, err := url.Parse(urlStr)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
err = config.SetURL(parsedURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("getURL", func() {
ginkgo.It("should generate correct URL with all parameters", func() {
config := &Config{
Group: "11111111-4444-4444-8444-cccccccccccc",
Tenant: "22222222-4444-4444-8444-cccccccccccc",
AltID: "33333301222222222233333333333344",
GroupOwner: "44444444-4444-4444-8444-cccccccccccc",
ExtraID: extraIDValue,
Title: "Test",
Color: "red",
Host: "test.webhook.office.com",
}
urlObj := config.GetURL()
urlStr := urlObj.String()
expectedURL := testURLBase + "?color=red&host=test.webhook.office.com&title=Test"
gomega.Expect(urlStr).To(gomega.Equal(expectedURL))
})
})
ginkgo.Describe("verifyWebhookParts", func() {
ginkgo.It("should validate correct webhook parts", func() {
parts := [5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}
err := verifyWebhookParts(parts)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
})
ginkgo.It("should reject invalid group ID", func() {
parts := [5]string{
"invalid-id",
"22222222-4444-4444-8444-cccccccccccc",
"33333333012222222222333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}
err := verifyWebhookParts(parts)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
ginkgo.Describe("parseAndVerifyWebhookURL", func() {
ginkgo.It("should correctly parse valid webhook URL", func() {
webhookURL := scopedWebhookURL
parts, err := ParseAndVerifyWebhookURL(webhookURL)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(parts).To(gomega.Equal([5]string{
"11111111-4444-4444-8444-cccccccccccc",
"22222222-4444-4444-8444-cccccccccccc",
"33333301222222222233333333333344",
"44444444-4444-4444-8444-cccccccccccc",
extraIDValue,
}))
})
ginkgo.It("should reject invalid webhook URL", func() {
webhookURL := "https://teams.microsoft.com/invalid/webhook/url"
_, err := ParseAndVerifyWebhookURL(webhookURL)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
})

View file

@ -0,0 +1,62 @@
package teams
// payload is the main structure for a Teams message card.
type payload struct {
CardType string `json:"@type"`
Context string `json:"@context"`
ThemeColor string `json:"themeColor,omitempty"`
Summary string `json:"summary"`
Title string `json:"title,omitempty"`
Markdown bool `json:"markdown"`
Sections []section `json:"sections"`
}
// section represents a section of a Teams message card.
type section struct {
ActivityTitle string `json:"activityTitle,omitempty"`
ActivitySubtitle string `json:"activitySubtitle,omitempty"`
ActivityImage string `json:"activityImage,omitempty"`
Facts []fact `json:"facts,omitempty"`
Text string `json:"text,omitempty"`
Images []image `json:"images,omitempty"`
Actions []action `json:"potentialAction,omitempty"`
HeroImage *heroCard `json:"heroImage,omitempty"`
}
// fact represents a key-value pair in a Teams message card.
type fact struct {
Name string `json:"name"`
Value string `json:"value"`
}
// image represents an image in a Teams message card.
type image struct {
Image string `json:"image"`
Title string `json:"title,omitempty"`
}
// action represents an action button in a Teams message card.
type action struct {
Type string `json:"@type"`
Name string `json:"name"`
Targets []target `json:"targets,omitempty"`
Actions []subAction `json:"actions,omitempty"`
}
// target represents a target for an action in a Teams message card.
type target struct {
OS string `json:"os"`
URI string `json:"uri"`
}
// subAction represents a sub-action in a Teams message card.
type subAction struct {
Type string `json:"@type"`
Name string `json:"name"`
URI string `json:"uri"`
}
// heroCard represents a hero image in a Teams message card.
type heroCard struct {
Image string `json:"image"`
}

View file

@ -0,0 +1,107 @@
package teams
import (
"fmt"
"regexp"
)
// Validation constants.
const (
UUID4Length = 36 // Length of a UUID4 identifier
HashLength = 32 // Length of a hash identifier
WebhookDomain = ".webhook.office.com"
ExpectedComponents = 7 // Expected number of components in webhook URL (1 match + 6 captures)
Path = "webhookb2"
ProviderName = "IncomingWebhook"
AltIDIndex = 2 // Index of AltID in parts array
GroupOwnerIndex = 3 // Index of GroupOwner in parts array
)
var (
// HostValidator ensures the host matches the Teams webhook domain pattern.
HostValidator = regexp.MustCompile(`^[a-zA-Z0-9-]+\.webhook\.office\.com$`)
// WebhookURLValidator ensures the full webhook URL matches the Teams pattern.
WebhookURLValidator = regexp.MustCompile(
`^https://[a-zA-Z0-9-]+\.webhook\.office\.com/webhookb2/[0-9a-f-]{36}@[0-9a-f-]{36}/IncomingWebhook/[0-9a-f]{32}/[0-9a-f-]{36}/[^/]+$`,
)
)
// ValidateWebhookURL ensures the webhook URL is valid before use.
func ValidateWebhookURL(url string) error {
if !WebhookURLValidator.MatchString(url) {
return fmt.Errorf("%w: %q", ErrInvalidWebhookURL, url)
}
return nil
}
// ParseAndVerifyWebhookURL extracts and validates webhook components from a URL.
func ParseAndVerifyWebhookURL(webhookURL string) ([5]string, error) {
pattern := regexp.MustCompile(
`https://([a-zA-Z0-9-\.]+)` + WebhookDomain + `/` + Path + `/([0-9a-f\-]{36})@([0-9a-f\-]{36})/` + ProviderName + `/([0-9a-f]{32})/([0-9a-f\-]{36})/([^/]+)`,
)
groups := pattern.FindStringSubmatch(webhookURL)
if len(groups) != ExpectedComponents {
return [5]string{}, fmt.Errorf(
"%w: expected %d components, got %d",
ErrInvalidWebhookComponents,
ExpectedComponents,
len(groups),
)
}
parts := [5]string{groups[2], groups[3], groups[4], groups[5], groups[6]}
if err := verifyWebhookParts(parts); err != nil {
return [5]string{}, err
}
return parts, nil
}
// verifyWebhookParts ensures webhook components meet format requirements.
func verifyWebhookParts(parts [5]string) error {
type partSpec struct {
name string
length int
index int
optional bool
}
specs := []partSpec{
{name: "group ID", length: UUID4Length, index: 0, optional: true},
{name: "tenant ID", length: UUID4Length, index: 1, optional: true},
{name: "altID", length: HashLength, index: AltIDIndex, optional: true},
{name: "groupOwner", length: UUID4Length, index: GroupOwnerIndex, optional: true},
}
for _, spec := range specs {
if len(parts[spec.index]) != spec.length && parts[spec.index] != "" {
return fmt.Errorf(
"%w: %s must be %d characters, got %d",
ErrInvalidPartLength,
spec.name,
spec.length,
len(parts[spec.index]),
)
}
}
if parts[4] == "" {
return ErrMissingExtraID
}
return nil
}
// BuildWebhookURL constructs a Teams webhook URL from components.
func BuildWebhookURL(host, group, tenant, altID, groupOwner, extraID string) string {
// Host validation moved here for clarity
if !HostValidator.MatchString(host) {
return "" // Will trigger ErrInvalidHostFormat in caller
}
return fmt.Sprintf("https://%s/%s/%s@%s/%s/%s/%s/%s",
host, Path, group, tenant, ProviderName, altID, groupOwner, extraID)
}