webhookdb 1.4.0 → 1.5.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.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. data/db/migrations/026_undo_integration_backfill_cursor.rb +2 -0
  3. data/db/migrations/032_remove_db_defaults.rb +2 -0
  4. data/db/migrations/043_text_search.rb +2 -0
  5. data/db/migrations/047_sync_parallelism.rb +9 -0
  6. data/db/migrations/048_sync_stats.rb +9 -0
  7. data/db/migrations/049_error_handlers.rb +18 -0
  8. data/db/migrations/050_logged_webhook_indices.rb +25 -0
  9. data/db/migrations/051_partitioning.rb +9 -0
  10. data/integration/async_spec.rb +0 -2
  11. data/integration/service_integrations_spec.rb +0 -2
  12. data/lib/amigo/durable_job.rb +2 -2
  13. data/lib/amigo/job_in_context.rb +12 -0
  14. data/lib/webhookdb/api/entities.rb +6 -2
  15. data/lib/webhookdb/api/error_handlers.rb +104 -0
  16. data/lib/webhookdb/api/helpers.rb +8 -1
  17. data/lib/webhookdb/api/icalproxy.rb +22 -0
  18. data/lib/webhookdb/api/install.rb +2 -1
  19. data/lib/webhookdb/api/saved_queries.rb +1 -0
  20. data/lib/webhookdb/api/saved_views.rb +1 -0
  21. data/lib/webhookdb/api/service_integrations.rb +1 -1
  22. data/lib/webhookdb/api/sync_targets.rb +1 -1
  23. data/lib/webhookdb/api/system.rb +5 -0
  24. data/lib/webhookdb/api/webhook_subscriptions.rb +1 -0
  25. data/lib/webhookdb/api.rb +4 -1
  26. data/lib/webhookdb/apps.rb +4 -0
  27. data/lib/webhookdb/async/autoscaler.rb +10 -0
  28. data/lib/webhookdb/async/job.rb +4 -0
  29. data/lib/webhookdb/async/scheduled_job.rb +4 -0
  30. data/lib/webhookdb/async.rb +2 -0
  31. data/lib/webhookdb/backfiller.rb +17 -4
  32. data/lib/webhookdb/concurrent.rb +96 -0
  33. data/lib/webhookdb/connection_cache.rb +29 -8
  34. data/lib/webhookdb/customer.rb +2 -2
  35. data/lib/webhookdb/database_document.rb +1 -1
  36. data/lib/webhookdb/db_adapter/default_sql.rb +1 -14
  37. data/lib/webhookdb/db_adapter/partition.rb +14 -0
  38. data/lib/webhookdb/db_adapter/partitioning.rb +8 -0
  39. data/lib/webhookdb/db_adapter/pg.rb +77 -5
  40. data/lib/webhookdb/db_adapter/snowflake.rb +15 -6
  41. data/lib/webhookdb/db_adapter.rb +24 -2
  42. data/lib/webhookdb/fixtures/logged_webhooks.rb +4 -0
  43. data/lib/webhookdb/fixtures/organization_error_handlers.rb +20 -0
  44. data/lib/webhookdb/http.rb +29 -15
  45. data/lib/webhookdb/icalendar.rb +30 -9
  46. data/lib/webhookdb/jobs/amigo_test_jobs.rb +1 -1
  47. data/lib/webhookdb/jobs/backfill.rb +21 -25
  48. data/lib/webhookdb/jobs/create_mirror_table.rb +3 -4
  49. data/lib/webhookdb/jobs/deprecated_jobs.rb +2 -0
  50. data/lib/webhookdb/jobs/emailer.rb +2 -1
  51. data/lib/webhookdb/jobs/front_signalwire_message_channel_sync_inbound.rb +15 -0
  52. data/lib/webhookdb/jobs/icalendar_delete_stale_cancelled_events.rb +7 -2
  53. data/lib/webhookdb/jobs/icalendar_enqueue_syncs.rb +74 -11
  54. data/lib/webhookdb/jobs/icalendar_enqueue_syncs_for_urls.rb +22 -0
  55. data/lib/webhookdb/jobs/icalendar_sync.rb +21 -9
  56. data/lib/webhookdb/jobs/increase_event_handler.rb +3 -2
  57. data/lib/webhookdb/jobs/logged_webhooks_replay.rb +5 -3
  58. data/lib/webhookdb/jobs/message_dispatched.rb +1 -0
  59. data/lib/webhookdb/jobs/model_event_system_log_tracker.rb +7 -0
  60. data/lib/webhookdb/jobs/monitor_metrics.rb +1 -1
  61. data/lib/webhookdb/jobs/organization_database_migration_notify.rb +32 -0
  62. data/lib/webhookdb/jobs/organization_database_migration_run.rb +4 -6
  63. data/lib/webhookdb/jobs/organization_error_handler_dispatch.rb +26 -0
  64. data/lib/webhookdb/jobs/prepare_database_connections.rb +1 -0
  65. data/lib/webhookdb/jobs/process_webhook.rb +11 -12
  66. data/lib/webhookdb/jobs/renew_watch_channel.rb +7 -10
  67. data/lib/webhookdb/jobs/replication_migration.rb +5 -2
  68. data/lib/webhookdb/jobs/reset_code_create_dispatch.rb +1 -2
  69. data/lib/webhookdb/jobs/scheduled_backfills.rb +2 -2
  70. data/lib/webhookdb/jobs/send_invite.rb +3 -2
  71. data/lib/webhookdb/jobs/send_test_webhook.rb +1 -3
  72. data/lib/webhookdb/jobs/send_webhook.rb +4 -5
  73. data/lib/webhookdb/jobs/stale_row_deleter.rb +31 -0
  74. data/lib/webhookdb/jobs/sync_target_enqueue_scheduled.rb +3 -0
  75. data/lib/webhookdb/jobs/sync_target_run_sync.rb +9 -15
  76. data/lib/webhookdb/jobs/webhook_subscription_delivery_event.rb +5 -8
  77. data/lib/webhookdb/liquid/expose.rb +1 -1
  78. data/lib/webhookdb/liquid/filters.rb +1 -1
  79. data/lib/webhookdb/liquid/partial.rb +2 -2
  80. data/lib/webhookdb/logged_webhook/resilient.rb +3 -3
  81. data/lib/webhookdb/logged_webhook.rb +16 -2
  82. data/lib/webhookdb/message/email_transport.rb +1 -1
  83. data/lib/webhookdb/message.rb +2 -2
  84. data/lib/webhookdb/messages/error_generic_backfill.rb +2 -0
  85. data/lib/webhookdb/messages/error_icalendar_fetch.rb +2 -0
  86. data/lib/webhookdb/messages/error_signalwire_send_sms.rb +2 -0
  87. data/lib/webhookdb/organization/alerting.rb +50 -4
  88. data/lib/webhookdb/organization/database_migration.rb +1 -1
  89. data/lib/webhookdb/organization/db_builder.rb +4 -3
  90. data/lib/webhookdb/organization/error_handler.rb +141 -0
  91. data/lib/webhookdb/organization.rb +62 -9
  92. data/lib/webhookdb/postgres/model_utilities.rb +2 -0
  93. data/lib/webhookdb/postgres.rb +1 -3
  94. data/lib/webhookdb/replicator/base.rb +136 -29
  95. data/lib/webhookdb/replicator/base_stale_row_deleter.rb +165 -0
  96. data/lib/webhookdb/replicator/email_octopus_contact_v1.rb +0 -1
  97. data/lib/webhookdb/replicator/fake.rb +100 -88
  98. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +105 -44
  99. data/lib/webhookdb/replicator/github_repo_v1_mixin.rb +17 -0
  100. data/lib/webhookdb/replicator/icalendar_calendar_v1.rb +144 -23
  101. data/lib/webhookdb/replicator/icalendar_event_v1.rb +20 -44
  102. data/lib/webhookdb/replicator/icalendar_event_v1_partitioned.rb +33 -0
  103. data/lib/webhookdb/replicator/intercom_contact_v1.rb +1 -0
  104. data/lib/webhookdb/replicator/intercom_conversation_v1.rb +1 -0
  105. data/lib/webhookdb/replicator/intercom_v1_mixin.rb +24 -2
  106. data/lib/webhookdb/replicator/partitionable_mixin.rb +116 -0
  107. data/lib/webhookdb/replicator/shopify_v1_mixin.rb +1 -1
  108. data/lib/webhookdb/replicator/signalwire_message_v1.rb +1 -2
  109. data/lib/webhookdb/replicator/sponsy_v1_mixin.rb +1 -1
  110. data/lib/webhookdb/replicator/transistor_episode_stats_v1.rb +0 -1
  111. data/lib/webhookdb/replicator.rb +4 -1
  112. data/lib/webhookdb/service/helpers.rb +4 -0
  113. data/lib/webhookdb/service/middleware.rb +6 -2
  114. data/lib/webhookdb/service_integration.rb +5 -0
  115. data/lib/webhookdb/signalwire.rb +1 -1
  116. data/lib/webhookdb/spec_helpers/async.rb +0 -4
  117. data/lib/webhookdb/spec_helpers/sentry.rb +32 -0
  118. data/lib/webhookdb/spec_helpers/shared_examples_for_replicators.rb +87 -1
  119. data/lib/webhookdb/spec_helpers.rb +1 -0
  120. data/lib/webhookdb/sync_target.rb +195 -29
  121. data/lib/webhookdb/tasks/admin.rb +1 -1
  122. data/lib/webhookdb/tasks/annotate.rb +1 -1
  123. data/lib/webhookdb/tasks/db.rb +13 -1
  124. data/lib/webhookdb/tasks/docs.rb +1 -1
  125. data/lib/webhookdb/tasks/fixture.rb +1 -1
  126. data/lib/webhookdb/tasks/message.rb +1 -1
  127. data/lib/webhookdb/tasks/regress.rb +1 -1
  128. data/lib/webhookdb/tasks/release.rb +1 -1
  129. data/lib/webhookdb/tasks/sidekiq.rb +1 -1
  130. data/lib/webhookdb/tasks/specs.rb +1 -1
  131. data/lib/webhookdb/version.rb +1 -1
  132. data/lib/webhookdb/webhook_subscription.rb +2 -3
  133. data/lib/webhookdb.rb +3 -1
  134. metadata +88 -54
  135. data/lib/webhookdb/jobs/organization_database_migration_notify_finished.rb +0 -21
  136. data/lib/webhookdb/jobs/organization_database_migration_notify_started.rb +0 -21
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "webhookdb/replicator/partitionable_mixin"
4
+
3
5
  class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
