arikawa/session/session.go

433 lines
12 KiB
Go

// Package session abstracts around the REST API and the Gateway, managing both
// at once. It offers a handler interface similar to that in discordgo for
// Gateway events.
package session
import (
"context"
"errors"
"fmt"
"log"
"sync"
"github.com/diamondburned/arikawa/v3/api"
"github.com/diamondburned/arikawa/v3/api/webhook"
"github.com/diamondburned/arikawa/v3/gateway"
"github.com/diamondburned/arikawa/v3/utils/handler"
"github.com/diamondburned/arikawa/v3/utils/json/option"
"github.com/diamondburned/arikawa/v3/utils/ws"
"github.com/diamondburned/arikawa/v3/utils/ws/ophandler"
)
// ErrMFA is returned if the account requires a 2FA code to log in.
var ErrMFA = errors.New("account has 2FA enabled")
// ErrClosed is returned if the Session is closed, either because it's already
// closed (and Close is being called again) or it was never started.
var ErrClosed = errors.New("Session is closed")
// Session manages both the API and Gateway. As such, Session inherits all of
// API's methods, as well has the Handler used for Gateway.
type Session struct {
*api.Client
*handler.Handler
// internal state to not be copied around.
state *sessionState
// OnInteractionError is called when an interaction added using
// AddInteractionHandler cannot be sent. By default, it logs into the
// console.
OnInteractionError func(*gateway.InteractionCreateEvent, error)
// DontWaitForReady makes Open not wait for the Ready event. This is useful
// for non-bots, since Discord may send over a READY_SUPPLEMENT instead. If
// this is true, then any event sent by Discord will unblock Open (usually
// HELLO).
DontWaitForReady bool // false
}
type sessionState struct {
sync.Mutex
id gateway.Identifier
gateway *gateway.Gateway
ctx context.Context
cancel context.CancelFunc
doneCh <-chan struct{}
}
// NewWithIntents is similar to New but adds the given intents in during
// construction.
func NewWithIntents(token string, intents ...gateway.Intents) *Session {
var allIntent gateway.Intents
for _, intent := range intents {
allIntent |= intent
}
id := gateway.DefaultIdentifier(token)
id.Intents = option.NewUint(uint(allIntent))
return NewWithIdentifier(id)
}
// New creates a new session from a given token. Most bots should be using
// NewWithIntents instead.
func New(token string) *Session {
return NewWithIdentifier(gateway.DefaultIdentifier(token))
}
// Login tries to log in as a normal user account; MFA is optional.
func Login(ctx context.Context, email, password, mfa string) (*Session, error) {
// Make a scratch HTTP client without a token
client := api.NewClient("").WithContext(ctx)
// Try to login without TOTP
l, err := client.Login(email, password)
if err != nil {
return nil, fmt.Errorf("failed to login: %w", err)
}
if l.Token != "" && !l.MFA {
// We got the token, return with a new Session.
return New(l.Token), nil
}
// Discord requests MFA, so we need the MFA token.
if mfa == "" {
return nil, ErrMFA
}
// Retry logging in with a 2FA token
l, err = client.TOTP(mfa, l.Ticket)
if err != nil {
return nil, fmt.Errorf("failed to login with 2FA: %w", err)
}
return New(l.Token), nil
}
// NewWithIdentifier creates a bare Session with the given identifier.
func NewWithIdentifier(id gateway.Identifier) *Session {
return NewCustom(id, api.NewClient(id.Token), handler.New())
}
// NewWithGateway constructs a bare Session from the given UNOPENED gateway.
func NewWithGateway(g *gateway.Gateway, h *handler.Handler) *Session {
state := g.State()
client := api.NewClient(state.Identifier.Token)
return newCustom(state.Identifier, client, h, g)
}
// NewCustom constructs a bare Session from the given parameters.
func NewCustom(id gateway.Identifier, cl *api.Client, h *handler.Handler) *Session {
return newCustom(id, cl, h, nil)
}
func newCustom(
id gateway.Identifier,
cl *api.Client,
h *handler.Handler,
g *gateway.Gateway) *Session {
return &Session{
Client: cl,
Handler: h,
state: &sessionState{
gateway: g,
id: id,
},
OnInteractionError: func(ev *gateway.InteractionCreateEvent, err error) {
// Log the error by default.
// TODO: fix this once we resolve
// https://github.com/diamondburned/arikawa/issues/361.
log.Printf("session: error handling interaction %v: %v", ev.ID, err)
},
}
}
// AddIntents adds the given intents into the gateway. Calling it after Open has
// already been called will result in a panic.
func (s *Session) AddIntents(intents gateway.Intents) {
s.state.Lock()
s.state.id.AddIntents(intents)
if s.state.gateway != nil {
s.state.gateway.AddIntents(intents)
}
s.state.Unlock()
}
// HasIntents reports if the Gateway has the passed Intents.
//
// If no intents are set, e.g. if using a user account, HasIntents will always
// return true.
func (s *Session) HasIntents(intents gateway.Intents) bool {
return s.state.id.HasIntents(intents)
}
// Gateway returns the current session's gateway. If Open has never been called
// or Session was never constructed with a gateway, then nil is returned.
func (s *Session) Gateway() *gateway.Gateway {
s.state.Lock()
defer s.state.Unlock()
return s.state.gateway
}
// GatewayOpts returns a copy of the current session's gateway options. If Open
// has never been called or Session was never constructed with a gateway, then
// the default gateway options are returned.
func (s *Session) GatewayOpts() *ws.GatewayOpts {
s.state.Lock()
defer s.state.Unlock()
opts := &gateway.DefaultGatewayOpts
if s.state.gateway != nil {
opts = s.state.gateway.Opts()
}
return opts
}
// GatewayError returns the gateway's error if the gateway is dead. If it's not
// dead, then nil is always returned. The check is done with GatewayIsAlive().
// If the gateway has never been started, nil will be returned (even though
// GatewayIsAlive would've returned true).
//
// This method would return what Close() would've returned if a fatal gateway
// error was found.
func (s *Session) GatewayError() error {
s.state.Lock()
defer s.state.Unlock()
if !s.gatewayIsAlive() && s.state.gateway != nil {
return s.state.gateway.LastError()
}
return nil
}
// GatewayIsAlive returns true if the gateway is still alive, that is, it is
// either connected or is trying to reconnect after an interruption. In other
// words, false is returned if the gateway isn't open or it has exited after
// seeing a fatal error code (and therefore cannot recover).
func (s *Session) GatewayIsAlive() bool {
s.state.Lock()
defer s.state.Unlock()
return s.gatewayIsAlive()
}
func (s *Session) gatewayIsAlive() bool {
if s.state.gateway == nil || s.state.doneCh == nil {
return false
}
select {
case <-s.state.doneCh:
return false
default:
return true
}
}
// Connect opens the Discord gateway and waits until an unrecoverable error
// occurs. Always prefer this method over Open. Note that Connect will return
// when ctx is done or when s.Close is called.
//
// As an odd case, when ctx is done and if the gateway is already finished
// connecting, then a nil error will be returned (unless the gateway has an
// error). This is contrary to the common behavior of a ctx function returning
// ctx.Err().
func (s *Session) Connect(ctx context.Context) error {
opts := s.GatewayOpts()
for {
if err := s.Open(ctx); err != nil {
if opts.ErrorIsFatalClose(err) || ctx.Err() != nil {
// Fatal error or context is done, return.
return err
}
// Non-fatal error, retry.
continue
}
if err := s.Wait(ctx); err != nil {
if opts.ErrorIsFatalClose(err) {
// Gateway returned a fatal error, so we can't recover.
return err
}
if ctx.Err() != nil {
// Context was done, so we can't recover. Exit with no error,
// since we're just waiting.
return nil
}
// Non-fatal error, retry.
}
}
}
// Open opens the Discord gateway and its handler, then waits until either the
// Ready or Resumed event gets through. Prefer using Connect instead of Open.
func (s *Session) Open(ctx context.Context) error {
evCh := make(chan interface{})
s.state.Lock()
defer s.state.Unlock()
if s.state.cancel != nil {
if err := s.close(); err != nil {
return err
}
}
if s.state.gateway == nil {
g, err := gateway.NewWithIdentifier(ctx, s.state.id)
if err != nil {
return err
}
s.state.gateway = g
}
// Make a context that's stored in state so this can be used throughout.
s.state.ctx, s.state.cancel = context.WithCancel(context.Background())
// TODO: change this to AddSyncHandler.
rm := s.AddHandler(evCh)
defer rm()
opCh := s.state.gateway.Connect(s.state.ctx)
s.state.doneCh = ophandler.Loop(opCh, s.Handler)
for {
select {
case <-ctx.Done():
s.close()
return ctx.Err()
case <-s.state.ctx.Done():
s.close()
return s.state.ctx.Err()
case <-s.state.doneCh:
// Event loop died.
return s.state.gateway.LastError()
case ev := <-evCh:
if s.DontWaitForReady {
return nil
}
switch ev.(type) {
case *gateway.ReadyEvent, *gateway.ResumedEvent:
return nil
}
}
}
}
// Wait blocks until either ctx is done or the gateway stumbles on an
// unrecoverable error.
func (s *Session) Wait(ctx context.Context) error {
s.state.Lock()
doneCh := s.state.doneCh
s.state.Unlock()
if doneCh == nil {
return ErrClosed
}
for {
select {
case <-ctx.Done():
s.Close()
// Prefer gateway errors over context errors.
if err := s.GatewayError(); err != nil {
return err
}
return ctx.Err()
case <-doneCh:
// Event loop died.
return s.GatewayError()
}
}
}
// WithContext returns a shallow copy of Session with the context replaced in
// the API client. All methods called on the returned Session will use this
// given context.
//
// This method is thread-safe only after Open and before Close are called. Open
// and Close should not be called on the returned Session.
func (s *Session) WithContext(ctx context.Context) *Session {
cpy := *s
cpy.Client = s.Client.WithContext(ctx)
return &cpy
}
// AddInteractionHandler adds an interaction handler function to be handled with
// the gateway and the API client. Use this as a compatibility layer for bots
// that support both methods of hosting.
//
// AddInteractionHandler will automatically send the return value of the
// interaction handler to the API. If the return value cannot be sent
// successfully, then s.OnInteractionError will be called.
func (s *Session) AddInteractionHandler(h webhook.InteractionHandler) {
// State doesn't override this, but it doesn't touch
// InteractionCreateEvents, so it shouldn't need to.
s.AddHandler(func(ev *gateway.InteractionCreateEvent) {
if resp := h.HandleInteraction(&ev.InteractionEvent); resp != nil {
if err := s.RespondInteraction(ev.ID, ev.Token, *resp); err != nil {
s.OnInteractionError(ev, err)
}
}
})
}
// AddInteractionHandlerFunc is a function variant of AddInteractionHandler.
func (s *Session) AddInteractionHandlerFunc(f webhook.InteractionHandlerFunc) {
s.AddInteractionHandler(f)
}
// SendGateway is a helper to send messages over the gateway. It will check
// if the gateway is open and available, then send the message.
func (s *Session) SendGateway(ctx context.Context, m ws.Event) error {
// The only necessary check here is checking if gateway is nil, however
// this will save us a bit of work in serialization.
if !s.GatewayIsAlive() {
return ErrClosed
}
return s.Gateway().Send(ctx, m)
}
// Close closes the underlying Websocket connection, invalidating the session
// ID. It will send a closing frame before ending the connection, closing it
// gracefully. This will cause the bot to appear as offline instantly. To
// prevent this behavior, change Gateway.AlwaysCloseGracefully.
func (s *Session) Close() error {
s.state.Lock()
defer s.state.Unlock()
return s.close()
}
func (s *Session) close() error {
if s.state.cancel == nil {
return ErrClosed
}
s.state.cancel()
s.state.cancel = nil
s.state.ctx = nil
<-s.state.doneCh
s.state.doneCh = nil
return s.state.gateway.LastError()
}