arikawa/voice/voicegateway/gateway.go

210 lines
5.6 KiB
Go

//
// For the brave souls who get this far: You are the chosen ones,
// the valiant knights of programming who toil away, without rest,
// fixing our most awful code. To you, true saviors, kings of men,
// I say this: never gonna give you up, never gonna let you down,
// never gonna run around and desert you. Never gonna make you cry,
// never gonna say goodbye. Never gonna tell a lie and hurt you.
//
package voicegateway
import (
"context"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/diamondburned/arikawa/v3/discord"
"github.com/diamondburned/arikawa/v3/utils/ws"
)
// Version represents the current version of the Discord Gateway Gateway this package uses.
const Version = "4"
var (
ErrNoSessionID = errors.New("no sessionID was received")
ErrNoEndpoint = errors.New("no endpoint was received")
)
// State contains state information of a voice gateway.
type State struct {
UserID discord.UserID // constant
GuildID discord.GuildID // constant
ChannelID discord.ChannelID
SessionID string
Token string
Endpoint string
}
// Gateway represents a Discord Gateway Gateway connection.
type Gateway struct {
gateway *ws.Gateway
state State // constant
mutex sync.RWMutex
ready *ReadyEvent
}
// DefaultGatewayOpts contains the default options to be used for connecting to
// the gateway.
var DefaultGatewayOpts = ws.GatewayOpts{
ReconnectDelay: func(try int) time.Duration {
// minimum 4 seconds
return time.Duration(4+(2*try)) * time.Second
},
// FatalCloseCodes contains the default gateway close codes that will cause
// the gateway to exit. In other words, it's a list of unrecoverable close
// codes.
FatalCloseCodes: []int{
4003, // not authenticated
4004, // authentication failed
4006, // session invalid
4009, // session timed out
4011, // server not found
4012, // unknown protocol
4014, // disconnected
4016, // unknown encryption mode
},
DialTimeout: 0,
ReconnectAttempt: 0,
AlwaysCloseGracefully: true,
}
// New creates a new voice gateway.
func New(state State) *Gateway {
// https://discord.com/developers/docs/topics/voice-connections#establishing-a-voice-websocket-connection
endpoint := "wss://" + strings.TrimSuffix(state.Endpoint, ":80") + "/?v=" + Version
gw := ws.NewGateway(
ws.NewWebsocket(ws.NewCodec(OpUnmarshalers), endpoint),
&DefaultGatewayOpts,
)
return &Gateway{
gateway: gw,
state: state,
}
}
// Ready returns the ready event.
func (g *Gateway) Ready() *ReadyEvent {
g.mutex.RLock()
defer g.mutex.RUnlock()
return g.ready
}
// LastError returns the last error that the gateway has received. It only
// returns a valid error if the gateway's event loop as exited. If the event
// loop hasn't been started AND stopped, the function will panic.
func (g *Gateway) LastError() error {
return g.gateway.LastError()
}
// Send is a function to send an Op payload to the Gateway.
func (g *Gateway) Send(ctx context.Context, data ws.Event) error {
return g.gateway.Send(ctx, data)
}
// Speaking sends a Speaking operation (opcode 5) to the Gateway Gateway.
func (g *Gateway) Speaking(ctx context.Context, flag SpeakingFlag) error {
g.mutex.RLock()
ready := g.ready
g.mutex.RUnlock()
if ready == nil {
return errors.New("Speaking called before gateway was ready")
}
return g.gateway.Send(ctx, &SpeakingEvent{
Speaking: flag,
Delay: 0,
SSRC: ready.SSRC,
})
}
func (g *Gateway) Connect(ctx context.Context) <-chan ws.Op {
return g.gateway.Connect(ctx, (*gatewayImpl)(g))
}
var (
// ErrMissingForIdentify is an error when we are missing information to
// identify.
ErrMissingForIdentify = errors.New("missing GuildID, UserID, SessionID, or Token for identify")
// ErrMissingForResume is an error when we are missing information to
// resume.
ErrMissingForResume = errors.New("missing GuildID, SessionID, or Token for resuming")
)
type gatewayImpl Gateway
func (g *gatewayImpl) sendIdentify(ctx context.Context) error {
id := IdentifyCommand{
GuildID: g.state.GuildID,
UserID: g.state.UserID,
SessionID: g.state.SessionID,
Token: g.state.Token,
}
if !id.GuildID.IsValid() || id == (IdentifyCommand{}) {
return ErrMissingForIdentify
}
return g.gateway.Send(ctx, &id)
}
func (g *gatewayImpl) sendResume(ctx context.Context) error {
if !g.state.GuildID.IsValid() || g.state.SessionID == "" || g.state.Token == "" {
return ErrMissingForResume
}
return g.gateway.Send(ctx, &ResumeCommand{
GuildID: g.state.GuildID,
SessionID: g.state.SessionID,
Token: g.state.Token,
})
}
func (g *gatewayImpl) OnOp(ctx context.Context, op ws.Op) bool {
switch data := op.Data.(type) {
case *HelloEvent:
g.gateway.ResetHeartbeat(data.HeartbeatInterval.Duration())
// Send Discord either the Identify packet (if it's a fresh
// connection), or a Resume packet (if it's a dead connection).
if g.ready == nil {
// SessionID is empty, so this is a completely new session.
if err := g.sendIdentify(ctx); err != nil {
g.gateway.SendErrorWrap(err, "failed to send identify")
g.gateway.QueueReconnect()
}
} else {
if err := g.sendResume(ctx); err != nil {
g.gateway.SendErrorWrap(err, "failed to send resume")
g.gateway.QueueReconnect()
}
}
case *ReadyEvent:
g.mutex.Lock()
g.ready = data
g.mutex.Unlock()
}
return true
}
func (g *gatewayImpl) SendHeartbeat(ctx context.Context) {
heartbeat := HeartbeatCommand(time.Now().UnixNano())
if err := g.gateway.Send(ctx, &heartbeat); err != nil {
g.gateway.SendErrorWrap(err, "heartbeat error")
g.gateway.QueueReconnect()
}
}
func (g *gatewayImpl) Close() error {
return nil
}