4
6
  extend Webhookdb::MethodUtilities
5
7
 
@@ -10,18 +12,24 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
10
12
  singleton_attr_accessor :process_webhooks_synchronously
11
13
  singleton_attr_accessor :obfuscate_headers_for_logging
12
14
  singleton_attr_accessor :requires_sequence
15
+ singleton_attr_accessor :extra_index_specs
13
16
 
14
- def self.descriptor
15
- return Webhookdb::Replicator::Descriptor.new(
16
- name: "fake_v1",
17
- ctor: ->(sint) { Webhookdb::Replicator::Fake.new(sint) },
17
+ def self._descriptor(**kw)
18
+ clsname = self.name.split("::").last
19
+ opts = {
20
+ name: clsname.underscore + "_v1",
21
+ ctor: ->(sint) { self.new(sint) },
18
22
  feature_roles: ["internal"],
19
- resource_name_singular: "Fake",
23
+ resource_name_singular: clsname,
20
24
  supports_webhooks: true,
21
25
  supports_backfill: true,
22
- )
26
+ }
27
+ opts.merge!(kw)
28
+ return Webhookdb::Replicator::Descriptor.new(**opts)
23
29
  end
24
30
 
31
+ def self.descriptor = self._descriptor
32
+
25
33
  def self.reset
