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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +37 -0
- data/LICENSE +21 -0
- data/README.md +386 -0
- data/exe/kwtsms +222 -0
- data/lib/kwtsms/client.rb +395 -0
- data/lib/kwtsms/env_loader.rb +34 -0
- data/lib/kwtsms/errors.rb +51 -0
- data/lib/kwtsms/logger.rb +19 -0
- data/lib/kwtsms/message.rb +86 -0
- data/lib/kwtsms/phone.rb +64 -0
- data/lib/kwtsms/request.rb +80 -0
- data/lib/kwtsms/version.rb +5 -0
- data/lib/kwtsms.rb +30 -0
- metadata +65 -0
|
@@ -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
|
data/lib/kwtsms/phone.rb
ADDED
|
@@ -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
|