noticed 1.3.2 → 1.6.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.
@@ -0,0 +1,96 @@
1
+ require "googleauth"
2
+
3
+ # class CommentNotifier
4
+ # deliver_by :fcm, credentials: Rails.root.join("config/certs/fcm.json"), format: :format_notification
5
+ #
6
+ # deliver_by :fcm, credentials: :fcm_credentials
7
+ # def fcm_credentials
8
+ # { project_id: "api-12345" }
9
+ # end
10
+ # end
11
+
12
+ module Noticed
13
+ module DeliveryMethods
14
+ class Fcm < Base
15
+ BASE_URI = "https://fcm.googleapis.com/v1/projects/"
16
+
17
+ option :format
18
+
19
+ def deliver
20
+ device_tokens.each do |device_token|
21
+ post("#{BASE_URI}#{project_id}/messages:send", headers: {authorization: "Bearer #{access_token}"}, json: {message: format(device_token)})
22
+ rescue ResponseUnsuccessful => exception
23
+ if exception.response.code == 404
24
+ cleanup_invalid_token(device_token)
25
+ else
26
+ raise
27
+ end
28
+ end
29
+ end
30
+
31
+ def cleanup_invalid_token(device_token)
32
+ return unless notification.respond_to?(:cleanup_device_token)
33
+ notification.send(:cleanup_device_token, token: device_token, platform: "fcm")
34
+ end
35
+
36
+ def credentials
37
+ @credentials ||= begin
38
+ option = options[:credentials]
39
+ credentials_hash = case option
40
+ when Hash
41
+ option
42
+ when Pathname
43
+ load_json(option)
44
+ when String
45
+ load_json(Rails.root.join(option))
46
+ when Symbol
47
+ notification.send(option)
48
+ else
49
+ Rails.application.credentials.fcm
50
+ end
51
+
52
+ credentials_hash.symbolize_keys
53
+ end
54
+ end
55
+
56
+ def load_json(path)
57
+ JSON.parse(File.read(path))
58
+ end
59
+
60
+ def project_id
61
+ credentials[:project_id]
62
+ end
63
+
64
+ def access_token
65
+ token = authorizer.fetch_access_token!
66
+ token["access_token"]
67
+ end
68
+
69
+ def authorizer
70
+ @authorizer ||= options.fetch(:authorizer, Google::Auth::ServiceAccountCredentials).make_creds(
71
+ json_key_io: StringIO.new(credentials.to_json),
72
+ scope: "https://www.googleapis.com/auth/firebase.messaging"
73
+ )
74
+ end
75
+
76
+ def format(device_token)
77
+ notification.send(options[:format], device_token)
78
+ end
79
+
80
+ def device_tokens
81
+ if notification.respond_to?(:fcm_device_tokens)
82
+ Array.wrap(notification.fcm_device_tokens(recipient))
83
+ else
84
+ raise NoMethodError, <<~MESSAGE
85
+ You must implement `fcm_device_tokens` to send Firebase Cloud Messaging notifications
86
+
87
+ # This must return an Array of FCM device tokens
88
+ def fcm_device_tokens(recipient)
89
+ recipient.fcm_device_tokens.pluck(:token)
90
+ end
91
+ MESSAGE
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,171 @@
1
+ require "apnotic"
2
+
3
+ module Noticed
4
+ module DeliveryMethods
5
+ class Ios < Base
6
+ cattr_accessor :connection_pool
7
+
8
+ def deliver
9
+ raise ArgumentError, "bundle_identifier is missing" if bundle_identifier.blank?
10
+ raise ArgumentError, "key_id is missing" if key_id.blank?
11
+ raise ArgumentError, "team_id is missing" if team_id.blank?
12
+ raise ArgumentError, "Could not find APN cert at '#{cert_path}'" unless valid_cert_path?
13
+
14
+ device_tokens.each do |device_token|
15
+ connection_pool.with do |connection|
16
+ apn = Apnotic::Notification.new(device_token)
17
+ format_notification(apn)
18
+
19
+ response = connection.push(apn)
20
+ raise "Timeout sending iOS push notification" unless response
21
+
22
+ if bad_token?(response)
23
+ # Allow notification to cleanup invalid iOS device tokens
24
+ cleanup_invalid_token(device_token)
25
+ elsif !response.ok?
26
+ raise "Request failed #{response.body}"
27
+ end
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def format_notification(apn)
35
+ apn.topic = bundle_identifier
36
+
37
+ if (method = options[:format])
38
+ notification.send(method, apn)
39
+ elsif params[:message].present?
40
+ apn.alert = params[:message]
41
+ else
42
+ raise ArgumentError, "No message for iOS delivery. Either include message in params or add the 'format' option in 'deliver_by :ios'."
43
+ end
44
+ end
45
+
46
+ def device_tokens
47
+ if notification.respond_to?(:ios_device_tokens)
48
+ Array.wrap(notification.ios_device_tokens(recipient))
49
+ else
50
+ raise NoMethodError, <<~MESSAGE
51
+ You must implement `ios_device_tokens` to send iOS notifications
52
+
53
+ # This must return an Array of iOS device tokens
54
+ def ios_device_tokens(recipient)
55
+ recipient.ios_device_tokens.pluck(:token)
56
+ end
57
+ MESSAGE
58
+ end
59
+ end
60
+
61
+ def bad_token?(response)
62
+ response.status == "410" || (response.status == "400" && response.body["reason"] == "BadDeviceToken")
63
+ end
64
+
65
+ def cleanup_invalid_token(token)
66
+ return unless notification.respond_to?(:cleanup_device_token)
67
+ notification.send(:cleanup_device_token, token: token, platform: "iOS")
68
+ end
69
+
70
+ def connection_pool
71
+ self.class.connection_pool ||= new_connection_pool
72
+ end
73
+
74
+ def new_connection_pool
75
+ handler = proc do |connection|
76
+ connection.on(:error) do |exception|
77
+ Rails.logger.info "Apnotic exception raised: #{exception}"
78
+ end
79
+ end
80
+
81
+ if development?
82
+ Apnotic::ConnectionPool.development(connection_pool_options, pool_options, &handler)
83
+ else
84
+ Apnotic::ConnectionPool.new(connection_pool_options, pool_options, &handler)
85
+ end
86
+ end
87
+
88
+ def connection_pool_options
89
+ {
90
+ auth_method: :token,
91
+ cert_path: cert_path,
92
+ key_id: key_id,
93
+ team_id: team_id
94
+ }
95
+ end
96
+
97
+ def bundle_identifier
98
+ option = options[:bundle_identifier]
99
+ case option
100
+ when String
101
+ option
102
+ when Symbol
103
+ notification.send(option)
104
+ else
105
+ Rails.application.credentials.dig(:ios, :bundle_identifier)
106
+ end
107
+ end
108
+
109
+ def cert_path
110
+ option = options[:cert_path]
111
+ case option
112
+ when String
113
+ option
114
+ when Symbol
115
+ notification.send(option)
116
+ else
117
+ Rails.root.join("config/certs/ios/apns.p8")
118
+ end
119
+ end
120
+
121
+ def key_id
122
+ option = options[:key_id]
123
+ case option
124
+ when String
125
+ option
126
+ when Symbol
127
+ notification.send(option)
128
+ else
129
+ Rails.application.credentials.dig(:ios, :key_id)
130
+ end
131
+ end
132
+
133
+ def team_id
134
+ option = options[:team_id]
135
+ case option
136
+ when String
137
+ option
138
+ when Symbol
139
+ notification.send(option)
140
+ else
141
+ Rails.application.credentials.dig(:ios, :team_id)
142
+ end
143
+ end
144
+
145
+ def development?
146
+ option = options[:development]
147
+ case option
148
+ when Symbol
149
+ !!notification.send(option)
150
+ else
151
+ !!option
152
+ end
153
+ end
154
+
155
+ def valid_cert_path?
156
+ case cert_path
157
+ when File, StringIO
158
+ cert_path.size > 0
159
+ else
160
+ File.exist?(cert_path)
161
+ end
162
+ end
163
+
164
+ def pool_options
165
+ {
166
+ size: options.fetch(:pool_size, 5)
167
+ }
168
+ end
169
+ end
170
+ end
171
+ end
@@ -5,5 +5,9 @@ module Noticed
5
5
  include Noticed::HasNotifications
