package coolq import ( "bytes" "crypto/md5" "encoding/hex" "errors" "fmt" "io" "math/rand" "net/url" "os" "path" "runtime" "strconv" "strings" "time" "github.com/Mrs4s/MiraiGo/binary" "github.com/Mrs4s/MiraiGo/message" "github.com/Mrs4s/MiraiGo/utils" b14 "github.com/fumiama/go-base16384" "github.com/segmentio/asm/base64" log "github.com/sirupsen/logrus" "github.com/tidwall/gjson" "github.com/Mrs4s/go-cqhttp/db" "github.com/Mrs4s/go-cqhttp/global" "github.com/Mrs4s/go-cqhttp/internal/base" "github.com/Mrs4s/go-cqhttp/internal/cache" "github.com/Mrs4s/go-cqhttp/internal/download" "github.com/Mrs4s/go-cqhttp/internal/mime" "github.com/Mrs4s/go-cqhttp/internal/msg" "github.com/Mrs4s/go-cqhttp/internal/param" "github.com/Mrs4s/go-cqhttp/pkg/onebot" ) // TODO: move this file to internal/msg, internal/onebot // TODO: support OneBot V12 const ( maxImageSize = 1024 * 1024 * 30 // 30MB maxVideoSize = 1024 * 1024 * 100 // 100MB ) func replyID(r *message.ReplyElement, source message.Source) int32 { id := source.PrimaryID seq := r.ReplySeq if r.GroupID != 0 { id = r.GroupID } // 私聊时,部分(不确定)的账号会在 ReplyElement 中带有 GroupID 字段。 // 这里需要判断是由于 “直接回复” 功能,GroupID 为触发直接回复的来源那个群。 if source.SourceType == message.SourcePrivate && (r.Sender == source.PrimaryID || r.GroupID == source.PrimaryID) { // 私聊似乎腾讯服务器有bug? seq = int32(uint16(seq)) id = r.Sender } return db.ToGlobalID(id, seq) } // toElements 将消息元素数组转为MSG数组以用于消息上报 // // nolint:govet func toElements(e []message.IMessageElement, source message.Source) (r []msg.Element) { // TODO: support OneBot V12 type pair = msg.Pair // simplify code type pairs = []pair r = make([]msg.Element, 0, len(e)) m := &message.SendingMessage{Elements: e} reply := m.FirstOrNil(func(e message.IMessageElement) bool { _, ok := e.(*message.ReplyElement) return ok }) if reply != nil && source.SourceType&(message.SourceGroup|message.SourcePrivate) != 0 { replyElem := reply.(*message.ReplyElement) id := replyID(replyElem, source) elem := msg.Element{ Type: "reply", Data: pairs{ {K: "id", V: strconv.FormatInt(int64(id), 10)}, }, } if base.ExtraReplyData { elem.Data = append(elem.Data, pair{K: "seq", V: strconv.FormatInt(int64(replyElem.ReplySeq), 10)}, pair{K: "qq", V: strconv.FormatInt(replyElem.Sender, 10)}, pair{K: "time", V: strconv.FormatInt(int64(replyElem.Time), 10)}, pair{K: "text", V: toStringMessage(replyElem.Elements, source)}, ) } r = append(r, elem) } for i, elem := range e { var m msg.Element switch o := elem.(type) { case *message.ReplyElement: if base.RemoveReplyAt && i+1 < len(e) { elem, ok := e[i+1].(*message.AtElement) if ok && elem.Target == o.Sender { e[i+1] = nil } } continue case *message.TextElement: m = msg.Element{ Type: "text", Data: pairs{ {K: "text", V: o.Content}, }, } case *message.LightAppElement: m = msg.Element{ Type: "json", Data: pairs{ {K: "data", V: o.Content}, }, } case *message.AtElement: target := "all" if o.Target != 0 { target = strconv.FormatUint(uint64(o.Target), 10) } m = msg.Element{ Type: "at", Data: pairs{ {K: "qq", V: target}, }, } case *message.RedBagElement: m = msg.Element{ Type: "redbag", Data: pairs{ {K: "title", V: o.Title}, }, } case *message.ForwardElement: m = msg.Element{ Type: "forward", Data: pairs{ {K: "id", V: o.ResId}, }, } case *message.FaceElement: m = msg.Element{ Type: "face", Data: pairs{ {K: "id", V: strconv.FormatInt(int64(o.Index), 10)}, }, } case *message.VoiceElement: m = msg.Element{ Type: "record", Data: pairs{ {K: "file", V: o.Name}, {K: "url", V: o.Url}, }, } case *message.ShortVideoElement: m = msg.Element{ Type: "video", Data: pairs{ {K: "file", V: o.Name}, {K: "url", V: o.Url}, }, } case *message.GroupImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "subType", V: strconv.FormatInt(int64(o.ImageBizType), 10)}, {K: "url", V: o.Url}, } switch { case o.Flash: data = append(data, pair{K: "type", V: "flash"}) case o.EffectID != 0: data = append(data, pair{K: "type", V: "show"}) data = append(data, pair{K: "id", V: strconv.FormatInt(int64(o.EffectID), 10)}) } m = msg.Element{ Type: "image", Data: data, } case *message.GuildImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "url", V: o.Url}, } m = msg.Element{ Type: "image", Data: data, } case *message.FriendImageElement: data := pairs{ {K: "file", V: hex.EncodeToString(o.Md5) + ".image"}, {K: "url", V: o.Url}, } if o.Flash { data = append(data, pair{K: "type", V: "flash"}) } m = msg.Element{ Type: "image", Data: data, } case *message.DiceElement: m = msg.Element{ Type: "dice", Data: pairs{ {K: "value", V: strconv.FormatInt(int64(o.Value), 10)}, }, } case *message.FingerGuessingElement: m = msg.Element{ Type: "rps", Data: pairs{ {K: "value", V: strconv.FormatInt(int64(o.Value), 10)}, }, } case *message.MarketFaceElement: m = msg.Element{ Type: "text", Data: pairs{ {K: "text", V: o.Name}, }, } case *message.ServiceElement: m = msg.Element{ Type: "xml", Data: pairs{ {K: "data", V: o.Content}, {K: "resid", V: o.ResId}, }, } if !strings.Contains(o.Content, " 0 { log.Warnf("警告: 一条信息只能包含一个 Reply 元素.") break } replyCount++ // 将回复消息放置于第一个 r = append([]message.IMessageElement{i}, r...) case message.IMessageElement: r = append(r, i) case []message.IMessageElement: r = append(r, i...) } } return } func (bot *CQBot) reply(spec *onebot.Spec, elem msg.Element, sourceType message.SourceType) (any, error) { mid, err := strconv.Atoi(elem.Get("id")) customText := elem.Get("text") var re *message.ReplyElement switch { case customText != "": var org db.StoredMessage sender, senderErr := strconv.ParseInt(elem.Get("user_id"), 10, 64) if senderErr != nil { sender, senderErr = strconv.ParseInt(elem.Get("qq"), 10, 64) } if senderErr != nil && err != nil { return nil, errors.New("警告: 自定义 reply 元素中必须包含 user_id 或 id") } msgTime, timeErr := strconv.ParseInt(elem.Get("time"), 10, 64) if timeErr != nil { msgTime = time.Now().Unix() } messageSeq, seqErr := strconv.ParseInt(elem.Get("seq"), 10, 64) if err == nil { org, _ = db.GetMessageByGlobalID(int32(mid)) } if org != nil { re = &message.ReplyElement{ ReplySeq: org.GetAttribute().MessageSeq, Sender: org.GetAttribute().SenderUin, Time: int32(org.GetAttribute().Timestamp), Elements: bot.ConvertStringMessage(spec, customText, sourceType), } if senderErr != nil { re.Sender = sender } if timeErr != nil { re.Time = int32(msgTime) } if seqErr != nil { re.ReplySeq = int32(messageSeq) } break } re = &message.ReplyElement{ ReplySeq: int32(messageSeq), Sender: sender, Time: int32(msgTime), Elements: bot.ConvertStringMessage(spec, customText, sourceType), } case err == nil: org, err := db.GetMessageByGlobalID(int32(mid)) if err != nil { return nil, err } re = &message.ReplyElement{ ReplySeq: org.GetAttribute().MessageSeq, Sender: org.GetAttribute().SenderUin, Time: int32(org.GetAttribute().Timestamp), Elements: bot.ConvertContentMessage(org.GetContent(), sourceType), } default: return nil, errors.New("reply消息中必须包含 text 或 id") } return re, nil } func (bot *CQBot) voice(elem msg.Element) (m any, err error) { f := elem.Get("file") data, err := global.FindFile(f, elem.Get("cache"), global.VoicePath) if err != nil { return nil, err } if !global.IsAMRorSILK(data) { mt, ok := mime.CheckAudio(bytes.NewReader(data)) if !ok { return nil, errors.New("voice type error: " + mt) } data, err = global.EncoderSilk(data) if err != nil { return nil, err } } return &message.VoiceElement{Data: data}, nil } func (bot *CQBot) at(id, name string) (m any, err error) { t, err := strconv.ParseInt(id, 10, 64) if err != nil { return nil, err } name = strings.TrimSpace(name) if len(name) > 0 { name = "@" + name } return message.NewAt(t, name), nil } // convertV11 ConvertElement11 func (bot *CQBot) convertV11(elem msg.Element) (m any, ok bool, err error) { switch elem.Type { default: // not ok return case "at": qq := elem.Get("qq") if qq == "" { qq = elem.Get("target") } if qq == "all" { m = message.AtAll() break } m, err = bot.at(qq, elem.Get("name")) case "record": m, err = bot.voice(elem) } ok = true return } // convertV12 ConvertElement12 func (bot *CQBot) convertV12(elem msg.Element) (m any, ok bool, err error) { switch elem.Type { default: // not ok return case "mention": m, err = bot.at(elem.Get("user_id"), elem.Get("name")) case "mention_all": m = message.AtAll() case "voice": m, err = bot.voice(elem) } ok = true return } // ConvertElement 将解码后的消息转换为MiraiGoElement. // // 返回 interface{} 存在三种类型 // // message.IMessageElement []message.IMessageElement nil func (bot *CQBot) ConvertElement(spec *onebot.Spec, elem msg.Element, sourceType message.SourceType) (m any, err error) { var ok bool switch spec.Version { case 11: m, ok, err = bot.convertV11(elem) case 12: m, ok, err = bot.convertV12(elem) default: panic("invalid onebot version:" + strconv.Itoa(spec.Version)) } if ok { return m, err } switch elem.Type { case "text": text := elem.Get("text") if base.SplitURL { var ret []message.IMessageElement for _, text := range param.SplitURL(text) { ret = append(ret, message.NewText(text)) } return ret, nil } return message.NewText(text), nil case "image": img, err := bot.makeImageOrVideoElem(elem, false, sourceType) if err != nil { return nil, err } tp := elem.Get("type") flash, id := false, int64(0) switch tp { case "flash": flash = true case "show": id, _ = strconv.ParseInt(elem.Get("id"), 10, 64) if id < 40000 || id >= 40006 { id = 40000 } default: return img, nil } switch img := img.(type) { case *msg.LocalImage: img.Flash = flash img.EffectID = int32(id) case *message.GroupImageElement: img.Flash = flash img.EffectID = int32(id) i, _ := strconv.ParseInt(elem.Get("subType"), 10, 64) img.ImageBizType = message.ImageBizType(i) case *message.FriendImageElement: img.Flash = flash } return img, nil case "reply": return bot.reply(spec, elem, sourceType) case "forward": id := elem.Get("id") if id == "" { return nil, errors.New("forward 消息中必须包含 id") } fwdMsg := bot.Client.DownloadForwardMessage(id) if fwdMsg == nil { return nil, errors.New("forward 消息不存在或已过期") } return fwdMsg, nil case "poke": t, _ := strconv.ParseInt(elem.Get("qq"), 10, 64) return &msg.Poke{Target: t}, nil case "tts": data, err := bot.Client.GetTts(elem.Get("text")) if err != nil { return nil, err } return &message.VoiceElement{Data: base.ResampleSilk(data)}, nil case "face": id, err := strconv.Atoi(elem.Get("id")) if err != nil { return nil, err } if elem.Get("type") == "sticker" { return &message.AnimatedSticker{ID: int32(id)}, nil } return message.NewFace(int32(id)), nil case "share": return message.NewUrlShare(elem.Get("url"), elem.Get("title"), elem.Get("content"), elem.Get("image")), nil case "music": id := elem.Get("id") switch elem.Get("type") { case "qq": info, err := global.QQMusicSongInfo(id) if err != nil { return nil, err } if !info.Get("track_info").Exists() { return nil, errors.New("song not found") } albumMid := info.Get("track_info.album.mid").String() pinfo, _ := download.Request{URL: "https://u.y.qq.com/cgi-bin/musicu.fcg?g_tk=2034008533&uin=0&format=json&data={\"comm\":{\"ct\":23,\"cv\":0},\"url_mid\":{\"module\":\"vkey.GetVkeyServer\",\"method\":\"CgiGetVkey\",\"param\":{\"guid\":\"4311206557\",\"songmid\":[\"" + info.Get("track_info.mid").Str + "\"],\"songtype\":[0],\"uin\":\"0\",\"loginflag\":1,\"platform\":\"23\"}}}&_=1599039471576"}.JSON() jumpURL := "https://i.y.qq.com/v8/playsong.html?platform=11&appshare=android_qq&appversion=10030010&hosteuin=oKnlNenz7i-s7c**&songmid=" + info.Get("track_info.mid").Str + "&type=0&appsongtype=1&_wv=1&source=qq&ADTAG=qfshare" content := info.Get("track_info.singer.0.name").String() if elem.Get("content") != "" { content = elem.Get("content") } return &message.MusicShareElement{ MusicType: message.QQMusic, Title: info.Get("track_info.name").Str, Summary: content, Url: jumpURL, PictureUrl: "https://y.gtimg.cn/music/photo_new/T002R180x180M000" + albumMid + ".jpg", MusicUrl: pinfo.Get("url_mid.data.midurlinfo.0.purl").String(), }, nil case "163": info, err := global.NeteaseMusicSongInfo(id) if err != nil { return nil, err } if !info.Exists() { return nil, errors.New("song not found") } artistName := "" if info.Get("artists.0").Exists() { artistName = info.Get("artists.0.name").String() } return &message.MusicShareElement{ MusicType: message.CloudMusic, Title: info.Get("name").String(), Summary: artistName, Url: "https://y.music.163.com/m/song/" + id, PictureUrl: info.Get("album.picUrl").String(), MusicUrl: "https://music.163.com/song/media/outer/url?id=" + id, }, nil case "custom": if elem.Get("subtype") != "" { var subType int switch elem.Get("subtype") { default: subType = message.QQMusic case "163": subType = message.CloudMusic case "migu": subType = message.MiguMusic case "kugou": subType = message.KugouMusic case "kuwo": subType = message.KuwoMusic } return &message.MusicShareElement{ MusicType: subType, Title: elem.Get("title"), Summary: elem.Get("content"), Url: elem.Get("url"), PictureUrl: elem.Get("image"), MusicUrl: elem.Get("voice"), }, nil } xml := fmt.Sprintf(`%s%s`, utils.XmlEscape(elem.Get("title")), elem.Get("url"), elem.Get("image"), elem.Get("voice"), utils.XmlEscape(elem.Get("title")), utils.XmlEscape(elem.Get("content"))) return &message.ServiceElement{ Id: 60, Content: xml, SubType: "music", }, nil } return nil, errors.New("unsupported music type: " + elem.Get("type")) case "dice": value := elem.Get("value") i, _ := strconv.ParseInt(value, 10, 64) if i < 0 || i > 6 { return nil, errors.New("invalid dice value " + value) } return message.NewDice(int32(i)), nil case "rps": value := elem.Get("value") i, _ := strconv.ParseInt(value, 10, 64) if i < 0 || i > 2 { return nil, errors.New("invalid finger-guessing value " + value) } return message.NewFingerGuessing(int32(i)), nil case "xml": resID := elem.Get("resid") template := elem.Get("data") i, _ := strconv.ParseInt(resID, 10, 64) m := message.NewRichXml(template, i) return m, nil case "json": resID := elem.Get("resid") data := elem.Get("data") i, _ := strconv.ParseInt(resID, 10, 64) if i == 0 { // 默认情况下走小程序通道 return message.NewLightApp(data), nil } // resid不为0的情况下走富文本通道,后续补全透传service Id,此处暂时不处理 TODO return message.NewRichJson(data), nil case "cardimage": source := elem.Get("source") icon := elem.Get("icon") brief := elem.Get("brief") parseIntWithDefault := func(name string, origin int64) int64 { v, _ := strconv.ParseInt(elem.Get(name), 10, 64) if v <= 0 { return origin } return v } minWidth := parseIntWithDefault("minwidth", 200) maxWidth := parseIntWithDefault("maxwidth", 500) minHeight := parseIntWithDefault("minheight", 200) maxHeight := parseIntWithDefault("maxheight", 1000) img, err := bot.makeImageOrVideoElem(elem, false, sourceType) if err != nil { return nil, errors.New("send cardimage faild") } return bot.makeShowPic(img, source, brief, icon, minWidth, minHeight, maxWidth, maxHeight, sourceType == message.SourceGroup) case "video": file, err := bot.makeImageOrVideoElem(elem, true, sourceType) if err != nil { return nil, err } v, ok := file.(*msg.LocalVideo) if !ok { return file, nil } if v.File == "" { return v, nil } var data []byte if cover := elem.Get("cover"); cover != "" { data, _ = global.FindFile(cover, elem.Get("cache"), global.ImagePath) } else { err = global.ExtractCover(v.File, v.File+".jpg") if err != nil { return nil, err } data, _ = os.ReadFile(v.File + ".jpg") } v.Thumb = bytes.NewReader(data) video, _ := os.Open(v.File) defer video.Close() _, _ = video.Seek(4, io.SeekStart) header := make([]byte, 4) _, _ = video.Read(header) if !bytes.Equal(header, []byte{0x66, 0x74, 0x79, 0x70}) { // check file header ftyp _, _ = video.Seek(0, io.SeekStart) hash, _ := utils.ComputeMd5AndLength(video) cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash)+".mp4") if !(elem.Get("cache") == "" || elem.Get("cache") == "1") || !global.PathExists(cacheFile) { err = global.EncodeMP4(v.File, cacheFile) if err != nil { return nil, err } } v.File = cacheFile } return v, nil default: return nil, errors.New("unsupported message type: " + elem.Type) } } // makeImageOrVideoElem 图片 elem 生成器,单独拎出来,用于公用 func (bot *CQBot) makeImageOrVideoElem(elem msg.Element, video bool, sourceType message.SourceType) (message.IMessageElement, error) { f := elem.Get("file") u := elem.Get("url") if strings.HasPrefix(f, "http") { hash := md5.Sum([]byte(f)) cacheFile := path.Join(global.CachePath, hex.EncodeToString(hash[:])+".cache") maxSize := int64(maxImageSize) if video { maxSize = maxVideoSize } thread, _ := strconv.Atoi(elem.Get("c")) exist := global.PathExists(cacheFile) if exist && (elem.Get("cache") == "" || elem.Get("cache") == "1") { goto useCacheFile } if exist { _ = os.Remove(cacheFile) } { r := download.Request{URL: f, Limit: maxSize} if err := r.WriteToFileMultiThreading(cacheFile, thread); err != nil { return nil, err } } useCacheFile: if video { return &msg.LocalVideo{File: cacheFile}, nil } return &msg.LocalImage{File: cacheFile, URL: f}, nil } if strings.HasPrefix(f, "file") { fu, err := url.Parse(f) if err != nil { return nil, err } if runtime.GOOS == `windows` && strings.HasPrefix(fu.Path, "/") { fu.Path = fu.Path[1:] } info, err := os.Stat(fu.Path) if err != nil { if !os.IsExist(err) { return nil, errors.New("file not found") } return nil, err } if video { if info.Size() == 0 || info.Size() >= maxVideoSize { return nil, errors.New("invalid video size") } return &msg.LocalVideo{File: fu.Path}, nil } if info.Size() == 0 || info.Size() >= maxImageSize { return nil, errors.New("invalid image size") } return &msg.LocalImage{File: fu.Path, URL: f}, nil } if !video && strings.HasPrefix(f, "base64") { b, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(f, "base64://")) if err != nil { return nil, err } return &msg.LocalImage{Stream: bytes.NewReader(b), URL: f}, nil } if !video && strings.HasPrefix(f, "base16384") { b, err := b14.UTF82UTF16BE(utils.S2B(strings.TrimPrefix(f, "base16384://"))) if err != nil { return nil, err } return &msg.LocalImage{Stream: bytes.NewReader(b14.Decode(b)), URL: f}, nil } rawPath := path.Join(global.ImagePath, f) if video { if strings.HasSuffix(f, ".video") { hash, err := hex.DecodeString(strings.TrimSuffix(f, ".video")) if err == nil { if b := cache.Video.Get(hash); b != nil { return bot.readVideoCache(b), nil } } } rawPath = path.Join(global.VideoPath, f) if !global.PathExists(rawPath) { return nil, errors.New("invalid video") } if path.Ext(rawPath) != ".video" { return &msg.LocalVideo{File: rawPath}, nil } b, _ := os.ReadFile(rawPath) return bot.readVideoCache(b), nil } // 目前频道内上传的图片均无法被查询到, 需要单独处理 if sourceType == message.SourceGuildChannel { cacheFile := path.Join(global.ImagePath, "guild-images", f) if global.PathExists(cacheFile) { return &msg.LocalImage{File: cacheFile}, nil } } if strings.HasSuffix(f, ".image") { hash, err := hex.DecodeString(strings.TrimSuffix(f, ".image")) if err == nil { if b := cache.Image.Get(hash); b != nil { return bot.readImageCache(b, sourceType) } } } exist := global.PathExists(rawPath) if !exist { if elem.Get("url") != "" { elem.Data = []msg.Pair{{K: "file", V: elem.Get("url")}} return bot.makeImageOrVideoElem(elem, false, sourceType) } return nil, errors.New("invalid image") } if path.Ext(rawPath) != ".image" { return &msg.LocalImage{File: rawPath, URL: u}, nil } b, err := os.ReadFile(rawPath) if err != nil { return nil, err } return bot.readImageCache(b, sourceType) } func (bot *CQBot) readImageCache(b []byte, sourceType message.SourceType) (message.IMessageElement, error) { var err error if len(b) < 20 { return nil, errors.New("invalid cache") } r := binary.NewReader(b) hash := r.ReadBytes(16) size := r.ReadInt32() r.ReadString() imageURL := r.ReadString() if size == 0 && imageURL != "" { // TODO: fix this var elem msg.Element elem.Type = "image" elem.Data = []msg.Pair{{K: "file", V: imageURL}} return bot.makeImageOrVideoElem(elem, false, sourceType) } var rsp message.IMessageElement switch sourceType { // nolint:exhaustive case message.SourceGroup: rsp, err = bot.Client.QueryGroupImage(int64(rand.Uint32()), hash, size) case message.SourceGuildChannel: if len(bot.Client.GuildService.Guilds) == 0 { err = errors.New("cannot query guild image: not any joined guild") break } guild := bot.Client.GuildService.Guilds[0] rsp, err = bot.Client.GuildService.QueryImage(guild.GuildId, guild.Channels[0].ChannelId, hash, uint64(size)) default: rsp, err = bot.Client.QueryFriendImage(int64(rand.Uint32()), hash, size) } if err != nil && imageURL != "" { var elem msg.Element elem.Type = "image" elem.Data = []msg.Pair{{K: "file", V: imageURL}} return bot.makeImageOrVideoElem(elem, false, sourceType) } return rsp, err } func (bot *CQBot) readVideoCache(b []byte) message.IMessageElement { r := binary.NewReader(b) return &message.ShortVideoElement{ // todo 检查缓存是否有效 Md5: r.ReadBytes(16), ThumbMd5: r.ReadBytes(16), Size: r.ReadInt32(), ThumbSize: r.ReadInt32(), Name: r.ReadString(), Uuid: r.ReadAvailable(), } } // makeShowPic 一种xml 方式发送的群消息图片 func (bot *CQBot) makeShowPic(elem message.IMessageElement, source string, brief string, icon string, minWidth int64, minHeight int64, maxWidth int64, maxHeight int64, group bool) ([]message.IMessageElement, error) { xml := "" var suf message.IMessageElement if brief == "" { brief = "[分享]我看到一张很赞的图片,分享给你,快来看!" } if local, ok := elem.(*msg.LocalImage); ok { r := rand.Uint32() typ := message.SourceGroup if !group { typ = message.SourcePrivate } e, err := bot.uploadLocalImage(message.Source{SourceType: typ, PrimaryID: int64(r)}, local) if err != nil { log.Warnf("警告: 图片上传失败: %v", err) return nil, err } elem = e } switch i := elem.(type) { case *message.GroupImageElement: xml = fmt.Sprintf(``, brief, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) suf = i case *message.FriendImageElement: xml = fmt.Sprintf(``, brief, "", i.Md5, i.Md5, 0, "", minWidth, minHeight, maxWidth, maxHeight, source, icon) suf = i } if xml == "" { return nil, errors.New("生成xml图片消息失败") } ret := []message.IMessageElement{suf, message.NewRichXml(xml, 5)} return ret, nil }