kwtsms 0.3.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 30bcbad536b8b98f9bbf6c3612d5f01de9d8d14d8133746b1a493aa9c49ae7e4
4
- data.tar.gz: 6bd53db09b4acdd136779c1a1c8a032814a0472ac9e564d00347b99070e5b5c1
3
+ metadata.gz: 8d0309851d3d99cd00dc43e57fcc1a6a3c325573a3ef5f03cc605b6155e2f8c5
4
+ data.tar.gz: ded82b65342f4b32e9aa42ced78bcdeeeb496efb2865b543ec4dfef1d8e8bc46
5
5
  SHA512:
6
- metadata.gz: '0876d1fff5adf4ae9175b841aa434453d7cfd7ff771b915390411edc54f31697b770740580c3c9129f8b7459c4bf09ba04580380457aa0527e75361c63fced61'
7
- data.tar.gz: 5aceda9bda7fbf0a94a2f78f75238b9fcf7e82ac3d6f2a9574b2612228058a894cd31b4f2160702d3648f7469c95c0442a75dee5380987b2bc706858a040ec59
6
+ metadata.gz: abf1aab66a4eef8ce7254f8a7963992b65a1dd70b527b893181f7b72a29f836aca7a32f76a01fe5507bc557fc8a82c4c9244b10ad9d4c0cee8a69b30327e7075
7
+ data.tar.gz: 5551f9ce6129a20e7f2c33c23395acf89910db177362f097f048618621624365c4b9a3add2efd8a82359f5af0f40597230dc48d3d1ca6ef96d8a17241ef8ef1b
data/CHANGELOG.md CHANGED
@@ -5,6 +5,42 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.4.0] - 2026-11-07
11
+
12
+ ### Added
13
+ - The client sends a User-Agent `kwtsms-ruby/<version>` (name and version only, no personal data) so kwtSMS can see which integrations are in use. An optional `app_name:` keyword on `KwtSMS::Client.new` puts your app's token in front, e.g. `kwtsms-wordpress/3.5.2 kwtsms-ruby/0.3.1`. New `KwtSMS.build_user_agent` and `client.user_agent`.
14
+
15
+ ### Changed
16
+ - Package author and license holder are now kwtSMS; contact email support@kwtsms.com.
17
+
18
+ - `send_with_retry`: ERR028 retry is now off by default (`max_retries: 0`), and the wait is a new `wait_seconds:` keyword (default 10) instead of a hardcoded 16 seconds. Duplicate SMS protection (ERR028) is opt-in per account and its window is configurable per account (10 seconds by default); when on, the whole request is rejected and nothing is charged.
19
+ - ERR028 action text no longer states a fixed 15 seconds.
20
+ - ERR006 and ERR025 action text, docs and examples use the placeholder `965XXXXXXXX` instead of a sample mobile number; tests read the phone number from `KWTSMS_TEST_NUMBER` and skip the tests that need it when it is unset.
21
+ - The live 250-number bulk test is replaced by an offline mocked test of the 200 + 50 batching; live tests send only to `KWTSMS_TEST_NUMBER`.
22
+
23
+ ### Docs
24
+
25
+ - Test mode: test messages are queued but not delivered. Their credits are held until you delete them from the Sending Queue in your kwtsms.com account.
26
+
27
+ ## [0.3.1] - 2026-03-15
28
+
29
+ ### Fixed
30
+
31
+ - Password no longer visible in `Client#inspect` or `#to_s` output (redacted)
32
+ - Sanitize raw input in phone validation errors to prevent log injection
33
+ - Command injection risk in dependabot auto-merge workflow (use PR number not URL)
34
+ - Explicit SSL `VERIFY_PEER` on all HTTP connections
35
+ - Log file path traversal protection: rejects `..`, absolute paths, null bytes, pipes
36
+ - `.env` parser now handles `export KEY=VALUE` syntax
37
+ - CI workflow scoped to `permissions: contents: read`
38
+ - Missing error codes ERR019-ERR023 added to `API_ERRORS`
39
+ - Em dashes replaced with commas/colons per style rules
40
+ - SECURITY.md updated to reflect 0.3.x support
41
+ - CONTRIBUTING.md expanded with all required sections
42
+ - Error code test coverage includes ERR019-ERR023
43
+
8
44
  ## [0.3.0] - 2026-03-15
9
45
 
10
46
  ### Added
@@ -31,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
31
67
 
32
68
  ### Added
33
69
 
34
- - Raw API example (`examples/00_raw_api.rb`) — call all 7 kwtSMS endpoints using only Ruby stdlib, no gem needed
70
+ - Raw API example (`examples/00_raw_api.rb`): call all 7 kwtSMS endpoints using only Ruby stdlib, no gem needed
35
71
  - Step-by-step guide (`examples/00_raw_api.md`) with helper function reference, endpoints table, key rules, and going-live checklist