26
34
  self.webhook_response = Webhookdb::WebhookResponse.ok
27
35
  self.upsert_has_deps = false
@@ -30,6 +38,11 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
30
38
  self.process_webhooks_synchronously = nil
31
39
  self.obfuscate_headers_for_logging = []
32
40
  self.requires_sequence = false
41
+ self.descendants&.each do |d|
42
+ d.reset if d.respond_to?(:reset)
43
+ end
44
+ self.extra_index_specs = nil
45
+ self.descendants&.each(&:reset)
33
46
  end
34
47
 
35
48
  def self.stub_backfill_request(items, status: 200)
@@ -121,6 +134,8 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
121
134
  return self.class.requires_sequence
122
135
  end
123
136
 
137
+ def _extra_index_specs = super + (self.class.extra_index_specs || [])
138
+
124
139
  def dispatch_request_to(request)
125
140
  return self.class.dispatch_request_to_hook.call(request) if self.class.dispatch_request_to_hook
126
141
  return super
@@ -138,16 +153,7 @@ class Webhookdb::Replicator::Fake < Webhookdb::Replicator::Base
138
153
  end
139
154
 
140
155
  class Webhookdb::Replicator::FakeWithEnrichments < Webhookdb::Replicator::Fake
141
- def self.descriptor
142
- return Webhookdb::Replicator::Descriptor.new(
143
- name: "fake_with_enrichments_v1",
144
- ctor: ->(sint) { Webhookdb::Replicator::FakeWithEnrichments.new(sint) },
145
- feature_roles: ["internal"],
146
- resource_name_singular: "Enriched Fake",
147
- supports_webhooks: true,
148
- supports_backfill: true,
149
- )
150
- end
156
+ def self.descriptor = self._descriptor(supports_webhooks: true, supports_backfill: true)
151
157
 
