🎨 添加定时任务调度,优化消息同步逻辑,更新相关API接口调用
All checks were successful
BuildImage / build-image (push) Successful in 2m2s

This commit is contained in:
李寻欢 2025-04-07 16:08:55 +08:00
parent 2f6b3fac01
commit 080fdb66ab
16 changed files with 385 additions and 290 deletions

3
go.mod
View File

@ -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

8
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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"`
}

View File

@ -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{

View File

@ -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,

View File

@ -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 {

View File

@ -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 {

View File

@ -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 指定表名

View File

@ -1,6 +0,0 @@
# 数据访问层
此目录包含与数据库交互的代码:
- 数据库CRUD操作
- 查询构建
- 数据持久化逻辑

View File

@ -1,6 +0,0 @@
# 业务逻辑层
此目录包含应用程序的核心业务逻辑:
- 微信机器人管理
- 聊天记录处理
- 联系人管理

72
internal/tasks/sync.go Normal file
View File

@ -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)
}

78
internal/tasks/tasks.go Normal file
View File

@ -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))
}

51
internal/types/message.go Normal file
View File

@ -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)
}

View File

@ -1,7 +0,0 @@
# 微信API客户端
此目录包含与微信API交互的代码
- 登录处理
- 消息发送接收
- 联系人管理
- 群组管理

View File

@ -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)