mirror of
https://github.com/yincongcyincong/wechat_chatter.git
synced 2026-07-15 10:26:52 +08:00
408 lines
12 KiB
Go
408 lines
12 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/aes"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"encoding/xml"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime/debug"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
func SendWorker() {
|
|
defer func() {
|
|
if err := recover(); err != nil {
|
|
Error("SendWorker panic", "err", err, "stack", string(debug.Stack()))
|
|
go SendWorker()
|
|
}
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case m, ok := <-msgChan:
|
|
if !ok {
|
|
Fatal("发送通道关闭")
|
|
return
|
|
}
|
|
SendWechatMsg(m)
|
|
}
|
|
}
|
|
}
|
|
|
|
func SendWechatMsg(m *SendMsg) {
|
|
var sendErr error
|
|
defer func() {
|
|
if m.ResultChan != nil {
|
|
m.ResultChan <- sendErr
|
|
}
|
|
}()
|
|
|
|
time.Sleep(time.Duration(config.SendInterval) * time.Millisecond)
|
|
currTaskId := atomic.AddInt64(&taskId, 1)
|
|
Info("📩 收到任务", "task_id", currTaskId, "type", m.Type)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
targetId := m.UserId
|
|
if m.GroupID != "" {
|
|
targetId = m.GroupID
|
|
}
|
|
|
|
if targetId == "" {
|
|
Error("目标为空", "task_id", currTaskId, "target_id", targetId)
|
|
sendErr = errors.New("target is empty")
|
|
return
|
|
}
|
|
|
|
switch m.Type {
|
|
case "text":
|
|
protoHex, err := BuildTextMsgProto(targetId, m.Content, m.AtUser)
|
|
if err != nil {
|
|
Error("构建文本protobuf失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
payloadHex := BuildSendPayload(currTaskId, "text")
|
|
result := fridaScript.ExportsCall("triggerSendTextMessage", currTaskId, targetId, m.Content, m.AtUser, protoHex, payloadHex)
|
|
Info("📩 发送文本任务执行结果", "result", result, "task_id", currTaskId, "target_id", targetId, "at_user", m.AtUser)
|
|
if result != "1" {
|
|
Error("发送文本失败", "task_id", currTaskId, "target_id", targetId, "result", result)
|
|
sendErr = errors.New("send text failed")
|
|
return
|
|
}
|
|
case "image":
|
|
targetPath, md5Str, err := SaveBase64Image(m.Content)
|
|
if err != nil {
|
|
Error("保存图片失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
|
|
uploadPayloadHex := BuildUploadPayload("img")
|
|
result := fridaScript.ExportsCall("triggerUploadImg", targetId, md5Str, targetPath, uploadPayloadHex)
|
|
Info("📩 上传图片任务执行结果", "result", result, "target_id", targetId, "md5", md5Str, "path", targetPath)
|
|
if result != "0" {
|
|
Error("上传图片失败", "target_id", targetId, "md5", md5Str, "result", result)
|
|
sendErr = errors.New("upload image failed")
|
|
return
|
|
}
|
|
if m.ResultChan != nil {
|
|
pendingResultMap.Store(targetId, m.ResultChan)
|
|
m.ResultChan = nil // 不让 defer 发送结果
|
|
}
|
|
return
|
|
case "send_image":
|
|
protoHex, err := BuildImgMsgProto(myWechatId, targetId, m.CdnKey, m.AesKey, m.Md5Key)
|
|
if err != nil {
|
|
Error("构建图片protobuf失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
payloadHex := BuildSendPayload(currTaskId, "img")
|
|
result := fridaScript.ExportsCall("triggerSendImgMessage", currTaskId, myWechatId, targetId, protoHex, payloadHex)
|
|
Info("📩 发送图片任务执行结果", "result", result, "task_id", currTaskId, "wechat_id", myWechatId, "target_id", targetId)
|
|
if result != "1" {
|
|
Error("发送图片失败", "task_id", currTaskId, "target_id", targetId, "result", result)
|
|
sendErr = errors.New("send image failed")
|
|
return
|
|
}
|
|
case "video":
|
|
targetPath, md5Str, err := SaveBase64Image(m.Content)
|
|
if err != nil {
|
|
Error("保存图片失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
|
|
// 获取视频时长和文件大小
|
|
info := &VideoInfo{}
|
|
duration, err := GetVideoDuration(targetPath)
|
|
if err != nil {
|
|
Error("获取视频时长失败", "err", err)
|
|
} else {
|
|
info.Duration = duration
|
|
}
|
|
if fi, err := os.Stat(targetPath); err == nil {
|
|
info.VideoSize = int32(fi.Size())
|
|
}
|
|
videoInfoMap.Store(targetId, info)
|
|
|
|
uploadPayloadHex := BuildUploadPayload("video")
|
|
result := fridaScript.ExportsCall("triggerUploadVideo", targetId, md5Str, targetPath, uploadPayloadHex)
|
|
Info("📩 上传视频任务执行结果", "result", result, "target_id", targetId, "md5", md5Str, "path", targetPath, "duration", info.Duration, "size", info.VideoSize)
|
|
if result != "0" {
|
|
Error("上传视频失败", "target_id", targetId, "md5", md5Str, "result", result)
|
|
sendErr = errors.New("upload video failed")
|
|
return
|
|
}
|
|
if m.ResultChan != nil {
|
|
pendingResultMap.Store(targetId, m.ResultChan)
|
|
m.ResultChan = nil
|
|
}
|
|
return
|
|
case "send_video":
|
|
var duration, videoSize int32
|
|
if info, ok := videoInfoMap.LoadAndDelete(targetId); ok {
|
|
vi := info.(*VideoInfo)
|
|
duration = vi.Duration
|
|
videoSize = vi.VideoSize
|
|
}
|
|
protoHex, err := BuildVideoMsgProto(myWechatId, targetId, m.CdnKey, m.AesKey, m.Md5Key, m.VideoId, duration, videoSize)
|
|
if err != nil {
|
|
Error("构建视频protobuf失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
payloadHex := BuildSendPayload(currTaskId, "video")
|
|
result := fridaScript.ExportsCall("triggerSendVideoMessage", currTaskId, myWechatId, targetId, protoHex, payloadHex)
|
|
Info("📩 发送视频任务执行结果", "result", result, "task_id", currTaskId, "wechat_id", myWechatId, "target_id", targetId, "duration", duration, "size", videoSize)
|
|
if result != "1" {
|
|
Error("发送视频失败", "task_id", currTaskId, "target_id", targetId, "result", result)
|
|
sendErr = errors.New("send video failed")
|
|
return
|
|
}
|
|
case "download":
|
|
result := fridaScript.ExportsCall("triggerDownload", targetId, m.FIleCdnUrl, m.AesKey, m.FilePath, m.FileType)
|
|
Info("📩 下载任务执行结果", "result", result, "task_id", currTaskId, "wechat_id", myWechatId, "target_id", targetId)
|
|
case "reply":
|
|
replyInfo := &ReplyInfo{
|
|
Content: m.Content,
|
|
MsgId: m.ReferMsgId,
|
|
MsgSender: m.ReferMsgSender,
|
|
MsgType: m.ReferMsgType,
|
|
CreateTime: m.ReferCreateTime,
|
|
Msgsource: m.ReferMsgsource,
|
|
DisplayName: m.ReferDisplayName,
|
|
MsgContent: m.ReferContent,
|
|
}
|
|
protoHex, err := BuildReplyMsgProto(myWechatId, targetId, replyInfo)
|
|
if err != nil {
|
|
Error("构建回复protobuf失败", "err", err)
|
|
sendErr = err
|
|
return
|
|
}
|
|
payloadHex := BuildSendPayload(currTaskId, "reply")
|
|
result := fridaScript.ExportsCall("triggerSendReplyMessage", currTaskId, myWechatId, targetId, protoHex, payloadHex)
|
|
Info("📩 发送回复任务执行结果", "result", result, "task_id", currTaskId, "wechat_id", myWechatId, "target_id", targetId)
|
|
if result != "1" {
|
|
Error("发送回复失败", "task_id", currTaskId, "target_id", targetId, "result", result)
|
|
sendErr = errors.New("send reply failed")
|
|
return
|
|
}
|
|
}
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
Error("任务执行超时!", "taskId", currTaskId)
|
|
sendErr = errors.New("send timeout")
|
|
case resp := <-buf2RespChan:
|
|
if resp.Err != nil {
|
|
Error("收到buf2resp失败信号", "taskId", currTaskId, "msg_type", resp.MsgType, "err", resp.Err)
|
|
sendErr = resp.Err
|
|
return
|
|
}
|
|
Info("收到buf2resp完成信号,任务完成", "taskId", currTaskId, "msg_type", resp.MsgType, "data_len", len(resp.Data))
|
|
}
|
|
}
|
|
|
|
func HandleMsg(jsonData []byte) ([]byte, error) {
|
|
m := new(WechatMessage)
|
|
err := json.Unmarshal(jsonData, m)
|
|
if err != nil {
|
|
Error("解析消息失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
if myWechatId == "" && m.SelfID != "" {
|
|
myWechatId = m.SelfID
|
|
}
|
|
if m.GroupId != "" {
|
|
userID2NicknameMap.Store(m.GroupId+"_"+m.UserID, m.Sender.Nickname)
|
|
}
|
|
|
|
for _, msg := range m.Message {
|
|
switch msg.Type {
|
|
case "record":
|
|
path, err := SaveAudioFile(msg.Data.Media)
|
|
if err != nil {
|
|
Error("保存音频失败", "err", err)
|
|
return nil, err
|
|
}
|
|
msg.Data.URL = "file://" + path
|
|
msg.Data.Media = nil
|
|
case "image":
|
|
var fileMsg FileMsg
|
|
err = xml.Unmarshal([]byte(msg.Data.Text), &fileMsg)
|
|
if err != nil {
|
|
Error("XML解析失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
path, err := GetDownloadPath(fileMsg.Image.MidImgURL, fileMsg.Image.AesKey)
|
|
if err != nil {
|
|
Error("获取文件路径失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
msg.Data.URL = "file://" + path
|
|
|
|
case "file":
|
|
var fileMsg FileMsg
|
|
err = xml.Unmarshal([]byte(msg.Data.Text), &fileMsg)
|
|
if err != nil {
|
|
Error("XML解析失败", "err", err)
|
|
return nil, err
|
|
}
|
|
path, err := GetDownloadPath(fileMsg.AppMsg.AppAttach.CdnAttachURL, fileMsg.AppMsg.AppAttach.AesKey)
|
|
if err != nil {
|
|
Error("获取文件路径失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
msg.Data.URL = "file://" + path
|
|
case "video":
|
|
var fileMsg FileMsg
|
|
err = xml.Unmarshal([]byte(msg.Data.Text), &fileMsg)
|
|
if err != nil {
|
|
Error("XML解析失败", "err", err)
|
|
return nil, err
|
|
}
|
|
path, err := GetDownloadPath(fileMsg.Video.CdnVideoUrl, fileMsg.Video.AesKey)
|
|
if err != nil {
|
|
Error("获取文件路径失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
msg.Data.URL = "file://" + path
|
|
case "face":
|
|
var fileMsg FileMsg
|
|
err = xml.Unmarshal([]byte(msg.Data.Text), &fileMsg)
|
|
if err != nil {
|
|
Error("XML解析失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
// 优先cdnurl,为空则用thumburl,再为空则用externurl
|
|
emojiUrl := fileMsg.Emoji.CdnUrl
|
|
if emojiUrl == "" {
|
|
emojiUrl = fileMsg.Emoji.ThumbUrl
|
|
}
|
|
if emojiUrl == "" {
|
|
emojiUrl = fileMsg.Emoji.ExternUrl
|
|
}
|
|
|
|
data, err := DownloadFile(emojiUrl)
|
|
if err != nil {
|
|
Error("下载表情失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
path, err := DetectAndSaveImage(data)
|
|
if err != nil {
|
|
Error("保存表情失败", "err", err)
|
|
return nil, err
|
|
}
|
|
|
|
msg.Data.URL = "file://" + path
|
|
}
|
|
}
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
func GetDownloadPath(cdnUrl, aesKeyStr string) (string, error) {
|
|
for i := 0; i < 10; i++ {
|
|
if downloadMsgInter, ok := userID2FileMsgMap.Load(cdnUrl); ok {
|
|
downloadReq := downloadMsgInter.(*DownloadRequest)
|
|
if downloadReq.FilePath != "" {
|
|
return downloadReq.FilePath, nil
|
|
}
|
|
|
|
// 检查数据是否还在接收中
|
|
timeSinceLastAppend := time.Now().UnixMilli() - downloadReq.LastAppendTime
|
|
Info("文件等待下载", "url", cdnUrl, "times", i, "last_append_time", timeSinceLastAppend)
|
|
|
|
// 如果数据仍在接收中(1秒内有新数据),继续等待
|
|
if timeSinceLastAppend < 1000 && i < 9 {
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
|
|
// 数据接收完成,尝试解密
|
|
if len(downloadReq.Media) > 0 {
|
|
if len(downloadReq.Media)%aes.BlockSize != 0 {
|
|
Info("文件数据仍未对齐 AES 块,继续等待", "url", cdnUrl, "times", i, "media_len", len(downloadReq.Media), "block_size", aes.BlockSize)
|
|
if i < 9 {
|
|
time.Sleep(2 * time.Second)
|
|
continue
|
|
}
|
|
userID2FileMsgMap.Delete(cdnUrl)
|
|
return "", fmt.Errorf("文件数据长度不是 AES 块大小倍数: media_len=%d block_size=%d", len(downloadReq.Media), aes.BlockSize)
|
|
}
|
|
|
|
aesKey, err := hex.DecodeString(aesKeyStr)
|
|
if err != nil {
|
|
Error("AES key 解码失败", "err", err)
|
|
return "", err
|
|
}
|
|
filePath, err := GetFilePath(downloadReq.Media, aesKey)
|
|
if err != nil {
|
|
Error("获取文件路径失败", "err", err, "media_len", len(downloadReq.Media))
|
|
userID2FileMsgMap.Delete(cdnUrl)
|
|
return "", err
|
|
}
|
|
|
|
downloadReq.FilePath = filePath
|
|
downloadReq.Media = nil
|
|
return filePath, nil
|
|
}
|
|
}
|
|
|
|
time.Sleep(2 * time.Second)
|
|
}
|
|
|
|
return "", errors.New("文件下载超时或数据为空")
|
|
}
|
|
|
|
// HandleBuf2Resp 处理所有消息类型的buf2resp响应
|
|
func HandleBuf2Resp(msgType string, data []byte) {
|
|
Info("收到buf2resp响应", "msg_type", msgType, "data_len", len(data))
|
|
|
|
if len(data) == 0 {
|
|
Error("buf2resp响应数据为空", "msg_type", msgType)
|
|
buf2RespChan <- &Buf2RespData{
|
|
MsgType: msgType,
|
|
Data: data,
|
|
Err: errors.New("response data is empty"),
|
|
}
|
|
return
|
|
}
|
|
|
|
ret, errMsg, err := ParseSendMsgResponse(data)
|
|
if err != nil {
|
|
Info("buf2resp响应无法提取错误码,视为成功", "msg_type", msgType, "err", err)
|
|
}
|
|
|
|
// 判断错误码是否为0
|
|
if ret != 0 {
|
|
Error("buf2resp响应错误", "msg_type", msgType, "ret", ret, "errMsg", errMsg)
|
|
buf2RespChan <- &Buf2RespData{
|
|
MsgType: msgType,
|
|
Data: data,
|
|
Err: fmt.Errorf("response error, ret=%d, errMsg=%s", ret, errMsg),
|
|
}
|
|
return
|
|
}
|
|
|
|
Info("buf2resp响应成功", "msg_type", msgType)
|
|
buf2RespChan <- &Buf2RespData{
|
|
MsgType: msgType,
|
|
Data: data,
|
|
}
|
|
}
|