mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-07 13:07:57 +02:00
Add files via upload
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
package robot
|
||||
|
||||
// MessageHandler 供飞书/钉钉长连接调用的消息处理接口(由 handler.RobotHandler 实现)
|
||||
type MessageHandler interface {
|
||||
HandleMessage(platform, userID, text string) string
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot"
|
||||
"github.com/open-dingtalk/dingtalk-stream-sdk-go/client"
|
||||
dingutils "github.com/open-dingtalk/dingtalk-stream-sdk-go/utils"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
dingReconnectInitial = 5 * time.Second // 首次重连间隔
|
||||
dingReconnectMax = 60 * time.Second // 最大重连间隔
|
||||
)
|
||||
|
||||
// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。
|
||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
||||
func StartDing(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Dingtalk
|
||||
if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" {
|
||||
return
|
||||
}
|
||||
go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||
}
|
||||
|
||||
// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。
|
||||
func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := dingReconnectInitial
|
||||
for {
|
||||
streamClient := client.NewStreamClient(
|
||||
client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)),
|
||||
client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get",
|
||||
chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) {
|
||||
go handleDingMessage(ctx, msg, cfg, strictUserIdentity, h, logger)
|
||||
return nil, nil
|
||||
}).OnEventReceived),
|
||||
)
|
||||
logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID))
|
||||
err := streamClient.Start(ctx)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("钉钉 Stream 已按配置重启关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("钉钉 Stream 长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
// 下次重连间隔递增,上限 60 秒,避免频繁重试
|
||||
if backoff < dingReconnectMax {
|
||||
backoff *= 2
|
||||
if backoff > dingReconnectMax {
|
||||
backoff = dingReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||
if msg == nil || msg.SessionWebhook == "" {
|
||||
return
|
||||
}
|
||||
content := ""
|
||||
if msg.Text.Content != "" {
|
||||
content = strings.TrimSpace(msg.Text.Content)
|
||||
}
|
||||
if content == "" && msg.Msgtype == "richText" {
|
||||
if cMap, ok := msg.Content.(map[string]interface{}); ok {
|
||||
if rich, ok := cMap["richText"].([]interface{}); ok {
|
||||
for _, c := range rich {
|
||||
if m, ok := c.(map[string]interface{}); ok {
|
||||
if txt, ok := m["text"].(string); ok {
|
||||
content = strings.TrimSpace(txt)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if content == "" {
|
||||
logger.Debug("钉钉消息内容为空,已忽略", zap.String("msgtype", msg.Msgtype))
|
||||
return
|
||||
}
|
||||
logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content))
|
||||
tenantKey := strings.TrimSpace(cfg.ClientID)
|
||||
if tenantKey == "" {
|
||||
tenantKey = "default"
|
||||
}
|
||||
userID := strings.TrimSpace(msg.SenderId)
|
||||
if userID != "" {
|
||||
userID = "t:" + tenantKey + "|u:" + userID
|
||||
} else if cfg.AllowConversationIDFallback && !strictUserIdentity {
|
||||
conversationID := strings.TrimSpace(msg.ConversationId)
|
||||
if conversationID != "" {
|
||||
userID = "t:" + tenantKey + "|c:" + conversationID
|
||||
}
|
||||
}
|
||||
if userID == "" {
|
||||
logger.Warn("钉钉消息缺少可用用户标识,已忽略")
|
||||
return
|
||||
}
|
||||
reply := h.HandleMessage("dingtalk", userID, content)
|
||||
// 使用 markdown 类型以便正确展示标题、列表、代码块等格式
|
||||
title := reply
|
||||
if idx := strings.IndexAny(reply, "\n"); idx > 0 {
|
||||
title = strings.TrimSpace(reply[:idx])
|
||||
}
|
||||
if len(title) > 50 {
|
||||
title = title[:50] + "…"
|
||||
}
|
||||
if title == "" {
|
||||
title = "回复"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"msgtype": "markdown",
|
||||
"markdown": map[string]string{
|
||||
"title": title,
|
||||
"text": reply,
|
||||
},
|
||||
}
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes))
|
||||
if err != nil {
|
||||
logger.Warn("钉钉构造回复请求失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
logger.Warn("钉钉回复请求失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logger.Warn("钉钉回复非 200", zap.Int("status", resp.StatusCode))
|
||||
return
|
||||
}
|
||||
logger.Debug("钉钉回复成功", zap.String("content_preview", reply))
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 不是图片直链,不能用作 <img src>。
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/larksuite/oapi-sdk-go/v3/event/dispatcher"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
larkws "github.com/larksuite/oapi-sdk-go/v3/ws"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
larkReconnectInitial = 5 * time.Second // 首次重连间隔
|
||||
larkReconnectMax = 60 * time.Second // 最大重连间隔
|
||||
)
|
||||
|
||||
type larkTextContent struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。
|
||||
// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。
|
||||
func StartLark(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) {
|
||||
cfg := robotsCfg.Lark
|
||||
if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return
|
||||
}
|
||||
go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger)
|
||||
}
|
||||
|
||||
// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。
|
||||
func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) {
|
||||
backoff := larkReconnectInitial
|
||||
for {
|
||||
larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret)
|
||||
eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error {
|
||||
go handleLarkMessage(ctx, event, cfg, strictUserIdentity, h, larkClient, logger)
|
||||
return nil
|
||||
})
|
||||
wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret,
|
||||
larkws.WithEventHandler(eventHandler),
|
||||
larkws.WithLogLevel(larkcore.LogLevelInfo),
|
||||
)
|
||||
logger.Info("飞书长连接正在连接…", zap.String("app_id", cfg.AppID))
|
||||
err := wsClient.Start(ctx)
|
||||
if ctx.Err() != nil {
|
||||
logger.Info("飞书长连接已按配置重启关闭")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
logger.Warn("飞书长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff))
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
if backoff < larkReconnectMax {
|
||||
backoff *= 2
|
||||
if backoff > larkReconnectMax {
|
||||
backoff = larkReconnectMax
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, client *lark.Client, logger *zap.Logger) {
|
||||
if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
||||
return
|
||||
}
|
||||
msg := event.Event.Message
|
||||
msgType := larkcore.StringValue(msg.MessageType)
|
||||
if msgType != larkim.MsgTypeText {
|
||||
logger.Debug("飞书暂仅处理文本消息", zap.String("msg_type", msgType))
|
||||
return
|
||||
}
|
||||
var textBody larkTextContent
|
||||
if err := json.Unmarshal([]byte(larkcore.StringValue(msg.Content)), &textBody); err != nil {
|
||||
logger.Warn("飞书消息 Content 解析失败", zap.Error(err))
|
||||
return
|
||||
}
|
||||
text := strings.TrimSpace(textBody.Text)
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity)
|
||||
if userID == "" {
|
||||
logger.Warn("飞书消息缺少可用用户标识,已忽略")
|
||||
return
|
||||
}
|
||||
messageID := larkcore.StringValue(msg.MessageId)
|
||||
reply := h.HandleMessage("lark", userID, text)
|
||||
contentBytes, _ := json.Marshal(larkTextContent{Text: reply})
|
||||
_, err := client.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder().
|
||||
MessageId(messageID).
|
||||
Body(larkim.NewReplyMessageReqBodyBuilder().
|
||||
MsgType(larkim.MsgTypeText).
|
||||
Content(string(contentBytes)).
|
||||
Build()).
|
||||
Build())
|
||||
if err != nil {
|
||||
logger.Warn("飞书回复失败", zap.String("message_id", messageID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
logger.Debug("飞书已回复", zap.String("message_id", messageID))
|
||||
}
|
||||
|
||||
// resolveLarkUserID 提取飞书会话隔离键:
|
||||
// tenant_key + 稳定用户标识(user_id/open_id/union_id);按配置可选 chat_id 兜底。
|
||||
func resolveLarkUserID(event *larkim.P2MessageReceiveV1, allowChatIDFallback bool) string {
|
||||
if event == nil || event.Event == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil {
|
||||
return ""
|
||||
}
|
||||
tenantKey := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.TenantKey))
|
||||
if tenantKey == "" {
|
||||
tenantKey = "default"
|
||||
}
|
||||
prefix := "t:" + tenantKey + "|"
|
||||
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UserId)); id != "" {
|
||||
return prefix + "u:" + id
|
||||
}
|
||||
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.OpenId)); id != "" {
|
||||
return prefix + "o:" + id
|
||||
}
|
||||
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UnionId)); id != "" {
|
||||
return prefix + "n:" + id
|
||||
}
|
||||
if allowChatIDFallback && event.Event.Message != nil {
|
||||
if id := strings.TrimSpace(larkcore.StringValue(event.Event.Message.ChatId)); id != "" {
|
||||
return prefix + "c:" + id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user