kirimi 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 +8 -0
- data/LICENSE +21 -0
- data/README.md +235 -0
- data/kirimi.gemspec +29 -0
- data/lib/kirimi/client.rb +218 -0
- data/lib/kirimi/errors.rb +17 -0
- data/lib/kirimi/response.rb +26 -0
- data/lib/kirimi/version.rb +5 -0
- data/lib/kirimi.rb +16 -0
- data/test/client_test.rb +160 -0
- metadata +71 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0bc013f5f011e16f83060dc902e32b6cf6c45312c2d6c443cb882503babed732
|
|
4
|
+
data.tar.gz: '082c9dcd26bee5df651c865dc77c93222c49f8fa70e68d4e5970ef9013efad17'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 487b38bee90b1463c0550efdb36a7646e0bdfd50ec2f0e598407bf4aacf3b0a3a770f0c0fead719b0fb98ad68debfa54e8b26574a538c5b76b4ff1cefa94d0a4
|
|
7
|
+
data.tar.gz: cc5f8545838710df3641cf0207aa2dc9d168c906f76a2edc1d288942f4ce05677ed9ffa50e7c2c45c5b6128dcafec5950cf0198bc821cf989fcd66cf5d7fafe2
|
data/CHANGELOG.md
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kirimi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# Kirimi Ruby SDK
|
|
2
|
+
|
|
3
|
+
[](https://badge.fury.io/rb/kirimi)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Official Ruby SDK for the [Kirimi WhatsApp API](https://kirimi.id). Zero runtime dependencies — uses only Ruby's built-in `net/http`.
|
|
7
|
+
|
|
8
|
+
## Installation
|
|
9
|
+
|
|
10
|
+
Add to your Gemfile:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
gem 'kirimi'
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then run:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or install directly:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gem install kirimi
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
require 'kirimi'
|
|
32
|
+
|
|
33
|
+
client = Kirimi::Client.new(
|
|
34
|
+
user_code: 'YOUR_USER_CODE',
|
|
35
|
+
secret: 'YOUR_SECRET'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
resp = client.send_message(
|
|
39
|
+
device_id: 'YOUR_DEVICE_ID',
|
|
40
|
+
phone: '628111222333',
|
|
41
|
+
message: 'Hello from Kirimi!'
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
puts resp.success? # => true
|
|
45
|
+
puts resp.message # => "Message sent"
|
|
46
|
+
puts resp.data # => { ... }
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Constructor
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
Kirimi::Client.new(
|
|
53
|
+
user_code: 'YOUR_USER_CODE', # required
|
|
54
|
+
secret: 'YOUR_SECRET', # required
|
|
55
|
+
base_url: 'https://api.kirimi.id', # optional, default
|
|
56
|
+
timeout: 30 # optional, seconds
|
|
57
|
+
)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## All Methods
|
|
61
|
+
|
|
62
|
+
### WhatsApp Unofficial
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
# Send text/media message
|
|
66
|
+
client.send_message(device_id:, phone:, message:, media_url: nil)
|
|
67
|
+
|
|
68
|
+
# Send file via multipart (accepts File, StringIO, or file path string)
|
|
69
|
+
client.send_message_file(device_id:, phone:, file:, file_name:, message: nil)
|
|
70
|
+
|
|
71
|
+
# Send message without typing indicator
|
|
72
|
+
client.send_message_fast(device_id:, phone:, message:, media_url: nil)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### WABA (WhatsApp Business API)
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
client.send_waba_message(device_id:, phone:, message:)
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Devices
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
client.list_devices
|
|
85
|
+
client.device_status(device_id:)
|
|
86
|
+
client.device_status_enhanced(device_id:)
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### User
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
client.user_info
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Contacts
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
client.save_contact(phone:, name: nil, email: nil)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### OTP (V1)
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
# Generate & send OTP
|
|
105
|
+
client.generate_otp(
|
|
106
|
+
device_id:,
|
|
107
|
+
phone:,
|
|
108
|
+
otp_length: 6, # optional
|
|
109
|
+
otp_type: 'numeric', # optional: numeric, alphabetic, alphanumeric
|
|
110
|
+
custom_otp_message: nil # optional
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Validate OTP
|
|
114
|
+
client.validate_otp(device_id:, phone:, otp:)
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### OTP (V2)
|
|
118
|
+
|
|
119
|
+
```ruby
|
|
120
|
+
# Send OTP via WABA template or device
|
|
121
|
+
client.send_otp_v2(
|
|
122
|
+
phone:,
|
|
123
|
+
device_id:,
|
|
124
|
+
method: 'device', # optional: device, waba
|
|
125
|
+
app_name: 'MyApp', # optional
|
|
126
|
+
template_code: nil, # optional, for waba method
|
|
127
|
+
custom_message: nil # optional, for device method
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
# Verify OTP
|
|
131
|
+
client.verify_otp_v2(phone:, otp_code:)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Broadcast
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
# phones accepts String or Array (Array auto-joined with comma)
|
|
138
|
+
client.broadcast_message(
|
|
139
|
+
device_id:,
|
|
140
|
+
phones: ['628111', '628222', '628333'],
|
|
141
|
+
message: 'Promo!',
|
|
142
|
+
delay: 2 # optional, seconds between messages
|
|
143
|
+
)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Deposits
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
client.list_deposits(status: nil) # status: nil, 'paid', 'unpaid', 'expired'
|
|
150
|
+
client.list_packages
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
## Response Object
|
|
154
|
+
|
|
155
|
+
All methods return a `Kirimi::Response` instance:
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
resp = client.send_message(device_id: 'DEV', phone: '628xxx', message: 'hi')
|
|
159
|
+
|
|
160
|
+
resp.success? # => true / false (boolean method)
|
|
161
|
+
resp.success # => true / false (raw value)
|
|
162
|
+
resp.data # => Hash or nil
|
|
163
|
+
resp.message # => String
|
|
164
|
+
resp.raw # => full parsed Hash
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Error Handling
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
begin
|
|
171
|
+
resp = client.send_message(device_id: 'DEV', phone: '628xxx', message: 'hi')
|
|
172
|
+
rescue Kirimi::ApiError => e
|
|
173
|
+
puts e.status_code # => 401
|
|
174
|
+
puts e.message # => "Unauthorized"
|
|
175
|
+
puts e.response_data # => parsed response body
|
|
176
|
+
rescue Kirimi::NetworkError => e
|
|
177
|
+
puts "Network problem: #{e.message}"
|
|
178
|
+
rescue Kirimi::Error => e
|
|
179
|
+
puts "SDK error: #{e.message}"
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Rails Integration
|
|
184
|
+
|
|
185
|
+
### Application configuration
|
|
186
|
+
|
|
187
|
+
```ruby
|
|
188
|
+
# config/initializers/kirimi.rb
|
|
189
|
+
KIRIMI_CLIENT = Kirimi::Client.new(
|
|
190
|
+
user_code: ENV.fetch('KIRIMI_USER_CODE'),
|
|
191
|
+
secret: ENV.fetch('KIRIMI_SECRET')
|
|
192
|
+
)
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Controller example
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
class NotificationsController < ApplicationController
|
|
199
|
+
def send_otp
|
|
200
|
+
resp = KIRIMI_CLIENT.generate_otp(
|
|
201
|
+
device_id: params[:device_id],
|
|
202
|
+
phone: params[:phone],
|
|
203
|
+
otp_length: 6,
|
|
204
|
+
otp_type: 'numeric'
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if resp.success?
|
|
208
|
+
render json: { sent: true }
|
|
209
|
+
else
|
|
210
|
+
render json: { sent: false, error: resp.message }, status: :unprocessable_entity
|
|
211
|
+
end
|
|
212
|
+
rescue Kirimi::ApiError => e
|
|
213
|
+
render json: { error: e.message }, status: e.status_code
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### Background Job example
|
|
219
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
class SendWhatsAppJob < ApplicationJob
|
|
222
|
+
queue_as :default
|
|
223
|
+
|
|
224
|
+
def perform(device_id, phone, message)
|
|
225
|
+
KIRIMI_CLIENT.send_message(device_id: device_id, phone: phone, message: message)
|
|
226
|
+
rescue Kirimi::ApiError, Kirimi::NetworkError => e
|
|
227
|
+
Rails.logger.error "[Kirimi] #{e.class}: #{e.message}"
|
|
228
|
+
raise # re-raise to trigger job retry
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## License
|
|
234
|
+
|
|
235
|
+
MIT — see [LICENSE](LICENSE).
|
data/kirimi.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/kirimi/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'kirimi'
|
|
7
|
+
spec.version = Kirimi::VERSION
|
|
8
|
+
spec.authors = ['Kirimi']
|
|
9
|
+
spec.email = ['support@kirimi.id']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Official Ruby SDK for Kirimi WhatsApp API'
|
|
12
|
+
spec.description = 'Send WhatsApp messages, OTP, broadcasts, and more via the Kirimi API.'
|
|
13
|
+
spec.homepage = 'https://github.com/kiriminow/kirimi-ruby'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
|
|
16
|
+
spec.required_ruby_version = '>= 2.6.0'
|
|
17
|
+
|
|
18
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
19
|
+
spec.metadata['source_code_uri'] = spec.homepage
|
|
20
|
+
spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
|
|
21
|
+
|
|
22
|
+
spec.files = Dir.glob(%w[lib/**/* LICENSE README.md CHANGELOG.md kirimi.gemspec])
|
|
23
|
+
spec.test_files = Dir.glob('test/**/*_test.rb')
|
|
24
|
+
spec.require_paths = ['lib']
|
|
25
|
+
|
|
26
|
+
# No runtime dependencies — uses only Ruby stdlib (net/http, json, securerandom)
|
|
27
|
+
|
|
28
|
+
spec.add_development_dependency 'rake', '~> 13.0'
|
|
29
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'uri'
|
|
6
|
+
|
|
7
|
+
module Kirimi
|
|
8
|
+
class Client
|
|
9
|
+
DEFAULT_BASE_URL = 'https://api.kirimi.id'
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
11
|
+
|
|
12
|
+
def initialize(user_code:, secret:, base_url: DEFAULT_BASE_URL, timeout: DEFAULT_TIMEOUT)
|
|
13
|
+
@user_code = user_code
|
|
14
|
+
@secret = secret
|
|
15
|
+
@base_url = base_url.chomp('/')
|
|
16
|
+
@timeout = timeout
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# --- WhatsApp Unofficial ---
|
|
20
|
+
|
|
21
|
+
def send_message(device_id:, phone:, message:, media_url: nil)
|
|
22
|
+
body = { device_id: device_id, phone: phone, message: message }
|
|
23
|
+
body[:media_url] = media_url if media_url
|
|
24
|
+
post('/v1/send-message', body)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def send_message_file(device_id:, phone:, file:, file_name:, message: nil)
|
|
28
|
+
file_io = file.is_a?(String) ? File.open(file, 'rb') : file
|
|
29
|
+
fields = {
|
|
30
|
+
'user_code' => @user_code,
|
|
31
|
+
'secret' => @secret,
|
|
32
|
+
'device_id' => device_id,
|
|
33
|
+
'phone' => phone,
|
|
34
|
+
'fileName' => file_name
|
|
35
|
+
}
|
|
36
|
+
fields['message'] = message if message
|
|
37
|
+
post_multipart('/v1/send-message-file', fields, file_io, file_name)
|
|
38
|
+
ensure
|
|
39
|
+
file_io&.close if file.is_a?(String)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def send_message_fast(device_id:, phone:, message:, media_url: nil)
|
|
43
|
+
body = { device_id: device_id, phone: phone, message: message }
|
|
44
|
+
body[:media_url] = media_url if media_url
|
|
45
|
+
post('/v1/send-message-fast', body)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- WABA ---
|
|
49
|
+
|
|
50
|
+
def send_waba_message(device_id:, phone:, message:)
|
|
51
|
+
post('/v1/waba/send-message', { device_id: device_id, phone: phone, message: message })
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# --- Devices ---
|
|
55
|
+
|
|
56
|
+
def list_devices
|
|
57
|
+
post('/v1/list-devices', {})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def device_status(device_id:)
|
|
61
|
+
post('/v1/device-status', { device_id: device_id })
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def device_status_enhanced(device_id:)
|
|
65
|
+
post('/v1/device-status-enhanced', { device_id: device_id })
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- User ---
|
|
69
|
+
|
|
70
|
+
def user_info
|
|
71
|
+
post('/v1/user-info', {})
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# --- Contacts ---
|
|
75
|
+
|
|
76
|
+
def save_contact(phone:, name: nil, email: nil)
|
|
77
|
+
body = { phone: phone }
|
|
78
|
+
body[:name] = name if name
|
|
79
|
+
body[:email] = email if email
|
|
80
|
+
post('/v1/save-contact', body)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# --- OTP ---
|
|
84
|
+
|
|
85
|
+
def generate_otp(device_id:, phone:, otp_length: nil, otp_type: nil, custom_otp_message: nil)
|
|
86
|
+
body = { device_id: device_id, phone: phone }
|
|
87
|
+
body[:otp_length] = otp_length if otp_length
|
|
88
|
+
body[:otp_type] = otp_type if otp_type
|
|
89
|
+
body[:customOtpMessage] = custom_otp_message if custom_otp_message
|
|
90
|
+
post('/v1/generate-otp', body)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validate_otp(device_id:, phone:, otp:)
|
|
94
|
+
post('/v1/validate-otp', { device_id: device_id, phone: phone, otp: otp })
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# --- OTP V2 ---
|
|
98
|
+
|
|
99
|
+
def send_otp_v2(phone:, device_id:, method: nil, app_name: nil, template_code: nil, custom_message: nil)
|
|
100
|
+
body = { phone: phone, device_id: device_id }
|
|
101
|
+
body[:method] = method if method
|
|
102
|
+
body[:app_name] = app_name if app_name
|
|
103
|
+
body[:template_code] = template_code if template_code
|
|
104
|
+
body[:custom_message] = custom_message if custom_message
|
|
105
|
+
post('/v2/otp/send', body)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def verify_otp_v2(phone:, otp_code:)
|
|
109
|
+
post('/v2/otp/verify', { phone: phone, otp_code: otp_code })
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# --- Broadcast ---
|
|
113
|
+
|
|
114
|
+
def broadcast_message(device_id:, phones:, message:, delay: nil)
|
|
115
|
+
phones_str = phones.is_a?(Array) ? phones.join(',') : phones
|
|
116
|
+
body = { device_id: device_id, phones: phones_str, message: message }
|
|
117
|
+
body[:delay] = delay if delay
|
|
118
|
+
post('/v1/broadcast-message', body)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# --- Deposits ---
|
|
122
|
+
|
|
123
|
+
def list_deposits(status: nil)
|
|
124
|
+
body = {}
|
|
125
|
+
body[:status] = status if status
|
|
126
|
+
post('/v1/list-deposits', body)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def list_packages
|
|
130
|
+
post('/v1/list-packages', {})
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def auth_params
|
|
136
|
+
{ user_code: @user_code, secret: @secret }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def post(path, body)
|
|
140
|
+
uri = URI("#{@base_url}#{path}")
|
|
141
|
+
http = build_http(uri)
|
|
142
|
+
|
|
143
|
+
request = Net::HTTP::Post.new(uri)
|
|
144
|
+
request['Content-Type'] = 'application/json'
|
|
145
|
+
request['Accept'] = 'application/json'
|
|
146
|
+
request.body = JSON.generate(auth_params.merge(body))
|
|
147
|
+
|
|
148
|
+
execute(http, request)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def post_multipart(path, fields, file_io, file_name)
|
|
152
|
+
uri = URI("#{@base_url}#{path}")
|
|
153
|
+
http = build_http(uri)
|
|
154
|
+
boundary = "KirimiRubySDK#{SecureRandom.hex(8)}"
|
|
155
|
+
|
|
156
|
+
body_parts = []
|
|
157
|
+
fields.each do |key, value|
|
|
158
|
+
body_parts << "--#{boundary}\r\n"
|
|
159
|
+
body_parts << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n"
|
|
160
|
+
body_parts << "#{value}\r\n"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# File part
|
|
164
|
+
file_data = file_io.read
|
|
165
|
+
body_parts << "--#{boundary}\r\n"
|
|
166
|
+
body_parts << "Content-Disposition: form-data; name=\"file\"; filename=\"#{file_name}\"\r\n"
|
|
167
|
+
body_parts << "Content-Type: application/octet-stream\r\n\r\n"
|
|
168
|
+
body_parts << file_data
|
|
169
|
+
body_parts << "\r\n--#{boundary}--\r\n"
|
|
170
|
+
|
|
171
|
+
request = Net::HTTP::Post.new(uri)
|
|
172
|
+
request['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
|
173
|
+
request['Accept'] = 'application/json'
|
|
174
|
+
request.body = body_parts.join
|
|
175
|
+
|
|
176
|
+
execute(http, request)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_http(uri)
|
|
180
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
181
|
+
http.use_ssl = (uri.scheme == 'https')
|
|
182
|
+
http.read_timeout = @timeout
|
|
183
|
+
http.open_timeout = @timeout
|
|
184
|
+
http
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def execute(http, request)
|
|
188
|
+
response = http.request(request)
|
|
189
|
+
parse_response(response)
|
|
190
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
191
|
+
Net::OpenTimeout, Net::ReadTimeout, SocketError => e
|
|
192
|
+
raise Kirimi::NetworkError, "Network error: #{e.message}"
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def parse_response(response)
|
|
196
|
+
status = response.code.to_i
|
|
197
|
+
body = parse_body(response.body)
|
|
198
|
+
|
|
199
|
+
unless (200..299).cover?(status)
|
|
200
|
+
msg = body.is_a?(Hash) ? (body['message'] || response.message) : response.message
|
|
201
|
+
raise Kirimi::ApiError.new(status, msg, body)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
Kirimi::Response.new(body)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def parse_body(body)
|
|
208
|
+
return {} if body.nil? || body.empty?
|
|
209
|
+
|
|
210
|
+
JSON.parse(body)
|
|
211
|
+
rescue JSON::ParserError
|
|
212
|
+
{ 'message' => body }
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Lazy require SecureRandom (part of stdlib, always available)
|
|
218
|
+
require 'securerandom'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kirimi
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
class ApiError < Error
|
|
7
|
+
attr_reader :status_code, :response_data
|
|
8
|
+
|
|
9
|
+
def initialize(status_code, message, response_data = nil)
|
|
10
|
+
super(message)
|
|
11
|
+
@status_code = status_code
|
|
12
|
+
@response_data = response_data
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class NetworkError < Error; end
|
|
17
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Kirimi
|
|
4
|
+
class Response
|
|
5
|
+
attr_reader :success, :data, :message, :raw
|
|
6
|
+
|
|
7
|
+
def initialize(hash)
|
|
8
|
+
@raw = hash
|
|
9
|
+
@success = hash['success']
|
|
10
|
+
@data = hash['data']
|
|
11
|
+
@message = hash['message']
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def success?
|
|
15
|
+
@success == true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_s
|
|
19
|
+
"#<Kirimi::Response success=#{@success} message=#{@message.inspect}>"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def inspect
|
|
23
|
+
to_s
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/kirimi.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'kirimi/version'
|
|
4
|
+
require_relative 'kirimi/errors'
|
|
5
|
+
require_relative 'kirimi/response'
|
|
6
|
+
require_relative 'kirimi/client'
|
|
7
|
+
|
|
8
|
+
# Kirimi Ruby SDK — Official client for the Kirimi WhatsApp API.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
# client = Kirimi::Client.new(user_code: 'USER', secret: 'SECRET')
|
|
12
|
+
# resp = client.send_message(device_id: 'DEV', phone: '628xxx', message: 'Hello!')
|
|
13
|
+
# puts resp.success? # => true
|
|
14
|
+
# puts resp.data # => { ... }
|
|
15
|
+
module Kirimi
|
|
16
|
+
end
|
data/test/client_test.rb
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'minitest/autorun'
|
|
4
|
+
require 'minitest/pride'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'ostruct'
|
|
7
|
+
|
|
8
|
+
$LOAD_PATH.unshift File.expand_path('../lib', __dir__)
|
|
9
|
+
require 'kirimi'
|
|
10
|
+
|
|
11
|
+
# Minimal HTTP response stub
|
|
12
|
+
FakeResponse = Struct.new(:code, :body, :message) do
|
|
13
|
+
def [](key); nil; end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Captures the last request body sent via Net::HTTP
|
|
17
|
+
module NetHTTPStub
|
|
18
|
+
@stub_status = 200
|
|
19
|
+
@stub_body = {}
|
|
20
|
+
@last_body = nil
|
|
21
|
+
@last_path = nil
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
attr_accessor :stub_status, :stub_body, :last_body, :last_path
|
|
25
|
+
|
|
26
|
+
def setup(status:, body:)
|
|
27
|
+
@stub_status = status
|
|
28
|
+
@stub_body = body
|
|
29
|
+
@last_body = nil
|
|
30
|
+
@last_path = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Monkey-patch Net::HTTP for tests only
|
|
36
|
+
module Net
|
|
37
|
+
class HTTP
|
|
38
|
+
def request(req)
|
|
39
|
+
NetHTTPStub.last_body = JSON.parse(req.body) rescue req.body
|
|
40
|
+
NetHTTPStub.last_path = req.path
|
|
41
|
+
FakeResponse.new(
|
|
42
|
+
NetHTTPStub.stub_status.to_s,
|
|
43
|
+
NetHTTPStub.stub_body.to_json,
|
|
44
|
+
'OK'
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class ClientTest < Minitest::Test
|
|
51
|
+
def setup
|
|
52
|
+
@client = Kirimi::Client.new(user_code: 'USER', secret: 'SECRET')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def stub(status: 200, body: { 'success' => true, 'data' => nil, 'message' => 'ok' })
|
|
56
|
+
NetHTTPStub.setup(status: status, body: body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ---- send_message ----
|
|
60
|
+
|
|
61
|
+
def test_send_message_correct_body
|
|
62
|
+
stub(body: { 'success' => true, 'data' => { 'id' => 'msg_1' }, 'message' => 'sent' })
|
|
63
|
+
|
|
64
|
+
resp = @client.send_message(device_id: 'DEV1', phone: '628111', message: 'Hello!')
|
|
65
|
+
|
|
66
|
+
assert_equal 'USER', NetHTTPStub.last_body['user_code']
|
|
67
|
+
assert_equal 'SECRET', NetHTTPStub.last_body['secret']
|
|
68
|
+
assert_equal 'DEV1', NetHTTPStub.last_body['device_id']
|
|
69
|
+
assert_equal '628111', NetHTTPStub.last_body['phone']
|
|
70
|
+
assert_equal 'Hello!', NetHTTPStub.last_body['message']
|
|
71
|
+
assert_instance_of Kirimi::Response, resp
|
|
72
|
+
assert resp.success?
|
|
73
|
+
assert_equal 'msg_1', resp.data['id']
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def test_send_message_includes_media_url
|
|
77
|
+
stub
|
|
78
|
+
@client.send_message(device_id: 'DEV1', phone: '628111', message: 'pic', media_url: 'https://img.example.com/a.jpg')
|
|
79
|
+
assert_equal 'https://img.example.com/a.jpg', NetHTTPStub.last_body['media_url']
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_send_message_omits_media_url_when_nil
|
|
83
|
+
stub
|
|
84
|
+
@client.send_message(device_id: 'DEV1', phone: '628111', message: 'hi')
|
|
85
|
+
refute NetHTTPStub.last_body.key?('media_url')
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# ---- generate_otp ----
|
|
89
|
+
|
|
90
|
+
def test_generate_otp_with_optional_params
|
|
91
|
+
stub(body: { 'success' => true, 'data' => { 'otp' => '123456' }, 'message' => 'OTP sent' })
|
|
92
|
+
|
|
93
|
+
resp = @client.generate_otp(
|
|
94
|
+
device_id: 'DEV1',
|
|
95
|
+
phone: '628111',
|
|
96
|
+
otp_length: 6,
|
|
97
|
+
otp_type: 'numeric',
|
|
98
|
+
custom_otp_message: 'Your code is {otp}'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
assert_equal 6, NetHTTPStub.last_body['otp_length']
|
|
102
|
+
assert_equal 'numeric', NetHTTPStub.last_body['otp_type']
|
|
103
|
+
assert_equal 'Your code is {otp}', NetHTTPStub.last_body['customOtpMessage']
|
|
104
|
+
assert resp.success?
|
|
105
|
+
assert_equal '123456', resp.data['otp']
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def test_generate_otp_omits_optional_when_nil
|
|
109
|
+
stub
|
|
110
|
+
@client.generate_otp(device_id: 'DEV1', phone: '628111')
|
|
111
|
+
refute NetHTTPStub.last_body.key?('otp_length')
|
|
112
|
+
refute NetHTTPStub.last_body.key?('customOtpMessage')
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# ---- error handling ----
|
|
116
|
+
|
|
117
|
+
def test_raises_api_error_on_401
|
|
118
|
+
stub(status: 401, body: { 'success' => false, 'message' => 'Unauthorized' })
|
|
119
|
+
|
|
120
|
+
err = assert_raises(Kirimi::ApiError) { @client.user_info }
|
|
121
|
+
assert_equal 401, err.status_code
|
|
122
|
+
assert_equal 'Unauthorized', err.message
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_raises_api_error_on_500
|
|
126
|
+
stub(status: 500, body: { 'success' => false, 'message' => 'Server Error' })
|
|
127
|
+
|
|
128
|
+
err = assert_raises(Kirimi::ApiError) { @client.list_devices }
|
|
129
|
+
assert_equal 500, err.status_code
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# ---- response wrapping ----
|
|
133
|
+
|
|
134
|
+
def test_response_wraps_data_correctly
|
|
135
|
+
stub(body: { 'success' => true, 'data' => { 'name' => 'John' }, 'message' => 'OK' })
|
|
136
|
+
|
|
137
|
+
resp = @client.user_info
|
|
138
|
+
|
|
139
|
+
assert_instance_of Kirimi::Response, resp
|
|
140
|
+
assert resp.success?
|
|
141
|
+
assert_equal true, resp.success
|
|
142
|
+
assert_equal 'John', resp.data['name']
|
|
143
|
+
assert_equal 'OK', resp.message
|
|
144
|
+
assert_instance_of Hash, resp.raw
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# ---- broadcast phones array ----
|
|
148
|
+
|
|
149
|
+
def test_broadcast_joins_phones_array
|
|
150
|
+
stub
|
|
151
|
+
@client.broadcast_message(device_id: 'DEV1', phones: %w[628111 628222 628333], message: 'Promo!')
|
|
152
|
+
assert_equal '628111,628222,628333', NetHTTPStub.last_body['phones']
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def test_broadcast_accepts_phones_string
|
|
156
|
+
stub
|
|
157
|
+
@client.broadcast_message(device_id: 'DEV1', phones: '628111,628222', message: 'Hi')
|
|
158
|
+
assert_equal '628111,628222', NetHTTPStub.last_body['phones']
|
|
159
|
+
end
|
|
160
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: kirimi
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Kirimi
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-04-14 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: rake
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '13.0'
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '13.0'
|
|
27
|
+
description: Send WhatsApp messages, OTP, broadcasts, and more via the Kirimi API.
|
|
28
|
+
email:
|
|
29
|
+
- support@kirimi.id
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- CHANGELOG.md
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- kirimi.gemspec
|
|
38
|
+
- lib/kirimi.rb
|
|
39
|
+
- lib/kirimi/client.rb
|
|
40
|
+
- lib/kirimi/errors.rb
|
|
41
|
+
- lib/kirimi/response.rb
|
|
42
|
+
- lib/kirimi/version.rb
|
|
43
|
+
- test/client_test.rb
|
|
44
|
+
homepage: https://github.com/kiriminow/kirimi-ruby
|
|
45
|
+
licenses:
|
|
46
|
+
- MIT
|
|
47
|
+
metadata:
|
|
48
|
+
homepage_uri: https://github.com/kiriminow/kirimi-ruby
|
|
49
|
+
source_code_uri: https://github.com/kiriminow/kirimi-ruby
|
|
50
|
+
changelog_uri: https://github.com/kiriminow/kirimi-ruby/blob/main/CHANGELOG.md
|
|
51
|
+
post_install_message:
|
|
52
|
+
rdoc_options: []
|
|
53
|
+
require_paths:
|
|
54
|
+
- lib
|
|
55
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - ">="
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: 2.6.0
|
|
60
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '0'
|
|
65
|
+
requirements: []
|
|
66
|
+
rubygems_version: 3.0.3.1
|
|
67
|
+
signing_key:
|
|
68
|
+
specification_version: 4
|
|
69
|
+
summary: Official Ruby SDK for Kirimi WhatsApp API
|
|
70
|
+
test_files:
|
|
71
|
+
- test/client_test.rb
|