Class: Clacky::Channel::Adapters::Weixin::ApiClient

Inherits:
Object
  • Object
show all
Defined in:
lib/clacky/server/channel/adapters/weixin/api_client.rb

Overview

HTTP API client for Weixin iLink bot protocol.

All requests POST JSON to <base_url>/<endpoint>. Required headers per request:

Content-Type:      application/json
AuthorizationType: ilink_bot_token
Authorization:     Bearer <token>
X-WECHAT-UIN:      base64(random uint32 as decimal string)

Defined Under Namespace

Classes: ApiError, TimeoutError

Constant Summary collapse

DEFAULT_BASE_URL =
"https://ilinkai.weixin.qq.com"
CDN_BASE_URL =
"https://novac2c.cdn.weixin.qq.com/c2c"
API_PATH_PREFIX =
"ilink/bot"
CHANNEL_VERSION =
"1.0.2"
LONG_POLL_TIMEOUT_S =

slightly above the server’s 35s

40
API_TIMEOUT_S =
15
MEDIA_TYPE_IMAGE =

media_type values for getuploadurl

1
MEDIA_TYPE_VIDEO =
2
MEDIA_TYPE_FILE =
3
MEDIA_TYPE_VOICE =
4
SESSION_EXPIRED_ERRCODE =

Server errcode for expired sessions.

-14

Instance Method Summary collapse

Constructor Details

#initialize(base_url:, token:) ⇒ ApiClient

Returns a new instance of ApiClient.



52
53
54
55
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 52

def initialize(base_url:, token:)
  @base_url = base_url.to_s.chomp("/")
  @token    = token.to_s
end

Instance Method Details

#aes_ecb_decrypt(data, key) ⇒ Object

Decrypt bytes with AES-128-ECB + PKCS7 unpadding using OpenSSL.



300
301
302
303
304
305
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 300

def aes_ecb_decrypt(data, key)
  cipher = OpenSSL::Cipher.new("AES-128-ECB")
  cipher.decrypt
  cipher.key = key
  cipher.update(data) + cipher.final
end

#aes_ecb_encrypt(data, key) ⇒ Object

Encrypt bytes with AES-128-ECB + PKCS7 padding using OpenSSL.



308
309
310
311
312
313
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 308

def aes_ecb_encrypt(data, key)
  cipher = OpenSSL::Cipher.new("AES-128-ECB")
  cipher.encrypt
  cipher.key = key
  cipher.update(data) + cipher.final
end

#build_media_item(media_type, cdn_media, raw_bytes, file_name) ⇒ Object

Build the item_list entry for sendmessage based on media type.



331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 331

def build_media_item(media_type, cdn_media, raw_bytes, file_name)
  case media_type
  when MEDIA_TYPE_IMAGE
    { type: 2, image_item: { media: cdn_media } }
  when MEDIA_TYPE_VIDEO
    { type: 5, video_item: { media: cdn_media } }
  when MEDIA_TYPE_VOICE
    { type: 3, voice_item: { media: cdn_media } }
  else
    {
      type: 4,
      file_item: {
        media:     cdn_media,
        file_name: file_name,
        md5:       Digest::MD5.hexdigest(raw_bytes),
        len:       raw_bytes.bytesize.to_s
      }
    }
  end
end

#cdn_get(url) ⇒ Object

GET raw bytes from a CDN URL (no iLink auth headers needed for download).

Raises:



281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 281

def cdn_get(url)
  uri  = URI(url)
  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl      = true
  http.verify_mode  = OpenSSL::SSL::VERIFY_PEER
  http.read_timeout = API_TIMEOUT_S
  http.open_timeout = 10

  req = Net::HTTP::Get.new("#{uri.path}?#{uri.query}")
  Clacky::Logger.debug("[WeixinApiClient] CDN GET #{uri.host}#{uri.path}")

  res = http.request(req)
  raise ApiError.new(res.code.to_i, "CDN download HTTP #{res.code}") \
    unless res.is_a?(Net::HTTPSuccess)

  res.body.force_encoding("BINARY")
end

#cdn_upload(upload_param:, filekey:, encrypted_bytes:) ⇒ Object

POST encrypted bytes to CDN. Returns the x-encrypted-param header value.

Raises:

  • (ApiError.new(res.code.to_i, res.body.to_s.slice(0, 200)))


251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 251