152
158
  def _denormalized_columns
153
159
  return super << Webhookdb::Replicator::Column.new(:extra, TEXT, from_enrichment: true)
@@ -166,17 +172,7 @@ end
166
172
  class Webhookdb::Replicator::FakeDependent < Webhookdb::Replicator::Fake
167
173
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
168
174
 
169
- def self.descriptor
170
- return Webhookdb::Replicator::Descriptor.new(
171
- name: "fake_dependent_v1",
172
- ctor: ->(sint) { Webhookdb::Replicator::FakeDependent.new(sint) },
173
- feature_roles: ["internal"],
174
- resource_name_singular: "FakeDependent",
175
- dependency_descriptor: Webhookdb::Replicator::Fake.descriptor,
176
- supports_webhooks: true,
177
- supports_backfill: true,
178
- )
179
- end
175
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::Fake.descriptor)
180
176
 
181
177
  def on_dependency_webhook_upsert(replicator, payload, changed:)
182
178
  self.class.on_dependency_webhook_upsert_callback&.call(replicator, payload, changed:)
@@ -194,17 +190,7 @@ end
194
190
  class Webhookdb::Replicator::FakeDependentDependent < Webhookdb::Replicator::Fake
195
191
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
196
192
 
197
- def self.descriptor
198
- return Webhookdb::Replicator::Descriptor.new(
199
- name: "fake_dependent_dependent_v1",
200
- ctor: ->(sint) { Webhookdb::Replicator::FakeDependentDependent.new(sint) },
201
- feature_roles: ["internal"],
202
- resource_name_singular: "FakeDependentDependent",
203
- dependency_descriptor: Webhookdb::Replicator::FakeDependent.descriptor,
204
- supports_webhooks: true,
205
- supports_backfill: true,
206
- )
207
- end
193
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::FakeDependent.descriptor)
208
194
 
209
195
  def on_dependency_webhook_upsert(replicator, payload, changed:)
210
196
  self.class.on_dependency_webhook_upsert_callback&.call(replicator, payload, changed:)
@@ -222,16 +208,7 @@ end
222
208
  class Webhookdb::Replicator::FakeEnqueueBackfillOnCreate < Webhookdb::Replicator::Fake
223
209
  singleton_attr_accessor :on_dependency_webhook_upsert_callback
224
210
 
225
- def self.descriptor
226
- return Webhookdb::Replicator::Descriptor.new(
227
- name: "fake_enqueue_backfill_on_create_v1",
228
- ctor: ->(sint) { Webhookdb::Replicator::FakeEnqueueBackfillOnCreate.new(sint) },
229
- feature_roles: ["internal"],
230
- resource_name_singular: "FakeEnqueueBackfillOnCreate",
231
- supports_webhooks: true,
232
- supports_backfill: true,
233
- )
234
- end
211
+ def self.descriptor = self._descriptor
235
212
 
236
213
  def calculate_backfill_state_machine
237
214
  # To mimic situations where it is possible to enqueue a backfill job on create, this backfill machine does
@@ -246,16 +223,7 @@ class Webhookdb::Replicator::FakeEnqueueBackfillOnCreate < Webhookdb::Replicator
246
223
  end
247
224
 
248
225
  class Webhookdb::Replicator::FakeWebhooksOnly < Webhookdb::Replicator::Fake
249
- def self.descriptor
250
- return Webhookdb::Replicator::Descriptor.new(
251
- name: "fake_webhooks_only_v1",
252
- ctor: ->(sint) { Webhookdb::Replicator::FakeWebhooksOnly.new(sint) },
253
- feature_roles: ["internal"],
254
- resource_name_singular: "Fake Webhooks Only (No Backfill)",
255
- supports_webhooks: true,
256
- supports_backfill: false,
257
- )
258
- end
226
+ def self.descriptor = self._descriptor(supports_backfill: false)
259
227
 
