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