relaygrid 0.1.1
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 +7 -0
- data/CHANGELOG.md +87 -0
- data/QUICKSTART.md +112 -0
- data/README.md +249 -0
- data/examples/rails_initializer.rb +37 -0
- data/examples/usage_examples.rb +115 -0
- data/lib/relaygrid/client.rb +171 -0
- data/lib/relaygrid/configuration.rb +96 -0
- data/lib/relaygrid/delivery.rb +129 -0
- data/lib/relaygrid/errors.rb +105 -0
- data/lib/relaygrid/resources/channel_tokens.rb +35 -0
- data/lib/relaygrid/resources/deliveries.rb +101 -0
- data/lib/relaygrid/resources/message_templates.rb +33 -0
- data/lib/relaygrid/resources/messages.rb +63 -0
- data/lib/relaygrid/resources/notifications.rb +108 -0
- data/lib/relaygrid/resources/users.rb +117 -0
- data/lib/relaygrid/send_result.rb +78 -0
- data/lib/relaygrid/version.rb +5 -0
- data/lib/relaygrid/websocket_client.rb +214 -0
- data/lib/relaygrid.rb +74 -0
- data/sig/relaygrid.rbs +4 -0
- metadata +178 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# The receive side: reading a recipient's inbox and marking messages off.
|
|
6
|
+
#
|
|
7
|
+
# `user_id` is always *your* identifier for the user (the API's
|
|
8
|
+
# `external_user_id`), never a RelayGrid internal id.
|
|
9
|
+
class Messages
|
|
10
|
+
def initialize(client)
|
|
11
|
+
@client = client
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Unseen messages for a recipient, newest first. The polling counterpart
|
|
15
|
+
# to `RelayGrid.websocket`, and what an inbox view renders.
|
|
16
|
+
#
|
|
17
|
+
# @param user_id [String]
|
|
18
|
+
# @return [Array<Hash>]
|
|
19
|
+
# @raise [RelayGrid::UserNotFoundError] no such recipient in this account
|
|
20
|
+
def new_for(user_id:)
|
|
21
|
+
body = @client.get(
|
|
22
|
+
"/api/v1/messages/new_messages_for",
|
|
23
|
+
params: { external_user_id: user_id.to_s }
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
Array(body["messages"])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param id [Integer] message id
|
|
30
|
+
# @return [Hash] the message with its deliveries
|
|
31
|
+
def get(id)
|
|
32
|
+
@client.get("/api/v1/messages/#{id}")["message"]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Mark a message read. With a channel id, the matching delivery is marked
|
|
36
|
+
# opened (which is what drives open-rate analytics); without one, only the
|
|
37
|
+
# message's own seen flag is set.
|
|
38
|
+
#
|
|
39
|
+
# @return [Hash] the updated message
|
|
40
|
+
def mark_as_seen(id, notification_channel_id: nil)
|
|
41
|
+
body = {}
|
|
42
|
+
body[:notification_channel_id] = notification_channel_id if notification_channel_id
|
|
43
|
+
|
|
44
|
+
@client.patch("/api/v1/messages/#{id}/mark_as_seen", body: body)["message"]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Confirm a push message reached the device. Normally the browser/device
|
|
48
|
+
# does this over the websocket; this is the HTTP equivalent.
|
|
49
|
+
#
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def mark_as_delivered(id)
|
|
52
|
+
@client.patch("/api/v1/messages/#{id}/mark_as_delivered")["success"] == true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Rendered subject and body with the message's attributes substituted in.
|
|
56
|
+
#
|
|
57
|
+
# @return [Hash] `{ "rendered_subject" => ..., "rendered_body" => ... }`
|
|
58
|
+
def render(id)
|
|
59
|
+
@client.get("/api/v1/messages/#{id}/render_message")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# Sending. The developer contract the gem exists for: a user, a template
|
|
6
|
+
# name, and the attributes the template needs.
|
|
7
|
+
class Notifications
|
|
8
|
+
# Recipient details a send may carry alongside the id. `email` becomes the
|
|
9
|
+
# contact email deliveries resolve their recipient through.
|
|
10
|
+
RECIPIENT_ATTRIBUTES = %i[first_name middle_name last_name email].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Send a notification, creating or updating the recipient on the way.
|
|
17
|
+
#
|
|
18
|
+
# @param user [Hash] `{ id:, first_name:, last_name:, middle_name:,
|
|
19
|
+
# email: }`. `id` is the *your-system* identifier -- it maps to the API's
|
|
20
|
+
# `external_user_id`. RelayGrid's internal ids are never exposed here.
|
|
21
|
+
# May also be a bare String/Integer id for a recipient that already
|
|
22
|
+
# exists, in which case nothing about them is sent.
|
|
23
|
+
#
|
|
24
|
+
# `email` is optional but load-bearing: email deliveries resolve their
|
|
25
|
+
# recipient through the contact stored for that channel type, so a send
|
|
26
|
+
# over an email channel for a user with no email on file produces a
|
|
27
|
+
# delivery that fails with "no contact on file". Push needs no contact.
|
|
28
|
+
# Passing an empty string removes the contact.
|
|
29
|
+
# @param template [String] the message template's name
|
|
30
|
+
# @param attributes [Hash] values for the template's `{{placeholders}}`
|
|
31
|
+
# @return [RelayGrid::SendResult]
|
|
32
|
+
#
|
|
33
|
+
# @raise [RelayGrid::TemplateNotFoundError] no such template in the account
|
|
34
|
+
# @raise [RelayGrid::LimitExceededError] the monthly notification limit is spent
|
|
35
|
+
# @raise [RelayGrid::ValidationError] the payload was rejected
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# result = RelayGrid.client.notify(
|
|
39
|
+
# user: { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
|
|
40
|
+
# template: "order_shipped",
|
|
41
|
+
# attributes: { order_number: "1042", eta: "tomorrow" }
|
|
42
|
+
# )
|
|
43
|
+
# result.delivery_ids # => [201, 202]
|
|
44
|
+
def notify(user:, template:, attributes: {})
|
|
45
|
+
message = {
|
|
46
|
+
message_template_name: template.to_s,
|
|
47
|
+
message_attributes_attributes: format_attributes(attributes),
|
|
48
|
+
}
|
|
49
|
+
message.merge!(recipient_params(user))
|
|
50
|
+
|
|
51
|
+
SendResult.new(@client.post("/api/v1/messages", body: { message: message }), client: @client)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# A Hash upserts the recipient server-side (one round trip instead of
|
|
57
|
+
# registering the user first); a bare id is a strict lookup.
|
|
58
|
+
def recipient_params(user)
|
|
59
|
+
return { external_user_id: user.to_s } unless user.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
user = normalize_keys(user)
|
|
62
|
+
external_user_id = user[:id] || user[:external_user_id]
|
|
63
|
+
|
|
64
|
+
if external_user_id.nil? || external_user_id.to_s.empty?
|
|
65
|
+
raise ArgumentError, "user must include an :id (your own identifier for the recipient)"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
nested = { external_user_id: external_user_id.to_s }
|
|
69
|
+
# Only keys the caller actually supplied are forwarded: the server leaves
|
|
70
|
+
# anything absent untouched, so omitting a name doesn't blank it out and
|
|
71
|
+
# omitting an email doesn't drop the contact.
|
|
72
|
+
RECIPIENT_ATTRIBUTES.each do |name|
|
|
73
|
+
nested[name] = user[name].to_s unless user[name].nil?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
{ user: nested }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The API takes attributes as [{ name:, value: }], which is awkward to
|
|
80
|
+
# write by hand, so a plain Hash is accepted and converted.
|
|
81
|
+
def format_attributes(attributes)
|
|
82
|
+
return [] if attributes.nil? || attributes.empty?
|
|
83
|
+
|
|
84
|
+
case attributes
|
|
85
|
+
when Hash
|
|
86
|
+
attributes.map { |name, value| { name: name.to_s, value: value.to_s } }
|
|
87
|
+
when Array
|
|
88
|
+
attributes.map do |attribute|
|
|
89
|
+
attribute = normalize_keys(attribute)
|
|
90
|
+
unless attribute.is_a?(Hash) && attribute.key?(:name)
|
|
91
|
+
raise ArgumentError, "array attributes must be hashes with a :name key"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
{ name: attribute[:name].to_s, value: attribute[:value].to_s }
|
|
95
|
+
end
|
|
96
|
+
else
|
|
97
|
+
raise ArgumentError, "attributes must be a Hash or an Array of {name:, value:} hashes"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_keys(hash)
|
|
102
|
+
return hash unless hash.is_a?(Hash)
|
|
103
|
+
|
|
104
|
+
hash.each_with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
module Resources
|
|
5
|
+
# Recipient management. `notify` upserts the recipient for you, so these are
|
|
6
|
+
# only needed to manage users outside a send -- backfilling, renaming, or
|
|
7
|
+
# honouring a deletion request.
|
|
8
|
+
#
|
|
9
|
+
# Everywhere here, `id` means *your* identifier for the user (the API's
|
|
10
|
+
# `external_user_id`). RelayGrid's internal ids stay internal.
|
|
11
|
+
class Users
|
|
12
|
+
def initialize(client)
|
|
13
|
+
@client = client
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @return [Array<Hash>] every recipient in the account
|
|
17
|
+
def list
|
|
18
|
+
Array(@client.get("/api/v1/notification_users")["notification_users"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @param id [String] your identifier for the user
|
|
22
|
+
# @return [Hash, nil]
|
|
23
|
+
def get(id)
|
|
24
|
+
list.find { |user| user["external_user_id"] == id.to_s }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Create the recipient, or update their names if they already exist.
|
|
28
|
+
#
|
|
29
|
+
# @return [Hash] the notification user
|
|
30
|
+
def upsert(id:, first_name:, last_name:, middle_name: nil)
|
|
31
|
+
existing = get(id)
|
|
32
|
+
attributes = { first_name: first_name, last_name: last_name }
|
|
33
|
+
attributes[:middle_name] = middle_name unless middle_name.nil?
|
|
34
|
+
|
|
35
|
+
if existing
|
|
36
|
+
body = @client.patch(
|
|
37
|
+
"/api/v1/notification_users/#{existing['id']}",
|
|
38
|
+
body: { notification_user: attributes }
|
|
39
|
+
)
|
|
40
|
+
else
|
|
41
|
+
body = @client.post(
|
|
42
|
+
"/api/v1/notification_users",
|
|
43
|
+
body: { notification_user: attributes.merge(external_user_id: id.to_s) }
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
body["notification_user"]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Soft-delete the recipient. Their message history is retained.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean] false when there was no such user
|
|
53
|
+
def delete(id)
|
|
54
|
+
existing = get(id)
|
|
55
|
+
return false unless existing
|
|
56
|
+
|
|
57
|
+
@client.delete("/api/v1/notification_users/#{existing['id']}")
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# -----------------------------------------------------------------------
|
|
62
|
+
# Contacts
|
|
63
|
+
#
|
|
64
|
+
# Email deliveries resolve their recipient through the contact stored for
|
|
65
|
+
# that channel *type* -- one address per user, whichever channel of that
|
|
66
|
+
# type delivers the message. `notify` upserts it from the `user:` payload,
|
|
67
|
+
# so these are for managing contacts outside a send: backfilling an
|
|
68
|
+
# existing user base, or reacting to someone changing their address.
|
|
69
|
+
# -----------------------------------------------------------------------
|
|
70
|
+
|
|
71
|
+
# @param id [String] your identifier for the user
|
|
72
|
+
# @return [Array<Hash>] the user's live contacts
|
|
73
|
+
# @raise [RelayGrid::UserNotFoundError]
|
|
74
|
+
def contacts(id)
|
|
75
|
+
Array(@client.get("#{contacts_path(id)}")["contacts"])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Create or repoint the user's contact for one channel type.
|
|
79
|
+
#
|
|
80
|
+
# @param channel_type [String] "email"
|
|
81
|
+
# @param value [String] the address deliveries on that channel are sent to
|
|
82
|
+
# @return [Hash] the contact
|
|
83
|
+
# @raise [RelayGrid::ValidationError] the value was rejected
|
|
84
|
+
def set_contact(id, channel_type:, value:)
|
|
85
|
+
path = contacts_path(id)
|
|
86
|
+
existing = contacts(id).find { |contact| contact["channel_type"] == channel_type.to_s }
|
|
87
|
+
body = { contact: { channel_type: channel_type.to_s, value: value } }
|
|
88
|
+
|
|
89
|
+
if existing
|
|
90
|
+
@client.patch("#{path}/#{existing['id']}", body: body)["contact"]
|
|
91
|
+
else
|
|
92
|
+
@client.post(path, body: body)["contact"]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# @return [Boolean] false when there was no such contact
|
|
97
|
+
def delete_contact(id, channel_type:)
|
|
98
|
+
existing = contacts(id).find { |contact| contact["channel_type"] == channel_type.to_s }
|
|
99
|
+
return false unless existing
|
|
100
|
+
|
|
101
|
+
@client.delete("#{contacts_path(id)}/#{existing['id']}")
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# Contacts hang off the user's *internal* id, which callers never hold, so
|
|
108
|
+
# it is resolved from their own identifier first.
|
|
109
|
+
def contacts_path(id)
|
|
110
|
+
user = get(id)
|
|
111
|
+
raise UserNotFoundError.new("Notification user '#{id}' not found", status: 404) unless user
|
|
112
|
+
|
|
113
|
+
"/api/v1/notification_users/#{user['id']}/contacts"
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RelayGrid
|
|
4
|
+
# What `RelayGrid.client.notify` hands back: the message that was created, the
|
|
5
|
+
# deliveries queued for it, and -- when the template has a live push channel
|
|
6
|
+
# -- the short-lived token the recipient's device needs to subscribe to the
|
|
7
|
+
# realtime channel.
|
|
8
|
+
class SendResult
|
|
9
|
+
attr_reader :message_id, :push_token, :push_token_expires_at, :deliveries,
|
|
10
|
+
:rendered_subject, :rendered_body, :raw
|
|
11
|
+
|
|
12
|
+
def initialize(body, client:)
|
|
13
|
+
@raw = body
|
|
14
|
+
@client = client
|
|
15
|
+
|
|
16
|
+
message = body["message"] || {}
|
|
17
|
+
@message_id = message["id"]
|
|
18
|
+
@rendered_subject = message["rendered_subject"]
|
|
19
|
+
@rendered_body = message["rendered_body"]
|
|
20
|
+
|
|
21
|
+
@push_token = body["push_token"]
|
|
22
|
+
expires_in = body["push_token_expires_in"]
|
|
23
|
+
@push_token_expires_at = expires_in ? Time.now + expires_in.to_i : nil
|
|
24
|
+
|
|
25
|
+
@deliveries = Array(message["deliveries"]).map { |d| Delivery.new(d, client: client) }.freeze
|
|
26
|
+
|
|
27
|
+
freeze
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Ids to hand to `client.deliveries.get_all` later. The primary reason a
|
|
31
|
+
# caller keeps anything from a send.
|
|
32
|
+
def delivery_ids
|
|
33
|
+
deliveries.map(&:id)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# True when the template dispatched over a push channel, so `push_token` is
|
|
37
|
+
# present and worth handing down to the recipient's device.
|
|
38
|
+
def push?
|
|
39
|
+
!push_token.nil?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def delivery_for(channel_type)
|
|
43
|
+
deliveries.find { |delivery| delivery.channel_type == channel_type.to_s }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Poll until every delivery settles into a success state, or `timeout`
|
|
47
|
+
# seconds elapse.
|
|
48
|
+
#
|
|
49
|
+
# `failed` is not treated as terminal inside the window: delivery jobs
|
|
50
|
+
# retry, so a failed row can still flip to `sent` and then `delivered`.
|
|
51
|
+
# That mirrors the dashboard's semantics.
|
|
52
|
+
#
|
|
53
|
+
# @param timeout [Numeric] seconds to keep polling
|
|
54
|
+
# @param interval [Numeric] seconds between polls
|
|
55
|
+
# @param raise_on_timeout [Boolean] raise TimeoutWaitingForDeliveries
|
|
56
|
+
# instead of returning the last-seen deliveries
|
|
57
|
+
# @return [Array<RelayGrid::Delivery>] deliveries as last seen
|
|
58
|
+
def wait_for_deliveries(timeout: 30, interval: 2, raise_on_timeout: false)
|
|
59
|
+
return deliveries if deliveries.empty?
|
|
60
|
+
|
|
61
|
+
@client.deliveries.wait_for(
|
|
62
|
+
delivery_ids,
|
|
63
|
+
timeout: timeout,
|
|
64
|
+
interval: interval,
|
|
65
|
+
raise_on_timeout: raise_on_timeout
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def to_h
|
|
70
|
+
raw
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def inspect
|
|
74
|
+
"#<RelayGrid::SendResult message_id: #{message_id.inspect}, " \
|
|
75
|
+
"delivery_ids: #{delivery_ids.inspect}, push: #{push?}>"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "websocket-client-simple"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module RelayGrid
|
|
8
|
+
# Realtime delivery of a recipient's messages over Action Cable.
|
|
9
|
+
#
|
|
10
|
+
# ws = RelayGrid.websocket(user_id: "user-123")
|
|
11
|
+
# ws.on_message { |message| puts message["rendered_subject"] }
|
|
12
|
+
# ws.connect
|
|
13
|
+
#
|
|
14
|
+
# Authentication is the same short-lived channel token the send response
|
|
15
|
+
# returns -- it authorises exactly one recipient's stream. Tokens last an
|
|
16
|
+
# hour, so a fresh one is minted on every (re)connect rather than being held
|
|
17
|
+
# for the life of the object.
|
|
18
|
+
class WebsocketClient
|
|
19
|
+
CHANNEL = "MessagesChannel"
|
|
20
|
+
|
|
21
|
+
# Action Cable's own frame types, which are protocol noise rather than
|
|
22
|
+
# messages a subscriber cares about.
|
|
23
|
+
CONTROL_TYPES = %w[welcome ping confirm_subscription reject_subscription disconnect].freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :user_id
|
|
26
|
+
|
|
27
|
+
def initialize(user_id:, client: nil, configuration: nil, reconnect: true)
|
|
28
|
+
@user_id = user_id.to_s
|
|
29
|
+
@client = client || RelayGrid.client
|
|
30
|
+
@configuration = configuration || @client.configuration
|
|
31
|
+
@reconnect = reconnect
|
|
32
|
+
|
|
33
|
+
@message_callbacks = []
|
|
34
|
+
@error_callbacks = []
|
|
35
|
+
@connect_callbacks = []
|
|
36
|
+
@close_callbacks = []
|
|
37
|
+
|
|
38
|
+
@mutex = Mutex.new
|
|
39
|
+
@connected = false
|
|
40
|
+
@subscribed = false
|
|
41
|
+
@closing = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @yield [Hash] the message payload, as `messages.new_for` returns them
|
|
45
|
+
def on_message(&block)
|
|
46
|
+
@message_callbacks << block
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @yield [Exception]
|
|
51
|
+
def on_error(&block)
|
|
52
|
+
@error_callbacks << block
|
|
53
|
+
self
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def on_connect(&block)
|
|
57
|
+
@connect_callbacks << block
|
|
58
|
+
self
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def on_close(&block)
|
|
62
|
+
@close_callbacks << block
|
|
63
|
+
self
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Open the connection and subscribe.
|
|
67
|
+
#
|
|
68
|
+
# @param blocking [Boolean] block the calling thread until disconnected.
|
|
69
|
+
# Useful for a standalone consumer process; never do this in a web
|
|
70
|
+
# request.
|
|
71
|
+
def connect(blocking: false)
|
|
72
|
+
@closing = false
|
|
73
|
+
open_socket
|
|
74
|
+
|
|
75
|
+
wait_until_closed if blocking
|
|
76
|
+
|
|
77
|
+
self
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def disconnect
|
|
81
|
+
@closing = true
|
|
82
|
+
set(:@subscribed, false)
|
|
83
|
+
set(:@connected, false)
|
|
84
|
+
@socket&.close
|
|
85
|
+
@socket = nil
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def connected?
|
|
90
|
+
read(:@connected)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def subscribed?
|
|
94
|
+
read(:@subscribed)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def open_socket
|
|
100
|
+
# Minted per connection: a token from an earlier connect may well have
|
|
101
|
+
# expired by the time a reconnect happens.
|
|
102
|
+
token = @client.channel_tokens.token_for(@user_id)
|
|
103
|
+
identifier = { channel: CHANNEL, channel_token: token }.to_json
|
|
104
|
+
|
|
105
|
+
@socket = WebSocket::Client::Simple.connect(@configuration.cable_url)
|
|
106
|
+
attach_handlers(@socket, identifier)
|
|
107
|
+
@socket
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def attach_handlers(socket, identifier)
|
|
111
|
+
subscriber = self
|
|
112
|
+
|
|
113
|
+
socket.on :open do
|
|
114
|
+
subscriber.send(:handle_open, socket, identifier)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
socket.on :message do |frame|
|
|
118
|
+
subscriber.send(:handle_frame, frame)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
socket.on :close do
|
|
122
|
+
subscriber.send(:handle_close)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
socket.on :error do |error|
|
|
126
|
+
subscriber.send(:handle_error, error)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def handle_open(socket, identifier)
|
|
131
|
+
set(:@connected, true)
|
|
132
|
+
socket.send({ command: "subscribe", identifier: identifier }.to_json)
|
|
133
|
+
dispatch(@connect_callbacks)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def handle_frame(frame)
|
|
137
|
+
data = frame.respond_to?(:data) ? frame.data : frame
|
|
138
|
+
return if data.nil? || data.empty?
|
|
139
|
+
|
|
140
|
+
payload = JSON.parse(data)
|
|
141
|
+
return unless payload.is_a?(Hash)
|
|
142
|
+
|
|
143
|
+
case payload["type"]
|
|
144
|
+
when "confirm_subscription"
|
|
145
|
+
set(:@subscribed, true)
|
|
146
|
+
return
|
|
147
|
+
when "reject_subscription"
|
|
148
|
+
set(:@subscribed, false)
|
|
149
|
+
handle_error(Error.new("Subscription rejected for user #{@user_id}"))
|
|
150
|
+
return
|
|
151
|
+
when *CONTROL_TYPES
|
|
152
|
+
return
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Action Cable wraps the broadcast in "message"; the server broadcasts
|
|
156
|
+
# `{ message: <payload> }`, so the message itself is one level further in.
|
|
157
|
+
envelope = payload["message"]
|
|
158
|
+
return unless envelope.is_a?(Hash)
|
|
159
|
+
|
|
160
|
+
message = envelope["message"] || envelope
|
|
161
|
+
dispatch(@message_callbacks, message)
|
|
162
|
+
rescue JSON::ParserError
|
|
163
|
+
nil # a frame we can't parse is not worth tearing the connection down for
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def handle_close
|
|
167
|
+
set(:@connected, false)
|
|
168
|
+
set(:@subscribed, false)
|
|
169
|
+
dispatch(@close_callbacks)
|
|
170
|
+
|
|
171
|
+
reconnect! if @reconnect && !@closing
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def handle_error(error)
|
|
175
|
+
set(:@connected, false)
|
|
176
|
+
|
|
177
|
+
# Nothing is watching for exceptions on the socket's own thread, so an
|
|
178
|
+
# unhandled raise here would vanish silently. Hand it to the caller's
|
|
179
|
+
# callback, or re-raise only if they never registered one.
|
|
180
|
+
raise error if @error_callbacks.empty? && error.is_a?(Exception)
|
|
181
|
+
|
|
182
|
+
dispatch(@error_callbacks, error)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def reconnect!
|
|
186
|
+
sleep(1)
|
|
187
|
+
open_socket
|
|
188
|
+
rescue StandardError => e
|
|
189
|
+
handle_error(e)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def wait_until_closed
|
|
193
|
+
sleep(0.1) while connected? || (@reconnect && !@closing)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def dispatch(callbacks, *args)
|
|
197
|
+
callbacks.each do |callback|
|
|
198
|
+
callback.call(*args)
|
|
199
|
+
rescue StandardError => e
|
|
200
|
+
# One misbehaving subscriber must not take down the connection or stop
|
|
201
|
+
# the remaining callbacks.
|
|
202
|
+
@configuration.logger&.error("RelayGrid websocket callback failed: #{e.message}")
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def set(ivar, value)
|
|
207
|
+
@mutex.synchronize { instance_variable_set(ivar, value) }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def read(ivar)
|
|
211
|
+
@mutex.synchronize { instance_variable_get(ivar) }
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
data/lib/relaygrid.rb
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "relaygrid/version"
|
|
4
|
+
require_relative "relaygrid/errors"
|
|
5
|
+
require_relative "relaygrid/configuration"
|
|
6
|
+
require_relative "relaygrid/delivery"
|
|
7
|
+
require_relative "relaygrid/send_result"
|
|
8
|
+
require_relative "relaygrid/client"
|
|
9
|
+
require_relative "relaygrid/websocket_client"
|
|
10
|
+
require_relative "relaygrid/resources/notifications"
|
|
11
|
+
require_relative "relaygrid/resources/deliveries"
|
|
12
|
+
require_relative "relaygrid/resources/messages"
|
|
13
|
+
require_relative "relaygrid/resources/message_templates"
|
|
14
|
+
require_relative "relaygrid/resources/users"
|
|
15
|
+
require_relative "relaygrid/resources/channel_tokens"
|
|
16
|
+
|
|
17
|
+
# Ruby client for the RelayGrid / RelayGrid notification API.
|
|
18
|
+
#
|
|
19
|
+
# RelayGrid.configure do |config|
|
|
20
|
+
# config.api_key = ENV["RELAYGRID_API_KEY"]
|
|
21
|
+
# config.base_url = ENV.fetch("RELAYGRID_BASE_URL", "https://relaygrid.dev")
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# result = RelayGrid.client.notify(
|
|
25
|
+
# user: { id: "user-123", first_name: "Ada", last_name: "Lovelace" },
|
|
26
|
+
# template: "order_shipped",
|
|
27
|
+
# attributes: { order_number: "1042" }
|
|
28
|
+
# )
|
|
29
|
+
module RelayGrid
|
|
30
|
+
class << self
|
|
31
|
+
attr_writer :configuration
|
|
32
|
+
|
|
33
|
+
def configuration
|
|
34
|
+
@configuration ||= Configuration.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Set the process-wide defaults. The cached default client is dropped so the
|
|
38
|
+
# next call picks the new settings up (clients are frozen and never mutate).
|
|
39
|
+
def configure
|
|
40
|
+
yield(configuration)
|
|
41
|
+
@client = nil
|
|
42
|
+
configuration
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The default client, built from the global configuration.
|
|
46
|
+
#
|
|
47
|
+
# @return [RelayGrid::Client]
|
|
48
|
+
def client
|
|
49
|
+
@client ||= Client.new(configuration)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Send a notification with the default client.
|
|
53
|
+
# See Resources::Notifications#notify.
|
|
54
|
+
#
|
|
55
|
+
# @return [RelayGrid::SendResult]
|
|
56
|
+
def notify(**kwargs)
|
|
57
|
+
client.notify(**kwargs)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A realtime subscriber for one recipient's messages.
|
|
61
|
+
#
|
|
62
|
+
# @param user_id [String] your identifier for the user
|
|
63
|
+
# @return [RelayGrid::WebsocketClient]
|
|
64
|
+
def websocket(user_id:, **options)
|
|
65
|
+
WebsocketClient.new(user_id: user_id, client: client, **options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Drop the global configuration and cached client. Intended for test suites.
|
|
69
|
+
def reset!
|
|
70
|
+
@configuration = Configuration.new
|
|
71
|
+
@client = nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/sig/relaygrid.rbs
ADDED