260
228
  def documentation_url = "https://abc.xyz"
261
229
 
@@ -263,31 +231,13 @@ class Webhookdb::Replicator::FakeWebhooksOnly < Webhookdb::Replicator::Fake
263
231
  end
264
232
 
265
233
  class Webhookdb::Replicator::FakeBackfillOnly < Webhookdb::Replicator::Fake
266
- def self.descriptor
267
- return Webhookdb::Replicator::Descriptor.new(
268
- name: "fake_backfill_only_v1",
269
- ctor: ->(sint) { Webhookdb::Replicator::FakeBackfillOnly.new(sint) },
270
- feature_roles: ["internal"],
271
- resource_name_singular: "Fake Backfill Only (No Webhooks)",
272
- supports_webhooks: false,
273
- supports_backfill: true,
274
- )
275
- end
234
+ def self.descriptor = self._descriptor(supports_webhooks: false)
276
235
 
277
236
  def calculate_webhook_state_machine = raise NotImplementedError
278
237
  end
279
238
 
280
239
  class Webhookdb::Replicator::FakeBackfillWithCriteria < Webhookdb::Replicator::Fake
281
- def self.descriptor
282
- return Webhookdb::Replicator::Descriptor.new(
283
- name: "fake_backfill_with_criteria_v1",
284
- ctor: ->(sint) { Webhookdb::Replicator::FakeBackfillWithCriteria.new(sint) },
285
- feature_roles: ["internal"],
286
- resource_name_singular: "Fake Backfill with Criteria",
287
- dependency_descriptor: Webhookdb::Replicator::Fake,
288
- supports_backfill: true,
289
- )
290
- end
240
+ def self.descriptor = self._descriptor(dependency_descriptor: Webhookdb::Replicator::Fake)
291
241
 
292
242
  def _denormalized_columns
293
243
  return super << Webhookdb::Replicator::Column.new(:backfill_kwargs, OBJECT, optional: true)
@@ -316,16 +266,7 @@ class Webhookdb::Replicator::FakeBackfillWithCriteria < Webhookdb::Replicator::F
316
266
  end
317
267
 
318
268
  class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fake
319
- def self.descriptor
320
- return Webhookdb::Replicator::Descriptor.new(
321
- name: "fake_exhaustive_converter_v1",
322
- ctor: ->(sint) { Webhookdb::Replicator::FakeExhaustiveConverter.new(sint) },
323
- feature_roles: ["internal"],
324
- resource_name_singular: "Fake with all converters",
325
- supports_webhooks: true,
326
- supports_backfill: false,
327
- )
328
- end
269
+ def self.descriptor = self._descriptor(supports_backfill: false)
329
270
 
330
271
  def requires_sequence? = true
331
272
 
@@ -456,3 +397,74 @@ class Webhookdb::Replicator::FakeExhaustiveConverter < Webhookdb::Replicator::Fa
456
397
  return cols
457
398
  end
458
399
  end