6
6
  end
7
7
  end
8
+
9
+ initializer "noticed.rails_5_2_support" do
10
+ require "rails_6_polyfills/base" if Rails::VERSION::MAJOR < 6
11
+ end
8
12
  end
9
13
  end
@@ -1,6 +1,6 @@
1
1
  module Noticed
2
2
  module HasNotifications
3
- # Defines a method for the association and a before_destory callback to remove notifications
3
+ # Defines a method for the association and a before_destroy callback to remove notifications
4
4
  # where this record is a param
5
5
  #
6
6
  # class User < ApplicationRecord
@@ -15,10 +15,19 @@ module Noticed
15
15
 
16
16
  class_methods do
17
17
  def has_noticed_notifications(param_name: model_name.singular, **options)
18
- model = options.fetch(:model_name, "Notification").constantize
19
-
20
18
  define_method "notifications_as_#{param_name}" do
21
- model.where(params: {param_name.to_sym => self})
19
+ model = options.fetch(:model_name, "Notification").constantize
20
+ case current_adapter
21
+ when "postgresql", "postgis"
22
+ model.where("params @> ?", Noticed::Coder.dump(param_name.to_sym => self).to_json)
23
+ when "mysql2"
24
+ model.where("JSON_CONTAINS(params, ?)", Noticed::Coder.dump(param_name.to_sym => self).to_json)
25
+ when "sqlite3"
26
+ model.where("json_extract(params, ?) = ?", "$.#{param_name}", Noticed::Coder.dump(self).to_json)
27
+ else
28
+ # This will perform an exact match which isn't ideal
29
+ model.where(params: {param_name.to_sym => self})
30
+ end
22
31
  end
