webhookdb 1.0.1 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
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.