pushover 3.0.2 → 4.0.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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +28 -0
  3. data/.gitignore +1 -0
  4. data/.rubocop.yml +11 -24
  5. data/.ruby-version +1 -0
  6. data/CONVENTIONS.md +19 -0
  7. data/Gemfile +1 -2
  8. data/Guardfile +3 -3
  9. data/README.md +236 -44
  10. data/Rakefile +9 -59
  11. data/exe/pushover +6 -5
  12. data/lib/pushover/client.rb +53 -0
  13. data/lib/pushover/glances.rb +86 -0
  14. data/lib/pushover/groups.rb +137 -0
  15. data/lib/pushover/licenses.rb +67 -0
  16. data/lib/pushover/limits.rb +18 -0
  17. data/lib/pushover/message.rb +29 -32
  18. data/lib/pushover/message_encryption.rb +48 -0
  19. data/lib/pushover/message_validator.rb +167 -0
  20. data/lib/pushover/messages.rb +20 -0
  21. data/lib/pushover/receipt.rb +23 -10
  22. data/lib/pushover/receipts.rb +60 -0
  23. data/lib/pushover/response.rb +8 -3
  24. data/lib/pushover/sounds.rb +18 -0
  25. data/lib/pushover/subscriptions.rb +48 -0
  26. data/lib/pushover/teams.rb +93 -0
  27. data/lib/pushover/users.rb +37 -0
  28. data/lib/pushover/version.rb +1 -1
  29. data/lib/pushover.rb +14 -1
  30. data/pushover.gemspec +9 -7
  31. data/spec/gem/specification_spec.rb +19 -0
  32. data/spec/pushover/client_spec.rb +149 -0
  33. data/spec/pushover/glances_spec.rb +206 -0
  34. data/spec/pushover/groups_spec.rb +486 -0
  35. data/spec/pushover/licenses_spec.rb +238 -0
  36. data/spec/pushover/limits_spec.rb +92 -0
  37. data/spec/pushover/message_spec.rb +146 -26
  38. data/spec/pushover/messages_spec.rb +199 -0
  39. data/spec/pushover/receipt_spec.rb +119 -11
  40. data/spec/pushover/receipts_spec.rb +232 -0
  41. data/spec/pushover/response_spec.rb +21 -16
  42. data/spec/pushover/sounds_spec.rb +92 -0
  43. data/spec/pushover/subscriptions_spec.rb +185 -0
  44. data/spec/pushover/teams_spec.rb +325 -0
  45. data/spec/pushover/users_spec.rb +115 -0
  46. data/spec/spec_helper.rb +2 -3
  47. metadata +54 -18
  48. data/.travis.yml +0 -8
