ilink 0.1.2
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/README.md +248 -0
- data/lib/ilink/bot.rb +135 -0
- data/lib/ilink/configuration.rb +31 -0
- data/lib/ilink/connection.rb +91 -0
- data/lib/ilink/constants.rb +41 -0
- data/lib/ilink/errors.rb +22 -0
- data/lib/ilink/resources/messages.rb +54 -0
- data/lib/ilink/version.rb +5 -0
- data/lib/ilink.rb +24 -0
- metadata +62 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e4a12ec6f693d67456b1f7cbd06a3bbf7251c15eff1fb963c8b3a6405c756970
|
|
4
|
+
data.tar.gz: '049ddfe910aa9100ee71bd83b61759830b8c48f12f4d1ae2fda49567395b9722'
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0b5eba8c115ef1030ad52b7271e37550b6615b1a643743d00571e278d8fd8826b2f585169e6e29f53721d6064db5f7b9d30f128721d42500a398491281a56454
|
|
7
|
+
data.tar.gz: 549c34e5ec1efcf19ad1baba056f264751ada594bf063be8ff12247aca447ff3dcff10678aee8f6986cf38d6c9c7ee2608153a48b12d41a2a0887d09a1f4dc85
|
data/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# ILink
|
|
2
|
+
|
|
3
|
+
Ruby SDK for the WeChat iLink Bot API.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# Gemfile
|
|
9
|
+
gem "ilink"
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Or install directly:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
gem install ilink
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
require "ilink"
|
|
22
|
+
|
|
23
|
+
ILink.configure do |c|
|
|
24
|
+
c.token = "your_bot_token"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
bot = ILink::Bot.new
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Configuration
|
|
31
|
+
|
|
32
|
+
| Option | Default | Description |
|
|
33
|
+
| ------------------- | ------------------------------- | --------------------------------------------------------------- |
|
|
34
|
+
| `base_url` | `https://ilinkai.weixin.qq.com` | API base URL |
|
|
35
|
+
| `token` | `nil` | Bot token (Bearer auth) |
|
|
36
|
+
| `app_id` | `"bot"` | iLink-App-Id header |
|
|
37
|
+
| `app_version` | `"2.1.1"` | Client version string |
|
|
38
|
+
| `timeout` | `15` | Default request timeout (seconds) |
|
|
39
|
+
| `long_poll_timeout` | `35` | Long-poll timeout for `get_updates` / `qrcode_status` (seconds) |
|
|
40
|
+
| `route_tag` | `nil` | Optional SKRouteTag header |
|
|
41
|
+
|
|
42
|
+
Global configuration applies to all `Bot` instances. You can also override per-instance:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
bot = ILink::Bot.new(
|
|
46
|
+
token: "another_token",
|
|
47
|
+
base_url: "https://custom-host.example.com"
|
|
48
|
+
)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## API Reference
|
|
52
|
+
|
|
53
|
+
### QR Code Login
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
bot = ILink::Bot.new
|
|
57
|
+
|
|
58
|
+
# Step 1: Get a QR code
|
|
59
|
+
qr = bot.qrcode
|
|
60
|
+
puts qr[:qrcode_img_content] # QR code image URL — show to user
|
|
61
|
+
|
|
62
|
+
# Step 2: Poll scan status (long-poll, blocks until scanned or timeout)
|
|
63
|
+
loop do
|
|
64
|
+
status = bot.qrcode_status(qrcode: qr[:qrcode])
|
|
65
|
+
case status[:status]
|
|
66
|
+
when "confirmed"
|
|
67
|
+
puts "Login success!"
|
|
68
|
+
puts "bot_token: #{status[:bot_token]}"
|
|
69
|
+
puts "ilink_bot_id: #{status[:ilink_bot_id]}"
|
|
70
|
+
puts "base_url: #{status[:baseurl]}"
|
|
71
|
+
break
|
|
72
|
+
when "scaned"
|
|
73
|
+
puts "Scanned, waiting for confirmation..."
|
|
74
|
+
when "expired"
|
|
75
|
+
puts "QR code expired, request a new one"
|
|
76
|
+
break
|
|
77
|
+
when "scaned_but_redirect"
|
|
78
|
+
# IDC redirect — switch polling host
|
|
79
|
+
puts "Redirecting to #{status[:redirect_host]}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Polling Messages
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
bot = ILink::Bot.new(token: "your_bot_token")
|
|
88
|
+
|
|
89
|
+
buf = ""
|
|
90
|
+
loop do
|
|
91
|
+
resp = bot.get_updates(buf: buf)
|
|
92
|
+
buf = resp[:get_updates_buf] || buf
|
|
93
|
+
|
|
94
|
+
(resp[:msgs] || []).each do |msg|
|
|
95
|
+
from = msg[:from_user_id]
|
|
96
|
+
msg[:item_list]&.each do |item|
|
|
97
|
+
case item[:type]
|
|
98
|
+
when ILink::MessageItemType::TEXT
|
|
99
|
+
puts "#{from}: #{item[:text_item][:text]}"
|
|
100
|
+
when ILink::MessageItemType::IMAGE
|
|
101
|
+
puts "#{from}: [image] #{item[:image_item][:url]}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Sending Messages
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
# Send a simple text message
|
|
112
|
+
bot.send_text(to: "user_id", text: "Hello from Ruby!")
|
|
113
|
+
|
|
114
|
+
# Send with session ID
|
|
115
|
+
bot.send_text(to: "user_id", text: "Reply in session", session_id: "session_123")
|
|
116
|
+
|
|
117
|
+
# Send a custom message (image, file, video, etc.)
|
|
118
|
+
bot.send_message({
|
|
119
|
+
to_user_id: "user_id",
|
|
120
|
+
message_type: ILink::MessageType::BOT,
|
|
121
|
+
message_state: ILink::MessageState::FINISH,
|
|
122
|
+
item_list: [
|
|
123
|
+
{
|
|
124
|
+
type: ILink::MessageItemType::IMAGE,
|
|
125
|
+
image_item: {
|
|
126
|
+
media: { encrypt_query_param: "...", aes_key: "..." }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
]
|
|
130
|
+
})
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Media Upload
|
|
134
|
+
|
|
135
|
+
```ruby
|
|
136
|
+
resp = bot.upload_url(
|
|
137
|
+
media_type: ILink::UploadMediaType::IMAGE,
|
|
138
|
+
to_user_id: "user_id",
|
|
139
|
+
rawsize: File.size("photo.jpg"),
|
|
140
|
+
rawfilemd5: Digest::MD5.hexdigest(File.read("photo.jpg")),
|
|
141
|
+
filesize: encrypted_size,
|
|
142
|
+
aeskey: aes_key_hex
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
puts resp[:upload_full_url] # Pre-signed upload URL
|
|
146
|
+
puts resp[:upload_param] # Upload encryption param
|
|
147
|
+
puts resp[:thumb_upload_param] # Thumbnail upload param (if applicable)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Typing Indicators
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
# Get the typing ticket first
|
|
154
|
+
config = bot.get_config(user_id: "user_id")
|
|
155
|
+
ticket = config[:typing_ticket]
|
|
156
|
+
|
|
157
|
+
# Show "typing..."
|
|
158
|
+
bot.send_typing(user_id: "user_id", ticket: ticket)
|
|
159
|
+
|
|
160
|
+
# Cancel typing
|
|
161
|
+
bot.cancel_typing(user_id: "user_id", ticket: ticket)
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Bot Config
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
config = bot.get_config(user_id: "user_id", context_token: "optional_token")
|
|
168
|
+
puts config[:typing_ticket]
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Constants
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
ILink::UploadMediaType::IMAGE # 1
|
|
175
|
+
ILink::UploadMediaType::VIDEO # 2
|
|
176
|
+
ILink::UploadMediaType::FILE # 3
|
|
177
|
+
ILink::UploadMediaType::VOICE # 4
|
|
178
|
+
|
|
179
|
+
ILink::MessageType::USER # 1
|
|
180
|
+
ILink::MessageType::BOT # 2
|
|
181
|
+
|
|
182
|
+
ILink::MessageItemType::TEXT # 1
|
|
183
|
+
ILink::MessageItemType::IMAGE # 2
|
|
184
|
+
ILink::MessageItemType::VOICE # 3
|
|
185
|
+
ILink::MessageItemType::FILE # 4
|
|
186
|
+
ILink::MessageItemType::VIDEO # 5
|
|
187
|
+
|
|
188
|
+
ILink::MessageState::NEW # 0
|
|
189
|
+
ILink::MessageState::GENERATING # 1
|
|
190
|
+
ILink::MessageState::FINISH # 2
|
|
191
|
+
|
|
192
|
+
ILink::TypingStatus::TYPING # 1
|
|
193
|
+
ILink::TypingStatus::CANCEL # 2
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
## Error Handling
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
begin
|
|
200
|
+
bot.send_text(to: "user_id", text: "hi")
|
|
201
|
+
rescue ILink::AuthenticationError => e
|
|
202
|
+
puts "Auth failed (#{e.status}): #{e.body}"
|
|
203
|
+
rescue ILink::ApiError => e
|
|
204
|
+
puts "API error (#{e.status}): #{e.body}"
|
|
205
|
+
rescue Net::ReadTimeout
|
|
206
|
+
puts "Request timed out"
|
|
207
|
+
end
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Full Echo Bot Example
|
|
211
|
+
|
|
212
|
+
```ruby
|
|
213
|
+
require "ilink"
|
|
214
|
+
|
|
215
|
+
ILink.configure do |c|
|
|
216
|
+
c.token = ENV["ILINK_BOT_TOKEN"]
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
bot = ILink::Bot.new
|
|
220
|
+
buf = ""
|
|
221
|
+
|
|
222
|
+
puts "Bot started, waiting for messages..."
|
|
223
|
+
|
|
224
|
+
loop do
|
|
225
|
+
resp = bot.get_updates(get_updates_buf: buf)
|
|
226
|
+
buf = resp[:get_updates_buf] || buf
|
|
227
|
+
|
|
228
|
+
(resp[:msgs] || []).each do |msg|
|
|
229
|
+
next unless msg[:message_type] == ILink::MessageType::USER
|
|
230
|
+
|
|
231
|
+
from = msg[:from_user_id]
|
|
232
|
+
msg[:item_list]&.each do |item|
|
|
233
|
+
next unless item[:type] == ILink::MessageItemType::TEXT
|
|
234
|
+
|
|
235
|
+
text = item.dig(:text_item, :text)
|
|
236
|
+
puts "Received: #{text} from #{from}"
|
|
237
|
+
bot.send_text(to: from, text: "Echo: #{text}", session_id: msg[:session_id])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
rescue ILink::ApiError => e
|
|
241
|
+
warn "API error: #{e.message}"
|
|
242
|
+
sleep 3
|
|
243
|
+
end
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## License
|
|
247
|
+
|
|
248
|
+
MIT
|
data/lib/ilink/bot.rb
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ILink
|
|
4
|
+
# Main entry point for the iLink Bot API.
|
|
5
|
+
#
|
|
6
|
+
# bot = ILink::Bot.new(token: "your_bot_token")
|
|
7
|
+
# bot.get_updates
|
|
8
|
+
# bot.send_text(to: "user_id", text: "Hello!")
|
|
9
|
+
# bot.upload_url(media_type: 1, to_user_id: "user_id", ...)
|
|
10
|
+
# bot.send_typing(user_id: "...", ticket: "...")
|
|
11
|
+
# bot.get_config(user_id: "...")
|
|
12
|
+
# bot.create_qr_code
|
|
13
|
+
#
|
|
14
|
+
class Bot
|
|
15
|
+
attr_reader :configuration
|
|
16
|
+
|
|
17
|
+
def initialize(token: nil, base_url: nil, **options)
|
|
18
|
+
@configuration = ILink.configuration.dup
|
|
19
|
+
@configuration.token = token if token
|
|
20
|
+
@configuration.base_url = base_url if base_url
|
|
21
|
+
|
|
22
|
+
options.each do |key, value|
|
|
23
|
+
@configuration.public_send(:"#{key}=", value) if @configuration.respond_to?(:"#{key}=")
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Long-poll for new messages.
|
|
28
|
+
#
|
|
29
|
+
# @param buf [String] opaque cursor from previous call ("" on first call)
|
|
30
|
+
# @return [Hash] parsed response with :ret, :msgs, :get_updates_buf, etc.
|
|
31
|
+
def get_updates(buf: "")
|
|
32
|
+
connection.post("/ilink/bot/getupdates",
|
|
33
|
+
{ get_updates_buf: buf },
|
|
34
|
+
timeout: @configuration.long_poll_timeout)
|
|
35
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
36
|
+
{ ret: 0, msgs: [], get_updates_buf: buf }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Send a message.
|
|
40
|
+
#
|
|
41
|
+
# @param message [Hash] a WeixinMessage hash with keys like :to_user_id, :item_list, etc.
|
|
42
|
+
# @return [Hash] parsed response
|
|
43
|
+
def send_message(message)
|
|
44
|
+
connection.post("/ilink/bot/sendmessage", { msg: message })
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Convenience: send a text message to a user.
|
|
48
|
+
#
|
|
49
|
+
# @param to [String] target user ID
|
|
50
|
+
# @param text [String] message text
|
|
51
|
+
# @param session_id [String, nil] optional session ID
|
|
52
|
+
# @return [Hash]
|
|
53
|
+
def send_text(to:, text:, session_id: nil)
|
|
54
|
+
message = {
|
|
55
|
+
to_user_id: to,
|
|
56
|
+
message_type: MessageType::BOT,
|
|
57
|
+
message_state: MessageState::FINISH,
|
|
58
|
+
item_list: [
|
|
59
|
+
{ type: MessageItemType::TEXT, text_item: { text: text } }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
message[:session_id] = session_id if session_id
|
|
63
|
+
send_message(message)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get a pre-signed CDN upload URL.
|
|
67
|
+
#
|
|
68
|
+
# @param params [Hash] :filekey, :media_type, :to_user_id, :rawsize, :rawfilemd5,
|
|
69
|
+
# :filesize, :aeskey, :thumb_rawsize, :thumb_rawfilemd5, :thumb_filesize, :no_need_thumb
|
|
70
|
+
# @return [Hash] { upload_param:, thumb_upload_param:, upload_full_url: }
|
|
71
|
+
def upload_url(**params)
|
|
72
|
+
connection.post("/ilink/bot/getuploadurl", params)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Send a typing indicator.
|
|
76
|
+
#
|
|
77
|
+
# @param user_id [String] ilink user ID
|
|
78
|
+
# @param ticket [String] typing ticket (from get_config)
|
|
79
|
+
# @return [Hash]
|
|
80
|
+
def send_typing(user_id:, ticket:)
|
|
81
|
+
set_typing(user_id: user_id, ticket: ticket, status: TypingStatus::TYPING)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Cancel a typing indicator.
|
|
85
|
+
#
|
|
86
|
+
# @param user_id [String] ilink user ID
|
|
87
|
+
# @param ticket [String] typing ticket
|
|
88
|
+
# @return [Hash]
|
|
89
|
+
def cancel_typing(user_id:, ticket:)
|
|
90
|
+
set_typing(user_id: user_id, ticket: ticket, status: TypingStatus::CANCEL)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def set_typing(user_id:, ticket:, status:)
|
|
94
|
+
connection.post("/ilink/bot/sendtyping", {
|
|
95
|
+
ilink_user_id: user_id,
|
|
96
|
+
typing_ticket: ticket,
|
|
97
|
+
status: status
|
|
98
|
+
}, timeout: 10)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Fetch bot config for a user (includes typing_ticket).
|
|
102
|
+
#
|
|
103
|
+
# @param user_id [String] ilink user ID
|
|
104
|
+
# @param context_token [String, nil] optional context token
|
|
105
|
+
# @return [Hash] { ret:, typing_ticket:, ... }
|
|
106
|
+
def get_config(user_id:, context_token: nil)
|
|
107
|
+
body = { ilink_user_id: user_id, context_token: context_token }.compact
|
|
108
|
+
connection.post("/ilink/bot/getconfig", body, timeout: 10)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Request a new QR code for login.
|
|
112
|
+
#
|
|
113
|
+
# @param bot_type [String] bot type identifier (default "3")
|
|
114
|
+
# @return [Hash] { qrcode:, qrcode_img_content: }
|
|
115
|
+
def qrcode(bot_type: "3")
|
|
116
|
+
connection.get("/ilink/bot/get_bot_qrcode?bot_type=#{URI.encode_www_form_component(bot_type)}", timeout: 5)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Poll QR code scan status (long-poll).
|
|
120
|
+
#
|
|
121
|
+
# @param qrcode [String] qrcode value from #create_qr_code
|
|
122
|
+
# @return [Hash] { status:, bot_token:, ilink_bot_id:, baseurl:, ilink_user_id:, redirect_host: }
|
|
123
|
+
def qrcode_status(qrcode:)
|
|
124
|
+
connection.get("/ilink/bot/get_qrcode_status?qrcode=#{URI.encode_www_form_component(qrcode)}",
|
|
125
|
+
timeout: @configuration.long_poll_timeout)
|
|
126
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
127
|
+
{ status: "wait" }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
def connection
|
|
132
|
+
Connection.new(@configuration)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ILink
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :base_url, :token
|
|
6
|
+
attr_accessor :app_id, :app_version
|
|
7
|
+
attr_accessor :timeout, :long_poll_timeout, :route_tag
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://ilinkai.weixin.qq.com"
|
|
10
|
+
DEFAULT_APP_ID = "bot"
|
|
11
|
+
DEFAULT_APP_VERSION = "2.1.1"
|
|
12
|
+
DEFAULT_TIMEOUT = 15
|
|
13
|
+
DEFAULT_LONG_POLL_TIMEOUT = 35
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@base_url = DEFAULT_BASE_URL
|
|
17
|
+
@app_id = DEFAULT_APP_ID
|
|
18
|
+
@app_version = DEFAULT_APP_VERSION
|
|
19
|
+
@timeout = DEFAULT_TIMEOUT
|
|
20
|
+
@long_poll_timeout = DEFAULT_LONG_POLL_TIMEOUT
|
|
21
|
+
@token = nil
|
|
22
|
+
@route_tag = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Encode version string "M.N.P" as uint32: 0x00MMNNPP
|
|
26
|
+
def client_version_int
|
|
27
|
+
parts = (app_version || "0.0.0").split(".").map(&:to_i)
|
|
28
|
+
((parts[0] & 0xFF) << 16) | ((parts[1].to_i & 0xFF) << 8) | (parts[2].to_i & 0xFF)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
require "json"
|
|
5
|
+
require "base64"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "net/http"
|
|
8
|
+
|
|
9
|
+
module ILink
|
|
10
|
+
# Low-level HTTP connection using Ruby's built-in net/http.
|
|
11
|
+
# Builds proper iLink headers, handles timeouts, and parses JSON responses.
|
|
12
|
+
class Connection
|
|
13
|
+
attr_reader :config
|
|
14
|
+
|
|
15
|
+
def initialize(config)
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def get(endpoint, timeout: config.timeout, headers: {})
|
|
20
|
+
uri = build_uri(endpoint)
|
|
21
|
+
request = Net::HTTP::Get.new(uri)
|
|
22
|
+
common_headers.merge(headers).each { |k, v| request[k] = v }
|
|
23
|
+
|
|
24
|
+
execute(uri, request, timeout: timeout)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def post(endpoint, body = {}, timeout: config.timeout, headers: {})
|
|
28
|
+
uri = build_uri(endpoint)
|
|
29
|
+
payload = body.merge(base_info: { channel_version: config.app_version })
|
|
30
|
+
json_body = JSON.generate(payload)
|
|
31
|
+
|
|
32
|
+
request = Net::HTTP::Post.new(uri)
|
|
33
|
+
post_headers.merge(headers).each { |k, v| request[k] = v }
|
|
34
|
+
request.body = json_body
|
|
35
|
+
|
|
36
|
+
execute(uri, request, timeout: timeout)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
def build_uri(endpoint)
|
|
41
|
+
URI.join(config.base_url.to_s, endpoint)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def common_headers
|
|
45
|
+
headers = {
|
|
46
|
+
"iLink-App-Id" => config.app_id,
|
|
47
|
+
"iLink-App-ClientVersion" => config.client_version_int.to_s
|
|
48
|
+
}
|
|
49
|
+
headers["SKRouteTag"] = config.route_tag if config.route_tag
|
|
50
|
+
headers
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def post_headers
|
|
54
|
+
headers = common_headers.merge(
|
|
55
|
+
"Content-Type" => "application/json",
|
|
56
|
+
"AuthorizationType" => "ilink_bot_token",
|
|
57
|
+
"X-WECHAT-UIN" => random_wechat_uin
|
|
58
|
+
)
|
|
59
|
+
headers["Authorization"] = "Bearer #{config.token}" if config.token
|
|
60
|
+
headers
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def random_wechat_uin
|
|
64
|
+
uint32 = SecureRandom.random_number(2**32)
|
|
65
|
+
Base64.strict_encode64(uint32.to_s)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def execute(uri, request, timeout:)
|
|
69
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
70
|
+
http.use_ssl = (uri.scheme == "https")
|
|
71
|
+
http.open_timeout = [timeout, 10].min
|
|
72
|
+
http.read_timeout = timeout
|
|
73
|
+
http.write_timeout = timeout
|
|
74
|
+
|
|
75
|
+
response = http.request(request)
|
|
76
|
+
handle_response(response)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_response(response)
|
|
80
|
+
case response.code.to_i
|
|
81
|
+
when 200..299
|
|
82
|
+
body = response.body.to_s
|
|
83
|
+
body.empty? ? {} : JSON.parse(body, symbolize_names: true)
|
|
84
|
+
when 401, 403
|
|
85
|
+
raise AuthenticationError.new(response.code.to_i, response.body)
|
|
86
|
+
else
|
|
87
|
+
raise ApiError.new(response.code.to_i, response.body)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ILink
|
|
4
|
+
# Media type for upload requests
|
|
5
|
+
module UploadMediaType
|
|
6
|
+
IMAGE = 1
|
|
7
|
+
VIDEO = 2
|
|
8
|
+
FILE = 3
|
|
9
|
+
VOICE = 4
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Message sender type
|
|
13
|
+
module MessageType
|
|
14
|
+
NONE = 0
|
|
15
|
+
USER = 1
|
|
16
|
+
BOT = 2
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Message content type
|
|
20
|
+
module MessageItemType
|
|
21
|
+
NONE = 0
|
|
22
|
+
TEXT = 1
|
|
23
|
+
IMAGE = 2
|
|
24
|
+
VOICE = 3
|
|
25
|
+
FILE = 4
|
|
26
|
+
VIDEO = 5
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Message lifecycle state
|
|
30
|
+
module MessageState
|
|
31
|
+
NEW = 0
|
|
32
|
+
GENERATING = 1
|
|
33
|
+
FINISH = 2
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Typing indicator status
|
|
37
|
+
module TypingStatus
|
|
38
|
+
TYPING = 1
|
|
39
|
+
CANCEL = 2
|
|
40
|
+
end
|
|
41
|
+
end
|
data/lib/ilink/errors.rb
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ILink
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Raised on non-2xx HTTP responses
|
|
7
|
+
class ApiError < Error
|
|
8
|
+
attr_reader :status, :body
|
|
9
|
+
|
|
10
|
+
def initialize(status, body)
|
|
11
|
+
@status = status
|
|
12
|
+
@body = body
|
|
13
|
+
super("iLink API error #{status}: #{body}")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when long-poll times out.
|
|
18
|
+
class TimeoutError < Error; end
|
|
19
|
+
|
|
20
|
+
# Raised on authentication failures (401/403)
|
|
21
|
+
class AuthenticationError < ApiError; end
|
|
22
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ILink
|
|
4
|
+
module Resources
|
|
5
|
+
# RESTful resource for message operations.
|
|
6
|
+
#
|
|
7
|
+
# bot.messages.get_updates - long-poll for new messages (getUpdates)
|
|
8
|
+
# bot.messages.send(msg) - send a message to a user
|
|
9
|
+
class Messages < Base
|
|
10
|
+
# Long-poll for new messages.
|
|
11
|
+
#
|
|
12
|
+
# @param get_updates_buf [String] opaque cursor from previous poll ("" on first call)
|
|
13
|
+
# @return [Hash] parsed response with :ret, :msgs, :get_updates_buf, etc.
|
|
14
|
+
def poll(get_updates_buf: "")
|
|
15
|
+
post("/ilink/bot/getupdates",
|
|
16
|
+
{ get_updates_buf: get_updates_buf },
|
|
17
|
+
timeout: connection.config.long_poll_timeout)
|
|
18
|
+
rescue Net::ReadTimeout, Net::OpenTimeout
|
|
19
|
+
# Long-poll timeout is normal; return empty response so caller can retry
|
|
20
|
+
{ ret: 0, msgs: [], get_updates_buf: get_updates_buf }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Send a message.
|
|
24
|
+
#
|
|
25
|
+
# @param message [Hash] a WeixinMessage hash with keys like :to_user_id, :item_list, etc.
|
|
26
|
+
# @return [Hash] parsed response
|
|
27
|
+
def send(message)
|
|
28
|
+
post("/ilink/bot/sendmessage", { msg: message })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convenience: send a text message to a user.
|
|
32
|
+
#
|
|
33
|
+
# @param to [String] target user ID
|
|
34
|
+
# @param text [String] message text
|
|
35
|
+
# @param session_id [String, nil] optional session ID
|
|
36
|
+
# @return [Hash]
|
|
37
|
+
def send_text(to:, text:, session_id: nil)
|
|
38
|
+
message = {
|
|
39
|
+
to_user_id: to,
|
|
40
|
+
message_type: MessageType::BOT,
|
|
41
|
+
message_state: MessageState::FINISH,
|
|
42
|
+
item_list: [
|
|
43
|
+
{
|
|
44
|
+
type: MessageItemType::TEXT,
|
|
45
|
+
text_item: { text: text }
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
message[:session_id] = session_id if session_id
|
|
50
|
+
send(message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/ilink.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ilink/version"
|
|
4
|
+
require_relative "ilink/errors"
|
|
5
|
+
require_relative "ilink/constants"
|
|
6
|
+
require_relative "ilink/configuration"
|
|
7
|
+
require_relative "ilink/connection"
|
|
8
|
+
require_relative "ilink/bot"
|
|
9
|
+
|
|
10
|
+
module ILink
|
|
11
|
+
class << self
|
|
12
|
+
def configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield(configuration)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def reset_configuration!
|
|
21
|
+
@configuration = Configuration.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ilink
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.2
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Songji Zeng
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: base64
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0'
|
|
26
|
+
description: |
|
|
27
|
+
A lightweight Ruby SDK for building WeChat bots via the iLink Bot API.
|
|
28
|
+
Handles QR login, long-polling for messages, sending replies, and typing indicators.
|
|
29
|
+
executables: []
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- README.md
|
|
34
|
+
- lib/ilink.rb
|
|
35
|
+
- lib/ilink/bot.rb
|
|
36
|
+
- lib/ilink/configuration.rb
|
|
37
|
+
- lib/ilink/connection.rb
|
|
38
|
+
- lib/ilink/constants.rb
|
|
39
|
+
- lib/ilink/errors.rb
|
|
40
|
+
- lib/ilink/resources/messages.rb
|
|
41
|
+
- lib/ilink/version.rb
|
|
42
|
+
licenses:
|
|
43
|
+
- MIT
|
|
44
|
+
metadata: {}
|
|
45
|
+
rdoc_options: []
|
|
46
|
+
require_paths:
|
|
47
|
+
- lib
|
|
48
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - ">="
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.1'
|
|
53
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
54
|
+
requirements:
|
|
55
|
+
- - ">="
|
|
56
|
+
- !ruby/object:Gem::Version
|
|
57
|
+
version: '0'
|
|
58
|
+
requirements: []
|
|
59
|
+
rubygems_version: 4.0.9
|
|
60
|
+
specification_version: 4
|
|
61
|
+
summary: Ruby SDK for the WeChat iLink Bot API
|
|
62
|
+
test_files: []
|