ruby-whatsapp 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +54 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +15 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +643 -0
- data/Rakefile +12 -0
- data/lib/ruby/whatsapp/client.rb +82 -0
- data/lib/ruby/whatsapp/configuration.rb +79 -0
- data/lib/ruby/whatsapp/instrumentation.rb +34 -0
- data/lib/ruby/whatsapp/media.rb +134 -0
- data/lib/ruby/whatsapp/message_templates/button/base.rb +26 -0
- data/lib/ruby/whatsapp/message_templates/button/copy_code.rb +59 -0
- data/lib/ruby/whatsapp/message_templates/button/otp/supported_app.rb +44 -0
- data/lib/ruby/whatsapp/message_templates/button/otp.rb +138 -0
- data/lib/ruby/whatsapp/message_templates/button/phone_number.rb +45 -0
- data/lib/ruby/whatsapp/message_templates/button/quick_reply.rb +38 -0
- data/lib/ruby/whatsapp/message_templates/button/url.rb +110 -0
- data/lib/ruby/whatsapp/message_templates/button.rb +56 -0
- data/lib/ruby/whatsapp/message_templates/categories.rb +47 -0
- data/lib/ruby/whatsapp/message_templates/component/base.rb +112 -0
- data/lib/ruby/whatsapp/message_templates/component/body.rb +139 -0
- data/lib/ruby/whatsapp/message_templates/component/buttons.rb +95 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel/card.rb +100 -0
- data/lib/ruby/whatsapp/message_templates/component/carousel.rb +71 -0
- data/lib/ruby/whatsapp/message_templates/component/footer.rb +99 -0
- data/lib/ruby/whatsapp/message_templates/component/header.rb +188 -0
- data/lib/ruby/whatsapp/message_templates/component/limited_time_offer.rb +57 -0
- data/lib/ruby/whatsapp/message_templates/component.rb +62 -0
- data/lib/ruby/whatsapp/message_templates/component_set.rb +221 -0
- data/lib/ruby/whatsapp/message_templates/example.rb +148 -0
- data/lib/ruby/whatsapp/message_templates/library_template/body_inputs.rb +81 -0
- data/lib/ruby/whatsapp/message_templates/library_template/button_inputs.rb +146 -0
- data/lib/ruby/whatsapp/message_templates/library_template.rb +114 -0
- data/lib/ruby/whatsapp/message_templates/parameter_formats.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/placeholders.rb +86 -0
- data/lib/ruby/whatsapp/message_templates/response/collection.rb +74 -0
- data/lib/ruby/whatsapp/message_templates/response/created.rb +65 -0
- data/lib/ruby/whatsapp/message_templates/response/node.rb +152 -0
- data/lib/ruby/whatsapp/message_templates/response/paging.rb +39 -0
- data/lib/ruby/whatsapp/message_templates/response/quality_score.rb +49 -0
- data/lib/ruby/whatsapp/message_templates/response/summary.rb +68 -0
- data/lib/ruby/whatsapp/message_templates/response.rb +22 -0
- data/lib/ruby/whatsapp/message_templates/statuses.rb +66 -0
- data/lib/ruby/whatsapp/message_templates/template.rb +180 -0
- data/lib/ruby/whatsapp/message_templates/value_object.rb +62 -0
- data/lib/ruby/whatsapp/message_templates.rb +283 -0
- data/lib/ruby/whatsapp/messages/address.rb +103 -0
- data/lib/ruby/whatsapp/messages/audio.rb +64 -0
- data/lib/ruby/whatsapp/messages/base.rb +46 -0
- data/lib/ruby/whatsapp/messages/contacts/address.rb +84 -0
- data/lib/ruby/whatsapp/messages/contacts/contact.rb +78 -0
- data/lib/ruby/whatsapp/messages/contacts/email.rb +49 -0
- data/lib/ruby/whatsapp/messages/contacts/name.rb +68 -0
- data/lib/ruby/whatsapp/messages/contacts/org.rb +41 -0
- data/lib/ruby/whatsapp/messages/contacts/phone.rb +59 -0
- data/lib/ruby/whatsapp/messages/contacts/url.rb +51 -0
- data/lib/ruby/whatsapp/messages/contacts.rb +37 -0
- data/lib/ruby/whatsapp/messages/document.rb +79 -0
- data/lib/ruby/whatsapp/messages/image.rb +72 -0
- data/lib/ruby/whatsapp/messages/interactive/base.rb +15 -0
- data/lib/ruby/whatsapp/messages/interactive/body.rb +39 -0
- data/lib/ruby/whatsapp/messages/interactive/footer.rb +40 -0
- data/lib/ruby/whatsapp/messages/interactive/header.rb +102 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section/row.rb +66 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons/section.rb +64 -0
- data/lib/ruby/whatsapp/messages/interactive/list_buttons.rb +63 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button/quick_reply.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card/button.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel/card.rb +78 -0
- data/lib/ruby/whatsapp/messages/interactive/media_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel/card.rb +55 -0
- data/lib/ruby/whatsapp/messages/interactive/product_carousel.rb +50 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons/button.rb +59 -0
- data/lib/ruby/whatsapp/messages/interactive/reply_buttons.rb +47 -0
- data/lib/ruby/whatsapp/messages/interactive/url_button.rb +68 -0
- data/lib/ruby/whatsapp/messages/interactive.rb +112 -0
- data/lib/ruby/whatsapp/messages/location.rb +67 -0
- data/lib/ruby/whatsapp/messages/location_request.rb +41 -0
- data/lib/ruby/whatsapp/messages/mark_message_as_read.rb +42 -0
- data/lib/ruby/whatsapp/messages/reaction.rb +61 -0
- data/lib/ruby/whatsapp/messages/response/contacts.rb +36 -0
- data/lib/ruby/whatsapp/messages/response/messages.rb +27 -0
- data/lib/ruby/whatsapp/messages/response.rb +49 -0
- data/lib/ruby/whatsapp/messages/sticker.rb +62 -0
- data/lib/ruby/whatsapp/messages/template/component.rb +67 -0
- data/lib/ruby/whatsapp/messages/template/language.rb +55 -0
- data/lib/ruby/whatsapp/messages/template/parameter.rb +113 -0
- data/lib/ruby/whatsapp/messages/template.rb +62 -0
- data/lib/ruby/whatsapp/messages/text.rb +43 -0
- data/lib/ruby/whatsapp/messages/video.rb +68 -0
- data/lib/ruby/whatsapp/messages.rb +116 -0
- data/lib/ruby/whatsapp/railtie.rb +14 -0
- data/lib/ruby/whatsapp/request_error.rb +5 -0
- data/lib/ruby/whatsapp/response_handling.rb +44 -0
- data/lib/ruby/whatsapp/utils/language_codes.rb +96 -0
- data/lib/ruby/whatsapp/version.rb +5 -0
- data/lib/ruby/whatsapp/webhook/account_alerts.rb +58 -0
- data/lib/ruby/whatsapp/webhook/account_review_update.rb +27 -0
- data/lib/ruby/whatsapp/webhook/account_update/ban_info.rb +33 -0
- data/lib/ruby/whatsapp/webhook/account_update.rb +46 -0
- data/lib/ruby/whatsapp/webhook/automatic_events.rb +43 -0
- data/lib/ruby/whatsapp/webhook/business_capability_update.rb +36 -0
- data/lib/ruby/whatsapp/webhook/change.rb +61 -0
- data/lib/ruby/whatsapp/webhook/contact.rb +36 -0
- data/lib/ruby/whatsapp/webhook/entry.rb +35 -0
- data/lib/ruby/whatsapp/webhook/error.rb +47 -0
- data/lib/ruby/whatsapp/webhook/history.rb +54 -0
- data/lib/ruby/whatsapp/webhook/installer.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/audio.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/base.rb +61 -0
- data/lib/ruby/whatsapp/webhook/message/button.rb +33 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/address.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/contact.rb +68 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/email.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/name.rb +62 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/org.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/phone.rb +40 -0
- data/lib/ruby/whatsapp/webhook/message/contacts/url.rb +35 -0
- data/lib/ruby/whatsapp/webhook/message/contacts.rb +31 -0
- data/lib/ruby/whatsapp/webhook/message/context.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/document.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/image.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message/interactive.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message/location.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/media.rb +54 -0
- data/lib/ruby/whatsapp/webhook/message/order/product_item.rb +50 -0
- data/lib/ruby/whatsapp/webhook/message/order.rb +43 -0
- data/lib/ruby/whatsapp/webhook/message/reaction.rb +37 -0
- data/lib/ruby/whatsapp/webhook/message/referral.rb +80 -0
- data/lib/ruby/whatsapp/webhook/message/sticker.rb +32 -0
- data/lib/ruby/whatsapp/webhook/message/system.rb +49 -0
- data/lib/ruby/whatsapp/webhook/message/text.rb +28 -0
- data/lib/ruby/whatsapp/webhook/message/unknown.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message/video.rb +18 -0
- data/lib/ruby/whatsapp/webhook/message.rb +39 -0
- data/lib/ruby/whatsapp/webhook/message_template_components_update.rb +53 -0
- data/lib/ruby/whatsapp/webhook/message_template_quality_update.rb +59 -0
- data/lib/ruby/whatsapp/webhook/message_template_status_update.rb +54 -0
- data/lib/ruby/whatsapp/webhook/messages.rb +55 -0
- data/lib/ruby/whatsapp/webhook/metadata.rb +35 -0
- data/lib/ruby/whatsapp/webhook/notification.rb +36 -0
- data/lib/ruby/whatsapp/webhook/partner_solutions.rb +36 -0
- data/lib/ruby/whatsapp/webhook/payment_configuration_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_name_update.rb +51 -0
- data/lib/ruby/whatsapp/webhook/phone_number_quality_update.rb +45 -0
- data/lib/ruby/whatsapp/webhook/security.rb +41 -0
- data/lib/ruby/whatsapp/webhook/signature.rb +28 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync/state_sync.rb +38 -0
- data/lib/ruby/whatsapp/webhook/smb_app_state_sync.rb +33 -0
- data/lib/ruby/whatsapp/webhook/smb_message_echoes.rb +48 -0
- data/lib/ruby/whatsapp/webhook/status/conversation.rb +42 -0
- data/lib/ruby/whatsapp/webhook/status/pricing.rb +38 -0
- data/lib/ruby/whatsapp/webhook/status.rb +65 -0
- data/lib/ruby/whatsapp/webhook/template_category_update.rb +64 -0
- data/lib/ruby/whatsapp/webhook/templates/webhooks_controller.rb.tt +31 -0
- data/lib/ruby/whatsapp/webhook/unknown_field.rb +37 -0
- data/lib/ruby/whatsapp/webhook/user_preferences.rb +47 -0
- data/lib/ruby/whatsapp/webhook/verification.rb +27 -0
- data/lib/ruby/whatsapp/webhook.rb +8 -0
- data/lib/ruby/whatsapp.rb +49 -0
- data/lib/tasks/whatsapp.rake +10 -0
- data/sig/ruby/whatsapp.rbs +6 -0
- metadata +267 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# A horizontally scrollable set of 2 to 10 cards, each with its own media header
|
|
7
|
+
# and buttons. Marketing templates only, which {Template} enforces.
|
|
8
|
+
#
|
|
9
|
+
# The card count is fixed at creation: an approved carousel template must be sent
|
|
10
|
+
# with exactly the number of cards it was created with.
|
|
11
|
+
#
|
|
12
|
+
# Every card must share the same structure — same header format, same button
|
|
13
|
+
# types, and body text either on all of them or none. Meta's stated reason for
|
|
14
|
+
# the body rule is keeping card heights consistent. {Card#signature} is what makes
|
|
15
|
+
# this checkable.
|
|
16
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/carousel-templates
|
|
17
|
+
class Carousel < Base
|
|
18
|
+
module Defaults
|
|
19
|
+
TYPE = "CAROUSEL"
|
|
20
|
+
MIN_CARDS = 2
|
|
21
|
+
MAX_CARDS = 10
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @!attribute [rw] cards
|
|
25
|
+
# @return [Array<Carousel::Card>]
|
|
26
|
+
attr_accessor :cards
|
|
27
|
+
|
|
28
|
+
validates :cards, presence: true,
|
|
29
|
+
length: { minimum: Defaults::MIN_CARDS, maximum: Defaults::MAX_CARDS }
|
|
30
|
+
validate :validate_identical_structure
|
|
31
|
+
|
|
32
|
+
# @param cards [Array<Hash, Carousel::Card>] Between 2 and 10 cards.
|
|
33
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
34
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
35
|
+
def initialize(cards: [], **)
|
|
36
|
+
super(**)
|
|
37
|
+
|
|
38
|
+
@cards = build_cards(cards)
|
|
39
|
+
|
|
40
|
+
validate!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Hash] The serialized component.
|
|
44
|
+
def serialize
|
|
45
|
+
{ type: Defaults::TYPE, cards: cards.map(&:serialize) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# @return [Array<Carousel::Card>]
|
|
51
|
+
# @raise [ActiveModel::ValidationError] if a card is invalid.
|
|
52
|
+
def build_cards(list)
|
|
53
|
+
Array(list).map do |card|
|
|
54
|
+
card.is_a?(Card) ? card : Card.new(**card, parameter_format:)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [void]
|
|
59
|
+
def validate_identical_structure
|
|
60
|
+
return if cards.size < 2
|
|
61
|
+
|
|
62
|
+
signatures = cards.map(&:signature).uniq
|
|
63
|
+
return if signatures.size == 1
|
|
64
|
+
|
|
65
|
+
errors.add(:cards, "must all have an identical structure: same header format, same button types, " \
|
|
66
|
+
"and body text on either every card or none")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# The optional small-print line below the body. At most one per template.
|
|
7
|
+
#
|
|
8
|
+
# Two mutually exclusive shapes, mirroring {Body}:
|
|
9
|
+
#
|
|
10
|
+
# standard — `text` (60 chars, and no placeholders at all)
|
|
11
|
+
# authentication — `code_expiration_minutes`, which renders Meta's own
|
|
12
|
+
# localised "This code expires in N minutes." Omit the footer
|
|
13
|
+
# entirely to omit that notice.
|
|
14
|
+
#
|
|
15
|
+
# Forbidden altogether in limited-time-offer templates, which {Template} enforces.
|
|
16
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
17
|
+
class Footer < Base
|
|
18
|
+
module Defaults
|
|
19
|
+
TYPE = "FOOTER"
|
|
20
|
+
MAX_TEXT_LENGTH = 60
|
|
21
|
+
CODE_EXPIRATION_RANGE = (1..90)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @!attribute [rw] text
|
|
25
|
+
# @return [String, nil]
|
|
26
|
+
attr_accessor :text
|
|
27
|
+
|
|
28
|
+
# @!attribute [rw] code_expiration_minutes
|
|
29
|
+
# @return [Integer, nil] Authentication templates only.
|
|
30
|
+
attr_accessor :code_expiration_minutes
|
|
31
|
+
|
|
32
|
+
validate :validate_shape
|
|
33
|
+
validate :validate_text
|
|
34
|
+
validate :validate_code_expiration
|
|
35
|
+
|
|
36
|
+
# @param text [String, nil] The footer text (max 60 characters, no placeholders).
|
|
37
|
+
# @param code_expiration_minutes [Integer, nil] Authentication templates only;
|
|
38
|
+
# must be within 1..90. Mutually exclusive with `text`.
|
|
39
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
40
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
41
|
+
def initialize(text: nil, code_expiration_minutes: nil, **)
|
|
42
|
+
super(**)
|
|
43
|
+
|
|
44
|
+
@text = text
|
|
45
|
+
@code_expiration_minutes = code_expiration_minutes
|
|
46
|
+
|
|
47
|
+
validate!
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] Whether this is the expiry-based authentication shape.
|
|
51
|
+
def authentication?
|
|
52
|
+
!code_expiration_minutes.nil?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Hash] The serialized component.
|
|
56
|
+
def serialize
|
|
57
|
+
return { type: Defaults::TYPE, code_expiration_minutes: } if authentication?
|
|
58
|
+
|
|
59
|
+
{ type: Defaults::TYPE, text: }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
# @return [void]
|
|
65
|
+
def validate_shape
|
|
66
|
+
if authentication? && !blank_value?(text)
|
|
67
|
+
return errors.add(:text, "cannot be combined with code_expiration_minutes")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
return unless blank_value?(text) && !authentication?
|
|
71
|
+
|
|
72
|
+
errors.add(:base, "requires either text or code_expiration_minutes")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [void]
|
|
76
|
+
def validate_text
|
|
77
|
+
return if authentication? || blank_value?(text)
|
|
78
|
+
|
|
79
|
+
if text.length > Defaults::MAX_TEXT_LENGTH
|
|
80
|
+
errors.add(:text, "is too long (maximum is #{Defaults::MAX_TEXT_LENGTH} characters)")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
return if Placeholders.count(text).zero?
|
|
84
|
+
|
|
85
|
+
errors.add(:text, "does not support placeholders")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# @return [void]
|
|
89
|
+
def validate_code_expiration
|
|
90
|
+
return unless authentication?
|
|
91
|
+
return if code_expiration_minutes.is_a?(Integer) &&
|
|
92
|
+
Defaults::CODE_EXPIRATION_RANGE.cover?(code_expiration_minutes)
|
|
93
|
+
|
|
94
|
+
errors.add(:code_expiration_minutes, "must be in #{Defaults::CODE_EXPIRATION_RANGE}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# The optional component above the body: a line of text, a media attachment, or
|
|
7
|
+
# a location pin. At most one per template.
|
|
8
|
+
#
|
|
9
|
+
# Three shapes share one class because they all serialize to the same
|
|
10
|
+
# `{type:, format:, ...}` envelope and differ only in which fields are legal.
|
|
11
|
+
# The `format` decides:
|
|
12
|
+
#
|
|
13
|
+
# TEXT — `text` (60 chars, at most ONE placeholder) plus an `example`
|
|
14
|
+
# media — `header_handle` from the Resumable Upload API; no text
|
|
15
|
+
# LOCATION — nothing else; coordinates are supplied when the message is sent
|
|
16
|
+
#
|
|
17
|
+
# This mirrors the conditional-validation approach already used by
|
|
18
|
+
# {Whatsapp::Messages::Video} for its id-or-link choice.
|
|
19
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
20
|
+
class Header < Base
|
|
21
|
+
module Defaults
|
|
22
|
+
TYPE = "HEADER"
|
|
23
|
+
MAX_TEXT_LENGTH = 60
|
|
24
|
+
MAX_VARIABLES = 1
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
module Formats
|
|
28
|
+
TEXT = "TEXT"
|
|
29
|
+
IMAGE = "IMAGE"
|
|
30
|
+
VIDEO = "VIDEO"
|
|
31
|
+
DOCUMENT = "DOCUMENT"
|
|
32
|
+
GIF = "GIF"
|
|
33
|
+
LOCATION = "LOCATION"
|
|
34
|
+
|
|
35
|
+
# Formats that carry an uploaded asset handle rather than text.
|
|
36
|
+
MEDIA = [IMAGE, VIDEO, DOCUMENT, GIF].freeze
|
|
37
|
+
ALL = [TEXT, *MEDIA, LOCATION].freeze
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Characters Meta rejects in a header, which does not support Markdown.
|
|
41
|
+
MARKDOWN_CHARACTERS = ["*", "_", "~", "`"].freeze
|
|
42
|
+
|
|
43
|
+
# @!attribute [rw] format
|
|
44
|
+
# @return [String] one of {Formats::ALL}.
|
|
45
|
+
attr_accessor :format
|
|
46
|
+
|
|
47
|
+
# @!attribute [rw] text
|
|
48
|
+
# @return [String, nil] TEXT headers only.
|
|
49
|
+
attr_accessor :text
|
|
50
|
+
|
|
51
|
+
# @!attribute [rw] example
|
|
52
|
+
# @return [Hash, nil] The serialized example payload.
|
|
53
|
+
attr_accessor :example
|
|
54
|
+
|
|
55
|
+
# @!attribute [rw] header_handle
|
|
56
|
+
# @return [String, Array<String>, nil] Media headers only.
|
|
57
|
+
attr_accessor :header_handle
|
|
58
|
+
|
|
59
|
+
validates :format, presence: true, inclusion: { in: Formats::ALL }
|
|
60
|
+
validate :validate_text_shape
|
|
61
|
+
validate :validate_media_shape
|
|
62
|
+
validate :validate_location_shape
|
|
63
|
+
|
|
64
|
+
# @param format [String, Symbol] one of {Formats::ALL}, any casing.
|
|
65
|
+
# @param text [String, nil] The header text, for the TEXT format.
|
|
66
|
+
# @param example [Array, Hash, String, nil] A sample value for the text
|
|
67
|
+
# placeholder, or a pre-built example payload.
|
|
68
|
+
# @param header_handle [String, Array<String>, nil] The uploaded asset handle,
|
|
69
|
+
# for media formats. A convenience alternative to
|
|
70
|
+
# `example: { header_handle: [...] }`.
|
|
71
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
72
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
73
|
+
def initialize(format:, text: nil, example: nil, header_handle: nil, **)
|
|
74
|
+
super(**)
|
|
75
|
+
|
|
76
|
+
@format = normalize_format(format)
|
|
77
|
+
@text = text
|
|
78
|
+
@header_handle = header_handle
|
|
79
|
+
@example = build_example(example)
|
|
80
|
+
|
|
81
|
+
validate!
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Boolean] Whether this header carries an uploaded asset.
|
|
85
|
+
def media?
|
|
86
|
+
Formats::MEDIA.include?(format)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Hash] The serialized component.
|
|
90
|
+
def serialize
|
|
91
|
+
{
|
|
92
|
+
type: Defaults::TYPE,
|
|
93
|
+
format:,
|
|
94
|
+
text:,
|
|
95
|
+
example:,
|
|
96
|
+
}.compact
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
# Meta's docs are inconsistent about enum casing; accept either and emit
|
|
102
|
+
# uppercase, which is what responses come back as.
|
|
103
|
+
# @return [String, nil]
|
|
104
|
+
def normalize_format(value)
|
|
105
|
+
return if value.nil?
|
|
106
|
+
|
|
107
|
+
candidate = value.to_s.upcase
|
|
108
|
+
Formats::ALL.include?(candidate) ? candidate : value.to_s
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Builds the example payload from whichever input the caller used.
|
|
112
|
+
# @return [Hash, nil]
|
|
113
|
+
def build_example(value)
|
|
114
|
+
return { header_handle: Array(header_handle) } unless blank_value?(header_handle)
|
|
115
|
+
|
|
116
|
+
build_example_payload(role: :header, values: value)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [void]
|
|
120
|
+
def validate_text_shape
|
|
121
|
+
return unless format == Formats::TEXT
|
|
122
|
+
|
|
123
|
+
reject_unsupported(:header_handle, "cannot be set on a #{Formats::TEXT} header")
|
|
124
|
+
validate_text_presence_and_length
|
|
125
|
+
validate_text_variables
|
|
126
|
+
validate_placeholder_style(text)
|
|
127
|
+
validate_no_markdown
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [void]
|
|
131
|
+
def validate_text_presence_and_length
|
|
132
|
+
return errors.add(:text, "can't be blank") if blank_value?(text)
|
|
133
|
+
return if text.length <= Defaults::MAX_TEXT_LENGTH
|
|
134
|
+
|
|
135
|
+
errors.add(:text, "is too long (maximum is #{Defaults::MAX_TEXT_LENGTH} characters)")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# A text header supports at most one placeholder, and needs an example for it.
|
|
139
|
+
# @return [void]
|
|
140
|
+
def validate_text_variables
|
|
141
|
+
count = Placeholders.count(text)
|
|
142
|
+
return if count.zero?
|
|
143
|
+
|
|
144
|
+
if count > Defaults::MAX_VARIABLES
|
|
145
|
+
errors.add(:text, "supports at most one variable, found #{count}")
|
|
146
|
+
return
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
errors.add(:example, "can't be blank when the header text contains a variable") if blank_value?(example)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Checks only the literal text, with placeholders removed first.
|
|
153
|
+
#
|
|
154
|
+
# Named placeholders are lowercase-and-underscores by definition, so scanning
|
|
155
|
+
# the raw text would reject `{{sale_start_date}}` for containing an underscore
|
|
156
|
+
# and make named parameters unusable in a text header.
|
|
157
|
+
# @return [void]
|
|
158
|
+
def validate_no_markdown
|
|
159
|
+
return if blank_value?(text)
|
|
160
|
+
|
|
161
|
+
literal = text.gsub(Placeholders::PATTERN, "")
|
|
162
|
+
found = MARKDOWN_CHARACTERS.select { |char| literal.include?(char) }
|
|
163
|
+
return if found.empty?
|
|
164
|
+
|
|
165
|
+
errors.add(:text, "cannot contain Markdown characters (found #{found.join(' ')})")
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# @return [void]
|
|
169
|
+
def validate_media_shape
|
|
170
|
+
return unless media?
|
|
171
|
+
|
|
172
|
+
reject_unsupported(:text, "cannot be set on a #{format} header")
|
|
173
|
+
return unless blank_value?(example)
|
|
174
|
+
|
|
175
|
+
errors.add(:header_handle, "can't be blank for a #{format} header")
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# @return [void]
|
|
179
|
+
def validate_location_shape
|
|
180
|
+
return unless format == Formats::LOCATION
|
|
181
|
+
|
|
182
|
+
reject_unsupported(:text, "cannot be set on a #{Formats::LOCATION} header")
|
|
183
|
+
reject_unsupported(:header_handle, "cannot be set on a #{Formats::LOCATION} header")
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
class Component
|
|
6
|
+
# The offer banner that turns a marketing template into a limited-time offer,
|
|
7
|
+
# optionally with a live countdown timer.
|
|
8
|
+
#
|
|
9
|
+
# Its presence changes the rules for the rest of the template: a footer becomes
|
|
10
|
+
# forbidden, the body drops to 600 characters, the header narrows to image or
|
|
11
|
+
# video, and the category must be MARKETING. Those are all cross-component rules,
|
|
12
|
+
# so {Template} enforces them.
|
|
13
|
+
#
|
|
14
|
+
# The expiry timestamp itself is *not* set here — it is supplied per message when
|
|
15
|
+
# sending, as `expiration_time_ms`. This component only declares that the
|
|
16
|
+
# template has one.
|
|
17
|
+
# Source: https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/marketing-templates/limited-time-offer-templates
|
|
18
|
+
class LimitedTimeOffer < Base
|
|
19
|
+
module Defaults
|
|
20
|
+
TYPE = "LIMITED_TIME_OFFER"
|
|
21
|
+
MAX_TEXT_LENGTH = 16
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @!attribute [rw] text
|
|
25
|
+
# @return [String]
|
|
26
|
+
attr_accessor :text
|
|
27
|
+
|
|
28
|
+
# @!attribute [rw] has_expiration
|
|
29
|
+
# @return [Boolean, nil]
|
|
30
|
+
attr_accessor :has_expiration
|
|
31
|
+
|
|
32
|
+
validates :text, presence: true, length: { maximum: Defaults::MAX_TEXT_LENGTH }
|
|
33
|
+
|
|
34
|
+
# @param text [String] The offer label (max 16 characters).
|
|
35
|
+
# @param has_expiration [Boolean, nil] Whether to show a countdown timer.
|
|
36
|
+
# @param kwargs [Hash] Forwarded to {Base} (`parameter_format`).
|
|
37
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
38
|
+
def initialize(text:, has_expiration: nil, **)
|
|
39
|
+
super(**)
|
|
40
|
+
|
|
41
|
+
@text = text
|
|
42
|
+
@has_expiration = has_expiration
|
|
43
|
+
|
|
44
|
+
validate!
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Hash] The serialized component.
|
|
48
|
+
def serialize
|
|
49
|
+
{
|
|
50
|
+
type: Defaults::TYPE,
|
|
51
|
+
limited_time_offer: { text:, has_expiration: }.compact,
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# Resolves a component kind to its implementing class.
|
|
6
|
+
#
|
|
7
|
+
# Resolution goes through the frozen {TYPES} whitelist — never `const_get` on
|
|
8
|
+
# caller input — so an unknown or hostile `type` can only raise, never resolve an
|
|
9
|
+
# arbitrary constant. Mirrors {Whatsapp::Messages::KINDS} and {Button::TYPES}.
|
|
10
|
+
#
|
|
11
|
+
# Only the component types with a published schema are registered. Meta's Graph API
|
|
12
|
+
# enum also lists GREETING, ALBUM, CALL_PERMISSION_REQUEST, TAP_TARGET_CONFIGURATION
|
|
13
|
+
# and ATTACHMENT, but publishes no field reference for them, so they are
|
|
14
|
+
# deliberately absent rather than guessed.
|
|
15
|
+
#
|
|
16
|
+
# Note that a component validates only *itself* here. Rules that span components —
|
|
17
|
+
# a footer being forbidden alongside a limited-time offer, a carousel implying the
|
|
18
|
+
# MARKETING category — live on {Template}, which is the only object that can see
|
|
19
|
+
# all the siblings.
|
|
20
|
+
# Source: https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates/components/
|
|
21
|
+
class Component
|
|
22
|
+
TYPES = {
|
|
23
|
+
header: Header,
|
|
24
|
+
body: Body,
|
|
25
|
+
footer: Footer,
|
|
26
|
+
buttons: Buttons,
|
|
27
|
+
carousel: Carousel,
|
|
28
|
+
limited_time_offer: LimitedTimeOffer,
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# @param type [Symbol, String] The component kind, in either casing
|
|
33
|
+
# (`:body`, `"body"`, or Meta's `"BODY"`).
|
|
34
|
+
# @param attrs [Hash] Forwarded to the resolved component class, including the
|
|
35
|
+
# owning template's `parameter_format`.
|
|
36
|
+
# @return [Component::Base]
|
|
37
|
+
# @raise [TemplateError] if the type is unknown.
|
|
38
|
+
# @raise [ActiveModel::ValidationError] if the component is invalid.
|
|
39
|
+
def build(type:, **attrs)
|
|
40
|
+
klass = TYPES.fetch(normalize(type)) do
|
|
41
|
+
raise TemplateError,
|
|
42
|
+
"Unknown component type: #{type.inspect}. Known types: #{TYPES.keys.join(', ')}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
klass.new(**attrs)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Builds and serializes in one step.
|
|
49
|
+
# @return [Hash]
|
|
50
|
+
def serialize(type:, **attrs)
|
|
51
|
+
build(type:, **attrs).serialize
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param type [Symbol, String]
|
|
55
|
+
# @return [Symbol] The registry key for the given type.
|
|
56
|
+
def normalize(type)
|
|
57
|
+
type.to_s.downcase.to_sym
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Whatsapp
|
|
4
|
+
class MessageTemplates
|
|
5
|
+
# A template's components, plus every rule that spans more than one of them.
|
|
6
|
+
#
|
|
7
|
+
# This is the other half of the split described in {Component}: a component class
|
|
8
|
+
# validates itself, and everything about how components *relate* lives here. That
|
|
9
|
+
# keeps rules like "a footer is forbidden alongside a limited-time offer" in one
|
|
10
|
+
# place instead of duplicated across the sibling classes that would each only see
|
|
11
|
+
# half the picture.
|
|
12
|
+
#
|
|
13
|
+
# It is a separate object from {Template} because editing a template replaces its
|
|
14
|
+
# components wholesale without re-supplying a name or language, so `update` needs
|
|
15
|
+
# exactly these checks and none of the template-identity ones.
|
|
16
|
+
#
|
|
17
|
+
# `category` is optional for that reason: when it is absent — as when editing — the
|
|
18
|
+
# category-dependent rules are skipped rather than guessed at.
|
|
19
|
+
class ComponentSet
|
|
20
|
+
include ValueObject
|
|
21
|
+
|
|
22
|
+
module Defaults
|
|
23
|
+
# A limited-time-offer template caps the body well below the usual 1024.
|
|
24
|
+
LIMITED_TIME_OFFER_MAX_BODY_LENGTH = 600
|
|
25
|
+
# ...and its copy-code button below the usual 20.
|
|
26
|
+
LIMITED_TIME_OFFER_MAX_COPY_CODE_LENGTH = 15
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Wire types that may appear at most once in a template.
|
|
30
|
+
SINGLETON_TYPES = [
|
|
31
|
+
Component::Header::Defaults::TYPE,
|
|
32
|
+
Component::Footer::Defaults::TYPE,
|
|
33
|
+
Component::Buttons::Defaults::TYPE,
|
|
34
|
+
Component::Carousel::Defaults::TYPE,
|
|
35
|
+
Component::LimitedTimeOffer::Defaults::TYPE,
|
|
36
|
+
].freeze
|
|
37
|
+
|
|
38
|
+
# Components restricted to a subset of categories, and which ones.
|
|
39
|
+
CATEGORY_RESTRICTED_TYPES = {
|
|
40
|
+
Component::Carousel::Defaults::TYPE => [Categories::MARKETING].freeze,
|
|
41
|
+
Component::LimitedTimeOffer::Defaults::TYPE => [Categories::MARKETING].freeze,
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Header formats a limited-time-offer template may use.
|
|
45
|
+
LIMITED_TIME_OFFER_HEADER_FORMATS = [
|
|
46
|
+
Component::Header::Formats::IMAGE,
|
|
47
|
+
Component::Header::Formats::VIDEO,
|
|
48
|
+
].freeze
|
|
49
|
+
|
|
50
|
+
# Categories that may use a location header.
|
|
51
|
+
LOCATION_HEADER_CATEGORIES = [Categories::UTILITY, Categories::MARKETING].freeze
|
|
52
|
+
|
|
53
|
+
# @!attribute [rw] components
|
|
54
|
+
# @return [Array<Component::Base>]
|
|
55
|
+
attr_accessor :components
|
|
56
|
+
|
|
57
|
+
# @!attribute [rw] category
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
attr_accessor :category
|
|
60
|
+
|
|
61
|
+
# @!attribute [rw] parameter_format
|
|
62
|
+
# @return [String]
|
|
63
|
+
attr_accessor :parameter_format
|
|
64
|
+
|
|
65
|
+
validates :components, presence: true
|
|
66
|
+
validate :validate_body_count
|
|
67
|
+
validate :validate_singletons
|
|
68
|
+
validate :validate_category_restrictions
|
|
69
|
+
validate :validate_location_header
|
|
70
|
+
validate :validate_limited_time_offer
|
|
71
|
+
|
|
72
|
+
# @param components [Array<Hash, Component::Base>] The template's components.
|
|
73
|
+
# @param category [String, Symbol, nil] The owning template's category. Omit to
|
|
74
|
+
# skip the category-dependent rules.
|
|
75
|
+
# @param parameter_format [String, Symbol] The placeholder style, threaded into
|
|
76
|
+
# every component.
|
|
77
|
+
# @raise [ActiveModel::ValidationError] if validation fails.
|
|
78
|
+
# @raise [TemplateError] if a component type is unknown.
|
|
79
|
+
def initialize(components:, category: nil, parameter_format: ParameterFormats::POSITIONAL)
|
|
80
|
+
@category = Categories.normalize(category)
|
|
81
|
+
@parameter_format = ParameterFormats.normalize(parameter_format)
|
|
82
|
+
@components = build_components(components)
|
|
83
|
+
|
|
84
|
+
validate!
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# @param api_type [String] A component wire type, e.g. `"BODY"`.
|
|
88
|
+
# @return [Component::Base, nil]
|
|
89
|
+
def find(api_type)
|
|
90
|
+
components.find { |component| component.api_type == api_type }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @return [Array<String>] The wire types present, in declared order.
|
|
94
|
+
def api_types
|
|
95
|
+
components.map(&:api_type)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# @return [Array<Hash>] The serialized components.
|
|
99
|
+
def serialize
|
|
100
|
+
components.map(&:serialize)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
# @return [Array<Component::Base>]
|
|
106
|
+
def build_components(list)
|
|
107
|
+
Array(list).map do |component|
|
|
108
|
+
next component if component.is_a?(Component::Base)
|
|
109
|
+
|
|
110
|
+
Component.build(**component, parameter_format:)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @return [void]
|
|
115
|
+
def validate_body_count
|
|
116
|
+
return if api_types.count(Component::Body::Defaults::TYPE) == 1
|
|
117
|
+
|
|
118
|
+
errors.add(:components, "requires exactly one BODY component")
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# @return [void]
|
|
122
|
+
def validate_singletons
|
|
123
|
+
SINGLETON_TYPES.each do |type|
|
|
124
|
+
next if api_types.count(type) <= 1
|
|
125
|
+
|
|
126
|
+
errors.add(:components, "allow at most one #{type} component")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @return [void]
|
|
131
|
+
def validate_category_restrictions
|
|
132
|
+
return if category.nil?
|
|
133
|
+
|
|
134
|
+
CATEGORY_RESTRICTED_TYPES.each do |type, allowed|
|
|
135
|
+
next unless api_types.include?(type)
|
|
136
|
+
next if allowed.include?(category)
|
|
137
|
+
|
|
138
|
+
errors.add(:components, "#{type} is only supported on #{allowed.join('/')} templates, not #{category}")
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# @return [void]
|
|
143
|
+
def validate_location_header
|
|
144
|
+
return if category.nil?
|
|
145
|
+
|
|
146
|
+
header = find(Component::Header::Defaults::TYPE)
|
|
147
|
+
return unless header&.format == Component::Header::Formats::LOCATION
|
|
148
|
+
return if LOCATION_HEADER_CATEGORIES.include?(category)
|
|
149
|
+
|
|
150
|
+
errors.add(:components,
|
|
151
|
+
"a LOCATION header is only supported on #{LOCATION_HEADER_CATEGORIES.join('/')} templates, not #{category}")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# A limited-time offer reshapes the rest of the template: no footer, a shorter
|
|
155
|
+
# body, a media-only header, and a copy-code button that must come first.
|
|
156
|
+
# @return [void]
|
|
157
|
+
def validate_limited_time_offer
|
|
158
|
+
return unless api_types.include?(Component::LimitedTimeOffer::Defaults::TYPE)
|
|
159
|
+
|
|
160
|
+
validate_offer_has_no_footer
|
|
161
|
+
validate_offer_body_length
|
|
162
|
+
validate_offer_header_format
|
|
163
|
+
validate_offer_buttons
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# @return [void]
|
|
167
|
+
def validate_offer_has_no_footer
|
|
168
|
+
return unless api_types.include?(Component::Footer::Defaults::TYPE)
|
|
169
|
+
|
|
170
|
+
errors.add(:components, "a FOOTER is not allowed in a LIMITED_TIME_OFFER template")
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# @return [void]
|
|
174
|
+
def validate_offer_body_length
|
|
175
|
+
text = find(Component::Body::Defaults::TYPE)&.text
|
|
176
|
+
return if text.nil? || text.length <= Defaults::LIMITED_TIME_OFFER_MAX_BODY_LENGTH
|
|
177
|
+
|
|
178
|
+
errors.add(:components,
|
|
179
|
+
"the BODY of a LIMITED_TIME_OFFER template is limited to " \
|
|
180
|
+
"#{Defaults::LIMITED_TIME_OFFER_MAX_BODY_LENGTH} characters")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# @return [void]
|
|
184
|
+
def validate_offer_header_format
|
|
185
|
+
header = find(Component::Header::Defaults::TYPE)
|
|
186
|
+
return if header.nil? || LIMITED_TIME_OFFER_HEADER_FORMATS.include?(header.format)
|
|
187
|
+
|
|
188
|
+
errors.add(:components,
|
|
189
|
+
"the header must be IMAGE or VIDEO in a LIMITED_TIME_OFFER template, got #{header.format}")
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# @return [void]
|
|
193
|
+
def validate_offer_buttons
|
|
194
|
+
buttons = find(Component::Buttons::Defaults::TYPE)&.buttons
|
|
195
|
+
return if buttons.nil?
|
|
196
|
+
|
|
197
|
+
copy_code = buttons.find { |button| button.is_a?(Button::CopyCode) }
|
|
198
|
+
return if copy_code.nil?
|
|
199
|
+
|
|
200
|
+
validate_offer_copy_code_position(buttons, copy_code)
|
|
201
|
+
validate_offer_copy_code_length(copy_code)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @return [void]
|
|
205
|
+
def validate_offer_copy_code_position(buttons, copy_code)
|
|
206
|
+
return if buttons.index(copy_code).zero?
|
|
207
|
+
|
|
208
|
+
errors.add(:components, "the copy-code button must be first in a LIMITED_TIME_OFFER template")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @return [void]
|
|
212
|
+
def validate_offer_copy_code_length(copy_code)
|
|
213
|
+
maximum = Defaults::LIMITED_TIME_OFFER_MAX_COPY_CODE_LENGTH
|
|
214
|
+
return if copy_code.example.to_s.length <= maximum
|
|
215
|
+
|
|
216
|
+
errors.add(:components,
|
|
217
|
+
"the copy-code example is limited to #{maximum} characters in a LIMITED_TIME_OFFER template")
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|