@@ -0,0 +1,86 @@
1
+ require 'pushover/users'
2
+
3
+ module Pushover
4
+ # Glances provides operations for the Glances API.
5
+ class Glances
6
+ DATA_FIELDS = %i[title text subtext count percent].freeze
7
+ SUPPORTED_FIELDS = ([:device] + DATA_FIELDS).freeze
8
+ TEXT_FIELDS = %i[title text subtext].freeze
9
+
10
+ def initialize(client)
11
+ @client = client
12
+ end
13
+
14
+ # Update one or more fields shown on a user's registered widgets.
15
+ # @return [Response] the Pushover API response
16
+ def update(user:, **params)
17
+ params = params.compact
18
+ validate_supported_fields!(params)
19
+ validate_user!(user)
20
+ validate_data_fields!(params)
21
+ validate_device!(params)
22
+ validate_text_fields!(params)
23
+ validate_count!(params)
24
+ validate_percent!(params)
25
+
26
+ body = { 'token' => @client.token, 'user' => user }.merge(params.transform_keys(&:to_s))
27
+ response = @client.connection.post(path: '/1/glances.json', body: Oj.dump(body))
28
+ Response.create_from_excon_response(response)
29
+ end
30
+
31
+ private
32
+
33
+ def validate_supported_fields!(params)
34
+ unknown = params.keys - SUPPORTED_FIELDS
35
+ raise ArgumentError, "unsupported glance parameter: #{unknown.first}" unless unknown.empty?
36
+ end
37
+
38
+ def validate_user!(user)
39
+ raise ArgumentError, 'user must be supplied' if user.nil? || user == ''
40
+ raise ArgumentError, 'user must be 30 alphanumeric characters' unless user.is_a?(String) && user.match?(Users::USER_PATTERN)
41
+ end
42
+
43
+ def validate_data_fields!(params)
44
+ return if DATA_FIELDS.any? { |field| params.key?(field) }
45
+
46
+ raise ArgumentError, 'at least one glance data field must be supplied'
47
+ end
48
+
49
+ def validate_device!(params)
50
+ return unless params.key?(:device)
51
+
52
+ device = params[:device]
53
+ return if device == '' || (device.is_a?(String) && device.match?(Users::DEVICE_PATTERN))
54
+
55
+ raise ArgumentError, 'device must be blank or 1 to 25 letters, numbers, underscores, or hyphens'
56
+ end
57
+
58
+ def validate_text_fields!(params)
59
+ TEXT_FIELDS.each do |field|
60
+ next unless params.key?(field)
61
+
62
+ value = params[field]
63
+ raise ArgumentError, "#{field} must be a String" unless value.is_a?(String)
64
+ raise ArgumentError, "#{field} must be at most 100 characters" if value.length > 100
65
+ end
66
+ end
67
+
68
+ def validate_count!(params)
69
+ return unless params.key?(:count)
70
+
71
+ count = params[:count]
72
+ return if count == '' || count.is_a?(Integer)
73
+
74
+ raise ArgumentError, 'count must be an Integer or blank'
75
+ end
76
+
77
+ def validate_percent!(params)
78
+ return unless params.key?(:percent)
79
+
80
+ percent = params[:percent]
81
+ return if percent == '' || (percent.is_a?(Integer) && percent.between?(0, 100))
82
+
83
+ raise ArgumentError, 'percent must be an Integer from 0 to 100 or blank'
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,137 @@
1
+ require 'pushover/users'
2
+
3
+ module Pushover
4
+ # Groups provides operations for the Delivery Groups API.
5
+ class Groups
6
+ GROUP_PATTERN = /\A[A-Za-z0-9]{30}\z/
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Create an empty delivery group.
13
+ # @return [Response] the Pushover API response
14
+ def create(name:)
15
+ validate_name!(name)
16
+
17
+ response = @client.connection.post(
18
+ path: '/1/groups.json',
19
+ body: Oj.dump('token' => @client.token, 'name' => name)
20
+ )
21
+ Response.create_from_excon_response(response)
22
+ end
23
+
24
+ # List delivery groups owned by the client's account or team.
25
+ # @return [Response] the Pushover API response
26
+ def list
27
+ response = @client.connection.get(
28
+ path: '/1/groups.json',
29
+ query: { token: @client.token }
30
+ )
31
+ Response.create_from_excon_response(response)
32
+ end
33
+
34
+ # Retrieve a delivery group's name and users.
35
+ # @return [Response] the Pushover API response
36
+ def get(group:)
37
+ validate_group!(group)
38
+
39
+ response = @client.connection.get(
40
+ path: "/1/groups/#{group}.json",
41
+ query: { token: @client.token }
42
+ )
43
+ Response.create_from_excon_response(response)
44
+ end
45
+
46
+ # Add a user membership to a delivery group.
47
+ # @return [Response] the Pushover API response
48
+ def add_user(group:, user:, device: nil, memo: nil)
49
+ validate_membership!(group, user, device)
50
+ validate_memo!(memo) unless memo.nil?
51
+
52
+ fields = membership_fields(user, device)
53
+ fields['memo'] = memo unless memo.nil?
54
+ post_group_action(group, 'add_user', fields)
55
+ end
56
+
57
+ # Remove a user's matching memberships from a delivery group.
58
+ # @return [Response] the Pushover API response
59
+ def remove_user(group:, user:, device: nil)
60
+ post_user_action('remove_user', group:, user:, device:)
61
+ end
62
+
63
+ # Temporarily disable a user's matching group memberships.
64
+ # @return [Response] the Pushover API response
65
+ def disable_user(group:, user:, device: nil)
66
+ post_user_action('disable_user', group:, user:, device:)
67
+ end
68
+
69
+ # Re-enable a user's matching group memberships.
70
+ # @return [Response] the Pushover API response
71
+ def enable_user(group:, user:, device: nil)
72
+ post_user_action('enable_user', group:, user:, device:)
73
+ end
74
+
75
+ # Rename a delivery group.
76
+ # @return [Response] the Pushover API response
77
+ def rename(group:, name:)
78
+ validate_group!(group)
79
+ validate_name!(name)
80
+ post_group_action(group, 'rename', 'name' => name)
81
+ end
82
+
83
+ private
84
+
85
+ def post_user_action(action, group:, user:, device:)
86
+ validate_membership!(group, user, device)
87
+ post_group_action(group, action, membership_fields(user, device))
88
+ end
89
+
90
+ def post_group_action(group, action, fields)
91
+ body = { 'token' => @client.token }.merge(fields)
92
+ response = @client.connection.post(
93
+ path: "/1/groups/#{group}/#{action}.json",
94
+ body: Oj.dump(body)
95
+ )
96
+ Response.create_from_excon_response(response)
97
+ end
98
+
99
+ def membership_fields(user, device)
100
+ fields = { 'user' => user }
101
+ fields['device'] = device unless device.nil?
102
+ fields
103
+ end
104
+
105
+ def validate_membership!(group, user, device)
106
+ validate_group!(group)
107
+ validate_user!(user)
108
+ validate_device!(device) unless device.nil?
109
+ end
110
+
111
+ def validate_group!(group)
112
+ raise ArgumentError, 'group must be supplied' if group.nil? || group == ''
113
+ raise ArgumentError, 'group must be 30 alphanumeric characters' unless group.is_a?(String) && group.match?(GROUP_PATTERN)
114
+ end
115
+
116
+ def validate_user!(user)
117
+ raise ArgumentError, 'user must be supplied' if user.nil? || user == ''
118
+ raise ArgumentError, 'user must be 30 alphanumeric characters' unless user.is_a?(String) && user.match?(Users::USER_PATTERN)
119
+ end
120
+
121
+ def validate_device!(device)
122
+ return if device == '' || (device.is_a?(String) && device.match?(Users::DEVICE_PATTERN))
123
+
124
+ raise ArgumentError, 'device must be blank or 1 to 25 letters, numbers, underscores, or hyphens'
125
+ end
126
+
127
+ def validate_memo!(memo)
128
+ raise ArgumentError, 'memo must be a String' unless memo.is_a?(String)
129
+ raise ArgumentError, 'memo must be at most 200 characters' if memo.length > 200
130
+ end
131
+
132
+ def validate_name!(name)
133
+ raise ArgumentError, 'name must be supplied' if name.nil? || name == ''
134
+ raise ArgumentError, 'name must be a String' unless name.is_a?(String)
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,67 @@
1
+ require 'pushover/users'
2
+
3
+ module Pushover
4
+ # Licenses provides operations for the Licensing API.
5
+ class Licenses
6
+ OPERATING_SYSTEMS = ['Android', 'iOS', 'Desktop'].freeze
7
+
8
+ def initialize(client)
9
+ @client = client
10
+ end
11
+
12
+ # Retrieve the application's available prepaid license credits.
13
+ # @return [Response] the Pushover API response
14
+ def get
15
+ response = @client.connection.get(
16
+ path: '/1/licenses.json',
17
+ query: { token: @client.token }
18
+ )
19
+ Response.create_from_excon_response(response)
20
+ end
21
+
22
+ # Permanently assign one prepaid license credit to a user or e-mail address.
23
+ # @return [Response] the Pushover API response
24
+ def assign(user: nil, email: nil, os: nil)
25
+ validate_recipient!(user, email)
26
+ validate_os!(os) unless os.nil?
27
+
28
+ body = { 'token' => @client.token }
29
+ body['user'] = user unless user.nil?
30
+ body['email'] = email unless email.nil?
31
+ body['os'] = os unless os.nil?
32
+
33
+ response = @client.connection.post(
34
+ path: '/1/licenses/assign.json',
35
+ body: Oj.dump(body),
36
+ idempotent: false
37
+ )
38
+ Response.create_from_excon_response(response)
39
+ end
40
+
41
+ private
42
+
43
+ def validate_recipient!(user, email)
44
+ raise ArgumentError, 'user or email must be supplied' if user.nil? && email.nil?
45
+
46
+ validate_user!(user) unless user.nil?
47
+ validate_email!(email) unless email.nil?
48
+ end
49
+
50
+ def validate_user!(user)
51
+ raise ArgumentError, 'user must be supplied' if user == ''
52
+ raise ArgumentError, 'user must be 30 alphanumeric characters' unless user.is_a?(String) && user.match?(Users::USER_PATTERN)
53
+ end
54
+
55
+ def validate_email!(email)
56
+ return if email.is_a?(String) && !email.strip.empty?
57
+
58
+ raise ArgumentError, 'email must be a nonblank String'
59
+ end
60
+
61
+ def validate_os!(os)
62
+ return if os == '' || OPERATING_SYSTEMS.include?(os)
63
+
64
+ raise ArgumentError, 'os must be blank, Android, iOS, or Desktop'
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,18 @@
1
+ module Pushover
2
+ # Limits provides operations for the account and team usage limits API.
3
+ class Limits
4
+ def initialize(client)
5
+ @client = client
6
+ end
7
+
8
+ # Retrieve the current monthly message usage limits.
9
+ # @return [Response] the Pushover API response
10
+ def get
11
+ response = @client.connection.get(
12
+ path: '/1/apps/limits.json',
13
+ query: { token: @client.token }
14
+ )
15
+ Response.create_from_excon_response(response)
16
+ end
17
+ end
18
+ end
@@ -1,38 +1,35 @@
1
1
  module Pushover