23
32
 
24
33
  if options.fetch(:destroy, true)
@@ -28,5 +37,13 @@ module Noticed
28
37
  end
29
38
  end
30
39
  end
40
+
41
+ def current_adapter
42
+ if ActiveRecord::Base.respond_to?(:connection_db_config)
43
+ ActiveRecord::Base.connection_db_config.adapter
44
+ else
45
+ ActiveRecord::Base.connection_config[:adapter]
46
+ end
47
+ end
31
48
  end
32
49
  end
data/lib/noticed/model.rb CHANGED
@@ -1,5 +1,13 @@
1
1
  module Noticed
2
2
  module Model
3
+ DATABASE_ERROR_CLASS_NAMES = lambda {
4
+ classes = [ActiveRecord::NoDatabaseError]
5
+ classes << ActiveRecord::ConnectionNotEstablished
6
+ classes << Mysql2::Error if defined?(::Mysql2)
7
+ classes << PG::ConnectionBad if defined?(::PG)
8
+ classes
9
+ }.call.freeze
10
+
3
11
  extend ActiveSupport::Concern
4
12
 
5
13
  included do
@@ -32,7 +40,9 @@ module Noticed
32
40
  else
33
41
  Noticed::TextCoder
34
42
  end
35
- rescue ActiveRecord::NoDatabaseError
43
+ rescue *DATABASE_ERROR_CLASS_NAMES => _error
44
+ warn("Noticed was unable to bootstrap correctly as the database is unavailable.")
45
+
36
46
  Noticed::TextCoder
37
47
  end
38
48
  end
@@ -42,6 +52,7 @@ module Noticed
42
52
  @_notification ||= begin
43
53
  instance = type.constantize.with(params)
44
54
  instance.record = self
55
+ instance.recipient = recipient
45
56
  instance
46
57
  end
47
58
  end
@@ -61,5 +72,10 @@ module Noticed
61
72
  def read?
62
73
  read_at?
63
74
  end
75
+
76
+ # If a GlobalID record in params is no longer found, the params will default with a noticed_error key
77
+ def deserialize_error?
78
+ !!params[:noticed_error]
79
+ end
64
80
  end
65
81
  end
@@ -7,6 +7,10 @@ module Noticed
7
7
  :notifications
8
8
  end
9
9
 
10
+ def class_scope
11
+ self.class.name.underscore.tr("/", ".")
12
+ end
13
+
10
14
  def translate(key, **options)
11
15
  I18n.translate(scope_translation_key(key), **options)
12
16
  end
@@ -14,7 +18,7 @@ module Noticed
14
18
 
15
19
  def scope_translation_key(key)
16
20
  if key.to_s.start_with?(".")
17
- "#{i18n_scope}.#{self.class.name.underscore}#{key}"
21
+ "#{i18n_scope}.#{class_scope}#{key}"
18
22
  else
19
23
  key
20
24
  end
@@ -1,3 +1,3 @@
1
1
  module Noticed
2
- VERSION = "1.3.2"
2
+ VERSION = "1.6.0"
3
3
  end
data/lib/noticed.rb CHANGED
@@ -12,26 +12,19 @@ module Noticed
12
12
  autoload :NotificationChannel, "noticed/notification_channel"
