ovh-http2sms 0.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.
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "gsm_encoder"
4
+
5
+ module Ovh
6
+ module Http2sms
7
+ # GSM 03.38 encoding utilities for SMS character counting
8
+ #
9
+ # Uses the gsm_encoder gem for character validation and provides
10
+ # OVH-specific SMS limits accounting for the STOP clause.
11
+ #
12
+ # Standard SMS messages use GSM 7-bit encoding which allows 160 characters.
13
+ # Extension characters (€, |, ^, {, }, [, ], ~, \) count as 2 characters.
14
+ # Non-GSM characters force Unicode encoding which limits messages to 70 characters.
15
+ #
16
+ # Commercial SMS must include STOP clause which uses 11 characters,
17
+ # reducing the first SMS limit.
18
+ module GsmEncoding
19
+ # SMS limits for GSM 7-bit encoding
20
+ GSM_SINGLE_SMS_LIMIT = 160
21
+ GSM_CONCAT_SMS_LIMIT = 153 # 7 chars used for UDH header in concatenated SMS
22
+ GSM_FIRST_COMMERCIAL_LIMIT = 149 # After STOP clause (11 chars)
23
+ GSM_CONCAT_COMMERCIAL_LIMIT = 153
24
+
25
+ # SMS limits for Unicode encoding
26
+ UNICODE_SINGLE_SMS_LIMIT = 70
27
+ UNICODE_CONCAT_SMS_LIMIT = 67 # 3 chars used for UDH header
28
+ UNICODE_FIRST_COMMERCIAL_LIMIT = 59 # After STOP clause (11 chars)
29
+ UNICODE_CONCAT_COMMERCIAL_LIMIT = 70
30
+
31
+ # Extension characters that count as 2 in GSM encoding
32
+ EXTENSION_CHARACTERS = Set["€", "|", "^", "{", "}", "[", "]", "~", "\\"].freeze
33
+
34
+ class << self
35
+ # Check if a message contains only GSM 03.38 characters
36
+ #
37
+ # @param message [String] The message to check
38
+ # @return [Boolean] true if all characters are GSM compatible
39
+ #
40
+ # @example
41
+ # GsmEncoding.gsm_compatible?("Hello!") # => true
42
+ # GsmEncoding.gsm_compatible?("Hello πŸ‘‹") # => false
43
+ def gsm_compatible?(message)
44
+ GSMEncoder.can_encode?(message)
45
+ end
46
+
47
+ # Detect the required encoding for a message
48
+ #
49
+ # @param message [String] The message to analyze
50
+ # @return [Symbol] :gsm or :unicode
51
+ #
52
+ # @example
53
+ # GsmEncoding.detect_encoding("Hello!") # => :gsm
54
+ # GsmEncoding.detect_encoding("ΠŸΡ€ΠΈΠ²Π΅Ρ‚") # => :unicode
55
+ def detect_encoding(message)
56
+ gsm_compatible?(message) ? :gsm : :unicode
57
+ end
58
+
59
+ # Calculate the GSM character count (extension chars count as 2)
60
+ #
61
+ # @param message [String] The message to count
62
+ # @return [Integer] Character count in GSM encoding
63
+ #
64
+ # @example
65
+ # GsmEncoding.gsm_char_count("Hello") # => 5
66
+ # GsmEncoding.gsm_char_count("Price: €10") # => 11 (€ counts as 2)
67
+ def gsm_char_count(message)
68
+ message.each_char.sum do |char|
69
+ EXTENSION_CHARACTERS.include?(char) ? 2 : 1
70
+ end
71
+ end
72
+
73
+ # Find all non-GSM characters in a message
74
+ #
75
+ # @param message [String] The message to check
76
+ # @return [Array<String>] Array of non-GSM characters found
77
+ #
78
+ # @example
79
+ # GsmEncoding.non_gsm_characters("Hello πŸ‘‹ World") # => ["πŸ‘‹"]
80
+ def non_gsm_characters(message)
81
+ message.each_char.reject { |char| can_encode_char?(char) }.uniq
82
+ end
83
+
84
+ # Calculate message info including SMS count
85
+ #
86
+ # @param message [String] The message to analyze
87
+ # @param commercial [Boolean] Whether this is a commercial SMS (includes STOP clause)
88
+ # @return [Hash] Message information with keys:
89
+ # - :characters - Character count (accounting for extension chars in GSM)
90
+ # - :encoding - :gsm or :unicode
91
+ # - :sms_count - Number of SMS segments required
92
+ # - :remaining - Characters remaining in current segment
93
+ # - :max_single_sms - Maximum chars for single SMS with this encoding
94
+ # - :non_gsm_chars - Array of non-GSM characters found (empty for GSM encoding)
95
+ #
96
+ # @example
97
+ # GsmEncoding.message_info("Hello!")
98
+ # # => { characters: 6, encoding: :gsm, sms_count: 1, remaining: 143, ... }
99
+ def message_info(message, commercial: true)
100
+ encoding = detect_encoding(message)
101
+
102
+ if encoding == :gsm
103
+ gsm_message_info(message, commercial: commercial)
104
+ else
105
+ unicode_message_info(message, commercial: commercial)
106
+ end
107
+ end
108
+
109
+ private
110
+
111
+ def can_encode_char?(char)
112
+ GSMEncoder.can_encode?(char)
113
+ end
114
+
115
+ def gsm_message_info(message, commercial:)
116
+ char_count = gsm_char_count(message)
117
+
118
+ first_limit = commercial ? GSM_FIRST_COMMERCIAL_LIMIT : GSM_SINGLE_SMS_LIMIT
119
+ concat_limit = commercial ? GSM_CONCAT_COMMERCIAL_LIMIT : GSM_CONCAT_SMS_LIMIT
120
+
121
+ sms_count, remaining = calculate_sms_count(char_count, first_limit, concat_limit)
122
+
123
+ {
124
+ characters: char_count,
125
+ encoding: :gsm,
126
+ sms_count: sms_count,
127
+ remaining: remaining,
128
+ max_single_sms: first_limit,
129
+ non_gsm_chars: []
130
+ }
131
+ end
132
+
133
+ def unicode_message_info(message, commercial:)
134
+ char_count = message.length
135
+
136
+ first_limit = commercial ? UNICODE_FIRST_COMMERCIAL_LIMIT : UNICODE_SINGLE_SMS_LIMIT
137
+ concat_limit = commercial ? UNICODE_CONCAT_COMMERCIAL_LIMIT : UNICODE_CONCAT_SMS_LIMIT
138
+
139
+ sms_count, remaining = calculate_sms_count(char_count, first_limit, concat_limit)
140
+
141
+ {
142
+ characters: char_count,
143
+ encoding: :unicode,
144
+ sms_count: sms_count,
145
+ remaining: remaining,
146
+ max_single_sms: first_limit,
147
+ non_gsm_chars: non_gsm_characters(message)
148
+ }
149
+ end
150
+
151
+ def calculate_sms_count(char_count, first_limit, concat_limit)
152
+ if char_count <= first_limit
153
+ [1, first_limit - char_count]
154
+ else
155
+ # Multi-part SMS calculation
156
+ remaining_after_first = char_count - first_limit
157
+ additional_sms = (remaining_after_first.to_f / concat_limit).ceil
158
+ total_sms = 1 + additional_sms
159
+
160
+ total_capacity = first_limit + (additional_sms * concat_limit)
161
+ remaining = total_capacity - char_count
162
+
163
+ [total_sms, remaining]
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ovh
4
+ module Http2sms
5
+ # Phone number formatting and validation utilities
6
+ #
7
+ # Converts local phone number formats to international format required by OVH API.
8
+ # OVH requires the 00 prefix (e.g., 0033601020304 for French numbers).
9
+ # Supports configurable country codes for different regions.
10
+ #
11
+ # @example Convert French number
12
+ # PhoneNumber.format("0601020304") # => "0033601020304"
13
+ #
14
+ # @example Convert UK number
15
+ # PhoneNumber.format("07911123456", country_code: "44") # => "00447911123456"
16
+ module PhoneNumber
17
+ # Pattern for numbers starting with 0 but not 00 (local format)
18
+ LOCAL_FORMAT_PATTERN = /\A0(?!0)/
19
+
20
+ # Pattern for numbers starting with + (international format with plus)
21
+ PLUS_FORMAT_PATTERN = /\A\+/
22
+
23
+ # Pattern for numbers starting with 00 (already OVH format)
24
+ DOUBLE_ZERO_PATTERN = /\A00/
25
+
26
+ # Valid phone number pattern (digits only, 9-17 digits for 00 prefix format)
27
+ VALID_PHONE_PATTERN = /\A00\d{7,15}\z/
28
+
29
+ class << self
30
+ # Format a phone number to OVH international format (00 prefix)
31
+ #
32
+ # @param phone [String] Phone number in local or international format
33
+ # @param country_code [String] Country code to use for local numbers (default: from config)
34
+ # @return [String] Phone number in OVH format (e.g., "0033601020304")
35
+ # @raise [PhoneNumberError] if phone number is invalid
36
+ #
37
+ # @example Local French number
38
+ # PhoneNumber.format("0601020304") # => "0033601020304"
39
+ #
40
+ # @example Already international with +
41
+ # PhoneNumber.format("+33601020304") # => "0033601020304"
42
+ #
43
+ # @example Already OVH format
44
+ # PhoneNumber.format("0033601020304") # => "0033601020304"
45
+ #
46
+ # @example UK number
47
+ # PhoneNumber.format("07911123456", country_code: "44") # => "00447911123456"
48
+ def format(phone, country_code: nil)
49
+ return nil if phone.nil?
50
+
51
+ country_code ||= Ovh::Http2sms.configuration.default_country_code
52
+
53
+ # Remove all non-digit characters except leading +
54
+ cleaned = clean_phone(phone)
55
+
56
+ # Convert to OVH international format (00 prefix)
57
+ formatted = to_ovh_format(cleaned, country_code)
58
+
59
+ # Validate the result
60
+ validate!(formatted)
61
+
62
+ formatted
63
+ end
64
+
65
+ # Format multiple phone numbers
66
+ #
67
+ # @param phones [Array<String>, String] Phone number(s) - can be array or comma-separated string
68
+ # @param country_code [String] Country code to use for local numbers
69
+ # @return [Array<String>] Array of formatted phone numbers
70
+ # @raise [PhoneNumberError] if any phone number is invalid
71
+ #
72
+ # @example Array input
73
+ # PhoneNumber.format_multiple(["0601020304", "0602030405"])
74
+ # # => ["0033601020304", "0033602030405"]
75
+ #
76
+ # @example Comma-separated string
77
+ # PhoneNumber.format_multiple("0601020304,0602030405")
78
+ # # => ["0033601020304", "0033602030405"]
79
+ def format_multiple(phones, country_code: nil)
80
+ phone_array = phones.is_a?(Array) ? phones : phones.to_s.split(",")
81
+ phone_array.map { |p| format(p.strip, country_code: country_code) }
82
+ end
83
+
84
+ # Validate a phone number format
85
+ #
86
+ # @param phone [String] Phone number to validate
87
+ # @return [Boolean] true if valid
88
+ def valid?(phone)
89
+ return false if phone.nil? || phone.empty?
90
+
91
+ phone.match?(VALID_PHONE_PATTERN)
92
+ end
93
+
94
+ # Validate a phone number and raise error if invalid
95
+ #
96
+ # @param phone [String] Phone number to validate
97
+ # @raise [PhoneNumberError] if phone number is invalid
98
+ # @return [void]
99
+ def validate!(phone)
100
+ return if valid?(phone)
101
+
102
+ raise PhoneNumberError.new(
103
+ "Invalid phone number format: '#{phone}'. " \
104
+ "Expected OVH format with 00 prefix (e.g., 0033601020304)",
105
+ phone_number: phone
106
+ )
107
+ end
108
+
109
+ private
110
+
111
+ def clean_phone(phone)
112
+ # Remove all whitespace, dashes, dots, and parentheses
113
+ phone.to_s.gsub(/[\s\-.()\[\]]/, "")
114
+ end
115
+
116
+ def to_ovh_format(phone, country_code)
117
+ if phone.match?(PLUS_FORMAT_PATTERN)
118
+ # +33601020304 -> 0033601020304
119
+ "00#{phone.sub(PLUS_FORMAT_PATTERN, "")}"
120
+ elsif phone.match?(DOUBLE_ZERO_PATTERN)
121
+ # Already in OVH format: 0033601020304
122
+ phone
123
+ elsif phone.match?(LOCAL_FORMAT_PATTERN)
124
+ # 0601020304 -> 0033601020304
125
+ "00#{country_code}#{phone.sub(LOCAL_FORMAT_PATTERN, "")}"
126
+ else
127
+ # Assume raw country code format: 33601020304 -> 0033601020304
128
+ "00#{phone}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ovh
4
+ module Http2sms
5
+ # Rails integration for OVH HTTP2SMS
6
+ #
7
+ # Automatically loads generators and sets up Rails-specific defaults.
8
+ class Railtie < Rails::Railtie
9
+ generators do
10
+ require_relative "../../generators/ovh/http2sms/install_generator"
11
+ end
12
+
13
+ initializer "ovh_http2sms.set_defaults" do
14
+ # Set Rails logger as default if not already configured
15
+ config.after_initialize do
16
+ if Ovh::Http2sms.configuration.logger.nil? && defined?(Rails.logger)
17
+ Ovh::Http2sms.configuration.logger = Rails.logger
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ovh
6
+ module Http2sms
7
+ # Response object for OVH HTTP2SMS API responses
8
+ #
9
+ # Parses responses in all supported formats: JSON, XML, HTML, and text/plain.
10
+ # Provides a unified interface for accessing response data.
11
+ #
12
+ # @example Successful response
13
+ # response = Response.parse(body, content_type: "application/json")
14
+ # response.success? # => true
15
+ # response.sms_ids # => ["123456789"]
16
+ # response.credits_remaining # => 1987.0
17
+ #
18
+ # @example Error response
19
+ # response = Response.parse(body, content_type: "application/json")
20
+ # response.success? # => false
21
+ # response.error_message # => "Missing message"
22
+ class Response
23
+ # @return [Integer] API status code (100, 101 = success; 201, 202, 401 = error)
24
+ attr_reader :status
25
+
26
+ # @return [Float, nil] Remaining SMS credits
27
+ attr_reader :credits_remaining
28
+
29
+ # @return [Array<String>] SMS IDs for sent messages
30
+ attr_reader :sms_ids
31
+
32
+ # @return [String, nil] Error message if request failed
33
+ attr_reader :error_message
34
+
35
+ # @return [String] Raw response body
36
+ attr_reader :raw_response
37
+
38
+ # @return [String] Content type of the response
39
+ attr_reader :content_type
40
+
41
+ # Success status codes
42
+ SUCCESS_CODES = [100, 101].freeze
43
+
44
+ # Error status codes
45
+ ERROR_CODES = {
46
+ 201 => :missing_parameter,
47
+ 202 => :invalid_parameter,
48
+ 241 => :sender_not_found,
49
+ 401 => :authentication_error
50
+ }.freeze
51
+
52
+ # rubocop:disable Metrics/ParameterLists
53
+ def initialize(status:, credits_remaining: nil, sms_ids: [], error_message: nil,
54
+ raw_response: nil, content_type: nil)
55
+ # rubocop:enable Metrics/ParameterLists
56
+ @status = status
57
+ @credits_remaining = credits_remaining
58
+ @sms_ids = Array(sms_ids).map(&:to_s)
59
+ @error_message = error_message
60
+ @raw_response = raw_response
61
+ @content_type = content_type
62
+ end
63
+
64
+ # Check if the request was successful
65
+ #
66
+ # @return [Boolean] true if status code indicates success (100 or 101)
67
+ def success?
68
+ SUCCESS_CODES.include?(status)
69
+ end
70
+
71
+ # Check if the request failed
72
+ #
73
+ # @return [Boolean] true if status code indicates failure
74
+ def failure?
75
+ !success?
76
+ end
77
+
78
+ # Get the error type based on status code
79
+ #
80
+ # @return [Symbol, nil] Error type (:missing_parameter, :invalid_parameter, :authentication_error)
81
+ def error_type
82
+ ERROR_CODES[status]
83
+ end
84
+
85
+ # Parse a raw API response
86
+ #
87
+ # @param body [String] Raw response body
88
+ # @param content_type [String] Content-Type header value
89
+ # @return [Response] Parsed response object
90
+ #
91
+ # @example Parse JSON response
92
+ # Response.parse('{"status":100,"creditLeft":"1987","SmsIds":["123"]}', content_type: "application/json")
93
+ def self.parse(body, content_type: "text/plain")
94
+ parser = ResponseParser.new(body, content_type)
95
+ parser.parse
96
+ end
97
+ end
98
+
99
+ # Internal parser for different response formats
100
+ # @api private
101
+ class ResponseParser
102
+ def initialize(body, content_type)
103
+ @body = body.to_s
104
+ @content_type = content_type.to_s.downcase
105
+ end
106
+
107
+ def parse
108
+ case @content_type
109
+ when /json/
110
+ parse_json
111
+ when /xml/
112
+ parse_xml
113
+ when /html/
114
+ parse_html
115
+ else
116
+ parse_plain
117
+ end
118
+ rescue StandardError => e
119
+ # If parsing fails, raise a specific error
120
+ raise ResponseParseError.new(
121
+ "Failed to parse API response: #{e.message}",
122
+ content_type: @content_type,
123
+ raw_response: @body
124
+ )
125
+ end
126
+
127
+ private
128
+
129
+ def parse_json
130
+ data = JSON.parse(@body)
131
+
132
+ Response.new(
133
+ status: data["status"].to_i,
134
+ credits_remaining: parse_credits(data["creditLeft"]),
135
+ sms_ids: data["SmsIds"] || data["smsIds"] || [],
136
+ error_message: data["message"],
137
+ raw_response: @body,
138
+ content_type: @content_type
139
+ )
140
+ end
141
+
142
+ def parse_xml
143
+ # Simple XML parsing without additional dependencies
144
+ status = extract_xml_value("status").to_i
145
+ credits = parse_credits(extract_xml_value("creditLeft"))
146
+ message = extract_xml_value("message")
147
+ sms_ids = extract_xml_sms_ids
148
+
149
+ Response.new(
150
+ status: status,
151
+ credits_remaining: credits,
152
+ sms_ids: sms_ids,
153
+ error_message: message,
154
+ raw_response: @body,
155
+ content_type: @content_type
156
+ )
157
+ end
158
+
159
+ def parse_html
160
+ # HTML format: OK/KO<br>credits/message<br>sms_id<br>
161
+ lines = extract_html_lines
162
+
163
+ parse_text_lines(lines)
164
+ end
165
+
166
+ def parse_plain
167
+ # Text/plain format:
168
+ # OK\n1987\n123456789
169
+ # or
170
+ # KO\nError message
171
+ lines = @body.strip.split(/\r?\n/)
172
+
173
+ parse_text_lines(lines)
174
+ end
175
+
176
+ def parse_text_lines(lines)
177
+ return empty_response if lines.empty?
178
+
179
+ status_line = lines[0].to_s.strip.upcase
180
+
181
+ if status_line == "OK"
182
+ parse_success_lines(lines)
183
+ else
184
+ parse_error_lines(lines, status_line)
185
+ end
186
+ end
187
+
188
+ def parse_success_lines(lines)
189
+ credits = lines[1] ? parse_credits(lines[1]) : nil
190
+ sms_ids = lines[2..].map(&:strip).reject(&:empty?)
191
+
192
+ Response.new(
193
+ status: 100,
194
+ credits_remaining: credits,
195
+ sms_ids: sms_ids,
196
+ raw_response: @body,
197
+ content_type: @content_type
198
+ )
199
+ end
200
+
201
+ def parse_error_lines(lines, status_line)
202
+ # Status might be numeric (like in some error cases) or "KO"
203
+ status = status_line == "KO" ? 0 : status_line.to_i
204
+ error_message = lines[1..].join("\n").strip
205
+
206
+ # Try to extract status code from message if present
207
+ if error_message =~ /\A(\d{3})\s/
208
+ status = ::Regexp.last_match(1).to_i
209
+ error_message = error_message.sub(/\A\d{3}\s*/, "")
210
+ end
211
+
212
+ Response.new(
213
+ status: status,
214
+ error_message: error_message.empty? ? "Unknown error" : error_message,
215
+ raw_response: @body,
216
+ content_type: @content_type
217
+ )
218
+ end
219
+
220
+ def empty_response
221
+ Response.new(
222
+ status: 0,
223
+ error_message: "Empty response",
224
+ raw_response: @body,
225
+ content_type: @content_type
226
+ )
227
+ end
228
+
229
+ def extract_xml_value(tag)
230
+ match = @body.match(%r{<#{tag}>(.*?)</#{tag}>}i)
231
+ match ? match[1] : nil
232
+ end
233
+
234
+ def extract_xml_sms_ids
235
+ # Handle both <smsIds><smsId>...</smsId></smsIds> format
236
+ ids = []
237
+ @body.scan(%r{<smsId>(.*?)</smsId>}i) { |match| ids << match[0] }
238
+ ids
239
+ end
240
+
241
+ def extract_html_lines
242
+ # Extract content between <BODY> tags and split by <br>
243
+ body_match = @body.match(%r{<BODY>(.*?)</BODY>}im)
244
+ return [] unless body_match
245
+
246
+ body_content = body_match[1]
247
+ body_content.split(%r{<br\s*/?>}).map(&:strip).reject(&:empty?)
248
+ end
249
+
250
+ def parse_credits(value)
251
+ return nil if value.nil? || value.to_s.empty?
252
+
253
+ Float(value)
254
+ rescue ArgumentError, TypeError
255
+ nil
256
+ end
257
+ end
258
+ end
259
+ end