feat(channels/weixin_ilink): 微信 iLink 渠道插件(扫码登录+长轮询+游标+context_token+AES-128-ECB 媒体+24h 主动推送窗口)
This commit is contained in:
1 parent
7831b0ea1b
commit
88dc12466d
13 files changed
+2627
No files matched your search
@@ -0,0 +1,96 @@
|
||||
package weixin_ilink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/aes"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrKeyTooShort is returned when the inbound derivation cannot yield an AES
|
||||
// key of the required block size.
|
||||
var ErrKeyTooShort = errors.New("weixin_ilink: derived key shorter than a block")
|
||||
|
||||
// deriveInboundKey derives the AES-128 key for an inbound media payload. Per
|
||||
// design/08 §3.3 the key is extracted from the bot_token: base64-decode the
|
||||
// token and take the trailing 16 bytes. This is marked ⚠️ in the reference, so
|
||||
// DownloadMediaWithKey lets callers supply an explicit key when the platform
|
||||
// carries one per item instead.
|
||||
func deriveInboundKey(botToken string) ([]byte, error) {
|
||||
token := strings.TrimSpace(botToken)
|
||||
if token == "" {
|
||||
return nil, ErrInvalidConfig
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: decode bot_token for key: %w", err)
|
||||
}
|
||||
if len(b) < aes.BlockSize {
|
||||
return nil, ErrKeyTooShort
|
||||
}
|
||||
return b[len(b)-aes.BlockSize:], nil
|
||||
}
|
||||
|
||||
// DownloadMedia downloads an inbound CDN reference and returns the decrypted
|
||||
// plaintext using the key derived from the configured bot_token. It fails with
|
||||
// ErrMediaTooLarge when the ciphertext exceeds MaxMediaSize.
|
||||
func (a *Adapter) DownloadMedia(ctx context.Context, cdnURL string) ([]byte, error) {
|
||||
key, err := deriveInboundKey(a.cfg.BotToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: derive inbound key: %w", err)
|
||||
}
|
||||
return a.downloadWithKey(ctx, cdnURL, key)
|
||||
}
|
||||
|
||||
// DownloadMediaWithKey is DownloadMedia with an explicit per-item AES key,
|
||||
// used when the protocol delivers the key alongside the media reference.
|
||||
func (a *Adapter) DownloadMediaWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
|
||||
if len(key) != aes.BlockSize {
|
||||
return nil, fmt.Errorf("weixin_ilink: key must be %d bytes: %w", aes.BlockSize, ErrKeyTooShort)
|
||||
}
|
||||
return a.downloadWithKey(ctx, cdnURL, key)
|
||||
}
|
||||
|
||||
// downloadWithKey fetches the CDN bytes and removes the AES-ECB layer.
|
||||
func (a *Adapter) downloadWithKey(ctx context.Context, cdnURL string, key []byte) ([]byte, error) {
|
||||
ciphertext, err := a.fetchBody(ctx, a.cdnURL(cdnURL))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: fetch media: %w", err)
|
||||
}
|
||||
plaintext, err := aesECBDecrypt(key, ciphertext)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: decrypt media: %w", err)
|
||||
}
|
||||
return plaintext, nil
|
||||
}
|
||||
|
||||
// fetchBody GETs rawURL and returns the body, refusing anything over
|
||||
// MaxMediaSize so a single huge file cannot exhaust memory.
|
||||
func (a *Adapter) fetchBody(ctx context.Context, rawURL string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: build media request: %w", err)
|
||||
}
|
||||
a.authorizationHeader(req)
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: download media: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("weixin_ilink: download media: status %d", resp.StatusCode)
|
||||
}
|
||||
limit := a.cfg.MaxMediaSize + 1
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, limit))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("weixin_ilink: read media: %w", err)
|
||||
}
|
||||
if int64(len(data)) > a.cfg.MaxMediaSize {
|
||||
return nil, ErrMediaTooLarge
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
Reference in new issue
Block a user