diff --git a/README.md b/README.md index fbe56cf..c291e3e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# go-teams-notify +# goteamsnotify A package to send messages to a Microsoft Teams channel. @@ -44,23 +44,33 @@ inclusion into the project. ## Overview The `goteamsnotify` package (aka, `go-teams-notify`) allows sending messages -to a Microsoft Teams channel. +to a Microsoft Teams channel. These messages can be composed of legacy +[`MessageCard`][msgcard-ref] or [`Adaptive Card`][adaptivecard-ref] card +formats. -Simple messages can be composed of only a title and a text body. More complex -messages can be composed of multiple sections, key/value pairs (aka, `Facts`) -and/or externally hosted images. See the [Features](#features) list for more -information. +Simple messages can be created by specifying only a title and a text body. +More complex messages may be composed of multiple sections (`MessageCard`) or +containers (`Adaptive Card`), key/value pairs (aka, `Facts`) and externally +hosted images. See the [Features](#features) list for more information. + +**NOTE**: `Adaptive Card` support is currently limited. The goal is to expand +this support in future releases to include additional features supported by +Microsoft Teams. ## Features - Submit simple or complex messages to Microsoft Teams - simple messages consist of only a title and a text body (one or more strings) - - complex messages consist of one or more sections, key/value pairs (aka, - `Facts`) and/or externally hosted images. or images (hosted externally) -- Support for [`Actions`][msgcard-ref-actions], allowing users to take quick - actions within Microsoft Teams -- Support for [user mentions][botapi-user-mentions] (limited) + - complex messages may consist of multiple sections (`MessageCard`), + containers (`Adaptive Card`) key/value pairs (aka, `Facts`) and externally + hosted images +- Support for Actions, allowing users to take quick actions within Microsoft + Teams + - [`MessageCard` `Actions`][msgcard-ref-actions] + - [`Adaptive Card` `Actions`][adaptivecard-ref-actions] +- Support for [user mentions][adaptivecard-user-mentions] (`Adaptive + Card` format) - Configurable validation of webhook URLs - enabled by default, attempts to match most common known webhook URL patterns @@ -70,6 +80,10 @@ information. - default assertion that bare-minimum required fields are present - support for providing a custom validation function to override default validation behavior +- Configurable validation of `Adaptive Card` type + - default assertion that bare-minimum required fields are present + - support for providing a custom validation function to override default + validation behavior - Configurable timeouts - Configurable retry support @@ -91,10 +105,18 @@ For more details, see the ## Supported Releases -| Series | Example | Status | -| -------- | -------- | ------------------- | -| `v1.x.x` | `v1.3.1` | Not Supported (EOL) | -| `v2.x.x` | `v2.6.0` | Supported | +| Series | Example | Status | +| -------- | ---------------- | ------------------- | +| `v1.x.x` | `v1.3.1` | Not Supported (EOL) | +| `v2.x.x` | `v2.6.0` | Supported | +| `v3.x.x` | `v3.0.0-alpha.1` | TBD | + +The current plan is to continue extending the v2 branch with new functionality +while retaining backwards compatibility. Any breakage in compatibility for the +v2 series is considered a bug (please report it). + +Long-term, the goal is to learn from missteps made in current releases and +correct as many as possible for a future v3 series. ## Changelog @@ -108,26 +130,6 @@ official release is also provided for further review. ### Add this project as a dependency -Assuming that you're using [Go -Modules](https://blog.golang.org/using-go-modules), add this line to your -imports like so: - -```golang -import ( - // ... - - "github.com/atc0005/go-teams-notify/v2" -) -``` - -Depending on your editor and current settings, your editor may resolve the -import and update your `go.mod` and `go.sum` files accordingly. If not, review -these resources for further information: - -- -- -- - See the [Examples](#examples) section for more details. ### Webhook URLs @@ -185,27 +187,40 @@ shadabacc3934](https://gist.github.com/chusiang/895f6406fbf9285c58ad0a3ace13d025 This is an example of a simple client application which uses this library. -File: [basic](./examples/basic/main.go) +- `Adaptive Card` + - File: [basic](./examples/adaptivecard/basic/main.go) +- `MessageCard` + - File: [basic](./examples/messagecard/basic/main.go) #### User Mention -This example illustrates the use of a user mention. +These examples illustrates the use of one or more user mentions. This feature +is not available in the legacy `MessageCard` card format. -File: [basic](./examples/user-mention/main.go) +- File: [user-mention-single](./examples/adaptivecard/user-mention-single/main.go) +- File: [user-mention-multiple](./examples/adaptivecard/user-mention-multiple/main.go) +- File: [user-mention-verbose](./examples/adaptivecard/user-mention-verbose/main.go) + - this example does not necessarily reflect an optimal implementation #### Set custom user agent This example illustrates setting a custom user agent. -File: [custom-user-agent](./examples/custom-user-agent/main.go) +- `Adaptive Card` + - File: [custom-user-agent](./examples/adaptivecard/custom-user-agent/main.go) +- `MessageCard` + - File: [custom-user-agent](./examples/messagecard/custom-user-agent/main.go) #### Add an Action -This example illustrates adding an [`OpenUri Action`][msgcard-ref-actions] to -a message card. When used, this action triggers opening a URI in a separate -browser or application. +This example illustrates adding an [`OpenUri`][msgcard-ref-actions] +(`MessageCard`) or [`OpenUrl`][adaptivecard-ref-actions] Action. When used, +this action triggers opening a URL in a separate browser or application. -File: [actions](./examples/actions/main.go) +- `Adaptive Card` + - File: [actions](./examples/adaptivecard/actions/main.go) +- `MessageCard` + - File: [actions](./examples/messagecard/actions/main.go) #### Disable webhook URL prefix validation @@ -213,14 +228,20 @@ This example disables the validation webhook URLs, including the validation of known prefixes so that custom/private webhook URL endpoints can be used (e.g., testing purposes). -File: [disable-validation](./examples/disable-validation/main.go) +- `Adaptive Card` + - File: [disable-validation](./examples/adaptivecard/disable-validation/main.go) +- `MessageCard` + - File: [disable-validation](./examples/messagecard/disable-validation/main.go) #### Enable custom patterns' validation This example demonstrates how to enable custom validation patterns for webhook URLs. -File: [custom-validation](./examples/custom-validation/main.go) +- `Adaptive Card` + - File: [custom-validation](./examples/adaptivecard/custom-validation/main.go) +- `MessageCard` + - File: [custom-validation](./examples/messagecard/custom-validation/main.go) ## Used by @@ -257,4 +278,6 @@ using either this library or the original project. [msgcard-ref]: [msgcard-ref-actions]: -[botapi-user-mentions]: +[adaptivecard-ref]: +[adaptivecard-ref-actions]: +[adaptivecard-user-mentions]: diff --git a/adaptivecard/adaptivecard.go b/adaptivecard/adaptivecard.go new file mode 100644 index 0000000..065f933 --- /dev/null +++ b/adaptivecard/adaptivecard.go @@ -0,0 +1,2226 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package adaptivecard + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "regexp" + "strconv" + "strings" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" +) + +// General constants. +const ( + // TypeMessage is the type for an Adaptive Card Message. + TypeMessage string = "message" +) + +// Card & TopLevelCard specific constants. +const ( + // TypeAdaptiveCard is the supported type value for an Adaptive Card. + TypeAdaptiveCard string = "AdaptiveCard" + + // AdaptiveCardSchema represents the URI of the Adaptive Card schema. + AdaptiveCardSchema string = "http://adaptivecards.io/schemas/adaptive-card.json" + + // AdaptiveCardMaxVersion represents the highest supported version of the + // Adaptive Card schema supported in Microsoft Teams messages. + // + // Version 1.3 is the highest supported for user-generated cards. + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards + // https://adaptivecards.io/designer + // + // Version 1.4 is when Action.Execute was introduced. + // + // Per this doc: + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards + // + // the "Action.Execute" action is supported: + // + // "For Adaptive Cards in Incoming Webhooks, all native Adaptive Card + // schema elements, except Action.Submit, are fully supported. The + // supported actions are Action.OpenURL, Action.ShowCard, + // Action.ToggleVisibility, and Action.Execute." + // + // Per this doc, Teams MAY support the Action.Execute action: + // + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model#schema + // + // AdaptiveCardMaxVersion float64 = 1.4 + AdaptiveCardMaxVersion float64 = 1.3 + AdaptiveCardMinVersion float64 = 1.0 + AdaptiveCardVersionTmpl string = "%0.1f" +) + +// Mention constants. +const ( + // TypeMention is the type for a user mention for a Adaptive Card Message. + TypeMention string = "mention" + + // MentionTextFormatTemplate is the expected format of the Mention.Text + // field value. + MentionTextFormatTemplate string = "%s" + + // defaultMentionTextSeparator is the default separator used between the + // contents of the Mention.Text field and a TextBlock.Text field. + defaultMentionTextSeparator string = " " +) + +// Attachment constants. +// +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference +// https://docs.microsoft.com/en-us/dotnet/api/microsoft.bot.schema.attachmentlayouttypes +// https://docs.microsoft.com/en-us/javascript/api/botframework-schema/attachmentlayouttypes +// https://github.com/matthidinger/ContosoScubaBot/blob/master/Cards/1-Schools.JSON +const ( + + // AttachmentContentType is the supported type value for an attached + // Adaptive Card for a Microsoft Teams message. + AttachmentContentType string = "application/vnd.microsoft.card.adaptive" + + AttachmentLayoutList string = "list" + AttachmentLayoutCarousel string = "carousel" +) + +// TextBlock specific contants. +// https://adaptivecards.io/explorer/TextBlock.html +const ( + // TextBlockStyleDefault indicates that the TextBlock uses the default + // style which provides no special styling or behavior. + TextBlockStyleDefault string = "default" + + // TextBlockStyleHeading indicates that the TextBlock is a heading. This + // will apply the heading styling defaults and mark the text block as a + // heading for accessibility. + TextBlockStyleHeading string = "heading" +) + +// Column specific constants. +// https://adaptivecards.io/explorer/Column.html +const ( + // TypeColumn is the type for an Adaptive Card Column. + TypeColumn string = "Column" + + // ColumnWidthAuto indicates that a column's width should be determined + // automatically based on other columns in the column group. + ColumnWidthAuto string = "auto" + + // ColumnWidthStretch indicates that a column's width should be stretched + // to fill the enclosing column group. + ColumnWidthStretch string = "stretch" + + // ColumnWidthPixelRegex is a regular expression pattern intended to match + // specific pixel width values (e.g., 50px). + ColumnWidthPixelRegex string = "^[0-9]+px$" + + // ColumnWidthPixelWidthExample is an example of a valid pixel width for a + // Column. + ColumnWidthPixelWidthExample string = "50px" +) + +// Text size for TextBlock or TextRun elements. +const ( + SizeSmall string = "small" + SizeDefault string = "default" + SizeMedium string = "medium" + SizeLarge string = "large" + SizeExtraLarge string = "extraLarge" +) + +// Text weight for TextBlock or TextRun elements. +const ( + WeightBolder string = "bolder" + WeightLighter string = "lighter" + WeightDefault string = "default" +) + +// Supported colors for TextBlock or TextRun elements. +const ( + ColorDefault string = "default" + ColorDark string = "dark" + ColorLight string = "light" + ColorAccent string = "accent" + ColorGood string = "good" + ColorWarning string = "warning" + ColorAttention string = "attention" +) + +// Image specific constants. +// https://adaptivecards.io/explorer/Image.html +const ( + ImageStyleDefault string = "" + ImageStylePerson string = "" +) + +// ChoiceInput specific contants. +const ( + ChoiceInputStyleCompact string = "compact" + ChoiceInputStyleExpanded string = "expanded" + ChoiceInputStyleFiltered string = "filtered" // Introduced in version 1.5 +) + +// TextInput specific contants. +const ( + TextInputStyleText string = "text" + TextInputStyleTel string = "tel" + TextInputStyleURL string = "url" + TextInputStyleEmail string = "email" + TextInputStylePassword string = "password" // Introduced in version 1.5 +) + +// Container specific constants. +const ( + ContainerStyleDefault string = "default" + ContainerStyleEmphasis string = "emphasis" + ContainerStyleGood string = "good" + ContainerStyleAttention string = "attention" + ContainerStyleWarning string = "warning" + ContainerStyleAccent string = "accent" +) + +// Supported spacing values for FactSet, Container and other container element +// types. +const ( + SpacingDefault string = "default" + SpacingNone string = "none" + SpacingSmall string = "small" + SpacingMedium string = "medium" + SpacingLarge string = "large" + SpacingExtraLarge string = "extraLarge" + SpacingPadding string = "padding" +) + +// Supported width values for the msteams property used in in Adaptive Card +// messages sent via Microsoft Teams. +const ( + MSTeamsWidthFull string = "Full" +) + +// Supported Actions +const ( + + // TeamsActionsDisplayLimit is the observed limit on the number of visible + // URL "buttons" in a Microsoft Teams message. + // + // Unlike the MessageCard format which has a clearly documented limit of 4 + // actions, testing reveals that Desktop / Web displays 6 without the + // option to expand and see any additional defined actions. Mobile + // displays 6 with an ellipsis to expand into a list of other Actions. + // + // This results in a maximum limit of 6 actions in the Actions array for a + // Card. + // + // A workaround is to create multiple ActionSet elements and limit the + // number of Actions in each set ot 6. + // + // https://docs.microsoft.com/en-us/outlook/actionable-messages/message-card-reference#actions + TeamsActionsDisplayLimit int = 6 + + // TypeActionExecute is an action that gathers input fields, merges with + // optional data field, and sends an event to the client. Clients process + // the event by sending an Invoke activity of type adaptiveCard/action to + // the target Bot. The inputs that are gathered are those on the current + // card, and in the case of a show card those on any parent cards. See + // Universal Action Model documentation for more details: + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model + // + // TypeActionExecute was introduced in Adaptive Cards schema version 1.4. + // TypeActionExecute actions may not render with earlier versions of the + // Teams client. + TypeActionExecute string = "Action.Execute" + + // ActionExecuteMinCardVersionRequired is the minimum version of the + // Adaptive Card schema required to support Action.Execute. + ActionExecuteMinCardVersionRequired float64 = 1.4 + + // TypeActionSubmit is used in Adaptive Cards schema version 1.3 and + // earlier or as a fallback for TypeActionExecute in schema version 1.4. + // TypeActionSubmit is not supported in Incoming Webhooks. + TypeActionSubmit string = "Action.Submit" + + // TypeActionOpenURL (when invoked) shows the given url either by + // launching it in an external web browser or showing within an embedded + // web browser. + TypeActionOpenURL string = "Action.OpenUrl" + + // TypeActionShowCard defines an AdaptiveCard which is shown to the user + // when the button or link is clicked. + TypeActionShowCard string = "Action.ShowCard" + + // TypeActionToggleVisibility toggles the visibility of associated card + // elements. + TypeActionToggleVisibility string = "Action.ToggleVisibility" +) + +// Supported Fallback options. +const ( + TypeFallbackActionExecute string = TypeActionExecute + TypeFallbackActionOpenURL string = TypeActionOpenURL + TypeFallbackActionShowCard string = TypeActionShowCard + TypeFallbackActionSubmit string = TypeActionSubmit + TypeFallbackActionToggleVisibility string = TypeActionToggleVisibility + + // TypeFallbackOptionDrop causes this element to be dropped immediately + // when unknown elements are encountered. The unknown element doesn't + // bubble up any higher. + TypeFallbackOptionDrop string = "drop" +) + +// Valid types for an Adaptive Card element. Not all types are supported by +// Microsoft Teams. +// +// https://adaptivecards.io/explorer/AdaptiveCard.html +// +// TODO: Confirm whether all types are supported. +// NOTE: Based on current docs, version 1.4 is the latest supported at this +// time. +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards +// https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model#schema +const ( + TypeElementActionSet string = "ActionSet" + TypeElementColumnSet string = "ColumnSet" + TypeElementContainer string = "Container" + TypeElementFactSet string = "FactSet" + TypeElementImage string = "Image" + TypeElementImageSet string = "ImageSet" + TypeElementInputChoiceSet string = "Input.ChoiceSet" + TypeElementInputDate string = "Input.Date" + TypeElementInputNumber string = "Input.Number" + TypeElementInputText string = "Input.Text" + TypeElementInputTime string = "Input.Time" + TypeElementInputToggle string = "Input.Toggle" + TypeElementMedia string = "Media" // Introduced in version 1.1 (TODO: Is this supported in Teams message?) + TypeElementRichTextBlock string = "RichTextBlock" // Introduced in version 1.2 + TypeElementTextBlock string = "TextBlock" + TypeElementTextRun string = "TextRun" // Introduced in version 1.2 +) + +// Sentinel errors for this package. +var ( + // ErrInvalidType indicates that an invalid type was specified. + ErrInvalidType = errors.New("invalid type value") + + // ErrInvalidFieldValue indicates that an invalid value was specified. + ErrInvalidFieldValue = errors.New("invalid field value") + + // ErrMissingValue indicates that an expected value was missing. + ErrMissingValue = errors.New("missing expected value") + + // ErrValueNotFound indicates that a requested value was not found. + ErrValueNotFound = errors.New("requested value not found") +) + +// Message represents a Microsoft Teams message containing one or more +// Adaptive Cards. +type Message struct { + // Type is required; must be set to "message". + Type string `json:"type"` + + // Attachments is a collection of one or more Adaptive Cards. + // + // NOTE: Including multiple attachment *without* AttachmentLayout set to + // "carousel" hides cards after the first. Not sure if this is a bug, or + // if it's intentional. + Attachments Attachments `json:"attachments"` + + // AttachmentLayout controls the layout for Adaptive Cards in the + // Attachments collection. + AttachmentLayout string `json:"attachmentLayout,omitempty"` + + // ValidateFunc is an optional user-specified validation function that is + // responsible for validating a Message. If not specified, default + // validation is performed. + ValidateFunc func() error `json:"-"` + + // payload is a prepared Message in JSON format for submission or pretty + // printing. + payload *bytes.Buffer `json:"-"` +} + +// Attachments is a collection of Adaptive Cards for a Microsoft Teams +// message. +// +// TODO: Creating a custom type in order to "hang" methods off of it. May not +// need this if we expose bulk of functionality from Message type. +// +// TODO: Use slice of pointers? +type Attachments []Attachment + +// Attachment represents an attached Adaptive Card for a Microsoft Teams +// message. +type Attachment struct { + + // ContentType is required; must be set to + // "application/vnd.microsoft.card.adaptive". + ContentType string `json:"contentType"` + + // ContentURL appears to be related to support for tabs. Most examples + // have this value set to null. + // + // TODO: Update this description with confirmed details. + ContentURL NullString `json:"contentUrl,omitempty"` + + // Content represents the content of an Adaptive Card. + // + // TODO: Should this be a pointer? + Content TopLevelCard `json:"content"` +} + +// TopLevelCard represents the outer or top-level Card for a Microsoft Teams +// Message attachment. +type TopLevelCard struct { + Card +} + +// Card represents the content of an Adaptive Card. The TopLevelCard is a +// superset of this one, asserting that the Version field is properly set. +// That type is used exclusively for Message Attachments. This type is used +// directly for the Action.ShowCard Card field. +type Card struct { + + // Type is required; must be set to "AdaptiveCard" + Type string `json:"type"` + + // Schema represents the URI of the Adaptive Card schema. + Schema string `json:"$schema"` + + // Version is required for top-level cards (i.e., the outer card in an + // attachment); the schema version that the content for an Adaptive Card + // requires. + // + // The TopLevelCard type is a superset of the Card type and asserts that + // this field is properly set, whereas the validation logic for this + // (Card) type skips that assertion. + Version string `json:"version"` + + // FallbackText is the text shown when the client doesn't support the + // version specified (may contain markdown). + FallbackText string `json:"fallbackText,omitempty"` + + // Body represents the body of an Adaptive Card. The body is made up of + // building-blocks known as elements. Elements can be composed to create + // many types of cards. These elements are shown in the primary card + // region. + Body []Element `json:"body"` + + // Actions is a collection of actions to show in the card's action bar. + // The action bar is displayed at the bottom of a Card. + // + // NOTE: The max display limit has been observed to be a fixed value for + // web/desktop app and a matching value as an initial display limit for + // mobile app with the option to expand remaining actions in a list. + // + // This value is recorded in this package as "TeamsActionsDisplayLimit". + // + // To work around this limit, create multiple ActionSets each limited to + // the value of TeamsActionsDisplayLimit. + Actions []Action `json:"actions,omitempty"` + + // MSTeams is a container for properties specific to Microsoft Teams + // messages, including formatting properties and user mentions. + // + // NOTE: Using pointer in order to omit unused field from JSON output. + // https://stackoverflow.com/questions/18088294/how-to-not-marshal-an-empty-struct-into-json-with-go + // MSTeams *MSTeams `json:"msteams,omitempty"` + // + // TODO: Revisit this and use a pointer if remote API doesn't like + // receiving an empty object, though brief testing doesn't show this to be + // a problem. + MSTeams MSTeams `json:"msteams,omitempty"` + + // MinHeight specifies the minimum height of the card. + MinHeight string `json:"minHeight,omitempty"` + + // VerticalContentAlignment defines how the content should be aligned + // vertically within the container. Only relevant for fixed-height cards, + // or cards with a minHeight specified. If MinHeight field is specified, + // this field is required. + VerticalContentAlignment string `json:"verticalContentAlignment,omitempty"` +} + +// Element is a "building block" for an Adaptive Card. Elements are shown +// within the primary card region (aka, "body"), columns and other container +// types. Not all fields of this Go struct type are supported by all Adaptive +// Card element types. +type Element struct { + + // Type is required and indicates the type of the element used in the body + // of an Adaptive Card. + // https://adaptivecards.io/explorer/AdaptiveCard.html + Type string `json:"type"` + + // ID is a unique identifier associated with this Element. + ID string `json:"id,omitempty"` + + // Text is required by the TextBlock and TextRun element types. Text is + // used to display text. A subset of markdown is supported for text used + // in TextBlock elements, but no formatting is permitted in text used in + // TextRun elements. + // + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features + // https://adaptivecards.io/explorer/TextBlock.html + // https://adaptivecards.io/explorer/TextRun.html + Text string `json:"text,omitempty"` + + // URL is required for the Image element type. URL is the URL to an Image + // in an ImageSet element type. + // + // https://adaptivecards.io/explorer/Image.html + // https://adaptivecards.io/explorer/ImageSet.html + URL string `json:"uri,omitempty"` + + // Size controls the size of text within a TextBlock element. + Size string `json:"size,omitempty"` + + // Weight controls the weight of text in TextBlock or TextRun elements. + Weight string `json:"weight,omitempty"` + + // Color controls the color of TextBlock elements or text used in TextRun + // elements. + Color string `json:"color,omitempty"` + + // Spacing controls the amount of spacing between this element and the + // preceding element. + Spacing string `json:"spacing,omitempty"` + + // The style of the element for accessibility purposes. Valid values + // differ based on the element type. For example, a TextBlock element + // supports the "heading" style, whereas the Column element supports the + // "attention" style (TextBlock does not). + Style string `json:"style,omitempty"` + + // Items is required for the Container element type. Items is a collection + // of card elements to render inside the Container. + Items []Element `json:"items,omitempty"` + + // Columns is a collection of Columns used to divide a region. This field + // is used by a ColumnSet element type. + Columns []Column `json:"columns,omitempty"` + + // Actions is required for the ActionSet element type. Actions is a + // collection of Actions to show for an ActionSet element type. + // + // TODO: Should this be a pointer? + Actions []Action `json:"actions,omitempty"` + + // Facts is required for the FactSet element type. Actions is a collection + // of Fact values that are part of a FactSet element type. Each Fact value + // is a key/value pair displayed in tabular form. + // + // TODO: Should this be a pointer? + Facts []Fact `json:"facts,omitempty"` + + // Wrap controls whether text is allowed to wrap or is clipped for + // TextBlock elements. + Wrap bool `json:"wrap,omitempty"` + + // Separator, when true, indicates that a separating line shown should + // drawn at the top of the element. + Separator bool `json:"separator,omitempty"` +} + +// Container is an Element type that allows grouping items together. +type Container Element + +// FactSet is an Element type that groups and displays a series of facts (i.e. +// name/value pairs) in a tabular form. +// +type FactSet Element + +// Column is a container used by a ColumnSet element type. Each container +// may contain one or more elements. +// +// https://adaptivecards.io/explorer/Column.html +type Column struct { + + // Type is required; must be set to "Column". + Type string `json:"type"` + + // ID is a unique identifier associated with this Column. + ID string `json:"id,omitempty"` + + // Width represents the width of a column in the column group. Valid + // values consist of fixed strings OR a number representing the relative + // width. + // + // "auto", "stretch", a number representing relative width of the column + // in the column group, or in version 1.1 and higher, a specific pixel + // width, like "50px". + Width interface{} `json:"width,omitempty"` + + // Items are the card elements that should be rendered inside of the + // column. + Items []*Element `json:"items,omitempty"` + + // SelectAction is an action that will be invoked when the Column is + // tapped or selected. Action.ShowCard is not supported. + SelectAction *ISelectAction `json:"selectAction,omitempty"` +} + +// Fact represents a Fact in a FactSet as a key/value pair. +type Fact struct { + // Title is required; the title of the fact. + Title string `json:"title"` + + // Value is required; the value of the fact. + Value string `json:"value"` +} + +// Action represents an action that a user may take on a card. Actions +// typically get rendered in an "action bar" at the bottom of a card. +// +// https://adaptivecards.io/explorer/ActionSet.html +// https://adaptivecards.io/explorer/AdaptiveCard.html +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference +// +// TODO: Extend with additional supported fields. +type Action struct { + + // Type is required; specific values are supported. + // + // Action.Submit is not supported for Incoming Webhooks. + // + // Action.Execute was added in Adaptive Card schema version 1.4. which + // Teams MAY not fully support. + // + // The supported actions are Action.OpenURL, Action.ShowCard, + // Action.ToggleVisibility, and Action.Execute (see above). + // + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model#schema + Type string `json:"type"` + + // ID is a unique identifier associated with this Action. + ID string `json:"id,omitempty"` + + // Title is a label for the button or link that represents this action. + Title string `json:"title,omitempty"` + + // URL to open; required for the Action.OpenUrl type, optional for other + // action types. + URL string `json:"url,omitempty"` + + // Fallback describes what to do when an unknown element is encountered or + // the requirements of this or any children can't be met. + Fallback string `json:"fallback,omitempty"` + + // Card property is used by Action.ShowCard type. + // + // NOTE: Based on a review of JSON content, it looks like `ActionCard` is + // really just a `Card` type. + // + // refs https://github.com/matthidinger/ContosoScubaBot/blob/master/Cards/SubscriberNotification.JSON + Card *Card `json:"card,omitempty"` +} + +// ISelectAction represents an Action that will be invoked when a container +// type (e.g., Column, ColumnSet, Container) is tapped or selected. +// Action.ShowCard is not supported. +// +// https://adaptivecards.io/explorer/Container.html +// https://adaptivecards.io/explorer/ColumnSet.html +// https://adaptivecards.io/explorer/Column.html +// +// TODO: Extend with additional supported fields. +type ISelectAction struct { + + // Type is required; specific values are supported. + // + // The supported actions are Action.Execute, Action.OpenUrl, + // Action.ToggleVisibility. + // + // See also https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + Type string `json:"type"` + + // ID is a unique identifier associated with this ISelectAction. + ID string `json:"id,omitempty"` + + // Title is a label for the button or link that represents this action. + Title string `json:"title,omitempty"` + + // URL is required for the Action.OpenUrl type, optional for other action + // types. + URL string `json:"url,omitempty"` + + // Fallback describes what to do when an unknown element is encountered or + // the requirements of this or any children can't be met. + Fallback string `json:"fallback,omitempty"` +} + +// MSTeams represents a container for properties specific to Microsoft Teams +// messages, including formatting properties and user mentions. +type MSTeams struct { + + // Width controls the width of Adaptive Cards within a Microsoft Teams + // messages. + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#full-width-adaptive-card + Width string `json:"width,omitempty"` + + // AllowExpand controls whether images can be displayed in stage view + // selectively. + // + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#stage-view-for-images-in-adaptive-cards + AllowExpand bool `json:"allowExpand,omitempty"` + + // Entities is a collection of user mentions. + // TODO: Should this be a slice of pointers? + Entities []Mention `json:"entities,omitempty"` +} + +// Mention represents a mention in the message for a specific user. +type Mention struct { + // Type is required; must be set to "mention". + Type string `json:"type"` + + // Text must match a portion of the message text field. If it does not, + // the mention is ignored. + // + // Brief testing indicates that this needs to wrap a name/value in NAME + // HERE tags. + Text string `json:"text"` + + // Mentioned represents a user that is mentioned. + Mentioned Mentioned `json:"mentioned"` +} + +// Mentioned represents the user id and name of a user that is mentioned. +type Mentioned struct { + // ID is the unique identifier for a user that is mentioned. This value + // can be an object ID (e.g., 5e8b0f4d-2cd4-4e17-9467-b0f6a5c0c4d0) or a + // UserPrincipalName (e.g., NewUser@contoso.onmicrosoft.com). + ID string `json:"id"` + + // Name is the DisplayName of the user mentioned. + Name string `json:"name"` +} + +// NewMessage creates a new Message with required fields predefined. +func NewMessage() *Message { + return &Message{ + Type: TypeMessage, + } +} + +// NewSimpleMessage creates a new simple Message using the specified text and +// optional title. If specified, text wrapping is enabled. An error is +// returned if an empty text string is specified. +func NewSimpleMessage(text string, title string, wrap bool) (*Message, error) { + if text == "" { + return nil, fmt.Errorf( + "required field text is empty: %w", + ErrMissingValue, + ) + } + + msg := Message{ + Type: TypeMessage, + } + + textCard, err := NewTextBlockCard(text, title, wrap) + if err != nil { + return nil, fmt.Errorf( + "failed to create TextBlock card: %w", + err, + ) + } + + if err := msg.Attach(textCard); err != nil { + return nil, fmt.Errorf( + "failed to create simple message: %w", + err, + ) + } + + return &msg, nil +} + +// NewTextBlockCard creates a new Card using the specified text and optional +// title. If specified, the TextBlock has text wrapping enabled. +func NewTextBlockCard(text string, title string, wrap bool) (Card, error) { + if text == "" { + return Card{}, fmt.Errorf( + "required field text is empty: %w", + ErrMissingValue, + ) + } + + textBlock := Element{ + Type: TypeElementTextBlock, + Wrap: wrap, + Text: text, + } + + card := Card{ + Type: TypeAdaptiveCard, + Schema: AdaptiveCardSchema, + Version: fmt.Sprintf(AdaptiveCardVersionTmpl, AdaptiveCardMaxVersion), + Body: []Element{ + textBlock, + }, + } + + if title != "" { + titleTextBlock := NewTitleTextBlock(title, wrap) + card.Body = append([]Element{titleTextBlock}, card.Body...) + } + + return card, nil +} + +// NewCard creates and returns an empty Card. +func NewCard() Card { + return Card{ + Type: TypeAdaptiveCard, + Schema: AdaptiveCardSchema, + Version: fmt.Sprintf(AdaptiveCardVersionTmpl, AdaptiveCardMaxVersion), + } +} + +// Attach receives and adds one or more Card values to the Attachments +// collection for a Microsoft Teams message. +// +// NOTE: Including multiple cards in the attachments collection *without* +// attachmentLayout set to "carousel" hides cards after the first. Not sure if +// this is a bug, or if it's intentional. +func (m *Message) Attach(cards ...Card) error { + if len(cards) == 0 { + return fmt.Errorf( + "received empty collection of cards: %w", + ErrMissingValue, + ) + } + + for _, card := range cards { + attachment := Attachment{ + ContentType: AttachmentContentType, + + // Explicitly convert Card to TopLevelCard in order to assert that + // TopLevelCard specific requirements are checked during + // validation. + Content: TopLevelCard{card}, + } + + m.Attachments = append(m.Attachments, attachment) + } + + return nil +} + +// Carousel sets the Message Attachment layout to Carousel display mode. +func (m *Message) Carousel() *Message { + m.AttachmentLayout = AttachmentLayoutCarousel + return m +} + +// PrettyPrint returns a formatted JSON payload of the Message if the +// Prepare() method has been called, or an empty string otherwise. +func (m *Message) PrettyPrint() string { + if m.payload != nil { + var prettyJSON bytes.Buffer + _ = json.Indent(&prettyJSON, m.payload.Bytes(), "", "\t") + + return prettyJSON.String() + } + + return "" +} + +// Prepare handles tasks needed to construct a payload from a Message for +// delivery to an endpoint. +func (m *Message) Prepare() error { + jsonMessage, err := json.Marshal(m) + if err != nil { + return fmt.Errorf( + "error marshalling Message to JSON: %w", + err, + ) + } + + switch { + case m.payload == nil: + m.payload = &bytes.Buffer{} + default: + m.payload.Reset() + } + + _, err = m.payload.Write(jsonMessage) + if err != nil { + return fmt.Errorf( + "error updating JSON payload for Message: %w", + err, + ) + } + + return nil +} + +// Payload returns the prepared Message payload. The caller should call +// Prepare() prior to calling this method, results are undefined otherwise. +func (m *Message) Payload() io.Reader { + return m.payload +} + +// Validate performs validation for Message using ValidateFunc if defined, +// otherwise applying default validation. +func (m Message) Validate() error { + if m.ValidateFunc != nil { + return m.ValidateFunc() + } + + if m.Type != TypeMessage { + return fmt.Errorf( + "invalid message type %q; expected %q: %w", + m.Type, + TypeMessage, + ErrInvalidType, + ) + } + + // We need an attachment (containing one or more Adaptive Cards) in order + // to generate a valid Message for Microsoft Teams delivery. + if len(m.Attachments) == 0 { + return fmt.Errorf( + "required field Attachments is empty for Message: %w", + ErrMissingValue, + ) + } + + for _, attachment := range m.Attachments { + if err := attachment.Validate(); err != nil { + return err + } + } + + // Optional field, but only specific values permitted if set. + if m.AttachmentLayout != "" { + supportedValues := supportedAttachmentLayoutValues() + if !goteamsnotify.InList(m.AttachmentLayout, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for Message; expected one of %v: %w", + "AttachmentLayout", + m.AttachmentLayout, + supportedValues, + ErrInvalidFieldValue, + ) + } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (a Attachment) Validate() error { + if a.ContentType != AttachmentContentType { + return fmt.Errorf( + "invalid attachment type %q; expected %q: %w", + a.ContentType, + AttachmentContentType, + ErrInvalidType, + ) + } + + return nil +} + +// Validate asserts that fields have valid values. +func (c Card) Validate() error { + if c.Type != TypeAdaptiveCard { + return fmt.Errorf( + "invalid card type %q; expected %q: %w", + c.Type, + TypeAdaptiveCard, + ErrInvalidType, + ) + } + + if c.Schema != "" { + if c.Schema != AdaptiveCardSchema { + return fmt.Errorf( + "invalid Schema value %q; expected %q: %w", + c.Schema, + AdaptiveCardSchema, + ErrInvalidFieldValue, + ) + } + } + + // The Version field is required for top-level cards, optional for + // Cards nested within an Action.ShowCard. + + for _, element := range c.Body { + if err := element.Validate(); err != nil { + return err + } + } + + for _, action := range c.Actions { + if err := action.Validate(); err != nil { + return err + } + } + + // Both are optional fields, unless MinHeight is set in which case + // VerticalContentAlignment is required. + if c.MinHeight != "" && c.VerticalContentAlignment == "" { + return fmt.Errorf( + "field MinHeight is set, VerticalContentAlignment is not;"+ + " field VerticalContentAlignment is only optional when MinHeight"+ + " is not set: %w", + ErrMissingValue, + ) + } + + // If there are recorded user mentions, we need to assert that + // Mention.Text is contained (substring match) within an applicable + // field of a supported Element of the Card Body. + // + // At present, this includes the Text field of a TextBlock Element or + // the Title or Value fields of a Fact from a FactSet. + // + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#mention-support-within-adaptive-cards + if len(c.MSTeams.Entities) > 0 { + hasMentionText := func(elements []Element, m Mention) bool { + for _, element := range elements { + if element.HasMentionText(m) { + return true + } + } + return false + } + + // User mentions recorded, but no elements in Card Body to potentially + // contain required text string. + if len(c.Body) == 0 { + return fmt.Errorf( + "user mention text not found in empty Card Body: %w", + ErrMissingValue, + ) + } + + // For every user mention, we require at least one match in an + // applicable Element in the Card Body. + for _, mention := range c.MSTeams.Entities { + if !hasMentionText(c.Body, mention) { + // Card Body contains no applicable elements with required + // Mention text string. + return fmt.Errorf( + "user mention text not found in elements of Card Body: %w", + ErrMissingValue, + ) + } + } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (tc TopLevelCard) Validate() error { + // Validate embedded Card first as those validation requirements apply + // here also. + if err := tc.Card.Validate(); err != nil { + return err + } + + // The Version field is required for top-level cards (this one), optional + // for Cards nested within an Action.ShowCard. + switch { + case strings.TrimSpace(tc.Version) == "": + return fmt.Errorf( + "required field Version is empty for top-level Card: %w", + ErrMissingValue, + ) + default: + // Assert that Version value can be converted to the expected format. + versionNum, err := strconv.ParseFloat(tc.Version, 64) + if err != nil { + return fmt.Errorf( + "value %q incompatible with Version field: %w", + tc.Version, + ErrInvalidFieldValue, + ) + } + + // This is a high confidence validation failure. + if versionNum < AdaptiveCardMinVersion { + return fmt.Errorf( + "unsupported version %q;"+ + " expected minimum value of %0.1f: %w", + tc.Version, + AdaptiveCardMinVersion, + ErrInvalidFieldValue, + ) + } + + // This is *NOT* a high confidence validation failure; it is likely + // that Microsoft Teams will gain support for future versions of the + // Adaptive Card greater than the current recorded max configured + // schema version. Because the max value constant is subject to fall + // out of sync (at least briefly), this is a risky assertion to make. + // + // if versionNum < AdaptiveCardMinVersion || versionNum > AdaptiveCardMaxVersion { + // return fmt.Errorf( + // "unsupported version %q;"+ + // " expected value between %0.1f and %0.1f: %w", + // tc.Version, + // AdaptiveCardMinVersion, + // AdaptiveCardMaxVersion, + // ErrInvalidFieldValue, + // ) + // } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (e Element) Validate() error { + supportedElementTypes := supportedElementTypes() + if !goteamsnotify.InList(e.Type, supportedElementTypes, false) { + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Type", + e.Type, + supportedElementTypes, + ErrInvalidType, + ) + } + + // The Text field is required by TextBlock and TextRun elements, but an + // empty string appears to be permitted. Because of this, we do not have + // to assert that a value is present for the field. + + if e.Type == TypeElementImage { + // URL is required for Image element type. + // https://adaptivecards.io/explorer/Image.html + if e.URL == "" { + return fmt.Errorf( + "required URL is empty for %s: %w", + e.Type, + ErrMissingValue, + ) + } + } + + if e.Size != "" { + supportedSizeValues := supportedSizeValues() + if !goteamsnotify.InList(e.Size, supportedSizeValues, false) { + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Size", + e.Size, + supportedSizeValues, + ErrInvalidFieldValue, + ) + } + } + + if e.Weight != "" { + supportedWeightValues := supportedWeightValues() + if !goteamsnotify.InList(e.Weight, supportedWeightValues, false) { + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Weight", + e.Weight, + supportedWeightValues, + ErrInvalidFieldValue, + ) + } + } + + if e.Color != "" { + supportedColorValues := supportedColorValues() + if !goteamsnotify.InList(e.Color, supportedColorValues, false) { + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Color", + e.Color, + supportedColorValues, + ErrInvalidFieldValue, + ) + } + } + + if e.Spacing != "" { + supportedSpacingValues := supportedSpacingValues() + if !goteamsnotify.InList(e.Spacing, supportedSpacingValues, false) { + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Spacing", + e.Spacing, + supportedSpacingValues, + ErrInvalidFieldValue, + ) + } + } + + if e.Style != "" { + // Valid Style field values differ based on type. For example, a + // Container element supports Container styles whereas a TextBlock + // supports a different and more limited set of style values. We use a + // helper function to retrieve valid style values for evaluation. + supportedStyleValues := supportedStyleValues(e.Type) + + switch { + case len(supportedStyleValues) == 0: + return fmt.Errorf( + "invalid %s %q for element; %s values not supported for element: %w", + "Style", + e.Style, + "Style", + ErrInvalidFieldValue, + ) + + case !goteamsnotify.InList(e.Style, supportedStyleValues, false): + return fmt.Errorf( + "invalid %s %q for element; expected one of %v: %w", + "Style", + e.Style, + supportedStyleValues, + ErrInvalidFieldValue, + ) + } + } + + if e.Type == TypeElementContainer { + // Items collection is required for Container element type. + // https://adaptivecards.io/explorer/Container.html + if len(e.Items) == 0 { + return fmt.Errorf( + "required Items collection is empty for %s: %w", + e.Type, + ErrMissingValue, + ) + } + + for _, item := range e.Items { + if err := item.Validate(); err != nil { + return err + } + } + } + + // Used by ColumnSet type, but not required. + for _, column := range e.Columns { + if err := column.Validate(); err != nil { + return err + } + } + + if e.Type == TypeElementActionSet { + // Actions collection is required for ActionSet element type. + // https://adaptivecards.io/explorer/ActionSet.html + if len(e.Actions) == 0 { + return fmt.Errorf( + "required Actions collection is empty for %s: %w", + e.Type, + ErrMissingValue, + ) + } + + for _, action := range e.Actions { + if err := action.Validate(); err != nil { + return err + } + } + } + + if e.Type == TypeElementFactSet { + // Facts collection is required for FactSet element type. + // https://adaptivecards.io/explorer/FactSet.html + if len(e.Facts) == 0 { + return fmt.Errorf( + "required Facts collection is empty for %s: %w", + e.Type, + ErrMissingValue, + ) + } + + for _, fact := range e.Facts { + if err := fact.Validate(); err != nil { + return err + } + } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (c Column) Validate() error { + if c.Type != TypeColumn { + return fmt.Errorf( + "invalid column type %q; expected %q: %w", + c.Type, + TypeColumn, + ErrInvalidType, + ) + } + + if c.Width != nil { + switch v := c.Width.(type) { + // Assert fixed keyword values or valid pixel width. + case string: + v = strings.TrimSpace(v) + + switch v { + case ColumnWidthAuto: + case ColumnWidthStretch: + default: + matched, _ := regexp.MatchString(ColumnWidthPixelRegex, v) + if !matched { + return fmt.Errorf( + "invalid pixel width %q; expected value in format %s: %w", + v, + ColumnWidthPixelWidthExample, + ErrInvalidFieldValue, + ) + } + } + + // Number representing relative width of the column. + case int: + + // Unsupported value. + default: + return fmt.Errorf( + "invalid pixel width %q; "+ + "expected one of keywords %q, int value (e.g., %d) "+ + "or specific pixel width (e.g., %s): %w", + v, + strings.Join([]string{ + ColumnWidthAuto, + ColumnWidthStretch, + }, ","), + 1, + ColumnWidthPixelWidthExample, + ErrInvalidFieldValue, + ) + } + } + + for _, element := range c.Items { + if err := element.Validate(); err != nil { + return err + } + } + + if c.SelectAction != nil { + return c.SelectAction.Validate() + } + + return nil +} + +// Validate asserts that fields have valid values. +func (f Fact) Validate() error { + if f.Title == "" { + return fmt.Errorf( + "required field Title is empty for Fact: %w", + ErrMissingValue, + ) + } + + if f.Value == "" { + return fmt.Errorf( + "required field Value is empty for Fact: %w", + ErrMissingValue, + ) + } + + return nil +} + +// Validate asserts that fields have valid values. +func (m MSTeams) Validate() error { + // If an optional width value is set, assert that it is a valid value. + if m.Width != "" { + supportedValues := supportedMSTeamsWidthValues() + if !goteamsnotify.InList(m.Width, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for Action; expected one of %v: %w", + "Width", + m.Width, + supportedValues, + ErrInvalidFieldValue, + ) + } + } + + for _, mention := range m.Entities { + if err := mention.Validate(); err != nil { + return err + } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (i ISelectAction) Validate() error { + // Some supportedISelectActionValues are restricted to later Adaptive Card + // schema versions. + supportedValues := supportedISelectActionValues(AdaptiveCardMaxVersion) + if !goteamsnotify.InList(i.Type, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for ISelectAction; expected one of %v: %w", + "Type", + i.Type, + supportedValues, + ErrInvalidType, + ) + } + + if i.Fallback != "" { + supportedValues := supportedISelectActionFallbackValues(AdaptiveCardMaxVersion) + if !goteamsnotify.InList(i.Fallback, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for ISelectAction; expected one of %v: %w", + "Fallback", + i.Fallback, + supportedValues, + ErrInvalidFieldValue, + ) + } + } + + if i.Type == TypeActionOpenURL { + if i.URL == "" { + return fmt.Errorf( + "invalid URL for Action: %w", + ErrMissingValue, + ) + } + } + + return nil +} + +// Validate asserts that fields have valid values. +func (a Action) Validate() error { + + // Some Actions are restricted to later Adaptive Card schema versions. + supportedValues := supportedActionValues(AdaptiveCardMaxVersion) + if !goteamsnotify.InList(a.Type, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for Action; expected one of %v: %w", + "Type", + a.Type, + supportedValues, + ErrInvalidType, + ) + } + + if a.Type == TypeActionOpenURL { + if a.URL == "" { + return fmt.Errorf( + "invalid URL for Action: %w", + ErrMissingValue, + ) + } + } + + if a.Fallback != "" { + supportedValues := supportedActionFallbackValues(AdaptiveCardMaxVersion) + if !goteamsnotify.InList(a.Fallback, supportedValues, false) { + return fmt.Errorf( + "invalid %s %q for Action; expected one of %v: %w", + "Fallback", + a.Fallback, + supportedValues, + ErrInvalidFieldValue, + ) + } + } + + // Optional, but only supported by the Action.ShowCard type. + if a.Type != TypeActionShowCard && a.Card != nil { + return fmt.Errorf( + "error: specifying a Card is unsupported for Action type %q: %w", + a.Type, + ErrInvalidFieldValue, + ) + } + + return nil +} + +// Validate asserts that fields have valid values. +// +// Element.Validate() asserts that required Mention.Text content is found for +// each recorded user mention the Card.. +func (m Mention) Validate() error { + if m.Type != TypeMention { + return fmt.Errorf( + "invalid Mention type %q; expected %q: %w", + m.Type, + TypeMention, + ErrInvalidType, + ) + } + + if m.Text == "" { + return fmt.Errorf( + "required field Text is empty for Mention: %w", + ErrMissingValue, + ) + } + + return nil +} + +// Validate asserts that fields have valid values. +func (m Mentioned) Validate() error { + if m.ID == "" { + return fmt.Errorf( + "required field ID is empty: %w", + ErrMissingValue, + ) + } + + if m.Name == "" { + return fmt.Errorf( + "required field Name is empty: %w", + ErrMissingValue, + ) + } + + return nil +} + +// Mention uses the provided display name, ID and text values to add a new +// user Mention and TextBlock element to the first Card in the Message. +// +// If no Cards are yet attached to the Message, a new card is created using +// the Mention and TextBlock element. If specified, the new TextBlock element +// is added as the first element of the Card, otherwise it is added last. An +// error is returned if insufficient values are provided. +func (m *Message) Mention(prependElement bool, displayName string, id string, msgText string) error { + // NOTE: Rely on called functions to validate given arguments. + + switch { + // If no existing cards, add a new one. + case len(m.Attachments) == 0: + mentionCard, err := NewMentionCard(displayName, id, msgText) + if err != nil { + return err + } + + if err := m.Attach(mentionCard); err != nil { + return err + } + + // We have at least one Card already, use it. + default: + + // Build mention. + mention, err := NewMention(displayName, id) + if err != nil { + return fmt.Errorf( + "add new Mention to Message: %w", + err, + ) + } + + textBlock := Element{ + Type: TypeElementTextBlock, + + // TODO: Any issues caused by enabling wrapping? The goal is to + // prevent the Mention.Text content from pushing user specified + // text off of the Card, out of sight. + Wrap: true, + + // The text block contains the mention text string (required) and + // user-specified message text string. Use the mention text as a + // "greeting" or lead-in for the user-specified message text. + Text: mention.Text + " " + msgText, + } + + switch { + case prependElement: + m.Attachments[0].Content.Body = append( + []Element{textBlock}, + m.Attachments[0].Content.Body..., + ) + default: + m.Attachments[0].Content.Body = append( + m.Attachments[0].Content.Body, + textBlock, + ) + } + + m.Attachments[0].Content.MSTeams.Entities = append( + m.Attachments[0].Content.MSTeams.Entities, + mention, + ) + } + + return nil +} + +// Mention uses the given display name, ID and message text to add a new user +// Mention and TextBlock element to the Card. If specified, the new TextBlock +// element is added as the first element of the Card, otherwise it is added +// last. An error is returned if provided values are insufficient to create +// the user mention. +func (c *Card) Mention(displayName string, id string, msgText string, prependElement bool) error { + if msgText == "" { + return fmt.Errorf( + "required msgText argument is empty: %w", + ErrMissingValue, + ) + } + + // Rely on this called function to validate the other arguments. + mention, err := NewMention(displayName, id) + if err != nil { + return err + } + + textBlock := Element{ + Type: TypeElementTextBlock, + + // TODO: Any issues caused by enabling wrapping? The goal is to + // prevent the Mention.Text content from pushing user specified text + // off of the Card, out of sight. + Wrap: true, + Text: mention.Text + " " + msgText, + } + + switch { + case prependElement: + c.Body = append(c.Body, textBlock) + default: + c.Body = append([]Element{textBlock}, c.Body...) + } + + return nil +} + +// AddMention adds one or more provided user mentions to the associated Card +// along with a new TextBlock element. The Text field for the new TextBlock +// element is updated with the Mention Text. +// +// If specified, the new TextBlock element is inserted as the first element in +// the Card body. This effectively creates a dedicated TextBlock that acts as +// a "lead-in" or "announcement block" for other elements in the Card. If +// false, the newly created TextBlock is appended to the Card, effectively +// creating a "CC" list commonly found at the end of an email message. +// +// An error is returned if specified Mention values fail validation. +func (c *Card) AddMention(prepend bool, mentions ...Mention) error { + textBlock := Element{ + Type: TypeElementTextBlock, + + // The goal is to prevent the Mention.Text from extending off of the + // Card, out of sight. + Wrap: true, + } + + // Whether the mention text is prepended or appended doesn't matter since + // the TextBlock element we are adding is empty. Likewise, the separator + // chosen doesn't really matter either as there isn't any existing text + // that we need to separate from the mention text. + // + // NOTE: WE rely on this function to apply validation of user mention + // values instead of duplicating that logic here. + err := AddMention(c, &textBlock, true, defaultMentionTextSeparator, mentions...) + if err != nil { + return err + } + + switch prepend { + case true: + c.Body = append([]Element{textBlock}, c.Body...) + case false: + c.Body = append(c.Body, textBlock) + } + + return nil +} + +// AddElement adds one or more provided Elements to the Body of the associated +// Card. If specified, the Element values are prepended to the Card Body (as a +// contiguous set retaining current order), otherwise appended to the Card +// Body. +// +// An error is returned if specified Element values fail validation. +func (c *Card) AddElement(prepend bool, elements ...Element) error { + if len(elements) == 0 { + return fmt.Errorf( + "received empty collection of elements: %w", + ErrMissingValue, + ) + } + + // Validate first before adding to Card Body. + for _, element := range elements { + if err := element.Validate(); err != nil { + return err + } + } + + switch prepend { + case true: + c.Body = append(elements, c.Body...) + case false: + c.Body = append(c.Body, elements...) + } + + return nil +} + +// AddAction adds one or more provided Actions to the associated Card. If +// specified, the Action values are prepended to the Card (as a collection +// retaining current order), otherwise appended. +// +// NOTE: The max display limit for a Card's actions array has been observed to +// be a fixed value for web/desktop app and a matching value as an initial +// display limit for mobile app with the option to expand remaining actions in +// a list. +// +// This value is recorded in this package as "TeamsActionsDisplayLimit". +// +// Consider adding Action values to one or more ActionSet elements as needed +// and include within the Card.Body directly or within a Container to +// workaround this limit. +// +// An error is returned if specified Action values fail validation. +func (c *Card) AddAction(prepend bool, actions ...Action) error { + if len(actions) == 0 { + return fmt.Errorf( + "received empty collection of actions: %w", + ErrMissingValue, + ) + } + + for _, action := range actions { + if err := action.Validate(); err != nil { + return err + } + } + + switch prepend { + case true: + c.Actions = append(actions, c.Actions...) + case false: + c.Actions = append(c.Actions, actions...) + } + + return nil +} + +// GetElement searches all Element values attached to the Card for the +// specified ID (case sensitive). If found, a pointer to the Element is +// returned, otherwise an error is returned. +func (c *Card) GetElement(id string) (*Element, error) { + if id == "" { + return nil, fmt.Errorf( + "empty ID value specified: %w", + ErrMissingValue, + ) + } + + for _, element := range c.Body { + if element.ID == id { + return &element, nil + } + + // If the Element is a Container, we need to evaluate its collection + // of Elements. + for _, item := range element.Items { + if item.ID == id { + return &element, nil + } + } + } + + return nil, fmt.Errorf( + "unable to retrieve element id: %w", + ErrValueNotFound, + ) +} + +// AddFactSet adds one or more provided FactSet elements to the Body of the +// associated Card. If specified, the FactSet values are prepended to the Card +// Body (as a contiguous set retaining current order), otherwise appended to +// the Card Body. +// +// An error is returned if specified FactSet values fail validation. +// +// TODO: Is this needed? Should we even have a separate FactSet type that is +// so difficult to work with? +func (c *Card) AddFactSet(prepend bool, factsets ...FactSet) error { + if len(factsets) == 0 { + return fmt.Errorf( + "received empty collection of factsets: %w", + ErrMissingValue, + ) + } + + // Convert to base Element type + factsetElements := make([]Element, 0, len(factsets)) + for _, factset := range factsets { + element := Element(factset) + factsetElements = append(factsetElements, element) + } + + // Validate first before adding to Card Body. + for _, element := range factsetElements { + if err := element.Validate(); err != nil { + return err + } + } + + switch prepend { + case true: + c.Body = append(factsetElements, c.Body...) + case false: + c.Body = append(c.Body, factsetElements...) + } + + return nil +} + +// SetFullWidth enables full width display for the Card. +func (c *Card) SetFullWidth() { + c.MSTeams.Width = MSTeamsWidthFull +} + +// NewMention uses the given display name and ID to create a user Mention +// value for inclusion in a Card. An error is returned if provided values are +// insufficient to create the user mention. +func NewMention(displayName string, id string) (Mention, error) { + switch { + case displayName == "": + return Mention{}, fmt.Errorf( + "required name argument is empty: %w", + ErrMissingValue, + ) + + case id == "": + return Mention{}, fmt.Errorf( + "required id argument is empty: %w", + ErrMissingValue, + ) + + default: + + // Build mention. + mention := Mention{ + Type: TypeMention, + Text: fmt.Sprintf(MentionTextFormatTemplate, displayName), + Mentioned: Mentioned{ + ID: id, + Name: displayName, + }, + } + + return mention, nil + } +} + +// AddMention adds one or more provided user mentions to the specified Card. +// The Text field for the specified TextBlock element is updated with the +// Mention Text. If specified, the Mention Text is prepended, otherwise +// appended. If specified, a custom separator is used between the Mention Text +// and the TextBlock Text field, otherwise the default separator is used. +// +// NOTE: This function "registers" the specified Mention values with the Card +// and updates the specified textBlock element, however the caller is +// responsible for ensuring that the specified textBlock element is added to +// the Card. +// +// An error is returned if specified Mention values fail validation, or one of +// Card or Element pointers are null. +func AddMention(card *Card, textBlock *Element, prependText bool, separator string, mentions ...Mention) error { + if card == nil { + return fmt.Errorf( + "specified pointer to Card is nil: %w", + ErrMissingValue, + ) + } + + if textBlock == nil { + return fmt.Errorf( + "specified pointer to TextBlock element is nil: %w", + ErrMissingValue, + ) + } + + if textBlock.Type != TypeElementTextBlock { + return fmt.Errorf( + "invalid element type %q; expected %q: %w", + textBlock.Type, + TypeElementTextBlock, + ErrInvalidType, + ) + } + + if len(mentions) == 0 { + return fmt.Errorf( + "received empty collection of mentions: %w", + ErrMissingValue, + ) + } + + // Validate all user mentions before modifying Card or Element. + for _, mention := range mentions { + if err := mention.Validate(); err != nil { + return err + } + } + + if separator == "" { + separator = defaultMentionTextSeparator + } + + mentionsText := make([]string, 0, len(mentions)) + + // Record user mentions in the Card and collect all required user mention + // text values. + for _, mention := range mentions { + mentionsText = append(mentionsText, mention.Text) + card.MSTeams.Entities = append(card.MSTeams.Entities, mention) + } + + // Update TextBlock element text with required user mention text string. + switch prependText { + case true: + textBlock.Text = strings.Join(mentionsText, " ") + separator + textBlock.Text + case false: + textBlock.Text = textBlock.Text + separator + strings.Join(mentionsText, " ") + } + + // The original text may have been sufficiently short to not be truncated, + // but once we add the user mention text it is more likely that truncation + // could occur. Indicate that the text should be wrapped to avoid this. + textBlock.Wrap = true + + return nil +} + +// NewMentionMessage creates a new simple Message. Using the given message +// text, displayName and ID, a user Mention is also created and added to the +// new Message. An error is returned if provided values are insufficient to +// create the user mention. +func NewMentionMessage(displayName string, id string, msgText string) (*Message, error) { + msg := Message{ + Type: TypeMessage, + } + + // Rely on function to apply validation instead of duplicating it here. + mentionCard, err := NewMentionCard(displayName, id, msgText) + if err != nil { + return nil, err + } + + if err := msg.Attach(mentionCard); err != nil { + return nil, err + } + + return &msg, nil +} + +// NewMentionCard creates a new Card with user Mention using the given +// displayName, ID and message text. An error is returned if provided values +// are insufficient to create the user mention. +func NewMentionCard(displayName string, id string, msgText string) (Card, error) { + if msgText == "" { + return Card{}, fmt.Errorf( + "required msgText argument is empty: %w", + ErrMissingValue, + ) + } + + // Build mention. + mention, err := NewMention(displayName, id) + if err != nil { + return Card{}, err + } + + // Create basic card. + textCard, err := NewTextBlockCard(msgText, "", true) + if err != nil { + return Card{}, err + } + + // Update the text block so that it contains the mention text string + // (required) and user-specified message text string. Use the mention + // text as a "greeting" or lead-in for the user-specified message + // text. + textCard.Body[0].Text = mention.Text + + " " + textCard.Body[0].Text + + textCard.MSTeams.Entities = append( + textCard.MSTeams.Entities, + mention, + ) + + return textCard, nil +} + +// NewMessageFromCard is a helper function for creating a new Message based +// off of an existing Card value. +func NewMessageFromCard(card Card) (*Message, error) { + msg := Message{ + Type: TypeMessage, + } + + if err := msg.Attach(card); err != nil { + return nil, err + } + + return &msg, nil +} + +// NewContainer creates an empty Container. +func NewContainer() Container { + container := Container{ + Type: TypeElementContainer, + } + + return container +} + +// NewActionSet creates an empty ActionSet. +// +// TODO: Should we create a type alias for ActionSet, or keep it as a "base" +// Element type? +func NewActionSet() Element { + actionSet := Element{ + Type: TypeElementActionSet, + } + + return actionSet +} + +// NewTextBlock creates a new TextBlock element using the optional user +// specified Text. If specified, text wrapping is enabled. +func NewTextBlock(text string, wrap bool) Element { + textBlock := Element{ + Type: TypeElementTextBlock, + Wrap: wrap, + Text: text, + } + + return textBlock +} + +// NewTitleTextBlock uses the specified text to create a new TextBlock +// formatted as a "header" or "title" element. If specified, the TextBlock has +// text wrapping enabled. The effect is meant to emulate the visual effects of +// setting a MessageCard.Title field. +func NewTitleTextBlock(title string, wrap bool) Element { + return Element{ + Type: TypeElementTextBlock, + Wrap: wrap, + Text: title, + Style: TextBlockStyleHeading, + Size: SizeLarge, + Weight: WeightBolder, + } +} + +// NewFactSet creates an empty FactSet. +func NewFactSet() FactSet { + factSet := FactSet{ + Type: TypeElementFactSet, + } + + return factSet +} + +// AddFact adds one or many Fact values to a FactSet. An error is returned if +// the Fact fails validation or if AddFact is called on an unsupported Element +// type. +func (fs *FactSet) AddFact(facts ...Fact) error { + // Fail early if called on the wrong Element type. + if fs.Type != TypeElementFactSet { + return fmt.Errorf( + "unsupported element type %s; expected %s: %w", + fs.Type, + TypeElementFactSet, + ErrInvalidType, + ) + } + + if len(facts) == 0 { + return fmt.Errorf( + "received empty collection of facts: %w", + ErrMissingValue, + ) + } + + // Validate all Fact values before adding them to the collection. + for _, fact := range facts { + if err := fact.Validate(); err != nil { + return err + } + } + + fs.Facts = append(fs.Facts, facts...) + + return nil +} + +// HasMentionText asserts that a supported Element type contains the required +// Mention text string necessary to link a user mention to a specific Element. +func (e Element) HasMentionText(m Mention) bool { + switch { + case e.Type == TypeElementTextBlock: + if strings.Contains(e.Text, m.Text) { + return true + } + return false + + case e.Type == TypeElementFactSet: + for _, fact := range e.Facts { + if strings.Contains(fact.Title, m.Text) || + strings.Contains(fact.Value, m.Text) { + + return true + } + } + return false + + default: + return false + } +} + +// NewActionOpenURL creates a new Action.OpenURL value using the provided URL +// and title. An error is returned if invalid values are supplied. +func NewActionOpenURL(url string, title string) (Action, error) { + // Accept the user-specified values as-is, use Validate() method to do the + // heavy lifting. + action := Action{ + Type: TypeActionOpenURL, + Title: title, + URL: url, + } + + err := action.Validate() + if err != nil { + return Action{}, err + } + + return action, nil +} + +// NewActionSetsFromActions creates a new ActionSet for every +// TeamsActionsDisplayLimit count of Actions given. An error is returned if +// the specified Actions do not pass validation. +func NewActionSetsFromActions(actions ...Action) ([]Element, error) { + if len(actions) == 0 { + return nil, fmt.Errorf( + "received empty collection of actions to create ActionSet: %w", + ErrMissingValue, + ) + } + + for _, action := range actions { + if err := action.Validate(); err != nil { + return nil, err + } + } + + // Create a new ActionSet for every TeamsActionsDisplayLimit count of + // Actions given. + actionSetsNeeded := int(math.Ceil(float64(len(actions)) / float64(TeamsActionsDisplayLimit))) + actionSets := make([]Element, 0, actionSetsNeeded) + + stride := TeamsActionsDisplayLimit + for i := 0; i < len(actions); i += stride { + // Ensure that we don't stride past the end of the actions slice. + if stride > len(actions)-i { + stride = len(actions) - i + } + + actionSetItems := actions[i : i+stride] + actionSet := Element{ + Type: TypeElementActionSet, + Actions: actionSetItems, + } + + actionSets = append(actionSets, actionSet) + } + + return actionSets, nil +} + +// AddElement adds the given Element to the collection of Element values in +// the container. If specified, the Element is inserted at the beginning of +// the collection, otherwise appended to the end. +func (c *Container) AddElement(prepend bool, element Element) error { + if err := element.Validate(); err != nil { + return err + } + + switch prepend { + case true: + c.Items = append([]Element{element}, c.Items...) + case false: + c.Items = append(c.Items, element) + } + + return nil +} + +// AddAction adds one or more provided Action values to the associated +// Container as one or more new ActionSets. The number of actions in each +// newly created ActionSet is limited to the number specified by +// TeamsActionsDisplayLimit. +// +// If specified, the newly created ActionSets are inserted before other +// Elements in the Container, otherwise appended. +// +// An error is returned if specified Action values fail validation. +func (c *Container) AddAction(prepend bool, actions ...Action) error { + // Rely on function to apply validation instead of duplicating it here. + actionSets, err := NewActionSetsFromActions(actions...) + if err != nil { + return err + } + + switch prepend { + case true: + c.Items = append(actionSets, c.Items...) + case false: + c.Items = append(c.Items, actionSets...) + } + + return nil +} + +// AddContainer adds the given Container Element to the collection of Element +// values for the Card. If specified, the Container Element is inserted at the +// beginning of the collection, otherwise appended to the end. +func (c *Card) AddContainer(prepend bool, container Container) error { + element := Element(container) + + if err := element.Validate(); err != nil { + return err + } + + switch prepend { + case true: + c.Body = append([]Element{element}, c.Body...) + case false: + c.Body = append(c.Body, element) + } + + return nil +} diff --git a/adaptivecard/doc.go b/adaptivecard/doc.go new file mode 100644 index 0000000..3162a7b --- /dev/null +++ b/adaptivecard/doc.go @@ -0,0 +1,32 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* +Package adaptivecard provides support for generating Microsoft Teams messages +using the Adaptive Card format. + +See the provided examples in this repo, the Godoc generated documentation at +https://pkg.go.dev/github.com/atc0005/go-teams-notify/v2 and the following +resources for more information: + +https://adaptivecards.io/explorer +https://docs.microsoft.com/en-us/adaptive-cards/ +https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/getting-started +https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model +https://docs.microsoft.com/en-us/adaptive-cards/getting-started/bots +https://docs.microsoft.com/en-us/adaptive-cards/resources/principles +https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format +https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#mention-support-within-adaptive-cards +https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards +https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/what-are-cards +https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using +https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#send-adaptive-cards-using-an-incoming-webhook +https://stackoverflow.com/questions/50753072/microsoft-teams-webhook-generating-400-for-adaptive-card + +*/ +package adaptivecard diff --git a/adaptivecard/format.go b/adaptivecard/format.go new file mode 100644 index 0000000..0b5818f --- /dev/null +++ b/adaptivecard/format.go @@ -0,0 +1,73 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package adaptivecard + +import "strings" + +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#newlines-for-adaptive-cards +// https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features + +// Newline and break statement patterns stripped out of text content sent to +// Microsoft Teams (by request). +const ( + // CR LF \r\n (windows) + windowsEOLActual = "\r\n" + windowsEOLEscaped = `\r\n` + + // CF \r (mac) + macEOLActual = "\r" + macEOLEscaped = `\r` + + // LF \n (unix) + unixEOLActual = "\n" + unixEOLEscaped = `\n` + + // Used with MessageCard format to emulate newlines, incompatible with + // Adaptive Card format (displays as literal values). + breakStatement = "
" +) + +// ConvertEOL converts \r\n (windows), \r (mac) and \n (unix) into \n\n. +// +// This function is intended for processing text for use in an Adaptive Card +// TextBlock element. The goal is to provide spacing in rendered text display +// comparable to native display. +// +// NOTE: There are known discrepancies in the way that Microsoft Teams renders +// text in desktop, web and mobile, so even with using this helper function +// some differences are to be expected. +// +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#newlines-for-adaptive-cards +// https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +func ConvertEOL(s string) string { + s = strings.ReplaceAll(s, windowsEOLEscaped, unixEOLActual+unixEOLActual) + s = strings.ReplaceAll(s, windowsEOLActual, unixEOLActual+unixEOLActual) + s = strings.ReplaceAll(s, macEOLActual, unixEOLActual+unixEOLActual) + s = strings.ReplaceAll(s, macEOLEscaped, unixEOLActual+unixEOLActual) + s = strings.ReplaceAll(s, unixEOLEscaped, unixEOLActual+unixEOLActual) + + return s +} + +// ConvertBreakToEOL converts
statements into \n\n to provide comparable +// spacing in Adaptive Card TextBlock elements. +// +// This function is intended for processing text for use in an Adaptive Card +// TextBlock element. The goal is to provide spacing in rendered text display +// comparable to native display. +// +// The primary use case of this function is to process text that was +// previously formatted in preparation for use in a MessageCard; the +// MessageCard format supports
statements for text spacing/formatting +// where the Adaptive Card format does not. +// +// https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#newlines-for-adaptive-cards +// https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +func ConvertBreakToEOL(s string) string { + return strings.ReplaceAll(s, breakStatement, unixEOLActual+unixEOLActual) +} diff --git a/adaptivecard/getters.go b/adaptivecard/getters.go new file mode 100644 index 0000000..a7cfd1e --- /dev/null +++ b/adaptivecard/getters.go @@ -0,0 +1,310 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package adaptivecard + +// supportedElementTypes returns a list of valid types for an Adaptive Card +// element used in Microsoft Teams messages. This list is intended to be used +// for validation and display purposes. +func supportedElementTypes() []string { + // TODO: Confirm whether all types are supported. + // NOTE: Based on current docs, version 1.4 is the latest supported at this + // time. + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference#support-for-adaptive-cards + // https://adaptivecards.io/explorer/AdaptiveCard.html + return []string{ + TypeElementActionSet, + TypeElementColumnSet, + TypeElementContainer, + TypeElementFactSet, + TypeElementImage, + TypeElementImageSet, + TypeElementInputChoiceSet, + TypeElementInputDate, + TypeElementInputNumber, + TypeElementInputText, + TypeElementInputTime, + TypeElementInputToggle, + TypeElementMedia, // Introduced in version 1.1 (TODO: Is this supported in Teams message?) + TypeElementRichTextBlock, + TypeElementTextBlock, + TypeElementTextRun, + } +} + +// supportedSizeValues returns a list of valid Size values for applicable +// Element types. This list is intended to be used for validation and display +// purposes. +func supportedSizeValues() []string { + // https://adaptivecards.io/explorer/TextBlock.html + return []string{ + SizeSmall, + SizeDefault, + SizeMedium, + SizeLarge, + SizeExtraLarge, + } +} + +// supportedWeightValues returns a list of valid Weight values for text in +// applicable Element types. This list is intended to be used for validation +// and display purposes. +func supportedWeightValues() []string { + // https://adaptivecards.io/explorer/TextBlock.html + return []string{ + WeightBolder, + WeightLighter, + WeightDefault, + } +} + +// supportedColorValues returns a list of valid Color values for text in +// applicable Element types. This list is intended to be used for validation +// and display purposes. +func supportedColorValues() []string { + // https://adaptivecards.io/explorer/TextBlock.html + return []string{ + ColorDefault, + ColorDark, + ColorLight, + ColorAccent, + ColorGood, + ColorWarning, + ColorAttention, + } +} + +// supportedSpacingValues returns a list of valid Spacing values for Element +// types. This list is intended to be used for validation and display +// purposes. +func supportedSpacingValues() []string { + // https://adaptivecards.io/explorer/TextBlock.html + return []string{ + SpacingDefault, + SpacingNone, + SpacingSmall, + SpacingMedium, + SpacingLarge, + SpacingExtraLarge, + SpacingPadding, + } +} + +// supportedActionValues accepts a value indicating the maximum Adaptive Card +// schema version supported and returns a list of valid Action types. This +// list is intended to be used for validation and display purposes. +// +// NOTE: See also the supportedISelectActionValues() function. See ref links +// for unsupported Action types. +func supportedActionValues(version float64) []string { + // https://adaptivecards.io/explorer/AdaptiveCard.html + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + supportedValues := []string{ + TypeActionOpenURL, + TypeActionShowCard, + TypeActionToggleVisibility, + + // Action.Submit is not supported for Adaptive Cards in Incoming + // Webhooks. + // + // TypeActionSubmit, + } + + // Version 1.4 is when Action.Execute was introduced. + // + // Per this doc: + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + // + // the "Action.Execute" action is supported: + // + // "For Adaptive Cards in Incoming Webhooks, all native Adaptive Card + // schema elements, except Action.Submit, are fully supported. The + // supported actions are Action.OpenURL, Action.ShowCard, + // Action.ToggleVisibility, and Action.Execute." + if version >= ActionExecuteMinCardVersionRequired { + supportedValues = append(supportedValues, TypeActionExecute) + } + + return supportedValues +} + +// supportedISelectActionValues accepts a value indicating the maximum +// Adaptive Card schema version supported and returns a list of valid +// ISelectAction types. This list is intended to be used for validation and +// display purposes. +// +// NOTE: See also the supportedActionValues() function. See ref links for +// unsupported Action types. +func supportedISelectActionValues(version float64) []string { + // https://adaptivecards.io/explorer/Column.html + // https://adaptivecards.io/explorer/TableCell.html + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + supportedValues := []string{ + TypeActionOpenURL, + TypeActionToggleVisibility, + + // Action.Submit is not supported for Adaptive Cards in Incoming + // Webhooks. + // + // TypeActionSubmit, + + // Action.ShowCard is not a supported Action for selectAction fields + // (ISelectAction). + // + // TypeActionShowCard, + } + + // Version 1.4 is when Action.Execute was introduced. + // + // Per this doc: + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + // + // the "Action.Execute" action is supported: + // + // "For Adaptive Cards in Incoming Webhooks, all native Adaptive Card + // schema elements, except Action.Submit, are fully supported. The + // supported actions are Action.OpenURL, Action.ShowCard, + // Action.ToggleVisibility, and Action.Execute." + if version >= ActionExecuteMinCardVersionRequired { + supportedValues = append(supportedValues, TypeActionExecute) + } + + return supportedValues +} + +// supportedAttachmentLayoutValues returns a list of valid AttachmentLayout +// values for Message type. This list is intended to be used for validation +// and display purposes. +// +// NOTE: See also the supportedActionValues() function. +func supportedAttachmentLayoutValues() []string { + return []string{ + AttachmentLayoutList, + AttachmentLayoutCarousel, + } +} + +// supportedStyleValues returns a list of valid Style field values for the +// specified element type. This list is intended to be used for validation and +// display purposes. +func supportedStyleValues(elementType string) []string { + switch elementType { + case TypeElementColumnSet: + return supportedContainerStyleValues() + case TypeElementContainer: + return supportedContainerStyleValues() + case TypeElementImage: + return supportedImageStyleValues() + case TypeElementInputChoiceSet: + return supportedChoiceInputStyleValues() + case TypeElementInputText: + return supportedTextInputStyleValues() + case TypeElementTextBlock: + return supportedTextBlockStyleValues() + + // Unsupported element types are indicated by an explicit empty list. + default: + return []string{} + } +} + +// supportedImageStyleValues returns a list of valid Style field values for +// the Image element type. This list is intended to be used for validation and +// display purposes. +func supportedImageStyleValues() []string { + return []string{ + ImageStyleDefault, + ImageStylePerson, + } +} + +// supportedChoiceInputStyleValues returns a list of valid Style field values +// for ChoiceInput related element types (e.g., Input.ChoiceSet) This list is +// intended to be used for validation and display purposes. +func supportedChoiceInputStyleValues() []string { + return []string{ + ChoiceInputStyleCompact, + ChoiceInputStyleExpanded, + ChoiceInputStyleFiltered, + } +} + +// supportedTextInputStyleValues returns a list of valid Style field values +// for TextInput related element types (e.g., Input.Text) This list is +// intended to be used for validation and display purposes. +func supportedTextInputStyleValues() []string { + return []string{ + TextInputStyleText, + TextInputStyleTel, + TextInputStyleURL, + TextInputStyleEmail, + TextInputStylePassword, + } +} + +// supportedTextBlockStyleValues returns a list of valid Style field values +// for the TextBlock element type. This list is intended to be used for +// validation and display purposes. +func supportedTextBlockStyleValues() []string { + return []string{ + TextBlockStyleDefault, + TextBlockStyleHeading, + } +} + +// supportedContainerStyleValues returns a list of valid Style field values +// for Container types (e.g., Column, ColumnSet, Container). This list is +// intended to be used for validation and display purposes. +func supportedContainerStyleValues() []string { + return []string{ + ContainerStyleDefault, + ContainerStyleEmphasis, + ContainerStyleGood, + ContainerStyleAttention, + ContainerStyleWarning, + ContainerStyleAccent, + } +} + +// supportedMSTeamsWidthValues returns a list of valid Width field values for +// MSTeams type. This list is intended to be used for validation and display +// purposes. +func supportedMSTeamsWidthValues() []string { + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-format#full-width-adaptive-card + return []string{ + MSTeamsWidthFull, + } +} + +// supportedActionFallbackValues accepts a value indicating the maximum +// Adaptive Card schema version supported and returns a list of valid Action +// Fallback types. This list is intended to be used for validation and display +// purposes. +func supportedActionFallbackValues(version float64) []string { + // https://adaptivecards.io/explorer/Action.OpenUrl.html + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + supportedValues := supportedActionValues(version) + supportedValues = append(supportedValues, TypeFallbackOptionDrop) + + return supportedValues +} + +// supportedISelectActionFallbackValues accepts a value indicating the maximum +// Adaptive Card schema version supported and returns a list of valid +// ISelectAction Fallback types. This list is intended to be used for +// validation and display purposes. +func supportedISelectActionFallbackValues(version float64) []string { + // https://adaptivecards.io/explorer/Action.OpenUrl.html + // https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/universal-action-model + // https://docs.microsoft.com/en-us/microsoftteams/platform/task-modules-and-cards/cards/cards-reference + supportedValues := supportedISelectActionValues(version) + supportedValues = append(supportedValues, TypeFallbackOptionDrop) + + return supportedValues +} diff --git a/adaptivecard/nullstring.go b/adaptivecard/nullstring.go new file mode 100644 index 0000000..57e6449 --- /dev/null +++ b/adaptivecard/nullstring.go @@ -0,0 +1,63 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package adaptivecard + +import ( + "encoding/json" + "strings" +) + +// Credit: +// +// These resources were used while developing the json.Marshaler and +// json.Unmarshler interface implementations used in this file: +// +// https://stackoverflow.com/questions/31048557/assigning-null-to-json-fields-instead-of-empty-strings +// https://stackoverflow.com/questions/25087960/json-unmarshal-time-that-isnt-in-rfc-3339-format/ + +// Add an "implements assertion" to fail the build if the json.Unmarshaler +// implementation isn't correct. +// +// This resolves the unparam linter error: +// (*NullString).UnmarshalJSON - result 0 (error) is always nil (unparam) +// +// https://github.com/mvdan/unparam/issues/52 +var _ json.Unmarshaler = (*NullString)(nil) + +// Perform similar "implements assertion" for the json.Marshaler interface. +var _ json.Marshaler = (*NullString)(nil) + +// NullString represents a string value used in component fields that may +// potentially be null in the input JSON feed. +type NullString string + +// MarshalJSON implements the json.Marshaler interface. This compliments the +// custom Unmarshaler implementation to handle potentially null component +// description field value. +func (ns NullString) MarshalJSON() ([]byte, error) { + if len(string(ns)) == 0 { + return []byte("null"), nil + } + + // NOTE: If we fail to convert the type, an infinite loop will occur. + return json.Marshal(string(ns)) +} + +// UnmarshalJSON implements the json.Unmarshaler interface to handle +// potentially null component description field value. +func (ns *NullString) UnmarshalJSON(data []byte) error { + str := string(data) + if str == "null" { + *ns = "" + return nil + } + + *ns = NullString(strings.Trim(str, "\"")) + + return nil +} diff --git a/botapi/botapi.go b/botapi/botapi.go deleted file mode 100644 index 6c971c6..0000000 --- a/botapi/botapi.go +++ /dev/null @@ -1,350 +0,0 @@ -// Copyright 2022 Adam Chalkley -// -// https://github.com/atc0005/go-teams-notify -// -// Licensed under the MIT License. See LICENSE file in the project root for -// full license information. - -package botapi - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "strings" -) - -const ( - // MessageType is the type for a BotAPI Message. - MessageType string = "message" - - // MentionType is the type for a user mention for a BotAPI Message. - MentionType string = "mention" - - // MentionTextFormatTemplate is the expected format of the Mention.Text - // field value. - MentionTextFormatTemplate string = "%s" -) - -var ( - // ErrInvalidType indicates that an invalid type was specified. - ErrInvalidType = errors.New("invalid type value") - - // ErrInvalidFieldValue indicates that an invalid value was specified. - ErrInvalidFieldValue = errors.New("invalid field value") - - // ErrMissingValue indicates that an expected value was missing. - ErrMissingValue = errors.New("missing expected value") -) - -// Message is a minimal representation of the object used to mention one or -// more users in a Teams channel. -// -// https://docs.microsoft.com/en-us/microsoftteams/platform/bots/how-to/conversations/channel-and-group-conversations?tabs=json#add-mentions-to-your-messages -type Message struct { - // Type is required; must be set to "message". - Type string `json:"type"` - - // Text is required; mostly freeform content, but testing shows that the - // "Some User" string (composed of Display Name value) is - // required by Microsoft Teams for each Mention in the entities - // collection. - Text string `json:"text"` - - // Entities is required; a collection of Mention values, one per mentioned - // individual. - Entities []Mention `json:"entities"` - - // payload is a prepared Message in JSON format for submission or pretty - // printing. - payload *bytes.Buffer `json:"-"` -} - -// Mention represents a mention in the message for a specific user. -type Mention struct { - // Type is required; must be set to "mention". - Type string `json:"type"` - - // Text must match a portion of the message text field. If it does not, - // the mention is ignored. - // - // Brief testing indicates that this needs to wrap a name/value in NAME - // HERE tags. - Text string `json:"text"` - - // Mentioned represents a user that is mentioned. - Mentioned Mentioned `json:"mentioned"` -} - -// Mentioned represents the user id and name of a user that is mentioned. -type Mentioned struct { - // ID is the unique identifier for a user that is mentioned. This value - // can be an object ID (e.g., 5e8b0f4d-2cd4-4e17-9467-b0f6a5c0c4d0) or a - // UserPrincipalName (e.g., NewUser@contoso.onmicrosoft.com). - ID string `json:"id"` - - // Name is the DisplayName of the user mentioned. - Name string `json:"name"` -} - -// NewMessage creates a new Message with required fields predefined. -func NewMessage() *Message { - return &Message{ - Type: MessageType, - } -} - -// NewMessage creates a new Message using provided text with required fields -// predefined. -// func NewMessage(text string) *Message { -// return &Message{ -// Type: MessageType, -// Text: text, -// } -// } - -// AddText adds given text to the message for delivery. If specified, this -// method prepends given text instead of appending it. -// -// The caller may directly write to the exported Message Text field in order -// to overwrite existing Message text. The caller then takes responsibility -// for ensuring that any user mention placeholders are explicitly provided for -// the Message Text field in order to comply with API requirements. -// func (m *Message) AddText(text string, prepend bool) *Message { -// switch { -// case strings.TrimSpace(text) == "": -// // Passing an empty text string is effectively a NOOP. -// case prepend: -// m.Text = text + " " + m.Text -// default: -// m.Text += text -// } -// -// return m -// } - -// AddText appends given text to the message for delivery. -// -// As an alternative to using this method, the caller may directly write to -// the exported Message Text field. If opting to use this approach, care -// should be taken by the caller to retain any previously added mention -// placeholders. -func (m *Message) AddText(text string) *Message { - switch { - case strings.TrimSpace(text) == "": - // Passing an empty text string is effectively a NOOP. - default: - m.Text += text - } - - return m -} - -// PrettyPrint returns a formatted JSON payload of the Message if the -// Prepare() method has been called, or an empty string otherwise. -func (m *Message) PrettyPrint() string { - if m.payload != nil { - var prettyJSON bytes.Buffer - - // Validation is handled by the Message.Prepare() method. - _ = json.Indent(&prettyJSON, m.payload.Bytes(), "", "\t") - - return prettyJSON.String() - } - - return "" -} - -// Validate performs basic validation of required field values. -func (m Message) Validate() error { - if m.Text == "" { - return fmt.Errorf( - "required Text field is empty: %w", - ErrInvalidFieldValue, - ) - } - - if m.Type != MessageType { - return fmt.Errorf( - "got %s; wanted %s: %w", - m.Type, - MessageType, - ErrInvalidType, - ) - } - - // If we have any recorded user mentions, check each of them. - if len(m.Entities) > 0 { - for _, mention := range m.Entities { - if err := mention.Validate(); err != nil { - return err - } - } - } - - return nil -} - -// Validate performs basic validation of required field values. -func (m Mention) Validate() error { - if m.Type != MentionType { - return fmt.Errorf( - "got %s; wanted %s: %w", - m.Type, - MentionType, - ErrInvalidType, - ) - } - - if m.Text == "" { - return fmt.Errorf( - "required Text field is empty: %w", - ErrInvalidFieldValue, - ) - } - - if m.Mentioned.ID == "" { - return fmt.Errorf( - "required ID field is empty: %w", - ErrInvalidFieldValue, - ) - } - - if m.Mentioned.Name == "" { - return fmt.Errorf( - "required Name field is empty: %w", - ErrInvalidFieldValue, - ) - } - - return nil -} - -// AddMention adds one or many Mention values to a Message. -// -// If specified, the Text field from each given Mention is prepended to the -// Text field of the Message in order to satisfy the API Message format -// requirements. If specified, the given separator is used, otherwise a space -// is assumed. -// -// If the caller opts to not update the Message Text field when adding a -// Mention, the caller is then responsible for ensuring that the Message Text -// field contains a valid match for each mentioned user. -// -// NOTE: Testing indicates that the expected format matches the DisplayName -// field for the user (e.g., "John Doe" instead of "John" or "Doe" or a custom -// format). -func (m *Message) AddMention(prependToText bool, separator string, mentions ...Mention) error { - if len(mentions) == 0 { - return fmt.Errorf( - "func AddMention: missing value: %w", - ErrMissingValue, - ) - } - - for _, mention := range mentions { - if err := mention.Validate(); err != nil { - return fmt.Errorf( - "func AddMention: validation failed: %w", - err, - ) - } - - m.Entities = append(m.Entities, mention) - - // Fallback to single space separator if user didn't specify one. - if separator == "" { - separator = " " - } - - if prependToText { - m.Text = mention.Text + separator + m.Text - } - } - - return nil -} - -// Mention creates a new user Mention to be included in the Message entities -// collection. -// -// This method receives a user's DisplayName, ID and a boolean value used to -// indicate whether a leading text string of the format "John Doe" -// (i.e., a user "mention") should be prepended to the Message Text field. -// -// If the caller opts to not have this method update the Message Text field, -// then the caller will need to ensure that the Message Text field is updated -// to include a matching pattern for every Mention that is included in the -// entities collection for the Message. -// -// NOTE: Brief testing suggests that the user's display name (e.g., "John -// Doe") is required instead of a firstname (e.g., "John"), lastname ("Doe") -// or custom value (e.g., "JD") is required. -// -// The ID value can be an object ID (e.g., -// 5e8b0f4d-2cd4-4e17-9467-b0f6a5c0c4d0) or a UserPrincipalName (e.g., -// NewUser@contoso.onmicrosoft.com). -func (m *Message) Mention(displayName string, id string, prependToText bool) error { - switch { - case displayName == "": - return fmt.Errorf( - "func Mention: required name argument is empty: %w", - ErrMissingValue, - ) - - case id == "": - return fmt.Errorf( - "func Mention: required id argument is empty: %w", - ErrMissingValue, - ) - - default: - mention := Mention{ - Type: MentionType, - // Text: textVal, - Text: fmt.Sprintf(MentionTextFormatTemplate, displayName), - Mentioned: Mentioned{ - ID: id, - Name: displayName, - }, - } - - m.Entities = append(m.Entities, mention) - - if prependToText { - m.Text = mention.Text + " " + m.Text - } - } - - return nil -} - -// Prepare handles tasks needed to prepare a given Message for delivery to an -// endpoint. If specified, tasks are repeated regardless of whether a previous -// Prepare call was made. Validation should be performed by the caller prior -// to calling this method. -func (m *Message) Prepare(recreate bool) error { - if m.payload != nil && !recreate { - return nil - } - - jsonMessage, err := json.Marshal(m) - if err != nil { - return fmt.Errorf( - "failed to prepare message: %w", - err, - ) - } - - m.payload = bytes.NewBuffer(jsonMessage) - - return nil -} - -// Payload returns the prepared Message payload. The caller should call -// Prepare() prior to calling this method, results are undefined otherwise. -func (m *Message) Payload() io.Reader { - return m.payload -} diff --git a/botapi/doc.go b/botapi/doc.go deleted file mode 100644 index 8774e3d..0000000 --- a/botapi/doc.go +++ /dev/null @@ -1,26 +0,0 @@ -/* -Package botapi is intended to provide limited support for adding user mention -functionality to messages sent to a Microsoft Teams channel. - -This package is currently a work-in-progress; when complete, this package will -provide support for generating a message equivalent to the example below, -contributed by @ghokun via -https://github.com/atc0005/go-teams-notify/issues/127. - -curl -X POST -H "Content-type: application/json" -d '{ - "type": "message", - "text": "Hey Some User check out this message", - "entities": [ - { - "type":"mention", - "mentioned":{ - "id":"some.user@company.com", - "name":"Some User" - }, - "text": "Some User" - } - ] -}' - -*/ -package botapi diff --git a/doc.go b/doc.go index a48e63a..1535943 100644 --- a/doc.go +++ b/doc.go @@ -25,9 +25,11 @@ FEATURES • Submit messages to Microsoft Teams consisting of one or more sections, Facts (key/value pairs), Actions or images (hosted externally) +• Support for MessageCard and Adaptive Card messages + • Support for Actions, allowing users to take quick actions within Microsoft Teams -• Support for user mentions (limited) +• Support for user mentions • Configurable validation diff --git a/examples/adaptivecard/actions/main.go b/examples/adaptivecard/actions/main.go new file mode 100644 index 0000000..e8747f6 --- /dev/null +++ b/examples/adaptivecard/actions/main.go @@ -0,0 +1,91 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to generate +a basic Microsoft Teams message in Adaptive Card format. + +Of note: + +- default timeout +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- simple message submitted to Microsoft Teams consisting of title and + formatted message body + +See https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +for the list of supported Adaptive Card text formatting options. + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // Create card using provided formatted title and text. We'll modify the + // card and when finished use it to generate a message for delivery. + card, err := adaptivecard.NewTextBlockCard("Simple message with OpenURL action", "Hello World", true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + // Destination for OpenURL action. + targetURL := "https://github.com/atc0005/go-teams-notify" + targetURLDesc := "Project Homepage" + + urlAction, err := adaptivecard.NewActionOpenURL(targetURL, targetURLDesc) + if err != nil { + log.Printf( + "failed to create action for card: %v", + err, + ) + os.Exit(1) + } + + if err := card.AddAction(true, urlAction); err != nil { + log.Printf( + "failed to add action to card: %v", + err, + ) + os.Exit(1) + } + + // Create Message from Card + msg, err := adaptivecard.NewMessageFromCard(card) + if err != nil { + log.Printf("failed to create message from card: %v", err) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/basic/main.go b/examples/adaptivecard/basic/main.go new file mode 100644 index 0000000..4a4ac95 --- /dev/null +++ b/examples/adaptivecard/basic/main.go @@ -0,0 +1,69 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to generate +a basic Microsoft Teams message in Adaptive Card format. + +Of note: + +- default timeout +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- simple message submitted to Microsoft Teams consisting of title and + formatted message body + +See https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +for the list of supported Adaptive Card text formatting options. + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // The title for message (first TextBlock element). + msgTitle := "Hello world" + + // Formatted message body. + msgText := "Here are some examples of formatted stuff like " + + "\n * this list itself \n * **bold** \n * *italic* \n * ***bolditalic***" + + // Create message using provided formatted title and text. + msg, err := adaptivecard.NewSimpleMessage(msgText, msgTitle, true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/custom-user-agent/main.go b/examples/adaptivecard/custom-user-agent/main.go new file mode 100644 index 0000000..b0d639f --- /dev/null +++ b/examples/adaptivecard/custom-user-agent/main.go @@ -0,0 +1,71 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to generate +a basic Microsoft Teams message in Adaptive Card format. + +Of note: + +- message is in Adaptive Card format +- default timeout +- custom user agent +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- simple message submitted to Microsoft Teams consisting of formatted body and + title + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Override the project-specific default user agent + mstClient.SetUserAgent("go-teams-notify-example/1.0") + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // The title for message (first TextBlock element). + msgTitle := "Hello world" + + // Formatted message body. + msgText := "Here are some examples of formatted stuff like " + + "\n * this list itself \n * **bold** \n * *italic* \n * ***bolditalic***" + + // Create message using provided formatted title and text. + msg, err := adaptivecard.NewSimpleMessage(msgText, msgTitle, true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/custom-validation/main.go b/examples/adaptivecard/custom-validation/main.go new file mode 100644 index 0000000..4efdf54 --- /dev/null +++ b/examples/adaptivecard/custom-validation/main.go @@ -0,0 +1,82 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This example demonstrates how to enable custom validation patterns for webhook +URLs. + +Of note: + +- message is in Adaptive Card format +- default timeout +- package-level logging is disabled by default +- webhook URL validation uses custom pattern + - allows use of custom/private webhook URL endpoints +- simple message submitted to Microsoft Teams consisting of formatted body and + title + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // Add a custom pattern for webhook URL validation + mstClient.AddWebhookURLValidationPatterns(`^https://.*\.domain\.com/.*$`) + + /* + It's also possible to use multiple patterns with one call: + + mstClient.AddWebhookURLValidationPatterns(`^https://arbitrary\.example\.com/webhook/.*$`, `^https://.*\.domain\.com/.*$`) + + To keep the default behavior and add a custom one, use something like + the following: + + mstClient.AddWebhookURLValidationPatterns(DefaultWebhookURLValidationPattern, `^https://.*\.domain\.com/.*$`) + */ + + // The title for message (first TextBlock element). + msgTitle := "Hello world" + + // Formatted message body. + msgText := "Here are some examples of formatted stuff like " + + "\n * this list itself \n * **bold** \n * *italic* \n * ***bolditalic***" + + // Create message using provided formatted title and text. + msg, err := adaptivecard.NewSimpleMessage(msgText, msgTitle, true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/disable-validation/main.go b/examples/adaptivecard/disable-validation/main.go new file mode 100644 index 0000000..8205d68 --- /dev/null +++ b/examples/adaptivecard/disable-validation/main.go @@ -0,0 +1,72 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This example disables the validation webhook URLs, including the validation of +known prefixes so that custom/private webhook URL endpoints can be used (e.g., +testing purposes). + +Of note: + +- message is in Adaptive Card format +- default timeout +- package-level logging is disabled by default +- webhook URL validation is **disabled** + - allows use of custom/private webhook URL endpoints +- simple message submitted to Microsoft Teams consisting of formatted body and + title + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // Disable webhook URL validation + mstClient.SkipWebhookURLValidationOnSend(true) + + // The title for message (first TextBlock element). + msgTitle := "Hello world" + + // Formatted message body. + msgText := "Here are some examples of formatted stuff like " + + "\n * this list itself \n * **bold** \n * *italic* \n * ***bolditalic***" + + // Create message using provided formatted title and text. + msg, err := adaptivecard.NewSimpleMessage(msgText, msgTitle, true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/user-mention-multiple/main.go b/examples/adaptivecard/user-mention-multiple/main.go new file mode 100644 index 0000000..d4d9533 --- /dev/null +++ b/examples/adaptivecard/user-mention-multiple/main.go @@ -0,0 +1,114 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to process +(pretend) user display names and IDs and generate multiple user mentions +within a specific Microsoft Teams channel. + +Of note: + +- default timeout +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- message is in Adaptive Card format +- text is formatted +- multiple user mentions are added to the message + +See https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +for the list of supported Adaptive Card text formatting options. + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +type userMention struct { + DisplayName string + ID string +} + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // The title for message (first TextBlock element). + msgTitle := "Hello world" + + // Formatted message body. + msgText := "Here are some examples of formatted stuff like " + + "\n * this list itself \n * **bold** \n * *italic* \n * ***bolditalic***" + + // Create Card using provided formatted title and text. We'll attach user + // mentions to this Card and then later generate a valid Message for + // delivery using the Card as input. + card, err := adaptivecard.NewTextBlockCard(msgText, msgTitle, true) + if err != nil { + log.Printf("failed to create card: %v", err) + os.Exit(1) + } + + // We pretend that the user has submitted these values via command line + // flags or some other input source and we have stored them in a struct + // with two fields for conversion to user mentions in our Microsoft Teams + // message. + usersToMention := []userMention{ + { + DisplayName: "John Doe", + ID: "jdoe@example.com", + }, + { + DisplayName: "Harry Dresden", + ID: "hdresden@example.com", + }, + } + + if len(usersToMention) > 0 { + // Process user mention details specified by user, create user mention + // values that we can attach to the card. + userMentions := make([]adaptivecard.Mention, 0, len(usersToMention)) + + for _, user := range usersToMention { + userMention, err := adaptivecard.NewMention(user.DisplayName, user.ID) + if err != nil { + log.Printf("failed to process user mention: %v\n", err) + os.Exit(1) + } + userMentions = append(userMentions, userMention) + } + + // Add user mention collection to card. + if err := card.AddMention(true, userMentions...); err != nil { + log.Printf("failed to add user mentions to message: %v\n", err) + } + } + + // Create new Message using Card as input. + msg, err := adaptivecard.NewMessageFromCard(card) + if err != nil { + log.Printf("failed to create message from card: %v", err) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/user-mention-single/main.go b/examples/adaptivecard/user-mention-single/main.go new file mode 100644 index 0000000..c3302f9 --- /dev/null +++ b/examples/adaptivecard/user-mention-single/main.go @@ -0,0 +1,65 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to generate +a user mention within a specific Microsoft Teams channel. + +Of note: + +- default timeout +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- message is in Adaptive Card format +- text is unformatted +- a specific user mention is added to the message + +See https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +for the list of supported Adaptive Card text formatting options. + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // Setup empty message. + msg := adaptivecard.NewMessage() + + // Add user mention and specified text. + if err := msg.Mention(true, "John Doe", "jdoe@example.com", "Hello there!"); err != nil { + log.Printf( + "failed to add user mention: %v", + err, + ) + os.Exit(1) + } + + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } +} diff --git a/examples/adaptivecard/user-mention-verbose/main.go b/examples/adaptivecard/user-mention-verbose/main.go new file mode 100644 index 0000000..abcab34 --- /dev/null +++ b/examples/adaptivecard/user-mention-verbose/main.go @@ -0,0 +1,196 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +/* + +This is an example of a client application which uses this library to process +(pretend) user display names and IDs and generate multiple user mentions +within a specific Microsoft Teams channel. + +This example is a somewhat rambling exploration of available options for +generating user mentions. While functional, this example file does not +necessarily reflect optimal approaches for generating user mentions. + +See the other Adaptive Card user mention examples for more targeted use cases +or the https://github.com/atc0005/send2teams project for a real-world CLI app +that uses this library to generate (optional) user mentions. + +Of note: + +- default timeout +- package-level logging is disabled by default +- validation of known webhook URL prefixes is *enabled* +- message is in Adaptive Card format +- text is formatted +- multiple user mentions are added to the message + +See https://docs.microsoft.com/en-us/adaptive-cards/authoring-cards/text-features +for the list of supported Adaptive Card text formatting options. + +*/ + +package main + +import ( + "log" + "os" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" +) + +func main() { + + // Initialize a new Microsoft Teams client. + mstClient := goteamsnotify.NewTeamsClient() + + // Set webhook url. + webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" + + // Allow specifying webhook URL via environment variable, fall-back to + // hard-coded value in this example file. + expectedEnvVar := "WEBHOOK_URL" + envWebhookURL := os.Getenv(expectedEnvVar) + switch { + case envWebhookURL != "": + log.Printf( + "Using webhook URL %q from environment variable %q\n\n", + envWebhookURL, + expectedEnvVar, + ) + webhookUrl = envWebhookURL + default: + log.Println(expectedEnvVar, "environment variable not set.") + log.Printf("Using hardcoded value %q as fallback\n\n", webhookUrl) + } + + // Create, print & send simple message. + simpleMsg, err := adaptivecard.NewSimpleMessage("Hello from NewSimpleMessage!", "", true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + if err := simpleMsg.Prepare(); err != nil { + log.Printf( + "failed to prepare message: %v", + err, + ) + os.Exit(1) + } + + log.Println(simpleMsg.PrettyPrint()) + + if err := mstClient.Send(webhookUrl, simpleMsg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } + + // Create, print & send user mention message. + mentionMsg, err := adaptivecard.NewMentionMessage( + "John Doe", + "jdoe@example.com", + "New user mention message.", + ) + if err != nil { + log.Printf( + "failed to create mention message: %v", + err, + ) + os.Exit(1) + } + + if err := mentionMsg.Prepare(); err != nil { + log.Printf( + "failed to prepare message: %v", + err, + ) + os.Exit(1) + } + + log.Println(mentionMsg.PrettyPrint()) + + if err := mstClient.Send(webhookUrl, mentionMsg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } + + // Create simple message, then add a user mention to it. + customMsg, err := adaptivecard.NewSimpleMessage("NewSimpleMessage.", "", true) + if err != nil { + log.Printf( + "failed to create message: %v", + err, + ) + os.Exit(1) + } + + if err := customMsg.Mention( + false, + "John Doe", + "jdoe@example.com", + "with a user mention added as a second step.", + ); err != nil { + log.Printf( + "failed to add user mention: %v", + err, + ) + os.Exit(1) + } + + if err := customMsg.Prepare(); err != nil { + log.Printf( + "failed to prepare message: %v", + err, + ) + os.Exit(1) + } + + log.Println(customMsg.PrettyPrint()) + + if err := mstClient.Send(webhookUrl, customMsg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } + + // Create empty message, add a user mention to it. + bareMsg := adaptivecard.NewMessage() + err = bareMsg.Mention( + false, + "John Doe", + "jdoe@example.com", + "Testing Message.Mention() method on card with no prior Elements.", + ) + if err != nil { + log.Printf( + "failed to add user mention: %v", + err, + ) + os.Exit(1) + } + + if err := mstClient.Send(webhookUrl, bareMsg); err != nil { + log.Printf( + "failed to send message: %v", + err, + ) + os.Exit(1) + } + +} diff --git a/examples/actions/main.go b/examples/messagecard/actions/main.go similarity index 80% rename from examples/actions/main.go rename to examples/messagecard/actions/main.go index b7d9087..54b2c78 100644 --- a/examples/actions/main.go +++ b/examples/messagecard/actions/main.go @@ -13,6 +13,7 @@ used, this action triggers opening a URI in a separate browser or application. Of note: +- message is in MessageCard format - default timeout - package-level logging is disabled by default - validation of known webhook URL prefixes is *enabled* @@ -29,34 +30,32 @@ package main import ( "log" + "os" goteamsnotify "github.com/atc0005/go-teams-notify/v2" "github.com/atc0005/go-teams-notify/v2/messagecard" ) func main() { - _ = sendTheMessage() -} -func sendTheMessage() error { - // init the client + // Initialize a new Microsoft Teams client. mstClient := goteamsnotify.NewTeamsClient() - // setup webhook url + // Set webhook url. webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" - // destination for OpenUri action + // Destination for OpenUri action. targetURL := "https://github.com/atc0005/go-teams-notify" targetURLDesc := "Project Homepage" - // setup message card + // Setup message card. msgCard := messagecard.NewMessageCard() msgCard.Title = "Hello world" msgCard.Text = "Here are some examples of formatted stuff like " + "
* this list itself
* **bold**
* *italic*
* ***bolditalic***" msgCard.ThemeColor = "#DF813D" - // setup Action for message card + // Setup Action for message card. pa, err := messagecard.NewPotentialAction( messagecard.PotentialActionOpenURIType, targetURLDesc, @@ -74,11 +73,14 @@ func sendTheMessage() error { }, } - // add the Action to the message card + // Add the Action to the message card. if err := msgCard.AddPotentialAction(pa); err != nil { log.Fatal("error encountered when adding action to message card:", err) } - // send - return mstClient.Send(webhookUrl, msgCard) + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msgCard); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } } diff --git a/examples/basic/main.go b/examples/messagecard/basic/main.go similarity index 72% rename from examples/basic/main.go rename to examples/messagecard/basic/main.go index 354c17f..5636e7a 100644 --- a/examples/basic/main.go +++ b/examples/messagecard/basic/main.go @@ -7,10 +7,12 @@ /* -This is an example of a simple client application which uses this library. +This is an example of a simple client application which uses this library to +generate a Microsoft Teams message in MessageCard format. Of note: +- message is in MessageCard format - default timeout - package-level logging is disabled by default - validation of known webhook URL prefixes is *enabled* @@ -22,28 +24,31 @@ Of note: package main import ( + "log" + "os" + goteamsnotify "github.com/atc0005/go-teams-notify/v2" "github.com/atc0005/go-teams-notify/v2/messagecard" ) func main() { - _ = sendTheMessage() -} -func sendTheMessage() error { - // init the client + // Initialize a new Microsoft Teams client. mstClient := goteamsnotify.NewTeamsClient() - // setup webhook url + // Set webhook url. webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" - // setup message card + // Setup message card. msgCard := messagecard.NewMessageCard() msgCard.Title = "Hello world" msgCard.Text = "Here are some examples of formatted stuff like " + "
* this list itself
* **bold**
* *italic*
* ***bolditalic***" msgCard.ThemeColor = "#DF813D" - // send - return mstClient.Send(webhookUrl, msgCard) + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msgCard); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } } diff --git a/examples/custom-user-agent/main.go b/examples/messagecard/custom-user-agent/main.go similarity index 77% rename from examples/custom-user-agent/main.go rename to examples/messagecard/custom-user-agent/main.go index 0c7fc51..b7d457c 100644 --- a/examples/custom-user-agent/main.go +++ b/examples/messagecard/custom-user-agent/main.go @@ -11,6 +11,7 @@ This is an example of a simple client application which uses this library. Of note: +- message is in MessageCard format - default timeout - custom user agent - package-level logging is disabled by default @@ -23,32 +24,34 @@ Of note: package main import ( + "log" + "os" + goteamsnotify "github.com/atc0005/go-teams-notify/v2" "github.com/atc0005/go-teams-notify/v2/messagecard" ) func main() { - _ = sendTheMessage() -} - -func sendTheMessage() error { // Initialize a new Microsoft Teams client. mstClient := goteamsnotify.NewTeamsClient() - // override the project-specific default user agent + // Override the project-specific default user agent mstClient.SetUserAgent("go-teams-notify-example/1.0") - // setup webhook url + // Set webhook url. webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" - // setup message card + // Setup message card. msgCard := messagecard.NewMessageCard() msgCard.Title = "Hello world" msgCard.Text = "Here are some examples of formatted stuff like " + "
* this list itself
* **bold**
* *italic*
* ***bolditalic***" msgCard.ThemeColor = "#DF813D" - // send - return mstClient.Send(webhookUrl, msgCard) + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msgCard); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } } diff --git a/examples/custom-validation/main.go b/examples/messagecard/custom-validation/main.go similarity index 75% rename from examples/custom-validation/main.go rename to examples/messagecard/custom-validation/main.go index 90eded7..ad936e3 100644 --- a/examples/custom-validation/main.go +++ b/examples/messagecard/custom-validation/main.go @@ -12,28 +12,32 @@ URLs. Of note: +- message is in MessageCard format +- default timeout +- package-level logging is disabled by default - webhook URL validation uses custom pattern - allows use of custom/private webhook URL endpoints -- other settings are the same as the basic example previously listed +- simple message submitted to Microsoft Teams consisting of formatted body and + title */ package main import ( + "log" + "os" + goteamsnotify "github.com/atc0005/go-teams-notify/v2" "github.com/atc0005/go-teams-notify/v2/messagecard" ) func main() { - _ = sendTheMessage() -} -func sendTheMessage() error { - // init the client + // Initialize a new Microsoft Teams client. mstClient := goteamsnotify.NewTeamsClient() - // setup webhook url + // Set webhook url. webhookUrl := "https://my.domain.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" // Add a custom pattern for webhook URL validation @@ -43,13 +47,16 @@ func sendTheMessage() error { // To keep the default behavior and add a custom one, use something like the following: // mstClient.AddWebhookURLValidationPatterns(DefaultWebhookURLValidationPattern, `^https://.*\.domain\.com/.*$`) - // setup message card + // Setup message card. msgCard := messagecard.NewMessageCard() msgCard.Title = "Hello world" msgCard.Text = "Here are some examples of formatted stuff like " + "
* this list itself
* **bold**
* *italic*
* ***bolditalic***" msgCard.ThemeColor = "#DF813D" - // send - return mstClient.Send(webhookUrl, msgCard) + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msgCard); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } } diff --git a/examples/disable-validation/main.go b/examples/messagecard/disable-validation/main.go similarity index 70% rename from examples/disable-validation/main.go rename to examples/messagecard/disable-validation/main.go index 7404e91..61a85c5 100644 --- a/examples/disable-validation/main.go +++ b/examples/messagecard/disable-validation/main.go @@ -13,40 +13,47 @@ testing purposes). Of note: +- message is in MessageCard format +- default timeout +- package-level logging is disabled by default - webhook URL validation is **disabled** - allows use of custom/private webhook URL endpoints -- other settings are the same as the basic example previously listed +- simple message submitted to Microsoft Teams consisting of formatted body and + title */ package main import ( + "log" + "os" + goteamsnotify "github.com/atc0005/go-teams-notify/v2" "github.com/atc0005/go-teams-notify/v2/messagecard" ) func main() { - _ = sendTheMessage() -} -func sendTheMessage() error { - // init the client + // Initialize a new Microsoft Teams client. mstClient := goteamsnotify.NewTeamsClient() - // setup webhook url + // Set webhook url. webhookUrl := "https://example.webhook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" // Disable webhook URL validation mstClient.SkipWebhookURLValidationOnSend(true) - // setup message card + // Setup message card. msgCard := messagecard.NewMessageCard() msgCard.Title = "Hello world" msgCard.Text = "Here are some examples of formatted stuff like " + "
* this list itself
* **bold**
* *italic*
* ***bolditalic***" msgCard.ThemeColor = "#DF813D" - // send - return mstClient.Send(webhookUrl, msgCard) + // Send the message with default timeout/retry settings. + if err := mstClient.Send(webhookUrl, msgCard); err != nil { + log.Printf("failed to send message: %v", err) + os.Exit(1) + } } diff --git a/examples/user-mention/main.go b/examples/user-mention/main.go deleted file mode 100644 index dfe96a3..0000000 --- a/examples/user-mention/main.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2021 Adam Chalkley -// -// https://github.com/atc0005/go-teams-notify -// -// Licensed under the MIT License. See LICENSE file in the project root for -// full license information. - -/* - -This is an example of a simple client application which uses this library to -generate a user mention within a specific Microsoft Teams channel. - -Of note: - -- default timeout -- package-level logging is disabled by default -- validation of known webhook URL prefixes is *enabled* -- simple message submitted to Microsoft Teams consisting of plain text message - (formatting is allowed, just not shown here) with a specific user mention - -*/ - -package main - -import ( - "fmt" - "os" - - goteamsnotify "github.com/atc0005/go-teams-notify/v2" - "github.com/atc0005/go-teams-notify/v2/botapi" -) - -func main() { - - // init the client - mstClient := goteamsnotify.NewTeamsClient() - - webhookUrl := "https://outlook.office.com/webhook/YOUR_WEBHOOK_URL_OF_TEAMS_CHANNEL" - - // setup message - msg := botapi.NewMessage().AddText("Hello there!") - - // add user mention - if err := msg.Mention("John Doe", "jdoe@example.com", true); err != nil { - fmt.Printf( - "failed to add user mention: %v", - err, - ) - } - - // send message - if err := mstClient.Send(webhookUrl, msg); err != nil { - fmt.Printf( - "failed to send message: %v", - err, - ) - os.Exit(1) - } -} diff --git a/format.go b/format.go index 9690485..d393711 100644 --- a/format.go +++ b/format.go @@ -14,8 +14,17 @@ import ( "strings" ) +///////////////////////////////////////////////////////////////////////// +// NOTE: The contents of this file are deprecated. See the Deprecated +// indicators in this file for intended replacements. +// +// Please submit a bug report if you find exported code in this file which +// does *not* already have a replacement elsewhere in this library. +///////////////////////////////////////////////////////////////////////// + // Newline patterns stripped out of text content sent to Microsoft Teams (by -// request) and replacement break value used to provide equivalent formatting. +// request) and replacement break value used to provide equivalent formatting +// for MessageCard payloads in Microsoft Teams. const ( // CR LF \r\n (windows) @@ -43,24 +52,24 @@ const ( // msTeamsCodeBlockSubmissionPrefix is the prefix appended to text input // to indicate that the text should be displayed as a codeblock by - // Microsoft Teams. + // Microsoft Teams for MessageCard payloads. msTeamsCodeBlockSubmissionPrefix string = "\n```\n" // msTeamsCodeBlockSubmissionPrefix string = "```" // msTeamsCodeBlockSubmissionSuffix is the suffix appended to text input // to indicate that the text should be displayed as a codeblock by - // Microsoft Teams. + // Microsoft Teams for MessageCard payloads. msTeamsCodeBlockSubmissionSuffix string = "```\n" // msTeamsCodeBlockSubmissionSuffix string = "```" // msTeamsCodeSnippetSubmissionPrefix is the prefix appended to text input // to indicate that the text should be displayed as a code formatted - // string of text by Microsoft Teams. + // string of text by Microsoft Teams for MessageCard payloads. msTeamsCodeSnippetSubmissionPrefix string = "`" // msTeamsCodeSnippetSubmissionSuffix is the suffix appended to text input // to indicate that the text should be displayed as a code formatted - // string of text by Microsoft Teams. + // string of text by Microsoft Teams for MessageCard payloads. msTeamsCodeSnippetSubmissionSuffix string = "`" ) @@ -68,6 +77,12 @@ const ( // error is encountered in the FormatAsCodeBlock function, this function will // return the original string, otherwise if no errors occur the newly formatted // string will be returned. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +// +// Deprecated: use messagecard.TryToFormatAsCodeBlock instead. func TryToFormatAsCodeBlock(input string) string { result, err := FormatAsCodeBlock(input) if err != nil { @@ -80,10 +95,16 @@ func TryToFormatAsCodeBlock(input string) string { return result } -// TryToFormatAsCodeSnippet acts as a wrapper for FormatAsCodeSnippet. If -// an error is encountered in the FormatAsCodeSnippet function, this function will -// return the original string, otherwise if no errors occur the newly formatted -// string will be returned. +// TryToFormatAsCodeSnippet acts as a wrapper for FormatAsCodeSnippet. If an +// error is encountered in the FormatAsCodeSnippet function, this function +// will return the original string, otherwise if no errors occur the newly +// formatted string will be returned. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +// +// Deprecated: use messagecard.TryToFormatAsCodeSnippet instead. func TryToFormatAsCodeSnippet(input string) string { result, err := FormatAsCodeSnippet(input) if err != nil { @@ -98,7 +119,13 @@ func TryToFormatAsCodeSnippet(input string) string { // FormatAsCodeBlock accepts an arbitrary string, quoted or not, and calls a // helper function which attempts to format as a valid Markdown code block for -// submission to Microsoft Teams +// submission to Microsoft Teams. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +// +// Deprecated: use messagecard.FormatAsCodeBlock instead. func FormatAsCodeBlock(input string) (string, error) { if input == "" { return "", errors.New("received empty string, refusing to format") @@ -115,7 +142,13 @@ func FormatAsCodeBlock(input string) (string, error) { // FormatAsCodeSnippet accepts an arbitrary string, quoted or not, and calls a // helper function which attempts to format as a single-line valid Markdown -// code snippet for submission to Microsoft Teams +// code snippet for submission to Microsoft Teams. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +// +// Deprecated: use messagecard.FormatAsCodeSnippet instead. func FormatAsCodeSnippet(input string) (string, error) { if input == "" { return "", errors.New("received empty string, refusing to format") @@ -132,7 +165,12 @@ func FormatAsCodeSnippet(input string) (string, error) { // formatAsCode is a helper function which accepts an arbitrary string, quoted // or not, a desired prefix and a suffix for the string and attempts to format -// as a valid Markdown formatted code sample for submission to Microsoft Teams +// as a valid Markdown formatted code sample for submission to Microsoft +// Teams. This helper function is intended for processing text intended for a +// MessageCard. +// +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. func formatAsCode(input string, prefix string, suffix string) (string, error) { var err error var byteSlice []byte @@ -192,7 +230,13 @@ func formatAsCode(input string, prefix string, suffix string) (string, error) { } // ConvertEOLToBreak converts \r\n (windows), \r (mac) and \n (unix) into
-// HTML/Markdown break statements. +// statements. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +// +// Deprecated: use messagecard.ConvertEOLToBreak instead. func ConvertEOLToBreak(s string) string { logger.Printf("ConvertEOLToBreak: Received %#v", s) diff --git a/messagecard.go b/messagecard.go index 62033c0..dcabd19 100644 --- a/messagecard.go +++ b/messagecard.go @@ -17,6 +17,14 @@ import ( "strings" ) +///////////////////////////////////////////////////////////////////////// +// NOTE: The contents of this file are deprecated. See the Deprecated +// indicators in this file for intended replacements. +// +// Please submit a bug report if you find exported code in this file which +// does *not* already have a replacement elsewhere in this library. +///////////////////////////////////////////////////////////////////////// + const ( // PotentialActionOpenURIType is the type that must be used for OpenUri // potential action. @@ -601,23 +609,33 @@ func (mc *MessageCard) Validate() error { return nil } -// Prepare handles tasks needed to prepare a MessageCard for delivery to an -// endpoint. If specified, tasks are repeated regardless of whether a previous -// Prepare call was made. Validation should be performed by the caller prior -// to calling this method. +// Prepare handles tasks needed to construct a payload from a MessageCard for +// delivery to an endpoint. // // Deprecated: use (messagecard.MessageCard).Prepare instead. -func (mc *MessageCard) Prepare(recreate bool) error { - if mc.payload != nil && !recreate { - return nil - } - +func (mc *MessageCard) Prepare() error { jsonMessage, err := json.Marshal(mc) if err != nil { - return err + return fmt.Errorf( + "error marshalling MessageCard to JSON: %w", + err, + ) + } + + switch { + case mc.payload == nil: + mc.payload = &bytes.Buffer{} + default: + mc.payload.Reset() } - mc.payload = bytes.NewBuffer(jsonMessage) + _, err = mc.payload.Write(jsonMessage) + if err != nil { + return fmt.Errorf( + "error updating JSON payload for MessageCard: %w", + err, + ) + } return nil } @@ -637,8 +655,6 @@ func (mc *MessageCard) Payload() io.Reader { func (mc *MessageCard) PrettyPrint() string { if mc.payload != nil { var prettyJSON bytes.Buffer - - // Validation is handled by the MessageCard.Prepare() method. _ = json.Indent(&prettyJSON, mc.payload.Bytes(), "", "\t") return prettyJSON.String() @@ -821,7 +837,7 @@ func NewMessageCardSectionImage() MessageCardSectionImage { } // NewMessageCardPotentialAction creates a new MessageCardPotentialAction -// using the provided potential action type and name. The name values defines +// using the provided potential action type and name. The name value defines // the text that will be displayed on screen for the action. An error is // returned if invalid values are supplied. // diff --git a/messagecard/doc.go b/messagecard/doc.go index 2c22158..2ef83e9 100644 --- a/messagecard/doc.go +++ b/messagecard/doc.go @@ -1,5 +1,19 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + /* -Package messagecard provides support for the legacy MessageCard format in -order to generate Microsoft Teams messages. +Package messagecard provides support for generating Microsoft Teams messages +using the legacy "actionable message card" (aka, "MessageCard") format. + +See the provided examples in this repo, the Godoc generated documentation at +https://pkg.go.dev/github.com/atc0005/go-teams-notify/v2 and the following +resources for more information: + +https://docs.microsoft.com/en-us/outlook/actionable-messages/message-card-reference +https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using */ package messagecard diff --git a/messagecard/format.go b/messagecard/format.go new file mode 100644 index 0000000..029435b --- /dev/null +++ b/messagecard/format.go @@ -0,0 +1,212 @@ +// Copyright 2021 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package messagecard + +import ( + "bytes" + "encoding/json" + "errors" + "strings" +) + +// Newline patterns stripped out of text content sent to Microsoft Teams (by +// request) and replacement break value used to provide equivalent formatting +// for MessageCard payloads in Microsoft Teams. +const ( + + // CR LF \r\n (windows) + windowsEOLActual = "\r\n" + windowsEOLEscaped = `\r\n` + + // CF \r (mac) + macEOLActual = "\r" + macEOLEscaped = `\r` + + // LF \n (unix) + unixEOLActual = "\n" + unixEOLEscaped = `\n` + + // Used by Teams to separate lines + breakStatement = "
" +) + +// Even though Microsoft Teams doesn't show the additional newlines, +// https://messagecardplayground.azurewebsites.net/ DOES show the results +// as a formatted code block. Including the newlines now is an attempt at +// "future proofing" the codeblock support in MessageCard values sent to +// Microsoft Teams. +const ( + + // msTeamsCodeBlockSubmissionPrefix is the prefix appended to text input + // to indicate that the text should be displayed as a codeblock by + // Microsoft Teams for MessageCard payloads. + msTeamsCodeBlockSubmissionPrefix string = "\n```\n" + // msTeamsCodeBlockSubmissionPrefix string = "```" + + // msTeamsCodeBlockSubmissionSuffix is the suffix appended to text input + // to indicate that the text should be displayed as a codeblock by + // Microsoft Teams for MessageCard payloads. + msTeamsCodeBlockSubmissionSuffix string = "```\n" + // msTeamsCodeBlockSubmissionSuffix string = "```" + + // msTeamsCodeSnippetSubmissionPrefix is the prefix appended to text input + // to indicate that the text should be displayed as a code formatted + // string of text by Microsoft Teams for MessageCard payloads. + msTeamsCodeSnippetSubmissionPrefix string = "`" + + // msTeamsCodeSnippetSubmissionSuffix is the suffix appended to text input + // to indicate that the text should be displayed as a code formatted + // string of text by Microsoft Teams for MessageCard payloads. + msTeamsCodeSnippetSubmissionSuffix string = "`" +) + +// TryToFormatAsCodeBlock acts as a wrapper for FormatAsCodeBlock. If an +// error is encountered in the FormatAsCodeBlock function, this function will +// return the original string, otherwise if no errors occur the newly formatted +// string will be returned. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func TryToFormatAsCodeBlock(input string) string { + result, err := FormatAsCodeBlock(input) + if err != nil { + return input + } + + return result +} + +// TryToFormatAsCodeSnippet acts as a wrapper for FormatAsCodeSnippet. If an +// error is encountered in the FormatAsCodeSnippet function, this function +// will return the original string, otherwise if no errors occur the newly +// formatted string will be returned. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func TryToFormatAsCodeSnippet(input string) string { + result, err := FormatAsCodeSnippet(input) + if err != nil { + return input + } + + return result +} + +// FormatAsCodeBlock accepts an arbitrary string, quoted or not, and calls a +// helper function which attempts to format as a valid Markdown code block for +// submission to Microsoft Teams. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func FormatAsCodeBlock(input string) (string, error) { + if input == "" { + return "", errors.New("received empty string, refusing to format") + } + + result, err := formatAsCode( + input, + msTeamsCodeBlockSubmissionPrefix, + msTeamsCodeBlockSubmissionSuffix, + ) + + return result, err +} + +// FormatAsCodeSnippet accepts an arbitrary string, quoted or not, and calls a +// helper function which attempts to format as a single-line valid Markdown +// code snippet for submission to Microsoft Teams. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func FormatAsCodeSnippet(input string) (string, error) { + if input == "" { + return "", errors.New("received empty string, refusing to format") + } + + result, err := formatAsCode( + input, + msTeamsCodeSnippetSubmissionPrefix, + msTeamsCodeSnippetSubmissionSuffix, + ) + + return result, err +} + +// formatAsCode is a helper function which accepts an arbitrary string, quoted +// or not, a desired prefix and a suffix for the string and attempts to format +// as a valid Markdown formatted code sample for submission to Microsoft +// Teams. This helper function is intended for processing text intended for a +// MessageCard. +// +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func formatAsCode(input string, prefix string, suffix string) (string, error) { + var err error + var byteSlice []byte + + switch { + // required; protects against slice out of range panics + case input == "": + return "", errors.New("received empty string, refusing to format as code block") + + // If the input string is already valid JSON, don't double-encode and + // escape the content + case json.Valid([]byte(input)): + // FIXME: Is json.RawMessage() really needed if the input string is + // *already* JSON? https://golang.org/pkg/encoding/json/#RawMessage + // seems to imply a different use case. + byteSlice = json.RawMessage([]byte(input)) + // + // From light testing, it appears to not be necessary: + // + // logger.Printf("formatAsCode: Skipping json.RawMessage, converting string directly to byte slice; input: %+v", input) + // byteSlice = []byte(input) + + default: + byteSlice, err = json.Marshal(input) + if err != nil { + return "", err + } + } + + var prettyJSON bytes.Buffer + + err = json.Indent(&prettyJSON, byteSlice, "", "\t") + if err != nil { + return "", err + } + formattedJSON := prettyJSON.String() + + // handle both cases: where the formatted JSON string was not wrapped with + // double-quotes and when it was + codeContentForSubmission := prefix + strings.Trim(formattedJSON, "\"") + suffix + + // err should be nil if everything worked as expected + return codeContentForSubmission, err +} + +// ConvertEOLToBreak converts \r\n (windows), \r (mac) and \n (unix) into
+// statements. +// +// This function is intended for processing text intended for a MessageCard. +// Using this helper function for text intended for an Adaptive Card is +// unsupported and unlikely to produce the desired results. +func ConvertEOLToBreak(s string) string { + s = strings.ReplaceAll(s, windowsEOLActual, breakStatement) + s = strings.ReplaceAll(s, windowsEOLEscaped, breakStatement) + s = strings.ReplaceAll(s, macEOLActual, breakStatement) + s = strings.ReplaceAll(s, macEOLEscaped, breakStatement) + s = strings.ReplaceAll(s, unixEOLActual, breakStatement) + s = strings.ReplaceAll(s, unixEOLEscaped, breakStatement) + + return s +} diff --git a/messagecard/messagecard.go b/messagecard/messagecard.go index 2dc918c..7165f16 100644 --- a/messagecard/messagecard.go +++ b/messagecard/messagecard.go @@ -398,7 +398,9 @@ type MessageCard struct { // displayed in a non-obtrusive manner. ThemeColor string `json:"themeColor,omitempty"` - // ValidateFunc is a validation function that validates a MessageCard + // ValidateFunc is an optional user-specified validation function that is + // responsible for validating a MessageCard. If not specified, default + // validation is performed. ValidateFunc func() error `json:"-"` // Sections is a collection of sections to include in the card. @@ -502,8 +504,8 @@ func (mc *MessageCard) AddPotentialAction(actions ...*PotentialAction) error { return addPotentialAction(&mc.PotentialActions, actions...) } -// Validate validates a MessageCard calling ValidateFunc if defined, -// otherwise, a default validation occurs. +// Validate performs validation for MessageCard using ValidateFunc if defined, +// otherwise applying default validation. func (mc *MessageCard) Validate() error { if mc.ValidateFunc != nil { return mc.ValidateFunc() @@ -520,21 +522,31 @@ func (mc *MessageCard) Validate() error { return nil } -// Prepare handles tasks needed to prepare a MessageCard for delivery to an -// endpoint. If specified, tasks are repeated regardless of whether a previous -// Prepare call was made. Validation should be performed by the caller prior -// to calling this method. -func (mc *MessageCard) Prepare(recreate bool) error { - if mc.payload != nil && !recreate { - return nil - } - +// Prepare handles tasks needed to construct a payload from a MessageCard for +// delivery to an endpoint. +func (mc *MessageCard) Prepare() error { jsonMessage, err := json.Marshal(mc) if err != nil { - return err + return fmt.Errorf( + "error marshalling MessageCard to JSON: %w", + err, + ) + } + + switch { + case mc.payload == nil: + mc.payload = &bytes.Buffer{} + default: + mc.payload.Reset() } - mc.payload = bytes.NewBuffer(jsonMessage) + _, err = mc.payload.Write(jsonMessage) + if err != nil { + return fmt.Errorf( + "error updating JSON payload for MessageCard: %w", + err, + ) + } return nil } @@ -550,8 +562,6 @@ func (mc *MessageCard) Payload() io.Reader { func (mc *MessageCard) PrettyPrint() string { if mc.payload != nil { var prettyJSON bytes.Buffer - - // Validation is handled by the MessageCard.Prepare() method. _ = json.Indent(&prettyJSON, mc.payload.Bytes(), "", "\t") return prettyJSON.String() @@ -696,10 +706,10 @@ func NewSectionImage() *SectionImage { return &SectionImage{} } -// NewPotentialAction creates a new PotentialAction -// using the provided potential action type and name. The name values defines -// the text that will be displayed on screen for the action. An error is -// returned if invalid values are supplied. +// NewPotentialAction creates a new PotentialAction using the provided +// potential action type and name. The name value defines the text that will +// be displayed on screen for the action. An error is returned if invalid +// values are supplied. func NewPotentialAction(potentialActionType string, name string) (*PotentialAction, error) { pa := PotentialAction{ Type: potentialActionType, diff --git a/send.go b/send.go index 928587e..3348059 100644 --- a/send.go +++ b/send.go @@ -108,7 +108,6 @@ type API interface { // interface in order to support future changes (and not violate backwards // compatibility). type MessageSender interface { - // validateInput(message MessageValidator, webhookURL string) error HTTPClient() *http.Client UserAgent() string ValidateWebhook(webhookURL string) error @@ -122,7 +121,7 @@ type MessageSender interface { // messagePreparer is a message type that supports marshaling its fields // as preparation for delivery to an endpoint. type messagePreparer interface { - Prepare(recreate bool) error + Prepare() error } // messageValidator is a message type that provides validation of its format. @@ -487,7 +486,7 @@ func sendWithContext(ctx context.Context, client MessageSender, webhookURL strin ) } - if err := message.Prepare(false); err != nil { + if err := message.Prepare(); err != nil { return fmt.Errorf( "failed to prepare message: %w", err, diff --git a/textutils.go b/textutils.go new file mode 100644 index 0000000..c872266 --- /dev/null +++ b/textutils.go @@ -0,0 +1,29 @@ +// Copyright 2022 Adam Chalkley +// +// https://github.com/atc0005/go-teams-notify +// +// Licensed under the MIT License. See LICENSE file in the project root for +// full license information. + +package goteamsnotify + +import ( + "strings" +) + +// InList is a helper function to emulate Python's `if "x" in list:` +// functionality. The caller can optionally ignore case of compared items. +func InList(needle string, haystack []string, ignoreCase bool) bool { + for _, item := range haystack { + if ignoreCase { + if strings.EqualFold(item, needle) { + return true + } + } + + if item == needle { + return true + } + } + return false +}