def cdn_upload(upload_param:, filekey:, encrypted_bytes:)
  cdn_url = "#{CDN_BASE_URL}/upload" \
            "?encrypted_query_param=#{URI.encode_uri_component(upload_param)}" \
            "&filekey=#{URI.encode_uri_component(filekey)}"
  uri = URI(cdn_url)

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl      = true
  http.verify_mode  = OpenSSL::SSL::VERIFY_PEER
  http.read_timeout = API_TIMEOUT_S
  http.open_timeout = 10

  req = Net::HTTP::Post.new("#{uri.path}?#{uri.query}")
  req["Content-Type"]   = "application/octet-stream"
  req["Content-Length"] = encrypted_bytes.bytesize.to_s
  req.body = encrypted_bytes

  Clacky::Logger.debug("[WeixinApiClient] CDN upload #{encrypted_bytes.bytesize} bytes")

  res = http.request(req)
  raise ApiError.new(res.code.to_i, res.body.to_s.slice(0, 200)), "CDN upload HTTP #{res.code}" \
    unless res.is_a?(Net::HTTPSuccess)

  download_param = res["x-encrypted-param"]
  raise ApiError.new(0, "CDN upload: missing x-encrypted-param header") unless download_param

  download_param
end

#detect_media_type(file_name) ⇒ Object

Guess media_type from file extension.



316
317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 316

def detect_media_type(file_name)
  ext = File.extname(file_name).downcase
  case ext
  when ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"
    MEDIA_TYPE_IMAGE
  when ".mp4", ".mov", ".avi", ".mkv", ".flv"
    MEDIA_TYPE_VIDEO
  when ".mp3", ".m4a", ".amr", ".wav", ".ogg"
    MEDIA_TYPE_VOICE
  else
    MEDIA_TYPE_FILE
  end
end

#download_media(cdn_media, media_type) ⇒ String

Download and decrypt a media file from the Weixin CDN.

Parameters:

  • cdn_media (Hash)

    { “encrypt_query_param” => String, “aes_key” => String } Keys may be Symbol or String.

  • media_type (Integer)

    MEDIA_TYPE_* constant — controls aeskey decoding.

Returns:

  • (String)

    raw (decrypted) file bytes.

Raises:



143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 143

def download_media(cdn_media, media_type)
  encrypted_param = cdn_media[:encrypt_query_param] || cdn_media["encrypt_query_param"]
  aeskey_b64      = cdn_media[:aes_key]             || cdn_media["aes_key"]

  raise ApiError.new(0, "download_media: missing encrypt_query_param") unless encrypted_param
  raise ApiError.new(0, "download_media: missing aes_key")             unless aeskey_b64

  # Decode aes_key. The encoding depends on who generated the key:
  #
  # Outbound (we upload): image → base64(raw 16 bytes), others → base64(hex 32 chars)
  # Inbound (WeChat client uploaded): aes_key is a plain hex string (32 hex chars, no base64)
  #
  # Detection strategy — try to figure out the actual key by checking decoded size:
  #   decoded 16 bytes → raw AES key (our outbound image encoding)
  #   decoded 24 bytes → aes_key was a plain hex string (32 chars) passed as-is,
  #                      meaning aeskey_b64 IS the hex string, not base64 at all.
  #                      Use the original string directly: [aeskey_b64].pack("H*")
  #   decoded 32 bytes → base64(hex 32 chars) → [decoded].pack("H*") → 16 bytes
  raw_aes_key = begin
                  decoded = Base64.strict_decode64(aeskey_b64)
                  case decoded.bytesize
                  when 16
                    # Our outbound image encoding: base64(raw 16 bytes)
                    decoded
                  when 32
                    # Our outbound non-image encoding: base64(hex 32 chars)
                    [decoded].pack("H*")
                  else
                    # Unexpected — fall through to hex-string path
                    raise ArgumentError, "unexpected decoded size #{decoded.bytesize}"
                  end
                rescue ArgumentError
                  # aes_key is a plain hex string (32 hex chars), not base64.
                  # This is the inbound format used by WeChat clients.
                  if aeskey_b64.match?(/\A[0-9a-fA-F]{32}\z/)
                    [aeskey_b64].pack("H*")
                  else
                    Clacky::Logger.warn("[WeixinApiClient] unknown aeskey format: len=#{aeskey_b64.bytesize}")
                    aeskey_b64[0, 16]  # last-resort: first 16 bytes
                  end
                end

  Clacky::Logger.debug("[WeixinApiClient] download_media key_bytes=#{raw_aes_key.bytesize} media_type=#{media_type}")

  # GET encrypted bytes from CDN.
  cdn_url = "#{CDN_BASE_URL}/download" \
            "?encrypted_query_param=#{URI.encode_uri_component(encrypted_param)}"
  encrypted_bytes = cdn_get(cdn_url)

  # Decrypt with AES-128-ECB.
  aes_ecb_decrypt(encrypted_bytes, raw_aes_key)
