feat(channels/qq_obv11): OneBot v11 渠道插件(正/反 WS 双模式 + CQ 媒体 + 限频,stdlib 自研 WS)
This commit is contained in:
1 parent
f9f5a78a02
commit
7831b0ea1b
11 files changed
+2526
No files matched your search
@@ -0,0 +1,406 @@
|
||||
package qq_obv11
|
||||
|
||||
// This file implements a minimal RFC 6455 WebSocket (text/binary frames,
|
||||
// ping/pong, close, per-message client masking) using only the standard
|
||||
// library. The project pins no third-party WebSocket dependency (go.mod must
|
||||
// not change), so the OneBot v11 adapter carries its own tiny frame codec. It
|
||||
// is deliberately narrow: it supports what OneBot v11 needs — UTF-8 JSON text
|
||||
// frames carried over a single upgraded TCP connection — and nothing more.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WebSocket control/data opcodes (RFC 6455 section 5.2).
|
||||
const (
|
||||
opContinuation = 0x0
|
||||
opText = 0x1
|
||||
opBinary = 0x2
|
||||
opClose = 0x8
|
||||
opPing = 0x9
|
||||
opPong = 0xA
|
||||
)
|
||||
|
||||
// wsGUID is the fixed handshake GUID from RFC 6455 section 1.3.
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
// maxFramePayload caps a single WebSocket frame so a malicious peer cannot make
|
||||
// the adapter buffer an unbounded payload. Chat/JSON payloads are tiny; this is
|
||||
// generous.
|
||||
const maxFramePayload = 10 << 20 // 10 MiB
|
||||
|
||||
// DialContextFunc dials a raw TCP (or TLS) connection. It is injectable so
|
||||
// tests can route the handshake through a fake server; the default is a
|
||||
// net.Dialer.
|
||||
type DialContextFunc func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
|
||||
// wsConn is a single WebSocket connection over an upgraded TCP stream. It is
|
||||
// safe for one concurrent reader plus concurrent writers (writes are mutex
|
||||
// serialized); exactly one goroutine may call ReadMessage at a time.
|
||||
type wsConn struct {
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
bw *bufio.Writer
|
||||
isClient bool
|
||||
writeMu sync.Mutex
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newWSConn wraps an upgraded connection. br and bw are the buffered reader and
|
||||
// writer from the handshake; passing nil causes them to be created from conn.
|
||||
// isClient reports whether this side is the WebSocket client (which must mask
|
||||
// its outgoing frames).
|
||||
func newWSConn(conn net.Conn, br *bufio.Reader, bw *bufio.Writer, isClient bool) *wsConn {
|
||||
if br == nil {
|
||||
br = bufio.NewReader(conn)
|
||||
}
|
||||
if bw == nil {
|
||||
bw = bufio.NewWriter(conn)
|
||||
}
|
||||
return &wsConn{conn: conn, br: br, bw: bw, isClient: isClient}
|
||||
}
|
||||
|
||||
// Close closes the underlying connection exactly once.
|
||||
func (c *wsConn) Close() error {
|
||||
var err error
|
||||
c.closeOnce.Do(func() { err = c.conn.Close() })
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteMessage writes a single unmasked/known-masked data or control frame.
|
||||
// The caller must hold no locks.
|
||||
func (c *wsConn) WriteMessage(opcode byte, payload []byte) error {
|
||||
c.writeMu.Lock()
|
||||
defer c.writeMu.Unlock()
|
||||
|
||||
b0 := byte(0x80) | opcode // FIN set on the single frame
|
||||
if err := c.bw.WriteByte(b0); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maskBit := byte(0)
|
||||
if c.isClient {
|
||||
maskBit = 0x80
|
||||
}
|
||||
if err := writeFrameHeader(c.bw, maskBit, len(payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if c.isClient {
|
||||
if _, err := rand.Read(maskKey[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.bw.Write(maskKey[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !c.isClient {
|
||||
if _, err := c.bw.Write(payload); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.bw.Flush()
|
||||
}
|
||||
if err := writeMaskedPayload(c.bw, payload, maskKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.bw.Flush()
|
||||
}
|
||||
|
||||
func writeFrameHeader(w *bufio.Writer, maskBit byte, n int) error {
|
||||
switch {
|
||||
case n < 126:
|
||||
return w.WriteByte(maskBit | byte(n))
|
||||
case n <= 0xFFFF:
|
||||
if err := w.WriteByte(maskBit | 126); err != nil {
|
||||
return err
|
||||
}
|
||||
var ext [2]byte
|
||||
binary.BigEndian.PutUint16(ext[:], uint16(n))
|
||||
_, err := w.Write(ext[:])
|
||||
return err
|
||||
default:
|
||||
if err := w.WriteByte(maskBit | 127); err != nil {
|
||||
return err
|
||||
}
|
||||
var ext [8]byte
|
||||
binary.BigEndian.PutUint64(ext[:], uint64(n))
|
||||
_, err := w.Write(ext[:])
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func writeMaskedPayload(w *bufio.Writer, payload []byte, maskKey [4]byte) error {
|
||||
var buf [4096]byte
|
||||
for off := 0; off < len(payload); off += len(buf) {
|
||||
end := off + len(buf)
|
||||
if end > len(payload) {
|
||||
end = len(payload)
|
||||
}
|
||||
for i, b := range payload[off:end] {
|
||||
buf[i] = b ^ maskKey[(off+i)&3]
|
||||
}
|
||||
if _, err := w.Write(buf[:end-off]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReadMessage reads the next complete data message, reassembling fragmented
|
||||
// frames, replying to pings, and terminating (io.EOF) on a close frame or
|
||||
// network error. Only one goroutine may call it.
|
||||
func (c *wsConn) ReadMessage() (byte, []byte, error) {
|
||||
var (
|
||||
payload []byte
|
||||
dataOp byte
|
||||
started bool
|
||||
)
|
||||
|
||||
for {
|
||||
fin, opcode, data, err := c.readFrame()
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
|
||||
switch opcode {
|
||||
case opPing:
|
||||
if err := c.WriteMessage(opPong, data); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
continue
|
||||
case opPong:
|
||||
continue
|
||||
case opClose:
|
||||
_ = c.WriteMessage(opClose, nil)
|
||||
return opClose, nil, io.EOF
|
||||
case opContinuation:
|
||||
if !started {
|
||||
return 0, nil, errors.New("websocket: unexpected continuation frame")
|
||||
}
|
||||
payload = append(payload, data...)
|
||||
if fin {
|
||||
return dataOp, payload, nil
|
||||
}
|
||||
case opText, opBinary:
|
||||
if started {
|
||||
return 0, nil, errors.New("websocket: new data frame during fragmented message")
|
||||
}
|
||||
dataOp = opcode
|
||||
if fin {
|
||||
return opcode, data, nil
|
||||
}
|
||||
started = true
|
||||
payload = append([]byte(nil), data...)
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("websocket: unsupported opcode %d", opcode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readFrame reads a single frame header and payload, unmasking when required.
|
||||
func (c *wsConn) readFrame() (fin bool, opcode byte, payload []byte, err error) {
|
||||
var hdr [2]byte
|
||||
if _, err = io.ReadFull(c.br, hdr[:]); err != nil {
|
||||
return
|
||||
}
|
||||
fin = hdr[0]&0x80 != 0
|
||||
opcode = hdr[0] & 0x0F
|
||||
masked := hdr[1]&0x80 != 0
|
||||
|
||||
length := uint64(hdr[1] & 0x7F)
|
||||
switch length {
|
||||
case 126:
|
||||
var ext [2]byte
|
||||
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return
|
||||
}
|
||||
length = uint64(binary.BigEndian.Uint16(ext[:]))
|
||||
case 127:
|
||||
var ext [8]byte
|
||||
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return
|
||||
}
|
||||
length = binary.BigEndian.Uint64(ext[:])
|
||||
}
|
||||
if length > maxFramePayload {
|
||||
err = fmt.Errorf("websocket: frame too large (%d bytes)", length)
|
||||
return
|
||||
}
|
||||
|
||||
var maskKey [4]byte
|
||||
if masked {
|
||||
if _, err = io.ReadFull(c.br, maskKey[:]); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
payload = make([]byte, length)
|
||||
if _, err = io.ReadFull(c.br, payload); err != nil {
|
||||
return
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= maskKey[i&3]
|
||||
}
|
||||
}
|
||||
return fin, opcode, payload, nil
|
||||
}
|
||||
|
||||
// computeAccept derives the Sec-WebSocket-Accept value for a handshake key.
|
||||
func computeAccept(key string) string {
|
||||
h := sha1.New()
|
||||
_, _ = io.WriteString(h, key+wsGUID)
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// randomWSKey returns a random WebSocket handshake key.
|
||||
func randomWSKey() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return base64.StdEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
// upgradeWS performs the server side of the WebSocket handshake by hijacking
|
||||
// the HTTP connection and returning the upgraded wsConn.
|
||||
func upgradeWS(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
|
||||
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") ||
|
||||
!headerHasToken(r.Header, "Connection", "upgrade") {
|
||||
http.Error(w, "not a websocket upgrade", http.StatusBadRequest)
|
||||
return nil, errors.New("websocket: not an upgrade request")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
http.Error(w, "missing Sec-WebSocket-Key", http.StatusBadRequest)
|
||||
return nil, errors.New("websocket: missing handshake key")
|
||||
}
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
http.Error(w, "hijacking not supported", http.StatusInternalServerError)
|
||||
return nil, errors.New("websocket: hijacking not supported")
|
||||
}
|
||||
netConn, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket: hijack: %w", err)
|
||||
}
|
||||
|
||||
resp := "HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: " + computeAccept(key) + "\r\n\r\n"
|
||||
if _, err := brw.WriteString(resp); err != nil {
|
||||
_ = netConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := brw.Flush(); err != nil {
|
||||
_ = netConn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return newWSConn(netConn, brw.Reader, brw.Writer, false), nil
|
||||
}
|
||||
|
||||
// headerHasToken reports whether a comma-separated header value contains the
|
||||
// given token case-insensitively.
|
||||
func headerHasToken(h http.Header, name, token string) bool {
|
||||
for _, val := range h.Values(name) {
|
||||
for _, part := range strings.Split(val, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(part), token) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// dialWS performs the client side of the WebSocket handshake, returning the
|
||||
// upgraded wsConn. accessToken, when non-empty, is sent both as an
|
||||
// Authorization: Bearer header and as an access_token query parameter for
|
||||
// OneBot implementations that expect either.
|
||||
func dialWS(ctx context.Context, dial DialContextFunc, rawURL, accessToken string) (*wsConn, error) {
|
||||
if dial == nil {
|
||||
dial = (&net.Dialer{Timeout: 15 * time.Second}).DialContext
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket: parse url %q: %w", rawURL, err)
|
||||
}
|
||||
if u.Scheme != "ws" && u.Scheme != "wss" {
|
||||
return nil, fmt.Errorf("websocket: unsupported scheme %q (want ws/wss)", u.Scheme)
|
||||
}
|
||||
|
||||
host := u.Host
|
||||
if u.Port() == "" {
|
||||
if u.Scheme == "wss" {
|
||||
host += ":443"
|
||||
} else {
|
||||
host += ":80"
|
||||
}
|
||||
}
|
||||
conn, err := dial(ctx, "tcp", host)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("websocket: dial %s: %w", host, err)
|
||||
}
|
||||
|
||||
if u.Scheme == "wss" {
|
||||
tlsConn := tls.Client(conn, &tls.Config{ServerName: u.Hostname()})
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("websocket: tls handshake: %w", err)
|
||||
}
|
||||
conn = tlsConn
|
||||
}
|
||||
|
||||
brw := bufio.NewReadWriter(bufio.NewReader(conn), bufio.NewWriter(conn))
|
||||
key := randomWSKey()
|
||||
var b strings.Builder
|
||||
b.WriteString("GET " + u.RequestURI() + " HTTP/1.1\r\n")
|
||||
b.WriteString("Host: " + u.Host + "\r\n")
|
||||
b.WriteString("Upgrade: websocket\r\n")
|
||||
b.WriteString("Connection: Upgrade\r\n")
|
||||
b.WriteString("Sec-WebSocket-Key: " + key + "\r\n")
|
||||
b.WriteString("Sec-WebSocket-Version: 13\r\n")
|
||||
if accessToken != "" {
|
||||
b.WriteString("Authorization: Bearer " + accessToken + "\r\n")
|
||||
}
|
||||
b.WriteString("\r\n")
|
||||
if _, err := brw.WriteString(b.String()); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := brw.Flush(); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := http.ReadResponse(brw.Reader, &http.Request{Method: "GET"})
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("websocket: read handshake response: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusSwitchingProtocols {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("websocket: unexpected handshake status %d", resp.StatusCode)
|
||||
}
|
||||
if got := resp.Header.Get("Sec-WebSocket-Accept"); got != computeAccept(key) {
|
||||
_ = conn.Close()
|
||||
return nil, errors.New("websocket: bad Sec-WebSocket-Accept in response")
|
||||
}
|
||||
return newWSConn(conn, brw.Reader, brw.Writer, true), nil
|
||||
}
|
||||
Reference in new issue
Block a user