mirror of
https://github.com/diamondburned/arikawa.git
synced 2024-11-27 09:12:53 +00:00
8f548d2607
Signed-off-by: Cléo Rebert <cleo.rebert@gmail.com>
433 lines
12 KiB
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()
|
|
}
|