400
+
401
+ class Webhookdb::Replicator::FakeStaleRow < Webhookdb::Replicator::Fake
402
+ def self.descriptor = self._descriptor
403
+
404
+ def _denormalized_columns
405
+ return [
406
+ Webhookdb::Replicator::Column.new(:at, TIMESTAMP, index: true),
407
+ Webhookdb::Replicator::Column.new(:textcol, TEXT),
408
+ ]
409
+ end
410
+
411
+ class StaleRowDeleter < Webhookdb::Replicator::BaseStaleRowDeleter
412
+ def stale_at = 5.days
413
+ def lookback_window = 5.days
414
+ def updated_at_column = :at
415
+ def stale_condition = {textcol: "cancelled"}
416
+ def chunk_size = 10
417
+ end
418
+
419
+ def stale_row_deleter = StaleRowDeleter.new(self)
420
+ end
421
+
422
+ class Webhookdb::Replicator::FakeStaleRowPartitioned < Webhookdb::Replicator::FakeStaleRow
423
+ include Webhookdb::Replicator::PartitionableMixin
424
+
425
+ def self.descriptor = self._descriptor
426
+
427
+ def partition_method = Webhookdb::DBAdapter::Partitioning::HASH
428
+ def partition_column_name = :textcol
429
+ def partition_value(r) = r.fetch("textcol")
430
+ end
431
+
432
+ class Webhookdb::Replicator::FakeWithWatchChannel < Webhookdb::Replicator::Fake
433
+ singleton_attr_accessor :renew_calls
434
+
435
+ def self.reset
436
+ self.renew_calls = []
437
+ end
438
+
439
+ def self.descriptor = self._descriptor
440
+
441
+ def renew_watch_channel(row_pk:, expiring_before:)
442
+ self.class.renew_calls << {row_pk:, expiring_before:}
443
+ end
444
+ end
445
+
446
+ class Webhookdb::Replicator::FakeHashPartition < Webhookdb::Replicator::Fake
447
+ def self.descriptor = self._descriptor
448
+
449
+ include Webhookdb::Replicator::PartitionableMixin
450
+
451
+ def _denormalized_columns
452
+ d = super
453
+ d << Webhookdb::Replicator::Column.new(:hashkey, INTEGER, optional: true)
454
+ return d
455
+ end
456
+
457
+ def partition_method = Webhookdb::DBAdapter::Partitioning::HASH
458
+ def partition_column_name = :hashkey
459
+ def partition_value(resource) = self._str2inthash(resource.fetch("my_id"))
460
+ end
461
+
462
+ class Webhookdb::Replicator::FakeRangePartition < Webhookdb::Replicator::Fake
463
+ def self.descriptor = self._descriptor
464
+
465
+ include Webhookdb::Replicator::PartitionableMixin
466
+
467
+ def partition_method = Webhookdb::DBAdapter::Partitioning::RANGE
468
+ def partition_column_name = :at
469
+ def partition_value(resource) = resource.fetch("at")
470
+ end
@@ -4,6 +4,7 @@ require "jwt"
4
4
 
5
5
  require "webhookdb/messages/error_signalwire_send_sms"
6
6
  require "webhookdb/replicator/front_v1_mixin"
7
+ require "webhookdb/jobs/front_signalwire_message_channel_sync_inbound"
7
8
 
8
9
  # Front has a system of 'channels' but it is a challenge to use.
9
10
  # This replicator leverages WebhookDB (and our existing Front app)
@@ -154,8 +155,8 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
154
155
  when "message", "message_autoreply"
155
156
  return {
156
157
  type: "success",
157
- external_id: upserted.fetch(:external_id),
158
- external_conversation_id: upserted.fetch(:external_conversation_id),
158
+ external_id: upserted.map { |r| r.fetch(:external_id) }.join(","),
159
+ external_conversation_id: upserted.map { |r| r.fetch(:external_conversation_id) }.join(","),
159
160
  }.to_json
160
161
  else
161
162
  return "{}"
@@ -191,35 +192,52 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
191
192
  "#{replied_to_id}_autoreply"
192
193
  end
193
194
  resource["front_message_id"] = mid
194
- # Use the Front ID to identify this outbound message.
195
- resource["external_id"] = mid
196
195
  resource["direction"] = "outbound"
197
196
  resource["body"] = payload.fetch("text")
198
197
  resource["sender"] = self.support_phone
199
- resource["recipient"] = self._front_recipient_phone(payload)
200
- # All messages get the same conversation with SMS/chat, unlike email.
201
- resource["external_conversation_id"] = resource["recipient"]
202
- return resource, nil
198
+ resources = self._front_recipient_phones(payload).map do |recipient|
199
+ r = resource.dup
200
+ r["recipient"] = recipient
201
+ # The same message can go to multiple recipients, but we want to treat them as separate conversations.
202
+ # That is, we CANNOT use signalwire/front to do 'group chats' since we don't want to
203
+ # allow one user to send a message that is sent to other users (would be a spam vector).
204
+ r["external_id"] = "#{mid}-#{recipient}"
205
+ # Thread this message into the recipient's specific conversation, unlike email.
206
+ r["external_conversation_id"] = recipient
207
+ r
208
+ end
209
+ return resources, nil
203
210
  end
204
211
 
205
- def _front_recipient_phone(payload)
206
- recipient = payload["recipients"].find { |r| r.fetch("role") == "to" }
207
- raise Webhookdb::InvariantViolation, "no recipient found in #{payload}" if recipient.nil?
208
- return self.format_phone(recipient.fetch("handle"))
212
+ def _front_recipient_phones(payload)
213
+ recipients = payload["recipients"].select { |r| r.fetch("role") == "to" }
214
+ raise Webhookdb::InvariantViolation, "no recipient found in #{payload}" if recipients.empty?
215
+ return recipients.map { |r| self.format_phone(r.fetch("handle")) }
209
216
  end
210
217
 
