diff --git a/bot.go b/bot.go index 4384717..c0597d5 100644 --- a/bot.go +++ b/bot.go @@ -1,6 +1,21 @@ package nano -import "time" +import ( + "encoding/base64" + "encoding/json" + "net" + "net/http" + "reflect" + "sync" + "time" + "unsafe" + + "github.com/RomiChan/syncx" + "github.com/RomiChan/websocket" + log "github.com/sirupsen/logrus" +) + +var clients = syncx.Map[string, *Bot]{} // Bot 一个机器人实例的配置 type Bot struct { @@ -11,15 +26,158 @@ type Bot struct { Timeout time.Duration // Timeout is API 调用超时 Handler *Handler // Handler 注册对各种事件的处理 - handlers map[string]GeneralHandleType // handlers 方便调用的 handler + gateway string // gateway 获得的网关 + shard [2]byte // shard 分片 + seq uint32 + handlers map[string]GeneralHandleType // handlers 方便调用的 handler + mu sync.Mutex // 写锁 + conn *websocket.Conn + heartbeat uint32 // heartbeat 心跳周期, 单位毫秒 + intents uint32 + properties json.RawMessage + ready EventReady } // Init 初始化, 只需执行一次 -func (b *Bot) Init() { - +func (b *Bot) Init(gateway string, shard [2]byte, intents uint32, properties json.RawMessage) *Bot { + b.gateway = gateway + b.shard = shard + b.intents = intents + b.properties = properties + if b.Handler != nil { + h := reflect.ValueOf(b.Handler).Elem() + t := h.Type() + b.handlers = make(map[string]GeneralHandleType, h.NumField()*4) + for i := 0; i < h.NumField(); i++ { + f := h.Field(i) + if f.IsZero() { + continue + } + tp := t.Field(i).Name[2:] // skip On + log.Infoln("[bot] 注册处理函数", tp) + handler := f.Interface() + b.handlers[tp] = *(*GeneralHandleType)(unsafe.Add(unsafe.Pointer(&handler), unsafe.Sizeof(uintptr(0)))) + } + } + return b } // Authorization 返回 Authorization Header value func (bot *Bot) Authorization() string { return "Bot " + bot.AppID + "." + bot.Token } + +// receive 收一个 payload +func (bot *Bot) reveive() (payload WebsocketPayload, err error) { + err = bot.conn.ReadJSON(&payload) + return +} + +// Connect 连接到 Gateway + 鉴权连接 +// +// https://bot.q.qq.com/wiki/develop/api/gateway/reference.html#_1-%E8%BF%9E%E6%8E%A5%E5%88%B0-gateway +func (bot *Bot) Connect() { + network, address := resolveURI(bot.gateway) + log.Infoln("[bot] 开始尝试连接到网关:", network, ", AppID:", bot.AppID) + dialer := websocket.Dialer{ + NetDial: func(_, addr string) (net.Conn, error) { + if network == "unix" { + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + filepath, err := base64.RawURLEncoding.DecodeString(host) + if err == nil { + addr = BytesToString(filepath) + } + } + return net.Dial(network, addr) // support unix socket transport + }, + } + for { + conn, resp, err := dialer.Dial(address, http.Header{}) + if err != nil { + log.Warnf("[bot] 连接到网关 %v 时出现错误: %v", bot.gateway, err) + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + bot.conn = conn + _ = resp.Body.Close() + payload, err := bot.reveive() + if err != nil { + log.Warnln("[bot] 获取心跳间隔时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + bot.heartbeat, err = payload.GetHeartbeatInterval() + if err != nil { + log.Warnln("[bot] 解析心跳间隔时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + payload.Op = OpCodeIdentify + err = payload.WrapData(&OpCodeIdentifyMessage{ + Token: bot.Authorization(), + Intents: bot.intents, + Shard: bot.shard, + Properties: bot.properties, + }) + if err != nil { + log.Warnln("[bot] 包装 Identify 时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + err = bot.SendPayload(&payload) + if err != nil { + log.Warnln("[bot] 发送 Identify 时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + payload, err = bot.reveive() + if err != nil { + log.Warnln("[bot] 获取 EventReady 时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + bot.ready, err = payload.GetEventReady() + if err != nil { + log.Warnln("[bot] 解析 EventReady 时出现错误:", err) + _ = conn.Close() + time.Sleep(2 * time.Second) // 等待两秒后重新连接 + continue + } + break + } + clients.Store(bot.ready.User.ID, bot) + log.Infoln("[bot] 连接到网关成功, 用户名:", bot.ready.User.Username) + go bot.doheartbeat() +} + +// doheartbeat 按指定间隔进行心跳包收发 +func (bot *Bot) doheartbeat() { + payload := struct { + Op OpCode `json:"op"` + D *uint32 `json:"d"` + }{Op: OpCodeHeartbeat} + t := time.NewTicker(time.Duration(bot.heartbeat) * time.Millisecond) + defer t.Stop() + time.Sleep(time.Minute) + for range t.C { + if bot.seq == 0 { + payload.D = nil + } else { + payload.D = &bot.seq + } + bot.mu.Lock() + err := bot.conn.WriteJSON(&payload) + bot.mu.Unlock() + if err != nil { + log.Warnln("[bot] 发送心跳时出现错误:", err) + } + } +} diff --git a/define.go b/define.go deleted file mode 100644 index 0ae1b5b..0000000 --- a/define.go +++ /dev/null @@ -1,69 +0,0 @@ -package nano - -import "encoding/json" - -const ( - // StandardAPI 正式环境接口域名 - StandardAPI = `https://api.sgroup.qq.com` - // SandboxAPI 沙箱环境接口域名 - SandboxAPI = `https://sandbox.api.sgroup.qq.com` -) - -var ( - OpenAPI = StandardAPI // OpenAPI 实际使用的 API, 默认 StandardAPI, 可自行赋值配置 -) - -// CodeMessageBase 各种消息都有的 code + message 基类 -type CodeMessageBase struct { - C int `json:"code"` - M string `json:"message"` -} - -// OpCode https://bot.q.qq.com/wiki/develop/api/gateway/opcode.html -type OpCode int - -const ( - OpCodeDispatch OpCode = iota // Receive - OpCodeHeartbeat // Send/Receive - OpCodeIdentify // Send - OpCodeEmpty1 - OpCodeEmpty2 - OpCodeEmpty3 - OpCodeResume // Send - OpCodeReconnect // Receive - OpCodeEmpty4 - OpCodeInvalidSession // Receive - OpCodeHello // Receive - OpCodeHeartbeatACK // Receive/Reply - OpCodeHTTPCallbackACK // Reply -) - -// WebsocketPayload payload 指的是在 websocket 连接上传输的数据,网关的上下行消息采用的都是同一个结构 -// -// https://bot.q.qq.com/wiki/develop/api/gateway/reference.html -type WebsocketPayload struct { - Op OpCode `json:"op"` - D json.RawMessage `json:"d"` - S int `json:"s"` - T string `json:"t"` -} - -// https://bot.q.qq.com/wiki/develop/api/gateway/intents.html -const ( - IntentGuilds = 1 << 0 - IntentGuildMembers = 1 << 1 - IntentGuildMessages = 1 << 9 - IntentGuildMessageReactions = 1 << 10 - IntentDirectMessage = 1 << 12 - IntentOpenForumsEvent = 1 << 18 - IntentAudioOrLiveChannelMember = 1 << 19 - IntentInteraction = 1 << 26 - IntentMessageAudit = 1 << 27 - IntentForumsEvent = 1 << 28 - IntentAudioAction = 1 << 29 - IntentPublicGuildMessages = 1 << 30 - - IntentAll = IntentGuilds | IntentGuildMembers | IntentGuildMessages | IntentGuildMessageReactions | - IntentDirectMessage | IntentOpenForumsEvent | IntentAudioOrLiveChannelMember | IntentInteraction | - IntentMessageAudit | IntentForumsEvent | IntentAudioAction | IntentPublicGuildMessages -) diff --git a/go.mod b/go.mod index 38c57e8..5fc54d8 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,14 @@ module github.com/fumiama/NanoBot go 1.20 require ( + github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e + github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 github.com/fumiama/go-base16384 v1.7.0 github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.3 ) -require golang.org/x/text v0.3.7 // indirect +require ( + golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum index c0bd33b..213fce0 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,24 @@ -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e h1:wR3MXQ3VbUlPKOOUwLOYgh/QaJThBTYtsl673O3lqSA= +github.com/RomiChan/syncx v0.0.0-20221202055724-5f842c53020e/go.mod h1:vD7Ra3Q9onRtojoY5sMCLQ7JBgjUsrXDnDKyFxqpf9w= +github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5 h1:bBmmB7he0iVN4m5mcehfheeRUEer/Avo4ujnxI3uCqs= +github.com/RomiChan/websocket v1.4.3-0.20220227141055-9b2c6168c9c5/go.mod h1:0UcFaCkhp6vZw6l5Dpq0Dp673CoF9GdvA8lTfst0GiU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fumiama/go-base16384 v1.7.0 h1:6fep7XPQWxRlh4Hu+KsdH+6+YdUp+w6CwRXtMWSsXCA= github.com/fumiama/go-base16384 v1.7.0/go.mod h1:OEn+947GV5gsbTAnyuUW/SrfxJYUdYupSIQXOuGOcXM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/helper.go b/helper.go index 454f8fa..d266746 100644 --- a/helper.go +++ b/helper.go @@ -1,8 +1,11 @@ package nano import ( + "encoding/base64" + "net/url" "runtime" "strings" + "unsafe" ) func getFuncNameWithSkip(n int) string { @@ -66,3 +69,49 @@ func UnderlineToCamel(s string) string { } return sb.String() } + +// resolveURI github.com/wdvxdr1123/ZeroBot/driver/uri.go +func resolveURI(addr string) (network, address string) { + network, address = "tcp", addr + uri, err := url.Parse(addr) + if err == nil && uri.Scheme != "" { + scheme, ext, _ := strings.Cut(uri.Scheme, "+") + if ext != "" { + network = ext + uri.Scheme = scheme // remove `+unix`/`+tcp4` + if ext == "unix" { + uri.Host, uri.Path, _ = strings.Cut(uri.Path, ":") + uri.Host = base64.StdEncoding.EncodeToString(StringToBytes(uri.Host)) // special handle for unix + } + address = uri.String() + } + } + return +} + +// slice is the runtime representation of a slice. +// It cannot be used safely or portably and its representation may +// change in a later release. +// +// Unlike reflect.SliceHeader, its Data field is sufficient to guarantee the +// data it references will not be garbage collected. +type slice struct { + data unsafe.Pointer + len int + cap int +} + +// BytesToString 没有内存开销的转换 +func BytesToString(b []byte) string { + return *(*string)(unsafe.Pointer(&b)) +} + +// StringToBytes 没有内存开销的转换 +func StringToBytes(s string) (b []byte) { + bh := (*slice)(unsafe.Pointer(&b)) + sh := (*slice)(unsafe.Pointer(&s)) + bh.data = sh.data + bh.len = sh.len + bh.cap = sh.len + return b +} diff --git a/intent.go b/intent.go new file mode 100644 index 0000000..c5ea273 --- /dev/null +++ b/intent.go @@ -0,0 +1,21 @@ +package nano + +// https://bot.q.qq.com/wiki/develop/api/gateway/intents.html +const ( + IntentGuilds = 1 << 0 + IntentGuildMembers = 1 << 1 + IntentGuildMessages = 1 << 9 + IntentGuildMessageReactions = 1 << 10 + IntentDirectMessage = 1 << 12 + IntentOpenForumsEvent = 1 << 18 + IntentAudioOrLiveChannelMember = 1 << 19 + IntentInteraction = 1 << 26 + IntentMessageAudit = 1 << 27 + IntentForumsEvent = 1 << 28 + IntentAudioAction = 1 << 29 + IntentPublicGuildMessages = 1 << 30 + + IntentAll = IntentGuilds | IntentGuildMembers | IntentGuildMessages | IntentGuildMessageReactions | + IntentDirectMessage | IntentOpenForumsEvent | IntentAudioOrLiveChannelMember | IntentInteraction | + IntentMessageAudit | IntentForumsEvent | IntentAudioAction | IntentPublicGuildMessages +) diff --git a/opcode.go b/opcode.go new file mode 100644 index 0000000..76bde59 --- /dev/null +++ b/opcode.go @@ -0,0 +1,30 @@ +package nano + +import "encoding/json" + +// OpCode https://bot.q.qq.com/wiki/develop/api/gateway/opcode.html +type OpCode int + +const ( + OpCodeDispatch OpCode = iota // Receive + OpCodeHeartbeat // Send/Receive + OpCodeIdentify // Send + OpCodeEmpty1 + OpCodeEmpty2 + OpCodeEmpty3 + OpCodeResume // Send + OpCodeReconnect // Receive + OpCodeEmpty4 + OpCodeInvalidSession // Receive + OpCodeHello // Receive + OpCodeHeartbeatACK // Receive/Reply + OpCodeHTTPCallbackACK // Reply +) + +// OpCodeIdentifyMessage https://bot.q.qq.com/wiki/develop/api/gateway/reference.html#_2-%E9%89%B4%E6%9D%83%E8%BF%9E%E6%8E%A5 +type OpCodeIdentifyMessage struct { + Token string `json:"token"` + Intents uint32 `json:"intents"` + Shard [2]byte `json:"shard"` + Properties json.RawMessage `json:"properties"` +} diff --git a/openapi.go b/openapi.go index 2e56137..968cd2d 100644 --- a/openapi.go +++ b/openapi.go @@ -11,6 +11,23 @@ import ( "github.com/pkg/errors" ) +const ( + // StandardAPI 正式环境接口域名 + StandardAPI = `https://api.sgroup.qq.com` + // SandboxAPI 沙箱环境接口域名 + SandboxAPI = `https://sandbox.api.sgroup.qq.com` +) + +var ( + OpenAPI = StandardAPI // OpenAPI 实际使用的 API, 默认 StandardAPI, 可自行赋值配置 +) + +// CodeMessageBase 各种消息都有的 code + message 基类 +type CodeMessageBase struct { + C int `json:"code"` + M string `json:"message"` +} + func checkrespbaseunsafe(ptr any) error { respbase := (*CodeMessageBase)(*(*unsafe.Pointer)(unsafe.Add(unsafe.Pointer(&ptr), unsafe.Sizeof(uintptr(0))))) if respbase.C != 0 { diff --git a/payload.go b/payload.go new file mode 100644 index 0000000..318d39b --- /dev/null +++ b/payload.go @@ -0,0 +1,64 @@ +package nano + +import ( + "encoding/json" + "errors" + "strconv" +) + +// WebsocketPayload payload 指的是在 websocket 连接上传输的数据,网关的上下行消息采用的都是同一个结构 +// +// https://bot.q.qq.com/wiki/develop/api/gateway/reference.html +type WebsocketPayload struct { + Op OpCode `json:"op"` + D json.RawMessage `json:"d,omitempty"` + S int `json:"s,omitempty"` + T string `json:"t,omitempty"` +} + +// GetHeartbeatInterval OpCodeHello 获得心跳周期 单位毫秒 +func (wp *WebsocketPayload) GetHeartbeatInterval() (uint32, error) { + if wp.Op != OpCodeHello { + return 0, errors.New(getThisFuncName() + " unexpected OpCode " + strconv.Itoa(int(wp.Op))) + } + data := &struct { + H uint32 `json:"heartbeat_interval"` + }{} + err := json.Unmarshal(wp.D, data) + return data.H, err +} + +// SendPayload 发送 ws 包 +func (bot *Bot) SendPayload(wp *WebsocketPayload) error { + bot.mu.Lock() + defer bot.mu.Unlock() + return bot.conn.WriteJSON(wp) +} + +// WrapData 将结构体序列化到 wp.D +func (wp *WebsocketPayload) WrapData(v any) (err error) { + wp.D, err = json.Marshal(v) + return +} + +// EventReady https://bot.q.qq.com/wiki/develop/api/gateway/reference.html#_2-%E9%89%B4%E6%9D%83%E8%BF%9E%E6%8E%A5 +type EventReady struct { + Version int `json:"version"` + SessionID string `json:"session_id"` + User *User `json:"user"` + Shard [2]byte `json:"shard"` +} + +// GetEventReady OpCodeDispatch READY +func (wp *WebsocketPayload) GetEventReady() (er EventReady, err error) { + if wp.Op != OpCodeDispatch { + err = errors.New(getThisFuncName() + " unexpected OpCode " + strconv.Itoa(int(wp.Op))) + return + } + if wp.T != "READY" { + err = errors.New(getThisFuncName() + " unexpected event type " + wp.T) + return + } + err = json.Unmarshal(wp.D, &er) + return +}