webhookdb 1.0.2 → 1.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/data/messages/web/install-customer-login.liquid +1 -1
  3. data/data/messages/web/install-success.liquid +2 -2
  4. data/db/migrations/038_webhookdb_api_key.rb +13 -0
  5. data/lib/webhookdb/api/install.rb +23 -1
  6. data/lib/webhookdb/api/service_integrations.rb +17 -12
  7. data/lib/webhookdb/api/system.rb +13 -2
  8. data/lib/webhookdb/fixtures/service_integrations.rb +4 -0
  9. data/lib/webhookdb/front.rb +23 -11
  10. data/lib/webhookdb/idempotency.rb +94 -33
  11. data/lib/webhookdb/jobs/backfill.rb +24 -5
  12. data/lib/webhookdb/jobs/scheduled_backfills.rb +5 -0
  13. data/lib/webhookdb/oauth/{front.rb → front_provider.rb} +21 -4
  14. data/lib/webhookdb/oauth/{intercom.rb → intercom_provider.rb} +1 -1
  15. data/lib/webhookdb/oauth.rb +8 -7
  16. data/lib/webhookdb/organization/alerting.rb +11 -0
  17. data/lib/webhookdb/postgres/model_utilities.rb +19 -0
  18. data/lib/webhookdb/replicator/column.rb +9 -1
  19. data/lib/webhookdb/replicator/front_conversation_v1.rb +5 -1
  20. data/lib/webhookdb/replicator/front_marketplace_root_v1.rb +2 -5
  21. data/lib/webhookdb/replicator/front_message_v1.rb +5 -1
  22. data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +325 -0
  23. data/lib/webhookdb/replicator/front_v1_mixin.rb +9 -1
  24. data/lib/webhookdb/replicator/signalwire_message_v1.rb +25 -13
  25. data/lib/webhookdb/service_integration.rb +36 -3
  26. data/lib/webhookdb/signalwire.rb +40 -0
  27. data/lib/webhookdb/spec_helpers/citest.rb +18 -9
  28. data/lib/webhookdb/spec_helpers/postgres.rb +9 -0
  29. data/lib/webhookdb/spec_helpers/service.rb +5 -0
  30. data/lib/webhookdb/spec_helpers/shared_examples_for_columns.rb +25 -13
  31. data/lib/webhookdb/spec_helpers/whdb.rb +7 -0
  32. data/lib/webhookdb/sync_target.rb +1 -1
  33. data/lib/webhookdb/tasks/specs.rb +4 -2
  34. data/lib/webhookdb/version.rb +1 -1
  35. data/lib/webhookdb.rb +14 -0
  36. metadata +34 -4
