diff --git a/go.mod b/go.mod index 48f5179..f5a66d7 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/docker/docker v25.0.3+incompatible github.com/docker/go-connections v0.5.0 + github.com/go-co-op/gocron/v2 v2.16.1 github.com/go-resty/resty/v2 v2.10.0 github.com/goccy/go-json v0.10.5 github.com/gofiber/fiber/v2 v2.52.6 @@ -39,6 +40,7 @@ require ( github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -51,6 +53,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.7.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.12.0 // indirect diff --git a/go.sum b/go.sum index 8340cba..2e5c1d2 100644 --- a/go.sum +++ b/go.sum @@ -25,6 +25,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo= +github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -68,6 +70,8 @@ github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/ github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= @@ -102,6 +106,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= @@ -152,6 +158,8 @@ go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lI go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/docker/robot.go b/internal/docker/robot.go index 5278e20..5267ad8 100644 --- a/internal/docker/robot.go +++ b/internal/docker/robot.go @@ -2,9 +2,9 @@ package docker import ( "context" - "encoding/json" "fmt" "log" + "strings" "time" "github.com/go-resty/resty/v2" @@ -20,63 +20,6 @@ const ( WechatBotLabelValue = "wechat-bot" ) -// APIResponse API响应结构 -type APIResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data interface{} `json:"data"` -} - -// QRCodeResponse 获取二维码响应 -type QRCodeResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data struct { - UUID string `json:"Uuid"` - ExpiredTime string `json:"ExpiredTime"` - QRCodeBase64 string `json:"QRCodeBase64"` - QRCodeURL string `json:"QRCodeURL"` - } `json:"data"` -} - -// CheckUuidResponse 检查二维码状态响应 -type CheckUuidResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data struct { - Uuid string `json:"uuid"` - Status int `json:"status"` // 状态 - PushLoginUrlExpiredTime int `json:"pushLoginUrlexpiredTime"` // 推送登录url过期时间 - ExpiredTime int `json:"expiredTime"` // 过期时间(秒) - HeadImgUrl string `json:"headImgUrl"` // 头像 - NickName string `json:"nickName"` // 昵称 - AcctSectResp map[string]any `json:"acctSectResp"` // 账号信息-登录成功之后才有 - } `json:"data"` -} - -// AwakenLoginResponse 唤醒登录响应 -type AwakenLoginResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data struct { - QrCodeResponse json.RawMessage `json:"QrCodeResponse"` - } `json:"data"` -} - -// AutoHeartbeatResponse 心跳响应 -type AutoHeartbeatResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Data struct{} -} - -// AutoHeartbeatStatusResponse 心跳状态响应 -type AutoHeartbeatStatusResponse struct { - Success bool `json:"success"` - Message string `json:"message"` - Running bool `json:"running"` -} - // 定义一个HTTP客户端生成函数,用于向容器内的API发出请求 func newHTTPClient() *resty.Client { return resty.New(). @@ -127,12 +70,12 @@ func GetLoginQRCode(ctx context.Context, containerID string) (string, error) { } // GetQRCode 获取登录二维码 -func GetQRCode(ctx context.Context, containerID string, containerHost string) (*QRCodeResponse, error) { +func GetQRCode(ctx context.Context, containerHost string) (*BaseResponse[QRCodeResponse], error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/GetQRCode", containerHost) - var response QRCodeResponse + var response BaseResponse[QRCodeResponse] resp, err := client.R(). SetContext(ctx). SetBody("{}"). @@ -155,13 +98,13 @@ func GetQRCode(ctx context.Context, containerID string, containerHost string) (* } // CheckUuid 检查二维码状态 -func CheckUuid(ctx context.Context, uuid string, containerHost string) (*CheckUuidResponse, error) { +func CheckUuid(ctx context.Context, uuid string, containerHost string) (*BaseResponse[CheckUuidResponse], error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/CheckUuid", containerHost) reqBody := map[string]string{"Uuid": uuid} - var response CheckUuidResponse + var response BaseResponse[CheckUuidResponse] resp, err := client.R(). SetContext(ctx). SetBody(reqBody). @@ -180,13 +123,13 @@ func CheckUuid(ctx context.Context, uuid string, containerHost string) (*CheckUu } // AwakenLogin 唤醒登录 -func AwakenLogin(ctx context.Context, containerID string, wxid string, containerHost string) (*AwakenLoginResponse, error) { +func AwakenLogin(ctx context.Context, wxid string, containerHost string) (*BaseResponse[AwakenLoginResponse], error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/AwakenLogin", containerHost) reqBody := map[string]string{"Wxid": wxid} - var response AwakenLoginResponse + var response BaseResponse[AwakenLoginResponse] resp, err := client.R(). SetContext(ctx). SetBody(reqBody). @@ -205,13 +148,13 @@ func AwakenLogin(ctx context.Context, containerID string, wxid string, container } // AutoHeartbeatStart 开启自动心跳 -func AutoHeartbeatStart(ctx context.Context, containerID string, wxid string, containerHost string) (*AutoHeartbeatResponse, error) { +func AutoHeartbeatStart(ctx context.Context, wxid string, containerHost string) (*BaseResponse[AutoHeartbeatResponse], error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/AutoHeartbeatStart", containerHost) reqBody := map[string]string{"Wxid": wxid} - var response AutoHeartbeatResponse + var response BaseResponse[AutoHeartbeatResponse] resp, err := client.R(). SetContext(ctx). SetBody(reqBody). @@ -234,7 +177,7 @@ func AutoHeartbeatStart(ctx context.Context, containerID string, wxid string, co } // AutoHeartbeatStatus 获取自动心跳状态 -func AutoHeartbeatStatus(ctx context.Context, containerID string, wxid string, containerHost string) (*AutoHeartbeatStatusResponse, error) { +func AutoHeartbeatStatus(ctx context.Context, wxid string, containerHost string) (*AutoHeartbeatStatusResponse, error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/AutoHeartbeatStatus", containerHost) @@ -259,7 +202,7 @@ func AutoHeartbeatStatus(ctx context.Context, containerID string, wxid string, c } // AutoHeartbeatStop 停止自动心跳 -func AutoHeartbeatStop(ctx context.Context, containerID string, wxid string, containerHost string) (*AutoHeartbeatResponse, error) { +func AutoHeartbeatStop(ctx context.Context, wxid string, containerHost string) (*AutoHeartbeatResponse, error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/AutoHeartbeatStop", containerHost) @@ -288,7 +231,7 @@ func AutoHeartbeatStop(ctx context.Context, containerID string, wxid string, con } // LogOut 登出微信 -func LogOut(ctx context.Context, containerID string, wxid string, containerHost string) (*AutoHeartbeatResponse, error) { +func LogOut(ctx context.Context, wxid string, containerHost string) (*AutoHeartbeatResponse, error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/LogOut", containerHost) @@ -324,7 +267,7 @@ func LogoutWechatBot(ctx context.Context, containerID string) error { client := newHTTPClient() url := fmt.Sprintf("http://%s:3000/api/logout", hostIP) - var response APIResponse + var response BaseResponse[any] resp, err := client.R(). SetContext(ctx). SetResult(&response). @@ -363,7 +306,7 @@ func GetWechatBotStatus(ctx context.Context, containerID string) (model.RobotSta client := newHTTPClient() url := fmt.Sprintf("http://%s:3000/api/status", hostIP) - var response APIResponse + var response BaseResponse[any] resp, err := client.R(). SetContext(ctx). SetResult(&response). @@ -410,7 +353,7 @@ func ListWechatBots(ctx context.Context) ([]ContainerInfo, error) { } // GetWechatContacts 获取微信联系人列表 -func GetWechatContacts(ctx context.Context, containerID string, containerHost string) (string, error) { +func GetWechatContacts(ctx context.Context, containerHost string) (string, error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/api/contacts", containerHost) @@ -426,7 +369,7 @@ func GetWechatContacts(ctx context.Context, containerID string, containerHost st } // GetWechatGroupMembers 获取微信群成员 -func GetWechatGroupMembers(ctx context.Context, containerID string, groupID string, containerHost string) (string, error) { +func GetWechatGroupMembers(ctx context.Context, groupID string, containerHost string) (string, error) { client := newHTTPClient() url := fmt.Sprintf("http://%s/api/group/%s/members", containerHost, groupID) @@ -442,17 +385,31 @@ func GetWechatGroupMembers(ctx context.Context, containerID string, groupID stri } // GetWechatMessages 获取微信消息历史 -func GetWechatMessages(ctx context.Context, containerID string, contactID string, limit int, containerHost string) (string, error) { +func GetWechatMessages(ctx context.Context, containerHost, robotWxId string) (msg []Message, isOffline bool, err error) { client := newHTTPClient() - url := fmt.Sprintf("http://%s/api/messages/%s?limit=%d", containerHost, contactID, limit) + url := fmt.Sprintf("http://%s/Sync", containerHost) - resp, err := client.R(). + var response BaseResponse[Sync] + _, err = client.R(). SetContext(ctx). - Get(url) + SetBody(map[string]any{ + "Scene": 0, + "Synckey": "", + "Wxid": robotWxId, + }). + SetResult(&response). + Post(url) if err != nil { - return "", fmt.Errorf("获取消息历史请求失败: %w", err) + err = fmt.Errorf("获取消息历史请求失败: %w", err) + return } - return resp.String(), nil + // 判断是否离线 + if strings.Contains(response.Message, "用户可能退出") || strings.Contains(response.Message, "数据[DecryptData]失败") { + isOffline = true + } + + msg = response.Data.AddMsgs + return } diff --git a/internal/docker/robot_model.go b/internal/docker/robot_model.go new file mode 100644 index 0000000..5ee6e68 --- /dev/null +++ b/internal/docker/robot_model.go @@ -0,0 +1,105 @@ +package docker + +import "gitee.ltd/lxh/wechat-robot/internal/types" + +// BaseResponse API响应结构 +type BaseResponse[T any] struct { + Success bool `json:"Success"` + Code int `json:"Code"` + Message string `json:"Message"` + Data T `json:"Data"` +} + +// QRCodeResponse 获取二维码响应 +type QRCodeResponse struct { + UUID string `json:"Uuid"` + ExpiredTime string `json:"ExpiredTime"` + QRCodeBase64 string `json:"QRCodeBase64"` + QRCodeURL string `json:"QRCodeURL"` +} + +// CheckUuidResponse 检查二维码状态响应 +type CheckUuidResponse struct { + Uuid string `json:"uuid"` + Status int `json:"status"` // 状态 + PushLoginUrlExpiredTime int `json:"pushLoginUrlexpiredTime"` // 推送登录url过期时间 + ExpiredTime int `json:"expiredTime"` // 过期时间(秒) + HeadImgUrl string `json:"headImgUrl"` // 头像 + NickName string `json:"nickName"` // 昵称 + AcctSectResp map[string]any `json:"acctSectResp"` // 账号信息-登录成功之后才有 +} + +// AwakenLoginResponse 唤醒登录响应 +type AwakenLoginResponse struct { + QrCodeResponse struct { + BaseResponse any `json:"BaseResponse"` + BlueToothBroadCastContent any `json:"BlueToothBroadCastContent"` + BlueToothBroadCastUuid string `json:"BlueToothBroadCastUuid"` + CheckTime int `json:"CheckTime"` + ExpiredTime int `json:"ExpiredTime"` + NotifyKey any `json:"NotifyKey"` + Uuid string `json:"Uuid"` + } `json:"QrCodeResponse"` +} + +// AutoHeartbeatResponse 心跳响应 +type AutoHeartbeatResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data struct{} +} + +// AutoHeartbeatStatusResponse 心跳状态响应 +type AutoHeartbeatStatusResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Running bool `json:"running"` +} + +// Sync +// @description: 同步微信消息返回值 +type Sync struct { + ModUserInfos any `json:"ModUserInfos"` + ModContacts any `json:"ModContacts"` + DelContacts any `json:"DelContacts"` + ModUserImgs any `json:"ModUserImgs"` + FunctionSwitchs any `json:"FunctionSwitchs"` + UserInfoExts any `json:"UserInfoExts"` + AddMsgs []Message `json:"AddMsgs"` + ContinueFlag int `json:"ContinueFlag"` + KeyBuf struct { + ILen int `json:"iLen"` + Buffer string `json:"buffer"` + } `json:"KeyBuf"` + Status int `json:"Status"` + Continue int `json:"Continue"` + Time int `json:"Time"` + UnknownCmdId string `json:"UnknownCmdId"` + Remarks string `json:"Remarks"` +} + +// Message +// @description: 微信消息 +type Message struct { + MsgId int `json:"MsgId"` + FromUserName struct { + String string `json:"string"` + } `json:"FromUserName"` + ToWxid struct { + String string `json:"string"` + } `json:"ToWxid"` + MsgType types.MessageType `json:"MsgType"` + Content struct { + String string `json:"string"` + } `json:"Content"` + Status int `json:"Status"` + ImgStatus int `json:"ImgStatus"` + ImgBuf struct { + ILen int `json:"iLen"` + } `json:"ImgBuf"` + CreateTime int `json:"CreateTime"` + MsgSource string `json:"MsgSource"` + NewMsgId int64 `json:"NewMsgId"` + MsgSeq int `json:"MsgSeq"` + PushContent string `json:"PushContent,omitempty"` +} diff --git a/internal/handler/api_login.go b/internal/handler/api_login.go index 6c58f10..36d31dc 100644 --- a/internal/handler/api_login.go +++ b/internal/handler/api_login.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "gitee.ltd/lxh/wechat-robot/internal/config" + "gitee.ltd/lxh/wechat-robot/internal/tasks" "github.com/gofiber/fiber/v2/log" "strconv" "time" @@ -82,7 +83,7 @@ func CheckQRCodeStatus(c *fiber.Ctx) error { // 开启自动心跳,传递容器访问地址 if robot.WechatID != "" { - _, _ = docker.AutoHeartbeatStart(ctx, robot.ContainerID, robot.WechatID, robot.ContainerHost) + _, _ = docker.AutoHeartbeatStart(ctx, robot.WechatID, robot.ContainerHost) } // 更新机器人状态 @@ -90,6 +91,9 @@ func CheckQRCodeStatus(c *fiber.Ctx) error { now := time.Now() robot.LastLoginAt = &now db.Save(&robot) + + // 添加定时任务 + tasks.AddJob(robot) } return c.JSON(fiber.Map{ diff --git a/internal/handler/contact.go b/internal/handler/contact.go index 8939dea..4397777 100644 --- a/internal/handler/contact.go +++ b/internal/handler/contact.go @@ -1,15 +1,10 @@ package handler import ( - "context" - "encoding/json" - "strconv" - "time" - "github.com/gofiber/fiber/v2" "gorm.io/gorm" + "strconv" - "gitee.ltd/lxh/wechat-robot/internal/docker" "gitee.ltd/lxh/wechat-robot/internal/model" ) @@ -32,56 +27,10 @@ func ListContacts(c *fiber.Ctx) error { // 获取联系人列表 var contacts []model.Contact - if err := db.Where("robot_id = ?", robotID).Find(&contacts).Error; err != nil { + if err = db.Where("robot_id = ?", robotID).Find(&contacts).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "获取联系人列表失败") } - // 如果机器人在线并且没有联系人,则从机器人获取最新联系人 - if robot.Status == model.RobotStatusOnline && len(contacts) == 0 { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // 调用Docker API获取联系人,传递容器访问地址 - contactsData, err := docker.GetWechatContacts(ctx, robot.ContainerID, robot.ContainerHost) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "从机器人获取联系人失败: "+err.Error()) - } - - // 解析联系人数据 - var response docker.APIResponse - if err := json.Unmarshal([]byte(contactsData), &response); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "解析联系人数据失败: "+err.Error()) - } - - // 保存联系人到数据库 - if response.Success { - contactList, ok := response.Data.([]interface{}) - if ok { - for _, c := range contactList { - contactMap, ok := c.(map[string]interface{}) - if !ok { - continue - } - - contact := model.Contact{ - RobotID: uint(robotID), - WechatID: contactMap["id"].(string), - Nickname: contactMap["name"].(string), - Avatar: contactMap["avatar"].(string), - Type: model.ContactTypeFriend, - } - - if contactMap["type"].(string) == "group" { - contact.Type = model.ContactTypeGroup - } - - db.Create(&contact) - contacts = append(contacts, contact) - } - } - } - } - return c.Render("contact/index", fiber.Map{ "Title": "联系人列表", "Robot": robot, @@ -169,59 +118,10 @@ func ListGroupMembers(c *fiber.Ctx) error { // 获取群成员 var members []model.GroupMember - if err := db.Where("group_id = ?", contactID).Find(&members).Error; err != nil { + if err = db.Where("group_id = ?", contactID).Find(&members).Error; err != nil { return fiber.NewError(fiber.StatusInternalServerError, "获取群成员失败") } - // 如果机器人在线并且没有群成员,则从机器人获取 - if robot.Status == model.RobotStatusOnline && len(members) == 0 { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // 调用Docker API获取群成员,传递容器访问地址 - membersData, err := docker.GetWechatGroupMembers(ctx, robot.ContainerID, contact.WechatID, robot.ContainerHost) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "从机器人获取群成员失败: "+err.Error()) - } - - // 解析群成员数据 - var response docker.APIResponse - if err := json.Unmarshal([]byte(membersData), &response); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "解析群成员数据失败: "+err.Error()) - } - - // 保存群成员到数据库 - if response.Success { - memberList, ok := response.Data.([]interface{}) - if ok { - for _, m := range memberList { - memberMap, ok := m.(map[string]interface{}) - if !ok { - continue - } - - role := model.MemberRoleMember - if memberMap["role"].(string) == "owner" { - role = model.MemberRoleOwner - } else if memberMap["role"].(string) == "admin" { - role = model.MemberRoleAdmin - } - - member := model.GroupMember{ - GroupID: contact.ID, - WechatID: memberMap["id"].(string), - Nickname: memberMap["name"].(string), - Avatar: memberMap["avatar"].(string), - Role: role, - } - - db.Create(&member) - members = append(members, member) - } - } - } - } - return c.Render("contact/members", fiber.Map{ "Title": contact.Nickname + " 群成员", "Robot": robot, diff --git a/internal/handler/message.go b/internal/handler/message.go index 0f25c69..969a87e 100644 --- a/internal/handler/message.go +++ b/internal/handler/message.go @@ -1,15 +1,11 @@ package handler import ( - "context" - "encoding/json" - "strconv" - "time" - + "errors" "github.com/gofiber/fiber/v2" "gorm.io/gorm" + "strconv" - "gitee.ltd/lxh/wechat-robot/internal/docker" "gitee.ltd/lxh/wechat-robot/internal/model" ) @@ -33,16 +29,16 @@ func ListMessages(c *fiber.Ctx) error { db := model.GetDB() var robot model.Robot - if err := db.First(&robot, robotID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + if err = db.First(&robot, robotID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "机器人不存在") } return fiber.NewError(fiber.StatusInternalServerError, "查询机器人失败") } var contact model.Contact - if err := db.First(&contact, contactID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + if err = db.First(&contact, contactID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { return fiber.NewError(fiber.StatusNotFound, "联系人不存在") } return fiber.NewError(fiber.StatusInternalServerError, "查询联系人失败") @@ -50,7 +46,7 @@ func ListMessages(c *fiber.Ctx) error { // 获取消息 var messages []model.Message - if err := db.Where("robot_id = ? AND contact_id = ?", robotID, contactID). + if err = db.Where("robot_id = ? AND contact_id = ?", robotID, contactID). Order("send_time DESC"). Offset(offset).Limit(limit). Find(&messages).Error; err != nil { @@ -61,72 +57,6 @@ func ListMessages(c *fiber.Ctx) error { var total int64 db.Model(&model.Message{}).Where("robot_id = ? AND contact_id = ?", robotID, contactID).Count(&total) - // 如果机器人在线并且没有消息,则从机器人获取最新消息 - if robot.Status == model.RobotStatusOnline && len(messages) == 0 { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // 调用Docker API获取消息,传递容器访问地址 - messagesData, err := docker.GetWechatMessages(ctx, robot.ContainerID, contact.WechatID, 50, robot.ContainerHost) - if err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "从机器人获取消息失败: "+err.Error()) - } - - // 解析消息数据 - var response docker.APIResponse - if err := json.Unmarshal([]byte(messagesData), &response); err != nil { - return fiber.NewError(fiber.StatusInternalServerError, "解析消息数据失败: "+err.Error()) - } - - // 保存消息到数据库 - if response.Success { - messageList, ok := response.Data.([]interface{}) - if ok { - for _, m := range messageList { - msgMap, ok := m.(map[string]interface{}) - if !ok { - continue - } - - contentType := model.ContentTypeText - switch msgMap["type"].(string) { - case "image": - contentType = model.ContentTypeImage - case "voice": - contentType = model.ContentTypeVoice - case "video": - contentType = model.ContentTypeVideo - case "file": - contentType = model.ContentTypeFile - case "location": - contentType = model.ContentTypeLocation - } - - sendTimeStr := msgMap["time"].(string) - sendTime, _ := time.Parse(time.RFC3339, sendTimeStr) - - message := model.Message{ - RobotID: uint(robotID), - ContactID: uint(contactID), - SenderID: msgMap["sender_id"].(string), - SenderName: msgMap["sender_name"].(string), - Content: msgMap["content"].(string), - ContentType: contentType, - SendTime: sendTime, - IsFromMe: msgMap["from_me"].(bool), - } - - if mediaURL, ok := msgMap["media_url"].(string); ok { - message.MediaURL = mediaURL - } - - db.Create(&message) - messages = append(messages, message) - } - } - } - } - // 计算总页数 totalPages := int(total) / limit if int(total)%limit > 0 { diff --git a/internal/handler/robot.go b/internal/handler/robot.go index 86491f5..5623dde 100644 --- a/internal/handler/robot.go +++ b/internal/handler/robot.go @@ -218,7 +218,7 @@ func RobotLogin(c *fiber.Ctx) error { defer cancel() // 使用新的GetQRCode接口获取二维码,并传递容器访问地址 - qrcodeResp, err := docker.GetQRCode(ctx, robot.ContainerID, robot.ContainerHost) + qrcodeResp, err := docker.GetQRCode(ctx, robot.ContainerHost) if err != nil { return fiber.NewError(fiber.StatusInternalServerError, "获取登录二维码失败: "+err.Error()) } @@ -256,7 +256,7 @@ func RobotLogout(c *fiber.Ctx) error { // 使用新的LogOut API接口,传递容器访问地址 if robot.WechatID != "" { - if _, err = docker.LogOut(ctx, robot.ContainerID, robot.WechatID, robot.ContainerHost); err != nil { + if _, err = docker.LogOut(ctx, robot.WechatID, robot.ContainerHost); err != nil { return fiber.NewError(fiber.StatusInternalServerError, "登出微信失败: "+err.Error()) } } else { diff --git a/internal/model/message.go b/internal/model/message.go index 4aa94f4..e39f496 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -1,6 +1,7 @@ package model import ( + "gitee.ltd/lxh/wechat-robot/internal/types" "time" ) @@ -20,15 +21,16 @@ const ( // Message 表示微信消息 type Message struct { BaseModel - RobotID uint `gorm:"column:robot_id;index" json:"robot_id"` - ContactID uint `gorm:"column:contact_id;index" json:"contact_id"` - SenderID string `gorm:"column:sender_id;index:idx_sender_id,length:64" json:"sender_id"` // 添加索引长度 - SenderName string `gorm:"column:sender_name" json:"sender_name"` // 发送者昵称 - Content string `gorm:"column:content;type:text" json:"content"` - ContentType ContentType `gorm:"column:content_type" json:"content_type"` - MediaURL string `gorm:"column:media_url" json:"media_url"` // 媒体文件URL - SendTime time.Time `gorm:"column:send_time;index" json:"send_time"` - IsFromMe bool `gorm:"column:is_from_me" json:"is_from_me"` // 是否由机器人发送 + RobotId uint // 机器人Id + MsgId int64 // 消息Id + CreateTime int // 发送时间戳 + CreateAt time.Time // 发送时间 + Type types.MessageType // 消息类型 + Content string // 内容 + DisplayFullContent string // 显示的完整内容 + FromUser string // 发送者 + GroupUser string // 群成员 + ToUser string // 接收者 } // TableName 指定表名 diff --git a/internal/repository/README.md b/internal/repository/README.md deleted file mode 100644 index 9e9e49b..0000000 --- a/internal/repository/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# 数据访问层 - -此目录包含与数据库交互的代码: -- 数据库CRUD操作 -- 查询构建 -- 数据持久化逻辑 diff --git a/internal/service/README.md b/internal/service/README.md deleted file mode 100644 index f6c7e9c..0000000 --- a/internal/service/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# 业务逻辑层 - -此目录包含应用程序的核心业务逻辑: -- 微信机器人管理 -- 聊天记录处理 -- 联系人管理 diff --git a/internal/tasks/sync.go b/internal/tasks/sync.go new file mode 100644 index 0000000..f70224a --- /dev/null +++ b/internal/tasks/sync.go @@ -0,0 +1,72 @@ +package tasks + +import ( + "context" + "gitee.ltd/lxh/wechat-robot/internal/docker" + "gitee.ltd/lxh/wechat-robot/internal/model" + "gitee.ltd/lxh/wechat-robot/internal/types" + "strings" + "time" +) + +// syncMessage +// @description: 同步指定机器人的消息 +// @param containerHost 机器人接口地址 +// @param robotWxId 机器人微信Id +// @param robotId 机器人数据Id +func syncMessage(containerHost, robotWxId string, robotId uint) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + messages, isOffline, err := docker.GetWechatMessages(ctx, containerHost, robotWxId) + if err != nil { + // 处理错误 + return + } + db := model.GetDB() + if isOffline { + // 删除定时任务 + DeleteJob(robotId) + // 修改机器人状态 + db.Model(&model.Robot{}).Where("id = ?", robotId).Update("status", "offline") + return + } + + if len(messages) == 0 { + // 没有消息,直接返回 + return + } + + // 处理消息 + var msg []model.Message + for _, message := range messages { + var m model.Message + m.RobotId = robotId + m.MsgId = message.NewMsgId + m.CreateTime = message.CreateTime + m.CreateAt = time.Unix(int64(message.CreateTime), 0) + m.Type = message.MsgType + m.Content = message.Content.String + m.DisplayFullContent = message.PushContent + m.FromUser = message.FromUserName.String + m.ToUser = message.ToWxid.String + + // 如果是群聊消息,单独处理一下 + // Sys类型的消息正文不包含微信 Id,所以不需要处理 + if strings.HasSuffix(message.FromUserName.String, "@chatroom") && message.MsgType != types.MsgTypeSys { + // 群消息,处理一下消息和发信人 + groupUser := strings.Split(m.Content, "\n")[0] + groupUser = strings.ReplaceAll(groupUser, ":", "") + // 如果两个id一致,说明是系统发的 + if m.FromUser != groupUser { + m.GroupUser = groupUser + } + // 用户的操作单独提出来处理一下 + m.Content = strings.Join(strings.Split(m.Content, "\n")[1:], "\n") + } + + msg = append(msg, m) + } + // 保存入库 + db.Save(&msg) +} diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go new file mode 100644 index 0000000..0047d6d --- /dev/null +++ b/internal/tasks/tasks.go @@ -0,0 +1,78 @@ +package tasks + +import ( + "gitee.ltd/lxh/wechat-robot/internal/model" + "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" + "log" + "sync" +) + +// 定时任务调度器 +var scheduler gocron.Scheduler + +// 已启动定时任务的微信机器人 +var enabledMap = sync.Map{} + +// Start +// @description: 启动任务 +func Start() { + var err error + scheduler, err = gocron.NewScheduler() + if err != nil { + log.Panicf("定时任务启动失败: %v", err) + } + + // 预添加已经确定的任务 + db := model.GetDB() + // 查询数据库,获取在线的机器人 + var robots []model.Robot + if err = db.Where("`status` = 'online'").Find(&robots).Error; err != nil { + log.Panicf("查询机器人失败: %v", err) + } + // 遍历机器人,添加任务 + for _, robot := range robots { + var job gocron.Job + job, err = scheduler.NewJob( + gocron.CronJob("*/5 * * * * *", true), // 五秒钟同步一次 + gocron.NewTask(syncMessage, robot.ContainerHost, robot.WechatID, robot.ID), + ) + if err != nil { + log.Panicf("添加定时任务失败: %v", err) + } + // 添加到已启动的任务列表 + enabledMap.Store(robot.ID, job.ID()) + } + + // 启动定时任务 + scheduler.Start() + log.Println("定时任务已启动") +} + +// AddJob +// @description: 添加任务 +func AddJob(robot model.Robot) { + job, err := scheduler.NewJob( + gocron.CronJob("*/5 * * * * *", true), // 五秒钟同步一次 + gocron.NewTask(syncMessage, robot.ContainerHost, robot.WechatID, robot.ID), + ) + if err != nil { + log.Printf("添加定时任务失败: %v", err) + return + } + // 添加到已启动的任务列表 + enabledMap.Store(robot.ID, job.ID()) +} + +// DeleteJob +// @description: 删除定时任务 +// @param robotId +func DeleteJob(robotId uint) { + // 先取出任务Id + jobId, ok := enabledMap.Load(robotId) + if !ok { + log.Printf("定时任务不存在,robotId: %d", robotId) + return + } + _ = scheduler.RemoveJob(jobId.(uuid.UUID)) +} diff --git a/internal/types/message.go b/internal/types/message.go new file mode 100644 index 0000000..b0eb39c --- /dev/null +++ b/internal/types/message.go @@ -0,0 +1,51 @@ +package types + +import "fmt" + +type MessageType int + +// 微信定义的消息类型 +const ( + MsgTypeText MessageType = 1 // 文本消息 + MsgTypeImage MessageType = 3 // 图片消息 + MsgTypeVoice MessageType = 34 // 语音消息 + MsgTypeVerify MessageType = 37 // 认证消息 + MsgTypePossibleFriend MessageType = 40 // 好友推荐消息 + MsgTypeShareCard MessageType = 42 // 名片消息 + MsgTypeVideo MessageType = 43 // 视频消息 + MsgTypeEmoticon MessageType = 47 // 表情消息 + MsgTypeLocation MessageType = 48 // 地理位置消息 + MsgTypeApp MessageType = 49 // APP消息 + MsgTypeVoip MessageType = 50 // VOIP消息 + MsgTypeVoipNotify MessageType = 52 // VOIP结束消息 + MsgTypeVoipInvite MessageType = 53 // VOIP邀请 + MsgTypeMicroVideo MessageType = 62 // 小视频消息 + MsgTypeSys MessageType = 10000 // 系统消息 + MsgTypeRecalled MessageType = 10002 // 消息撤回 +) + +var MessageTypeMap = map[MessageType]string{ + MsgTypeText: "文本消息", + MsgTypeImage: "图片消息", + MsgTypeVoice: "语音消息", + MsgTypeVerify: "认证消息", + MsgTypePossibleFriend: "好友推荐消息", + MsgTypeShareCard: "名片消息", + MsgTypeVideo: "视频消息", + MsgTypeEmoticon: "表情消息", + MsgTypeLocation: "地理位置消息", + MsgTypeApp: "APP消息", + MsgTypeVoip: "VOIP消息", + MsgTypeVoipNotify: "VOIP结束消息", + MsgTypeVoipInvite: "VOIP邀请", + MsgTypeMicroVideo: "小视频消息", + MsgTypeSys: "系统消息", + MsgTypeRecalled: "消息撤回", +} + +func (mt MessageType) String() string { + if msg, ok := MessageTypeMap[mt]; ok { + return msg + } + return fmt.Sprintf("未知消息类型(%d)", mt) +} diff --git a/internal/wechat/README.md b/internal/wechat/README.md deleted file mode 100644 index f263414..0000000 --- a/internal/wechat/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# 微信API客户端 - -此目录包含与微信API交互的代码: -- 登录处理 -- 消息发送接收 -- 联系人管理 -- 群组管理 diff --git a/main.go b/main.go index e1dfaba..07f99d1 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "gitee.ltd/lxh/wechat-robot/internal/tasks" "log" "os" "os/signal" @@ -60,6 +61,9 @@ func main() { log.Println("Server started successfully") + // 启动定时任务 + tasks.Start() + // 优雅关闭 quit := make(chan os.Signal, 1) signal.Notify(quit, os.Interrupt, syscall.SIGTERM)