diff --git a/.golangci.yml b/.golangci.yml index 03671d9..ed8c8bd 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -157,6 +157,7 @@ issues: exclude-rules: - path: _test\.go linters: + - dupl - gomnd - lll - bodyclose diff --git a/mg-transport-api-client-go b/mg-transport-api-client-go new file mode 120000 index 0000000..0ff901d --- /dev/null +++ b/mg-transport-api-client-go @@ -0,0 +1 @@ +/home/pavel/work/mg-transports/mg-transport-api-client-go \ No newline at end of file diff --git a/v1/client.go b/v1/client.go index eddce10..deeef90 100644 --- a/v1/client.go +++ b/v1/client.go @@ -153,15 +153,15 @@ func (c *MgClient) ActivateTemplate(channelID uint64, request ActivateTemplateRe // if err != nil { // fmt.Printf("%#v", err) // } -func (c *MgClient) UpdateTemplate(request Template) (int, error) { +func (c *MgClient) UpdateTemplate(channelID uint64, code string, request UpdateTemplateRequest) (int, error) { outgoing, _ := json.Marshal(&request) - if request.ChannelID == 0 || request.Code == "" { + if channelID == 0 || code == "" { return 0, errors.New("`ChannelID` and `Code` cannot be blank") } data, status, err := c.PutRequest( - fmt.Sprintf("/channels/%d/templates/%s", request.ChannelID, url.PathEscape(request.Code)), outgoing) + fmt.Sprintf("/channels/%d/templates/%s", channelID, url.PathEscape(code)), outgoing) if err != nil { return status, err } diff --git a/v1/client_test.go b/v1/client_test.go index d30cb9c..89f04a0 100644 --- a/v1/client_test.go +++ b/v1/client_test.go @@ -351,24 +351,26 @@ func (t *MGClientTest) Test_ActivateTemplate() { c := t.client() req := ActivateTemplateRequest{ Code: "tplCode", - Name: "tplCode", Type: TemplateTypeText, - Template: []TemplateItem{ - { - Type: TemplateItemTypeText, - Text: "Hello ", - }, - { - Type: TemplateItemTypeVar, - VarType: TemplateVarFirstName, - }, - { - Type: TemplateItemTypeText, - Text: "!", + UpdateTemplateRequest: UpdateTemplateRequest{ + Name: "tplCode", + Template: []TemplateItem{ + { + Type: TemplateItemTypeText, + Text: "Hello ", + }, + { + Type: TemplateItemTypeVar, + VarType: TemplateVarFirstName, + }, + { + Type: TemplateItemTypeText, + Text: "!", + }, }, + RejectionReason: "", + VerificationStatus: TemplateStatusApproved, }, - RejectionReason: "", - VerificationStatus: "approved", } defer gock.Off() @@ -384,12 +386,8 @@ func (t *MGClientTest) Test_ActivateTemplate() { func (t *MGClientTest) Test_UpdateTemplate() { c := t.client() - tpl := Template{ - Code: "encodable#code", - ChannelID: 1, - Name: "updated name", - Enabled: true, - Type: TemplateTypeText, + tpl := UpdateTemplateRequest{ + Name: "updated name", Template: []TemplateItem{ { Type: TemplateItemTypeText, @@ -421,16 +419,20 @@ func (t *MGClientTest) Test_UpdateTemplate() { t.gock(). Get(t.transportURL("templates")). Reply(http.StatusOK). - JSON([]Template{tpl}) + JSON([]ActivateTemplateRequest{ActivateTemplateRequest{ + UpdateTemplateRequest: tpl, + Code: "encodable#code", + Type: TemplateTypeText, + }}) - status, err := c.UpdateTemplate(tpl) + status, err := c.UpdateTemplate(1, "encodable#code", tpl) t.Assert().NoError(err, fmt.Sprintf("%d %s", status, err)) templates, status, err := c.TransportTemplates() t.Assert().NoError(err, fmt.Sprintf("%d %s", status, err)) for _, template := range templates { - if template.Code == tpl.Code { + if template.Code == "encodable#code" { t.Assert().Equal(tpl.Name, template.Name) } } @@ -438,10 +440,8 @@ func (t *MGClientTest) Test_UpdateTemplate() { func (t *MGClientTest) Test_UpdateTemplateFail() { c := t.client() - tpl := Template{ - Name: "updated name", - Enabled: true, - Type: TemplateTypeText, + tpl := UpdateTemplateRequest{ + Name: "updated name", Template: []TemplateItem{ { Type: TemplateItemTypeText, @@ -467,7 +467,7 @@ func (t *MGClientTest) Test_UpdateTemplateFail() { }, ) - status, err := c.UpdateTemplate(tpl) + status, err := c.UpdateTemplate(1, "encodable#code", tpl) t.Assert().Error(err, fmt.Sprintf("%d %s", status, err)) } diff --git a/v1/template.go b/v1/template.go index 634181a..ff973b2 100644 --- a/v1/template.go +++ b/v1/template.go @@ -1,14 +1,12 @@ package v1 import ( + "bytes" "encoding/json" "errors" "fmt" ) -// TemplateTypeText is a text template type. There is no other types for now. -const TemplateTypeText = "text" - const ( // TemplateItemTypeText is a type for text chunk in template. TemplateItemTypeText uint8 = iota @@ -37,19 +35,260 @@ var templateVarAssoc = map[string]interface{}{ // Template struct. type Template struct { - Code string `json:"code"` - ChannelID uint64 `json:"channel_id,omitempty"` - Name string `json:"name"` - Enabled bool `json:"enabled,omitempty"` - Type string `json:"type"` - Template []TemplateItem `json:"template"` - HeaderParams *HeaderParams `json:"headerParams,omitempty"` - Footer *string `json:"footer,omitempty"` - ButtonParams []ButtonParam `json:"buttonParams,omitempty"` - Lang string `json:"lang,omitempty"` - Category string `json:"category,omitempty"` - RejectionReason string `json:"rejection_reason,omitempty"` - VerificationStatus string `json:"verification_status,omitempty"` + ID int64 `json:"id,omitempty"` + Code string `json:"code,omitempty"` + ChannelID uint64 `json:"channel_id"` + Name string `json:"name"` + Enabled bool `json:"enabled"` + Type TemplateType `json:"type"` + Template []TemplateItem `json:"template"` + Body string `json:"body"` + Lang string `json:"lang,omitempty"` + Category string `json:"category,omitempty"` + Example *TemplateExample `json:"example,omitempty"` + VerificationStatus TemplateVerificationStatus `json:"verification_status"` + RejectionReason TemplateRejectionReason `json:"rejection_reason,omitempty"` + Header *TemplateHeader `json:"header,omitempty"` + Footer string `json:"footer,omitempty"` + Buttons *TemplateButtons `json:"buttons,omitempty"` +} + +type TemplateExample struct { + Body []string `json:"body,omitempty"` + Header []string `json:"header,omitempty"` + Buttons [][]string `json:"buttons,omitempty"` +} + +type TemplateButtons struct { + Items []Button `json:"items"` +} + +func (b *TemplateButtons) UnmarshalJSON(value []byte) error { + var ScanType struct { + Items []json.RawMessage `json:"items"` + } + + if err := json.Unmarshal(value, &ScanType); err != nil { + return err + } + + var ButtonType struct { + Type ButtonType `json:"type"` + } + + for _, r := range ScanType.Items { + if err := json.Unmarshal(r, &ButtonType); err != nil { + return err + } + + var btn Button + switch ButtonType.Type { + case ButtonTypePlain: + btn = &PlainButton{} + case ButtonTypePhone: + btn = &PhoneButton{} + case ButtonTypeURL: + btn = &URLButton{} + default: + return errors.New("undefined type of button") + } + + if err := json.Unmarshal(r, btn); err != nil { + return err + } + + b.Items = append(b.Items, btn) + } + + return nil +} + +func (b TemplateButtons) MarshalJSON() ([]byte, error) { + var ValueType struct { + Items []json.RawMessage `json:"items"` + } + + for _, btn := range b.Items { + btnData, err := json.Marshal(btn) + if err != nil { + return nil, err + } + + buffer := bytes.NewBuffer(btnData[:len(btnData)-1]) + buffer.WriteByte(',') + buffer.WriteString(fmt.Sprintf(`"type":"%s"`, btn.ButtonType())) + buffer.WriteByte('}') + + ValueType.Items = append(ValueType.Items, buffer.Bytes()) + } + + d, err := json.Marshal(ValueType) + if err != nil { + return nil, err + } + + return d, nil +} + +type Button interface { + ButtonType() ButtonType +} + +type ButtonType string + +const ( + ButtonTypePlain ButtonType = "plain" + ButtonTypePhone ButtonType = "phone" + ButtonTypeURL ButtonType = "url" +) + +type PlainButton struct { + Label string `json:"label"` +} + +func (PlainButton) ButtonType() ButtonType { return ButtonTypePlain } + +type PhoneButton struct { + Label string `json:"label"` + Phone string `json:"phone"` +} + +func (PhoneButton) ButtonType() ButtonType { return ButtonTypePhone } + +type URLButton struct { + Label string `json:"label"` + URL string `json:"url"` +} + +func (URLButton) ButtonType() ButtonType { return ButtonTypeURL } + +type HeaderContent interface { + HeaderContentType() HeaderContentType +} + +type HeaderContentType string + +const ( + HeaderContentTypeText HeaderContentType = "text" + HeaderContentTypeDocument HeaderContentType = "document" + HeaderContentTypeImage HeaderContentType = "image" + HeaderContentTypeVideo HeaderContentType = "video" +) + +type HeaderContentText struct { + Body string `json:"body"` +} + +func (HeaderContentText) HeaderContentType() HeaderContentType { return HeaderContentTypeText } + +type HeaderContentDocument struct{} + +func (HeaderContentDocument) HeaderContentType() HeaderContentType { return HeaderContentTypeDocument } + +type HeaderContentImage struct{} + +func (HeaderContentImage) HeaderContentType() HeaderContentType { return HeaderContentTypeImage } + +type HeaderContentVideo struct{} + +func (HeaderContentVideo) HeaderContentType() HeaderContentType { return HeaderContentTypeVideo } + +type TemplateHeader struct { + Content HeaderContent `json:"content"` +} + +func (h *TemplateHeader) TextContent() *HeaderContentText { + if h.Content.HeaderContentType() != HeaderContentTypeText { + return nil + } + return h.Content.(*HeaderContentText) +} + +func (h *TemplateHeader) DocumentContent() *HeaderContentDocument { + if h.Content.HeaderContentType() != HeaderContentTypeDocument { + return nil + } + return h.Content.(*HeaderContentDocument) +} + +func (h *TemplateHeader) ImageContent() *HeaderContentImage { + if h.Content.HeaderContentType() != HeaderContentTypeImage { + return nil + } + return h.Content.(*HeaderContentImage) +} + +func (h *TemplateHeader) VideoContent() *HeaderContentVideo { + if h.Content.HeaderContentType() != HeaderContentTypeVideo { + return nil + } + return h.Content.(*HeaderContentVideo) +} + +func (h *TemplateHeader) UnmarshalJSON(value []byte) error { + var ScanType struct { + Content json.RawMessage `json:"content"` + } + + if err := json.Unmarshal(value, &ScanType); err != nil { + return err + } + + var ContentType struct { + Type HeaderContentType `json:"type"` + } + + if err := json.Unmarshal(ScanType.Content, &ContentType); err != nil { + return err + } + + var c HeaderContent + switch ContentType.Type { + case HeaderContentTypeText: + c = &HeaderContentText{} + case HeaderContentTypeDocument: + c = &HeaderContentDocument{} + case HeaderContentTypeImage: + c = &HeaderContentImage{} + case HeaderContentTypeVideo: + c = &HeaderContentVideo{} + default: + return errors.New("undefined type of header content") + } + + if err := json.Unmarshal(ScanType.Content, c); err != nil { + return err + } + + h.Content = c + return nil +} + +func (h TemplateHeader) MarshalJSON() ([]byte, error) { + content, err := json.Marshal(h.Content) + if err != nil { + return nil, err + } + + buffer := bytes.NewBuffer(content[:len(content)-1]) + if buffer.Len() > 1 { + buffer.WriteByte(',') + } + buffer.WriteString(fmt.Sprintf(`"type":"%s"`, h.Content.HeaderContentType())) + buffer.WriteByte('}') + + var ValueType struct { + Content json.RawMessage `json:"content"` + } + + ValueType.Content = buffer.Bytes() + + d, err := json.Marshal(ValueType) + if err != nil { + return nil, err + } + + return d, nil } // TemplateItem is a part of template. diff --git a/v1/template_test.go b/v1/template_test.go index 4642a50..7d94e3f 100644 --- a/v1/template_test.go +++ b/v1/template_test.go @@ -64,51 +64,193 @@ func TestTemplateItem_UnmarshalJSON(t *testing.T) { assert.Empty(t, emptyVariableResult.Text) } -func TestUnmarshalMediaInteractiveTemplate(t *testing.T) { +func TestUnmarshalInteractiveTemplate_TextHeader(t *testing.T) { var template Template input := `{ "code":"aaa#bbb#ru", "phone": "79252223456", "channel_id": 1, - "headerParams": { - "textVars": [ - "Johny", - "1234C" - ], - "imageUrl": "http://example.com/intaro/d2354125", - "videoUrl": "http://example.com/intaro/d2222", - "documentUrl": "http://example.com/intaro/d4444" + "header": { + "content": { + "type": "text", + "body": "Hello, {{1}}!" + } }, "footer": "Scooter", - "buttonParams": [ - { - "type": "URL", - "urlParameter": "222ddd" - }, - { - "type": "QUICK_REPLY", - "text": "Yes" - } - ], + "buttons": { + "items": [ + { + "type": "url", + "label": "Go to website", + "url": "222ddd" + }, + { + "type": "plain", + "label": "Yes" + } + ] + }, "verification_status": "approved" }` assert.NoError(t, json.Unmarshal([]byte(input), &template)) assert.Equal(t, "aaa#bbb#ru", template.Code) - assert.Equal(t, []string{"Johny", "1234C"}, template.HeaderParams.TextVars) - assert.Equal(t, "http://example.com/intaro/d2354125", template.HeaderParams.ImageURL) - assert.Equal(t, "http://example.com/intaro/d2222", template.HeaderParams.VideoURL) - assert.Equal(t, "http://example.com/intaro/d4444", template.HeaderParams.DocumentURL) - assert.Equal(t, "Scooter", *template.Footer) - assert.Equal(t, "approved", template.VerificationStatus) - assert.Equal(t, URLButton, template.ButtonParams[0].ButtonType) - assert.Equal(t, "222ddd", template.ButtonParams[0].URLParameter) - assert.Equal(t, QuickReplyButton, template.ButtonParams[1].ButtonType) - assert.Equal(t, "Yes", template.ButtonParams[1].Text) + assert.Equal(t, HeaderContentTypeText, template.Header.Content.HeaderContentType()) + + h := template.Header.TextContent() + assert.Equal(t, "Hello, {{1}}!", h.Body) + assert.Equal(t, "Scooter", template.Footer) + assert.Equal(t, TemplateStatusApproved, template.VerificationStatus) + assert.Equal(t, ButtonTypeURL, template.Buttons.Items[0].ButtonType()) + assert.Equal(t, "222ddd", template.Buttons.Items[0].(*URLButton).URL) + assert.Equal(t, "Go to website", template.Buttons.Items[0].(*URLButton).Label) + assert.Equal(t, ButtonTypePlain, template.Buttons.Items[1].ButtonType()) + assert.Equal(t, "Yes", template.Buttons.Items[1].(*PlainButton).Label) input = `{"footer": "Scooter"}` template = Template{} assert.NoError(t, json.Unmarshal([]byte(input), &template)) - assert.Nil(t, template.HeaderParams) - assert.Empty(t, template.ButtonParams) + assert.Nil(t, template.Header) + assert.Empty(t, template.Buttons) +} + +func TestUnmarshalInteractiveTemplate_DocumentHeader(t *testing.T) { + var template Template + input := `{ + "code":"aaa#bbb#ru", + "phone": "79252223456", + "channel_id": 1, + "header": { + "content": { + "type": "document" + } + }, + "footer": "Scooter", + "buttons": { + "items": [ + { + "type": "url", + "label": "Go to website", + "url": "222ddd" + }, + { + "type": "plain", + "label": "Yes" + } + ] + }, + "verification_status": "approved" +}` + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + + assert.Equal(t, "aaa#bbb#ru", template.Code) + assert.Equal(t, HeaderContentTypeDocument, template.Header.Content.HeaderContentType()) + assert.NotNil(t, template.Header.DocumentContent()) + assert.Equal(t, "Scooter", template.Footer) + assert.Equal(t, TemplateStatusApproved, template.VerificationStatus) + assert.Equal(t, ButtonTypeURL, template.Buttons.Items[0].ButtonType()) + assert.Equal(t, "222ddd", template.Buttons.Items[0].(*URLButton).URL) + assert.Equal(t, "Go to website", template.Buttons.Items[0].(*URLButton).Label) + assert.Equal(t, ButtonTypePlain, template.Buttons.Items[1].ButtonType()) + assert.Equal(t, "Yes", template.Buttons.Items[1].(*PlainButton).Label) + + input = `{"footer": "Scooter"}` + template = Template{} + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + assert.Nil(t, template.Header) + assert.Empty(t, template.Buttons) +} + +func TestUnmarshalInteractiveTemplate_ImageHeader(t *testing.T) { + var template Template + input := `{ + "code":"aaa#bbb#ru", + "phone": "79252223456", + "channel_id": 1, + "header": { + "content": { + "type": "image" + } + }, + "footer": "Scooter", + "buttons": { + "items": [ + { + "type": "url", + "label": "Go to website", + "url": "222ddd" + }, + { + "type": "plain", + "label": "Yes" + } + ] + }, + "verification_status": "approved" +}` + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + + assert.Equal(t, "aaa#bbb#ru", template.Code) + assert.Equal(t, HeaderContentTypeImage, template.Header.Content.HeaderContentType()) + assert.NotNil(t, template.Header.ImageContent()) + assert.Equal(t, "Scooter", template.Footer) + assert.Equal(t, TemplateStatusApproved, template.VerificationStatus) + assert.Equal(t, ButtonTypeURL, template.Buttons.Items[0].ButtonType()) + assert.Equal(t, "222ddd", template.Buttons.Items[0].(*URLButton).URL) + assert.Equal(t, "Go to website", template.Buttons.Items[0].(*URLButton).Label) + assert.Equal(t, ButtonTypePlain, template.Buttons.Items[1].ButtonType()) + assert.Equal(t, "Yes", template.Buttons.Items[1].(*PlainButton).Label) + + input = `{"footer": "Scooter"}` + template = Template{} + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + assert.Nil(t, template.Header) + assert.Empty(t, template.Buttons) +} + +func TestUnmarshalInteractiveTemplate_VideoHeader(t *testing.T) { + var template Template + input := `{ + "code":"aaa#bbb#ru", + "phone": "79252223456", + "channel_id": 1, + "header": { + "content": { + "type": "video" + } + }, + "footer": "Scooter", + "buttons": { + "items": [ + { + "type": "url", + "label": "Go to website", + "url": "222ddd" + }, + { + "type": "plain", + "label": "Yes" + } + ] + }, + "verification_status": "approved" +}` + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + + assert.Equal(t, "aaa#bbb#ru", template.Code) + assert.Equal(t, HeaderContentTypeVideo, template.Header.Content.HeaderContentType()) + assert.NotNil(t, template.Header.VideoContent()) + assert.Equal(t, "Scooter", template.Footer) + assert.Equal(t, TemplateStatusApproved, template.VerificationStatus) + assert.Equal(t, ButtonTypeURL, template.Buttons.Items[0].ButtonType()) + assert.Equal(t, "222ddd", template.Buttons.Items[0].(*URLButton).URL) + assert.Equal(t, "Go to website", template.Buttons.Items[0].(*URLButton).Label) + assert.Equal(t, ButtonTypePlain, template.Buttons.Items[1].ButtonType()) + assert.Equal(t, "Yes", template.Buttons.Items[1].(*PlainButton).Label) + + input = `{"footer": "Scooter"}` + template = Template{} + assert.NoError(t, json.Unmarshal([]byte(input), &template)) + assert.Nil(t, template.Header) + assert.Empty(t, template.Buttons) } diff --git a/v1/template_type.go b/v1/template_type.go new file mode 100644 index 0000000..bdbd4b4 --- /dev/null +++ b/v1/template_type.go @@ -0,0 +1,53 @@ +package v1 + +import ( + "bytes" + "errors" +) + +type TemplateType uint8 + +const ( + TemplateTypeText TemplateType = iota + 1 + TemplateTypeMedia +) + +var TypeMap = [][]byte{ + TemplateTypeText: []byte("text"), + TemplateTypeMedia: []byte("media"), +} + +var ErrUnknownTypeValue = errors.New("unknown TemplateType") + +func (e TemplateType) MarshalText() (text []byte, err error) { + if e.isValid() { + return TypeMap[e], nil + } + + return nil, ErrUnknownTypeValue +} + +func (e TemplateType) String() string { + if e.isValid() { + return string(TypeMap[e]) + } + + panic(ErrUnknownTypeValue) +} + +func (e *TemplateType) UnmarshalText(text []byte) error { + for f, v := range TypeMap { + if !bytes.Equal(text, v) { + continue + } + + *e = TemplateType(f) + return nil + } + + return ErrUnknownTypeValue +} + +func (e TemplateType) isValid() bool { + return int(e) < len(TypeMap) +} diff --git a/v1/types.go b/v1/types.go index 567bd77..408f17e 100644 --- a/v1/types.go +++ b/v1/types.go @@ -557,15 +557,25 @@ type TransportRequestMeta struct { Timestamp int64 `json:"timestamp"` } +type UpdateTemplateRequest struct { + Name string `json:"name"` + Template []TemplateItem `json:"template,omitempty"` + Body string `json:"body"` + Lang string `json:"lang,omitempty"` + Category string `json:"category,omitempty"` + Example *TemplateExample `json:"example,omitempty"` + VerificationStatus TemplateVerificationStatus `json:"verification_status"` + RejectionReason TemplateRejectionReason `json:"rejection_reason,omitempty"` + Header *TemplateHeader `json:"header,omitempty"` + Footer string `json:"footer,omitempty"` + Buttons *TemplateButtons `json:"buttons,omitempty"` +} + type ActivateTemplateRequest struct { - Code string `binding:"required,min=1,max=512" json:"code"` - Name string `binding:"required,min=1,max=512" json:"name"` - Type string `binding:"required" json:"type"` - Template []TemplateItem `json:"template"` - Lang string `json:"lang,omitempty"` - Category string `json:"category,omitempty"` - RejectionReason string `json:"rejection_reason,omitempty"` - VerificationStatus string `json:"verification_status,omitempty"` + UpdateTemplateRequest + + Code string `json:"code"` + Type TemplateType `json:"type"` } var ErrInvalidOriginator = errors.New("invalid originator") @@ -646,14 +656,6 @@ type HeaderParams struct { DocumentURL string `json:"documentUrl,omitempty"` } -const ( - QuickReplyButton ButtonType = "QUICK_REPLY" - PhoneNumberButton ButtonType = "PHONE_NUMBER" - URLButton ButtonType = "URL" -) - -type ButtonType string - type ButtonParam struct { ButtonType ButtonType `json:"type"` Text string `json:"text,omitempty"` @@ -661,13 +663,14 @@ type ButtonParam struct { } type TemplateContent struct { - Name string `json:"name"` - Lang string `json:"lang"` - Category string `json:"category"` - Body string `json:"body"` - Example struct { - Body []string `json:"body"` - } `json:"example"` + Name string `json:"name"` + Lang string `json:"lang"` + Category string `json:"category"` + Body string `json:"body"` + Header *TemplateHeader `json:"header,omitempty"` + Footer string `json:"footer,omitempty"` + Buttons *TemplateButtons `json:"buttons,omitempty"` + Example *TemplateExample `json:"example,omitempty"` } type TemplateCreateWebhookData struct { @@ -700,3 +703,12 @@ const ( TemplateStatusRejected TemplateVerificationStatus = "rejected" TemplateStatusNew TemplateVerificationStatus = "new" ) + +type TemplateRejectionReason string + +const ( + ReasonAbusiveContent TemplateRejectionReason = "abusive_content" + ReasonIncorrectCategory TemplateRejectionReason = "incorrect_category" + ReasonInvalidFormat TemplateRejectionReason = "invalid_format" + ReasonScam TemplateRejectionReason = "scam" +)