Compare commits

...

27 Commits

Author SHA1 Message Date
多吃点苹果
3e6fc41298
[fix]: 修改同步消息逻辑 (#236) 2023-02-05 11:23:05 +08:00
ford
3c7ac0cc75
【opt】优化好友公众号群组获取接口防止频繁发送网络请求 (#234)
Co-authored-by: wenyoufu <wenyoufu@jd.com>
2023-02-05 11:20:40 +08:00
多吃点苹果
99af4a2685
[refactor]: 添加 CookieGroup (#233) 2023-02-05 00:20:37 +08:00
多吃点苹果
0c57ab1ed5
更新 Group Display (#232) 2023-02-04 23:55:55 +08:00
多吃点苹果
a72c165c59
删除根据备注查找群组功能 (#231) 2023-02-04 12:04:36 +08:00
多吃点苹果
35a348f0af
[feat]: 添加最近联系人和公众号文章列表 (#230) 2023-02-04 11:59:17 +08:00
多吃点苹果
66c4bebd1f
提升上传文件性能 (#228) 2023-02-03 22:17:40 +08:00
多吃点苹果
fbfd691cb4
[style]: update User display (#227) 2023-02-03 17:57:58 +08:00
多吃点苹果
5194ad4965
[style]: 移除 DispatchMessage (#224) 2023-02-02 10:24:18 +08:00
多吃点苹果
eccc25e66e
[style]: Deprecated NewJsonFileHotReloadStorage (#223) 2023-02-02 10:06:17 +08:00
多吃点苹果
d77bb0a4cb
[feat]: 支持用户自定义热存储数据的序列化和反序列化 (#222) 2023-02-02 00:15:46 +08:00
多吃点苹果
e9c89f9ac8
[style]: 支持扫码登录自定义uuid (#221) 2023-02-02 00:05:26 +08:00
多吃点苹果
6629e77fd5
[feat]: 支持自定义添加 context 用于控制 bot 存活 (#220) 2023-02-01 23:54:11 +08:00
多吃点苹果
76bd0a5648
[fix]: 解决定时器同步数据到热存储中的数据竞争问题 https://github.com/eatmoreapple/openwech… (#219) 2023-02-01 23:43:10 +08:00
多吃点苹果
6be6359e34
[feat]: 添加邀请用户加入群聊功能 (#216) 2023-01-30 21:09:41 +08:00
李寻欢
7cbb2ae1bb
新增设置UUID接口,可使用该接口解耦登录逻辑 (#215)
* 🎨 优化是否有人加入了群聊判断逻辑

*  新增设置UUID接口,可使用该接口解耦登录逻辑
2023-01-30 20:30:32 +08:00
多吃点苹果
b9bbaed369
[doc]: update docs (#214) 2023-01-24 21:44:35 +08:00
多吃点苹果
2d11a7b95a
按照微信的排列顺序获取联系人列表 (#213) 2023-01-24 21:17:18 +08:00
eatmoreapple
e38b4258f0 wip 2023-01-22 09:21:34 +08:00
多吃点苹果
26fc0bb366
change readerToFile (#211) 2023-01-22 09:06:16 +08:00
多吃点苹果
71d8221cad
[bug]: 修复拍了拍我判断错误的问题 (#210) 2023-01-22 08:31:31 +08:00
多吃点苹果
1a13e73355
[feat]: 添加判断当前消息是否为拍了拍自己 (#209) 2023-01-21 09:32:46 +08:00
多吃点苹果
fc38bfb401
[fix]: 修复当用户主动退出后,block依然阻塞 (#206) 2023-01-13 20:53:52 +08:00
多吃点苹果
30da2df475
[fix]: 当用户自定义http client的时候 cookie 无法被反序列化的问题 (#205) 2023-01-13 20:40:55 +08:00
多吃点苹果
63143f9364
[refactor]: 增加 LoginCode 定义 (#204) 2023-01-13 19:56:52 +08:00
多吃点苹果
53478bad19
[perf]: 优化登录检查 (#203) 2023-01-13 19:44:15 +08:00
多吃点苹果
1eadbf8be6
[style]: 更新 Members 转换函数 (#202) 2023-01-13 12:55:22 +08:00
20 changed files with 533 additions and 312 deletions

View File

@ -4,16 +4,16 @@
[![Go Doc](https://pkg.go.dev/badge/github.com/eatMoreApple/openwechat)](https://godoc.org/github.com/eatMoreApple/openwechat)[![Release](https://img.shields.io/github/v/release/eatmoreapple/openwechat.svg?style=flat-square)](https://github.com/eatmoreapple/openwechat/releases)[![Go Report Card](https://goreportcard.com/badge/github.com/eatmoreapple/openwechat)](https://goreportcard.com/badge/github.com/eatmoreapple/openwechat)[![Stars](https://img.shields.io/github/stars/eatmoreapple/openwechat.svg?style=flat-square)](https://img.shields.io/github/stars/eatmoreapple/openwechat.svg?style=flat-square)[![Forks](https://img.shields.io/github/forks/eatmoreapple/openwechat.svg?style=flat-square)](https://img.shields.io/github/forks/eatmoreapple/openwechat.svg?style=flat-square) [![Go Doc](https://pkg.go.dev/badge/github.com/eatMoreApple/openwechat)](https://godoc.org/github.com/eatMoreApple/openwechat)[![Release](https://img.shields.io/github/v/release/eatmoreapple/openwechat.svg?style=flat-square)](https://github.com/eatmoreapple/openwechat/releases)[![Go Report Card](https://goreportcard.com/badge/github.com/eatmoreapple/openwechat)](https://goreportcard.com/badge/github.com/eatmoreapple/openwechat)[![Stars](https://img.shields.io/github/stars/eatmoreapple/openwechat.svg?style=flat-square)](https://img.shields.io/github/stars/eatmoreapple/openwechat.svg?style=flat-square)[![Forks](https://img.shields.io/github/forks/eatmoreapple/openwechat.svg?style=flat-square)](https://img.shields.io/github/forks/eatmoreapple/openwechat.svg?style=flat-square)
> golang版个人微信号API, 突破网页版限制,类似开发公众号一样,开发个人微信号 > golang版个人微信号API, 突破登录限制,类似开发公众号一样,开发个人微信号
微信机器人:smiling_imp:,利用微信号完成一些功能的定制化开发⭐ 微信机器人:smiling_imp:,利用微信号完成一些功能的定制化开发⭐
* 模块简单易用,易于扩展
* 支持定制化开发,如日志记录,自动回复 * 支持定制化开发,如日志记录,自动回复
* 突破网页版登录限制&#x1F4E3; * 突破登录限制&#x1F4E3;
* 无需重复扫码登录 * 无需重复扫码登录
* 支持多个微信号同时登陆 * 支持多个微信号同时登陆
@ -115,7 +115,7 @@ func main() {
### 添加微信(EatMoreApple):apple:(备注: openwechat进群交流:smiling_imp: ### 添加微信(EatMoreApple):apple:(备注: openwechat进群交流:smiling_imp:
**如果二维码图片没显示出来,请添加微信号 EatMoreApple** **如果二维码图片没显示出来,请添加微信号 eatmoreapple**
<img width="210px" src="https://raw.githubusercontent.com/eatmoreapple/eatMoreApple/main/img/wechat.jpg" align="left"> <img width="210px" src="https://raw.githubusercontent.com/eatmoreapple/eatMoreApple/main/img/wechat.jpg" align="left">

75
bot.go
View File

@ -2,32 +2,34 @@ package openwechat
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"io" "io"
"log" "log"
"net/url"
"os/exec" "os/exec"
"runtime" "runtime"
"sync" "sync"
) )
type Bot struct { type Bot struct {
ScanCallBack func(body []byte) // 扫码回调,可获取扫码用户的头像 ScanCallBack func(body CheckLoginResponse) // 扫码回调,可获取扫码用户的头像
LoginCallBack func(body []byte) // 登陆回调 LoginCallBack func(body CheckLoginResponse) // 登陆回调
LogoutCallBack func(bot *Bot) // 退出回调 LogoutCallBack func(bot *Bot) // 退出回调
UUIDCallback func(uuid string) // 获取UUID的回调函数 UUIDCallback func(uuid string) // 获取UUID的回调函数
SyncCheckCallback func(resp SyncCheckResponse) // 心跳回调 SyncCheckCallback func(resp SyncCheckResponse) // 心跳回调
MessageHandler MessageHandler // 获取消息成功的handle MessageHandler MessageHandler // 获取消息成功的handle
MessageErrorHandler func(err error) bool // 获取消息发生错误的handle, 返回true则尝试继续监听 MessageErrorHandler func(err error) bool // 获取消息发生错误的handle, 返回true则尝试继续监听
Serializer Serializer // 序列化器, 默认为json
Storage *Storage
Caller *Caller
once sync.Once once sync.Once
err error err error
context context.Context context context.Context
cancel context.CancelFunc cancel context.CancelFunc
Caller *Caller
self *Self self *Self
Storage *Storage
hotReloadStorage HotReloadStorage hotReloadStorage HotReloadStorage
uuid string uuid string
loginUUID *string
deviceId string // 设备Id deviceId string // 设备Id
loginOptionGroup BotOptionGroup loginOptionGroup BotOptionGroup
} }
@ -49,6 +51,7 @@ func (b *Bot) Alive() bool {
// @description: 设置设备Id // @description: 设置设备Id
// @receiver b // @receiver b
// @param deviceId // @param deviceId
// TODO ADD INTO LOGIN OPTION
func (b *Bot) SetDeviceId(deviceId string) { func (b *Bot) SetDeviceId(deviceId string) {
b.deviceId = deviceId b.deviceId = deviceId
} }
@ -82,7 +85,7 @@ func (b *Bot) login(login BotLogin) (err error) {
// Login 用户登录 // Login 用户登录
func (b *Bot) Login() error { func (b *Bot) Login() error {
scanLogin := &SacnLogin{} scanLogin := &SacnLogin{UUID: b.loginUUID}
return b.login(scanLogin) return b.login(scanLogin)
} }
@ -121,9 +124,9 @@ func (b *Bot) Logout() error {
} }
// HandleLogin 登录逻辑 // HandleLogin 登录逻辑
func (b *Bot) HandleLogin(data []byte) error { func (b *Bot) HandleLogin(path *url.URL) error {
// 获取登录的一些基本的信息 // 获取登录的一些基本的信息
info, err := b.Caller.GetLoginInfo(data) info, err := b.Caller.GetLoginInfo(path)
if err != nil { if err != nil {
return err return err
} }
@ -166,9 +169,10 @@ func (b *Bot) WebInit() error {
return err return err
} }
// 设置当前的用户 // 设置当前的用户
b.self = &Self{bot: b, User: &resp.User} b.self = &Self{bot: b, User: resp.User}
b.self.formatEmoji() b.self.formatEmoji()
b.self.self = b.self b.self.self = b.self
resp.ContactList.init(b.self)
b.Storage.Response = resp b.Storage.Response = resp
// 通知手机客户端已经登录 // 通知手机客户端已经登录
@ -190,6 +194,7 @@ func (b *Bot) WebInit() error {
// 判断是否继续, 如果不继续则退出 // 判断是否继续, 如果不继续则退出
if goon := b.MessageErrorHandler(err); !goon { if goon := b.MessageErrorHandler(err); !goon {
b.err = err b.err = err
b.Exit()
break break
} }
} }
@ -218,8 +223,8 @@ func (b *Bot) syncCheck() error {
if !resp.Success() { if !resp.Success() {
return resp.Err() return resp.Err()
} }
// 如果Selector不为0则获取消息 switch resp.Selector {
if !resp.NorMal() { case SelectorNewMsg:
messages, err := b.syncMessage() messages, err := b.syncMessage()
if err != nil { if err != nil {
return err return err
@ -232,8 +237,12 @@ func (b *Bot) syncCheck() error {
// 默认同步调用 // 默认同步调用
// 如果异步调用则需自行处理 // 如果异步调用则需自行处理
// 如配合 openwechat.MessageMatchDispatcher 使用 // 如配合 openwechat.MessageMatchDispatcher 使用
// NOTE: 请确保 MessageHandler 不会阻塞,否则会导致收不到后续的消息
b.MessageHandler(message) b.MessageHandler(message)
} }
case SelectorModContact:
case SelectorAddOrDelContact:
case SelectorModChatRoom:
} }
} }
return err return err
@ -284,15 +293,15 @@ func (b *Bot) DumpHotReloadStorage() error {
// DumpTo 将热登录需要的数据写入到指定的 io.Writer 中 // DumpTo 将热登录需要的数据写入到指定的 io.Writer 中
// 注: 写之前最好先清空之前的数据 // 注: 写之前最好先清空之前的数据
func (b *Bot) DumpTo(writer io.Writer) error { func (b *Bot) DumpTo(writer io.Writer) error {
cookies := b.Caller.Client.GetCookieJar() jar := b.Caller.Client.Jar()
item := HotReloadStorageItem{ item := HotReloadStorageItem{
BaseRequest: b.Storage.Request, BaseRequest: b.Storage.Request,
Jar: cookies, Jar: fromCookieJar(jar),
LoginInfo: b.Storage.LoginInfo, LoginInfo: b.Storage.LoginInfo,
WechatDomain: b.Caller.Client.Domain, WechatDomain: b.Caller.Client.Domain,
UUID: b.uuid, UUID: b.uuid,
} }
return json.NewEncoder(writer).Encode(item) return b.Serializer.Encode(writer, item)
} }
// IsHot returns true if is hot login otherwise false // IsHot returns true if is hot login otherwise false
@ -300,11 +309,20 @@ func (b *Bot) IsHot() bool {
return b.hotReloadStorage != nil return b.hotReloadStorage != nil
} }
// UUID returns current uuid of bot // UUID returns current UUID of bot
func (b *Bot) UUID() string { func (b *Bot) UUID() string {
return b.uuid return b.uuid
} }
// SetUUID
// @description: 设置UUID可以用来手动登录用
// @receiver b
// @param UUID
// TODO ADD INTO LOGIN OPTION
func (b *Bot) SetUUID(uuid string) {
b.loginUUID = &uuid
}
// Context returns current context of bot // Context returns current context of bot
func (b *Bot) Context() context.Context { func (b *Bot) Context() context.Context {
return b.context return b.context
@ -315,11 +333,10 @@ func (b *Bot) reload() error {
return errors.New("hotReloadStorage is nil") return errors.New("hotReloadStorage is nil")
} }
var item HotReloadStorageItem var item HotReloadStorageItem
err := json.NewDecoder(b.hotReloadStorage).Decode(&item) if err := b.Serializer.Decode(b.hotReloadStorage, &item); err != nil {
if err != nil {
return err return err
} }
b.Caller.Client.Jar = item.Jar.AsCookieJar() b.Caller.Client.SetCookieJar(item.Jar)
b.Storage.LoginInfo = item.LoginInfo b.Storage.LoginInfo = item.LoginInfo
b.Storage.Request = item.BaseRequest b.Storage.Request = item.BaseRequest
b.Caller.Client.Domain = item.WechatDomain b.Caller.Client.Domain = item.WechatDomain
@ -334,7 +351,13 @@ func NewBot(c context.Context) *Bot {
// 默认行为为网页版微信模式 // 默认行为为网页版微信模式
caller.Client.SetMode(normal) caller.Client.SetMode(normal)
ctx, cancel := context.WithCancel(c) ctx, cancel := context.WithCancel(c)
return &Bot{Caller: caller, Storage: &Storage{}, context: ctx, cancel: cancel} return &Bot{
Caller: caller,
Storage: &Storage{},
Serializer: &JsonSerializer{},
context: ctx,
cancel: cancel,
}
} }
// DefaultBot 默认的Bot的构造方法, // DefaultBot 默认的Bot的构造方法,
@ -346,11 +369,11 @@ func DefaultBot(prepares ...BotPreparer) *Bot {
// 获取二维码回调 // 获取二维码回调
bot.UUIDCallback = PrintlnQrcodeUrl bot.UUIDCallback = PrintlnQrcodeUrl
// 扫码回调 // 扫码回调
bot.ScanCallBack = func(body []byte) { bot.ScanCallBack = func(_ CheckLoginResponse) {
log.Println("扫码成功,请在手机上确认登录") log.Println("扫码成功,请在手机上确认登录")
} }
// 登录回调 // 登录回调
bot.LoginCallBack = func(body []byte) { bot.LoginCallBack = func(_ CheckLoginResponse) {
log.Println("登录成功") log.Println("登录成功")
} }
// 心跳回调函数 // 心跳回调函数

View File

@ -1,9 +1,39 @@
package openwechat package openwechat
import ( import (
"context"
"time" "time"
) )
// LoginCode 定义登录状态码
type LoginCode string
const (
// LoginCodeSuccess 登录成功
LoginCodeSuccess LoginCode = "200"
// LoginCodeScanned 已扫码
LoginCodeScanned LoginCode = "201"
// LoginCodeTimeout 登录超时
LoginCodeTimeout LoginCode = "400"
// LoginCodeWait 等待扫码
LoginCodeWait LoginCode = "408"
)
func (l LoginCode) String() string {
switch l {
case LoginCodeSuccess:
return "登录成功"
case LoginCodeScanned:
return "已扫码"
case LoginCodeTimeout:
return "登录超时"
case LoginCodeWait:
return "等待扫码"
default:
return "未知状态"
}
}
type BotPreparer interface { type BotPreparer interface {
Prepare(*Bot) Prepare(*Bot)
} }
@ -103,16 +133,16 @@ func NewSyncReloadDataLoginOption(duration time.Duration) BotLoginOption {
return &SyncReloadDataLoginOption{SyncLoopDuration: duration} return &SyncReloadDataLoginOption{SyncLoopDuration: duration}
} }
// WithModeOption 指定使用哪种客户端模式 // withModeOption 指定使用哪种客户端模式
type WithModeOption struct { type withModeOption struct {
mode Mode mode Mode
} }
// Prepare 实现了 BotLoginOption 接口 // Prepare 实现了 BotLoginOption 接口
func (w WithModeOption) Prepare(b *Bot) { b.Caller.Client.SetMode(w.mode) } func (w withModeOption) Prepare(b *Bot) { b.Caller.Client.SetMode(w.mode) }
func withMode(mode Mode) BotPreparer { func withMode(mode Mode) BotPreparer {
return WithModeOption{mode: mode} return withModeOption{mode: mode}
} }
// btw, 这两个变量已经变了4回了, 但是为了兼容以前的代码, 还是得想着法儿让用户无感知的更新 // btw, 这两个变量已经变了4回了, 但是为了兼容以前的代码, 还是得想着法儿让用户无感知的更新
@ -124,6 +154,19 @@ var (
Desktop = withMode(desktop) Desktop = withMode(desktop)
) )
// WithContextOption 指定一个 context.Context 用于Bot的生命周期
type WithContextOption struct {
Ctx context.Context
}
// Prepare 实现了 BotLoginOption 接口
func (w WithContextOption) Prepare(b *Bot) {
if w.Ctx == nil {
panic("context is nil")
}
b.context, b.cancel = context.WithCancel(w.Ctx)
}
const ( const (
defaultHotStorageSyncDuration = time.Minute * 5 defaultHotStorageSyncDuration = time.Minute * 5
) )
@ -134,13 +177,21 @@ type BotLogin interface {
} }
// SacnLogin 扫码登录 // SacnLogin 扫码登录
type SacnLogin struct{} type SacnLogin struct {
UUID *string
}
// Login 实现了 BotLogin 接口 // Login 实现了 BotLogin 接口
func (s *SacnLogin) Login(bot *Bot) error { func (s *SacnLogin) Login(bot *Bot) error {
uuid, err := bot.Caller.GetLoginUUID() var uuid string
if err != nil { if s.UUID == nil {
return err var err error
uuid, err = bot.Caller.GetLoginUUID()
if err != nil {
return err
}
} else {
uuid = *s.UUID
} }
return s.checkLogin(bot, uuid) return s.checkLogin(bot, uuid)
} }
@ -225,8 +276,8 @@ type LoginChecker struct {
Bot *Bot Bot *Bot
Tip string Tip string
UUIDCallback func(uuid string) UUIDCallback func(uuid string)
LoginCallBack func(body []byte) LoginCallBack func(body CheckLoginResponse)
ScanCallBack func(body []byte) ScanCallBack func(body CheckLoginResponse)
} }
func (l *LoginChecker) CheckLogin() error { func (l *LoginChecker) CheckLogin() error {
@ -242,27 +293,35 @@ func (l *LoginChecker) CheckLogin() error {
if err != nil { if err != nil {
return err return err
} }
code, err := resp.Code()
if err != nil {
return err
}
if tip == "1" { if tip == "1" {
tip = "0" tip = "0"
} }
switch resp.Code { switch code {
case StatusSuccess: case LoginCodeSuccess:
// 判断是否有登录回调,如果有执行它 // 判断是否有登录回调,如果有执行它
if err = l.Bot.HandleLogin(resp.Raw); err != nil { redirectURL, err := resp.RedirectURL()
if err != nil {
return err
}
if err = l.Bot.HandleLogin(redirectURL); err != nil {
return err return err
} }
if cb := l.LoginCallBack; cb != nil { if cb := l.LoginCallBack; cb != nil {
cb(resp.Raw) cb(resp)
} }
return nil return nil
case StatusScanned: case LoginCodeScanned:
// 执行扫码回调 // 执行扫码回调
if cb := l.ScanCallBack; cb != nil { if cb := l.ScanCallBack; cb != nil {
cb(resp.Raw) cb(resp)
} }
case StatusTimeout: case LoginCodeTimeout:
return ErrLoginTimeout return ErrLoginTimeout
case StatusWait: case LoginCodeWait:
continue continue
} }
} }

View File

@ -128,4 +128,30 @@ func TestSender(t *testing.T) {
bot.Block() bot.Block()
} }
// TestGetUUID
// @description: 获取登录二维码(UUID)
// @param t
func TestGetUUID(t *testing.T) {
bot := DefaultBot(Desktop)
uuid, err := bot.Caller.GetLoginUUID()
if err != nil {
t.Error(err)
return
}
t.Log(uuid)
}
// TestLoginWithUUID
// @description: 使用UUID登录
// @param t
func TestLoginWithUUID(t *testing.T) {
uuid := "oZZsO0Qv8Q=="
bot := DefaultBot(Desktop)
bot.SetUUID(uuid)
err := bot.Login()
if err != nil {
t.Errorf("登录失败: %v", err.Error())
return
}
}

View File

@ -50,7 +50,7 @@ func (c *Caller) GetLoginUUID() (string, error) {
} }
// CheckLogin 检查是否登录成功 // CheckLogin 检查是否登录成功
func (c *Caller) CheckLogin(uuid, tip string) (*CheckLoginResponse, error) { func (c *Caller) CheckLogin(uuid, tip string) (CheckLoginResponse, error) {
resp, err := c.Client.CheckLogin(uuid, tip) resp, err := c.Client.CheckLogin(uuid, tip)
if err != nil { if err != nil {
return nil, err return nil, err
@ -61,29 +61,13 @@ func (c *Caller) CheckLogin(uuid, tip string) (*CheckLoginResponse, error) {
if _, err := buffer.ReadFrom(resp.Body); err != nil { if _, err := buffer.ReadFrom(resp.Body); err != nil {
return nil, err return nil, err
} }
// 正则匹配检测的code return buffer.Bytes(), nil
// 具体code参考global.go
results := statusCodeRegexp.FindSubmatch(buffer.Bytes())
if len(results) != 2 {
return nil, errors.New("error status code match")
}
code := string(results[1])
return &CheckLoginResponse{Code: code, Raw: buffer.Bytes()}, nil
} }
// GetLoginInfo 获取登录信息 // GetLoginInfo 获取登录信息
func (c *Caller) GetLoginInfo(body []byte) (*LoginInfo, error) { func (c *Caller) GetLoginInfo(path *url.URL) (*LoginInfo, error) {
// 从响应体里面获取需要跳转的url // 从响应体里面获取需要跳转的url
results := redirectUriRegexp.FindSubmatch(body) resp, err := c.Client.GetLoginInfo(path)
if len(results) != 2 {
return nil, errors.New("redirect url does not match")
}
path, err := url.Parse(string(results[1]))
if err != nil {
return nil, err
}
c.Client.Domain = WechatDomain(path.Host)
resp, err := c.Client.GetLoginInfo(path.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -517,30 +501,27 @@ func (p *MessageResponseParser) SentMessage(msg *SendMessage) (*SentMessage, err
} }
func readerToFile(reader io.Reader) (file *os.File, cb func(), err error) { func readerToFile(reader io.Reader) (file *os.File, cb func(), err error) {
if file, ok := reader.(*os.File); ok { var ok bool
if file, ok = reader.(*os.File); ok {
return file, func() {}, nil return file, func() {}, nil
} }
file, err = os.CreateTemp("", "*") file, err = os.CreateTemp("", "*")
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
cb = func() {
_ = file.Close()
_ = os.Remove(file.Name())
}
_, err = io.Copy(file, reader) _, err = io.Copy(file, reader)
if err != nil { if err != nil {
_ = file.Close() cb()
_ = os.Remove(file.Name())
return nil, nil, err return nil, nil, err
} }
if err = file.Close(); err != nil { _, err = file.Seek(0, io.SeekStart)
_ = os.Remove(file.Name())
return nil, nil, err
}
file, err = os.Open(file.Name())
if err != nil { if err != nil {
_ = os.Remove(file.Name()) cb()
return nil, nil, err return nil, nil, err
} }
return file, func() { return file, cb, nil
_ = file.Close()
_ = os.Remove(file.Name())
}, nil
} }

138
client.go
View File

@ -15,7 +15,6 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
) )
@ -33,30 +32,44 @@ func (u UserAgentHook) BeforeRequest(req *http.Request) {
req.Header.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36") req.Header.Add("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.114 Safari/537.36")
} }
func (u UserAgentHook) AfterRequest(response *http.Response, err error) {} func (u UserAgentHook) AfterRequest(_ *http.Response, _ error) {}
// Client http请求客户端 // Client http请求客户端
// 客户端需要维持Session会话 // 客户端需要维持Session会话
// 并且客户端不允许跳转
type Client struct { type Client struct {
// 设置一些client的请求行为
// see normalMode desktopMode
mode Mode
// client http客户端
client *http.Client
// Domain 微信服务器请求域名
// 这个参数会在登录成功后被赋值
// 之后所有的请求都会使用这个域名
// 在登录热登录和扫码登录时会被重新赋值
Domain WechatDomain
// HttpHooks 请求上下文钩子
HttpHooks HttpHooks HttpHooks HttpHooks
*http.Client
Domain WechatDomain // MaxRetryTimes 最大重试次数
mode Mode
mu sync.Mutex
MaxRetryTimes int MaxRetryTimes int
} }
func NewClient() *Client { func NewClient() *Client {
timeout := 30 * time.Second httpClient := &http.Client{
return &Client{ // 设置客户端不自动跳转
Client: &http.Client{ CheckRedirect: func(req *http.Request, via []*http.Request) error {
CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse
return http.ErrUseLastResponse },
}, // 设置30秒超时
Jar: newCookieJar(), // 因为微信同步消息时是一个时间长达25秒的长轮训
Timeout: timeout, Timeout: 30 * time.Second,
}} }
client := &Client{client: httpClient}
client.SetCookieJar(NewJar())
return client
} }
// DefaultClient 自动存储cookie // DefaultClient 自动存储cookie
@ -81,7 +94,7 @@ func (c *Client) do(req *http.Request) (*http.Response, error) {
err error err error
) )
for i := 0; i < c.MaxRetryTimes; i++ { for i := 0; i < c.MaxRetryTimes; i++ {
resp, err = c.Client.Do(req) resp, err = c.client.Do(req)
if err == nil { if err == nil {
break break
} }
@ -101,9 +114,17 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
return c.do(req) return c.do(req)
} }
// GetCookieJar 获取当前client的所有的有效的client // Jar 返回当前client的 http.CookieJar
func (c *Client) GetCookieJar() *Jar { // this http.CookieJar must be *Jar type
return fromCookieJar(c.Client.Jar) func (c *Client) Jar() http.CookieJar {
return c.client.Jar
}
// SetCookieJar 设置cookieJar
// 这里限制了cookieJar必须是Jar类型
// 否则进行cookie序列化的时候因为字段的私有性无法进行所有字段的导出
func (c *Client) SetCookieJar(jar *Jar) {
c.client.Jar = jar.AsCookieJar()
} }
// GetLoginUUID 获取登录的uuid // GetLoginUUID 获取登录的uuid
@ -114,7 +135,7 @@ func (c *Client) GetLoginUUID() (*http.Response, error) {
// GetLoginQrcode 获取登录的二维吗 // GetLoginQrcode 获取登录的二维吗
func (c *Client) GetLoginQrcode(uuid string) (*http.Response, error) { func (c *Client) GetLoginQrcode(uuid string) (*http.Response, error) {
path := qrcode + uuid path := qrcode + uuid
return c.Get(path) return c.client.Get(path)
} }
// CheckLogin 检查是否登录 // CheckLogin 检查是否登录
@ -133,8 +154,9 @@ func (c *Client) CheckLogin(uuid, tip string) (*http.Response, error) {
} }
// GetLoginInfo 请求获取LoginInfo // GetLoginInfo 请求获取LoginInfo
func (c *Client) GetLoginInfo(path string) (*http.Response, error) { func (c *Client) GetLoginInfo(path *url.URL) (*http.Response, error) {
return c.mode.GetLoginInfo(c, path) c.Domain = WechatDomain(path.Host)
return c.mode.GetLoginInfo(c, path.String())
} }
// WebInit 请求获取初始化信息 // WebInit 请求获取初始化信息
@ -292,6 +314,8 @@ func (c *Client) WebWxGetHeadImg(user *User) (*http.Response, error) {
return c.Do(req) return c.Do(req)
} }
// WebWxUploadMediaByChunk 分块上传文件
// TODO 优化掉这个函数
func (c *Client) WebWxUploadMediaByChunk(file *os.File, request *BaseRequest, info *LoginInfo, forUserName, toUserName string) (*http.Response, error) { func (c *Client) WebWxUploadMediaByChunk(file *os.File, request *BaseRequest, info *LoginInfo, forUserName, toUserName string) (*http.Response, error) {
// 获取文件上传的类型 // 获取文件上传的类型
contentType, err := GetFileContentType(file) contentType, err := GetFileContentType(file)
@ -335,8 +359,12 @@ func (c *Client) WebWxUploadMediaByChunk(file *os.File, request *BaseRequest, in
path.RawQuery = params.Encode() path.RawQuery = params.Encode()
cookies := c.Jar.Cookies(path) cookies := c.Jar().Cookies(path)
webWxDataTicket := getWebWxDataTicket(cookies)
webWxDataTicket, err := getWebWxDataTicket(cookies)
if err != nil {
return nil, err
}
uploadMediaRequest := map[string]interface{}{ uploadMediaRequest := map[string]interface{}{
"UploadType": 2, "UploadType": 2,
@ -388,16 +416,17 @@ func (c *Client) WebWxUploadMediaByChunk(file *os.File, request *BaseRequest, in
return nil, err return nil, err
} }
var chunkBuff = make([]byte, chunkSize)
var formBuffer = bytes.NewBuffer(nil)
// 分块上传 // 分块上传
for chunk := 0; int64(chunk) < chunks; chunk++ { for chunk := 0; int64(chunk) < chunks; chunk++ {
isLastTime := int64(chunk)+1 == chunks
if chunks > 1 { if chunks > 1 {
content["chunk"] = strconv.Itoa(chunk) content["chunk"] = strconv.Itoa(chunk)
} }
var formBuffer = bytes.NewBuffer(nil) formBuffer.Reset()
writer := multipart.NewWriter(formBuffer) writer := multipart.NewWriter(formBuffer)
@ -412,34 +441,33 @@ func (c *Client) WebWxUploadMediaByChunk(file *os.File, request *BaseRequest, in
} }
w, err := writer.CreateFormFile("filename", file.Name()) w, err := writer.CreateFormFile("filename", file.Name())
if err != nil { if err != nil {
return nil, err return nil, err
} }
chunkData := make([]byte, chunkSize) n, err := file.Read(chunkBuff)
n, err := file.Read(chunkData)
if err != nil && err != io.EOF { if err != nil && err != io.EOF {
return nil, err return nil, err
} }
if _, err = w.Write(chunkBuff[:n]); err != nil {
if _, err = w.Write(chunkData[:n]); err != nil {
return nil, err return nil, err
} }
ct := writer.FormDataContentType() ct := writer.FormDataContentType()
if err = writer.Close(); err != nil { if err = writer.Close(); err != nil {
return nil, err return nil, err
} }
req, _ := http.NewRequest(http.MethodPost, path.String(), formBuffer) req, _ := http.NewRequest(http.MethodPost, path.String(), formBuffer)
req.Header.Set("Content-Type", ct) req.Header.Set("Content-Type", ct)
// 发送数据 // 发送数据
resp, err = c.Do(req) resp, err = c.Do(req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
isLastTime := int64(chunk)+1 == chunks
// 如果不是最后一次, 解析有没有错误 // 如果不是最后一次, 解析有没有错误
if !isLastTime { if !isLastTime {
parser := MessageResponseParser{Reader: resp.Body} parser := MessageResponseParser{Reader: resp.Body}
@ -569,13 +597,18 @@ func (c *Client) WebWxGetVideo(msg *Message, info *LoginInfo) (*http.Response, e
// WebWxGetMedia 获取文件消息的文件响应 // WebWxGetMedia 获取文件消息的文件响应
func (c *Client) WebWxGetMedia(msg *Message, info *LoginInfo) (*http.Response, error) { func (c *Client) WebWxGetMedia(msg *Message, info *LoginInfo) (*http.Response, error) {
path, _ := url.Parse(c.Domain.FileHost() + webwxgetmedia) path, _ := url.Parse(c.Domain.FileHost() + webwxgetmedia)
cookies := c.Jar().Cookies(path)
webWxDataTicket, err := getWebWxDataTicket(cookies)
if err != nil {
return nil, err
}
params := url.Values{} params := url.Values{}
params.Add("sender", msg.FromUserName) params.Add("sender", msg.FromUserName)
params.Add("mediaid", msg.MediaId) params.Add("mediaid", msg.MediaId)
params.Add("encryfilename", msg.EncryFileName) params.Add("encryfilename", msg.EncryFileName)
params.Add("fromuser", strconv.FormatInt(info.WxUin, 10)) params.Add("fromuser", strconv.FormatInt(info.WxUin, 10))
params.Add("pass_ticket", info.PassTicket) params.Add("pass_ticket", info.PassTicket)
params.Add("webwx_data_ticket", getWebWxDataTicket(c.Jar.Cookies(path))) params.Add("webwx_data_ticket", webWxDataTicket)
path.RawQuery = params.Encode() path.RawQuery = params.Encode()
req, _ := http.NewRequest(http.MethodGet, path.String(), nil) req, _ := http.NewRequest(http.MethodGet, path.String(), nil)
req.Header.Add("Referer", c.Domain.BaseHost()+"/") req.Header.Add("Referer", c.Domain.BaseHost()+"/")
@ -596,6 +629,14 @@ func (c *Client) Logout(info *LoginInfo) (*http.Response, error) {
// AddMemberIntoChatRoom 添加用户进群聊 // AddMemberIntoChatRoom 添加用户进群聊
func (c *Client) AddMemberIntoChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*Friend) (*http.Response, error) { func (c *Client) AddMemberIntoChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*Friend) (*http.Response, error) {
if len(group.MemberList) >= 40 {
return c.InviteMemberIntoChatRoom(req, info, group, friends...)
}
return c.addMemberIntoChatRoom(req, info, group, friends...)
}
// addMemberIntoChatRoom 添加用户进群聊
func (c *Client) addMemberIntoChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*Friend) (*http.Response, error) {
path, _ := url.Parse(c.Domain.BaseHost() + webwxupdatechatroom) path, _ := url.Parse(c.Domain.BaseHost() + webwxupdatechatroom)
params := url.Values{} params := url.Values{}
params.Add("fun", "addmember") params.Add("fun", "addmember")
@ -617,6 +658,29 @@ func (c *Client) AddMemberIntoChatRoom(req *BaseRequest, info *LoginInfo, group
return c.Do(requ) return c.Do(requ)
} }
// InviteMemberIntoChatRoom 邀请用户进群聊
func (c *Client) InviteMemberIntoChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*Friend) (*http.Response, error) {
path, _ := url.Parse(c.Domain.BaseHost() + webwxupdatechatroom)
params := url.Values{}
params.Add("fun", "invitemember")
params.Add("pass_ticket", info.PassTicket)
params.Add("lang", "zh_CN")
path.RawQuery = params.Encode()
addMemberList := make([]string, len(friends))
for index, friend := range friends {
addMemberList[index] = friend.UserName
}
content := map[string]interface{}{
"ChatRoomName": group.UserName,
"BaseRequest": req,
"InviteMemberList": strings.Join(addMemberList, ","),
}
buffer, _ := ToBuffer(content)
requ, _ := http.NewRequest(http.MethodPost, path.String(), buffer)
requ.Header.Set("Content-Type", jsonContentType)
return c.Do(requ)
}
// RemoveMemberFromChatRoom 从群聊中移除用户 // RemoveMemberFromChatRoom 从群聊中移除用户
func (c *Client) RemoveMemberFromChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*User) (*http.Response, error) { func (c *Client) RemoveMemberFromChatRoom(req *BaseRequest, info *LoginInfo, group *Group, friends ...*User) (*http.Response, error) {
path, _ := url.Parse(c.Domain.BaseHost() + webwxupdatechatroom) path, _ := url.Parse(c.Domain.BaseHost() + webwxupdatechatroom)

View File

@ -9,6 +9,7 @@ import (
) )
// Jar is a struct which as same as cookiejar.Jar // Jar is a struct which as same as cookiejar.Jar
// cookiejar.Jar's fields are private, so we can't use it directly
type Jar struct { type Jar struct {
PsList cookiejar.PublicSuffixList PsList cookiejar.PublicSuffixList
@ -16,7 +17,7 @@ type Jar struct {
mu sync.Mutex mu sync.Mutex
// Entries is a set of entries, keyed by their eTLD+1 and subkeyed by // Entries is a set of entries, keyed by their eTLD+1 and subkeyed by
// their name/domain/path. // their name/Domain/path.
Entries map[string]map[string]entry Entries map[string]map[string]entry
// nextSeqNum is the next sequence number assigned to a new cookie // nextSeqNum is the next sequence number assigned to a new cookie
@ -29,15 +30,15 @@ func (j *Jar) AsCookieJar() http.CookieJar {
return (*cookiejar.Jar)(unsafe.Pointer(j)) return (*cookiejar.Jar)(unsafe.Pointer(j))
} }
func newCookieJar() http.CookieJar {
jar, _ := cookiejar.New(nil)
return jar
}
func fromCookieJar(jar http.CookieJar) *Jar { func fromCookieJar(jar http.CookieJar) *Jar {
return (*Jar)(unsafe.Pointer(jar.(*cookiejar.Jar))) return (*Jar)(unsafe.Pointer(jar.(*cookiejar.Jar)))
} }
func NewJar() *Jar {
jar, _ := cookiejar.New(nil)
return fromCookieJar(jar)
}
type entry struct { type entry struct {
Name string Name string
Value string Value string
@ -57,3 +58,24 @@ type entry struct {
// equal Creation time. This simplifies testing. // equal Creation time. This simplifies testing.
seqNum uint64 seqNum uint64
} }
// CookieGroup is a group of cookies
type CookieGroup []*http.Cookie
func (c CookieGroup) GetByName(cookieName string) (cookie *http.Cookie, exist bool) {
for _, cookie := range c {
if cookie.Name == cookieName {
return cookie, true
}
}
return nil, false
}
func getWebWxDataTicket(cookies []*http.Cookie) (string, error) {
cookieGroup := CookieGroup(cookies)
cookie, exist := cookieGroup.GetByName("webwx_data_ticket")
if !exist {
return "", ErrWebWxDataTicketNotFound
}
return cookie.Value, nil
}

View File

@ -2,6 +2,8 @@ package openwechat
import ( import (
"errors" "errors"
"fmt"
"net/url"
) )
/* /*
@ -55,9 +57,9 @@ type WebInitResponse struct {
SKey string SKey string
BaseResponse BaseResponse BaseResponse BaseResponse
SyncKey SyncKey SyncKey SyncKey
User User User *User
MPSubscribeMsgList []MPSubscribeMsg MPSubscribeMsgList []*MPSubscribeMsg
ContactList []User ContactList Members
} }
// MPSubscribeMsg 公众号的订阅信息 // MPSubscribeMsg 公众号的订阅信息
@ -66,12 +68,14 @@ type MPSubscribeMsg struct {
Time int64 Time int64
UserName string UserName string
NickName string NickName string
MPArticleList []struct { MPArticleList []*MPArticle
Title string }
Cover string
Digest string type MPArticle struct {
Url string Title string
} Cover string
Digest string
Url string
} }
type UserDetailItem struct { type UserDetailItem struct {
@ -117,9 +121,49 @@ type WebWxBatchContactResponse struct {
ContactList []*User ContactList []*User
} }
type CheckLoginResponse struct { // CheckLoginResponse 检查登录状态的响应body
Code string type CheckLoginResponse []byte
Raw []byte
// RedirectURL 重定向的URL
func (c CheckLoginResponse) RedirectURL() (*url.URL, error) {
code, err := c.Code()
if err != nil {
return nil, err
}
if code != LoginCodeSuccess {
return nil, fmt.Errorf("expect status code %s, but got %s", LoginCodeSuccess, code)
}
results := redirectUriRegexp.FindSubmatch(c)
if len(results) != 2 {
return nil, errors.New("redirect url does not match")
}
return url.Parse(string(results[1]))
}
// Code 获取当前的登录检查状态的代码
func (c CheckLoginResponse) Code() (LoginCode, error) {
results := statusCodeRegexp.FindSubmatch(c)
if len(results) != 2 {
return "", errors.New("error status code match")
}
code := string(results[1])
return LoginCode(code), nil
}
// Avatar 获取扫码后的用户头像, base64编码
func (c CheckLoginResponse) Avatar() (string, error) {
code, err := c.Code()
if err != nil {
return "", err
}
if code != LoginCodeScanned {
return "", nil
}
results := avatarRegexp.FindSubmatch(c)
if len(results) != 2 {
return "", errors.New("avatar does not match")
}
return string(results[1]), nil
} }
type MessageResponse struct { type MessageResponse struct {

View File

@ -32,6 +32,9 @@ var (
// ErrLoginTimeout define login timeout error // ErrLoginTimeout define login timeout error
ErrLoginTimeout = errors.New("login timeout") ErrLoginTimeout = errors.New("login timeout")
// ErrWebWxDataTicketNotFound define webwx_data_ticket not found error
ErrWebWxDataTicketNotFound = errors.New("webwx_data_ticket not found")
) )
// Error impl error interface // Error impl error interface

View File

@ -7,6 +7,7 @@ import (
var ( var (
uuidRegexp = regexp.MustCompile(`uuid = "(.*?)";`) uuidRegexp = regexp.MustCompile(`uuid = "(.*?)";`)
statusCodeRegexp = regexp.MustCompile(`window.code=(\d+);`) statusCodeRegexp = regexp.MustCompile(`window.code=(\d+);`)
avatarRegexp = regexp.MustCompile(`window.userAvatar = '(.*)';`)
syncCheckRegexp = regexp.MustCompile(`window.synccheck=\{retcode:"(\d+)",selector:"(\d+)"\}`) syncCheckRegexp = regexp.MustCompile(`window.synccheck=\{retcode:"(\d+)",selector:"(\d+)"\}`)
redirectUriRegexp = regexp.MustCompile(`window.redirect_uri="(.*?)"`) redirectUriRegexp = regexp.MustCompile(`window.redirect_uri="(.*?)"`)
) )
@ -97,14 +98,6 @@ const (
AppMsgTypeReaderType AppMessageType = 100001 //自定义的消息 AppMsgTypeReaderType AppMessageType = 100001 //自定义的消息
) )
// 登录状态
const (
StatusSuccess = "200"
StatusScanned = "201"
StatusTimeout = "400"
StatusWait = "408"
)
// ALL 跟search函数搭配 // ALL 跟search函数搭配
// //
// friends.Search(openwechat.ALL, ) // friends.Search(openwechat.ALL, )

View File

@ -791,7 +791,7 @@ func (m *Message) IsAt() bool {
// IsPaiYiPai 判断消息是否为拍一拍 // IsPaiYiPai 判断消息是否为拍一拍
// 不要问我为什么取名为PaiYiPai因为我也不知道取啥名字好 // 不要问我为什么取名为PaiYiPai因为我也不知道取啥名字好
func (m *Message) IsPaiYiPai() bool { func (m *Message) IsPaiYiPai() bool {
return m.IsSystem() && strings.Contains(m.Content, "拍了拍") return m.IsTickled()
} }
// IsJoinGroup 判断是否有人加入了群聊 // IsJoinGroup 判断是否有人加入了群聊
@ -801,7 +801,12 @@ func (m *Message) IsJoinGroup() bool {
// IsTickled 判断消息是否为拍一拍 // IsTickled 判断消息是否为拍一拍
func (m *Message) IsTickled() bool { func (m *Message) IsTickled() bool {
return m.IsPaiYiPai() return m.IsSystem() && strings.Contains(m.Content, "拍了拍")
}
// IsTickledMe 判断消息是否拍了拍自己
func (m *Message) IsTickledMe() bool {
return m.IsSystem() && strings.Count(m.Content, "拍了拍我") == 1
} }
// IsVoipInvite 判断消息是否为语音或视频通话邀请 // IsVoipInvite 判断消息是否为语音或视频通话邀请

View File

@ -11,14 +11,6 @@ type MessageDispatcher interface {
Dispatch(msg *Message) Dispatch(msg *Message)
} }
// DispatchMessage 跟 MessageDispatcher 结合封装成 MessageHandler
// Deprecated: use MessageMatchDispatcher.AsMessageHandler instead
func DispatchMessage(dispatcher MessageDispatcher) func(msg *Message) {
return func(msg *Message) { dispatcher.Dispatch(msg) }
}
// MessageDispatcher impl
// MessageContextHandler 消息处理函数 // MessageContextHandler 消息处理函数
type MessageContextHandler func(ctx *MessageContext) type MessageContextHandler func(ctx *MessageContext)

View File

@ -38,15 +38,6 @@ func GetRandomDeviceId() string {
return builder.String() return builder.String()
} }
func getWebWxDataTicket(cookies []*http.Cookie) string {
for _, cookie := range cookies {
if cookie.Name == "webwx_data_ticket" {
return cookie.Value
}
}
return ""
}
// GetFileContentType 获取文件上传的类型 // GetFileContentType 获取文件上传的类型
func GetFileContentType(file multipart.File) (string, error) { func GetFileContentType(file multipart.File) (string, error) {
data := make([]byte, 512) data := make([]byte, 512)

View File

@ -9,8 +9,12 @@ import (
type Friend struct{ *User } type Friend struct{ *User }
// implement fmt.Stringer // implement fmt.Stringer
func (f Friend) String() string { func (f *Friend) String() string {
return fmt.Sprintf("<Friend:%s>", f.NickName) display := f.NickName
if f.RemarkName != "" {
display = f.RemarkName
}
return fmt.Sprintf("<Friend:%s>", display)
} }
// SetRemarkName 重命名当前好友 // SetRemarkName 重命名当前好友
@ -53,7 +57,7 @@ func (f Friends) Count() int {
// First 获取第一个好友 // First 获取第一个好友
func (f Friends) First() *Friend { func (f Friends) First() *Friend {
if f.Count() > 0 { if f.Count() > 0 {
return f[0] return f.Sort()[0]
} }
return nil return nil
} }
@ -61,7 +65,7 @@ func (f Friends) First() *Friend {
// Last 获取最后一个好友 // Last 获取最后一个好友
func (f Friends) Last() *Friend { func (f Friends) Last() *Friend {
if f.Count() > 0 { if f.Count() > 0 {
return f[f.Count()-1] return f.Sort()[f.Count()-1]
} }
return nil return nil
} }
@ -155,7 +159,7 @@ func (f Friends) SendFile(file io.Reader, delay ...time.Duration) error {
type Group struct{ *User } type Group struct{ *User }
// implement fmt.Stringer // implement fmt.Stringer
func (g Group) String() string { func (g *Group) String() string {
return fmt.Sprintf("<Group:%s>", g.NickName) return fmt.Sprintf("<Group:%s>", g.NickName)
} }
@ -238,7 +242,7 @@ func (g Groups) Count() int {
// First 获取第一个群组 // First 获取第一个群组
func (g Groups) First() *Group { func (g Groups) First() *Group {
if g.Count() > 0 { if g.Count() > 0 {
return g[0] return g.Sort()[0]
} }
return nil return nil
} }
@ -246,7 +250,7 @@ func (g Groups) First() *Group {
// Last 获取最后一个群组 // Last 获取最后一个群组
func (g Groups) Last() *Group { func (g Groups) Last() *Group {
if g.Count() > 0 { if g.Count() > 0 {
return g[g.Count()-1] return g.Sort()[g.Count()-1]
} }
return nil return nil
} }
@ -300,11 +304,6 @@ func (g Groups) SearchByNickName(limit int, nickName string) (results Groups) {
return g.Search(limit, func(group *Group) bool { return group.NickName == nickName }) return g.Search(limit, func(group *Group) bool { return group.NickName == nickName })
} }
// SearchByRemarkName 根据备注查找群组
func (g Groups) SearchByRemarkName(limit int, remarkName string) (results Groups) {
return g.Search(limit, func(group *Group) bool { return group.RemarkName == remarkName })
}
// Search 根据自定义条件查找群组 // Search 根据自定义条件查找群组
func (g Groups) Search(limit int, searchFuncList ...func(group *Group) bool) (results Groups) { func (g Groups) Search(limit int, searchFuncList ...func(group *Group) bool) (results Groups) {
return g.AsMembers().Search(limit, func(user *User) bool { return g.AsMembers().Search(limit, func(user *User) bool {
@ -340,7 +339,7 @@ func (g Groups) Uniq() Groups {
// Mp 公众号对象 // Mp 公众号对象
type Mp struct{ *User } type Mp struct{ *User }
func (m Mp) String() string { func (m *Mp) String() string {
return fmt.Sprintf("<Mp:%s>", m.NickName) return fmt.Sprintf("<Mp:%s>", m.NickName)
} }
@ -355,7 +354,7 @@ func (m Mps) Count() int {
// First 获取第一个 // First 获取第一个
func (m Mps) First() *Mp { func (m Mps) First() *Mp {
if m.Count() > 0 { if m.Count() > 0 {
return m[0] return m.Sort()[0]
} }
return nil return nil
} }
@ -363,7 +362,7 @@ func (m Mps) First() *Mp {
// Last 获取最后一个 // Last 获取最后一个
func (m Mps) Last() *Mp { func (m Mps) Last() *Mp {
if m.Count() > 0 { if m.Count() > 0 {
return m[m.Count()-1] return m.Sort()[m.Count()-1]
} }
return nil return nil
} }
@ -445,11 +444,6 @@ func (g Groups) GetByUsername(username string) *Group {
return g.SearchByUserName(1, username).First() return g.SearchByUserName(1, username).First()
} }
// GetByRemarkName 根据remarkName查询一个Group
func (g Groups) GetByRemarkName(remarkName string) *Group {
return g.SearchByRemarkName(1, remarkName).First()
}
// GetByNickName 根据nickname查询一个Group // GetByNickName 根据nickname查询一个Group
func (g Groups) GetByNickName(nickname string) *Group { func (g Groups) GetByNickName(nickname string) *Group {
return g.SearchByNickName(1, nickname).First() return g.SearchByNickName(1, nickname).First()

25
serializer.go Normal file
View File

@ -0,0 +1,25 @@
package openwechat
import (
"encoding/json"
"io"
)
// Serializer is an interface for encoding and decoding data.
type Serializer interface {
Encode(writer io.Writer, v interface{}) error
Decode(reader io.Reader, v interface{}) error
}
// JsonSerializer is a serializer for json.
type JsonSerializer struct{}
// Encode encodes v to writer.
func (j JsonSerializer) Encode(writer io.Writer, v interface{}) error {
return json.NewEncoder(writer).Encode(v)
}
// Decode decodes data from reader to v.
func (j JsonSerializer) Decode(reader io.Reader, v interface{}) error {
return json.NewDecoder(reader).Decode(v)
}

View File

@ -84,6 +84,8 @@ bot.Login()
// 创建热存储容器对象 // 创建热存储容器对象
reloadStorage := openwechat.NewJsonFileHotReloadStorage("storage.json") reloadStorage := openwechat.NewJsonFileHotReloadStorage("storage.json")
defer reloadStorage.Close()
// 执行热登录 // 执行热登录
bot.HotLogin(reloadStorage) bot.HotLogin(reloadStorage)
``` ```
@ -95,7 +97,7 @@ bot.HotLogin(reloadStorage)
我们只需要在`HotLogin`增加一个参数,让它在失败后执行扫码登录即可 我们只需要在`HotLogin`增加一个参数,让它在失败后执行扫码登录即可
```go ```go
bot.HotLogin(reloadStorage, openwechat.HotLoginWithRetry(true)) bot.HotLogin(reloadStorage, openwechat.NewRetryLoginOption())
``` ```
当扫码登录成功后,会将会话信息写入到`热存储容器`中,下次再执行热登录的时候就会从`热存储容器`中读取会话信息,直接登录成功。 当扫码登录成功后,会将会话信息写入到`热存储容器`中,下次再执行热登录的时候就会从`热存储容器`中读取会话信息,直接登录成功。
@ -119,29 +121,23 @@ type HotReloadStorage io.ReadWriter
openwechat也提供了这样的功能。 openwechat也提供了这样的功能。
```go ```go
bot.PushLogin(storage HotReloadStorage, opts ...PushLoginOptionFunc) error bot.PushLogin(storage HotReloadStorage, opts ...openwechat.BotLoginOption) error
``` ```
`PushLogin`需要传入一个`热存储容器`,和一些可选参数。 `PushLogin`需要传入一个`热存储容器`,和一些可选参数。
`HotReloadStorage` 跟上面一样,用来保存会话信息,必要参数。 `HotReloadStorage` 跟上面一样,用来保存会话信息,必要参数。
`PushLoginOptionFunc`是一个可选参数,用来设置一些额外的行为。 `openwechat.BotLoginOption`是一个可选参数,用来设置一些额外的行为。
目前有下面几个可选参数: 目前有下面几个可选参数:
```go ```go
// PushLoginWithoutUUIDCallback 设置 PushLogin 不执行二维码回调, 默认为 true // NewSyncReloadDataLoginOption 登录成功后定时同步热存储容器数据
func PushLoginWithoutUUIDCallback(flag bool) PushLoginOptionFunc func NewSyncReloadDataLoginOption(duration time.Duration) BotLoginOption
// PushLoginWithoutScanCallback 设置 PushLogin 不执行扫码回调, 默认为true // NewRetryLoginOption 登录失败后进行扫码登录
func PushLoginWithoutScanCallback(flag bool) PushLoginOptionFunc func NewRetryLoginOption() BotLoginOption
// PushLoginWithoutLoginCallback 设置 PushLogin 不执行登录回调默认为false
func PushLoginWithoutLoginCallback(flag bool) PushLoginOptionFunc
// PushLoginWithRetry 设置 PushLogin 失败后执行扫码登录默认为false
func PushLoginWithRetry(flag bool) PushLoginOptionFunc
``` ```
注意:如果是第一次登录,``PushLogin`` 一定会失败的,因为我们的`HotReloadStorage`里面没有会话信息,你需要设置失败会进行扫码登录。 注意:如果是第一次登录,``PushLogin`` 一定会失败的,因为我们的`HotReloadStorage`里面没有会话信息,你需要设置失败会进行扫码登录。
@ -149,7 +145,8 @@ func PushLoginWithRetry(flag bool) PushLoginOptionFunc
```go ```go
bot := openwechat.DefaultBot() bot := openwechat.DefaultBot()
reloadStorage := openwechat.NewJsonFileHotReloadStorage("storage.json") reloadStorage := openwechat.NewJsonFileHotReloadStorage("storage.json")
err = bot.PushLogin(reloadStorage, openwechat.PushLoginWithRetry(true)) defer reloadStorage.Close()
err = bot.PushLogin(reloadStorage, openwechat.NewRetryLoginOption())
``` ```
这样当第一次登录失败的时候,会自动执行扫码登录。 这样当第一次登录失败的时候,会自动执行扫码登录。
@ -164,13 +161,21 @@ err = bot.PushLogin(reloadStorage, openwechat.PushLoginWithRetry(true))
通过对`bot`对象绑定扫码回调即可实现对应的功能。 通过对`bot`对象绑定扫码回调即可实现对应的功能。
```go ```go
bot.ScanCallBack = func(body []byte) { fmt.Println(string(body)) } bot.ScanCallBack = func(body openwechat.CheckLoginResponse) { fmt.Println(string(body)) }
``` ```
用户扫码后body里面会携带用户的头像信息。 用户扫码后body里面会携带用户的头像信息。
**注**:绑定扫码回调须在登录前执行。 **注**:绑定扫码回调须在登录前执行。
`CheckLoginResponse` 是一个`[]byte`包装类型, 扫码成功后可以通过该类型获取用户的头像信息。
```go
type CheckLoginResponse []byte
func (c CheckLoginResponse) Avatar() (string, error)
```
### 登录回调 ### 登录回调
@ -178,13 +183,13 @@ bot.ScanCallBack = func(body []byte) { fmt.Println(string(body)) }
`bot`对象绑定登录 `bot`对象绑定登录
```go ```go
bot.LoginCallBack = func(body []byte) { bot.LoginCallBack = func(body openwechat.CheckLoginResponse) {
fmt.Println(string(body)) fmt.Println(string(body))
// to do your business // to do your business
} }
``` ```
登录回调的参数就是当前客户端需要跳转的链接,可以不用关心它。 登录回调的参数就是当前客户端需要跳转的链接,用户可以不用关心它。(其实可以拿来做一些骚操作😈)
登录回调函数可以当做一个信号处理,表示当前扫码登录的用户已经确认登录。 登录回调函数可以当做一个信号处理,表示当前扫码登录的用户已经确认登录。
@ -236,7 +241,7 @@ dispatcher.OnText(func(ctx *openwechat.MessageContext){
}) })
// 注册消息回调函数 // 注册消息回调函数
bot.MessageHandler = openwechat.DispatchMessage(dispatcher) bot.MessageHandler = dispatcher.AsMessageHandler()
``` ```
`openwechat.DispatchMessage`会将消息转发给`dispatcher`对象处理 `openwechat.DispatchMessage`会将消息转发给`dispatcher`对象处理

View File

@ -1,7 +1,5 @@
# 消息 # 消息
### 接受消息 ### 接受消息
被动接受的消息对象,由微信服务器发出 被动接受的消息对象,由微信服务器发出
@ -9,29 +7,25 @@
消息对象通过绑定在`bot`上的消息回调函数获取 消息对象通过绑定在`bot`上的消息回调函数获取
```go ```go
bot.MessageHandler = func(msg *openwechat.Message) { bot.MessageHandler = func (msg *openwechat.Message) {
if msg.IsText() && msg.Content == "ping" { if msg.IsText() && msg.Content == "ping" {
msg.ReplyText("pong") msg.ReplyText("pong")
} }
} }
``` ```
以下简写为`msg` 以下简写为`msg`
#### 消息内容 #### 消息内容
```go ```go
msg.Content // 获取消息内容 msg.Content // 获取消息内容
``` ```
通过访问`Content`属性可直接获取消息内容 通过访问`Content`属性可直接获取消息内容
由于消息分为很多种类型,它们都共用`Content`属性。一般当消息类型为文本类型的时候,我们才会去访问`Content`属性。 由于消息分为很多种类型,它们都共用`Content`属性。一般当消息类型为文本类型的时候,我们才会去访问`Content`属性。
#### 消息类型判断 #### 消息类型判断
下面的判断消息类型的方法均返回`bool` 下面的判断消息类型的方法均返回`bool`
@ -117,13 +111,17 @@ msg.IsIsPaiYiPai() // 拍一拍消息
msg.IsTickled() msg.IsTickled()
``` ```
##### 判断是否拍了拍自己
```go
msg.IsTickledMe()
```
##### 判断是否有新人加入群聊 ##### 判断是否有新人加入群聊
```go ```go
msg.IsJoinGroup() msg.IsJoinGroup()
``` ```
#### 获取消息的发送者 #### 获取消息的发送者
```go ```go
@ -132,16 +130,12 @@ sender, err := msg.Sender()
如果是群聊消息,该方法返回的是群聊对象(需要自己将`User`转换为`Group`对象) 如果是群聊消息,该方法返回的是群聊对象(需要自己将`User`转换为`Group`对象)
#### 获取消息的接受者 #### 获取消息的接受者
```go ```go
receiver, err := msg.Receiver() receiver, err := msg.Receiver()
``` ```
#### 获取消息在群里面的发送者 #### 获取消息在群里面的发送者
```go ```go
@ -150,8 +144,6 @@ sender, err := msg.SenderInGroup()
获取群聊中具体发消息的用户,前提该消息必须来自群聊。 获取群聊中具体发消息的用户,前提该消息必须来自群聊。
#### 是否由自己发送 #### 是否由自己发送
```go ```go
@ -164,31 +156,24 @@ msg.IsSendBySelf()
msg.IsTickled() msg.IsTickled()
``` ```
#### 消息是否由好友发出 #### 消息是否由好友发出
```go ```go
msg.IsSendByFriend() msg.IsSendByFriend()
``` ```
#### 消息是否由群聊发出 #### 消息是否由群聊发出
```go ```go
msg.IsSendByGroup() msg.IsSendByGroup()
``` ```
#### 回复文本消息 #### 回复文本消息
```go ```go
msg.ReplyText("hello") msg.ReplyText("hello")
``` ```
#### 回复图片消息 #### 回复图片消息
```go ```go
@ -197,8 +182,6 @@ defer img.Close()
msg.ReplyImage(img) msg.ReplyImage(img)
``` ```
#### 回复文件消息 #### 回复文件消息
```go ```go
@ -207,14 +190,12 @@ defer file.Close()
msg.ReplyFile(file) msg.ReplyFile(file)
``` ```
#### 获取消息里的其他信息 #### 获取消息里的其他信息
##### 名片消息 ##### 名片消息
```go ```go
card, err := msg. Card() card, err := msg.Card()
``` ```
该方法调用的前提为`msg.IsCard()`返回为`true` 该方法调用的前提为`msg.IsCard()`返回为`true`
@ -230,31 +211,29 @@ alias := card.Alias
```go ```go
// 名片消息内容 // 名片消息内容
type Card struct { type Card struct {
XMLName xml.Name `xml:"msg"` XMLName xml.Name `xml:"msg"`
ImageStatus int `xml:"imagestatus,attr"` ImageStatus int `xml:"imagestatus,attr"`
Scene int `xml:"scene,attr"` Scene int `xml:"scene,attr"`
Sex int `xml:"sex,attr"` Sex int `xml:"sex,attr"`
Certflag int `xml:"certflag,attr"` Certflag int `xml:"certflag,attr"`
BigHeadImgUrl string `xml:"bigheadimgurl,attr"` BigHeadImgUrl string `xml:"bigheadimgurl,attr"`
SmallHeadImgUrl string `xml:"smallheadimgurl,attr"` SmallHeadImgUrl string `xml:"smallheadimgurl,attr"`
UserName string `xml:"username,attr"` UserName string `xml:"username,attr"`
NickName string `xml:"nickname,attr"` NickName string `xml:"nickname,attr"`
ShortPy string `xml:"shortpy,attr"` ShortPy string `xml:"shortpy,attr"`
Alias string `xml:"alias,attr"` // Note: 这个是名片用户的微信号 Alias string `xml:"alias,attr"` // Note: 这个是名片用户的微信号
Province string `xml:"province,attr"` Province string `xml:"province,attr"`
City string `xml:"city,attr"` City string `xml:"city,attr"`
Sign string `xml:"sign,attr"` Sign string `xml:"sign,attr"`
Certinfo string `xml:"certinfo,attr"` Certinfo string `xml:"certinfo,attr"`
BrandIconUrl string `xml:"brandIconUrl,attr"` BrandIconUrl string `xml:"brandIconUrl,attr"`
BrandHomeUr string `xml:"brandHomeUr,attr"` BrandHomeUr string `xml:"brandHomeUr,attr"`
BrandSubscriptConfigUrl string `xml:"brandSubscriptConfigUrl,attr"` BrandSubscriptConfigUrl string `xml:"brandSubscriptConfigUrl,attr"`
BrandFlags string `xml:"brandFlags,attr"` BrandFlags string `xml:"brandFlags,attr"`
RegionCode string `xml:"regionCode,attr"` RegionCode string `xml:"regionCode,attr"`
} }
``` ```
##### 获取已撤回的消息 ##### 获取已撤回的消息
```go ```go
@ -267,19 +246,17 @@ revokeMsg, err := msg.RevokeMsg()
```go ```go
type RevokeMsg struct { type RevokeMsg struct {
SysMsg xml.Name `xml:"sysmsg"` SysMsg xml.Name `xml:"sysmsg"`
Type string `xml:"type,attr"` Type string `xml:"type,attr"`
RevokeMsg struct { RevokeMsg struct {
OldMsgId int64 `xml:"oldmsgid"` OldMsgId int64 `xml:"oldmsgid"`
MsgId int64 `xml:"msgid"` MsgId int64 `xml:"msgid"`
Session string `xml:"session"` Session string `xml:"session"`
ReplaceMsg string `xml:"replacemsg"` ReplaceMsg string `xml:"replacemsg"`
} `xml:"revokemsg"` } `xml:"revokemsg"`
} }
``` ```
#### 同意好友请求 #### 同意好友请求
```go ```go
@ -291,8 +268,6 @@ friend, err := msg.Agree()
该方法调用成功的前提是`msg.IsFriendAdd()`返回为`true` 该方法调用成功的前提是`msg.IsFriendAdd()`返回为`true`
#### 设置为已读 #### 设置为已读
```go ```go
@ -301,10 +276,6 @@ msg.AsRead()
该当前消息设置为已读 该当前消息设置为已读
#### 设置消息的上下文 #### 设置消息的上下文
用于多个消息处理函数之间的通信,并且是协程安全的。 用于多个消息处理函数之间的通信,并且是协程安全的。
@ -321,10 +292,6 @@ msg.Set("hello", "world")
value, exist := msg.Get("hello") value, exist := msg.Get("hello")
``` ```
### 已发送消息 ### 已发送消息
已发送消息指当前用户发送出去的消息 已发送消息指当前用户发送出去的消息
@ -339,8 +306,6 @@ sentMsg, err := msg.ReplyText("hello") // 通过回复消息获取
// and so on // and so on
``` ```
#### 撤回消息 #### 撤回消息
撤回刚刚发送的消息撤回消息的有效时间为2分钟超过了这个时间则无法撤回 撤回刚刚发送的消息撤回消息的有效时间为2分钟超过了这个时间则无法撤回
@ -349,16 +314,12 @@ sentMsg, err := msg.ReplyText("hello") // 通过回复消息获取
sentMsg.Revoke() sentMsg.Revoke()
``` ```
#### 判断是否可以撤回 #### 判断是否可以撤回
```go ```go
sentMsg.CanRevoke() sentMsg.CanRevoke()
``` ```
#### 转发给好友 #### 转发给好友
```go ```go
@ -367,8 +328,6 @@ sentMsg.ForwardToFriends(friend1, friend2)
将刚发送的消息转发给好友 将刚发送的消息转发给好友
#### 转发给群聊 #### 转发给群聊
```go ```go
@ -377,10 +336,6 @@ sentMsg.ForwardToGroups(group1, group2)
将刚发送的消息转发给群聊 将刚发送的消息转发给群聊
### Emoji表情 ### Emoji表情
openwechat提供了微信全套`emoji`表情的支持 openwechat提供了微信全套`emoji`表情的支持
@ -392,7 +347,7 @@ emoji表情可以通过发送`Text`类型的函数发送
```go ```go
firend.SendText(openwechat.Emoji.Doge) // 发送狗头表情 firend.SendText(openwechat.Emoji.Doge) // 发送狗头表情
msg.ReplyText(openwechat.Emoji.Awesome) // 发送666的表情 msg.ReplyText(openwechat.Emoji.Awesome) // 发送666的表情
``` ```

View File

@ -3,6 +3,7 @@ package openwechat
import ( import (
"io" "io"
"os" "os"
"sync"
"time" "time"
) )
@ -24,14 +25,17 @@ type HotReloadStorageItem struct {
// HotReloadStorage 热登陆存储接口 // HotReloadStorage 热登陆存储接口
type HotReloadStorage io.ReadWriter type HotReloadStorage io.ReadWriter
// jsonFileHotReloadStorage 实现HotReloadStorage接口 // fileHotReloadStorage 实现HotReloadStorage接口
// 默认json文件的形式存储 // 以文件的形式存储
type jsonFileHotReloadStorage struct { type fileHotReloadStorage struct {
filename string filename string
file *os.File file *os.File
lock sync.Mutex
} }
func (j *jsonFileHotReloadStorage) Read(p []byte) (n int, err error) { func (j *fileHotReloadStorage) Read(p []byte) (n int, err error) {
j.lock.Lock()
defer j.lock.Unlock()
if j.file == nil { if j.file == nil {
j.file, err = os.OpenFile(j.filename, os.O_RDWR, 0600) j.file, err = os.OpenFile(j.filename, os.O_RDWR, 0600)
if os.IsNotExist(err) { if os.IsNotExist(err) {
@ -44,38 +48,47 @@ func (j *jsonFileHotReloadStorage) Read(p []byte) (n int, err error) {
return j.file.Read(p) return j.file.Read(p)
} }
func (j *jsonFileHotReloadStorage) Write(p []byte) (n int, err error) { func (j *fileHotReloadStorage) Write(p []byte) (n int, err error) {
j.lock.Lock()
defer j.lock.Unlock()
if j.file == nil { if j.file == nil {
j.file, err = os.Create(j.filename) j.file, err = os.Create(j.filename)
if err != nil { if err != nil {
return 0, err return 0, err
} }
} }
// 为什么这里要对文件进行Truncate操作呢? // reset offset and truncate file
// 这是为了方便每次Dump的时候对文件进行重新写入, 而不是追加
// json序列化写入只会调用一次Write方法, 所以不要把这个方法当成io.Writer的Write方法
if _, err = j.file.Seek(0, io.SeekStart); err != nil { if _, err = j.file.Seek(0, io.SeekStart); err != nil {
return return
} }
if err = j.file.Truncate(0); err != nil { if err = j.file.Truncate(0); err != nil {
return return
} }
// json decode only write once
return j.file.Write(p) return j.file.Write(p)
} }
func (j *jsonFileHotReloadStorage) Close() error { func (j *fileHotReloadStorage) Close() error {
j.lock.Lock()
defer j.lock.Unlock()
if j.file == nil { if j.file == nil {
return nil return nil
} }
return j.file.Close() return j.file.Close()
} }
// NewJsonFileHotReloadStorage 创建JsonFileHotReloadStorage // Deprecated: use NewFileHotReloadStorage instead
// 不再单纯以json的格式存储支持了用户自定义序列化方式
func NewJsonFileHotReloadStorage(filename string) io.ReadWriteCloser { func NewJsonFileHotReloadStorage(filename string) io.ReadWriteCloser {
return &jsonFileHotReloadStorage{filename: filename} return NewFileHotReloadStorage(filename)
} }
var _ HotReloadStorage = (*jsonFileHotReloadStorage)(nil) // NewFileHotReloadStorage implements HotReloadStorage
func NewFileHotReloadStorage(filename string) io.ReadWriteCloser {
return &fileHotReloadStorage{filename: filename}
}
var _ HotReloadStorage = (*fileHotReloadStorage)(nil)
type HotReloadStorageSyncer struct { type HotReloadStorageSyncer struct {
duration time.Duration duration time.Duration

View File

@ -25,7 +25,11 @@ func (s SyncCheckResponse) Success() bool {
} }
func (s SyncCheckResponse) NorMal() bool { func (s SyncCheckResponse) NorMal() bool {
return s.Success() && s.Selector == "0" return s.Success() && s.Selector == SelectorNormal
}
func (s SyncCheckResponse) HasNewMessage() bool {
return s.Success() && s.Selector == SelectorNewMsg
} }
func (s SyncCheckResponse) Err() error { func (s SyncCheckResponse) Err() error {

46
user.go
View File

@ -58,14 +58,14 @@ type User struct {
// implement fmt.Stringer // implement fmt.Stringer
func (u *User) String() string { func (u *User) String() string {
format := "User" format := "User"
if u.IsFriend() { if u.IsSelf() {
format = "Self"
} else if u.IsFriend() {
format = "Friend" format = "Friend"
} else if u.IsGroup() { } else if u.IsGroup() {
format = "Group" format = "Group"
} else if u.IsMP() { } else if u.IsMP() {
format = "MP" format = "MP"
} else if u.IsSelf() {
format = "Self"
} }
return fmt.Sprintf("<%s:%s>", format, u.NickName) return fmt.Sprintf("<%s:%s>", format, u.NickName)
} }
@ -263,6 +263,7 @@ func (s *Self) Members(update ...bool) (Members, error) {
return nil, err return nil, err
} }
} }
s.members.Sort()
return s.members, nil return s.members, nil
} }
@ -287,13 +288,18 @@ func (s *Self) FileHelper() *Friend {
} }
return s.fileHelper return s.fileHelper
} }
func (s *Self) ChkFrdGrpMpNil() bool {
return s.friends == nil && s.groups == nil && s.mps == nil
}
// Friends 获取所有的好友 // Friends 获取所有的好友
func (s *Self) Friends(update ...bool) (Friends, error) { func (s *Self) Friends(update ...bool) (Friends, error) {
if s.friends == nil || (len(update) > 0 && update[0]) { if (len(update) > 0 && update[0]) || s.ChkFrdGrpMpNil() {
if _, err := s.Members(true); err != nil { if _, err := s.Members(true); err != nil {
return nil, err return nil, err
} }
}
if s.friends == nil || (len(update) > 0 && update[0]) {
s.friends = s.members.Friends() s.friends = s.members.Friends()
} }
return s.friends, nil return s.friends, nil
@ -301,10 +307,14 @@ func (s *Self) Friends(update ...bool) (Friends, error) {
// Groups 获取所有的群组 // Groups 获取所有的群组
func (s *Self) Groups(update ...bool) (Groups, error) { func (s *Self) Groups(update ...bool) (Groups, error) {
if s.groups == nil || (len(update) > 0 && update[0]) {
if (len(update) > 0 && update[0]) || s.ChkFrdGrpMpNil() {
if _, err := s.Members(true); err != nil { if _, err := s.Members(true); err != nil {
return nil, err return nil, err
} }
}
if s.groups == nil || (len(update) > 0 && update[0]) {
s.groups = s.members.Groups() s.groups = s.members.Groups()
} }
return s.groups, nil return s.groups, nil
@ -312,10 +322,12 @@ func (s *Self) Groups(update ...bool) (Groups, error) {
// Mps 获取所有的公众号 // Mps 获取所有的公众号
func (s *Self) Mps(update ...bool) (Mps, error) { func (s *Self) Mps(update ...bool) (Mps, error) {
if s.mps == nil || (len(update) > 0 && update[0]) { if (len(update) > 0 && update[0]) || s.ChkFrdGrpMpNil() {
if _, err := s.Members(true); err != nil { if _, err := s.Members(true); err != nil {
return nil, err return nil, err
} }
}
if s.mps == nil || (len(update) > 0 && update[0]) {
s.mps = s.members.MPs() s.mps = s.members.MPs()
} }
return s.mps, nil return s.mps, nil
@ -667,6 +679,16 @@ func (s *Self) SendVideoToGroups(video io.Reader, delay time.Duration, groups ..
return s.sendVideoToMembers(video, delay, members...) return s.sendVideoToMembers(video, delay, members...)
} }
// ContactList 获取最近的联系人列表
func (s *Self) ContactList() Members {
return s.Bot().Storage.Response.ContactList
}
// MPSubscribeList 获取部分公众号文章列表
func (s *Self) MPSubscribeList() []*MPSubscribeMsg {
return s.Bot().Storage.Response.MPSubscribeMsgList
}
// Members 抽象的用户组 // Members 抽象的用户组
type Members []*User type Members []*User
@ -776,8 +798,8 @@ func (m Members) GetByNickName(nickname string) (*User, bool) {
func (m Members) Friends() Friends { func (m Members) Friends() Friends {
friends := make(Friends, 0) friends := make(Friends, 0)
for _, mb := range m { for _, mb := range m {
if mb.IsFriend() { friend, ok := mb.AsFriend()
friend := &Friend{mb} if ok {
friends = append(friends, friend) friends = append(friends, friend)
} }
} }
@ -787,8 +809,8 @@ func (m Members) Friends() Friends {
func (m Members) Groups() Groups { func (m Members) Groups() Groups {
groups := make(Groups, 0) groups := make(Groups, 0)
for _, mb := range m { for _, mb := range m {
if mb.IsGroup() { group, ok := mb.AsGroup()
group := &Group{mb} if ok {
groups = append(groups, group) groups = append(groups, group)
} }
} }
@ -798,8 +820,8 @@ func (m Members) Groups() Groups {
func (m Members) MPs() Mps { func (m Members) MPs() Mps {
mps := make(Mps, 0) mps := make(Mps, 0)
for _, mb := range m { for _, mb := range m {
if mb.IsMP() { mp, ok := mb.AsMP()
mp := &Mp{mb} if ok {
mps = append(mps, mp) mps = append(mps, mp)
} }
} }