guesty-client 0.1.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/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +178 -0
- data/lib/guesty/access_tokens/upsert.rb +61 -0
- data/lib/guesty/api_error.rb +73 -0
- data/lib/guesty/client.rb +138 -0
- data/lib/guesty/config.rb +29 -0
- data/lib/guesty/conversations/attachments/index.rb +42 -0
- data/lib/guesty/conversations/index.rb +58 -0
- data/lib/guesty/conversations/messages/reply.rb +55 -0
- data/lib/guesty/conversations/posts/index.rb +121 -0
- data/lib/guesty/conversations/posts/since.rb +37 -0
- data/lib/guesty/conversations/search.rb +31 -0
- data/lib/guesty/conversations/show.rb +25 -0
- data/lib/guesty/conversations.rb +15 -0
- data/lib/guesty/error.rb +32 -0
- data/lib/guesty/guest_folio/invoice_items/index.rb +61 -0
- data/lib/guesty/guests/profiles/create.rb +25 -0
- data/lib/guesty/guests/profiles/show.rb +23 -0
- data/lib/guesty/guests/profiles/update.rb +36 -0
- data/lib/guesty/guests.rb +5 -0
- data/lib/guesty/homes/calendars/show.rb +134 -0
- data/lib/guesty/homes/search.rb +46 -0
- data/lib/guesty/memory_rate_limiter.rb +28 -0
- data/lib/guesty/memory_token_store.rb +33 -0
- data/lib/guesty/operation.rb +37 -0
- data/lib/guesty/rate_limiter_middleware.rb +23 -0
- data/lib/guesty/reservations/accommodation_fare/update.rb +44 -0
- data/lib/guesty/reservations/approve.rb +23 -0
- data/lib/guesty/reservations/create.rb +50 -0
- data/lib/guesty/reservations/custom_fields/update.rb +26 -0
- data/lib/guesty/reservations/dates/update.rb +41 -0
- data/lib/guesty/reservations/decline.rb +30 -0
- data/lib/guesty/reservations/flags/update.rb +33 -0
- data/lib/guesty/reservations/payments/create.rb +72 -0
- data/lib/guesty/reservations/pre_approve.rb +23 -0
- data/lib/guesty/reservations/recover.rb +27 -0
- data/lib/guesty/reservations/reviews/extractors/airbnb.rb +66 -0
- data/lib/guesty/reservations/reviews/extractors/base.rb +36 -0
- data/lib/guesty/reservations/reviews/extractors/booking_com.rb +35 -0
- data/lib/guesty/reservations/reviews/extractors/vrbo.rb +46 -0
- data/lib/guesty/reservations/reviews/index.rb +67 -0
- data/lib/guesty/reservations/search.rb +63 -0
- data/lib/guesty/reservations/show.rb +28 -0
- data/lib/guesty/reservations/status/cancel.rb +34 -0
- data/lib/guesty/reservations/status/update.rb +21 -0
- data/lib/guesty/reservations/stays/update.rb +42 -0
- data/lib/guesty/reservations/update.rb +19 -0
- data/lib/guesty/reservations.rb +13 -0
- data/lib/guesty/reviews/respond.rb +19 -0
- data/lib/guesty/version.rb +3 -0
- data/lib/guesty/views/create.rb +19 -0
- data/lib/guesty/views/update.rb +33 -0
- data/lib/guesty/webhooks/create.rb +21 -0
- data/lib/guesty/webhooks/index.rb +18 -0
- data/lib/guesty/webhooks/signatures/verify.rb +76 -0
- data/lib/guesty/webhooks/sync.rb +36 -0
- data/lib/guesty/webhooks/update.rb +22 -0
- data/lib/guesty-client.rb +1 -0
- data/lib/guesty.rb +78 -0
- data/sig/guesty.rbs +427 -0
- metadata +191 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
module Reviews
|
|
4
|
+
# startDate/endDate filter on the review's updatedAt, newest update first.
|
|
5
|
+
class Index
|
|
6
|
+
include Guesty::Operation
|
|
7
|
+
|
|
8
|
+
# @rbs (since: ::Time, ?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Array[::Hash[::Symbol, untyped]]]
|
|
9
|
+
def call(since:, skip: 0, take: 100)
|
|
10
|
+
argument! take: :invalid if take.blank? || take <= 0
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request(:get, 'reviews', query(since, skip, take))
|
|
13
|
+
|
|
14
|
+
return response unless response.success?
|
|
15
|
+
|
|
16
|
+
reviews = parse_reviews(response.value!.body[:data])
|
|
17
|
+
|
|
18
|
+
Success(reviews)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
# @rbs (::Time since, ::Integer skip, ::Integer take) -> ::Hash[::Symbol, untyped]
|
|
24
|
+
def query(since, skip, take)
|
|
25
|
+
{
|
|
26
|
+
startDate: since.utc.iso8601,
|
|
27
|
+
skip:,
|
|
28
|
+
limit: take
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @rbs (::Array[::Hash[::Symbol, untyped]] guesty_reviews_data) -> ::Array[::Hash[::Symbol, untyped]]
|
|
33
|
+
def parse_reviews(guesty_reviews_data)
|
|
34
|
+
return [] unless guesty_reviews_data.is_a?(Array)
|
|
35
|
+
|
|
36
|
+
guesty_reviews_data.map do |review_data|
|
|
37
|
+
next unless review_data[:rawReview].is_a?(Hash)
|
|
38
|
+
|
|
39
|
+
extractor = get_extractor_for_channel(review_data[:channelId]&.downcase)
|
|
40
|
+
result = extractor.new.call(raw_review: review_data[:rawReview])
|
|
41
|
+
next unless result.success?
|
|
42
|
+
|
|
43
|
+
result.value!.merge(
|
|
44
|
+
created_at: review_data[:createdAt],
|
|
45
|
+
guesty_review_id: review_data[:_id],
|
|
46
|
+
guesty_reservation_id: review_data[:reservationId]
|
|
47
|
+
)
|
|
48
|
+
end.compact
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @rbs (::String? channel) -> untyped
|
|
52
|
+
def get_extractor_for_channel(channel)
|
|
53
|
+
case channel.to_s.downcase
|
|
54
|
+
when /airbnb/i
|
|
55
|
+
Extractors::Airbnb
|
|
56
|
+
when /booking/i
|
|
57
|
+
Extractors::BookingCom
|
|
58
|
+
when /vrbo|homeaway/i
|
|
59
|
+
Extractors::Vrbo
|
|
60
|
+
else
|
|
61
|
+
error! "Unsupported channel: #{channel}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
# Searches the legacy /reservations index (deprecated, switching off at the
|
|
4
|
+
# end of 2026 — new reservation calls use reservations-v3).
|
|
5
|
+
class Search
|
|
6
|
+
include Guesty::Operation
|
|
7
|
+
|
|
8
|
+
FILTER_MAPPINGS = {
|
|
9
|
+
ids: { field: '_id', operator: '$in' },
|
|
10
|
+
updated_at: { field: 'lastUpdatedAt', operator: '$gt' },
|
|
11
|
+
conversation_id: { field: 'conversationId', operator: '$eq' },
|
|
12
|
+
status: { field: 'status', operator: '$eq' },
|
|
13
|
+
confirmation_code: { field: 'confirmationCode', operator: '$eq' }
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
# @rbs (::Hash[::Symbol, untyped] attributes) -> ::Array[::Hash[::Symbol, untyped]]
|
|
17
|
+
def self.filters(attributes)
|
|
18
|
+
attributes.map do |key, value|
|
|
19
|
+
normalized = key == :updated_at ? value&.strftime('%Y-%m-%d') : value
|
|
20
|
+
|
|
21
|
+
FILTER_MAPPINGS.fetch(key).merge(value: normalized)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @rbs (fields: ::Array[::String], attributes: ::Hash[::Symbol, untyped], ?skip: ::Integer, ?take: ::Integer) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
26
|
+
def call(fields:, attributes:, skip: 0, take: 100)
|
|
27
|
+
validate_arguments!(fields:, attributes:, take:)
|
|
28
|
+
|
|
29
|
+
response = Guesty.do_request(:get, 'reservations', params(fields:, attributes:, skip:, take:))
|
|
30
|
+
return response unless response.success?
|
|
31
|
+
|
|
32
|
+
body = response.value!.body
|
|
33
|
+
result = {
|
|
34
|
+
results: body[:results],
|
|
35
|
+
count: body[:count]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Success(result)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def validate_arguments!(fields:, attributes:, take:)
|
|
44
|
+
argument! fields: :required, fatal: true if fields.blank?
|
|
45
|
+
argument! attributes: :required if attributes.blank?
|
|
46
|
+
argument! take: :invalid if take && take <= 0
|
|
47
|
+
argument! attributes: :invalid unless attributes.keys.all? { |name| FILTER_MAPPINGS.key?(name) }
|
|
48
|
+
argument! updated_at: :required if attributes.key?(:updated_at) && attributes[:updated_at].blank?
|
|
49
|
+
argument! ids: :required if attributes.key?(:ids) && attributes[:ids].empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def params(fields:, attributes:, skip:, take:)
|
|
53
|
+
{
|
|
54
|
+
filters: self.class.filters(attributes).to_json,
|
|
55
|
+
sort: 'createdAt',
|
|
56
|
+
limit: take,
|
|
57
|
+
skip:,
|
|
58
|
+
fields: fields.join(' ')
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
class Show
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (reservation_id: ::String, fields: ::Array[::String]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
7
|
+
def call(reservation_id:, fields: [])
|
|
8
|
+
argument! reservation_id: :required if reservation_id.blank?
|
|
9
|
+
argument! fields: :required if fields.blank?
|
|
10
|
+
|
|
11
|
+
attributes = {} #: ::Hash[::Symbol, ::String]
|
|
12
|
+
attributes[:fields] = fields.join(' ') if fields.present?
|
|
13
|
+
|
|
14
|
+
response = Guesty.do_request :get, "reservations/#{reservation_id}", attributes
|
|
15
|
+
|
|
16
|
+
Success(handle_response(response))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def handle_response(response)
|
|
22
|
+
raise response.failure unless response.success?
|
|
23
|
+
|
|
24
|
+
response.value!.body
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
module Status
|
|
4
|
+
class Cancel
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
CANCELLED_BY = {
|
|
8
|
+
guest: 'GUEST',
|
|
9
|
+
owner: 'OWNER',
|
|
10
|
+
staff: 'TEAM_MEMBER'
|
|
11
|
+
}.freeze #: ::Hash[::Symbol, ::String]
|
|
12
|
+
|
|
13
|
+
# @rbs (reservation_id: ::String, note: ::String, cancelled_by: ::Symbol) -> Dry::Monads::Result[bool]
|
|
14
|
+
def call(reservation_id:, note:, cancelled_by:)
|
|
15
|
+
argument! reservation_id: :required if reservation_id.blank?
|
|
16
|
+
argument! note: :required if note.blank?
|
|
17
|
+
argument! cancelled_by: :invalid unless CANCELLED_BY.key?(cancelled_by)
|
|
18
|
+
|
|
19
|
+
Update.new.call!(
|
|
20
|
+
reservation_id:,
|
|
21
|
+
attributes: {
|
|
22
|
+
status: CANCELLED_STATUS,
|
|
23
|
+
cancellationReason: 'No Reason Provided',
|
|
24
|
+
cancellationNote: note,
|
|
25
|
+
canceledBy: CANCELLED_BY.fetch(cancelled_by)
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
Success(true)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
module Status
|
|
4
|
+
class Update
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
# @rbs (reservation_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
|
|
8
|
+
def call(reservation_id:, attributes:)
|
|
9
|
+
argument! reservation_id: :required if reservation_id.blank?
|
|
10
|
+
argument! attributes: :required if attributes.blank?
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request :put, "reservations-v3/#{reservation_id}/status", attributes
|
|
13
|
+
|
|
14
|
+
raise response.failure unless response.success?
|
|
15
|
+
|
|
16
|
+
Success(true)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
module Stays
|
|
4
|
+
class Update
|
|
5
|
+
include Guesty::Operation
|
|
6
|
+
|
|
7
|
+
# @rbs (reservation_id: ::String, stay: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
|
|
8
|
+
def call(reservation_id:, stay:)
|
|
9
|
+
response = Guesty.do_request(
|
|
10
|
+
:put,
|
|
11
|
+
"reservations/#{reservation_id}",
|
|
12
|
+
stay_attributes(stay)
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
raise response.failure if response.failure?
|
|
16
|
+
|
|
17
|
+
Success(true)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# @rbs (::Hash[::Symbol, untyped] stay) -> ::Hash[::Symbol, untyped]
|
|
23
|
+
def stay_attributes(stay)
|
|
24
|
+
{
|
|
25
|
+
checkInDateLocalized: stay.fetch(:start_date).iso8601,
|
|
26
|
+
checkOutDateLocalized: stay.fetch(:end_date).iso8601,
|
|
27
|
+
plannedArrival: stay.fetch(:check_in).strftime('%H:%M'),
|
|
28
|
+
plannedDeparture: stay.fetch(:check_out).strftime('%H:%M'),
|
|
29
|
+
guestsCount: stay.fetch(:num_guests),
|
|
30
|
+
numberOfGuests: {
|
|
31
|
+
numberOfAdults: stay.fetch(:adults),
|
|
32
|
+
numberOfChildren: stay.fetch(:children)
|
|
33
|
+
},
|
|
34
|
+
ignoreCalendar: stay.fetch(:ignore_calendar),
|
|
35
|
+
ignoreTerms: true,
|
|
36
|
+
listingId: stay.fetch(:listing_external_id)
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
class Update
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (reservation_id: ::String, attributes: ::Hash[::Symbol, untyped]) -> Dry::Monads::Result[bool]
|
|
7
|
+
def call(reservation_id:, attributes:)
|
|
8
|
+
argument! reservation_id: :required if reservation_id.blank?
|
|
9
|
+
argument! attributes: :required if attributes.blank?
|
|
10
|
+
|
|
11
|
+
response = Guesty.do_request :put, "reservations/#{reservation_id}", attributes
|
|
12
|
+
|
|
13
|
+
raise response.failure unless response.success?
|
|
14
|
+
|
|
15
|
+
Success(true)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reservations
|
|
3
|
+
CANCELLED_STATUS = 'canceled'.freeze #: ::String
|
|
4
|
+
CLOSED_STATUSES = %w[canceled closed declined expired].freeze #: ::Array[::String]
|
|
5
|
+
|
|
6
|
+
# @rbs (untyped conversation) -> ::Array[::Hash[::Symbol, untyped]]
|
|
7
|
+
def self.from_conversation(conversation)
|
|
8
|
+
(conversation.dig(:meta, :reservations) || []).filter_map do |reservation|
|
|
9
|
+
{ id: reservation[:_id], closed: CLOSED_STATUSES.include?(reservation[:status]) } if reservation[:_id]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Reviews
|
|
3
|
+
class Respond
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (review_id: ::String, text: ::String) -> Dry::Monads::Result[bool]
|
|
7
|
+
def call(review_id:, text:)
|
|
8
|
+
argument! review_id: :required if review_id.blank?
|
|
9
|
+
argument! text: :required if text.blank?
|
|
10
|
+
|
|
11
|
+
response = Guesty.do_request(:put, "reviews/#{review_id}/reply", { reviewReply: text })
|
|
12
|
+
|
|
13
|
+
raise response.failure unless response.success?
|
|
14
|
+
|
|
15
|
+
Success(true)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Views
|
|
3
|
+
class Create
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (title: ::String, section: ::String) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
7
|
+
def call(title:, section:)
|
|
8
|
+
argument! title: :required if title.blank?
|
|
9
|
+
argument! section: :required if section.blank?
|
|
10
|
+
|
|
11
|
+
response = Guesty.do_request :post, 'views', { title:, section: }
|
|
12
|
+
|
|
13
|
+
raise response.failure unless response.success?
|
|
14
|
+
|
|
15
|
+
Success(response.value!.body)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Views
|
|
3
|
+
class Update
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (id: ::String, ?title: ::String?, ?fields: ::String?, ?filters: ::Array[::Hash[::Symbol, untyped]]?, ?sort: ::String?) -> Dry::Monads::Result[untyped]
|
|
7
|
+
def call(id:, title: nil, fields: nil, filters: nil, sort: nil)
|
|
8
|
+
argument! id: :required if id.blank?
|
|
9
|
+
argument! attributes: :invalid if [title, fields, filters, sort].all?(&:nil?)
|
|
10
|
+
|
|
11
|
+
response = Guesty.do_request :put,
|
|
12
|
+
"views/#{id}",
|
|
13
|
+
payload(
|
|
14
|
+
id,
|
|
15
|
+
title:,
|
|
16
|
+
fields:,
|
|
17
|
+
filters:,
|
|
18
|
+
sort:
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
raise response.failure unless response.success?
|
|
22
|
+
|
|
23
|
+
Success(response.value!.body)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def payload(id, title:, fields:, filters:, sort:)
|
|
29
|
+
{ _id: id, title:, fields:, filters:, sort: }.compact
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Webhooks
|
|
3
|
+
class Create
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (url: ::String, events: ::Array[::String]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
7
|
+
def call(url:, events:)
|
|
8
|
+
argument! url: :required if url.blank?
|
|
9
|
+
argument! events: :required if events.blank?
|
|
10
|
+
|
|
11
|
+
response = Guesty.do_request :post, 'webhooks', { url:, events: }
|
|
12
|
+
|
|
13
|
+
if response.success?
|
|
14
|
+
Success(response.value!.body)
|
|
15
|
+
else
|
|
16
|
+
response
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Webhooks
|
|
3
|
+
class Index
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs () -> Dry::Monads::Result[::Array[untyped]]
|
|
7
|
+
def call
|
|
8
|
+
response = Guesty.do_request :get, 'webhooks'
|
|
9
|
+
|
|
10
|
+
if response.success?
|
|
11
|
+
Success(response.value!.body)
|
|
12
|
+
else
|
|
13
|
+
response
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'openssl'
|
|
3
|
+
|
|
4
|
+
module Guesty
|
|
5
|
+
module Webhooks
|
|
6
|
+
# Guesty delivers webhooks through Svix, which signs every request. The secret is the
|
|
7
|
+
# base64 after the optional whsec_ prefix of the account signing key, the signed content is
|
|
8
|
+
# "<svix-id>.<svix-timestamp>.<raw body>", and svix-signature lists space-separated
|
|
9
|
+
# "version,base64-HMAC-SHA256" entries, one per active key.
|
|
10
|
+
module Signatures
|
|
11
|
+
class Verify
|
|
12
|
+
include Guesty::Operation
|
|
13
|
+
|
|
14
|
+
SECRET_PREFIX = 'whsec_'.freeze
|
|
15
|
+
SIGNATURE_VERSION = 'v1'.freeze
|
|
16
|
+
TOLERANCE = 5.minutes.freeze #: ActiveSupport::Duration
|
|
17
|
+
|
|
18
|
+
# @rbs (payload: ::String, headers: untyped) -> Dry::Monads::Result[bool]
|
|
19
|
+
def call(payload:, headers:)
|
|
20
|
+
argument! payload: :required if payload.nil?
|
|
21
|
+
argument! headers: :required if headers.nil?
|
|
22
|
+
|
|
23
|
+
id = headers['svix-id']
|
|
24
|
+
timestamp = headers['svix-timestamp']
|
|
25
|
+
signatures = headers['svix-signature']
|
|
26
|
+
|
|
27
|
+
error! 'Missing Svix headers' if id.blank? || timestamp.blank? || signatures.blank?
|
|
28
|
+
|
|
29
|
+
verify_timestamp!(timestamp)
|
|
30
|
+
error! 'No matching Svix signature' unless signed?(signatures, sign(id, timestamp, payload))
|
|
31
|
+
|
|
32
|
+
Success(true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# A timestamp outside the tolerance fails even with a valid signature, so a captured
|
|
38
|
+
# delivery cannot be replayed later.
|
|
39
|
+
# @rbs (untyped header) -> void
|
|
40
|
+
def verify_timestamp!(header)
|
|
41
|
+
timestamp = Integer(header, exception: false)
|
|
42
|
+
error! 'Invalid Svix timestamp' if timestamp.nil?
|
|
43
|
+
error! 'Svix timestamp is outside the tolerance' unless timestamp.between?(TOLERANCE.ago.to_i, TOLERANCE.from_now.to_i)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# @rbs (::String id, ::String timestamp, ::String payload) -> ::String
|
|
47
|
+
def sign(id, timestamp, payload)
|
|
48
|
+
Base64.strict_encode64(
|
|
49
|
+
OpenSSL::HMAC.digest(
|
|
50
|
+
'sha256',
|
|
51
|
+
secret,
|
|
52
|
+
"#{id}.#{timestamp}.#{payload}"
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @rbs (::String header, ::String expected) -> bool
|
|
58
|
+
def signed?(header, expected)
|
|
59
|
+
header.split.any? do |versioned_signature|
|
|
60
|
+
version, signature = versioned_signature.split(',', 2)
|
|
61
|
+
|
|
62
|
+
version == SIGNATURE_VERSION && signature && ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @rbs () -> ::String
|
|
67
|
+
def secret
|
|
68
|
+
decoded = Base64.decode64(Guesty.webhook_key.to_s.delete_prefix(SECRET_PREFIX))
|
|
69
|
+
error! 'Guesty webhook signing key is not configured' if decoded.empty?
|
|
70
|
+
|
|
71
|
+
decoded
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Webhooks
|
|
3
|
+
# Subscribes the urls to the events: creates the missing, updates the
|
|
4
|
+
# drifted, leaves the rest. Returns the urls grouped by :created, :updated,
|
|
5
|
+
# :unchanged.
|
|
6
|
+
class Sync
|
|
7
|
+
include Guesty::Operation
|
|
8
|
+
|
|
9
|
+
# @rbs (urls: ::Array[::String], events: ::Array[::String]) -> Dry::Monads::Result[::Hash[::Symbol, ::Array[::String]]]
|
|
10
|
+
def call(urls:, events:)
|
|
11
|
+
argument! urls: :required if urls.blank?
|
|
12
|
+
argument! events: :required if events.blank?
|
|
13
|
+
|
|
14
|
+
webhooks = Index.new.call!.value!
|
|
15
|
+
existing = webhooks.index_by { |webhook| webhook[:url].strip }
|
|
16
|
+
|
|
17
|
+
Success(urls.group_by { |url| reconcile(url, events, existing[url]) })
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
# @rbs (::String url, ::Array[::String] events, ::Hash[::Symbol, untyped]? current) -> ::Symbol
|
|
23
|
+
def reconcile(url, events, current)
|
|
24
|
+
if current.nil?
|
|
25
|
+
Create.new.call!(url:, events:)
|
|
26
|
+
:created
|
|
27
|
+
elsif current[:events].to_set != events.to_set
|
|
28
|
+
Update.new.call!(id: current[:_id], url:, events:)
|
|
29
|
+
:updated
|
|
30
|
+
else
|
|
31
|
+
:unchanged
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Guesty
|
|
2
|
+
module Webhooks
|
|
3
|
+
class Update
|
|
4
|
+
include Guesty::Operation
|
|
5
|
+
|
|
6
|
+
# @rbs (id: ::String, url: ::String, events: ::Array[::String]) -> Dry::Monads::Result[::Hash[::Symbol, untyped]]
|
|
7
|
+
def call(id:, url:, events:)
|
|
8
|
+
argument! id: :required if id.blank?
|
|
9
|
+
argument! url: :required if url.blank?
|
|
10
|
+
argument! events: :required if events.blank?
|
|
11
|
+
|
|
12
|
+
response = Guesty.do_request :put, "webhooks/#{id}", { url:, events: }
|
|
13
|
+
|
|
14
|
+
if response.success?
|
|
15
|
+
Success(response.value!.body)
|
|
16
|
+
else
|
|
17
|
+
response
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative 'guesty'
|
data/lib/guesty.rb
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'active_support'
|
|
2
|
+
require 'active_support/core_ext'
|
|
3
|
+
require 'active_support/security_utils'
|
|
4
|
+
require 'dry/monads'
|
|
5
|
+
require 'faraday'
|
|
6
|
+
require 'faraday/retry'
|
|
7
|
+
require 'mini_mime'
|
|
8
|
+
require 'oj'
|
|
9
|
+
|
|
10
|
+
require_relative 'guesty/version'
|
|
11
|
+
require_relative 'guesty/error'
|
|
12
|
+
require_relative 'guesty/memory_token_store'
|
|
13
|
+
require_relative 'guesty/memory_rate_limiter'
|
|
14
|
+
require_relative 'guesty/config'
|
|
15
|
+
require_relative 'guesty/operation'
|
|
16
|
+
require_relative 'guesty/rate_limiter_middleware'
|
|
17
|
+
require_relative 'guesty/api_error'
|
|
18
|
+
require_relative 'guesty/client'
|
|
19
|
+
require_relative 'guesty/access_tokens/upsert'
|
|
20
|
+
require_relative 'guesty/conversations'
|
|
21
|
+
require_relative 'guesty/conversations/attachments/index'
|
|
22
|
+
require_relative 'guesty/conversations/index'
|
|
23
|
+
require_relative 'guesty/conversations/messages/reply'
|
|
24
|
+
require_relative 'guesty/conversations/posts/index'
|
|
25
|
+
require_relative 'guesty/conversations/posts/since'
|
|
26
|
+
require_relative 'guesty/conversations/search'
|
|
27
|
+
require_relative 'guesty/conversations/show'
|
|
28
|
+
require_relative 'guesty/guest_folio/invoice_items/index'
|
|
29
|
+
require_relative 'guesty/guests'
|
|
30
|
+
require_relative 'guesty/guests/profiles/create'
|
|
31
|
+
require_relative 'guesty/guests/profiles/show'
|
|
32
|
+
require_relative 'guesty/guests/profiles/update'
|
|
33
|
+
require_relative 'guesty/homes/calendars/show'
|
|
34
|
+
require_relative 'guesty/homes/search'
|
|
35
|
+
require_relative 'guesty/reservations'
|
|
36
|
+
require_relative 'guesty/reservations/accommodation_fare/update'
|
|
37
|
+
require_relative 'guesty/reservations/approve'
|
|
38
|
+
require_relative 'guesty/reservations/create'
|
|
39
|
+
require_relative 'guesty/reservations/custom_fields/update'
|
|
40
|
+
require_relative 'guesty/reservations/dates/update'
|
|
41
|
+
require_relative 'guesty/reservations/decline'
|
|
42
|
+
require_relative 'guesty/reservations/flags/update'
|
|
43
|
+
require_relative 'guesty/reservations/payments/create'
|
|
44
|
+
require_relative 'guesty/reservations/pre_approve'
|
|
45
|
+
require_relative 'guesty/reservations/recover'
|
|
46
|
+
require_relative 'guesty/reservations/reviews/extractors/base'
|
|
47
|
+
require_relative 'guesty/reservations/reviews/extractors/airbnb'
|
|
48
|
+
require_relative 'guesty/reservations/reviews/extractors/booking_com'
|
|
49
|
+
require_relative 'guesty/reservations/reviews/extractors/vrbo'
|
|
50
|
+
require_relative 'guesty/reservations/reviews/index'
|
|
51
|
+
require_relative 'guesty/reservations/search'
|
|
52
|
+
require_relative 'guesty/reservations/show'
|
|
53
|
+
require_relative 'guesty/reservations/status/cancel'
|
|
54
|
+
require_relative 'guesty/reservations/status/update'
|
|
55
|
+
require_relative 'guesty/reservations/stays/update'
|
|
56
|
+
require_relative 'guesty/reservations/update'
|
|
57
|
+
require_relative 'guesty/reviews/respond'
|
|
58
|
+
require_relative 'guesty/views/create'
|
|
59
|
+
require_relative 'guesty/views/update'
|
|
60
|
+
require_relative 'guesty/webhooks/create'
|
|
61
|
+
require_relative 'guesty/webhooks/index'
|
|
62
|
+
require_relative 'guesty/webhooks/signatures/verify'
|
|
63
|
+
require_relative 'guesty/webhooks/sync'
|
|
64
|
+
require_relative 'guesty/webhooks/update'
|
|
65
|
+
|
|
66
|
+
module Guesty
|
|
67
|
+
class << self
|
|
68
|
+
# @rbs () { (Config) -> void } -> void
|
|
69
|
+
def configure
|
|
70
|
+
yield(config)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# @rbs () -> Config
|
|
74
|
+
def config
|
|
75
|
+
@config ||= Config.new
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|