mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-22 15:39:47 +02:00
317 lines
8.3 KiB
Go
317 lines
8.3 KiB
Go
package ilink
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
const (
|
||
DefaultBaseURL = "https://ilinkai.weixin.qq.com"
|
||
DefaultBotType = "3"
|
||
DefaultBotAgent = "CyberStrikeAI/1.0"
|
||
ILinkAppID = "bot"
|
||
QRLongPollTimeout = 35 * time.Second
|
||
APIDefaultTimeout = 15 * time.Second
|
||
GetUpdatesTimeout = 35 * time.Second
|
||
)
|
||
|
||
// Client 微信 iLink Bot HTTP 客户端(与 @tencent-weixin/openclaw-weixin 协议兼容)
|
||
type Client struct {
|
||
BaseURL string
|
||
BotToken string
|
||
BotAgent string
|
||
ClientVersion uint32
|
||
HTTP *http.Client
|
||
}
|
||
|
||
func NewClient(baseURL, botToken, botAgent string, clientVersion uint32) *Client {
|
||
base := strings.TrimSpace(baseURL)
|
||
if base == "" {
|
||
base = DefaultBaseURL
|
||
}
|
||
agent := strings.TrimSpace(botAgent)
|
||
if agent == "" {
|
||
agent = DefaultBotAgent
|
||
}
|
||
return &Client{
|
||
BaseURL: strings.TrimRight(base, "/"),
|
||
BotToken: strings.TrimSpace(botToken),
|
||
BotAgent: sanitizeBotAgent(agent),
|
||
ClientVersion: clientVersion,
|
||
HTTP: &http.Client{Timeout: 0},
|
||
}
|
||
}
|
||
|
||
// BuildClientVersion 将 semver 编码为 iLink-App-ClientVersion(0x00MMNNPP)
|
||
func BuildClientVersion(version string) uint32 {
|
||
parts := strings.Split(version, ".")
|
||
parse := func(i int) int {
|
||
if i >= len(parts) {
|
||
return 0
|
||
}
|
||
n, _ := strconv.Atoi(strings.TrimSpace(parts[i]))
|
||
if n < 0 {
|
||
return 0
|
||
}
|
||
return n
|
||
}
|
||
major := parse(0) & 0xff
|
||
minor := parse(1) & 0xff
|
||
patch := parse(2) & 0xff
|
||
return uint32((major << 16) | (minor << 8) | patch)
|
||
}
|
||
|
||
type baseInfo struct {
|
||
ChannelVersion string `json:"channel_version"`
|
||
BotAgent string `json:"bot_agent"`
|
||
}
|
||
|
||
func (c *Client) buildBaseInfo() baseInfo {
|
||
return baseInfo{
|
||
ChannelVersion: "1.0.0",
|
||
BotAgent: c.BotAgent,
|
||
}
|
||
}
|
||
|
||
func randomWechatUIN() string {
|
||
var b [4]byte
|
||
_, _ = rand.Read(b[:])
|
||
u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||
return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(u), 10)))
|
||
}
|
||
|
||
func (c *Client) commonHeaders() http.Header {
|
||
h := http.Header{}
|
||
h.Set("iLink-App-Id", ILinkAppID)
|
||
h.Set("iLink-App-ClientVersion", strconv.FormatUint(uint64(c.ClientVersion), 10))
|
||
return h
|
||
}
|
||
|
||
func (c *Client) authHeaders() http.Header {
|
||
h := c.commonHeaders()
|
||
h.Set("Content-Type", "application/json")
|
||
h.Set("AuthorizationType", "ilink_bot_token")
|
||
h.Set("X-WECHAT-UIN", randomWechatUIN())
|
||
if c.BotToken != "" {
|
||
h.Set("Authorization", "Bearer "+c.BotToken)
|
||
}
|
||
return h
|
||
}
|
||
|
||
func (c *Client) endpointURL(path string) (string, error) {
|
||
u, err := url.Parse(c.BaseURL + "/")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
ref, err := url.Parse(path)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
return u.ResolveReference(ref).String(), nil
|
||
}
|
||
|
||
func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, headers http.Header, timeout time.Duration) ([]byte, error) {
|
||
reqURL, err := c.endpointURL(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var bodyReader io.Reader
|
||
if len(body) > 0 {
|
||
bodyReader = bytes.NewReader(body)
|
||
}
|
||
req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for k, vs := range headers {
|
||
for _, v := range vs {
|
||
req.Header.Add(k, v)
|
||
}
|
||
}
|
||
client := c.HTTP
|
||
if client == nil {
|
||
client = http.DefaultClient
|
||
}
|
||
if timeout > 0 {
|
||
ctx2, cancel := context.WithTimeout(ctx, timeout)
|
||
defer cancel()
|
||
req = req.WithContext(ctx2)
|
||
}
|
||
resp, err := client.Do(req)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
defer resp.Body.Close()
|
||
raw, err := io.ReadAll(resp.Body)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return nil, fmt.Errorf("ilink %s %s: %d %s", method, path, resp.StatusCode, string(raw))
|
||
}
|
||
return raw, nil
|
||
}
|
||
|
||
// QRCodeResponse 获取二维码响应
|
||
type QRCodeResponse struct {
|
||
QRCode string `json:"qrcode"`
|
||
QRCodeImgContent string `json:"qrcode_img_content"`
|
||
}
|
||
|
||
// GetBotQRCode 获取绑定二维码
|
||
func (c *Client) GetBotQRCode(ctx context.Context, botType string, localTokenList []string) (*QRCodeResponse, error) {
|
||
if strings.TrimSpace(botType) == "" {
|
||
botType = DefaultBotType
|
||
}
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"local_token_list": localTokenList,
|
||
})
|
||
path := "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(botType)
|
||
raw, err := c.doRequest(ctx, http.MethodPost, path, body, c.authHeaders(), APIDefaultTimeout)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var out QRCodeResponse
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// QRStatusResponse 二维码状态轮询响应
|
||
type QRStatusResponse struct {
|
||
Status string `json:"status"`
|
||
BotToken string `json:"bot_token"`
|
||
ILinkBotID string `json:"ilink_bot_id"`
|
||
ILinkUserID string `json:"ilink_user_id"`
|
||
BaseURL string `json:"baseurl"`
|
||
RedirectHost string `json:"redirect_host"`
|
||
}
|
||
|
||
// GetQRCodeStatus 长轮询二维码扫码状态
|
||
func (c *Client) GetQRCodeStatus(ctx context.Context, qrcode, verifyCode string) (*QRStatusResponse, error) {
|
||
path := "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode)
|
||
if verifyCode != "" {
|
||
path += "&verify_code=" + url.QueryEscape(verifyCode)
|
||
}
|
||
raw, err := c.doRequest(ctx, http.MethodGet, path, nil, c.commonHeaders(), QRLongPollTimeout)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return &QRStatusResponse{Status: "wait"}, nil
|
||
}
|
||
return &QRStatusResponse{Status: "wait"}, nil
|
||
}
|
||
var out QRStatusResponse
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// MessageItem 消息内容项
|
||
type MessageItem struct {
|
||
Type int `json:"type"`
|
||
TextItem *struct {
|
||
Text string `json:"text"`
|
||
} `json:"text_item,omitempty"`
|
||
}
|
||
|
||
// WeixinMessage 入站消息
|
||
type WeixinMessage struct {
|
||
FromUserID string `json:"from_user_id"`
|
||
MessageType int `json:"message_type"`
|
||
MessageState int `json:"message_state"`
|
||
ItemList []MessageItem `json:"item_list"`
|
||
ContextToken string `json:"context_token"`
|
||
}
|
||
|
||
// GetUpdatesResponse 长轮询消息响应
|
||
type GetUpdatesResponse struct {
|
||
Ret int `json:"ret"`
|
||
ErrCode int `json:"errcode"`
|
||
ErrMsg string `json:"errmsg"`
|
||
Msgs []WeixinMessage `json:"msgs"`
|
||
GetUpdatesBuf string `json:"get_updates_buf"`
|
||
LongPollingTimeoutMs int `json:"longpolling_timeout_ms"`
|
||
}
|
||
|
||
// GetUpdates 长轮询获取新消息
|
||
func (c *Client) GetUpdates(ctx context.Context, getUpdatesBuf string) (*GetUpdatesResponse, error) {
|
||
body, _ := json.Marshal(map[string]interface{}{
|
||
"get_updates_buf": getUpdatesBuf,
|
||
"base_info": c.buildBaseInfo(),
|
||
})
|
||
raw, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/getupdates", body, c.authHeaders(), GetUpdatesTimeout)
|
||
if err != nil {
|
||
if ctx.Err() != nil {
|
||
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
|
||
}
|
||
return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil
|
||
}
|
||
var out GetUpdatesResponse
|
||
if err := json.Unmarshal(raw, &out); err != nil {
|
||
return nil, err
|
||
}
|
||
return &out, nil
|
||
}
|
||
|
||
// SendTextMessage 发送文本回复
|
||
func (c *Client) SendTextMessage(ctx context.Context, toUserID, contextToken, text, clientID string) error {
|
||
if clientID == "" {
|
||
clientID = randomClientID()
|
||
}
|
||
payload := map[string]interface{}{
|
||
"msg": map[string]interface{}{
|
||
"to_user_id": toUserID,
|
||
"client_id": clientID,
|
||
"message_type": 2,
|
||
"message_state": 2,
|
||
"context_token": contextToken,
|
||
"item_list": []map[string]interface{}{
|
||
{"type": 1, "text_item": map[string]string{"text": text}},
|
||
},
|
||
},
|
||
"base_info": c.buildBaseInfo(),
|
||
}
|
||
body, _ := json.Marshal(payload)
|
||
_, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/sendmessage", body, c.authHeaders(), APIDefaultTimeout)
|
||
return err
|
||
}
|
||
|
||
func randomClientID() string {
|
||
var b [8]byte
|
||
_, _ = rand.Read(b[:])
|
||
return fmt.Sprintf("%x", b)
|
||
}
|
||
|
||
func sanitizeBotAgent(raw string) string {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return DefaultBotAgent
|
||
}
|
||
if len(raw) > 256 {
|
||
return raw[:256]
|
||
}
|
||
return raw
|
||
}
|
||
|
||
// ExtractText 从消息中提取首条文本
|
||
func ExtractText(msg WeixinMessage) string {
|
||
for _, item := range msg.ItemList {
|
||
if item.Type == 1 && item.TextItem != nil {
|
||
return strings.TrimSpace(item.TextItem.Text)
|
||
}
|
||
}
|
||
return ""
|
||
}
|