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.
- 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.
|