end

#get_typing_ticket(ilink_user_id:, context_token: nil) ⇒ String

Retrieve a typing_ticket for the given user. context_token is optional but recommended per protocol spec.

Returns:

  • (String)

    typing_ticket



67
68
69
70
71
72
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 67

def get_typing_ticket(ilink_user_id:, context_token: nil)
  body = { ilink_user_id: ilink_user_id }
  body[:context_token] = context_token if context_token
  resp = post("getconfig", body)
  resp["typing_ticket"].to_s
end

#get_updates(get_updates_buf:) ⇒ Hash

Long-poll for new messages.

Parameters:

  • get_updates_buf (String)

    cursor from last response (“” for first call)

Returns:

  • (Hash)

    { ret:, msgs: [], get_updates_buf:, longpolling_timeout_ms: }



60
61
62
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 60

def get_updates(get_updates_buf:)
  post("getupdates", { get_updates_buf: get_updates_buf }, timeout: LONG_POLL_TIMEOUT_S)
end

#post(endpoint, body_hash, timeout: API_TIMEOUT_S) ⇒ Object



352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 352

def post(endpoint, body_hash, timeout: API_TIMEOUT_S)
  uri  = URI("#{@base_url}/#{API_PATH_PREFIX}/#{endpoint}")
  # All POST bodies must include base_info per iLink protocol spec.
  body = JSON.generate(body_hash.merge(base_info: { channel_version: CHANNEL_VERSION }))

  http = Net::HTTP.new(uri.host, uri.port)
  http.use_ssl      = uri.scheme == "https"
  http.verify_mode  = OpenSSL::SSL::VERIFY_PEER
  http.read_timeout = timeout
  http.open_timeout = 10

  req = Net::HTTP::Post.new(uri.path)
  req["Content-Type"]      = "application/json"
  req["AuthorizationType"] = "ilink_bot_token"
  req["Content-Length"]    = body.bytesize.to_s
  req["X-WECHAT-UIN"]      = random_wechat_uin
  req["Authorization"]     = "Bearer #{@token}" unless @token.empty?
  req.body = body

  Clacky::Logger.debug("[WeixinApiClient] POST #{endpoint}")

  res = http.request(req)
  raise ApiError.new(res.code.to_i, res.body), "HTTP #{res.code}" unless res.is_a?(Net::HTTPSuccess)

  raw_body = res.body
  data = JSON.parse(raw_body)
  ret  = data["ret"] || data["errcode"]
  if ret && ret != 0
    # Include full response body for easier debugging (errmsg is often empty)
    detail = data["errmsg"].to_s.strip
    detail = raw_body.slice(0, 300) if detail.empty?
    raise ApiError.new(ret, detail)
  end

  data
rescue Net::ReadTimeout, Net::OpenTimeout
  raise TimeoutError, "#{endpoint} timed out"
rescue JSON::ParserError => e
  raise ApiError.new(0, "Invalid JSON: #{e.message}")
end

#random_wechat_uinObject

X-WECHAT-UIN: random uint32 → decimal string → base64



394
395
396
397
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 394

def random_wechat_uin
  uint32 = SecureRandom.random_bytes(4).unpack1("N")
  Base64.strict_encode64(uint32.to_s)
end

#send_file(to_user_id:, file_path:, context_token:, file_name: nil, media_type: nil) ⇒ Hash

Send a file (any type) to a user.

Parameters:

  • to_user_id (String)
  • file_path (String)

    local path to the file

  • file_name (String) (defaults to: nil)

    display name (defaults to basename)

  • context_token (String)
  • media_type (Integer) (defaults to: nil)

    MEDIA_TYPE_* constant (default: auto-detect)

Returns:

  • (Hash)

    API response



109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 109