@@ -10,7 +10,7 @@ class Webhookdb::Replicator::FrontMarketplaceRootV1 < Webhookdb::Replicator::Bas
10
10
  return Webhookdb::Replicator::Descriptor.new(
11
11
  name: "front_marketplace_root_v1",
12
12
  ctor: self,
13
- feature_roles: ["front"],
13
+ feature_roles: [],
14
14
  resource_name_singular: "Front Auth",
15
15
  resource_name_plural: "Front Auth",
16
16
  supports_webhooks: true,
@@ -47,9 +47,6 @@ class Webhookdb::Replicator::FrontMarketplaceRootV1 < Webhookdb::Replicator::Bas
47
47
  end
48
48
 
49
49
  def calculate_webhook_state_machine
50
- step = Webhookdb::Replicator::StateMachineStep.new
51
- step.output = "This integration cannot be modified through the command line."
52
- step.completed
53
- return step
50
+ return Webhookdb::Replicator::FrontV1Mixin.marketplace_only_state_machine
54
51
  end
55
52
  end
@@ -11,7 +11,7 @@ class Webhookdb::Replicator::FrontMessageV1 < Webhookdb::Replicator::Base
11
11
  return Webhookdb::Replicator::Descriptor.new(
12
12
  name: "front_message_v1",
13
13
  ctor: self,
14
- feature_roles: ["front"],
14
+ feature_roles: [],
15
15
  resource_name_singular: "Front Message",
16
16
  dependency_descriptor: Webhookdb::Replicator::FrontMarketplaceRootV1.descriptor,
17
17
  supports_webhooks: true,
@@ -42,4 +42,8 @@ class Webhookdb::Replicator::FrontMessageV1 < Webhookdb::Replicator::Base
42
42
  def _update_where_expr
43
43
  return self.qualified_table_sequel_identifier[:data] !~ Sequel[:excluded][:data]
44
44
  end
45
+
46
+ def calculate_webhook_state_machine
47
+ return Webhookdb::Replicator::FrontV1Mixin.marketplace_only_state_machine
48
+ end
45
49
  end
@@ -0,0 +1,325 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jwt"
4
+
5
+ require "webhookdb/replicator/front_v1_mixin"
6
+
7
+ # Front has a system of 'channels' but it is a challenge to use.
8
+ # This replicator leverages WebhookDB (and our existing Front app)
9
+ # to integrate Front and SignalWire messages,
10
+ # using a sort of two-way sync that implements the necessary Front channel contrWcts.
11
+ #
12
+ # Note: In the future, we can abstract this to support other channels, with minimal changes.
13
+ #
14
+ # We have the following concepts to keep in mind:
15
+ #
16
+ # - The front_message_v1 replicator stores ALL messages in Front (inbound and outbound).
17
+ # - The signalwire_message_v1 replicator stores ALL messages in SignalWire (inbound and outbound).
18
+ # - For two-way sync, we care that Outbound Front messages are turned into Outbound SignalWire messages,
19
+ # and Inbound SignalWire messages are turned into Inbound Front messages.
20
+ # - This means that, for the purpose of a two-way sync, this replicator can 'enqueue' deliveries by storing
21
+ # a row with *either* a Front message id (query Front for all outbound messages),
22
+ # *or* SignalWire message id (query signalwire for all inbound messages). When a row has *both* ids,
23
+ # it means it has been "delivered", so to speak.
24
+ # - We can ignore inbound Front messages and outbound SignalWire messages
25
+ # (stored in their respective replicators), since those are created by this replicator.
26
+ #
27
+ # This means that, rather than having to manage state between two event-based systems,
28
+ # we can *converge* to a correct state based on a given state.
29
+ # This is much easier (possible?) to reason about and test,
30
+ # and makes it possible to reuse code,
31
+ #
32
+ # The order of operations is:
33
+ # - The channel description instructs the user to go to /v1/install/front_signalwire/setup.
34
+ # - This loads a terminal, showing instructions for how to set up
35
+ # (enabling the WebhookDB Front app, setting up SignalWire).
36
+ # - The state machine also asks for the phone number to use to send messages.
37
+ # - The phone number used to send messages is stored in the api_url.
38
+ # - The state machine prints out the API token to use in Front.
39
+ # - The api token is stored in the 'webhookdb_api_key' field, which is searchable.
40
+ # - The user is directed to Front, to install the WebhookDB SignalWire channel.
41
+ # - The user inputs their API token and connects the channel.
42
+ # - Front makes an 'authorization' request to /v1/install/front_signalwire/authorization.
43
+ # - This uses the API key to find the right front_signalwire_message_channel_app_v1 integration
44
+ # via the webhookdb_api_key field.
45
+ # - This stores the channel_id on the integration as the api_url.
46
+ # - Front makes 'message' requests to /v1/install/front_signalwire/message/<opaque id>.
47
+ # - This upserts a DB row into the front_message_v1 replicator.
48
+ # - It also enqueues a backfill of this replicator.
49
+ # - Front can make a 'delete' request to /v1/install/front_signalwire/message/<opaque id>.
50
+ # - This deletes deletes this service integration.
51
+ # - Because this replicator is a dependent of signalwire_message_v1 (see explanation below),
52
+ # whenever a signalwire row is updated, this replicator will be triggered and enqueue a backfill.
53
+ # - When this replicator backfills, it will:
54
+ # - Look for inbound SMS, and upsert a row into this replication table.
55
+ # - Look for outbound Front messages, and upsert a row into this replication table.
56
+ # - Find replication table rows without a signalwire id, and send an SMS.
57
+ # - Find replication table rows without a Front message id, and create a Front message
58
+ # using https://dev.frontapp.com/reference/sync-inbound-message
59
+ #
60
+ class Webhookdb::Replicator::FrontSignalwireMessageChannelAppV1 < Webhookdb::Replicator::Base
61
+ include Webhookdb::DBAdapter::ColumnTypes
62
+
63
+ def self.descriptor
64
+ return Webhookdb::Replicator::Descriptor.new(
65
+ name: "front_signalwire_message_channel_app_v1",
66
+ ctor: self,
67
+ feature_roles: [],
68
+ resource_name_singular: "Front/SignalWire Message",
69
+ dependency_descriptor: Webhookdb::Replicator::SignalwireMessageV1.descriptor,
70
+ supports_webhooks: true,
71
+ supports_backfill: true,
72
+ api_docs_url: "https://dev.frontapp.com/docs/getting-started-with-partner-channels",
73
+ )
74
+ end
75
+
76
+ def _remote_key_column
77
+ return Webhookdb::Replicator::Column.new(:external_id, TEXT)
78
+ end
79
+
80
+ def _denormalized_columns
81
+ return [
82
+ Webhookdb::Replicator::Column.new(:signalwire_sid, TEXT, optional: true, index: true),
83
+ Webhookdb::Replicator::Column.new(:front_message_id, TEXT, optional: true, index: true),
84
+ Webhookdb::Replicator::Column.new(:external_conversation_id, TEXT, optional: true, index: true),
85
+ Webhookdb::Replicator::Column.new(:row_updated_at, TIMESTAMP, defaulter: :now, optional: true, index: true),
86
+ Webhookdb::Replicator::Column.new(:direction, TEXT),
87
+ Webhookdb::Replicator::Column.new(:body, TEXT),
88
+ Webhookdb::Replicator::Column.new(:sender, TEXT),
89
+ Webhookdb::Replicator::Column.new(:recipient, TEXT),
90
+ ]
91
+ end
92
+
93
+ def _timestamp_column_name
94
+ return :row_updated_at
95
+ end
96
+
97
+ def _update_where_expr
98
+ return (self.qualified_table_sequel_identifier[:signalwire_sid] =~ nil) |
99
+ (self.qualified_table_sequel_identifier[:front_message_id] =~ nil)
100
+ end
101
+
102
+ def format_phone(s) = Webhookdb::PhoneNumber.format_e164(s)
103
+ def support_phone = self.format_phone(self.service_integration.api_url)
104
+
105
+ def calculate_webhook_state_machine
106
+ if (step = self.calculate_dependency_state_machine_step(dependency_help: ""))
107
+ return step
108
+ end
109
+ step = Webhookdb::Replicator::StateMachineStep.new
110
+ if self.service_integration.api_url.blank?
111
+ step.output = %(This Front Channel will be linked to a specific number in SignalWire.
112
+ Choose the phone number to connect to Front.)
113
+ return step.prompting("Phone number").api_url(self.service_integration)
114
+ end
115
+ self.service_integration.webhookdb_api_key ||= self.service_integration.new_api_key
116
+ self.service_integration.save_changes
117
+ step.output = %(Almost there! You can now finish installing the SignalWire Channel in Front.
118
+
119
+ 1. In Front, go to Settings -> Company -> Channels (in the left nav), Connect a Channel,
120
+ and choose the 'WebhookDB/SignalWire' channel.
121
+ 2. In the 'Token' field, enter this API Key: #{self.service_integration.webhookdb_api_key}
122
+
123
+ If you need to find this key, you can run `webhookdb integrations info front_signalwire_message_channel_app_v1`.
124
+
125
+ All of this information can be found in the WebhookDB docs, at https://docs.webhookdb.com/guides/front-channel-signalwire/)
126
+ return step.completed
127
+ end
128
+
129
+ def calculate_backfill_state_machine
130
+ # The backfills here are not normal backfills, requested by the customer.
131
+ # They are procedurally enqueued when we upsert data.
132
+ # So just reuse the webhook state machine.
133
+ return self.calculate_webhook_state_machine
134
+ end
135
+
136
+ def clear_webhook_information
137
+ # We say we support backfill, so this won't get cleared normally.
138
+ self._clear_backfill_information
139
+ super
140
+ end
141
+
142
+ def process_webhooks_synchronously? = true
143
+
144
+ def synchronous_processing_response_body(upserted:, request:)
145
+ case request.body["type"]
146
+ when "authorization"
147
+ self.front_channel_id = request.body.fetch("payload").fetch("channel_id")
148
+ self.service_integration.save_changes
149
+ return {type: "success", webhook_url: "#{Webhookdb.api_url}/v1/install/front_signalwire/channel"}.to_json
150
+ when "delete"
151
+ self.service_integration.destroy
152
+ return "{}"
153
+ when "message", "message_autoreply"
154
+ return {
155
+ type: "success",
156
+ external_id: upserted.fetch(:external_id),
157
+ external_conversation_id: upserted.fetch(:external_conversation_id),
158
+ }.to_json
159
+ else
160
+ return "{}"
161
+ end
162
+ end
163
+
164
+ def front_channel_id = self.service_integration.backfill_key
165
+
166
+ def front_channel_id=(c)
167
+ self.service_integration.backfill_key = c
168
+ end
169
+
170
+ def _webhook_response(request)
171
+ return Webhookdb::Front.webhook_response(request, Webhookdb::Front.signalwire_channel_app_secret)
172
+ end
173
+
174
+ def _resource_and_event(request)
175
+ type = request.body["type"]
176
+ is_signalwire = type.nil?
177
+ return request.body, nil if is_signalwire
178
+
179
+ # This ends up being called for 'authorization' and 'delete' messages too.
180
+ # Those are handled in the webhook response body.
181
+ is_message_type = ["message", "message_autoreply"].include?(type)
182
+ return nil, nil unless is_message_type
183
+
184
+ resource = request.body.dup
185
+ payload = resource.fetch("payload")
186
+ mid = if type == "message"
187
+ payload.fetch("id")
188
+ else
189
+ replied_to_id = payload["_links"]["related"]["message_replied_to"].split("/").last
190
+ "#{replied_to_id}_autoreply"
191
+ end
192
+ resource["front_message_id"] = mid
193
+ # Use the Front ID to identify this outbound message.
194
+ resource["external_id"] = mid
195
+ resource["direction"] = "outbound"
196
+ resource["body"] = payload.fetch("text")
197
+ resource["sender"] = self.support_phone
198
+ resource["recipient"] = self._front_recipient_phone(payload)
199
+ # All messages get the same conversation with SMS/chat, unlike email.
200
+ resource["external_conversation_id"] = resource["recipient"]
201
+ return resource, nil
202
+ end
203
+
204
+ def _front_recipient_phone(payload)
205
+ recipient = payload["recipients"].find { |r| r.fetch("role") == "to" }
206
+ raise Webhookdb::InvariantViolation, "no recipient found in #{payload}" if recipient.nil?
207
+ return self.format_phone(recipient.fetch("handle"))
208
+ end
209
+
210
+ def on_dependency_webhook_upsert(_replicator, payload, changed:)
211
+ return unless changed
212
+ return unless payload.fetch(:direction) == "inbound"
213
+ return unless payload.fetch(:to) == self.support_phone
214
+ body = JSON.parse(payload.fetch(:data))
215
+ body.merge!(
216
+ "external_id" => payload.fetch(:signalwire_id),
217
+ "signalwire_sid" => payload.fetch(:signalwire_id),
218
+ "direction" => "inbound",
219
+ "sender" => payload.fetch(:from),
220
+ "recipient" => self.support_phone,
221
+ "external_conversation_id" => payload.fetch(:from),
222
+ )
223
+ self.upsert_webhook_body(body)
224
+ end
225
+
226
+ def _notify_dependents(inserting, changed)
227
+ super
228
+ return unless changed
229
+ Webhookdb::BackfillJob.create_recursive(service_integration: self.service_integration, incremental: true).enqueue
230
+ end
231
+
232
+ def _backfillers = [Backfiller.new(self)]
233
+
234
+ class Backfiller < Webhookdb::Backfiller
235
+ def initialize(replicator)
236
+ super()
237
+ @replicator = replicator
238
+ @signalwire_sint = replicator.service_integration.depends_on
239
+ end
240
+
241
+ def handle_item(item)
242
+ front_id = item.fetch(:front_message_id)
243
+ sw_id = item.fetch(:signalwire_sid)
244
+ if (front_id && sw_id) || (!front_id && !sw_id)
245
+ msg = "row should have a front id OR signalwire id, should not have been inserted, or selected: #{item}"
246
+ raise Webhookdb::InvariantViolation, msg
247
+ end
248
+ sender = @replicator.format_phone(item.fetch(:sender))
249
+ recipient = @replicator.format_phone(item.fetch(:recipient))
250
+ body = item.fetch(:body)
251
+ idempotency_key = "fsmca-fims-#{item.fetch(:external_id)}"
252
+ idempotency = Webhookdb::Idempotency.once_ever.stored.using_seperate_connection.under_key(idempotency_key)
253
+ if front_id.nil?
254
+ texted_at = Time.parse(item.fetch(:data).fetch("date_created"))
255
+ if texted_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
256
+ # Do not sync old rows, just mark them synced
257
+ item[:front_message_id] = "skipped_due_to_age"
258
+ else
259
+ # sync the message into Front
260
+ front_response_body = idempotency.execute do
261
+ self._sync_front_inbound(sender:, texted_at:, item:, body:)
262
+ end
263
+ item[:front_message_id] = front_response_body.fetch("message_uid")
264
+ end
265
+ else
266
+ messaged_at = Time.at(item.fetch(:data).fetch("created_at"))
267
+ if messaged_at < Webhookdb::Front.channel_sync_refreshness_cutoff.seconds.ago
268
+ # Do not sync old rows, just mark them synced
269
+ item[:signalwire_sid] = "skipped_due_to_age"
270
+ else
271
+ # send the SMS via signalwire
272
+ signalwire_resp = idempotency.execute do
273
+ Webhookdb::Signalwire.send_sms(
274
+ from: sender,
275
+ to: recipient,
276
+ body:,
277
+ space_url: @signalwire_sint.api_url,
278
+ project_id: @signalwire_sint.backfill_key,
279
+ api_key: @signalwire_sint.backfill_secret,
280
+ logger: @replicator.logger,
281
+ )
282
+ end
283
+ item[:signalwire_sid] = signalwire_resp.fetch("sid")
284
+ end
285
+ end
286
+ @replicator.upsert_webhook_body(item.deep_stringify_keys)
287
+ end
288
+
289
+ def _sync_front_inbound(sender:, texted_at:, item:, body:)
290
+ body = {
291
+ sender: {handle: sender},
292
+ body:,
293
+ delivered_at: texted_at.to_i,
294
+ metadata: {
295
+ external_id: item.fetch(:external_id),
296
+ external_conversation_id: item.fetch(:external_conversation_id),
297
+ },
298
+ }
299
+ token = JWT.encode(
300
+ {
301
+ iss: Webhookdb::Front.signalwire_channel_app_id,
302
+ jti: Webhookdb::Front.channel_jwt_jti,
303
+ sub: @replicator.front_channel_id,
304
+ exp: 10.seconds.from_now.to_i,
305
+ },
306
+ Webhookdb::Front.signalwire_channel_app_secret,
307
+ )
308
+ resp = Webhookdb::Http.post(
309
+ "https://api2.frontapp.com/channels/#{@replicator.front_channel_id}/inbound_messages",
310
+ body,
311
+ headers: {"Authorization" => "Bearer #{token}"},
312
+ timeout: Webhookdb::Front.http_timeout,
313
+ logger: @replicator.logger,
314
+ )
315
+ resp.parsed_response
316
+ end
317
+
318
+ def fetch_backfill_page(*)
319
+ rows = @replicator.admin_dataset do |ds|
320
+ ds.where(Sequel[signalwire_sid: nil] | Sequel[front_message_id: nil]).all
321
+ end
322
+ return rows, nil
323
+ end
324
+ end
325
+ end
@@ -13,10 +13,18 @@ module Webhookdb::Replicator::FrontV1Mixin
13
13
  end
14
14
 
15
15
  def _webhook_response(request)
16
- return Webhookdb::Front.webhook_response(request)
16
+ return Webhookdb::Front.webhook_response(request, Webhookdb::Front.app_secret)
17
17
  end
18
18
 
19
19
  def on_dependency_webhook_upsert(_replicator, _payload, *)
20
20
  return
21
21
  end
22
+
23
+ def self.marketplace_only_state_machine
24
+ step = Webhookdb::Replicator::StateMachineStep.new
25
+ step.output = %(Front integrations can only be enabled through the Front App Store.
26
+ Head over to https://app.frontapp.com/settings/apps/details/webhookdb/overview to set up WebhookDB replication.)
27
+ step.completed
28
+ return step
29
+ end
22
30
  end
@@ -43,6 +43,16 @@ class Webhookdb::Replicator::SignalwireMessageV1 < Webhookdb::Replicator::Base
43
43
  )
44
44
  end
45
45
 
46
+ def process_state_change(field, value, attr: nil)
47
+ if field == "api_url" && value.include?(".")
48
+ value = "https://" + value unless value.include?("://")
49
+ u = URI(value)
50
+ h = u.host.gsub(/\.signalwire\.com$/, "")
51
+ value = h
52
+ end
53
+ return super(field, value, attr:)
54
+ end
55
+
46
56
  def calculate_backfill_state_machine
47
57
  step = Webhookdb::Replicator::StateMachineStep.new
48
58
  unless self.service_integration.api_url.present?
@@ -67,8 +77,12 @@ Go to https://#{self.service_integration.api_url}.signalwire.com/credentials and
67
77
 
68
78
  unless self.service_integration.backfill_secret.present?
69
79
  step.needs_input = true
70
- step.output = %(Let's create or reuse an API token. Press the 'New' button on your dashboard,
71
- name the token something like 'WebhookDB', and under Scopes, ensure the 'Messaging' checkbox is checked.
80
+ step.output = %(Let's create or reuse an API token.
81
+
82
+ Go to https://#{self.service_integration.api_url}.signalwire.com/credentials
83
+ and press the 'New' button.
84
+ Name the token something like 'WebhookDB'.
85
+ Under Scopes, ensure the 'Messaging' checkbox is checked.
72
86
  Then press 'Save'.
73
87
 
74
88
  Press 'Show' next to the newly-created API token, and copy it.)
@@ -139,22 +153,20 @@ Press 'Show' next to the newly-created API token, and copy it.)
139
153
  end
140
154
 
141
155
  def _fetch_backfill_page(pagination_token, last_backfilled:)
142
- url = "https://#{self.service_integration.api_url}.signalwire.com"
156
+ urltail = pagination_token
143
157
  if pagination_token.blank?
144
158
  date_send_max = Date.tomorrow
145
- url += "/2010-04-01/Accounts/#{self.service_integration.backfill_key}/Messages.json" \
146
- "?PageSize=100&DateSend%3C=#{date_send_max}"
147
- else
148
- url += pagination_token
159
+ urltail = "/2010-04-01/Accounts/#{self.service_integration.backfill_key}/Messages.json" \
160
+ "?PageSize=100&DateSend%3C=#{date_send_max}"
149
161
  end
150
- response = Webhookdb::Http.get(
151
- url,
152
- basic_auth: {username: self.service_integration.backfill_key,
153
- password: self.service_integration.backfill_secret,},
162
+ data = Webhookdb::Signalwire.http_request(
163
+ :get,
164
+ urltail,
165
+ space_url: self.service_integration.api_url,
166
+ project_id: self.service_integration.backfill_key,
167
+ api_key: self.service_integration.backfill_secret,
154
168
  logger: self.logger,
155
- timeout: Webhookdb::Signalwire.http_timeout,
156
169
  )
157
- data = response.parsed_response
158
170
  messages = data["messages"]
159
171
 
160
172
  if last_backfilled.present?
@@ -9,8 +9,15 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
9
9
  class TableRenameError < Webhookdb::InvalidInput; end
10
10
 
11
11
  # We limit the information that a user can access through the CLI to these fields.
12
- # Blank string returns all info.
13
- INTEGRATION_INFO_FIELDS = ["id", "service", "table", "url", "webhook_secret", ""].freeze
12
+ INTEGRATION_INFO_FIELDS = {
13
+ "id" => :opaque_id,
14
+ "service" => :service_name,
15
+ "table" => :table_name,
16
+ "url" => :unauthed_webhook_endpoint,
17
+ "webhook_secret" => :webhook_secret,
18
+ "webhookdb_api_key" => :webhookdb_api_key,
19
+ "api_url" => :api_url,
20
+ }.freeze
14
21
 
15
22
  plugin :timestamps
16
23
  plugin :column_encryption do |enc|
@@ -18,6 +25,7 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
18
25
  enc.column :webhook_secret
19
26
  enc.column :backfill_key
20
27
  enc.column :backfill_secret
28
+ enc.column :webhookdb_api_key, searchable: true
21
29
  end
22
30
 
23
31
  many_to_one :organization, class: "Webhookdb::Organization"
@@ -64,11 +72,17 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
64
72
  one_to_many :dependents, key: :depends_on_id, class: self
65
73
  one_to_many :sync_targets, class: "Webhookdb::SyncTarget"
66
74
 
75
+ # @return [Webhookdb::ServiceIntegration]
67
76
  def self.create_disambiguated(service_name, **kwargs)
68
77
  kwargs[:table_name] ||= "#{service_name}_#{SecureRandom.hex(2)}"
69
78
  return self.create(service_name:, **kwargs)
70
79
  end
71
80
 
81
+ # @return [Webhookdb::ServiceIntegration]
82
+ def self.for_api_key(key)
83
+ return self.with_encrypted_value(:webhookdb_api_key, key).first
84
+ end
85
+
72
86
  def can_be_modified_by?(customer)
73
87
  return customer.verified_member_of?(self.organization)
74
88
  end
@@ -254,12 +268,24 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
254
268
  return self.db.select(Sequel.function(:nextval, self.sequence_name)).single_value
255
269
  end
256
270
 
271
+ def new_opaque_id = Webhookdb::Id.new_opaque_id("svi")
272
+
273
+ def ensure_opaque_id = self[:opaque_id] ||= self.new_opaque_id
274
+
275
+ def new_api_key
276
+ k = +"sk/"
277
+ k << self.ensure_opaque_id
278
+ k << "/"
279
+ k << Webhookdb::Id.rand_enc(24)
280
+ return k
281
+ end
282
+
257
283
  #
258
284
  # :Sequel Hooks:
259
285
  #
260
286
 
261
287
  def before_create
262
- self[:opaque_id] ||= Webhookdb::Id.new_opaque_id("svi")
288
+ self.ensure_opaque_id
263
289
  end
264
290
 
265
291
  # @!attribute organization
@@ -286,6 +312,13 @@ class Webhookdb::ServiceIntegration < Webhookdb::Postgres::Model(:service_integr
286
312
  # @!attribute webhook_secret
287
313
  # @return [String] Secret used to sign webhooks.
288
314
 
315
+ # @!attribute webhookdb_api_key
316
+ # @return [String] API Key used in the Whdb-Api-Key header that can be used to identify
317
+ # this service integration (where the opaque id cannot be used),
318
+ # and is a secret so can be used for authentication.
319
+ # Need for this should be rare- it's usually only used outside of the core webhookdb/backfill design
320
+ # like for two-way sync (Front Channel/Signalwire integration, for example).
321
+
289
322
  # @!attribute depends_on
290
323
  # @return [Webhookdb::ServiceIntegration]
291
324
 
@@ -9,5 +9,45 @@ module Webhookdb::Signalwire
9
9
 
10
10
  configurable(:signalwire) do
11
11
  setting :http_timeout, 30
12
+ setting :sms_allowlist, [], convert: ->(s) { s.split }
13
+ end
14
+
15
+ def self.send_sms(from:, to:, body:, project_id:, **kw)
16
+ sms_allowed = self.sms_allowlist.any? { |pattern| File.fnmatch(pattern, to) }
17
+ unless sms_allowed
18
+ self.logger.warn("signalwire_sms_not_allowed", to:)
19
+ return {"sid" => "skipped"}
20
+ end
21
+ return self.http_request(
22
+ :post,
23
+ "/2010-04-01/Accounts/#{project_id}/Messages.json",
24
+ body: {
25
+ From: from,
26
+ To: to,
27
+ Body: body,
28
+ },
29
+ project_id:,
30
+ **kw,
31
+ )
32
+ end
33
+
34
+ def self.http_request(method, tail, space_url:, project_id:, api_key:, logger:, headers: {}, body: nil, **kw)
35
+ url = "https://#{space_url}.signalwire.com" + tail
36
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
37
+ headers["Accept"] = "application/json"
38
+ kw[:body] = URI.encode_www_form(body) if body
39
+ resp = Webhookdb::Http.send(
40
+ method,
41
+ url,
42
+ basic_auth: {
43
+ username: project_id,
44
+ password: api_key,
45
+ },
46
+ logger:,
47
+ timeout: self.http_timeout,
48
+ headers:,
49
+ **kw,
50
+ )
51
+ return resp.parsed_response
12
52
  end
13
53
  end
@@ -4,10 +4,18 @@ require "webhookdb/slack"
4
4
  require "webhookdb/spec_helpers"
5
5
 
6
6
  module Webhookdb::SpecHelpers::Citest
7
- def self.run_tests(folder)
7
+ INTEGRATION_TESTS_DIR = Pathname(__FILE__).dirname.parent.parent.parent + "integration"
8
+
9
+ # Run RSpec against the given folders, create a DatabaseDocument for the html results,
10
+ # and POST to Slack about it.
11
+ def self.run_tests(folders)
8
12
  out = StringIO.new
9
13
  err = StringIO.new
10
- RSpec::Core::Runner.run([folder + "/", "--format", "html"], err, out)
14
+ folders = [folders] unless folders.respond_to?(:to_ary)
15
+ args = folders.map { |f| "#{f}/" }
16
+ args << "--format"
17
+ args << "html"
18
+ RSpec::Core::Runner.run(args, err, out)
11
19
 
12
20
  notifier = Webhookdb::Slack.new_notifier(
13
21
  force_channel: "#webhookdb-notifications",
@@ -17,13 +25,14 @@ module Webhookdb::SpecHelpers::Citest
17
25
  outstring = out.string
18
26
  result = Webhookdb::SpecHelpers::Citest.parse_rspec_html(outstring)
19
27
  unless result.ok?
20
- msg = "Errored or unparseable output running #{folder} tests:\nerror: #{err.string}\nout: #{outstring}"
28
+ msg = "Errored or unparseable output running #{folders.join(', ')} tests:" \
29
+ "\nerror: #{err.string}\nout: #{outstring}"
21
30
  notifier.post text: msg
22
31
  return
23
32
  end
24
33
 
25
- url = self.put_results(folder, result.html)
26
- payload = self.result_to_payload(folder, result, url)
34
+ url = self.put_results(result.html)
35
+ payload = self.result_to_payload(result, url)
27
36
  notifier.post(payload)
28
37
  end
29
38
 
@@ -43,9 +52,9 @@ module Webhookdb::SpecHelpers::Citest
43
52
  return result
44
53
  end
45
54
 
46
- def self.put_results(folder, html)
55
+ def self.put_results(html, key: "integration")
47
56
  now = Time.now
48
- key = "test-results/#{folder}/#{now.year}/#{now.month}/#{now.in_time_zone('UTC').iso8601}.html"
57
+ key = "test-results/#{key}/#{now.year}/#{now.month}/#{now.in_time_zone('UTC').iso8601}.html"
49
58
  doc = Webhookdb::DatabaseDocument.create(
50
59
  key:,
51
60
  content: html,
@@ -55,13 +64,13 @@ module Webhookdb::SpecHelpers::Citest
55
64
  return url
56
65
  end
57
66
 
58
- def self.result_to_payload(folder, result, html_url)
67
+ def self.result_to_payload(result, html_url, prefix: "Integration Tests")
59
68
  color = "good"
60
69
  color = "warning" if result.pending.nonzero?
61
70
  color = "danger" if result.failures.nonzero?
62
71
 
63
72
  return {
64
- text: "Tests for #{folder}: #{result.examples} examples, #{result.failures} failures, #{result.pending} pending",
73
+ text: "#{prefix}: #{result.examples} examples, #{result.failures} failures, #{result.pending} pending",
65
74
  attachments: [
66
75
  {
67
76
  color:,
@@ -31,6 +31,7 @@ module Webhookdb::SpecHelpers::Postgres
31
31
  context.around(:each) do |example|
32
32
  Webhookdb::Postgres.unsafe_skip_transaction_check = true if example.metadata[:no_transaction_check]
33
33
 
34
+ _truncate(example)
34
35
  setting = example.metadata[:db]
35
36
  if setting && setting != :no_transaction
36
37
  Webhookdb::SpecHelpers::Postgres.wrap_example_in_transactions(example)
@@ -44,11 +45,19 @@ module Webhookdb::SpecHelpers::Postgres
44
45
  Webhookdb::Postgres.unsafe_skip_transaction_check = false if example.metadata[:no_transaction_check]
45
46
 
46
47
  truncate_all if example.metadata[:db] == :no_transaction
48
+ _truncate(example)
47
49
  end
48
50
 
49
51
  super
50
52
  end
51
53
 
54
+ module_function def _truncate(example)
55
+ tr = example.metadata[:truncate]
56
+ return unless tr
57
+ tr = [tr] unless tr.respond_to?(:to_ary)
58
+ tr.each(&:truncate)
59
+ end
60
+
52
61
  ### Run the specified +example+ in the context of a transaction for each loaded
53
62
  ### model superclass. Raises if any of the loaded superclasses aren't
54
63
  ### configured.