211
- def on_dependency_webhook_upsert(_replicator, payload, changed:)
218
+ def on_dependency_webhook_upsert(_sw_replicator, sw_payload, changed:)
212
219
  return unless changed
213
- return unless payload.fetch(:direction) == "inbound"
214
- return unless payload.fetch(:to) == self.support_phone
215
- body = JSON.parse(payload.fetch(:data))
220
+
221
+ # If the signalwire message is failed, update the Front convo with a notification that the send failed
222
+ failed_notifier_cutoff = Time.now - 4.days
223
+ signalwire_send_failed = sw_payload.fetch(:date_updated) > failed_notifier_cutoff &&
224
+ ["failed", "undelivered"].include?(sw_payload.fetch(:status)) &&
225
+ sw_payload.fetch(:from) == self.support_phone
226
+ self.alert_async_failed_signalwire_send(sw_payload) if signalwire_send_failed
227
+
228
+ # If a message has come in from a user, insert a row so it'll be imported into Front
229
+ signalwire_payload_inbound_to_support = sw_payload.fetch(:direction) == "inbound" &&
230
+ sw_payload.fetch(:to) == self.support_phone
231
+ return unless signalwire_payload_inbound_to_support
232
+
233
+ body = JSON.parse(sw_payload.fetch(:data))
216
234
  body.merge!(
217
- "external_id" => payload.fetch(:signalwire_id),
218
- "signalwire_sid" => payload.fetch(:signalwire_id),
235
+ "external_id" => sw_payload.fetch(:signalwire_id),
236
+ "signalwire_sid" => sw_payload.fetch(:signalwire_id),
219
237
  "direction" => "inbound",
220
- "sender" => payload.fetch(:from),
238
+ "sender" => sw_payload.fetch(:from),
221
239
  "recipient" => self.support_phone,
222
- "external_conversation_id" => payload.fetch(:from),
240
+ "external_conversation_id" => sw_payload.fetch(:from),
223
241
  )
224
242
  self.upsert_webhook_body(body)
225
243
  end
@@ -230,6 +248,59 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
230
248
  Webhookdb::BackfillJob.create_recursive(service_integration: self.service_integration, incremental: true).enqueue
231
249
  end
232
250
 
251
+ # Send alerts for any undelivered or failed messages.
252
+ # The (outbound) message is already created in Front, but if the Signalwire message fails to send,
253
+ # we need to import a new message into Front as a reply explaining why the message failed to send.
254
+ def alert_async_failed_signalwire_send(sw_row)
255
+ idempotency_key = "fsmca-swfail-#{sw_row.fetch(:signalwire_id)}"
256
+ idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
257
+ idempotency.execute do
258
+ # The 'sender' of this message is who the failed message is sent **to**
259
+ sender = sw_row.fetch(:to)
260
+ data = JSON.parse(sw_row.fetch(:data))
261
+ external_id = sw_row.fetch(:signalwire_id)
262
+ external_conversation_id = sender
263
+ trunc_body = data.fetch("body", "")[..25]
264
+ body = "SMS failed to send. Error (#{data['error_code'] || '-'}): #{data['error_message'] || '-'}\n#{trunc_body}"
265
+ kwargs = {sender:, delivered_at: Time.now.to_i, body:, external_id:, external_conversation_id:}
266
+ # The call to Front MUST be done in a job, since if it fails, we would not be able to retry.
267
+ # The code is called after the signalwire payload is upserted and changes;
268
+ # but if this fails, the row won't change again in the future,
269
+ # so this code wouldn't be called again.
270
+ # This is a general problem and should probably have a general solution,
271
+ # but because of the external call, it is important to guard against it.
272
+ Webhookdb::Jobs::FrontSignalwireMessageChannelSyncInbound.perform_async(
273
+ self.service_integration.id, kwargs.as_json,
274
+ )
275
+ end
276
+ end
277
+
278
+ def sync_front_inbound_message(sender:, delivered_at:, body:, external_id:, external_conversation_id:)
279
+ body = {
280
+ sender: {handle: sender},
281
+ body:,
282
+ delivered_at:,
283
+ metadata: {external_id:, external_conversation_id:},
284
+ }
285
+ token = JWT.encode(
286
+ {
287
+ iss: Webhookdb::Front.signalwire_channel_app_id,
288
+ jti: Webhookdb::Front.channel_jwt_jti,
289
+ sub: self.front_channel_id,
290
+ exp: 10.seconds.from_now.to_i,
291
+ },
292
+ Webhookdb::Front.signalwire_channel_app_secret,
293
+ )
294
+ resp = Webhookdb::Http.post(
295
+ "https://api2.frontapp.com/channels/#{self.front_channel_id}/inbound_messages",
296
+ body,
297
+ headers: {"Authorization" => "Bearer #{token}"},
298
+ timeout: Webhookdb::Front.http_timeout,
299
+ logger: self.logger,
300
+ )
301
+ return resp.parsed_response
302
+ end
303
+
233
304
  def _backfillers = [Backfiller.new(self)]