13
13
 
14
14
  module DeliveryMethods
15
- autoload :Base, "noticed/delivery_methods/base"
16
15
  autoload :ActionCable, "noticed/delivery_methods/action_cable"
16
+ autoload :Base, "noticed/delivery_methods/base"
17
17
  autoload :Database, "noticed/delivery_methods/database"
18
18
  autoload :Email, "noticed/delivery_methods/email"
19
- autoload :Slack, "noticed/delivery_methods/slack"
19
+ autoload :Fcm, "noticed/delivery_methods/fcm"
20
+ autoload :Ios, "noticed/delivery_methods/ios"
20
21
  autoload :MicrosoftTeams, "noticed/delivery_methods/microsoft_teams"
22
+ autoload :Slack, "noticed/delivery_methods/slack"
21
23
  autoload :Test, "noticed/delivery_methods/test"
22
24
  autoload :Twilio, "noticed/delivery_methods/twilio"
23
25
  autoload :Vonage, "noticed/delivery_methods/vonage"
24
26
  end
25
27
 
26
- def self.notify(recipients:, notification:)
27
- recipients.each do |recipient|
28
- notification.notify(recipient)
29
- end
30
-
31
- # Clear the recipient after sending to the group
32
- notification.recipient = nil
33
- end
34
-
35
28
  mattr_accessor :parent_class
36
29
  @@parent_class = "ApplicationJob"
