事实(主人 2026-10-03 确认):iLink 的 bot token 是长期凭据(一个月前扫码的仍可用);errcode=-14 是 iLink 侧拒绝/作废该会话,不是时间性过期;且至今未观测到真实 -14(当日全部日志 0 次)。此前代码把这些写成「过期」,属沿用的旧命名,现纠正。 - 纯改名/文案:ErrSessionExpired→ErrSessionRejected、errSessionExpired→errcodeSessionRejected、expired/expiredToken→rejected/rejectedToken、markExpired/expiredLocked/expiredState→markRejected/rejectedLocked/rejectedState;409 错误码 session_expired→session_rejected - 行为一字未改:显式标记 / Connect 不假 OK / 面板 needsRescan+lastError / 扫码或换新 token 即清除 全部保留 - 微信协议自身的「二维码过期」与 auth 的 JWT/refresh 过期一律未动(那些是真·时间性过期) - Docs/api-client v0.39 补事实说明;顺手收尾 3 处残留注释(admin.go / channelbridge/inbound.go / readloop_test.go)
271 lines
8.3 KiB
Go
271 lines
8.3 KiB
Go
package weixin_ilink
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
|
)
|
|
|
|
// item kind constants reuse the iLink item type codes from poll.go, so
|
|
// outbound media maps to the same numbers as inbound parsing.
|
|
|
|
// sendResult is the sendmessage response envelope.
|
|
type sendResult struct {
|
|
Ret int `json:"ret"`
|
|
Errcode int `json:"errcode"`
|
|
}
|
|
|
|
// SendMessage delivers a reply to the channel. Each message is sent to the
|
|
// peer resolved from the message (or resp.ReplyTo) using that peer's stored
|
|
// context_token, so the platform can associate the reply with the right
|
|
// conversation (design/08 §8).
|
|
func (a *Adapter) SendMessage(ctx context.Context, resp adapter.UnifiedResponse) error {
|
|
if err := a.sendGuard(); err != nil {
|
|
return err
|
|
}
|
|
for _, m := range resp.Messages {
|
|
peer, _ := recipient(m)
|
|
if peer == "" {
|
|
peer = resp.ReplyTo
|
|
}
|
|
if err := a.sendToOne(ctx, peer, m); err != nil {
|
|
return fmt.Errorf("weixin_ilink: send message: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// SendProactive pushes a message that was not triggered by a recent inbound.
|
|
// WeChat iLink only allows this inside the 24h window since the user's last
|
|
// message (design/08 §3-4): when the user has been silent longer, or has never
|
|
// messaged, the push is rejected with ErrProactiveWindow and Capabilities.
|
|
// Proactive is only true so callers know the capability exists.
|
|
func (a *Adapter) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) error {
|
|
if err := a.sendGuard(); err != nil {
|
|
return err
|
|
}
|
|
peer, _ := recipient(msg)
|
|
if peer == "" {
|
|
return ErrNoContextToken
|
|
}
|
|
|
|
a.mu.RLock()
|
|
last, seen := a.lastMsgAt[peer]
|
|
window := a.cfg.ProactiveWindow
|
|
a.mu.RUnlock()
|
|
|
|
if !seen || a.clock().Sub(last) > window {
|
|
return ErrProactiveWindow
|
|
}
|
|
if a.contextFor(peer) == "" {
|
|
return ErrNoContextToken
|
|
}
|
|
return a.sendToOne(ctx, peer, msg)
|
|
}
|
|
|
|
// sendGuard reports the error a send must surface before touching the network: a
|
|
// session iLink already rejected (errcode=-14) reads as ErrSessionRejected — the
|
|
// owner must re-scan — while any other down session stays ErrNotConnected.
|
|
func (a *Adapter) sendGuard() error {
|
|
if rejected, reason := a.rejectedState(); rejected {
|
|
return fmt.Errorf("weixin_ilink: send: %w: %s", ErrSessionRejected, reason)
|
|
}
|
|
if !a.IsConnected() {
|
|
return adapter.ErrNotConnected
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// sendToOne sends one normalized message to a peer as a native iLink message,
|
|
// composing its item_list from any text plus uploaded media.
|
|
func (a *Adapter) sendToOne(ctx context.Context, peer string, m adapter.UnifiedMessage) error {
|
|
if peer == "" {
|
|
return ErrNoContextToken
|
|
}
|
|
ctxToken := a.contextFor(peer)
|
|
if ctxToken == "" {
|
|
return ErrNoContextToken
|
|
}
|
|
|
|
items := make([]any, 0, 1+len(m.Media))
|
|
if m.Content != "" {
|
|
items = append(items, nativeTextItem(m.Content))
|
|
}
|
|
for _, md := range m.Media {
|
|
item, err := a.buildMediaItem(ctx, md)
|
|
if err != nil {
|
|
return fmt.Errorf("weixin_ilink: build media item for %q: %w", md.Kind, err)
|
|
}
|
|
items = append(items, item)
|
|
}
|
|
if len(items) == 0 {
|
|
return nil
|
|
}
|
|
return a.sendNative(ctx, peer, ctxToken, items)
|
|
}
|
|
|
|
// buildMediaItem encrypts media bytes, uploads them to the CDN and returns the
|
|
// native item referencing the uploaded object and its AES key.
|
|
func (a *Adapter) buildMediaItem(ctx context.Context, md adapter.Media) (any, error) {
|
|
plaintext := md.Data
|
|
if len(plaintext) == 0 && md.URL != "" {
|
|
data, err := a.fetchBody(ctx, md.URL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: fetch media source: %w", err)
|
|
}
|
|
plaintext = data
|
|
}
|
|
|
|
key := make([]byte, aes.BlockSize)
|
|
if _, err := rand.Read(key); err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: generate aes key: %w", err)
|
|
}
|
|
encrypted, err := aesECBEncrypt(key, plaintext)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: encrypt media: %w", err)
|
|
}
|
|
uploadURL, err := a.getUploadURL(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: get upload url: %w", err)
|
|
}
|
|
if err := a.putBytes(ctx, uploadURL, encrypted); err != nil {
|
|
return nil, fmt.Errorf("weixin_ilink: upload media: %w", err)
|
|
}
|
|
return nativeMediaItem(nativeMediaTypeFor(md.Kind), uploadURL, key, md), nil
|
|
}
|
|
|
|
// sendNative posts a composed sendmessage payload.
|
|
func (a *Adapter) sendNative(ctx context.Context, peer, ctxToken string, items []any) error {
|
|
body, err := json.Marshal(map[string]any{
|
|
"msg": map[string]any{
|
|
"to_user_id": peer,
|
|
"client_id": newClientID(),
|
|
"message_type": 2, // bot message
|
|
"message_state": 2, // FINISH
|
|
"context_token": ctxToken,
|
|
"item_list": items,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("weixin_ilink: encode sendmessage: %w", err)
|
|
}
|
|
var out sendResult
|
|
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/sendmessage"),
|
|
bytes.NewReader(body), &out); err != nil {
|
|
return fmt.Errorf("weixin_ilink: sendmessage: %w", err)
|
|
}
|
|
if out.Errcode == errcodeSessionRejected {
|
|
// The send path sees the same rejected credential as the poll path: record
|
|
// the rejected state so the adapter stops claiming a live session and the
|
|
// panel can ask for a re-scan.
|
|
a.markRejected(rescanReasonSendMessage)
|
|
return ErrSessionRejected
|
|
}
|
|
if out.Ret != 0 {
|
|
return fmt.Errorf("weixin_ilink: sendmessage ret=%d", out.Ret)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// getUploadURL requests a pre-signed CDN upload URL. The response field names
|
|
// vary across server versions, so several candidates are tried.
|
|
func (a *Adapter) getUploadURL(ctx context.Context) (string, error) {
|
|
body, err := json.Marshal(map[string]any{})
|
|
if err != nil {
|
|
return "", fmt.Errorf("weixin_ilink: encode getuploadurl: %w", err)
|
|
}
|
|
var raw map[string]any
|
|
if err := a.doJSON(ctx, http.MethodPost, a.apiURL("/ilink/bot/getuploadurl"),
|
|
bytes.NewReader(body), &raw); err != nil {
|
|
return "", fmt.Errorf("weixin_ilink: getuploadurl: %w", err)
|
|
}
|
|
if intOf(raw["errcode"]) == errcodeSessionRejected {
|
|
// Media upload is part of the send path, so the same rejected credential is
|
|
// recorded here too rather than degrading into "no url returned".
|
|
a.markRejected(rescanReasonUploadURL)
|
|
return "", ErrSessionRejected
|
|
}
|
|
u := firstString(raw, "upload_url", "url", "uploadUrl", "cdn_url")
|
|
if u == "" {
|
|
return "", fmt.Errorf("weixin_ilink: getuploadurl returned no url")
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
// putBytes uploads encrypted media bytes to a pre-signed CDN URL.
|
|
func (a *Adapter) putBytes(ctx context.Context, uploadURL string, data []byte) error {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, uploadURL, bytes.NewReader(data))
|
|
if err != nil {
|
|
return fmt.Errorf("weixin_ilink: build upload: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
resp, err := a.httpClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("weixin_ilink: upload media: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
return fmt.Errorf("weixin_ilink: upload media: status %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nativeTextItem builds a text item_list entry.
|
|
func nativeTextItem(text string) map[string]any {
|
|
return map[string]any{
|
|
"type": itemText,
|
|
"text_item": map[string]any{"text": text},
|
|
}
|
|
}
|
|
|
|
// nativeMediaItem builds a media item_list entry that references the uploaded
|
|
// object. Both cdn_ref and url are set because the exact field is not public.
|
|
func nativeMediaItem(kind int, ref string, key []byte, md adapter.Media) map[string]any {
|
|
item := map[string]any{
|
|
"type": kind,
|
|
"cdn_ref": ref,
|
|
"url": ref,
|
|
"aes_key": base64.StdEncoding.EncodeToString(key),
|
|
"file_name": md.Name,
|
|
"size": md.Size,
|
|
}
|
|
if md.MIMEType != "" {
|
|
item["mime"] = md.MIMEType
|
|
}
|
|
return item
|
|
}
|
|
|
|
// nativeMediaTypeFor maps a normalized MediaKind to the iLink item type code.
|
|
func nativeMediaTypeFor(kind adapter.MediaKind) int {
|
|
switch kind {
|
|
case adapter.MediaImage:
|
|
return itemImage
|
|
case adapter.MediaVoice:
|
|
return itemVoice
|
|
case adapter.MediaVideo:
|
|
return itemVideo
|
|
default:
|
|
return itemFile
|
|
}
|
|
}
|
|
|
|
// newClientID returns a random hex client id used to de-duplicate sends. On an
|
|
// (unlikely) crypto/rand failure it falls back to a time-based value.
|
|
func newClientID() string {
|
|
var b [12]byte
|
|
if _, err := rand.Read(b[:]); err == nil {
|
|
return hex.EncodeToString(b[:])
|
|
}
|
|
return "c-" + strconv.FormatInt(time.Now().UnixNano(), 36)
|
|
}
|