From 22746d64a3e92a0bd43e52e94b34719c6a0d17ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 19 May 2026 18:53:46 +0800 Subject: [PATCH] Add files via upload --- internal/config/config.go | 15 +- internal/robot/ilink/client.go | 316 +++++++++++++++++++++++++++ internal/robot/ilink/qrcode_image.go | 26 +++ internal/robot/wechat.go | 96 ++++++++ 4 files changed, 452 insertions(+), 1 deletion(-) create mode 100644 internal/robot/ilink/client.go create mode 100644 internal/robot/ilink/qrcode_image.go create mode 100644 internal/robot/wechat.go diff --git a/internal/config/config.go b/internal/config/config.go index 08105ab9..3a1809db 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -395,14 +395,27 @@ type MultiAgentAPIUpdate struct { ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"` } -// RobotsConfig 机器人配置(企业微信、钉钉、飞书等) +// RobotsConfig 机器人配置(企业微信、钉钉、飞书、微信 iLink 等) type RobotsConfig struct { Session RobotSessionConfig `yaml:"session,omitempty" json:"session,omitempty"` // 机器人会话隔离策略 + Wechat RobotWechatConfig `yaml:"wechat,omitempty" json:"wechat,omitempty"` // 微信(iLink 扫码绑定) Wecom RobotWecomConfig `yaml:"wecom,omitempty" json:"wecom,omitempty"` // 企业微信 Dingtalk RobotDingtalkConfig `yaml:"dingtalk,omitempty" json:"dingtalk,omitempty"` // 钉钉 Lark RobotLarkConfig `yaml:"lark,omitempty" json:"lark,omitempty"` // 飞书 } +// RobotWechatConfig 微信 iLink 机器人配置(个人微信 ClawBot / iLink 协议) +type RobotWechatConfig struct { + Enabled bool `yaml:"enabled" json:"enabled"` + BotToken string `yaml:"bot_token,omitempty" json:"bot_token,omitempty"` + ILinkBotID string `yaml:"ilink_bot_id,omitempty" json:"ilink_bot_id,omitempty"` + ILinkUserID string `yaml:"ilink_user_id,omitempty" json:"ilink_user_id,omitempty"` + BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` // 默认 https://ilinkai.weixin.qq.com + BotType string `yaml:"bot_type,omitempty" json:"bot_type,omitempty"` // get_bot_qrcode 参数,默认 3 + BotAgent string `yaml:"bot_agent,omitempty" json:"bot_agent,omitempty"` // base_info.bot_agent + GetUpdatesBuf string `yaml:"get_updates_buf,omitempty" json:"get_updates_buf,omitempty"` // 长轮询游标(运行时) +} + // RobotSessionConfig 机器人会话隔离策略 type RobotSessionConfig struct { StrictUserIdentity *bool `yaml:"strict_user_identity,omitempty" json:"strict_user_identity,omitempty"` // true 时只允许真实用户标识,不允许会话/群 ID 兜底 diff --git a/internal/robot/ilink/client.go b/internal/robot/ilink/client.go new file mode 100644 index 00000000..00abafdb --- /dev/null +++ b/internal/robot/ilink/client.go @@ -0,0 +1,316 @@ +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 "" +} diff --git a/internal/robot/ilink/qrcode_image.go b/internal/robot/ilink/qrcode_image.go new file mode 100644 index 00000000..0ef6521f --- /dev/null +++ b/internal/robot/ilink/qrcode_image.go @@ -0,0 +1,26 @@ +package ilink + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/skip2/go-qrcode" +) + +// QRCodeDataURL 将扫码内容(一般为 liteapp 链接)编码为 PNG data URL,供 Web 端展示。 +// qrcode_img_content 不是图片直链,不能用作 。 +func QRCodeDataURL(content string, size int) (string, error) { + content = strings.TrimSpace(content) + if content == "" { + return "", fmt.Errorf("empty qr content") + } + if size <= 0 { + size = 256 + } + png, err := qrcode.Encode(content, qrcode.Medium, size) + if err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil +} diff --git a/internal/robot/wechat.go b/internal/robot/wechat.go new file mode 100644 index 00000000..17d50404 --- /dev/null +++ b/internal/robot/wechat.go @@ -0,0 +1,96 @@ +package robot + +import ( + "context" + "strings" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/robot/ilink" + + "go.uber.org/zap" +) + +const ( + wechatReconnectInitial = 5 * time.Second + wechatReconnectMax = 60 * time.Second + wechatPlatform = "wechat" +) + +// StartWechat 启动微信 iLink 长轮询(无需公网回调),收到消息后调用 handler 并回复。 +func StartWechat(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, appVersion string, logger *zap.Logger) { + cfg := robotsCfg.Wechat + if !cfg.Enabled || cfg.BotToken == "" { + return + } + go runWechatLoop(ctx, cfg, h, appVersion, logger) +} + +func runWechatLoop(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) { + backoff := wechatReconnectInitial + for { + err := runWechatPoll(ctx, cfg, h, appVersion, logger) + if ctx.Err() != nil { + logger.Info("微信 iLink 长轮询已按配置关闭") + return + } + if err != nil { + logger.Warn("微信 iLink 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + if backoff < wechatReconnectMax { + backoff *= 2 + if backoff > wechatReconnectMax { + backoff = wechatReconnectMax + } + } + } + } +} + +func runWechatPoll(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) error { + client := ilink.NewClient(cfg.BaseURL, cfg.BotToken, cfg.BotAgent, ilink.BuildClientVersion(appVersion)) + buf := cfg.GetUpdatesBuf + logger.Info("微信 iLink 长轮询已启动", zap.String("ilink_bot_id", cfg.ILinkBotID)) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + resp, err := client.GetUpdates(ctx, buf) + if err != nil { + return err + } + if resp.ErrCode != 0 && resp.Ret != 0 { + logger.Warn("微信 getUpdates 返回错误", zap.Int("errcode", resp.ErrCode), zap.String("errmsg", resp.ErrMsg)) + } + if resp.GetUpdatesBuf != "" { + buf = resp.GetUpdatesBuf + } + for _, msg := range resp.Msgs { + if msg.MessageType != 1 { + continue + } + text := ilink.ExtractText(msg) + if text == "" { + continue + } + userID := strings.TrimSpace(msg.FromUserID) + if userID == "" { + continue + } + logger.Info("微信收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(wechatPlatform, userID, text) + if strings.TrimSpace(reply) == "" { + continue + } + if err := client.SendTextMessage(ctx, userID, msg.ContextToken, reply, ""); err != nil { + logger.Warn("微信发送回复失败", zap.String("to", userID), zap.Error(err)) + } + } + } +}