36
72
 
37
73
  ### Removed
@@ -69,6 +105,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
69
105
  - Comprehensive test suite: unit tests, mocked API tests, and real integration tests
70
106
  - Examples: basic usage, OTP flow, bulk SMS, Rails endpoint, error handling, production OTP
71
107
 
108
+ [0.3.1]: https://github.com/boxlinknet/kwtsms-ruby/releases/tag/v0.3.1
72
109
  [0.3.0]: https://github.com/boxlinknet/kwtsms-ruby/releases/tag/v0.3.0
73
110
  [0.2.0]: https://github.com/boxlinknet/kwtsms-ruby/releases/tag/v0.2.0
74
111
  [0.1.0]: https://github.com/boxlinknet/kwtsms-ruby/releases/tag/v0.1.0
data/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 boxlink
3
+ Copyright (c) 2026 kwtSMS
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  [![Gem Version](https://img.shields.io/gem/v/kwtsms.svg)](https://rubygems.org/gems/kwtsms)
4
4
  [![Gem Downloads](https://img.shields.io/gem/dt/kwtsms.svg)](https://rubygems.org/gems/kwtsms)
5
- [![CI](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/ci.yml)
6
- [![CodeQL](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/codeql.yml/badge.svg)](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/codeql.yml)
7
- [![Bundle Audit](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/audit.yml/badge.svg)](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/audit.yml)
8
- [![GitGuardian](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/gitguardian.yml/badge.svg)](https://github.com/boxlinknet/kwtsms-ruby/actions/workflows/gitguardian.yml)
9
- [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/boxlinknet/kwtsms-ruby/badge)](https://scorecard.dev/viewer/?uri=github.com/boxlinknet/kwtsms-ruby)
5
+ [![CI](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/ci.yml)
6
+ [![CodeQL](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/codeql.yml/badge.svg)](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/codeql.yml)
7
+ [![Bundle Audit](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/audit.yml/badge.svg)](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/audit.yml)
8
+ [![GitGuardian](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/gitguardian.yml/badge.svg)](https://github.com/kwtsms/kwtsms-ruby/actions/workflows/gitguardian.yml)
9
+ [![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/kwtsms/kwtsms-ruby/badge)](https://scorecard.dev/viewer/?uri=github.com/kwtsms/kwtsms-ruby)
10
10
  [![Ruby](https://img.shields.io/badge/Ruby-%3E%3D%202.7-red.svg)](https://www.ruby-lang.org/)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
12
12
 
@@ -52,7 +52,7 @@ ok, balance, err = sms.verify
52
52
  puts "Balance: #{balance}" if ok
53
53
 
54
54
  # Send SMS
55
- result = sms.send_sms("96598765432", "Your OTP for MyApp is: 123456")
55
+ result = sms.send_sms("965XXXXXXXX", "Your OTP for MyApp is: 123456")
56
56
  puts "msg-id: #{result['msg-id']}" if result["result"] == "OK"
57
57
  ```
58
58
 
@@ -82,6 +82,16 @@ sms = KwtSMS::Client.new(
82
82
  )
83
83
  ```
84
84
 
85
+ ### User-Agent
86
+
87
+ Every API request carries the User-Agent `kwtsms-ruby/<version>`: the library name and version only, no
88
+ personal or customer data. Pass your own product token as `app_name:` to put it in front:
89
+
90
+ ```ruby
91
+ sms = KwtSMS::Client.new("ruby_username", "ruby_password", app_name: "my-shop/2.1.0")
92
+ # User-Agent: my-shop/2.1.0 kwtsms-ruby/0.3.1
93
+ ```
94
+
85
95
  ## API Reference
86
96
 
87
97
  ### verify
@@ -109,13 +119,13 @@ Send SMS to one or more numbers.
109
119
 
110
120
  ```ruby
111
121
  # Single number
112
- result = sms.send_sms("96598765432", "Hello!")
122
+ result = sms.send_sms("965XXXXXXXX", "Hello!")
113
123
 
114
124
  # Multiple numbers
115
- result = sms.send_sms(["96598765432", "96512345678"], "Bulk message")
125
+ result = sms.send_sms(["965XXXXXXXX", "96512345678"], "Bulk message")
116
126
 
117
127
  # Override sender ID
118
- result = sms.send_sms("96598765432", "Hello!", sender: "MY-APP")
128
+ result = sms.send_sms("965XXXXXXXX", "Hello!", sender: "MY-APP")
119
129
  ```
120
130
 
121
131
  Response on success:
@@ -133,10 +143,10 @@ Response on success:
133
143
 
134
144
  ### send_with_retry
135
145
 
136
- Send with automatic retry on ERR028 (15-second rate limit).
146
+ Send with optional retry on ERR028. ERR028 comes from Duplicate SMS protection, which is opt-in per account (in the account's API settings). When it is on and any number in the request was sent the same message within the account's window (10 seconds by default, configurable per account), the whole request is rejected with ERR028 and nothing is charged. Retry is off by default (`max_retries: 0`); set `wait_seconds:` to your account's window.
137
147
 
138
148
  ```ruby
139
- result = sms.send_with_retry("96598765432", "Hello!", max_retries: 3)
149
+ result = sms.send_with_retry("965XXXXXXXX", "Hello!", max_retries: 1, wait_seconds: 10)
140
150
  ```
141
151
 
142
152
  ### senderids
@@ -161,7 +171,7 @@ result = sms.coverage
161
171
  Validate phone numbers.
162
172
 
163
173
  ```ruby
164
- result = sms.validate(["96598765432", "invalid", "+96512345678"])
174
+ result = sms.validate(["965XXXXXXXX", "invalid", "+96512345678"])
165
175
  puts result["ok"] # valid numbers
166
176
  puts result["er"] # error numbers
167
177
  puts result["rejected"] # locally rejected with error messages
@@ -171,7 +181,7 @@ puts result["rejected"] # locally rejected with error messages
171
181
 
172
182
  ```ruby
173
183
  # Normalize phone number: Arabic digits, strip non-digits, strip leading zeros
174
- KwtSMS.normalize_phone("+965 9876 5432") # => "96598765432"
184
+ KwtSMS.normalize_phone("+965 XXXX XXXX") # => "965XXXXXXXX"
175
185
 
176
186
  # Validate phone input (returns [valid, error, normalized])
177
187
  valid, error, normalized = KwtSMS.validate_phone_input("user@email.com")
@@ -212,22 +222,24 @@ All formats are accepted and normalized automatically:
212
222
 
213
223
  | Input | Normalized | Valid? |
214
224
  |-------|-----------|--------|
215
- | `96598765432` | `96598765432` | Yes |
216
- | `+96598765432` | `96598765432` | Yes |
217
- | `0096598765432` | `96598765432` | Yes |
218
- | `965 9876 5432` | `96598765432` | Yes |
219
- | `965-9876-5432` | `96598765432` | Yes |
220
- | `(965) 98765432` | `96598765432` | Yes |
221
- | `٩٦٥٩٨٧٦٥٤٣٢` | `96598765432` | Yes |
222
- | `۹۶۵۹۸۷۶۵۴۳۲` | `96598765432` | Yes |
223
- | `+٩٦٥٩٨٧٦٥٤٣٢` | `96598765432` | Yes |
224
- | `٠٠٩٦٥٩٨٧٦٥٤٣٢` | `96598765432` | Yes |
225
- | `٩٦٥ ٩٨٧٦ ٥٤٣٢` | `96598765432` | Yes |
226
- | `٩٦٥-٩٨٧٦-٥٤٣٢` | `96598765432` | Yes |
227
- | `965٩٨٧٦٥٤٣٢` | `96598765432` | Yes |
225
+ | `965XXXXXXXX` | `965XXXXXXXX` | Yes |
226
+ | `+965XXXXXXXX` | `965XXXXXXXX` | Yes |
227
+ | `00965XXXXXXXX` | `965XXXXXXXX` | Yes |
228
+ | `965 XXXX XXXX` | `965XXXXXXXX` | Yes |
229
+ | `965-XXXX-XXXX` | `965XXXXXXXX` | Yes |
230
+ | `(965) XXXXXXXX` | `965XXXXXXXX` | Yes |
231
+ | `٩٦٥XXXXXXXX` | `965XXXXXXXX` | Yes |
232
+ | `۹۶۵XXXXXXXX` | `965XXXXXXXX` | Yes |
233
+ | `+٩٦٥XXXXXXXX` | `965XXXXXXXX` | Yes |
234
+ | `٠٠٩٦٥XXXXXXXX` | `965XXXXXXXX` | Yes |
235
+ | `٩٦٥ XXXX XXXX` | `965XXXXXXXX` | Yes |
236
+ | `٩٦٥-XXXX-XXXX` | `965XXXXXXXX` | Yes |
237
+ | `965` + 8 Arabic-Indic digits | `965XXXXXXXX` | Yes |
228
238
  | `123456` (too short) | rejected | No |
229
239
  | `user@gmail.com` | rejected | No |
230
240
 
241
+ `X` stands for any digit, in Latin, Arabic-Indic or Persian digits.
242
+
231
243
  Normalization rules:
232
244
  - Arabic-Indic and Extended Arabic-Indic digits converted to Latin
233
245
  - Non-digit characters stripped (`+`, spaces, dashes, dots, brackets)
@@ -247,7 +259,7 @@ Messages are cleaned automatically before sending to prevent silent delivery fai
247
259
 
248
260
  ## CLI
249
261
 
250
- For a standalone CLI tool that works on all operating systems, see [kwtsms-cli](https://github.com/boxlinknet/kwtsms-cli).
262
+ For a standalone CLI tool that works on all operating systems, see [kwtsms-cli](https://github.com/kwtsms/kwtsms-cli).
251
263
 
252
264
  ## Credential Management
253
265
 
@@ -331,7 +343,7 @@ Before going live:
331
343
  Every API call is logged to a JSONL file (default: `kwtsms.log`):
332
344
 
333
345
  ```json
334
- {"ts":"2026-03-06T12:00:00Z","endpoint":"send","request":{"username":"ruby_username","password":"***","mobile":"96598765432","message":"Hello"},"response":{"result":"OK","msg-id":"12345"},"ok":true,"error":null}
346
+ {"ts":"2026-03-06T12:00:00Z","endpoint":"send","request":{"username":"ruby_username","password":"***","mobile":"965XXXXXXXX","message":"Hello"},"response":{"result":"OK","msg-id":"12345"},"ok":true,"error":null}
335
347
  ```
336
348
 
337
349
  Passwords are always masked as `***`. Logging never crashes the main flow.
@@ -344,7 +356,7 @@ See the [examples/](examples/) directory:
344
356
 
345
357
  | # | Example | Description |
346
358
  |---|---------|-------------|
347
- | 00 | [Raw API](examples/00_raw_api.rb) | Call all 7 endpoints directly — no gem needed |
359
+ | 00 | [Raw API](examples/00_raw_api.rb) | Call all 7 endpoints directly, no gem needed |
348
360
  | 01 | [Basic Usage](examples/01_basic_usage.rb) | Connect, verify, send SMS, validate |
349
361
  | 02 | [OTP Flow](examples/02_otp_flow.rb) | Send OTP codes |
350
362
  | 03 | [Bulk SMS](examples/03_bulk_sms.rb) | Send to many recipients |
@@ -381,7 +393,7 @@ Check the **Sending Queue** at [kwtsms.com](https://www.kwtsms.com/login/). If y
381
393
 
382
394
  **2. What is the difference between Test mode and Live mode?**
383
395
 
384
- **Test mode** (`KWTSMS_TEST_MODE=1`) sends your message to the kwtSMS queue but does NOT deliver it to the handset. No SMS credits are consumed. Use this during development. **Live mode** (`KWTSMS_TEST_MODE=0`) delivers the message for real and deducts credits. Always develop in test mode and switch to live only when ready for production.
396
+ **Test mode** (`KWTSMS_TEST_MODE=1`) sends your message to the kwtSMS queue but does NOT deliver it to the handset. Its credits are held until you delete it from the Sending Queue in your kwtsms.com account. Use this during development. **Live mode** (`KWTSMS_TEST_MODE=0`) delivers the message for real and deducts credits. Always develop in test mode and switch to live only when ready for production.
385
397
 
386
398
  **3. What is a Sender ID and why should I not use "KWT-SMS" in production?**
387
399
 
@@ -407,7 +419,7 @@ International sending is **disabled by default** on kwtSMS accounts. Log in to y
407
419
  - **[kwtSMS Dashboard](https://www.kwtsms.com/login/)**: Recharge credits, buy Sender IDs, view message logs, manage coverage
408
420
  - **[Other Integrations](https://www.kwtsms.com/integrations.html)**: Plugins and integrations for other platforms and languages
409
421
  - **[RubyGems](https://rubygems.org/gems/kwtsms)**: Package on RubyGems.org
410
- - **[GitHub](https://github.com/boxlinknet/kwtsms-ruby)**: Source code and issue tracker
422
+ - **[GitHub](https://github.com/kwtsms/kwtsms-ruby)**: Source code and issue tracker
411
423
  - **[Changelog](CHANGELOG.md)** | **[Contributing](CONTRIBUTING.md)** | **[Security](SECURITY.md)**
412
424
 
413
425
  ## License
data/lib/kwtsms/client.rb CHANGED
@@ -19,21 +19,27 @@ module KwtSMS
19
19
  # Quick start:
20
20
  # sms = KwtSMS::Client.from_env
21
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")
22
+ # result = sms.send_sms("965XXXXXXXX", "Your OTP for MYAPP is: 123456")
23
+ # result = sms.send_sms("965XXXXXXXX", "Hello", sender: "OTHER-ID")
24
+ # 2026-11-05 08:07 +03: placeholder instead of a sample mobile number (it belongs to a real person), int-testnumber.
24
25
  # balance = sms.balance
25
26
  class Client
27
+ # 2026-11-05 14:19 +03: user_agent reader added, the User-Agent sent on every call (#939).
26
28
  attr_reader :username, :sender_id, :test_mode, :log_file,
27
- :cached_balance, :cached_purchased
29
+ :cached_balance, :cached_purchased, :user_agent
28
30
 
29
31
  # @param username [String] API username (not your account phone number)
30
32
  # @param password [String] API password
31
33
  # @param sender_id [String] Sender ID shown on recipient's phone (default: "KWT-SMS")
32
34
  # @param test_mode [Boolean] When true, messages are queued but not delivered (default: false)
33
35
  # @param log_file [String] Path to JSONL log file. Set to "" to disable logging.
34
- def initialize(username, password, sender_id: "KWT-SMS", test_mode: false, log_file: "kwtsms.log")
36
+ # @param app_name [String] Optional product token of the calling app or plugin, e.g. "kwtsms-wordpress/3.5.2",
37
+ # sent before the library token in the User-Agent (2026-11-05 14:19 +03, #939).
38
+ def initialize(username, password, sender_id: "KWT-SMS", test_mode: false, log_file: "kwtsms.log", app_name: "")
35
39
  raise ArgumentError, "username and password are required" if username.nil? || username.empty? || password.nil? || password.empty?
36
40
 
41
+ # 2026-11-05 14:19 +03: identify the caller to the API (name and version only, no personal data), #939.
42
+ @user_agent = KwtSMS.build_user_agent(app_name)
37
43
  @username = username
38
44
  @password = password
39
45
  @sender_id = sender_id
@@ -43,6 +49,13 @@ module KwtSMS
43
49
  @cached_purchased = nil
44
50
  end
45
51
 
52
+ # Redact password from inspect/to_s output to prevent accidental exposure
53
+ # in logs, error trackers (Sentry, Bugsnag), or REPL sessions.
54
+ def inspect
55
+ "#<#{self.class} @username=#{@username.inspect} @sender_id=#{@sender_id.inspect} @test_mode=#{@test_mode}>"
56
+ end
57
+ alias_method :to_s, :inspect
58
+
46
59
  # Load credentials from environment variables, falling back to .env file.
47
60
  #
48
61
  # Required env vars:
@@ -86,7 +99,7 @@ module KwtSMS
86
99
  # balance: Float or nil
87
100
  # error: nil or error message string
88
101
  def verify
89
- data = KwtSMS.api_request("balance", creds, @log_file)
102
+ data = KwtSMS.api_request("balance", creds, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
90
103
  if data["result"] == "OK"
91
104
  @cached_balance = data["available"].to_f
92
105
  @cached_purchased = data["purchased"].to_f
@@ -112,7 +125,7 @@ module KwtSMS
112
125
  # List sender IDs registered on this account via /senderid/.
113
126
  # Returns a consistent hash. Never raises, never crashes.
114
127
  def senderids
115
- data = KwtSMS.api_request("senderid", creds, @log_file)
128
+ data = KwtSMS.api_request("senderid", creds, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
116
129
  if data["result"] == "OK"
117
130
  { "result" => "OK", "senderids" => data["senderid"] || [] }
118
131
  else
@@ -126,7 +139,7 @@ module KwtSMS
126
139
  # List active country prefixes via /coverage/.
127
140
  # Returns the full API response hash with error enrichment.
128
141
  def coverage
129
- data = KwtSMS.api_request("coverage", creds, @log_file)
142
+ data = KwtSMS.api_request("coverage", creds, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
130
143
  KwtSMS.enrich_error(data)
131
144
  rescue RuntimeError => e
132
145
  { "result" => "ERROR", "code" => "NETWORK", "description" => e.message,
@@ -173,7 +186,7 @@ module KwtSMS
173
186
 
174
187
  payload = creds.merge("mobile" => valid_normalized.join(","))
175
188
  begin
176
- data = KwtSMS.api_request("validate", payload, @log_file)
189
+ data = KwtSMS.api_request("validate", payload, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
177
190
  if data["result"] == "OK"
178
191
  mobile = data["mobile"] || {}
179
192
  result["ok"] = mobile["OK"] || []
@@ -255,7 +268,7 @@ module KwtSMS
255
268
  "test" => @test_mode ? "1" : "0"
256
269
  )
257
270
  begin
258
- result = KwtSMS.api_request("send", payload, @log_file)
271
+ result = KwtSMS.api_request("send", payload, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
259
272
  rescue RuntimeError => e
260
273
  return {
261
274
  "result" => "ERROR",
@@ -275,21 +288,29 @@ module KwtSMS
275
288
  result
276
289
  end
277
290
 
278
- # Send SMS, retrying automatically on ERR028 (rate limit: wait 15 seconds).
291
+ # Send SMS, optionally retrying on ERR028 (duplicate SMS protection).
292
+ #
293
+ # ERR028 is opt-in per account (Duplicate SMS protection in the account's
294
+ # API settings). When it is on and any number in the request was sent the
295
+ # same message within the account's window (10 seconds by default,
296
+ # configurable per account), the whole request is rejected and nothing is
297
+ # charged. Retry is off by default: ERR028 is returned like any other error
298
+ # unless max_retries > 0. All other errors are returned immediately.
279
299
  #
280
- # Waits 16 seconds between retries (15s required + 1s buffer).
281
- # All other errors are returned immediately without retry.
300
+ # 2026-10-22 15:34 +03: retry off by default and wait is a parameter (default 10),
301
+ # was a hardcoded 3 retries x 16 s. The ERR028 window is per account, not a fixed 15 s.
282
302
  #
283
303
  # @param mobile [String, Array<String>] Phone number(s)
284
304
  # @param message [String] Message text
285
305
  # @param sender [String, nil] Sender ID override
286
- # @param max_retries [Integer] Max ERR028 retries after first attempt (default 3)
306
+ # @param max_retries [Integer] Max ERR028 retries after first attempt (default 0, no retry)
307
+ # @param wait_seconds [Numeric] Seconds to wait before each retry (default 10)
287
308
  # @return [Hash] Same shape as send_sms. Never raises.
288
- def send_with_retry(mobile, message, sender: nil, max_retries: 3)
309
+ def send_with_retry(mobile, message, sender: nil, max_retries: 0, wait_seconds: 10)
289
310
  result = send_sms(mobile, message, sender: sender)
290
311
  retries = 0
291
312
  while result["code"] == "ERR028" && retries < max_retries
292
- sleep(16)
313
+ sleep(wait_seconds)
293
314
  result = send_sms(mobile, message, sender: sender)
294
315
  retries += 1
295
316
  end
@@ -330,7 +351,7 @@ module KwtSMS
330
351
  waits.each_with_index do |wait, attempt|
331
352
  sleep(wait) if wait > 0
332
353
  begin
333
- data = KwtSMS.api_request("send", payload, @log_file)
354
+ data = KwtSMS.api_request("send", payload, @log_file, @user_agent) # 2026-11-05 14:19 +03: UA (#939)
334
355
  rescue RuntimeError => e
335
356
  errors << { "batch" => i + 1, "code" => "NETWORK", "description" => e.message }
336
357
  data = nil
@@ -9,6 +9,7 @@ module KwtSMS
9
9
  File.foreach(env_file, encoding: "utf-8") do |line|
10
10
  line = line.strip
11
11
  next if line.empty? || line.start_with?("#")
12
+ line = line.sub(/\Aexport\s+/, "")
12
13
  next unless line.include?("=")
13
14
 
14
15
  key, _, value = line.partition("=")
data/lib/kwtsms/errors.rb CHANGED
@@ -9,7 +9,8 @@ module KwtSMS
9
9
  "ERR003" => "Wrong API username or password. Check KWTSMS_USERNAME and KWTSMS_PASSWORD. These are your API credentials, not your account mobile number.",
10
10
  "ERR004" => "This account does not have API access. Contact kwtSMS support to enable it.",
11
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).",
12
+ # 2026-11-05 08:07 +03: placeholder instead of a sample mobile number (it belongs to a real person), int-testnumber.
13
+ "ERR006" => "No valid phone numbers. Make sure each number includes the country code (e.g., 965XXXXXXXX for Kuwait, not XXXXXXXX).",
13
14
  "ERR007" => "Too many numbers in a single request (maximum 200). Split into smaller batches.",
14
15
  "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
16
  "ERR009" => "Message is empty. Provide a non-empty message text.",
@@ -17,11 +18,17 @@ module KwtSMS
17
18
  "ERR011" => "Insufficient balance for this send. Buy more credits at kwtsms.com.",
18
19
  "ERR012" => "Message is too long (over 6 SMS pages). Shorten your message.",
19
20
  "ERR013" => "Send queue is full (1000 messages). Wait a moment and try again.",
21
+ "ERR019" => "No delivery reports found for this message.",
22
+ "ERR020" => "Message ID does not exist. Make sure you saved the msg-id from the send response.",
23
+ "ERR021" => "No delivery report available for this message yet.",
24
+ "ERR022" => "Delivery reports are not ready yet. Try again after 24 hours.",
25
+ "ERR023" => "Unknown delivery report error. Contact kwtSMS support.",
20
26
  "ERR024" => "Your IP address is not in the API whitelist. Add it at kwtsms.com > Account > API > IP Lockdown, or disable IP lockdown.",
21
- "ERR025" => "Invalid phone number. Make sure the number includes the country code (e.g., 96598765432 for Kuwait, not 98765432).",
27
+ "ERR025" => "Invalid phone number. Make sure the number includes the country code (e.g., 965XXXXXXXX for Kuwait, not XXXXXXXX).",
22
28
  "ERR026" => "This country is not activated on your account. Contact kwtSMS support to enable the destination country.",
23
29
  "ERR027" => "HTML tags are not allowed in the message. Remove any HTML content and try again.",
24
- "ERR028" => "You must wait at least 15 seconds before sending to the same number again. No credits were consumed.",
30
+ # 2026-10-22 15:34 +03: ERR028 text corrected, the window is opt-in and per account (10 s default), never a fixed 15 s.
31
+ "ERR028" => "Sent to the same number too soon. Duplicate SMS protection is on for this account, so the whole request was rejected and nothing was charged. Wait for the account's window (10 seconds by default) before resending.",
25
32
  "ERR029" => "Message ID does not exist or is incorrect.",
26
33
  "ERR030" => "Message is stuck in the send queue with an error. Delete it at kwtsms.com > Queue to recover credits.",
27
34
  "ERR031" => "Message rejected: bad language detected.",
data/lib/kwtsms/logger.rb CHANGED
@@ -4,10 +4,27 @@ require "json"
4
4
  require "time"
5
5
 
6
6
  module KwtSMS
7
+ # Validate a log file path. Returns [valid, error].
8
+ # Rejects path traversal, absolute paths outside CWD, and device paths.
9
+ def self.validate_log_path(log_file)
10
+ return [false, "log_file must be a String"] unless log_file.is_a?(String)
11
+ return [false, "log_file must not be empty"] if log_file.strip.empty?
12
+ return [false, "log_file must not contain '..'"] if log_file.include?("..")
13
+ return [false, "log_file must not contain null bytes"] if log_file.include?("\0")
14
+ return [false, "log_file must not start with /"] if log_file.start_with?("/")
15
+ return [false, "log_file must not start with ~"] if log_file.start_with?("~")
16
+ return [false, "log_file must not be a pipe or device"] if log_file.start_with?("|") || log_file.match?(%r{\A/dev/})
17
+
18
+ [true, nil]
19
+ end
20
+
7
21
  # Append a JSONL log entry. Never raises. Logging must not break main flow.
8
22
  def self.write_log(log_file, entry)
9
23
  return if log_file.nil? || log_file.empty?
10
24
 
25
+ valid, = validate_log_path(log_file)
26
+ return unless valid
27
+
11
28
  begin
12
29
  File.open(log_file, "a", encoding: "utf-8") do |f|
13
30
  f.puts(JSON.generate(entry))
data/lib/kwtsms/phone.rb CHANGED
@@ -222,24 +222,28 @@ module KwtSMS
222
222
  # 1. Empty / blank
223
223
  return [false, "Phone number is required", ""] if raw.empty?
224
224
 
225
+ # Sanitize raw input for safe interpolation in error messages:
226
+ # strip control chars and truncate to prevent log injection
227
+ safe = raw.gsub(/[[:cntrl:]]/, "")[0, 50]
228
+
225
229
  # 2. Email address entered by mistake
226
- return [false, "'#{raw}' is an email address, not a phone number", ""] if raw.include?("@")
230
+ return [false, "'#{safe}' is an email address, not a phone number", ""] if raw.include?("@")
227
231
 
228
232
  # 3. Normalize
229
233
  normalized = normalize_phone(raw)
230
234
 
231
235
  # 4. No digits survived normalization
232
- return [false, "'#{raw}' is not a valid phone number, no digits found", ""] if normalized.empty?
236
+ return [false, "'#{safe}' is not a valid phone number, no digits found", ""] if normalized.empty?
233
237
 
234
238
  # 5. Too short
235
239
  if normalized.length < 7
236
240
  digit_word = normalized.length == 1 ? "digit" : "digits"
237
- return [false, "'#{raw}' is too short to be a valid phone number (#{normalized.length} #{digit_word}, minimum is 7)", normalized]
241
+ return [false, "'#{safe}' is too short to be a valid phone number (#{normalized.length} #{digit_word}, minimum is 7)", normalized]
238
242
  end
239
243
 
240
244
  # 6. Too long
241
245
  if normalized.length > 15
242
- return [false, "'#{raw}' is too long to be a valid phone number (#{normalized.length} digits, maximum is 15)", normalized]
246
+ return [false, "'#{safe}' is too long to be a valid phone number (#{normalized.length} digits, maximum is 15)", normalized]
243
247
  end
244
248
 
245
249
  # 7. Country-specific format validation
@@ -12,13 +12,24 @@ module KwtSMS
12
12
  # unix-timestamp values in API responses are server time, not UTC.
13
13
  SERVER_TIMEZONE = "Asia/Kuwait (GMT+3)"
14
14
 
15
+ # Build the User-Agent "[app token ]kwtsms-ruby/<VERSION>". The app token is caller input: anything
16
+ # outside printable ASCII is removed (CR/LF would split the header), whitespace runs become "-",
17
+ # leading/trailing "-" are trimmed, and it is cut to 64 characters. Empty means the library token only.
18
+ # 2026-11-05 14:19 +03: added so kwtSMS can count accounts per integration (#939).
19
+ def self.build_user_agent(app_name = "")
20
+ app = app_name.to_s.gsub(/[^\x20-\x7e]+/, "").strip.gsub(/\s+/, "-").gsub(/\A-+|-+\z/, "")[0, 64]
21
+ lib = "kwtsms-ruby/#{VERSION}"
22
+ app.empty? ? lib : "#{app} #{lib}"
23
+ end
24
+
15
25
  # POST to a kwtSMS REST/JSON API endpoint.
16
26
  #
17
27
  # Always sets Content-Type and Accept: application/json.
18
28
  # Strips password from log entry.
19
29
  # Returns parsed JSON hash.
20
30
  # Raises RuntimeError on network / HTTP / parse failure.
21
- def self.api_request(endpoint, payload, log_file = "")
31
+ # 2026-11-05 14:19 +03: user_agent parameter added, sent on every call (#939).
32
+ def self.api_request(endpoint, payload, log_file = "", user_agent = nil)
22
33
  url = URI.parse("#{BASE_URL}#{endpoint}/")
23
34
 
24
35
  safe_payload = payload.transform_keys(&:to_s).each_with_object({}) do |(k, v), h|
@@ -37,12 +48,14 @@ module KwtSMS
37
48
  begin
38
49
  http = Net::HTTP.new(url.host, url.port)
39
50
  http.use_ssl = true
51
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
40
52
  http.open_timeout = 15
41
53
  http.read_timeout = 15
42
54
 
43
55
  request = Net::HTTP::Post.new(url.path)
44
56
  request["Content-Type"] = "application/json"
45
57
  request["Accept"] = "application/json"
58
+ request["User-Agent"] = user_agent || build_user_agent # 2026-11-05 14:19 +03: kwtsms-ruby/<version> (#939)
46
59
  request.body = JSON.generate(payload)
47
60
 
48
61
  response = http.request(request)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module KwtSMS
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/kwtsms.rb CHANGED
@@ -7,12 +7,14 @@
7
7
  #
8
8
  # sms = KwtSMS::Client.from_env # reads .env / env vars
9
9
  # ok, balance, err = sms.verify
10
- # result = sms.send_sms("96598765432", "Your OTP is: 123456")
11
- # result = sms.send_sms("96598765432", "Hello", sender: "MY-APP")
10
+ # result = sms.send_sms("965XXXXXXXX", "Your OTP is: 123456")
11
+ # result = sms.send_sms("965XXXXXXXX", "Hello", sender: "MY-APP")
12
12
  # balance = sms.balance
13
13
  #
14
14
  # Utility functions:
15
- # KwtSMS.normalize_phone("+965 9876 5432")
15
+ # KwtSMS.normalize_phone("+965 XXXX XXXX")
16
+ #
17
+ # 2026-11-05 08:07 +03: placeholder instead of a sample mobile number (it belongs to a real person), int-testnumber.
16
18
  # KwtSMS.validate_phone_input("user@email.com")
17
19
  # KwtSMS.clean_message("Hello \u{1F600} world")
18
20
 
metadata CHANGED
@@ -1,20 +1,20 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kwtsms
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
- - boxlink
7
+ - kwtSMS
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-15 00:00:00.000000000 Z
11
+ date: 2026-11-07 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Official Ruby client library for the kwtSMS SMS gateway. Send SMS, check
14
14
  balance, validate phone numbers, check delivery status, and manage sender IDs. Zero
15
15
  external dependencies.
16
16
  email:
17
- - info@boxlink.net
17
+ - support@kwtsms.com
18
18
  executables: []
19
19
  extensions: []
20
20
  extra_rdoc_files: []
@@ -31,15 +31,15 @@ files:
31
31
  - lib/kwtsms/phone.rb
32
32
  - lib/kwtsms/request.rb
33
33
  - lib/kwtsms/version.rb
34
- homepage: https://github.com/boxlinknet/kwtsms-ruby
34
+ homepage: https://github.com/kwtsms/kwtsms-ruby
35
35
  licenses:
36
36
  - MIT
37
37
  metadata:
38
- homepage_uri: https://github.com/boxlinknet/kwtsms-ruby
39
- source_code_uri: https://github.com/boxlinknet/kwtsms-ruby
40
- changelog_uri: https://github.com/boxlinknet/kwtsms-ruby/blob/main/CHANGELOG.md
41
- bug_tracker_uri: https://github.com/boxlinknet/kwtsms-ruby/issues
42
- documentation_uri: https://github.com/boxlinknet/kwtsms-ruby#readme
38
+ homepage_uri: https://github.com/kwtsms/kwtsms-ruby
39
+ source_code_uri: https://github.com/kwtsms/kwtsms-ruby
40
+ changelog_uri: https://github.com/kwtsms/kwtsms-ruby/blob/main/CHANGELOG.md
41
+ bug_tracker_uri: https://github.com/kwtsms/kwtsms-ruby/issues
42
+ documentation_uri: https://github.com/kwtsms/kwtsms-ruby#readme
43
43
  rubygems_mfa_required: 'true'
44
44
  post_install_message:
45
45
  rdoc_options: []