def send_file(to_user_id:, file_path:, context_token:, file_name: nil, media_type: nil)
  file_name  ||= File.basename(file_path)
  media_type ||= detect_media_type(file_name)
  raw_bytes    = File.binread(file_path)

  cdn_media = upload_media(
    raw_bytes:    raw_bytes,
    file_name:    file_name,
    media_type:   media_type,
    to_user_id:   to_user_id
  )

  item = build_media_item(media_type, cdn_media, raw_bytes, file_name)
  body = {
    msg: {
      from_user_id:  "",
      to_user_id:    to_user_id,
      client_id:     "clacky-#{SecureRandom.hex(8)}",
      message_type:  2,  # BOT
      message_state: 2,  # FINISH
      item_list:     [item],
      context_token: context_token
    }
  }
  Clacky::Logger.debug("[WeixinApiClient] send_file item: #{item.to_json}")
  post("sendmessage", body)
end

#send_text(to_user_id:, text:, context_token:) ⇒ Object

Send a plain text message. context_token is required by the Weixin protocol for conversation association.



86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 86

def send_text(to_user_id:, text:, context_token:)
  body = {
    msg: {
      from_user_id: "",
      to_user_id:   to_user_id,
      client_id:    "clacky-#{SecureRandom.hex(8)}",
      message_type: 2,   # BOT
      message_state: 2,  # FINISH
      item_list:     [{ type: 1, text_item: { text: text } }],
      context_token: context_token
    }
  }
  post("sendmessage", body)
end

#send_typing(ilink_user_id:, typing_ticket:, status:) ⇒ Object

Send/keep/cancel typing indicator.

Parameters:

  • status (Integer)

    1 = typing, 2 = cancel



76
77
78
79
80
81
82
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 76

def send_typing(ilink_user_id:, typing_ticket:, status:)
  post("sendtyping", {
    ilink_user_id: ilink_user_id,
    typing_ticket: typing_ticket,
    status:        status
  })
end

#upload_media(raw_bytes:, file_name:, media_type:, to_user_id:) ⇒ Object

Full upload pipeline: encrypt → getuploadurl → CDN PUT → return CDNMedia hash.

Raises:



198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
# File 'lib/clacky/server/channel/adapters/weixin/api_client.rb', line 198

def upload_media(raw_bytes:, file_name:, media_type:, to_user_id:)
  # Generate a random 16-byte AES key.
  aes_key_raw = SecureRandom.bytes(16)

  # Encrypt file bytes with AES-128-ECB + PKCS7.
  encrypted_bytes = aes_ecb_encrypt(raw_bytes, aes_key_raw)

  # filekey: arbitrary unique string (use hex of random bytes).
  filekey = SecureRandom.hex(16)

  # aeskey for getuploadurl: hex string of raw 16 bytes (32 hex chars), NOT base64.
  # Confirmed from @tencent-weixin/openclaw-weixin source: aeskey.toString("hex")
  aeskey_hex = aes_key_raw.unpack1("H*")

  # aes_key for CDNMedia: base64 of the hex string as UTF-8 bytes.
  # Confirmed: Buffer.from(aeskey_hex).toString("base64") in Node.js = base64 of hex string bytes
  aeskey_b64 = Base64.strict_encode64(aeskey_hex)

  raw_md5 = Digest::MD5.hexdigest(raw_bytes)

  # Step 1: get CDN upload URL from iLink API.
  upload_resp = post("getuploadurl", {
    filekey:        filekey,
    media_type:     media_type,
    to_user_id:     to_user_id,
    rawsize:        raw_bytes.bytesize,
    rawfilemd5:     raw_md5,
    filesize:       encrypted_bytes.bytesize,
    aeskey:         aeskey_hex,
    no_need_thumb:  true
  })

  upload_param = upload_resp["upload_param"]
  Clacky::Logger.debug("[WeixinApiClient] getuploadurl resp: #{upload_resp.to_json}")
  raise ApiError.new(0, "getuploadurl: missing upload_param") unless upload_param

  # Step 2: upload encrypted bytes to CDN.
  download_param = cdn_upload(
    upload_param:    upload_param,
    filekey:         filekey,
    encrypted_bytes: encrypted_bytes
  )

  # Return CDNMedia structure for use in sendmessage item_list.
  # encrypt_type: 1 confirmed from @tencent-weixin/openclaw-weixin source.
  {
    encrypt_query_param: download_param,
    aes_key:             aeskey_b64,
    encrypt_type:        1
  }
end