2
- # Message is a struct to send a message to pushover.
3
- # @!attribute [rw] token
4
- # @return [String] application token
5
- # @!attribute [rw] user
6
- # @return [String] user key
7
- # @!attribute [rw] message
8
- # @return [String] message to transmit
9
- # @!attribute [rw] device
10
- # @return [String] device to transmit too
11
- # @!attribute [rw] title
12
- # @return [String] title of the message
13
- # @!attribute [rw] url
14
- # @return [String] supplementary url
15
- # @!attribute [rw] url_title
16
- # @return [String] title of the supplementary url
17
- # @!attribute [rw] priority
18
- # @return [Numeric] numeric value for priority.
19
- # @!attribute [rw] sound
20
- # @return [String] sound to play
21
- # @!attribute [rw] timestamp
22
- # @return [Numeric] timestamp to expire, in epoch
23
- # @!attribute [rw] expire
24
- # @return [Numeric] expire time until message expires
25
- # @!attribute [rw] retry
26
- # @return [Numeric] how long to retry for, in seconds
27
- # @!attribute [rw] callback
28
- # @return [String] callback url
29
- Message = Struct.new(:token, :user, :message, :attachment, :device, :title, :url, :url_title, :priority, :sound, :timestamp, :expire, :retry, :callback, keyword_init: true) do
30
- # push the configured message to pushover.
31
- # @return [Response] response for the receipt request
2
+ # Deprecated compatibility interface for sending messages.
3
+ class Message
4
+ ATTRIBUTES = %i[
5
+ token user message attachment device title url url_title priority sound timestamp expire retry callback
6
+ ].freeze
7
+ private_constant :ATTRIBUTES
8
+
9
+ attr_accessor(*ATTRIBUTES)
10
+
11
+ def initialize(attributes = {}, **keywords)
12
+ raise ArgumentError, 'attributes must be supplied as a Hash' unless attributes.is_a?(Hash)
13
+
14
+ values = attributes.merge(keywords).transform_keys(&:to_sym)
15
+ unknown = values.keys - ATTRIBUTES
16
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
17
+
18
+ values.each { |attribute, value| public_send("#{attribute}=", value) }
19
+ end
20
+
21
+ # Send the configured message through the shared client.
22
+ # @return [Response] the Pushover API response
32
23
  def push
