webhookdb 1.3.1 → 1.4.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 +4 -4
- data/admin-dist/assets/{index-6aebf805.js → index-9306dd28.js} +39 -39
- data/admin-dist/index.html +1 -1
- data/data/messages/templates/errors/generic_backfill.email.liquid +30 -0
- data/data/messages/templates/errors/icalendar_fetch.email.liquid +8 -2
- data/data/messages/templates/specs/with_fields.email.liquid +6 -0
- data/db/migrations/045_system_log.rb +15 -0
- data/db/migrations/046_indices.rb +14 -0
- data/lib/webhookdb/admin.rb +6 -0
- data/lib/webhookdb/admin_api/data_provider.rb +1 -0
- data/lib/webhookdb/admin_api/entities.rb +8 -0
- data/lib/webhookdb/aggregate_result.rb +1 -1
- data/lib/webhookdb/api/helpers.rb +17 -0
- data/lib/webhookdb/api/organizations.rb +6 -0
- data/lib/webhookdb/api/service_integrations.rb +1 -0
- data/lib/webhookdb/connection_cache.rb +29 -3
- data/lib/webhookdb/console.rb +1 -1
- data/lib/webhookdb/customer/reset_code.rb +1 -1
- data/lib/webhookdb/customer.rb +3 -2
- data/lib/webhookdb/db_adapter.rb +1 -1
- data/lib/webhookdb/dbutil.rb +2 -0
- data/lib/webhookdb/errors.rb +34 -0
- data/lib/webhookdb/http.rb +1 -1
- data/lib/webhookdb/jobs/deprecated_jobs.rb +1 -0
- data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +105 -0
- data/lib/webhookdb/jobs/monitor_metrics.rb +29 -0
- data/lib/webhookdb/jobs/renew_watch_channel.rb +3 -0
- data/lib/webhookdb/message/transport.rb +1 -1
- data/lib/webhookdb/message.rb +53 -2
- data/lib/webhookdb/messages/error_generic_backfill.rb +45 -0
- data/lib/webhookdb/messages/error_icalendar_fetch.rb +3 -0
- data/lib/webhookdb/messages/specs.rb +16 -0
- data/lib/webhookdb/organization/alerting.rb +7 -3
- data/lib/webhookdb/organization/database_migration.rb +1 -1
- data/lib/webhookdb/organization/db_builder.rb +1 -1
- data/lib/webhookdb/organization.rb +14 -1
- data/lib/webhookdb/postgres/model.rb +1 -0
- data/lib/webhookdb/postgres.rb +2 -1
- data/lib/webhookdb/replicator/base.rb +66 -39
- data/lib/webhookdb/replicator/column.rb +2 -0
- data/lib/webhookdb/replicator/fake.rb +6 -0
- data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +28 -19
- data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +55 -11
- data/lib/webhookdb/replicator/intercom_v1_mixin.rb +25 -4
- data/lib/webhookdb/replicator/signalwire_message_v1.rb +31 -0
- data/lib/webhookdb/replicator/transistor_episode_v1.rb +11 -5
- data/lib/webhookdb/replicator/webhook_request.rb +8 -0
- data/lib/webhookdb/replicator.rb +2 -2
- data/lib/webhookdb/service/view_api.rb +1 -1
- data/lib/webhookdb/service.rb +10 -10
- data/lib/webhookdb/service_integration.rb +14 -1
- data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +153 -64
- data/lib/webhookdb/sync_target.rb +7 -5
- data/lib/webhookdb/system_log_event.rb +9 -0
- data/lib/webhookdb/version.rb +1 -1
- data/lib/webhookdb/webhook_subscription.rb +1 -1
- data/lib/webhookdb.rb +31 -7
- metadata +32 -16
- data/lib/webhookdb/jobs/customer_created_notify_internal.rb +0 -22
- /data/lib/webhookdb/jobs/{logged_webhook_replay.rb → logged_webhooks_replay.rb} +0 -0
- /data/lib/webhookdb/jobs/{logged_webhook_resilient_replay.rb → logged_webhooks_resilient_replay.rb} +0 -0
- /data/lib/webhookdb/jobs/{webhook_subscription_delivery_attempt.rb → webhook_subscription_delivery_event.rb} +0 -0
- /data/lib/webhookdb/jobs/{webhook_resource_notify_integrations.rb → webhookdb_resource_notify_integrations.rb} +0 -0
@@ -0,0 +1,45 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "webhookdb/message/template"
|
4
|
+
|
5
|
+
class Webhookdb::Messages::ErrorGenericBackfill < Webhookdb::Message::Template
|
6
|
+
def self.fixtured(_recipient)
|
7
|
+
sint = Webhookdb::Fixtures.service_integration.create
|
8
|
+
return self.new(
|
9
|
+
sint,
|
10
|
+
response_status: 422,
|
11
|
+
request_url: "https://whdbtest.signalwire.com/2010-04-01/Accounts/projid/Messages.json",
|
12
|
+
request_method: "POST",
|
13
|
+
response_body: "Unauthorized",
|
14
|
+
)
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(service_integration, request_url:, request_method:, response_status:, response_body:)
|
18
|
+
@service_integration = service_integration
|
19
|
+
@request_url = request_url
|
20
|
+
@request_method = request_method
|
21
|
+
@response_status = response_status
|
22
|
+
@response_body = response_body
|
23
|
+
super()
|
24
|
+
end
|
25
|
+
|
26
|
+
def signature
|
27
|
+
# Only alert on the backfill once a day
|
28
|
+
return "msg-#{self.full_template_name}-sint:#{@service_integration.id}"
|
29
|
+
end
|
30
|
+
|
31
|
+
def template_folder = "errors"
|
32
|
+
def template_name = "generic_backfill"
|
33
|
+
|
34
|
+
def liquid_drops
|
35
|
+
return super.merge(
|
36
|
+
friendly_name: @service_integration.replicator.descriptor.resource_name_singular,
|
37
|
+
service_name: @service_integration.service_name,
|
38
|
+
opaque_id: @service_integration.opaque_id,
|
39
|
+
request_method: @request_method,
|
40
|
+
request_url: @request_url,
|
41
|
+
response_status: @response_status,
|
42
|
+
response_body: @response_body,
|
43
|
+
)
|
44
|
+
end
|
45
|
+
end
|
@@ -36,6 +36,9 @@ class Webhookdb::Messages::ErrorIcalendarFetch < Webhookdb::Message::Template
|
|
36
36
|
response_status: @response_status,
|
37
37
|
response_body: @response_body,
|
38
38
|
external_calendar_id: @external_calendar_id,
|
39
|
+
webhook_endpoint: @service_integration.replicator.webhook_endpoint,
|
40
|
+
org_name: @service_integration.organization.name,
|
41
|
+
org_key: @service_integration.organization.key,
|
39
42
|
)
|
40
43
|
end
|
41
44
|
end
|
@@ -27,6 +27,22 @@ module Webhookdb::Messages::Testers
|
|
27
27
|
end
|
28
28
|
end
|
29
29
|
|
30
|
+
class WithFields < Base
|
31
|
+
# noinspection RubyInstanceVariableNamingConvention
|
32
|
+
def initialize(a: nil, b: nil, c: nil, d: nil, e: nil)
|
33
|
+
@a = a
|
34
|
+
@b = b
|
35
|
+
@c = c
|
36
|
+
@d = d
|
37
|
+
@e = e
|
38
|
+
super()
|
39
|
+
end
|
40
|
+
|
41
|
+
def liquid_drops
|
42
|
+
return super.merge(a: @a, b: @b, c: @c, d: @d, e: @e)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
30
46
|
class Nonextant < Base
|
31
47
|
end
|
32
48
|
|
@@ -23,7 +23,10 @@ class Webhookdb::Organization::Alerting
|
|
23
23
|
|
24
24
|
# Dispatch the message template to administrators of the org.
|
25
25
|
# @param message_template [Webhookdb::Message::Template]
|
26
|
-
|
26
|
+
# @param separate_connection [true,false] If true, send the alert on a separate connection.
|
27
|
+
# See +Webhookdb::Idempotency+. Defaults to true since this is an alert method and we
|
28
|
+
# don't want it to error accidentally, if the code is called from an unexpected situation.
|
29
|
+
def dispatch_alert(message_template, separate_connection: true)
|
27
30
|
unless message_template.respond_to?(:signature)
|
28
31
|
raise Webhookdb::InvalidPrecondition,
|
29
32
|
"message template #{message_template.template_name} must define a #signature method, " \
|
@@ -33,8 +36,9 @@ class Webhookdb::Organization::Alerting
|
|
33
36
|
max_alerts_per_customer_per_day = Webhookdb::Organization::Alerting.max_alerts_per_customer_per_day
|
34
37
|
yesterday = Time.now - 24.hours
|
35
38
|
self.org.admin_customers.each do |c|
|
36
|
-
|
37
|
-
|
39
|
+
idem = Webhookdb::Idempotency.every(Webhookdb::Organization::Alerting.interval)
|
40
|
+
idem = idem.using_seperate_connection if separate_connection
|
41
|
+
idem.under_key("orgalert-#{signature}-#{c.id}") do
|
38
42
|
sent_last_day = Webhookdb::Message::Delivery.
|
39
43
|
where(template: message_template.full_template_name, recipient: c).
|
40
44
|
where { created_at > yesterday }.
|
@@ -4,7 +4,7 @@ class Webhookdb::Organization::DatabaseMigration < Webhookdb::Postgres::Model(:o
|
|
4
4
|
include Webhookdb::Dbutil
|
5
5
|
|
6
6
|
class MigrationInProgress < Webhookdb::DatabaseLocked; end
|
7
|
-
class MigrationAlreadyFinished <
|
7
|
+
class MigrationAlreadyFinished < Webhookdb::WebhookdbError; end
|
8
8
|
|
9
9
|
plugin :timestamps
|
10
10
|
plugin :text_searchable, terms: [:organization, :started_by]
|
@@ -22,7 +22,7 @@ class Webhookdb::Organization::DbBuilder
|
|
22
22
|
include Webhookdb::Dbutil
|
23
23
|
extend Webhookdb::MethodUtilities
|
24
24
|
|
25
|
-
class IsolatedOperationError <
|
25
|
+
class IsolatedOperationError < Webhookdb::ProgrammingError; end
|
26
26
|
|
27
27
|
DATABASE = "database"
|
28
28
|
SCHEMA = "schema"
|
@@ -7,7 +7,9 @@ require "webhookdb/stripe"
|
|
7
7
|
require "webhookdb/jobs/replication_migration"
|
8
8
|
|
9
9
|
class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
|
10
|
-
|
10
|
+
include Webhookdb::Admin::Linked
|
11
|
+
|
12
|
+
class SchemaMigrationError < Webhookdb::ProgrammingError; end
|
11
13
|
|
12
14
|
plugin :timestamps
|
13
15
|
plugin :soft_deletes
|
@@ -454,6 +456,17 @@ class Webhookdb::Organization < Webhookdb::Postgres::Model(:organizations)
|
|
454
456
|
return self.add_all_membership(opts)
|
455
457
|
end
|
456
458
|
|
459
|
+
def close(confirm:)
|
460
|
+
raise Webhookdb::InvalidPrecondition, "confirm must be true to close the org" unless confirm
|
461
|
+
unless self.service_integrations_dataset.empty?
|
462
|
+
msg = "Organization[#{self.key} cannot close with active service integrations"
|
463
|
+
raise Webhookdb::InvalidPrecondition, msg
|
464
|
+
end
|
465
|
+
memberships = self.all_memberships_dataset.all.each(&:destroy)
|
466
|
+
self.destroy
|
467
|
+
return [self, memberships]
|
468
|
+
end
|
469
|
+
|
457
470
|
# SUBSCRIPTION PERMISSIONS
|
458
471
|
|
459
472
|
def active_subscription?
|
data/lib/webhookdb/postgres.rb
CHANGED
@@ -13,7 +13,7 @@ module Webhookdb::Postgres
|
|
13
13
|
extend Webhookdb::MethodUtilities
|
14
14
|
include Appydays::Loggable
|
15
15
|
|
16
|
-
class InTransaction <
|
16
|
+
class InTransaction < Webhookdb::ProgrammingError; end
|
17
17
|
|
18
18
|
singleton_attr_accessor :unsafe_skip_transaction_check
|
19
19
|
@unsafe_skip_transaction_check = false
|
@@ -66,6 +66,7 @@ module Webhookdb::Postgres
|
|
66
66
|
"webhookdb/service_integration",
|
67
67
|
"webhookdb/subscription",
|
68
68
|
"webhookdb/sync_target",
|
69
|
+
"webhookdb/system_log_event",
|
69
70
|
"webhookdb/webhook_subscription",
|
70
71
|
"webhookdb/webhook_subscription/delivery",
|
71
72
|
].freeze
|
@@ -651,6 +651,9 @@ for information on how to refresh data.)
|
|
651
651
|
# @param [Webhookdb::Replicator::WebhookRequest] request
|
652
652
|
def upsert_webhook(request, **kw)
|
653
653
|
return self._upsert_webhook(request, **kw)
|
654
|
+
rescue Amigo::Retry::Error
|
655
|
+
# Do not log this since it's expected/handled by Amigo
|
656
|
+
raise
|
654
657
|
rescue StandardError => e
|
655
658
|
self.logger.error("upsert_webhook_error", request: request.as_json, error: e)
|
656
659
|
raise
|
@@ -999,42 +1002,18 @@ for information on how to refresh data.)
|
|
999
1002
|
job.update(started_at: Time.now)
|
1000
1003
|
|
1001
1004
|
backfillers = self._backfillers(**job.criteria.symbolize_keys)
|
1002
|
-
|
1003
|
-
|
1004
|
-
|
1005
|
-
|
1006
|
-
|
1007
|
-
# Initialize a sized array to avoid any potential race conditions (though GIL should make it not an issue?).
|
1008
|
-
errors = Array.new(backfillers.size)
|
1009
|
-
backfillers.each_with_index do |bf, idx|
|
1010
|
-
pool.post do
|
1011
|
-
bf.backfill(last_backfilled)
|
1012
|
-
rescue StandardError => e
|
1013
|
-
errors[idx] = e
|
1014
|
-
end
|
1015
|
-
end
|
1016
|
-
# We've enqueued all backfillers; do not accept anymore work.
|
1017
|
-
pool.shutdown
|
1018
|
-
loop do
|
1019
|
-
# We want to stop early if we find an error, so check for errors every 10 seconds.
|
1020
|
-
completed = pool.wait_for_termination(10)
|
1021
|
-
first_error = errors.find { |e| !e.nil? }
|
1022
|
-
if first_error.nil?
|
1023
|
-
# No error, and wait_for_termination returned true, so all work is done.
|
1024
|
-
break if completed
|
1025
|
-
# No error, but work is still going on, so loop again.
|
1026
|
-
next
|
1027
|
-
end
|
1028
|
-
# We have an error; don't run any more backfillers.
|
1029
|
-
pool.kill
|
1030
|
-
# Wait for all ongoing backfills before raising.
|
1031
|
-
pool.wait_for_termination
|
1032
|
-
raise first_error
|
1005
|
+
begin
|
1006
|
+
if self._parallel_backfill && self._parallel_backfill > 1
|
1007
|
+
_do_parallel_backfill(backfillers, last_backfilled)
|
1008
|
+
else
|
1009
|
+
_do_serial_backfill(backfillers, last_backfilled)
|
1033
1010
|
end
|
1034
|
-
|
1035
|
-
|
1036
|
-
|
1011
|
+
rescue StandardError => e
|
1012
|
+
if self.on_backfill_error(e) == true
|
1013
|
+
job.update(finished_at: Time.now)
|
1014
|
+
return
|
1037
1015
|
end
|
1016
|
+
raise e
|
1038
1017
|
end
|
1039
1018
|
|
1040
1019
|
sint.update(last_backfilled_at: new_last_backfilled) if job.incremental?
|
@@ -1042,6 +1021,54 @@ for information on how to refresh data.)
|
|
1042
1021
|
job.enqueue_children
|
1043
1022
|
end
|
1044
1023
|
|
1024
|
+
protected def _do_parallel_backfill(backfillers, last_backfilled)
|
1025
|
+
# Create a dedicated threadpool for these backfillers,
|
1026
|
+
# with max parallelism determined by the replicator.
|
1027
|
+
pool = Concurrent::FixedThreadPool.new(self._parallel_backfill)
|
1028
|
+
# Record any errors that occur, since they won't raise otherwise.
|
1029
|
+
# Initialize a sized array to avoid any potential race conditions (though GIL should make it not an issue?).
|
1030
|
+
errors = Array.new(backfillers.size)
|
1031
|
+
backfillers.each_with_index do |bf, idx|
|
1032
|
+
pool.post do
|
1033
|
+
bf.backfill(last_backfilled)
|
1034
|
+
rescue StandardError => e
|
1035
|
+
errors[idx] = e
|
1036
|
+
end
|
1037
|
+
end
|
1038
|
+
# We've enqueued all backfillers; do not accept anymore work.
|
1039
|
+
pool.shutdown
|
1040
|
+
loop do
|
1041
|
+
# We want to stop early if we find an error, so check for errors every 10 seconds.
|
1042
|
+
completed = pool.wait_for_termination(10)
|
1043
|
+
first_error = errors.find { |e| !e.nil? }
|
1044
|
+
if first_error.nil?
|
1045
|
+
# No error, and wait_for_termination returned true, so all work is done.
|
1046
|
+
break if completed
|
1047
|
+
# No error, but work is still going on, so loop again.
|
1048
|
+
next
|
1049
|
+
end
|
1050
|
+
# We have an error; don't run any more backfillers.
|
1051
|
+
pool.kill
|
1052
|
+
# Wait for all ongoing backfills before raising.
|
1053
|
+
pool.wait_for_termination
|
1054
|
+
raise first_error
|
1055
|
+
end
|
1056
|
+
end
|
1057
|
+
|
1058
|
+
protected def _do_serial_backfill(backfillers, last_backfilled)
|
1059
|
+
backfillers.each do |backfiller|
|
1060
|
+
backfiller.backfill(last_backfilled)
|
1061
|
+
end
|
1062
|
+
end
|
1063
|
+
|
1064
|
+
# Called when the #backfill method errors.
|
1065
|
+
# This can do something like dispatch a developer alert.
|
1066
|
+
# The handler must raise in order to stop the job from processing-
|
1067
|
+
# if nothing is raised, the original exception will be raised instead.
|
1068
|
+
# By default, this method noops, so the original exception is raised.
|
1069
|
+
# @param e [Exception]
|
1070
|
+
def on_backfill_error(e) = nil
|
1071
|
+
|
1045
1072
|
# If this replicator supports backfilling in parallel (running multiple backfillers at a time),
|
1046
1073
|
# return the degree of paralellism (or nil if not running in parallel).
|
1047
1074
|
# We leave parallelism up to the replicator, not CPU count, since most work
|
@@ -1096,15 +1123,15 @@ for information on how to refresh data.)
|
|
1096
1123
|
|
1097
1124
|
def fetch_backfill_page(pagination_token, last_backfilled:)
|
1098
1125
|
return @svc._fetch_backfill_page(pagination_token, last_backfilled:)
|
1099
|
-
rescue ::Timeout::Error, ::SocketError
|
1100
|
-
self.__retryordie
|
1126
|
+
rescue ::Timeout::Error, ::SocketError => e
|
1127
|
+
self.__retryordie(e)
|
1101
1128
|
rescue Webhookdb::Http::Error => e
|
1102
|
-
self.__retryordie if e.status >= 500
|
1129
|
+
self.__retryordie(e) if e.status >= 500
|
1103
1130
|
raise
|
1104
1131
|
end
|
1105
1132
|
|
1106
|
-
def __retryordie
|
1107
|
-
raise Amigo::Retry::OrDie.new(self.server_error_retries, self.server_error_backoff)
|
1133
|
+
def __retryordie(e)
|
1134
|
+
raise Amigo::Retry::OrDie.new(self.server_error_retries, self.server_error_backoff, e)
|
1108
1135
|
end
|
1109
1136
|
end
|
1110
1137
|
|
@@ -349,6 +349,8 @@ class Webhookdb::Replicator::Column
|
|
349
349
|
|
350
350
|
# If provided, use this expression as the UPDATE value when adding the column
|
351
351
|
# to an existing table.
|
352
|
+
# To explicitly backfill using NULL, use the value +Sequel[nil]+
|
353
|
+
# rather than +nil+.
|
352
354
|
# @return [String,Sequel,Sequel::SQL::Expression]
|
353
355
|
attr_reader :backfill_expr
|
354
356
|
|
@@ -409,6 +409,12 @@ class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fa
|
|
409
409
|
data_key: "my_id",
|
410
410
|
backfill_expr: "hi there",
|
411
411
|
),
|
412
|
+
Webhookdb::Replicator::Column.new(
|
413
|
+
:using_null_backfill_expr,
|
414
|
+
TEXT,
|
415
|
+
data_key: "my_id",
|
416
|
+
backfill_expr: Sequel[nil],
|
417
|
+
),
|
412
418
|
Webhookdb::Replicator::Column.new(
|
413
419
|
:using_backfill_statement,
|
414
420
|
TEXT,
|
@@ -239,35 +239,44 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
|
|
239
239
|
@signalwire_sint = replicator.service_integration.depends_on
|
240
240
|
end
|
241
241
|
|
242
|
-
def handle_item(
|
243
|
-
front_id =
|
244
|
-
sw_id =
|
242
|
+
def handle_item(db_row)
|
243
|
+
front_id = db_row.fetch(:front_message_id)
|
244
|
+
sw_id = db_row.fetch(:signalwire_sid)
|
245
|
+
# This is sort of gross- we get the db row here, and need to re-update it with certain fields
|
246
|
+
# as a result of the signalwire or front sync. To do that, we need to run the upsert on 'data',
|
247
|
+
# but what's in 'data' is incomplete. So we use the db row to form a more fully complete 'data'.
|
248
|
+
upserting_data = db_row.dup
|
249
|
+
# Remove the columns that don't belong in 'data'
|
250
|
+
upserting_data.delete(:pk)
|
251
|
+
upserting_data.delete(:row_updated_at)
|
252
|
+
# Splat the 'data' column into the row so it all gets put back into 'data'
|
253
|
+
upserting_data.merge!(**upserting_data.delete(:data))
|
245
254
|
if (front_id && sw_id) || (!front_id && !sw_id)
|
246
|
-
msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{
|
255
|
+
msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{db_row}"
|
247
256
|
raise Webhookdb::InvariantViolation, msg
|
248
257
|
end
|
249
|
-
sender = @replicator.format_phone(
|
250
|
-
recipient = @replicator.format_phone(
|
251
|
-
body =
|
252
|
-
idempotency_key = "fsmca-fims-#{
|
258
|
+
sender = @replicator.format_phone(db_row.fetch(:sender))
|
259
|
+
recipient = @replicator.format_phone(db_row.fetch(:recipient))
|
260
|
+
body = db_row.fetch(:body)
|
261
|
+
idempotency_key = "fsmca-fims-#{db_row.fetch(:external_id)}"
|
253
262
|
idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
|
254
263
|
if front_id.nil?
|
255
|
-
texted_at = Time.parse(
|
264
|
+
texted_at = Time.parse(db_row.fetch(:data).fetch("date_created"))
|
256
265
|
if texted_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
|
257
266
|
# Do not sync old rows, just mark them synced
|
258
|
-
|
267
|
+
upserting_data[:front_message_id] = "skipped_due_to_age"
|
259
268
|
else
|
260
269
|
# sync the message into Front
|
261
270
|
front_response_body = idempotency.execute do
|
262
|
-
self._sync_front_inbound(sender:, texted_at:,
|
271
|
+
self._sync_front_inbound(sender:, texted_at:, db_row:, body:)
|
263
272
|
end
|
264
|
-
|
273
|
+
upserting_data[:front_message_id] = front_response_body.fetch("message_uid")
|
265
274
|
end
|
266
275
|
else
|
267
|
-
messaged_at = Time.at(
|
276
|
+
messaged_at = Time.at(db_row.fetch(:data).fetch("payload").fetch("created_at"))
|
268
277
|
if messaged_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
|
269
278
|
# Do not sync old rows, just mark them synced
|
270
|
-
|
279
|
+
upserting_data[:signalwire_sid] = "skipped_due_to_age"
|
271
280
|
else
|
272
281
|
# send the SMS via signalwire
|
273
282
|
signalwire_resp = _send_sms(
|
@@ -276,10 +285,10 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
|
|
276
285
|
to: recipient,
|
277
286
|
body:,
|
278
287
|
)
|
279
|
-
|
288
|
+
upserting_data[:signalwire_sid] = signalwire_resp.fetch("sid") if signalwire_resp
|
280
289
|
end
|
281
290
|
end
|
282
|
-
@replicator.upsert_webhook_body(
|
291
|
+
@replicator.upsert_webhook_body(upserting_data.deep_stringify_keys)
|
283
292
|
end
|
284
293
|
|
285
294
|
def _send_sms(idempotency, from:, to:, body:)
|
@@ -321,14 +330,14 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
|
|
321
330
|
return nil
|
322
331
|
end
|
323
332
|
|
324
|
-
def _sync_front_inbound(sender:, texted_at:,
|
333
|
+
def _sync_front_inbound(sender:, texted_at:, db_row:, body:)
|
325
334
|
body = {
|
326
335
|
sender: {handle: sender},
|
327
336
|
body: body || "<no body>",
|
328
337
|
delivered_at: texted_at.to_i,
|
329
338
|
metadata: {
|
330
|
-
external_id:
|
331
|
-
external_conversation_id:
|
339
|
+
external_id: db_row.fetch(:external_id),
|
340
|
+
external_conversation_id: db_row.fetch(:external_conversation_id),
|
332
341
|
},
|
333
342
|
}
|
334
343
|
token = JWT.encode(
|
@@ -74,6 +74,9 @@ The secret to use for signing is:
|
|
74
74
|
col.new(:row_updated_at, TIMESTAMP, index: true, optional: true, defaulter: :now),
|
75
75
|
col.new(:last_synced_at, TIMESTAMP, index: true, optional: true),
|
76
76
|
col.new(:ics_url, TEXT, converter: col.converter_gsub("^webcal", "https")),
|
77
|
+
col.new(:event_count, INTEGER, optional: true),
|
78
|
+
col.new(:feed_bytes, INTEGER, optional: true),
|
79
|
+
col.new(:last_sync_duration_ms, INTEGER, optional: true),
|
77
80
|
]
|
78
81
|
end
|
79
82
|
|
@@ -166,11 +169,20 @@ The secret to use for signing is:
|
|
166
169
|
def sync_row(row)
|
167
170
|
Appydays::Loggable.with_log_tags(icalendar_url: row.fetch(:ics_url)) do
|
168
171
|
self.with_advisory_lock(row.fetch(:pk)) do
|
172
|
+
start = Time.now
|
169
173
|
now = Time.now
|
170
174
|
if (dep = self.find_dependent("icalendar_event_v1"))
|
171
|
-
self._sync_row(row, dep, now:)
|
175
|
+
processor = self._sync_row(row, dep, now:)
|
176
|
+
end
|
177
|
+
self.admin_dataset do |ds|
|
178
|
+
ds.where(pk: row.fetch(:pk)).
|
179
|
+
update(
|
180
|
+
last_synced_at: now,
|
181
|
+
event_count: processor&.upserted_identities&.count,
|
182
|
+
feed_bytes: processor&.read_bytes,
|
183
|
+
last_sync_duration_ms: (Time.now - start).in_milliseconds,
|
184
|
+
)
|
172
185
|
end
|
173
|
-
self.admin_dataset { |ds| ds.where(pk: row.fetch(:pk)).update(last_synced_at: now) }
|
174
186
|
end
|
175
187
|
end
|
176
188
|
end
|
@@ -204,6 +216,7 @@ The secret to use for signing is:
|
|
204
216
|
row_updated_at: now,
|
205
217
|
)
|
206
218
|
end
|
219
|
+
return processor
|
207
220
|
end
|
208
221
|
|
209
222
|
# We get all sorts of strange urls, fix up what we can.
|
@@ -224,7 +237,20 @@ The secret to use for signing is:
|
|
224
237
|
self.logger.info("icalendar_fetch_not_modified", response_status: 304, request_url:, calendar_external_id:)
|
225
238
|
return
|
226
239
|
when Down::SSLError
|
227
|
-
|
240
|
+
# Most SSL errors are transient and can be retried, but some are due to a long-term misconfiguration.
|
241
|
+
# Handle these with an alert, like if we had a 404, which indicates a longer-term issue.
|
242
|
+
is_fatal =
|
243
|
+
# There doesn't appear to be a way to allow unsafe legacy content negotiation on a per-request basis,
|
244
|
+
# it is compiled into OpenSSL (may be wrong about this).
|
245
|
+
e.to_s.include?("unsafe legacy renegotiation disabled") ||
|
246
|
+
# Certificate failures are not transient
|
247
|
+
e.to_s.include?("certificate verify failed")
|
248
|
+
if is_fatal
|
249
|
+
response_status = 0
|
250
|
+
response_body = e.to_s
|
251
|
+
else
|
252
|
+
self._handle_retryable_down_error!(e, request_url:, calendar_external_id:)
|
253
|
+
end
|
228
254
|
when Down::TimeoutError, Down::ConnectionError, Down::InvalidUrl, URI::InvalidURIError
|
229
255
|
response_status = 0
|
230
256
|
response_body = e.to_s
|
@@ -259,8 +285,9 @@ The secret to use for signing is:
|
|
259
285
|
response_status = nil
|
260
286
|
end
|
261
287
|
raise e if response_status.nil?
|
288
|
+
loggable_body = response_body && response_body[..256]
|
262
289
|
self.logger.warn("icalendar_fetch_error",
|
263
|
-
response_body
|
290
|
+
response_body: loggable_body, response_status:, request_url:, calendar_external_id:,)
|
264
291
|
message = Webhookdb::Messages::ErrorIcalendarFetch.new(
|
265
292
|
self.service_integration,
|
266
293
|
calendar_external_id,
|
@@ -269,7 +296,7 @@ The secret to use for signing is:
|
|
269
296
|
request_url:,
|
270
297
|
request_method: "GET",
|
271
298
|
)
|
272
|
-
self.service_integration.organization.alerting.dispatch_alert(message)
|
299
|
+
self.service_integration.organization.alerting.dispatch_alert(message, separate_connection: false)
|
273
300
|
end
|
274
301
|
|
275
302
|
def _retryable_client_error?(e, request_url:)
|
@@ -299,7 +326,7 @@ The secret to use for signing is:
|
|
299
326
|
end
|
300
327
|
|
301
328
|
class EventProcessor
|
302
|
-
attr_reader :upserted_identities
|
329
|
+
attr_reader :upserted_identities, :read_bytes
|
303
330
|
|
304
331
|
def initialize(io, upserter)
|
305
332
|
@io = io
|
@@ -316,6 +343,9 @@ The secret to use for signing is:
|
|
316
343
|
# We need to keep track of how many events each UID spawns,
|
317
344
|
# so we can delete any with a higher count.
|
318
345
|
@max_sequence_num_by_uid = {}
|
346
|
+
# Keep track of the bytes we've read from the file.
|
347
|
+
# Never trust Content-Length headers for ical feeds.
|
348
|
+
@read_bytes = 0
|
319
349
|
end
|
320
350
|
|
321
351
|
def delete_condition
|
@@ -474,7 +504,11 @@ The secret to use for signing is:
|
|
474
504
|
def _ical_entry_from_ruby(r, entry, is_date)
|
475
505
|
return {"v" => r.strftime("%Y%m%d")} if is_date
|
476
506
|
return {"v" => r.strftime("%Y%m%dT%H%M%SZ")} if r.zone == "UTC"
|
477
|
-
|
507
|
+
tzid = entry["TZID"]
|
508
|
+
return {"v" => r.strftime("%Y%m%dT%H%M%S"), "TZID" => tzid} if tzid
|
509
|
+
value = entry.fetch("v")
|
510
|
+
return {"v" => value} if value.end_with?("Z")
|
511
|
+
raise "Cannot create ical entry from: #{r}, #{entry}, is_date: #{is_date}"
|
478
512
|
end
|
479
513
|
|
480
514
|
def _icecube_rule_from_ical(ical)
|
@@ -483,11 +517,20 @@ The secret to use for signing is:
|
|
483
517
|
# IceCube errors, because `day_of_month` isn't valid on a WeeklyRule.
|
484
518
|
# In this case, we need to sanitize the string to remove the offending rule piece.
|
485
519
|
# There are probably many other offending formats, but we'll add them here as needed.
|
520
|
+
unambiguous_ical = nil
|
486
521
|
if ical.include?("FREQ=WEEKLY") && ical.include?("BYMONTHDAY=")
|
487
|
-
|
488
|
-
|
489
|
-
|
490
|
-
|
522
|
+
unambiguous_ical = ical.gsub(/BYMONTHDAY=[\d,]+/, "")
|
523
|
+
elsif ical.include?("FREQ=MONTHLY") && ical.include?("BYYEARDAY=") && ical.include?("BYMONTHDAY=")
|
524
|
+
# Another rule: FREQ=MONTHLY;INTERVAL=3;BYYEARDAY=14;BYMONTHDAY=14
|
525
|
+
# Apple interprets this as monthly on the 14th; rrule.js interprets this as never happening.
|
526
|
+
# 'day_of_year' isn't valid on a MonthlyRule, so delete the BYYEARDAY component.
|
527
|
+
unambiguous_ical = ical.gsub(/BYYEARDAY=[\d,]+/, "")
|
528
|
+
end
|
529
|
+
if unambiguous_ical
|
530
|
+
unambiguous_ical.delete_prefix! ";"
|
531
|
+
unambiguous_ical.delete_suffix! ";"
|
532
|
+
unambiguous_ical.squeeze!(";")
|
533
|
+
ical = unambiguous_ical
|
491
534
|
end
|
492
535
|
return IceCube::IcalParser.rule_from_ical(ical)
|
493
536
|
end
|
@@ -507,6 +550,7 @@ The secret to use for signing is:
|
|
507
550
|
vevent_lines = []
|
508
551
|
in_vevent = false
|
509
552
|
while (line = @io.gets)
|
553
|
+
@read_bytes += line.size
|
510
554
|
begin
|
511
555
|
line.rstrip!
|
512
556
|
rescue Encoding::CompatibilityError
|
@@ -22,10 +22,9 @@ module Webhookdb::Replicator::IntercomV1Mixin
|
|
22
22
|
# webhook verification, which means that webhooks actually don't require any setup on the integration level. Thus,
|
23
23
|
# `supports_webhooks` is false.
|
24
24
|
def find_auth_integration
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
# rubocop:enable Naming/MemoizedInstanceVariableName
|
25
|
+
return @find_auth_integration ||= Webhookdb::Replicator.find_at_root!(
|
26
|
+
self.service_integration, service_name: "intercom_marketplace_root_v1",
|
27
|
+
)
|
29
28
|
end
|
30
29
|
|
31
30
|
def intercom_auth_headers
|
@@ -93,6 +92,28 @@ module Webhookdb::Replicator::IntercomV1Mixin
|
|
93
92
|
timeout: Webhookdb::Intercom.http_timeout,
|
94
93
|
)
|
95
94
|
rescue Webhookdb::Http::Error => e
|
95
|
+
is_token_suspended = e.status == 401 &&
|
96
|
+
e.response["errors"].present? &&
|
97
|
+
e.response["errors"].any? { |er| er["code"] == "token_suspended" }
|
98
|
+
if is_token_suspended
|
99
|
+
root_sint = self.find_auth_integration
|
100
|
+
message = "Organization has closed their Intercom workspace and this integration should be deleted. " \
|
101
|
+
"From a console, run: " \
|
102
|
+
"Webhookdb::ServiceIntegration[#{root_sint.id}].destroy_self_and_all_dependents"
|
103
|
+
Webhookdb::DeveloperAlert.new(
|
104
|
+
subsystem: "Intercom Workspace Closed Error",
|
105
|
+
emoji: ":hook:",
|
106
|
+
fallback: message,
|
107
|
+
fields: [
|
108
|
+
{title: "Organization", value: root_sint.organization.name, short: true},
|
109
|
+
{title: "Integration ID", value: root_sint.id.to_s, short: true},
|
110
|
+
{title: "Instructions", value: message},
|
111
|
+
],
|
112
|
+
).emit
|
113
|
+
# Noop here since there's nothing to do, the developer alert takes care of notifying
|
114
|
+
# so no need to error or log.
|
115
|
+
return [], nil
|
116
|
+
end
|
96
117
|
# We are looking to catch the "api plan restricted" error. This is always a 403 and every
|
97
118
|
# 403 will be an "api plan restricted" error according to the API documentation. Because we
|
98
119
|
# specify the API version in our headers we can expect that this won't change.
|
@@ -1,6 +1,8 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "webhookdb/errors"
|
3
4
|
require "webhookdb/signalwire"
|
5
|
+
require "webhookdb/messages/error_generic_backfill"
|
4
6
|
|
5
7
|
class Webhookdb::Replicator::SignalwireMessageV1 < Webhookdb::Replicator::Base
|
6
8
|
include Appydays::Loggable
|
@@ -180,4 +182,33 @@ Press 'Show' next to the newly-created API token, and copy it.)
|
|
180
182
|
|
181
183
|
return messages, data["next_page_uri"]
|
182
184
|
end
|
185
|
+
|
186
|
+
def on_backfill_error(be)
|
187
|
+
e = Webhookdb::Errors.find_cause(be) do |ex|
|
188
|
+
next true if ex.is_a?(Webhookdb::Http::Error) && ex.status == 401
|
189
|
+
next true if ex.is_a?(::SocketError)
|
190
|
+
end
|
191
|
+
return unless e
|
192
|
+
if e.is_a?(::SocketError)
|
193
|
+
response_status = 0
|
194
|
+
response_body = e.message
|
195
|
+
request_url = "<unknown>"
|
196
|
+
request_method = "<unknown>"
|
197
|
+
else
|
198
|
+
response_status = e.status
|
199
|
+
response_body = e.body
|
200
|
+
request_url = e.uri.to_s
|
201
|
+
request_method = e.http_method
|
202
|
+
end
|
203
|
+
self.logger.warn("signalwire_backfill_error", response_body:, response_status:, request_url:)
|
204
|
+
message = Webhookdb::Messages::ErrorGenericBackfill.new(
|
205
|
+
self.service_integration,
|
206
|
+
response_status:,
|
207
|
+
response_body:,
|
208
|
+
request_url:,
|
209
|
+
request_method:,
|
210
|
+
)
|
211
|
+
self.service_integration.organization.alerting.dispatch_alert(message)
|
212
|
+
return true
|
213
|
+
end
|
183
214
|
end
|