webhookdb 1.0.2 → 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/data/messages/web/install-customer-login.liquid +1 -1
- data/data/messages/web/install-success.liquid +2 -2
- data/db/migrations/038_webhookdb_api_key.rb +13 -0
- data/lib/webhookdb/api/install.rb +23 -1
- data/lib/webhookdb/api/service_integrations.rb +17 -12
- data/lib/webhookdb/api/system.rb +13 -2
- data/lib/webhookdb/fixtures/service_integrations.rb +4 -0
- data/lib/webhookdb/front.rb +23 -11
- data/lib/webhookdb/idempotency.rb +94 -33
- data/lib/webhookdb/jobs/backfill.rb +24 -5
- data/lib/webhookdb/jobs/scheduled_backfills.rb +5 -0
- data/lib/webhookdb/oauth/{front.rb → front_provider.rb} +21 -4
- data/lib/webhookdb/oauth/{intercom.rb → intercom_provider.rb} +1 -1
- data/lib/webhookdb/oauth.rb +8 -7
- data/lib/webhookdb/organization/alerting.rb +11 -0
- data/lib/webhookdb/postgres/model_utilities.rb +19 -0
- data/lib/webhookdb/replicator/column.rb +9 -1
- data/lib/webhookdb/replicator/front_conversation_v1.rb +5 -1
- data/lib/webhookdb/replicator/front_marketplace_root_v1.rb +2 -5
- data/lib/webhookdb/replicator/front_message_v1.rb +5 -1
- data/lib/webhookdb/replicator/front_signalwire_message_channel_app_v1.rb +325 -0
- data/lib/webhookdb/replicator/front_v1_mixin.rb +9 -1
- data/lib/webhookdb/replicator/signalwire_message_v1.rb +25 -13
- data/lib/webhookdb/service_integration.rb +36 -3
- data/lib/webhookdb/signalwire.rb +40 -0
- data/lib/webhookdb/spec_helpers/citest.rb +18 -9
- data/lib/webhookdb/spec_helpers/postgres.rb +9 -0
- data/lib/webhookdb/spec_helpers/service.rb +5 -0
- data/lib/webhookdb/spec_helpers/shared_examples_for_columns.rb +25 -13
- data/lib/webhookdb/spec_helpers/whdb.rb +7 -0
- data/lib/webhookdb/sync_target.rb +1 -1
- data/lib/webhookdb/tasks/specs.rb +4 -2
- data/lib/webhookdb/version.rb +1 -1
- data/lib/webhookdb.rb +14 -0
- 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: [
|
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
|
-
|
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: [
|
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.
|
71
|
-
|
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
|
-
|
156
|
+
urltail = pagination_token
|
143
157
|
if pagination_token.blank?
|
144
158
|
date_send_max = Date.tomorrow
|
145
|
-
|
146
|
-
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
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
|
-
|
13
|
-
|
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
|
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
|
|
data/lib/webhookdb/signalwire.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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 #{
|
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(
|
26
|
-
payload = self.result_to_payload(
|
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(
|
55
|
+
def self.put_results(html, key: "integration")
|
47
56
|
now = Time.now
|
48
|
-
key = "test-results/#{
|
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(
|
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: "
|
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.
|