234
305
 
235
306
  class Backfiller < Webhookdb::Backfiller
@@ -319,6 +390,14 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
319
390
  # https://developer.signalwire.com/guides/how-to-troubleshoot-common-messaging-issues
320
391
  raise e if code.nil?
321
392
 
393
+ # Error handling note as of Jan 2025:
394
+ # We are choosing to handle synchronous 'send sms' errors through the org alert system,
395
+ # which will tell developers (not support agents) about the failure.
396
+ # This is because, if this send fails, it will be retried later.
397
+ # For example, if we sent a bulk message to 1000 customers,
398
+ # and Signalwire was down and failed 500 sends, we would just retry the 500 sends.
399
+ # We do NOT want to update the 500 failed conversations, and force support agents
400
+ # to deal with the fallout of retrying a send only to those 500 people.
322
401
  message = Webhookdb::Messages::ErrorSignalwireSendSms.new(
323
402
  @replicator.service_integration,
324
403
  response_status:,
@@ -331,32 +410,14 @@ All of this information can be found in the WebhookDB docs, at https://docs.webh
331
410
  end
332
411
 
333
412
  def _sync_front_inbound(sender:, texted_at:, db_row:, body:)
334
- body = {
335
- sender: {handle: sender},
336
- body: body || "<no body>",
413
+ body ||= "<no body>"
414
+ return @replicator.sync_front_inbound_message(
415
+ sender:,
337
416
  delivered_at: texted_at.to_i,
338
- metadata: {
339
- external_id: db_row.fetch(:external_id),
340
- external_conversation_id: db_row.fetch(:external_conversation_id),
341
- },
342
- }
343
- token = JWT.encode(
344
- {
345
- iss: Webhookdb::Front.signalwire_channel_app_id,
346
- jti: Webhookdb::Front.channel_jwt_jti,
347
- sub: @replicator.front_channel_id,
348
- exp: 10.seconds.from_now.to_i,
349
- },
350
- Webhookdb::Front.signalwire_channel_app_secret,
351
- )
352
- resp = Webhookdb::Http.post(
353
- "https://api2.frontapp.com/channels/#{@replicator.front_channel_id}/inbound_messages",
354
- body,
355
- headers: {"Authorization" => "Bearer #{token}"},
356
- timeout: Webhookdb::Front.http_timeout,
357
- logger: @replicator.logger,
417
+ body:,
418
+ external_id: db_row.fetch(:external_id),
419
+ external_conversation_id: db_row.fetch(:external_conversation_id),
358
420
  )
359
- resp.parsed_response
360
421
  end
361
422
 
362
423
  def fetch_backfill_page(*)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "webhookdb/github"
4
+ require "webhookdb/messages/error_generic_backfill"
4
5
 
5
6
  # Mixin for repo-specific resources like issues and pull requests.
6
7
  module Webhookdb::Replicator::GithubRepoV1Mixin
@@ -236,6 +237,22 @@ Then click 'Generate token'.)
236
237
  return data
237
238
  end
238
239
 
240
+ def on_backfill_error(be)
241
+ e = Webhookdb::Errors.find_cause(be) do |ex|
242
+ next true if ex.is_a?(Webhookdb::Http::Error) && ex.status == 401
243
+ end
244
+ return unless e
245
+ message = Webhookdb::Messages::ErrorGenericBackfill.new(
246
+ self.service_integration,
247
+ response_status: e.status,
248
+ response_body: e.body,
249
+ request_url: e.uri.to_s,
250
+ request_method: e.http_method,
251
+ )
252
+ self.service_integration.organization.alerting.dispatch_alert(message)
253
+ return true
254
+ end
255
+
239
256
  def _prepare_for_insert(resource, event, request, enrichment)
240
257
  # if enrichment is not nil, it's the detailed resource.
241
258
  # See _mixin_fetch_resource_if_field_missing