33
- %i[token user message].each { |param| raise "#{param} must be supplied" unless send param }
24
+ require 'pushover' unless defined?(Pushover::Excon)
25
+ warn 'Pushover::Message#push is deprecated; use Pushover::Client.new(token: ...).messages.create(...)', uplevel: 1
26
+ %i[token user message].each { |param| raise "#{param} must be supplied" unless public_send(param) }
27
+ raise ArgumentError, 'raw attachment is unsupported; use attachment_base64 and attachment_type with Pushover::Client#messages.create' unless attachment.nil?
34
28
 
35
- Response.create_from_excon_response Excon.post(path: '1/messages.json', query: to_h)
29
+ params = ATTRIBUTES.to_h { |attribute| [attribute, public_send(attribute)] }
30
+ .except(:token, :attachment)
31
+ .compact
32
+ Client.new(token: token).messages.create(**params)
36
33
  end
37
34
  end
38
35
  end
@@ -0,0 +1,48 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+ require 'securerandom'
4
+ require 'stringio'
5
+ require 'zlib'
6
+
7
+ module Pushover
8
+ # MessageEncryption implements Pushover's end-to-end encryption wire format.
9
+ class MessageEncryption
10
+ FIELDS = %i[message title url url_title].freeze
11
+
12
+ def initialize(hexadecimal_key)
13
+ @key = [hexadecimal_key].pack('H*')
14
+ end
15
+
16
+ def encrypt(params)
17
+ params.merge(encrypted: 1).tap do |encrypted|
18
+ FIELDS.each { |field| encrypted[field] = encrypt_field(params[field]) if params.key?(field) }
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def encrypt_field(plaintext)
25
+ iv = SecureRandom.random_bytes(16)
26
+ encryptor = cipher(iv)
27
+ ciphertext = encryptor.update(compress(plaintext)) + encryptor.final
28
+ hmac = OpenSSL::HMAC.digest('SHA256', @key, iv + ciphertext)
29
+ Base64.strict_encode64(iv + ciphertext + hmac)
30
+ end
31
+
32
+ def cipher(initialization_vector)
33
+ OpenSSL::Cipher.new('aes-256-cbc').encrypt.tap do |cipher|
34
+ cipher.key = @key
35
+ cipher.iv = initialization_vector
36
+ end
37
+ end
38
+
39
+ def compress(plaintext)
40
+ output = StringIO.new(''.b)
41
+ writer = Zlib::GzipWriter.new(output, Zlib::BEST_COMPRESSION)
42
+ writer.mtime = 0
43
+ writer.write(plaintext)
44
+ writer.close
45
+ output.string
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,167 @@
1
+ require 'base64'
2
+ require 'uri'
3
+
4
+ module Pushover
5
+ # MessageValidator enforces deterministic Message API request rules.
6
+ class MessageValidator
7
+ ATTACHMENT_LIMIT = 5_242_880
8
+ CHARACTER_LIMITS = { message: 1024, title: 250, url: 512, url_title: 100 }.freeze
9
+ EMERGENCY_FIELDS = %i[retry expire callback tags].freeze
10
+ FLAGS = %i[html monospace].freeze
11
+ PRIORITIES = [-2, -1, 0, 1, 2].freeze
12
+ STRING_FIELDS = %i[
13
+ user message attachment_base64 attachment_type device sound title url url_title callback tags
14
+ ].freeze
15
+ SUPPORTED_FIELDS = (STRING_FIELDS + FLAGS + %i[priority timestamp ttl retry expire encryption_key]).freeze
16
+
17
+ def initialize(**params)
18
+ @params = params.compact
19
+ end
20
+
21
+ def validate
22
+ validate_supported_fields
23
+ validate_required_fields
24
+ validate_string_fields
25
+ validate_character_limits
26
+ validate_users
27
+ validate_devices
28
+ validate_flags
29
+ validate_numbers
30
+ validate_emergency_fields
31
+ validate_callback
32
+ validate_attachment
33
+ validate_encryption_key
34
+ @params
35
+ end
36
+
37
+ private
38
+
39
+ def validate_supported_fields
40
+ unknown = @params.keys - SUPPORTED_FIELDS
41
+ raise ArgumentError, "unsupported message parameter: #{unknown.first}" unless unknown.empty?
42
+ end
43
+
44
+ def validate_required_fields
45
+ %i[user message].each do |field|
46
+ value = @params[field]
47
+ raise ArgumentError, "#{field} must be supplied" unless value.is_a?(String) && !value.empty?
48
+ end
49
+ end
50
+
51
+ def validate_string_fields
52
+ STRING_FIELDS.each do |field|
53
+ next unless @params.key?(field)
54
+
55
+ raise ArgumentError, "#{field} must be a String" unless @params[field].is_a?(String)
56
+ end
57
+ end
58
+
59
+ def validate_character_limits
60
+ CHARACTER_LIMITS.each do |field, maximum|
61
+ next unless @params.key?(field)
62
+ next if @params[field].length <= maximum
63
+
64
+ raise ArgumentError, "#{field} must be at most #{maximum} characters"
65
+ end
66
+ end
67
+
68
+ def validate_users
69
+ raise ArgumentError, 'user may contain at most 50 users' if @params[:user].split(',', -1).length > 50
70
+ end
71
+
72
+ def validate_devices
73
+ return unless @params.key?(:device)
74
+
75
+ valid = @params[:device].split(',', -1).all? { |device| device.match?(/\A[A-Za-z0-9_-]{1,25}\z/) }
76
+ raise ArgumentError, 'device contains an invalid name' unless valid
77
+ end
78
+
79
+ def validate_flags
80
+ FLAGS.each do |field|
81
+ next unless @params.key?(field)
82
+
83
+ value = @params[field]
84
+ raise ArgumentError, "#{field} must be true, false, 1, or 0" unless [true, false, 1, 0].include?(value)
85
+
86
+ @params[field] = [true, 1].include?(value) ? 1 : 0
87
+ end
88
+
89
+ return unless @params[:html] == 1 && @params[:monospace] == 1
90
+
91
+ raise ArgumentError, 'html and monospace cannot both be enabled'
92
+ end
93
+
94
+ def validate_numbers
95
+ validate_allowed_integer(:priority, PRIORITIES)
96
+ validate_minimum_integer(:timestamp, 0)
97
+ validate_minimum_integer(:ttl, 1, 'ttl must be a positive integer')
98
+ validate_minimum_integer(:retry, 30, 'retry must be at least 30 seconds')
99
+ validate_minimum_integer(:expire, 1)
100
+ end
101
+
102
+ def validate_allowed_integer(field, allowed)
103
+ return unless @params.key?(field)
104
+
105
+ value = @params[field]
106
+ return if value.is_a?(Integer) && allowed.include?(value)
107
+
108
+ raise ArgumentError, "#{field} must be one of #{allowed.join(', ')}"
109
+ end
110
+
111
+ def validate_minimum_integer(field, minimum, message = nil)
112
+ return unless @params.key?(field)
113
+ return if @params[field].is_a?(Integer) && @params[field] >= minimum
114
+
115
+ raise ArgumentError, message || "#{field} must be at least #{minimum}"
116
+ end
117
+
118
+ def validate_emergency_fields
119
+ if @params[:priority] == 2
120
+ raise ArgumentError, 'retry and expire must be supplied with priority 2' unless @params.key?(:retry) && @params.key?(:expire)
121
+ raise ArgumentError, 'expire must be at most 10800 seconds' if @params[:expire] > 10_800
122
+
123
+ return
124
+ end
125
+
126
+ field = EMERGENCY_FIELDS.find { |name| @params.key?(name) }
127
+ raise ArgumentError, "#{field} is only valid with priority 2" if field
128
+ end
129
+
130
+ def validate_attachment
131
+ data = @params[:attachment_base64]
132
+ type = @params[:attachment_type]
133
+ return unless data || type
134
+
135
+ raise ArgumentError, 'attachment_base64 must be supplied with attachment_type' unless data
136
+ raise ArgumentError, 'attachment_type must be supplied with attachment_base64' unless type
137
+ raise ArgumentError, 'attachment_type must be an image MIME type' unless type.match?(/\Aimage\/[A-Za-z0-9.+-]+\z/)
138
+
139
+ decoded = decode_attachment(data)
140
+ raise ArgumentError, 'attachment must not exceed 5242880 bytes' if decoded.bytesize > ATTACHMENT_LIMIT
141
+ end
142
+
143
+ def validate_callback
144
+ return unless @params.key?(:callback)
145
+
146
+ callback = URI.parse(@params[:callback])
147
+ return if callback.is_a?(URI::HTTP) && callback.host
148
+
149
+ raise ArgumentError, 'callback must be an HTTP or HTTPS URL'
150
+ rescue URI::InvalidURIError
151
+ raise ArgumentError, 'callback must be an HTTP or HTTPS URL'
152
+ end
153
+
154
+ def decode_attachment(data)
155
+ Base64.strict_decode64(data)
156
+ rescue ArgumentError
157
+ raise ArgumentError, 'attachment_base64 must be valid Base64'
158
+ end
159
+
160
+ def validate_encryption_key
161
+ return unless @params.key?(:encryption_key)
162
+ return if @params[:encryption_key].is_a?(String) && @params[:encryption_key].match?(/\A[0-9a-fA-F]{64}\z/)
163
+
164
+ raise ArgumentError, 'encryption_key must be 64 hexadecimal characters'
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,20 @@
1
+ module Pushover
2
+ # Messages provides operations for the Message API.
3
+ class Messages
4
+ def initialize(client)
5
+ @client = client
6
+ end
7
+
8
+ # Create and send a message.
9
+ # @return [Response] the Pushover API response
10
+ def create(user:, message:, **)
11
+ params = MessageValidator.new(user: user, message: message, **).validate
12
+ encryption_key = params.delete(:encryption_key)
13
+ params = MessageEncryption.new(encryption_key).encrypt(params) if encryption_key
14
+ body = { 'token' => @client.token }.merge(params.transform_keys(&:to_s))
15
+
16
+ response = @client.connection.post(path: '/1/messages.json', body: Oj.dump(body))
17
+ Response.create_from_excon_response(response)
18
+ end
19
+ end
20
+ end
@@ -1,16 +1,29 @@
1
1
  module Pushover