37
30
 
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable/subscription_adapter/base"
4
+ require "action_cable/subscription_adapter/subscriber_map"
5
+ require "action_cable/subscription_adapter/async"
6
+
7
+ module ActionCable
8
+ module SubscriptionAdapter
9
+ # == Test adapter for Action Cable
10
+ #
11
+ # The test adapter should be used only in testing. Along with
12
+ # <tt>ActionCable::TestHelper</tt> it makes a great tool to test your Rails application.
13
+ #
14
+ # To use the test adapter set +adapter+ value to +test+ in your +config/cable.yml+ file.
15
+ #
16
+ # NOTE: Test adapter extends the <tt>ActionCable::SubscriptionsAdapter::Async</tt> adapter,
17
+ # so it could be used in system tests too.
18
+ class Test < Async
19
+ def broadcast(channel, payload)
20
+ broadcasts(channel) << payload
21
+ super
22
+ end
23
+
24
+ def broadcasts(channel)
25
+ channels_data[channel] ||= []
26
+ end
27
+
28
+ def clear_messages(channel)
29
+ channels_data[channel] = []
30
+ end
31
+
32
+ def clear
33
+ @channels_data = nil
34
+ end
35
+
36
+ private
37
+
38
+ def channels_data
39
+ @channels_data ||= {}
40
+ end
41
+ end
42
+ end
43
+
44
+ # Update how broadcast_for determines the channel name so it's consistent with the Rails 6 way
45
+ module Channel
46
+ module Broadcasting
47
+ delegate :broadcast_to, to: :class
48
+ module ClassMethods
49
+ def broadcast_to(model, message)
50
+ ActionCable.server.broadcast(broadcasting_for(model), message)
51
+ end
52
+
53
+ def broadcasting_for(model)
54
+ serialize_broadcasting([channel_name, model])
55
+ end
56
+
57
+ def serialize_broadcasting(object) # :nodoc:
58
+ case # standard:disable Style/EmptyCaseCondition
59
+ when object.is_a?(Array)
60
+ object.map { |m| serialize_broadcasting(m) }.join(":")
61
+ when object.respond_to?(:to_gid_param)
62
+ object.to_gid_param
63
+ else
64
+ object.to_param
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ # Have ActionCable pick its Test SubscriptionAdapter when it's called for in cable.yml
5
+ module Server
6
+ class Configuration
7
+ def pubsub_adapter
8
+ cable["adapter"] == "test" ? ActionCable::SubscriptionAdapter::Test : super
9
+ end
10
+ end
11
+ end
12
+
13
+ # Provides helper methods for testing Action Cable broadcasting
14
+ module TestHelper
15
+ def before_setup # :nodoc:
16
+ server = ActionCable.server
17
+ test_adapter = ActionCable::SubscriptionAdapter::Test.new(server)
18
+
19
+ @old_pubsub_adapter = server.pubsub
20
+
21
+ server.instance_variable_set(:@pubsub, test_adapter)
22
+ super
23
+ end
24
+
25
+ def after_teardown # :nodoc:
26
+ super
27
+ ActionCable.server.instance_variable_set(:@pubsub, @old_pubsub_adapter)
28
+ end
29
+
30
+ # Asserts that the number of broadcasted messages to the stream matches the given number.
31
+ #
32
+ # def test_broadcasts
33
+ # assert_broadcasts 'messages', 0
34
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
35
+ # assert_broadcasts 'messages', 1
36
+ # ActionCable.server.broadcast 'messages', { text: 'world' }
37
+ # assert_broadcasts 'messages', 2
38
+ # end
39
+ #
40
+ # If a block is passed, that block should cause the specified number of
41
+ # messages to be broadcasted.
42
+ #
43
+ # def test_broadcasts_again
44
+ # assert_broadcasts('messages', 1) do
45
+ # ActionCable.server.broadcast 'messages', { text: 'hello' }
46
+ # end
47
+ #
48
+ # assert_broadcasts('messages', 2) do
49
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
50
+ # ActionCable.server.broadcast 'messages', { text: 'how are you?' }
51
+ # end
52
+ # end
53
+ #
54
+ def assert_broadcasts(stream, number)
55
+ if block_given?
56
+ original_count = broadcasts_size(stream)
57
+ yield
58
+ new_count = broadcasts_size(stream)
59
+ actual_count = new_count - original_count
60
+ else
61
+ actual_count = broadcasts_size(stream)
62
+ end
63
+
64
+ assert_equal number, actual_count, "#{number} broadcasts to #{stream} expected, but #{actual_count} were sent"
65
+ end
66
+
67
+ # Asserts that no messages have been sent to the stream.
68
+ #
69
+ # def test_no_broadcasts
70
+ # assert_no_broadcasts 'messages'
71
+ # ActionCable.server.broadcast 'messages', { text: 'hi' }
72
+ # assert_broadcasts 'messages', 1
73
+ # end
74
+ #
75
+ # If a block is passed, that block should not cause any message to be sent.
76
+ #
77
+ # def test_broadcasts_again
78
+ # assert_no_broadcasts 'messages' do
79
+ # # No job messages should be sent from this block
80
+ # end
81
+ # end
82
+ #
83
+ # Note: This assertion is simply a shortcut for:
84
+ #
85
+ # assert_broadcasts 'messages', 0, &block
86
+ #
87
+ def assert_no_broadcasts(stream, &block)
88
+ assert_broadcasts stream, 0, &block
89
+ end
90
+
91
+ # Asserts that the specified message has been sent to the stream.
92
+ #
93
+ # def test_assert_transmitted_message
94
+ # ActionCable.server.broadcast 'messages', text: 'hello'
95
+ # assert_broadcast_on('messages', text: 'hello')
96
+ # end
97
+ #
98
+ # If a block is passed, that block should cause a message with the specified data to be sent.
99
+ #
100
+ # def test_assert_broadcast_on_again
101
+ # assert_broadcast_on('messages', text: 'hello') do
102
+ # ActionCable.server.broadcast 'messages', text: 'hello'
103
+ # end
104
+ # end
105
+ #
106
+ def assert_broadcast_on(stream, data)
107
+ # Encode to JSON and back–we want to use this value to compare
108
+ # with decoded JSON.
109
+ # Comparing JSON strings doesn't work due to the order of the keys.
110
+ serialized_msg =
111
+ ActiveSupport::JSON.decode(ActiveSupport::JSON.encode(data))
112
+
113
+ new_messages = broadcasts(stream)
114
+ if block_given?
115
+ old_messages = new_messages
116
+ clear_messages(stream)
117
+
118
+ yield
119
+ new_messages = broadcasts(stream)
120
+ clear_messages(stream)
121
+
122
+ # Restore all sent messages
123
+ (old_messages + new_messages).each { |m| pubsub_adapter.broadcast(stream, m) }
124
+ end
125
+
126
+ message = new_messages.find { |msg| ActiveSupport::JSON.decode(msg) == serialized_msg }
127
+
128
+ assert message, "No messages sent with #{data} to #{stream}"
129
+ end
130
+
131
+ def pubsub_adapter # :nodoc:
132
+ ActionCable.server.pubsub
133
+ end
134
+
135
+ delegate :broadcasts, :clear_messages, to: :pubsub_adapter
136
+
137
+ private
138
+
139
+ def broadcasts_size(channel)
140
+ broadcasts(channel).size
141
+ end
142
+ end
143
+ end