kwtsms 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,395 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module KwtSMS
6
+ # InvalidEntry represents a phone number that failed local pre-validation.
7
+ InvalidEntry = Struct.new(:input, :error, keyword_init: true) do
8
+ def to_h
9
+ { "input" => input, "error" => error }
10
+ end
11
+ end
12
+
13
+ # kwtSMS API client. Zero external dependencies. Ruby 2.7+
14
+ #
15
+ # Server timezone: Asia/Kuwait (GMT+3).
16
+ # unix-timestamp values in API responses are GMT+3 server time, not UTC.
17
+ # Log timestamps written by this client are always UTC ISO-8601.
18
+ #
19
+ # Quick start:
20
+ # sms = KwtSMS::Client.from_env
21
+ # ok, balance, err = sms.verify
22
+ # result = sms.send_sms("96598765432", "Your OTP for MYAPP is: 123456")
23
+ # result = sms.send_sms("96598765432", "Hello", sender: "OTHER-ID")
24
+ # report = sms.validate(["96598765432", "+96512345678"])
25
+ # balance = sms.balance
26
+ class Client
27
+ attr_reader :username, :sender_id, :test_mode, :log_file,
28
+ :cached_balance, :cached_purchased
29
+
30
+ # @param username [String] API username (not your account phone number)
31
+ # @param password [String] API password
32
+ # @param sender_id [String] Sender ID shown on recipient's phone (default: "KWT-SMS")
33
+ # @param test_mode [Boolean] When true, messages are queued but not delivered (default: false)
34
+ # @param log_file [String] Path to JSONL log file. Set to "" to disable logging.
35
+ def initialize(username, password, sender_id: "KWT-SMS", test_mode: false, log_file: "kwtsms.log")
36
+ raise ArgumentError, "username and password are required" if username.nil? || username.empty? || password.nil? || password.empty?
37
+
38
+ @username = username
39
+ @password = password
40
+ @sender_id = sender_id
41
+ @test_mode = test_mode
42
+ @log_file = log_file
43
+ @cached_balance = nil
44
+ @cached_purchased = nil
45
+ end
46
+
47
+ # Load credentials from environment variables, falling back to .env file.
48
+ #
49
+ # Required env vars:
50
+ # KWTSMS_USERNAME : API username
51
+ # KWTSMS_PASSWORD : API password
52
+ #
53
+ # Optional env vars:
54
+ # KWTSMS_SENDER_ID : Sender ID (default: "KWT-SMS")
55
+ # KWTSMS_TEST_MODE : "1" to queue without delivering (default: "0")
56
+ # KWTSMS_LOG_FILE : JSONL log path (default: "kwtsms.log")
57
+ def self.from_env(env_file: ".env")
58
+ file_env = KwtSMS.load_env_file(env_file)
59
+
60
+ get = lambda do |key, default = ""|
61
+ val = ENV[key]
62
+ return val unless val.nil?
63
+
64
+ val = file_env[key]
65
+ return val unless val.nil?
66
+
67
+ default
68
+ end
69
+
70
+ username = get.call("KWTSMS_USERNAME")
71
+ password = get.call("KWTSMS_PASSWORD")
72
+ sender_id = get.call("KWTSMS_SENDER_ID", "KWT-SMS")
73
+ test_mode = get.call("KWTSMS_TEST_MODE", "0") == "1"
74
+ log_file = get.call("KWTSMS_LOG_FILE", "kwtsms.log")
75
+
76
+ missing = []
77
+ missing << "KWTSMS_USERNAME" if username.empty?
78
+ missing << "KWTSMS_PASSWORD" if password.empty?
79
+ raise ArgumentError, "Missing credentials: #{missing.join(', ')}" unless missing.empty?
80
+
81
+ new(username, password, sender_id: sender_id, test_mode: test_mode, log_file: log_file)
82
+ end
83
+
84
+ # Test credentials by calling /balance/.
85
+ # Returns: [ok, balance, error]
86
+ # ok: true/false
87
+ # balance: Float or nil
88
+ # error: nil or error message string
89
+ def verify
90
+ data = KwtSMS.api_request("balance", creds, @log_file)
91
+ if data["result"] == "OK"
92
+ @cached_balance = data["available"].to_f
93
+ @cached_purchased = data["purchased"].to_f
94
+ [true, @cached_balance, nil]
95
+ else
96
+ data = KwtSMS.enrich_error(data)
97
+ description = data["description"] || data["code"] || "Unknown error"
98
+ action = data["action"]
99
+ error = action ? "#{description} > #{action}" : description
100
+ [false, nil, error]
101
+ end
102
+ rescue RuntimeError => e
103
+ [false, nil, e.message]
104
+ end
105
+
106
+ # Get current balance via /balance/ API call.
107
+ # Returns Float or nil on error (returns cached value if available).
108
+ def balance
109
+ ok, bal, = verify
110
+ ok ? bal : @cached_balance
111
+ end
112
+
113
+ # Get delivery status for a sent message via /report/.
114
+ #
115
+ # @param msg_id [String] The message ID returned by send_sms in result["msg-id"]
116
+ # @return [Hash] OK or ERROR hash with action guidance
117
+ def status(msg_id)
118
+ data = KwtSMS.api_request("report", creds.merge("msgid" => msg_id.to_s), @log_file)
119
+ KwtSMS.enrich_error(data)
120
+ rescue RuntimeError => e
121
+ { "result" => "ERROR", "code" => "NETWORK", "description" => e.message,
122
+ "action" => "Check your internet connection and try again." }
123
+ end
124
+
125
+ # List sender IDs registered on this account via /senderid/.
126
+ # Returns a consistent hash. Never raises, never crashes.
127
+ def senderids
128
+ data = KwtSMS.api_request("senderid", creds, @log_file)
129
+ if data["result"] == "OK"
130
+ { "result" => "OK", "senderids" => data["senderid"] || [] }
131
+ else
132
+ KwtSMS.enrich_error(data)
133
+ end
134
+ rescue RuntimeError => e
135
+ { "result" => "ERROR", "code" => "NETWORK", "description" => e.message,
136
+ "action" => "Check your internet connection and try again." }
137
+ end
138
+
139
+ # List active country prefixes via /coverage/.
140
+ # Returns the full API response hash with error enrichment.
141
+ def coverage
142
+ data = KwtSMS.api_request("coverage", creds, @log_file)
143
+ KwtSMS.enrich_error(data)
144
+ rescue RuntimeError => e
145
+ { "result" => "ERROR", "code" => "NETWORK", "description" => e.message,
146
+ "action" => "Check your internet connection and try again." }
147
+ end
148
+
149
+ # Validate and normalize phone numbers via /validate/.
150
+ #
151
+ # Numbers that fail local validation are rejected immediately with a clear error.
152
+ # Numbers that pass local validation are sent to the kwtSMS /validate/ endpoint.
153
+ #
154
+ # @param phones [Array<String>] List of phone numbers to validate
155
+ # @return [Hash] with keys: ok, er, nr, raw, error, rejected
156
+ def validate(phones)
157
+ valid_normalized = []
158
+ pre_rejected = []
159
+
160
+ Array(phones).each do |raw|
161
+ is_valid, error, normalized = KwtSMS.validate_phone_input(raw)
162
+ if is_valid
163
+ valid_normalized << normalized
164
+ else
165
+ pre_rejected << { "input" => raw.to_s, "error" => error }
166
+ end
167
+ end
168
+
169
+ result = {
170
+ "ok" => [],
171
+ "er" => pre_rejected.map { |r| r["input"] },
172
+ "nr" => [],
173
+ "raw" => nil,
174
+ "error" => nil,
175
+ "rejected" => pre_rejected
176
+ }
177
+
178
+ if valid_normalized.empty?
179
+ result["error"] = if pre_rejected.length == 1
180
+ pre_rejected[0]["error"]
181
+ else
182
+ "All #{pre_rejected.length} phone numbers failed validation"
183
+ end
184
+ return result
185
+ end
186
+
187
+ payload = creds.merge("mobile" => valid_normalized.join(","))
188
+ begin
189
+ data = KwtSMS.api_request("validate", payload, @log_file)
190
+ if data["result"] == "OK"
191
+ mobile = data["mobile"] || {}
192
+ result["ok"] = mobile["OK"] || []
193
+ result["er"] = (mobile["ER"] || []) + result["er"]
194
+ result["nr"] = mobile["NR"] || []
195
+ result["raw"] = data
196
+ else
197
+ data = KwtSMS.enrich_error(data)
198
+ result["er"] = valid_normalized + result["er"]
199
+ result["raw"] = data
200
+ result["error"] = data["description"] || data["code"]
201
+ result["error"] = "#{result['error']} > #{data['action']}" if data["action"]
202
+ end
203
+ rescue RuntimeError => e
204
+ result["er"] = valid_normalized + result["er"]
205
+ result["error"] = e.message
206
+ end
207
+
208
+ result
209
+ end
210
+
211
+ # Send SMS to one or more numbers.
212
+ #
213
+ # @param mobile [String, Array<String>] Phone number(s). Normalized automatically.
214
+ # @param message [String] SMS text. Cleaned automatically.
215
+ # @param sender [String, nil] Optional sender ID override for this call only.
216
+ # @return [Hash] API response with result, msg-id, balance-after, etc.
217
+ def send_sms(mobile, message, sender: nil)
218
+ effective_sender = sender || @sender_id
219
+
220
+ raw_list = mobile.is_a?(Array) ? mobile : [mobile]
221
+
222
+ valid_numbers = []
223
+ invalid = []
224
+
225
+ raw_list.each do |raw|
226
+ is_valid, error, normalized = KwtSMS.validate_phone_input(raw)
227
+ if is_valid
228
+ valid_numbers << normalized
229
+ else
230
+ invalid << { "input" => raw.to_s, "error" => error }
231
+ end
232
+ end
233
+
234
+ if valid_numbers.empty?
235
+ description = if invalid.length == 1
236
+ invalid[0]["error"]
237
+ else
238
+ "All #{invalid.length} phone numbers are invalid"
239
+ end
240
+ return KwtSMS.enrich_error({
241
+ "result" => "ERROR",
242
+ "code" => "ERR_INVALID_INPUT",
243
+ "description" => description,
244
+ "invalid" => invalid
245
+ })
246
+ end
247
+
248
+ # Deduplicate normalized numbers
249
+ valid_numbers = valid_numbers.uniq
250
+
251
+ # Clean message before routing
252
+ cleaned_message = KwtSMS.clean_message(message)
253
+ if cleaned_message.strip.empty?
254
+ return KwtSMS.enrich_error({
255
+ "result" => "ERROR",
256
+ "code" => "ERR009",
257
+ "description" => "Message is empty after cleaning (contained only emojis or invisible characters)."
258
+ })
259
+ end
260
+
261
+ if valid_numbers.length > 200
262
+ result = send_bulk(valid_numbers, cleaned_message, effective_sender)
263
+ else
264
+ payload = creds.merge(
265
+ "sender" => effective_sender,
266
+ "mobile" => valid_numbers.join(","),
267
+ "message" => cleaned_message,
268
+ "test" => @test_mode ? "1" : "0"
269
+ )
270
+ begin
271
+ result = KwtSMS.api_request("send", payload, @log_file)
272
+ rescue RuntimeError => e
273
+ return {
274
+ "result" => "ERROR",
275
+ "code" => "NETWORK",
276
+ "description" => e.message,
277
+ "action" => "Check your internet connection and try again."
278
+ }
279
+ end
280
+ if result["result"] == "OK" && result.key?("balance-after")
281
+ @cached_balance = result["balance-after"].to_f
282
+ else
283
+ result = KwtSMS.enrich_error(result)
284
+ end
285
+ end
286
+
287
+ result["invalid"] = invalid unless invalid.empty?
288
+ result
289
+ end
290
+
291
+ # Send SMS, retrying automatically on ERR028 (rate limit: wait 15 seconds).
292
+ #
293
+ # Waits 16 seconds between retries (15s required + 1s buffer).
294
+ # All other errors are returned immediately without retry.
295
+ #
296
+ # @param mobile [String, Array<String>] Phone number(s)
297
+ # @param message [String] Message text
298
+ # @param sender [String, nil] Sender ID override
299
+ # @param max_retries [Integer] Max ERR028 retries after first attempt (default 3)
300
+ # @return [Hash] Same shape as send_sms. Never raises.
301
+ def send_with_retry(mobile, message, sender: nil, max_retries: 3)
302
+ result = send_sms(mobile, message, sender: sender)
303
+ retries = 0
304
+ while result["code"] == "ERR028" && retries < max_retries
305
+ sleep(16)
306
+ result = send_sms(mobile, message, sender: sender)
307
+ retries += 1
308
+ end
309
+ result
310
+ end
311
+
312
+ private
313
+
314
+ def creds
315
+ { "username" => @username, "password" => @password }
316
+ end
317
+
318
+ # Internal: send to >200 pre-normalized numbers in batches of 200.
319
+ def send_bulk(numbers, message, sender)
320
+ batch_size = 200
321
+ batch_delay = 0.5
322
+ err013_wait = [30, 60, 120]
323
+
324
+ batches = numbers.each_slice(batch_size).to_a
325
+ total_batches = batches.length
326
+
327
+ msg_ids = []
328
+ errors = []
329
+ total_nums = 0
330
+ total_pts = 0
331
+ last_balance = nil
332
+
333
+ batches.each_with_index do |batch, i|
334
+ payload = creds.merge(
335
+ "sender" => sender,
336
+ "mobile" => batch.join(","),
337
+ "message" => message,
338
+ "test" => @test_mode ? "1" : "0"
339
+ )
340
+
341
+ data = nil
342
+ waits = [0] + err013_wait
343
+ waits.each_with_index do |wait, attempt|
344
+ sleep(wait) if wait > 0
345
+ begin
346
+ data = KwtSMS.api_request("send", payload, @log_file)
347
+ rescue RuntimeError => e
348
+ errors << { "batch" => i + 1, "code" => "NETWORK", "description" => e.message }
349
+ data = nil
350
+ break
351
+ end
352
+ break if data["code"] != "ERR013" || attempt == err013_wait.length
353
+ end
354
+
355
+ if data && data["result"] == "OK"
356
+ msg_ids << (data["msg-id"] || "")
357
+ total_nums += (data["numbers"] || batch.length).to_i
358
+ total_pts += (data["points-charged"] || 0).to_i
359
+ if data.key?("balance-after")
360
+ last_balance = data["balance-after"].to_f
361
+ @cached_balance = last_balance
362
+ end
363
+ elsif data && data["result"] == "ERROR"
364
+ errors << {
365
+ "batch" => i + 1,
366
+ "code" => data["code"],
367
+ "description" => data["description"]
368
+ }
369
+ end
370
+
371
+ sleep(batch_delay) if i < total_batches - 1
372
+ end
373
+
374
+ ok_count = msg_ids.length
375
+ overall = if ok_count == total_batches
376
+ "OK"
377
+ elsif ok_count == 0
378
+ "ERROR"
379
+ else
380
+ "PARTIAL"
381
+ end
382
+
383
+ {
384
+ "result" => overall,
385
+ "bulk" => true,
386
+ "batches" => total_batches,
387
+ "numbers" => total_nums,
388
+ "points-charged" => total_pts,
389
+ "balance-after" => last_balance,
390
+ "msg-ids" => msg_ids,
391
+ "errors" => errors
392
+ }
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KwtSMS
4
+ # Load key=value pairs from a .env file. Returns empty hash if not found.
5
+ # Never raises. Never modifies ENV.
6
+ def self.load_env_file(env_file = ".env")
7
+ env = {}
8
+ begin
9
+ File.foreach(env_file, encoding: "utf-8") do |line|
10
+ line = line.strip
11
+ next if line.empty? || line.start_with?("#")
12
+ next unless line.include?("=")
13
+
14
+ key, _, value = line.partition("=")
15
+ val = value.strip
16
+
17
+ # Strip inline comments from unquoted values
18
+ unless val.start_with?('"') || val.start_with?("'")
19
+ val = val.sub(/\s+#.*\z/, "")
20
+ end
21
+
22
+ # Strip one matching outer quote pair
23
+ if val.length >= 2 && val[0] == val[-1] && (val[0] == '"' || val[0] == "'")
24
+ val = val[1..-2]
25
+ end
26
+
27
+ env[key.strip] = val
28
+ end
29
+ rescue Errno::ENOENT
30
+ # File not found, return empty hash silently
31
+ end
32
+ env
33
+ end
34
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KwtSMS
4
+ # Maps every kwtSMS error code to a developer-friendly action message.
5
+ # Appended as result["action"] on any ERROR response so callers know what to do.
6
+ API_ERRORS = {
7
+ "ERR001" => "API is disabled on this account. Enable it at kwtsms.com > Account > API.",
8
+ "ERR002" => "A required parameter is missing. Check that username, password, sender, mobile, and message are all provided.",
9
+ "ERR003" => "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD. These are your API credentials, not your account mobile number.",
10
+ "ERR004" => "This account does not have API access. Contact kwtSMS support to enable it.",
11
+ "ERR005" => "This account is blocked. Contact kwtSMS support.",
12
+ "ERR006" => "No valid phone numbers. Make sure each number includes the country code (e.g., 96598765432 for Kuwait, not 98765432).",
13
+ "ERR007" => "Too many numbers in a single request (maximum 200). Split into smaller batches.",
14
+ "ERR008" => "This sender ID is banned or not found. Sender IDs are case sensitive (\"Kuwait\" is not the same as \"KUWAIT\"). Check your registered sender IDs at kwtsms.com.",
15
+ "ERR009" => "Message is empty. Provide a non-empty message text.",
16
+ "ERR010" => "Account balance is zero. Recharge credits at kwtsms.com.",
17
+ "ERR011" => "Insufficient balance for this send. Buy more credits at kwtsms.com.",
18
+ "ERR012" => "Message is too long (over 6 SMS pages). Shorten your message.",
19
+ "ERR013" => "Send queue is full (1000 messages). Wait a moment and try again.",
20
+ "ERR019" => "No delivery reports found for this message.",
21
+ "ERR020" => "Message ID does not exist. Make sure you saved the msg-id from the send response.",
22
+ "ERR021" => "No delivery report available for this message yet.",
23
+ "ERR022" => "Delivery reports are not ready yet. Try again after 24 hours.",
24
+ "ERR023" => "Unknown delivery report error. Contact kwtSMS support.",
25
+ "ERR024" => "Your IP address is not in the API whitelist. Add it at kwtsms.com > Account > API > IP Lockdown, or disable IP lockdown.",
26
+ "ERR025" => "Invalid phone number. Make sure the number includes the country code (e.g., 96598765432 for Kuwait, not 98765432).",
27
+ "ERR026" => "This country is not activated on your account. Contact kwtSMS support to enable the destination country.",
28
+ "ERR027" => "HTML tags are not allowed in the message. Remove any HTML content and try again.",
29
+ "ERR028" => "You must wait at least 15 seconds before sending to the same number again. No credits were consumed.",
30
+ "ERR029" => "Message ID does not exist or is incorrect.",
31
+ "ERR030" => "Message is stuck in the send queue with an error. Delete it at kwtsms.com > Queue to recover credits.",
32
+ "ERR031" => "Message rejected: bad language detected.",
33
+ "ERR032" => "Message rejected: spam detected.",
34
+ "ERR033" => "No active coverage found. Contact kwtSMS support.",
35
+ "ERR_INVALID_INPUT" => "One or more phone numbers are invalid. See details above."
36
+ }.freeze
37
+
38
+ # Add an "action" field to API error responses with developer-friendly guidance.
39
+ # Returns a new hash. Never mutates the original response.
40
+ # Has no effect on OK responses.
41
+ def self.enrich_error(data)
42
+ return data unless data.is_a?(Hash) && data["result"] == "ERROR"
43
+
44
+ code = data["code"].to_s
45
+ if API_ERRORS.key?(code)
46
+ data = data.dup
47
+ data["action"] = API_ERRORS[code]
48
+ end
49
+ data
50
+ end
51
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+
6
+ module KwtSMS
7
+ # Append a JSONL log entry. Never raises. Logging must not break main flow.
8
+ def self.write_log(log_file, entry)
9
+ return if log_file.nil? || log_file.empty?
10
+
11
+ begin
12
+ File.open(log_file, "a", encoding: "utf-8") do |f|
13
+ f.puts(JSON.generate(entry))
14
+ end
15
+ rescue StandardError
16
+ # Logging must never crash the main flow
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KwtSMS
4
+ # Hidden / invisible characters that break SMS delivery
5
+ HIDDEN_CHARS = [
6
+ "\u200B", # zero-width space
7
+ "\u200C", # zero-width non-joiner
8
+ "\u200D", # zero-width joiner
9
+ "\u2060", # word joiner
10
+ "\u00AD", # soft hyphen
11
+ "\uFEFF", # BOM / zero-width no-break space
12
+ "\uFFFC" # object replacement character
13
+ ].freeze
14
+
15
+ # Directional formatting characters
16
+ DIRECTIONAL_CHARS = [
17
+ "\u200E", # left-to-right mark
18
+ "\u200F", # right-to-left mark
19
+ "\u202A", "\u202B", "\u202C", "\u202D", "\u202E", # LRE, RLE, PDF, LRO, RLO
20
+ "\u2066", "\u2067", "\u2068", "\u2069" # LRI, RLI, FSI, PDI
21
+ ].freeze
22
+
23
+ STRIP_CHARS_SET = (HIDDEN_CHARS + DIRECTIONAL_CHARS).to_set.freeze
24
+
25
+ # Check if a Unicode codepoint is an emoji or pictographic symbol that breaks SMS delivery.
26
+ def self.emoji_codepoint?(cp)
27
+ (cp >= 0x1F600 && cp <= 0x1F64F) || # emoticons
28
+ (cp >= 0x1F300 && cp <= 0x1F5FF) || # misc symbols and pictographs
29
+ (cp >= 0x1F680 && cp <= 0x1F6FF) || # transport and map
30
+ (cp >= 0x1F700 && cp <= 0x1F77F) || # alchemical symbols
31
+ (cp >= 0x1F780 && cp <= 0x1F7FF) || # geometric shapes extended
32
+ (cp >= 0x1F800 && cp <= 0x1F8FF) || # supplemental arrows-C
33
+ (cp >= 0x1F900 && cp <= 0x1F9FF) || # supplemental symbols and pictographs
34
+ (cp >= 0x1FA00 && cp <= 0x1FA6F) || # chess symbols
35
+ (cp >= 0x1FA70 && cp <= 0x1FAFF) || # symbols and pictographs extended-A
36
+ (cp >= 0x2600 && cp <= 0x26FF) || # miscellaneous symbols
37
+ (cp >= 0x2700 && cp <= 0x27BF) || # dingbats
38
+ (cp >= 0xFE00 && cp <= 0xFE0F) || # variation selectors
39
+ (cp >= 0x1F000 && cp <= 0x1F0FF) || # mahjong tiles + playing cards
40
+ (cp >= 0x1F1E0 && cp <= 0x1F1FF) || # regional indicator symbols (country flags)
41
+ cp == 0x20E3 || # combining enclosing keycap
42
+ (cp >= 0xE0000 && cp <= 0xE007F) # tags block (subdivision flags)
43
+ end
44
+
45
+ # Clean SMS message text before sending to kwtSMS.
46
+ #
47
+ # Called automatically by KwtSMS::Client#send. No manual call needed.
48
+ #
49
+ # Strips content that silently breaks delivery:
50
+ # - Arabic-Indic / Extended Arabic-Indic digits converted to Latin digits
51
+ # - Emojis and pictographic symbols (silently stuck in queue)
52
+ # - Hidden control characters: BOM, zero-width space, soft hyphen, etc.
53
+ # - Directional formatting characters
54
+ # - C0/C1 control characters (except \n and \t)
55
+ # - HTML tags (causes ERR027)
56
+ #
57
+ # Does NOT strip Arabic letters. Arabic text is fully supported.
58
+ # Returns "" if the entire message was emoji or invisible characters.
59
+ def self.clean_message(text)
60
+ text = text.to_s
61
+
62
+ # 1. Convert Arabic-Indic and Extended Arabic-Indic digits to Latin
63
+ text = text.tr(ARABIC_DIGITS + EXTENDED_ARABIC_DIGITS, LATIN_DIGITS)
64
+
65
+ # 2. Strip HTML tags
66
+ text = text.gsub(/<[^>]*>/, "")
67
+
68
+ # 3. Remove emojis and pictographic characters, hidden chars, directional chars,
69
+ # and C0/C1 control characters (except \n and \t)
70
+ text = text.each_char.select do |c|
71
+ cp = c.ord
72
+ next false if emoji_codepoint?(cp)
73
+ next false if STRIP_CHARS_SET.include?(c)
74
+ # C0 controls (U+0000..U+001F) except TAB (0x09) and LF (0x0A)
75
+ next false if cp <= 0x1F && cp != 0x09 && cp != 0x0A
76
+ # DEL
77
+ next false if cp == 0x7F
78
+ # C1 controls (U+0080..U+009F)
79
+ next false if cp >= 0x80 && cp <= 0x9F
80
+
81
+ true
82
+ end.join
83
+
84
+ text
85
+ end
86
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ module KwtSMS
4
+ # Arabic-Indic digits (U+0660..U+0669) and Extended Arabic-Indic / Persian digits (U+06F0..U+06F9)
5
+ ARABIC_DIGITS = "\u0660\u0661\u0662\u0663\u0664\u0665\u0666\u0667\u0668\u0669"
6
+ EXTENDED_ARABIC_DIGITS = "\u06F0\u06F1\u06F2\u06F3\u06F4\u06F5\u06F6\u06F7\u06F8\u06F9"
7
+ LATIN_DIGITS = "01234567890123456789"
8
+
9
+ # Normalize phone to kwtSMS format: digits only, no leading zeros.
10
+ # Converts Arabic-Indic and Extended Arabic-Indic digits to Latin,
11
+ # strips all non-digit characters, strips leading zeros.
12
+ def self.normalize_phone(phone)
13
+ phone = phone.to_s
14
+ # 1. Convert Arabic-Indic and Extended Arabic-Indic digits to Latin
15
+ phone = phone.tr(ARABIC_DIGITS + EXTENDED_ARABIC_DIGITS, LATIN_DIGITS)
16
+ # 2. Strip every non-digit character
17
+ phone = phone.gsub(/\D/, "")
18
+ # 3. Strip leading zeros
19
+ phone = phone.sub(/\A0+/, "")
20
+ phone
21
+ end
22
+
23
+ # Validate a raw phone number input before sending to the kwtSMS API.
24
+ #
25
+ # Returns: [is_valid, error, normalized]
26
+ # is_valid: true/false
27
+ # error: nil or error message string
28
+ # normalized: normalized phone string
29
+ #
30
+ # Catches every common mistake without crashing:
31
+ # - Empty or blank input
32
+ # - Email address entered instead of a phone number
33
+ # - Non-numeric text with no digits
34
+ # - Too short after normalization (< 7 digits)
35
+ # - Too long after normalization (> 15 digits, E.164 maximum)
36
+ def self.validate_phone_input(phone)
37
+ raw = phone.to_s.strip
38
+
39
+ # 1. Empty / blank
40
+ return [false, "Phone number is required", ""] if raw.empty?
41
+
42
+ # 2. Email address entered by mistake
43
+ return [false, "'#{raw}' is an email address, not a phone number", ""] if raw.include?("@")
44
+
45
+ # 3. Normalize
46
+ normalized = normalize_phone(raw)
47
+
48
+ # 4. No digits survived normalization
49
+ return [false, "'#{raw}' is not a valid phone number, no digits found", ""] if normalized.empty?
50
+
51
+ # 5. Too short
52
+ if normalized.length < 7
53
+ digit_word = normalized.length == 1 ? "digit" : "digits"
54
+ return [false, "'#{raw}' is too short to be a valid phone number (#{normalized.length} #{digit_word}, minimum is 7)", normalized]
55
+ end
56
+
57
+ # 6. Too long
58
+ if normalized.length > 15
59
+ return [false, "'#{raw}' is too long to be a valid phone number (#{normalized.length} digits, maximum is 15)", normalized]
60
+ end
61
+
62
+ [true, nil, normalized]
63
+ end
64
+ end