2
- # Receipt is a struct to interface with the receipt endpoint.
3
- # @!attribute [rw] receipt
4
- # @return [String] id of the receipt to request
5
- # @!attribute [rw] token
6
- # @return [String] application token to check for a receipt
7
- Receipt = Struct.new(:receipt, :token, keyword_init: true) do
8
- # Call pushover and return a response.
9
- # @return [Response] response for the receipt request
2
+ # Deprecated compatibility interface for retrieving receipts.
3
+ class Receipt
4
+ ATTRIBUTES = %i[receipt token].freeze
5
+ private_constant :ATTRIBUTES
6
+
7
+ attr_accessor(*ATTRIBUTES)
8
+
9
+ def initialize(attributes = {}, **keywords)
10
+ raise ArgumentError, 'attributes must be supplied as a Hash' unless attributes.is_a?(Hash)
11
+
12
+ values = attributes.merge(keywords).transform_keys(&:to_sym)
13
+ unknown = values.keys - ATTRIBUTES
14
+ raise ArgumentError, "unknown keywords: #{unknown.join(', ')}" unless unknown.empty?
15
+
16
+ values.each { |attribute, value| public_send("#{attribute}=", value) }
17
+ end
18
+
19
+ # Retrieve the configured receipt through the shared client.
20
+ # @return [Response] the Pushover API response
10
21
  def get
11
- %i[receipt token].each { |param| raise "#{param} must be supplied" unless send param }
22
+ require 'pushover' unless defined?(Pushover::Excon)
23
+ warn 'Pushover::Receipt#get is deprecated; use Pushover::Client.new(token: ...).receipts.get(receipt: ...)', uplevel: 1
24
+ %i[receipt token].each { |param| raise "#{param} must be supplied" unless public_send(param) }
12
25
 
13
- Response.create_from_excon_response Excon.get(path: "1/receipts/#{receipt}.json", query: { token: token })
26
+ Client.new(token: token).receipts.get(receipt: receipt)
14
27
  end
15
28
  end
16
29
  end