├── .dockerignore ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── Dockerfile ├── README.md ├── cmd └── root.go ├── compression ├── chan_writer.go ├── compressor.go └── zstd.go ├── config └── config.go ├── examples ├── connect │ └── main.go └── sharded │ └── main.go ├── gateway ├── api.go ├── connection.go ├── errors.go ├── limiter.go ├── logger.go ├── manager.go ├── manager_options.go ├── shard.go ├── shard_options.go ├── shard_store.go └── types.go ├── go.mod ├── go.sum ├── main.go └── stats └── metrics.go /.dockerignore: -------------------------------------------------------------------------------- 1 | /.dockerignore 2 | /.git 3 | /.gitignore 4 | /Dockerfile 5 | /gateway.toml 6 | /README.md 7 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | release: 4 | types: [published] 5 | push: 6 | 7 | jobs: 8 | 9 | docker: 10 | name: Docker 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Build & Publish 15 | uses: jerray/publish-docker-action@v1.0.5 16 | with: 17 | repository: spectacles/gateway 18 | username: ${{ secrets.DOCKER_USERNAME }} 19 | password: ${{ secrets.DOCKER_PASSWORD }} 20 | auto_tag: true 21 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: [push, pull_request] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | os: [ubuntu-latest, windows-latest] 11 | steps: 12 | 13 | - name: Set up Go 1.17 14 | uses: actions/setup-go@v2 15 | with: 16 | go-version: '^1.17.5' 17 | id: go 18 | 19 | - name: Checkout repository 20 | uses: actions/checkout@v1 21 | 22 | - name: Create build output directory 23 | run: mkdir build 24 | 25 | - name: Build 26 | run: go build -v -o build . 27 | 28 | - name: Upload artifact 29 | uses: actions/upload-artifact@v1 30 | with: 31 | name: gateway 32 | path: build 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /gateway.exe 2 | /.gateway* 3 | /.vscode 4 | /gateway.toml 5 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS build 2 | 3 | WORKDIR /usr/gateway 4 | COPY go.mod go.sum ./ 5 | RUN go mod download 6 | COPY . . 7 | # https://github.com/valyala/gozstd/issues/20 8 | RUN apk add gcc musl-dev libc-dev make && \ 9 | GOZSTD_VER=$(cat go.mod | fgrep github.com/valyala/gozstd | awk '{print $NF}') && \ 10 | go get -d github.com/valyala/gozstd@${GOZSTD_VER} && \ 11 | cd ${GOPATH}/pkg/mod/github.com/valyala/gozstd@${GOZSTD_VER} && \ 12 | make clean && \ 13 | make -j $(nproc) libzstd.a && \ 14 | cd /usr/gateway && \ 15 | go build -o build/gateway 16 | 17 | FROM alpine:latest 18 | COPY --from=build /usr/gateway/build/gateway /gateway 19 | ENTRYPOINT ["/gateway"] 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spectacles Gateway 2 | 3 | [![Docker pulls](https://img.shields.io/docker/pulls/spectacles/gateway)](https://hub.docker.com/r/spectacles/gateway) 4 | 5 | The Spectacles gateway acts as a standalone process between your Discord bot application and the 6 | Discord gateway, allowing your bot to focus entirely on application logic. This has numerous 7 | benefits: 8 | 9 | 1. **Seamless upgrades.** If you configure the Spectacles Gateway to use one of the supported 10 | message broker protocols, you can restart your bot and not lose any messages from the Discord 11 | Gateway. 12 | 2. **Load scalability.** With the Spectacles Gateway responsible for all of the Discord logic, 13 | you can scale your bot to handle high-load situations without worrying about restarting shards 14 | and sessions. 15 | 3. **Feature scalability.** Since Discord messages get sent into a message broker, you can consume 16 | them from more than just your bot application. Making a dashboard is trivial since you can run a 17 | web application independently of your bot application and receive the exact same data. 18 | 19 | ## Getting Started 20 | 21 | The recommended usage is through Docker, but pre-built binaries are also available in Github 22 | Actions or you can compile it yourself using the latest Go compiler. Note that C build tools must 23 | be available on your machine. 24 | 25 | ### Example 26 | 27 | This example uses Docker to launch the most basic form of gateway with only the `MESSAGE_CREATE` 28 | event being output to STDOUT. 29 | 30 | ```bash 31 | docker run --rm -it \ 32 | -e DISCORD_TOKEN="your token" \ 33 | -e DISCORD_EVENTS=MESSAGE_CREATE \ 34 | -e DISCORD_INTENTS=GUILD,GUILD_MESSAGES \ 35 | spectacles/gateway 36 | ``` 37 | 38 | ## Usage 39 | 40 | ``` 41 | Usage of gateway: 42 | -config string 43 | location of the gateway config file (default "gateway.toml") 44 | -loglevel string 45 | log level for the client (default "info") 46 | ``` 47 | 48 | The gateway can be configured using either a config file or environment variables. Environment 49 | variables take precedence over their corresponding entry in the config file. 50 | 51 | ### Config file 52 | 53 | ```toml 54 | token = "" # Discord token 55 | events = [] # array of gateway event names to publish 56 | 57 | # https://discord.com/developers/docs/topics/gateway#gateway-intents 58 | intents = [] # array of gateway intents to send when identifying 59 | 60 | # everything below is optional 61 | 62 | [shards] 63 | count = 2 64 | ids = [0, 1] 65 | 66 | [broker] 67 | type = "redis" # can also use "amqp" 68 | group = "gateway" 69 | message_timeout = "2m" # this is the default value: https://golang.org/pkg/time/#ParseDuration 70 | 71 | [api] 72 | version = 10 73 | scheme = "https" 74 | host = "discord.com" 75 | 76 | # exposes Prometheus-compatible statistics 77 | [prometheus] 78 | address = ":8080" 79 | endpoint = "/metrics" 80 | 81 | [shard_store] 82 | type = "redis" # if left empty, shard info is stored locally 83 | prefix = "gateway" # string to prefix shard-store keys 84 | 85 | [presence] 86 | # https://discord.com/developers/docs/topics/gateway#update-status 87 | 88 | [redis] 89 | urls = ["localhost:6379"] # more than 1 URL will be interpreted as a cluster 90 | pool_size = 5 # size of Redis connection pool 91 | 92 | # required for AMQP broker type 93 | [amqp] 94 | url = "amqp://localhost" 95 | ``` 96 | 97 | Example presence: 98 | 99 | ```toml 100 | [presence] 101 | status = "online" 102 | 103 | [presence.game] 104 | name = "test" 105 | type = 0 106 | ``` 107 | 108 | ### Environment variables 109 | 110 | Each of the below environment variables corresponds exactly to the config file above. 111 | 112 | - `DISCORD_TOKEN` 113 | - `DISCORD_EVENTS`: comma-separated list of gateway events 114 | 115 | Optional: 116 | 117 | - `DISCORD_INTENTS`: comma-separated list of gateway intents 118 | - `DISCORD_RAW_INTENTS`: bitfield containing raw intent flags 119 | - `DISCORD_SHARD_COUNT` 120 | - `DISCORD_SHARD_IDS`: comma-separated list of shard IDs 121 | - `DISCORD_API_VERSION` 122 | - `DISCORD_API_PROTOCOL` 123 | - `DISCORD_API_HOST` 124 | - `BROKER_TYPE` 125 | - `BROKER_GROUP` 126 | - `BROKER_MESSAGE_TIMEOUT` 127 | - `PROMETHEUS_ADDRESS` 128 | - `PROMETHEUS_ENDPOINT` 129 | - `SHARD_STORE_TYPE` 130 | - `SHARD_STORE_PREFIX` 131 | - `DISCORD_PRESENCE`: JSON-formatted presence object 132 | 133 | External connections: 134 | 135 | - `AMQP_URL` 136 | - `REDIS_URL`: comma-separated list of Redis URLs 137 | - `REDIS_POOL_SIZE` 138 | 139 | ## How It Works 140 | 141 | The Spectacles Gateway handles all of the Discord logic and simply forwards events to the specified 142 | message broker. Your application is completely unaware of the existence of shards and just focuses 143 | on handling incoming messages. 144 | 145 | By default, the Spectacles Gateway sends and receives data through standard input and output. For 146 | optimal use, you should use one of the available message broker protocols (Redis or AMQP) to 147 | send output to an external message broker (we recommend Redis). Your application can then 148 | consume messages from the message broker. 149 | 150 | Logs are output to STDERR and can be used to inspect the state of the gateway at any point. The 151 | Spectacles Gateway also offers integration with Prometheus to enable detailed stats collection. 152 | 153 | If you configure a shard storage solution (currently only Redis), shard information will be stored 154 | there and used if/when the Spectacles Gateway restarts. If the Gateway restarts quickly enough, it 155 | will be able to resume sessions without re-identifying to Discord. If you do not configure shard 156 | storage, the gateway will just store the info in local memory. 157 | 158 | ## Goals 159 | 160 | - [x] Multiple output destinations 161 | - [x] STDIO 162 | - [x] AMQP 163 | - [x] Redis 164 | - [x] Sharding 165 | - [x] Internal 166 | - [x] External 167 | - [ ] Auto (fully managed) 168 | - [x] Distributable binary builds 169 | - [x] Linux 170 | - [x] Windows 171 | - [x] Multithreading 172 | - [x] Zero-alloc message handling 173 | - [x] Discord compression (ZSTD) 174 | - [x] Automatic restarting 175 | - [ ] Failover 176 | - [x] Session resuming 177 | - [x] Local 178 | - [x] Redis 179 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "net/http" 7 | "os" 8 | "strconv" 9 | 10 | "github.com/mediocregopher/radix/v4" 11 | "github.com/prometheus/client_golang/prometheus/promhttp" 12 | "github.com/rabbitmq/amqp091-go" 13 | "github.com/spec-tacles/gateway/config" 14 | "github.com/spec-tacles/gateway/gateway" 15 | "github.com/spec-tacles/go/broker" 16 | "github.com/spec-tacles/go/broker/amqp" 17 | "github.com/spec-tacles/go/broker/redis" 18 | "github.com/spec-tacles/go/rest" 19 | "github.com/spec-tacles/go/types" 20 | ) 21 | 22 | var ( 23 | logger = gateway.ChildLogger(gateway.DefaultLogger, "[CMD]") 24 | logLevels = map[string]int{ 25 | "suppress": gateway.LogLevelSuppress, 26 | "info": gateway.LogLevelInfo, 27 | "warn": gateway.LogLevelWarn, 28 | "debug": gateway.LogLevelDebug, 29 | "error": gateway.LogLevelError, 30 | } 31 | logLevel = flag.String("loglevel", "info", "log level for the client") 32 | configLocation = flag.String("config", "gateway.toml", "location of the gateway config file") 33 | ) 34 | 35 | var redisActor redis.RedisActor 36 | 37 | func getRedis(ctx context.Context, conf *config.Config) redis.RedisActor { 38 | if redisActor != nil { 39 | return redisActor 40 | } 41 | 42 | var ( 43 | newClient redis.RedisActor 44 | err error 45 | poolConf = radix.PoolConfig{ 46 | Size: conf.Redis.PoolSize, 47 | } 48 | ) 49 | 50 | if len(conf.Redis.URLs) > 1 { 51 | newClient, err = radix.ClusterConfig{ 52 | PoolConfig: poolConf, 53 | }.New(ctx, conf.Redis.URLs) 54 | } else { 55 | newClient, err = poolConf.New(ctx, "tcp", conf.Redis.URLs[0]) 56 | } 57 | 58 | if err != nil { 59 | logger.Fatalf("Unable to connect to redis: %s", err) 60 | } 61 | 62 | redisActor = newClient 63 | return newClient 64 | } 65 | 66 | // Run runs the CLI app 67 | func Run() { 68 | logger.Println("starting gateway") 69 | flag.Parse() 70 | 71 | conf, err := config.Read(*configLocation) 72 | if err != nil { 73 | logger.Fatalf("unable to load config: %s\n", err) 74 | } 75 | 76 | if conf.Prometheus.Address != "" { 77 | var mainHandler http.Handler 78 | if conf.Prometheus.Endpoint == "" { 79 | mainHandler = promhttp.Handler() 80 | } else { 81 | http.Handle(conf.Prometheus.Endpoint, promhttp.Handler()) 82 | } 83 | 84 | logger.Printf("exposing Prometheus stats at %v%v", conf.Prometheus.Address, conf.Prometheus.Endpoint) 85 | go func() { 86 | logger.Fatal(http.ListenAndServe(conf.Prometheus.Address, mainHandler)) 87 | }() 88 | } 89 | 90 | var ( 91 | manager *gateway.Manager 92 | b broker.Broker 93 | shardStore gateway.ShardStore 94 | logLevel = logLevels[*logLevel] 95 | ctx = context.Background() 96 | ) 97 | 98 | switch conf.Broker.Type { 99 | case "amqp": 100 | conn, err := amqp091.Dial(conf.AMQP.URL) 101 | if err != nil { 102 | logger.Fatalf("error connecting to AMQP: %s", err) 103 | } 104 | 105 | amqp := &amqp.AMQP{ 106 | Group: conf.Broker.Group, 107 | Timeout: conf.Broker.MessageTimeout.Duration, 108 | } 109 | amqp.Init(conn) 110 | b = amqp 111 | case "redis": 112 | client := getRedis(ctx, conf) 113 | r := redis.NewRedis(client, conf.Broker.Group) 114 | r.UnackTimeout = conf.Broker.MessageTimeout.Duration 115 | 116 | b = r 117 | default: 118 | b = &broker.RWBroker{R: os.Stdin, W: os.Stdout} 119 | } 120 | 121 | switch conf.ShardStore.Type { 122 | case "redis": 123 | redis := getRedis(ctx, conf) 124 | shardStore = &gateway.RedisShardStore{ 125 | Redis: redis, 126 | Prefix: conf.ShardStore.Prefix, 127 | } 128 | } 129 | 130 | r := rest.NewClient(conf.Token, strconv.FormatUint(uint64(conf.API.Version), 10)) 131 | r.URLHost = conf.API.Host 132 | r.URLScheme = conf.API.Scheme 133 | 134 | manager = gateway.NewManager(&gateway.ManagerOptions{ 135 | ShardOptions: &gateway.ShardOptions{ 136 | Store: shardStore, 137 | Identify: &types.Identify{ 138 | Token: conf.Token, 139 | Intents: int(conf.RawIntents), 140 | Presence: &conf.Presence, 141 | }, 142 | Version: conf.GatewayVersion, 143 | }, 144 | REST: r, 145 | LogLevel: logLevel, 146 | ShardCount: conf.Shards.Count, 147 | }) 148 | 149 | evts := make(map[string]struct{}) 150 | for _, e := range conf.Events { 151 | evts[e] = struct{}{} 152 | } 153 | manager.ConnectBroker(ctx, b, evts) 154 | 155 | logger.Printf("using config:\n%+v\n", conf) 156 | if err := manager.Start(ctx); err != nil { 157 | logger.Fatalf("failed to connect to discord: %v", err) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /compression/chan_writer.go: -------------------------------------------------------------------------------- 1 | package compression 2 | 3 | // ChanWriter is a writer than sends all writes to a channel 4 | type ChanWriter struct { 5 | C chan []byte 6 | } 7 | 8 | func (w *ChanWriter) Write(d []byte) (int, error) { 9 | w.C <- d 10 | return len(d), nil 11 | } 12 | 13 | // Close this writer 14 | func (w *ChanWriter) Close() error { 15 | close(w.C) 16 | return nil 17 | } 18 | -------------------------------------------------------------------------------- /compression/compressor.go: -------------------------------------------------------------------------------- 1 | package compression 2 | 3 | // Compressor is something that can de/compress data 4 | type Compressor interface { 5 | Compress([]byte) []byte 6 | Decompress([]byte) ([]byte, error) 7 | } 8 | -------------------------------------------------------------------------------- /compression/zstd.go: -------------------------------------------------------------------------------- 1 | package compression 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/valyala/gozstd" 7 | ) 8 | 9 | // Zstd represents a de/compression context. Zero value is not valid. 10 | type Zstd struct { 11 | cw *gozstd.Writer 12 | cr *ChanWriter 13 | dw io.Writer 14 | dr *ChanWriter 15 | } 16 | 17 | // NewZstd creates a valid zstd context 18 | func NewZstd() *Zstd { 19 | cr := &ChanWriter{make(chan []byte)} 20 | zw := gozstd.NewWriter(cr) 21 | 22 | dr, dw := io.Pipe() 23 | zr := gozstd.NewReader(dr) 24 | dChanWriter := &ChanWriter{make(chan []byte)} 25 | go zr.WriteTo(dChanWriter) 26 | return &Zstd{zw, cr, dw, dChanWriter} 27 | } 28 | 29 | // Compress compresses the given bytes and returns the compressed form 30 | func (z *Zstd) Compress(d []byte) []byte { 31 | z.cw.Write(d) 32 | go z.cw.Flush() 33 | return <-z.cr.C 34 | } 35 | 36 | // Decompress decompresses the given bytes and returns the decompressed form 37 | func (z *Zstd) Decompress(d []byte) ([]byte, error) { 38 | _, err := z.dw.Write(d) 39 | if err != nil { 40 | return []byte{}, err 41 | } 42 | 43 | return <-z.dr.C, nil 44 | } 45 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strconv" 9 | "strings" 10 | "time" 11 | 12 | "github.com/BurntSushi/toml" 13 | "github.com/spec-tacles/go/types" 14 | ) 15 | 16 | type duration struct { 17 | time.Duration 18 | } 19 | 20 | func (d *duration) UnmarshalText(text []byte) (err error) { 21 | d.Duration, err = time.ParseDuration(string(text)) 22 | return 23 | } 24 | 25 | // Config represents configuration structure for the gateway 26 | type Config struct { 27 | Token string 28 | Events []string 29 | Intents []string 30 | RawIntents uint 31 | GatewayVersion uint `toml:"gateway_version"` 32 | Shards struct { 33 | Count int 34 | IDs []int 35 | } 36 | Broker struct { 37 | Type string 38 | Group string 39 | MessageTimeout duration `toml:"message_timeout"` 40 | } 41 | Prometheus struct { 42 | Address string 43 | Endpoint string 44 | } 45 | ShardStore struct { 46 | Type string 47 | Prefix string 48 | } `toml:"shard_store"` 49 | Presence types.StatusUpdate 50 | 51 | API struct { 52 | Scheme string 53 | Host string 54 | Version uint 55 | } 56 | 57 | AMQP struct { 58 | URL string 59 | } 60 | Redis struct { 61 | URLs []string 62 | PoolSize int `toml:"pool_size"` 63 | } 64 | } 65 | 66 | // Read reads the config from file 67 | func Read(file string) (conf *Config, err error) { 68 | conf = &Config{} 69 | toml.DecodeFile(file, conf) 70 | conf.LoadEnv() 71 | err = conf.Init() 72 | return 73 | } 74 | 75 | // Init initializes default config values 76 | func (c *Config) Init() error { 77 | if c.Token == "" { 78 | return errors.New("missing Discord token") 79 | } 80 | 81 | if c.Broker.Group == "" { 82 | c.Broker.Group = "gateway" 83 | } 84 | 85 | if c.Broker.MessageTimeout.Duration == time.Duration(0) { 86 | c.Broker.MessageTimeout = duration{2 * time.Minute} 87 | } 88 | 89 | if c.RawIntents == 0 { 90 | for _, intent := range c.Intents { 91 | switch intent { 92 | case "GUILDS": 93 | c.RawIntents |= types.IntentGuilds 94 | case "GUILD_MEMBERS": 95 | c.RawIntents |= types.IntentGuildMembers 96 | case "GUILD_BANS": 97 | c.RawIntents |= types.IntentGuildBans 98 | case "GUILD_EMOJIS": 99 | c.RawIntents |= types.IntentGuildEmojis 100 | case "GUILD_INTEGRATIONS": 101 | c.RawIntents |= types.IntentGuildIntegrations 102 | case "GUILD_WEBHOOKS": 103 | c.RawIntents |= types.IntentGuildWebhooks 104 | case "GUILD_INVITES": 105 | c.RawIntents |= types.IntentGuildInvites 106 | case "GUILD_VOICE_STATES": 107 | c.RawIntents |= types.IntentGuildVoiceStates 108 | case "GUILD_PRESENCES": 109 | c.RawIntents |= types.IntentGuildPresences 110 | case "GUILD_MESSAGES": 111 | c.RawIntents |= types.IntentGuildMessages 112 | case "GUILD_MESSAGE_REACTIONS": 113 | c.RawIntents |= types.IntentGuildMessageReactions 114 | case "GUILD_MESSAGE_TYPING": 115 | c.RawIntents |= types.IntentGuildMessageTyping 116 | case "DIRECT_MESSAGES": 117 | c.RawIntents |= types.IntentDirectMessages 118 | case "DIRECT_MESSAGE_REACTIONS": 119 | c.RawIntents |= types.IntentDirectMessageReactions 120 | case "DIRECT_MESSAGE_TYPING": 121 | c.RawIntents |= types.IntentDirectMessageTyping 122 | case "MESSAGE_CONTENT": 123 | c.RawIntents |= types.IntentMessageContent 124 | case "GUILD_SCHEDULED_EVENTS": 125 | c.RawIntents |= types.IntentGuildScheduledEvents 126 | case "AUTO_MODERATION_CONFIGURATION": 127 | c.RawIntents |= types.IntentAutoModerationConfiguration 128 | case "AUTO_MODERATION_EXECUTION": 129 | c.RawIntents |= types.IntentAutoModerationExecution 130 | } 131 | } 132 | } 133 | 134 | if c.Redis.PoolSize == 0 { 135 | c.Redis.PoolSize = 5 136 | } 137 | 138 | if c.API.Scheme == "" { 139 | c.API.Scheme = "https" 140 | } 141 | 142 | if c.API.Host == "" { 143 | c.API.Host = "discord.com" 144 | } 145 | 146 | if c.API.Version == 0 { 147 | c.API.Version = 10 148 | } 149 | 150 | return nil 151 | } 152 | 153 | // LoadEnv loads environment variables into the config, overwriting any existing values 154 | func (c *Config) LoadEnv() { 155 | var v string 156 | 157 | v = os.Getenv("DISCORD_TOKEN") 158 | if v != "" { 159 | c.Token = v 160 | } 161 | 162 | v = os.Getenv("DISCORD_EVENTS") 163 | if v != "" { 164 | events := strings.Split(v, ",") 165 | 166 | for i, event := range events { 167 | events[i] = strings.TrimSpace(event) 168 | } 169 | 170 | c.Events = events 171 | } 172 | 173 | v = os.Getenv("DISCORD_INTENTS") 174 | if v != "" { 175 | intents := strings.Split(v, ",") 176 | 177 | for i, intent := range intents { 178 | intents[i] = strings.TrimSpace(intent) 179 | } 180 | 181 | c.Intents = intents 182 | } 183 | 184 | v = os.Getenv("DISCORD_RAW_INTENTS") 185 | if v != "" { 186 | i, err := strconv.ParseUint(v, 10, 32) 187 | if err == nil { 188 | c.RawIntents = uint(i) 189 | } 190 | } 191 | 192 | v = os.Getenv("DISCORD_GATEWAY_VERSION") 193 | if v != "" { 194 | i, err := strconv.ParseUint(v, 10, 32) 195 | if err == nil { 196 | c.GatewayVersion = uint(i) 197 | } 198 | } 199 | 200 | v = os.Getenv("DISCORD_SHARD_COUNT") 201 | if v != "" { 202 | i, err := strconv.ParseUint(v, 10, 32) 203 | if err == nil { 204 | c.Shards.Count = int(i) 205 | } 206 | } 207 | 208 | v = os.Getenv("DISCORD_SHARD_IDS") 209 | if v != "" { 210 | ids := strings.Split(v, ",") 211 | c.Shards.IDs = make([]int, len(ids)) 212 | for i, id := range ids { 213 | convID, err := strconv.Atoi(id) 214 | if err == nil { 215 | c.Shards.IDs[i] = convID 216 | } 217 | } 218 | } 219 | 220 | v = os.Getenv("DISCORD_PRESENCE") 221 | if v != "" { 222 | var presence types.StatusUpdate 223 | err := json.Unmarshal([]byte(v), &presence) 224 | if err == nil { 225 | c.Presence = presence 226 | } 227 | } 228 | 229 | v = os.Getenv("DISCORD_API_PROTOCOL") 230 | if v != "" { 231 | c.API.Scheme = v 232 | } 233 | 234 | v = os.Getenv("DISCORD_API_HOST") 235 | if v != "" { 236 | c.API.Host = v 237 | } 238 | 239 | v = os.Getenv("DISCORD_API_VERSION") 240 | if v != "" { 241 | version, err := strconv.ParseUint(v, 10, 8) 242 | if err == nil { 243 | c.API.Version = uint(version) 244 | } 245 | } 246 | 247 | v = os.Getenv("BROKER_TYPE") 248 | if v != "" { 249 | c.Broker.Type = v 250 | } 251 | 252 | v = os.Getenv("BROKER_GROUP") 253 | if v != "" { 254 | c.Broker.Group = v 255 | } 256 | 257 | v = os.Getenv("BROKER_MESSAGE_TIMEOUT") 258 | if v != "" { 259 | timeout, err := time.ParseDuration(v) 260 | if err == nil { 261 | c.Broker.MessageTimeout = duration{timeout} 262 | } 263 | } 264 | 265 | v = os.Getenv("PROMETHEUS_ADDRESS") 266 | if v != "" { 267 | c.Prometheus.Address = v 268 | } 269 | 270 | v = os.Getenv("PROMETHEUS_ENDPOINT") 271 | if v != "" { 272 | c.Prometheus.Endpoint = v 273 | } 274 | 275 | v = os.Getenv("SHARD_STORE_TYPE") 276 | if v != "" { 277 | c.ShardStore.Type = v 278 | } 279 | 280 | v = os.Getenv("SHARD_STORE_PREFIX") 281 | if v != "" { 282 | c.ShardStore.Prefix = v 283 | } 284 | 285 | v = os.Getenv("AMQP_URL") 286 | if v != "" { 287 | c.AMQP.URL = v 288 | } 289 | 290 | v = os.Getenv("REDIS_URL") 291 | if v != "" { 292 | urls := strings.Split(v, ",") 293 | c.Redis.URLs = urls 294 | } 295 | 296 | v = os.Getenv("REDIS_POOL_SIZE") 297 | if v != "" { 298 | i, err := strconv.Atoi(v) 299 | if err == nil { 300 | c.Redis.PoolSize = i 301 | } 302 | } 303 | } 304 | 305 | func (c *Config) String() string { 306 | strs := []string{ 307 | fmt.Sprintf("Events: %v", c.Events), 308 | fmt.Sprintf("Intents: %v", c.Intents), 309 | fmt.Sprintf("Raw intents: %d", c.RawIntents), 310 | fmt.Sprintf("Shard count: %d", c.Shards.Count), 311 | fmt.Sprintf("Shard IDs: %v", c.Shards.IDs), 312 | fmt.Sprintf("Broker: %+v", c.Broker), 313 | fmt.Sprintf("Shard store: %+v", c.ShardStore), 314 | fmt.Sprintf("API: %+v", c.API), 315 | fmt.Sprintf("Presence: %+v", c.Presence), 316 | fmt.Sprintf("Activities: %+v", c.Presence.Activities), 317 | "", 318 | fmt.Sprintf("Prometheus: %+v", c.Prometheus), 319 | fmt.Sprintf("AMQP: %+v", c.AMQP), 320 | fmt.Sprintf("Redis: %+v", c.Redis), 321 | } 322 | 323 | return strings.Join(strs, "\n") 324 | } 325 | -------------------------------------------------------------------------------- /examples/connect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/spec-tacles/gateway/gateway" 10 | "github.com/spec-tacles/go/rest" 11 | "github.com/spec-tacles/go/types" 12 | ) 13 | 14 | var token = os.Getenv("TOKEN") 15 | 16 | func main() { 17 | c := gateway.NewShard(&gateway.ShardOptions{ 18 | Identify: &types.Identify{ 19 | Token: token, 20 | }, 21 | OnPacket: func(r *types.ReceivePacket) { 22 | fmt.Printf("Received op %d, event %s, seq %d\n", r.Op, r.Event, r.Seq) 23 | }, 24 | LogLevel: gateway.LogLevelDebug, 25 | }) 26 | 27 | var err error 28 | c.Gateway, err = gateway.FetchGatewayBot(rest.NewClient(token, "9")) 29 | if err != nil { 30 | log.Panicf("failed to load gateway: %v", err) 31 | } 32 | 33 | ctx := context.Background() 34 | if err := c.Open(ctx); err != nil { 35 | log.Panicf("failed to open: %v", err) 36 | } 37 | 38 | select {} 39 | } 40 | -------------------------------------------------------------------------------- /examples/sharded/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "os" 8 | 9 | "github.com/spec-tacles/gateway/gateway" 10 | "github.com/spec-tacles/go/rest" 11 | "github.com/spec-tacles/go/types" 12 | ) 13 | 14 | var token = os.Getenv("TOKEN") 15 | 16 | func main() { 17 | m := gateway.NewManager(&gateway.ManagerOptions{ 18 | ShardOptions: &gateway.ShardOptions{ 19 | Identify: &types.Identify{ 20 | Token: token, 21 | }, 22 | LogLevel: gateway.LogLevelDebug, 23 | }, 24 | REST: rest.NewClient(token, "9"), 25 | OnPacket: func(shard int, r *types.ReceivePacket) { 26 | fmt.Printf("Received op %d, event %s, and seq %d on shard %d\n", r.Op, r.Event, r.Seq, shard) 27 | }, 28 | LogLevel: gateway.LogLevelInfo, 29 | }) 30 | 31 | ctx := context.Background() 32 | if err := m.Start(ctx); err != nil { 33 | log.Panicf("failed to start: %v", err) 34 | } 35 | 36 | select {} 37 | } 38 | -------------------------------------------------------------------------------- /gateway/api.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/spec-tacles/go/types" 8 | ) 9 | 10 | // DefaultVersion represents the default Gateway version 11 | const DefaultVersion uint = 10 12 | 13 | // Endpoints used for the Gateway 14 | const ( 15 | EndpointGateway = "/gateway" 16 | EndpointGatewayBot = EndpointGateway + "/bot" 17 | ) 18 | 19 | // REST represents a simple REST API handler 20 | type REST interface { 21 | DoJSON(string, string, io.Reader, interface{}) error 22 | } 23 | 24 | // FetchGatewayBot fetches bot Gateway information 25 | func FetchGatewayBot(rest REST) (*types.GatewayBot, error) { 26 | g := new(types.GatewayBot) 27 | return g, rest.DoJSON(http.MethodGet, EndpointGatewayBot, nil, g) 28 | } 29 | -------------------------------------------------------------------------------- /gateway/connection.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/gorilla/websocket" 7 | "github.com/spec-tacles/gateway/compression" 8 | ) 9 | 10 | // Connection wraps a websocket connection 11 | type Connection struct { 12 | ws *websocket.Conn 13 | compressor compression.Compressor 14 | rmux *sync.Mutex 15 | wmux *sync.Mutex 16 | } 17 | 18 | // NewConnection creates a new ReadWriteCloser wrapper around a connection 19 | func NewConnection(conn *websocket.Conn, compressor compression.Compressor) (c *Connection) { 20 | return &Connection{ 21 | ws: conn, 22 | compressor: compressor, 23 | rmux: &sync.Mutex{}, 24 | wmux: &sync.Mutex{}, 25 | } 26 | } 27 | 28 | // CloseWithCode closes the connection with the specified code 29 | func (c *Connection) CloseWithCode(code int) error { 30 | return c.ws.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(code, "Normal Closure")) 31 | } 32 | 33 | // Close closes this connection 34 | func (c *Connection) Close() error { 35 | return c.CloseWithCode(websocket.CloseNormalClosure) 36 | } 37 | 38 | func (c *Connection) Write(d []byte) (int, error) { 39 | // d = c.compressor.Compress(d) 40 | 41 | c.wmux.Lock() 42 | defer c.wmux.Unlock() 43 | 44 | return len(d), c.ws.WriteMessage(websocket.BinaryMessage, d) 45 | } 46 | 47 | func (c *Connection) Read() (d []byte, err error) { 48 | c.rmux.Lock() 49 | defer c.rmux.Unlock() 50 | 51 | t, d, err := c.ws.ReadMessage() 52 | if err != nil { 53 | return 54 | } 55 | 56 | if t == websocket.BinaryMessage { 57 | d, err = c.compressor.Decompress(d) 58 | } 59 | 60 | return 61 | } 62 | -------------------------------------------------------------------------------- /gateway/errors.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import "errors" 4 | 5 | // Errors 6 | var ( 7 | ErrGatewayAbsent = errors.New("gateway information hasn't been fetched") 8 | ErrHeartbeatUnacknowledged = errors.New("heartbeat was never acknowledged") 9 | ErrMaxRetriesExceeded = errors.New("max retries exceeded") 10 | ErrReconnectReceived = errors.New("received reconnect OP code") 11 | ErrConnectionClosed = errors.New("connection was closed") 12 | ) 13 | -------------------------------------------------------------------------------- /gateway/limiter.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | "time" 7 | ) 8 | 9 | // Limiter represents something that blocks until a ratelimit has been fulfilled 10 | type Limiter interface { 11 | Lock() 12 | } 13 | 14 | // DefaultLimiter is a limiter that works locally 15 | type DefaultLimiter struct { 16 | limit *int32 17 | duration *int64 18 | 19 | resetsAt *int64 20 | available *int32 21 | mux sync.Mutex 22 | } 23 | 24 | // NewDefaultLimiter creates a default limiter 25 | func NewDefaultLimiter(limit int32, duration time.Duration) Limiter { 26 | nanos := duration.Nanoseconds() 27 | return &DefaultLimiter{ 28 | limit: &limit, 29 | duration: &nanos, 30 | 31 | resetsAt: new(int64), 32 | available: new(int32), 33 | mux: sync.Mutex{}, 34 | } 35 | } 36 | 37 | // Lock establishes a ratelimited lock on the limiter 38 | func (l *DefaultLimiter) Lock() { 39 | now := time.Now().UnixNano() 40 | 41 | if atomic.LoadInt64(l.resetsAt) <= now { 42 | atomic.StoreInt64(l.resetsAt, now+atomic.LoadInt64(l.duration)) 43 | atomic.StoreInt32(l.available, atomic.LoadInt32(l.limit)) 44 | } 45 | 46 | if atomic.LoadInt32(l.available) <= 0 { 47 | time.Sleep(time.Duration(atomic.LoadInt64(l.resetsAt) - now)) 48 | l.Lock() 49 | return 50 | } 51 | 52 | atomic.AddInt32(l.available, -1) 53 | } 54 | -------------------------------------------------------------------------------- /gateway/logger.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | // Usable log levels 10 | const ( 11 | LogLevelSuppress = iota - 1 12 | LogLevelError 13 | LogLevelWarn 14 | LogLevelInfo 15 | LogLevelDebug 16 | ) 17 | 18 | // DefaultLogger is the default logger from which each child logger is derived 19 | var DefaultLogger = log.New(os.Stderr, "", log.LstdFlags|log.Lmicroseconds) 20 | 21 | // ChildLogger creates a child logger with the specified prefix 22 | func ChildLogger(parent *log.Logger, prefix string) *log.Logger { 23 | return log.New(parent.Writer(), parent.Prefix()+prefix+" ", parent.Flags()) 24 | } 25 | 26 | func (s *Shard) log(level int, format string, args ...interface{}) { 27 | if level > s.opts.LogLevel { 28 | return 29 | } 30 | 31 | s.opts.Logger.Printf(format+"\n", args...) 32 | } 33 | 34 | func (s *Shard) logTrace(trace []string) { 35 | if LogLevelDebug > s.opts.LogLevel { 36 | return 37 | } 38 | 39 | s.opts.Logger.Printf("Trace: %s\n", strings.Join(trace, " -> ")) 40 | } 41 | 42 | func (s *Manager) log(level int, format string, args ...interface{}) { 43 | if level > s.opts.LogLevel { 44 | return 45 | } 46 | 47 | s.opts.Logger.Printf(format+"\n", args...) 48 | } 49 | -------------------------------------------------------------------------------- /gateway/manager.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "strconv" 7 | "sync" 8 | 9 | "github.com/spec-tacles/gateway/stats" 10 | "github.com/spec-tacles/go/broker" 11 | "github.com/spec-tacles/go/types" 12 | ) 13 | 14 | // RepublishPacket represents a SEND packet that now has a shard ID and must be re-published back to AMQP 15 | type RepublishPacket struct { 16 | ShardID int 17 | Packet *types.SendPacket 18 | } 19 | 20 | // Manager manages Gateway shards 21 | type Manager struct { 22 | Shards map[int]*Shard 23 | Gateway *types.GatewayBot 24 | opts *ManagerOptions 25 | gatewayLock sync.Mutex 26 | } 27 | 28 | // NewManager creates a new Gateway manager 29 | func NewManager(opts *ManagerOptions) *Manager { 30 | opts.init() 31 | 32 | return &Manager{ 33 | Shards: make(map[int]*Shard), 34 | opts: opts, 35 | gatewayLock: sync.Mutex{}, 36 | } 37 | } 38 | 39 | // Start starts all shards 40 | func (m *Manager) Start(ctx context.Context) (err error) { 41 | if m.opts.ShardCount == 0 { 42 | m.log(LogLevelDebug, "Shard count unspecified: using Discord recommended value") 43 | 44 | var g *types.GatewayBot 45 | g, err = m.FetchGateway() 46 | if err != nil { 47 | m.log(LogLevelError, "Failed to fetch gateway info", err) 48 | return 49 | } 50 | 51 | m.opts.ShardCount = g.Shards 52 | } 53 | 54 | expected := m.opts.ShardCount / m.opts.ServerCount 55 | if m.opts.ServerIndex < (m.opts.ShardCount % m.opts.ServerCount) { 56 | expected++ 57 | } 58 | 59 | m.log(LogLevelInfo, "Starting %d shard(s) out of %d total", expected, m.opts.ShardCount) 60 | 61 | wg := sync.WaitGroup{} 62 | for i := m.opts.ServerIndex; i < m.opts.ShardCount; i += m.opts.ServerCount { 63 | id := i 64 | wg.Add(1) 65 | go func() { 66 | defer wg.Done() 67 | 68 | stats.TotalShards.Add(1) 69 | defer stats.TotalShards.Sub(1) 70 | 71 | err := m.Spawn(ctx, id) 72 | if err != nil { 73 | m.log(LogLevelError, "Fatal error in shard %d: %s", id, err) 74 | } else { 75 | m.log(LogLevelDebug, "Shard %d closing gracefully", id) 76 | } 77 | }() 78 | } 79 | 80 | wg.Wait() 81 | return 82 | } 83 | 84 | // Spawn a new shard with the specified ID 85 | func (m *Manager) Spawn(ctx context.Context, id int) (err error) { 86 | g, err := m.FetchGateway() 87 | if err != nil { 88 | return 89 | } 90 | 91 | opts := m.opts.ShardOptions.clone() 92 | opts.Identify.Shard = []int{id, m.opts.ShardCount} 93 | opts.LogLevel = m.opts.LogLevel 94 | opts.IdentifyLimiter = m.opts.ShardLimiter 95 | if opts.Logger == nil { 96 | opts.Logger = m.opts.Logger 97 | } 98 | 99 | if m.opts.OnPacket != nil { 100 | opts.OnPacket = func(r *types.ReceivePacket) { 101 | m.opts.OnPacket(id, r) 102 | } 103 | } 104 | 105 | s := NewShard(opts) 106 | s.Gateway = g 107 | m.Shards[id] = s 108 | 109 | err = s.Open(ctx) 110 | if err != nil { 111 | return 112 | } 113 | 114 | return s.Close() 115 | } 116 | 117 | // FetchGateway fetches the gateway or from cache 118 | func (m *Manager) FetchGateway() (g *types.GatewayBot, err error) { 119 | m.gatewayLock.Lock() 120 | defer m.gatewayLock.Unlock() 121 | 122 | if m.Gateway != nil { 123 | g = m.Gateway 124 | } else { 125 | g, err = FetchGatewayBot(m.opts.REST) 126 | m.log(LogLevelDebug, "Loaded gateway info %+v", g) 127 | m.Gateway = g 128 | } 129 | return 130 | } 131 | 132 | // ConnectBroker connects a broker to this manager. It forwards all packets from the gateway and 133 | // consumes packets from the broker for all shards it's responsible for. 134 | func (m *Manager) ConnectBroker(ctx context.Context, b broker.Broker, events map[string]struct{}) { 135 | ch := make(chan broker.Message) 136 | if b == nil { 137 | return 138 | } 139 | 140 | m.opts.OnPacket = func(shard int, d *types.ReceivePacket) { 141 | if d.Op != types.GatewayOpDispatch { 142 | return 143 | } 144 | 145 | if _, ok := events[string(d.Event)]; !ok { 146 | return 147 | } 148 | 149 | err := b.Publish(ctx, string(d.Event), d.Data) 150 | if err != nil { 151 | m.log(LogLevelError, "failed to publish packet to broker: %s", err) 152 | } 153 | } 154 | 155 | go func() { 156 | for msg := range ch { 157 | m.handleMessage(ctx, b, msg) 158 | } 159 | }() 160 | 161 | eventList := make([]string, len(m.Shards)+1) 162 | eventList = append(eventList, "SEND") 163 | for id := range m.Shards { 164 | eventList = append(eventList, strconv.FormatInt(int64(id), 10)) 165 | } 166 | 167 | go b.Subscribe(ctx, eventList, ch) 168 | } 169 | 170 | func (m *Manager) handleMessage(ctx context.Context, b broker.Broker, msg broker.Message) { 171 | var ( 172 | shard *Shard 173 | packet *types.SendPacket 174 | ) 175 | 176 | if msg.Event() == "SEND" { 177 | p := &UnknownSendPacket{} 178 | switch body := msg.Body().(type) { 179 | case []byte: 180 | err := json.Unmarshal(body, p) 181 | if err != nil { 182 | m.log(LogLevelWarn, "unable to parse SEND packet: %s", err) 183 | return 184 | } 185 | default: 186 | m.log(LogLevelWarn, "unexpected SEND packet type %T", body) 187 | return 188 | } 189 | 190 | shardID := int(p.GuildID >> 22 % uint64(m.opts.ShardCount)) 191 | shard = m.Shards[shardID] 192 | if shard == nil { 193 | data, err := json.Marshal(p.Packet) 194 | if err != nil { 195 | m.log(LogLevelError, "error serializing SEND packet data (%+v): %s", *p.Packet, err) 196 | return 197 | } 198 | 199 | err = b.Publish(ctx, strconv.Itoa(shardID), data) 200 | if err != nil { 201 | m.log(LogLevelError, "error re-publishing SEND packet data to shard %d: %s", shardID, err) 202 | } 203 | return 204 | } 205 | packet = p.Packet 206 | } else { 207 | shardID, err := strconv.Atoi(msg.Event()) 208 | if err != nil { 209 | m.log(LogLevelWarn, "received unexpected non-int event from AMQP: %s", err) 210 | } 211 | shard = m.Shards[shardID] 212 | if shard == nil { 213 | m.log(LogLevelWarn, "received event for shard %d which does not exist", shardID) 214 | return 215 | } 216 | 217 | switch body := msg.Body().(type) { 218 | case []byte: 219 | err := json.Unmarshal(body, packet) 220 | if err != nil { 221 | m.log(LogLevelWarn, "unable to parse packet intended for shard %d: %s", shardID, err) 222 | return 223 | } 224 | default: 225 | m.log(LogLevelWarn, "unexpected packet type %T", body) 226 | return 227 | } 228 | } 229 | 230 | err := shard.Send(packet) 231 | if err != nil { 232 | m.log(LogLevelError, "error sending packet (%d): %s", packet.Op, err) 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /gateway/manager_options.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | "github.com/spec-tacles/go/types" 8 | ) 9 | 10 | // ShardLimiter controls the rate at which the manager creates and starts shards 11 | type ShardLimiter interface { 12 | Wait(int) error 13 | } 14 | 15 | // ManagerOptions represents NewManager's options 16 | type ManagerOptions struct { 17 | ShardOptions *ShardOptions 18 | REST REST 19 | ShardLimiter Limiter 20 | 21 | ShardCount int 22 | ServerIndex int 23 | ServerCount int 24 | 25 | OnPacket func(int, *types.ReceivePacket) 26 | 27 | Logger *log.Logger 28 | LogLevel int 29 | } 30 | 31 | func (opts *ManagerOptions) init() { 32 | if opts.ShardLimiter == nil { 33 | // this is supposed to be 5s, but 5s causes every other session to be invalidated 34 | opts.ShardLimiter = NewDefaultLimiter(1, 5250*time.Millisecond) 35 | } 36 | 37 | if opts.ServerCount == 0 { 38 | opts.ServerCount = 1 39 | } 40 | 41 | if opts.Logger == nil { 42 | opts.Logger = DefaultLogger 43 | } 44 | opts.Logger = ChildLogger(opts.Logger, "[manager]") 45 | } 46 | -------------------------------------------------------------------------------- /gateway/shard.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "math/rand" 8 | "net/url" 9 | "strconv" 10 | "sync" 11 | "time" 12 | 13 | "github.com/gorilla/websocket" 14 | "github.com/spec-tacles/gateway/compression" 15 | "github.com/spec-tacles/gateway/stats" 16 | "github.com/spec-tacles/go/types" 17 | ) 18 | 19 | // Shard represents a Gateway shard 20 | type Shard struct { 21 | Gateway *types.GatewayBot 22 | Ping time.Duration 23 | 24 | conn *Connection 25 | 26 | id string 27 | opts *ShardOptions 28 | limiter Limiter 29 | packets *sync.Pool 30 | lastHeartbeat time.Time 31 | resumeURL string 32 | 33 | connMu sync.Mutex 34 | acks chan struct{} 35 | } 36 | 37 | // NewShard creates a new Gateway shard 38 | func NewShard(opts *ShardOptions) *Shard { 39 | opts.init() 40 | 41 | return &Shard{ 42 | opts: opts, 43 | limiter: NewDefaultLimiter(120, time.Minute), 44 | packets: &sync.Pool{ 45 | New: func() interface{} { 46 | return new(types.ReceivePacket) 47 | }, 48 | }, 49 | id: strconv.Itoa(opts.Identify.Shard[0]), 50 | acks: make(chan struct{}), 51 | resumeURL: "", 52 | } 53 | } 54 | 55 | // Open starts a new session. Any errors are fatal. 56 | func (s *Shard) Open(ctx context.Context) (err error) { 57 | err = s.connect(ctx) 58 | for s.handleClose(err) { 59 | err = s.connect(ctx) 60 | } 61 | return 62 | } 63 | 64 | // connect runs a single websocket connection; errors may indicate the connection is recoverable 65 | func (s *Shard) connect(ctx context.Context) (err error) { 66 | if s.Gateway == nil { 67 | return ErrGatewayAbsent 68 | } 69 | 70 | url := s.gatewayURL() 71 | s.log(LogLevelInfo, "Connecting using URL: %s", url) 72 | 73 | conn, _, err := websocket.DefaultDialer.Dial(url, nil) 74 | if err != nil { 75 | return 76 | } 77 | s.conn = NewConnection(conn, compression.NewZstd()) 78 | 79 | heartbeatCtx, cancelHeartbeat := context.WithCancel(ctx) 80 | defer cancelHeartbeat() 81 | 82 | err = s.expectPacket(ctx, types.GatewayOpHello, types.GatewayEventNone, s.handleHello(heartbeatCtx)) 83 | if err != nil { 84 | return 85 | } 86 | 87 | seq, err := s.opts.Store.GetSeq(ctx, s.idUint()) 88 | if err != nil { 89 | s.log(LogLevelWarn, "Unable to retrive sequence data for login: %s", err) 90 | } 91 | 92 | sessionID, err := s.opts.Store.GetSession(ctx, s.idUint()) 93 | if err != nil { 94 | s.log(LogLevelWarn, "Unable to retrieve session ID for login: %s", err) 95 | } 96 | 97 | s.log(LogLevelDebug, "session \"%s\", seq %d", sessionID, seq) 98 | errs := make(chan error) 99 | 100 | go func() { 101 | if sessionID == "" && seq == 0 { 102 | if err = s.sendIdentify(); err != nil { 103 | errs <- err 104 | } 105 | } else { 106 | if err = s.sendResume(ctx); err != nil { 107 | errs <- err 108 | } 109 | } 110 | }() 111 | 112 | // mark shard as alive 113 | stats.ShardsAlive.WithLabelValues(s.id).Inc() 114 | defer stats.ShardsAlive.WithLabelValues(s.id).Dec() 115 | 116 | s.log(LogLevelDebug, "beginning normal message consumption") 117 | 118 | go func() { 119 | for { 120 | err = s.readPacket(ctx, nil) 121 | if err != nil { 122 | errs <- err 123 | break 124 | } 125 | } 126 | }() 127 | 128 | return <-errs 129 | } 130 | 131 | // CloseWithReason closes the connection and logs the reason 132 | func (s *Shard) CloseWithReason(code int, reason error) error { 133 | s.log(LogLevelWarn, "%s: closing connection", reason) 134 | return s.conn.CloseWithCode(code) 135 | } 136 | 137 | // Close closes the current session 138 | func (s *Shard) Close() (err error) { 139 | if err = s.conn.Close(); err != nil { 140 | return 141 | } 142 | 143 | s.log(LogLevelInfo, "Cleanly closed connection") 144 | return 145 | } 146 | 147 | func (s *Shard) readPacket(ctx context.Context, fn func(*types.ReceivePacket) error) (err error) { 148 | d, err := s.conn.Read() 149 | if err != nil { 150 | return 151 | } 152 | 153 | p := s.packets.Get().(*types.ReceivePacket) 154 | defer s.packets.Put(p) 155 | 156 | err = json.Unmarshal(d, p) 157 | if err != nil { 158 | return 159 | } 160 | 161 | // remove event from any previous OP 0s that used this packet 162 | if p.Op != types.GatewayOpDispatch { 163 | p.Event = "" 164 | } 165 | 166 | s.log(LogLevelDebug, "<- op:%d t:\"%s\"", p.Op, p.Event) 167 | 168 | // record packet received 169 | stats.PacketsReceived.WithLabelValues(string(p.Event), strconv.Itoa(int(p.Op)), s.id).Inc() 170 | 171 | if s.opts.OnPacket != nil { 172 | s.opts.OnPacket(p) 173 | } 174 | 175 | err = s.handlePacket(ctx, p) 176 | if err != nil { 177 | return 178 | } 179 | 180 | if fn != nil { 181 | err = fn(p) 182 | } 183 | return 184 | } 185 | 186 | // expectPacket reads the next packet, verifies its operation code, and event name (if applicable) 187 | func (s *Shard) expectPacket(ctx context.Context, op types.GatewayOp, event types.GatewayEvent, handler func(*types.ReceivePacket) error) (err error) { 188 | err = s.readPacket(ctx, func(pk *types.ReceivePacket) error { 189 | if pk.Op != op { 190 | return fmt.Errorf("expected op to be %d, got %d", op, pk.Op) 191 | } 192 | 193 | if op == types.GatewayOpDispatch && pk.Event != event { 194 | return fmt.Errorf("expected event to be %s, got %s", event, pk.Event) 195 | } 196 | 197 | if handler != nil { 198 | return handler(pk) 199 | } 200 | 201 | return nil 202 | }) 203 | 204 | return 205 | } 206 | 207 | // handlePacket handles a packet according to its operation code 208 | func (s *Shard) handlePacket(ctx context.Context, p *types.ReceivePacket) (err error) { 209 | switch p.Op { 210 | case types.GatewayOpDispatch: 211 | return s.handleDispatch(ctx, p) 212 | 213 | case types.GatewayOpHeartbeat: 214 | return s.sendHeartbeat(ctx) 215 | 216 | case types.GatewayOpReconnect: 217 | if err = s.CloseWithReason(types.CloseUnknownError, ErrReconnectReceived); err != nil { 218 | return 219 | } 220 | 221 | case types.GatewayOpInvalidSession: 222 | resumable := new(bool) 223 | if err = json.Unmarshal(p.Data, resumable); err != nil { 224 | return 225 | } 226 | 227 | if *resumable { 228 | if err = s.sendResume(ctx); err != nil { 229 | return 230 | } 231 | 232 | s.log(LogLevelDebug, "Sent resume in response to invalid resumable session") 233 | return 234 | } 235 | 236 | time.Sleep(time.Second * time.Duration(rand.Intn(5)+1)) 237 | if err = s.sendIdentify(); err != nil { 238 | return 239 | } 240 | 241 | s.log(LogLevelDebug, "Sent identify in response to invalid non-resumable session") 242 | 243 | case types.GatewayOpHeartbeatACK: 244 | if s.lastHeartbeat.Unix() != 0 { 245 | // record latest gateway ping 246 | s.Ping = time.Since(s.lastHeartbeat) 247 | stats.Ping.WithLabelValues(s.id).Observe(float64(s.Ping.Nanoseconds()) / 1e6) 248 | } 249 | 250 | s.log(LogLevelDebug, "Heartbeat ACK (RTT %s)", s.Ping) 251 | s.acks <- struct{}{} 252 | } 253 | 254 | return 255 | } 256 | 257 | // handleDispatch handles dispatch packets 258 | func (s *Shard) handleDispatch(ctx context.Context, p *types.ReceivePacket) (err error) { 259 | if err = s.opts.Store.SetSeq(ctx, s.idUint(), uint(p.Seq)); err != nil { 260 | return 261 | } 262 | 263 | switch p.Event { 264 | case types.GatewayEventReady: 265 | r := new(types.Ready) 266 | if err = json.Unmarshal(p.Data, r); err != nil { 267 | return 268 | } 269 | 270 | s.resumeURL = r.ResumeGatewayURL 271 | 272 | if err = s.opts.Store.SetSession(ctx, s.idUint(), r.SessionID); err != nil { 273 | return 274 | } 275 | 276 | s.log(LogLevelDebug, "Session ID: %s", r.SessionID) 277 | s.log(LogLevelDebug, "Using version %d", r.Version) 278 | s.logTrace(r.Trace) 279 | 280 | case types.GatewayEventResumed: 281 | r := new(types.Resumed) 282 | if err = json.Unmarshal(p.Data, r); err != nil { 283 | return 284 | } 285 | 286 | s.logTrace(r.Trace) 287 | } 288 | 289 | return 290 | } 291 | 292 | func (s *Shard) handleHello(ctx context.Context) func(*types.ReceivePacket) error { 293 | return func(p *types.ReceivePacket) (err error) { 294 | h := new(types.Hello) 295 | if err = json.Unmarshal(p.Data, h); err != nil { 296 | return 297 | } 298 | 299 | s.logTrace(h.Trace) 300 | go s.startHeartbeater(ctx, time.Duration(h.HeartbeatInterval)*time.Millisecond) 301 | return 302 | } 303 | } 304 | 305 | // handleClose handles the WebSocket close event. Returns whether the session is recoverable. 306 | func (s *Shard) handleClose(err error) (recoverable bool) { 307 | recoverable = !websocket.IsCloseError( 308 | err, 309 | types.CloseAuthenticationFailed, 310 | types.CloseInvalidShard, 311 | types.CloseShardingRequired, 312 | types.CloseInvalidAPIVersion, 313 | types.CloseInvalidIntents, 314 | types.CloseDisallowedIntents, 315 | ) 316 | 317 | if recoverable { 318 | s.log(LogLevelInfo, "recoverable close: %s", err) 319 | } else { 320 | s.log(LogLevelInfo, "unrecoverable close: %s", err) 321 | } 322 | return 323 | } 324 | 325 | // SendPacket sends a packet 326 | func (s *Shard) SendPacket(op types.GatewayOp, data interface{}) error { 327 | return s.Send(&types.SendPacket{ 328 | Op: op, 329 | Data: data, 330 | }) 331 | } 332 | 333 | // Send sends a pre-prepared packet 334 | func (s *Shard) Send(p *types.SendPacket) error { 335 | d, err := json.Marshal(p) 336 | if err != nil { 337 | return err 338 | } 339 | 340 | s.limiter.Lock() 341 | s.connMu.Lock() 342 | defer s.connMu.Unlock() 343 | 344 | // record packet sent 345 | defer stats.PacketsSent.WithLabelValues("", strconv.Itoa(int(p.Op)), s.id).Inc() 346 | 347 | s.log(LogLevelDebug, "-> op:%d d:%+v", p.Op, p.Data) 348 | _, err = s.conn.Write(d) 349 | return err 350 | } 351 | 352 | // sendIdentify sends an identify packet 353 | func (s *Shard) sendIdentify() error { 354 | s.opts.IdentifyLimiter.Lock() 355 | return s.SendPacket(types.GatewayOpIdentify, s.opts.Identify) 356 | } 357 | 358 | // sendResume sends a resume packet 359 | func (s *Shard) sendResume(ctx context.Context) error { 360 | sessionID, err := s.opts.Store.GetSession(ctx, s.idUint()) 361 | if err != nil { 362 | return err 363 | } 364 | 365 | seq, err := s.opts.Store.GetSeq(ctx, s.idUint()) 366 | if err != nil { 367 | return err 368 | } 369 | 370 | s.log(LogLevelDebug, "attempting to resume session") 371 | return s.SendPacket(types.GatewayOpResume, &types.Resume{ 372 | Token: s.opts.Identify.Token, 373 | SessionID: sessionID, 374 | Seq: types.Seq(seq), 375 | }) 376 | } 377 | 378 | // sendHeartbeat sends a heartbeat packet 379 | func (s *Shard) sendHeartbeat(ctx context.Context) error { 380 | seq, err := s.opts.Store.GetSeq(ctx, s.idUint()) 381 | if err != nil { 382 | return err 383 | } 384 | 385 | s.lastHeartbeat = time.Now() 386 | return s.SendPacket(types.GatewayOpHeartbeat, seq) 387 | } 388 | 389 | // startHeartbeater calls sendHeartbeat on the provided interval 390 | func (s *Shard) startHeartbeater(ctx context.Context, interval time.Duration) { 391 | t := time.NewTicker(interval) 392 | defer t.Stop() 393 | 394 | acked := true 395 | s.log(LogLevelInfo, "starting heartbeat at interval %s", interval) 396 | defer s.log(LogLevelDebug, "stopping heartbeat timer") 397 | 398 | for { 399 | select { 400 | case <-s.acks: 401 | acked = true 402 | case <-t.C: 403 | if !acked { 404 | s.CloseWithReason(types.CloseSessionTimeout, ErrHeartbeatUnacknowledged) 405 | return 406 | } 407 | 408 | s.log(LogLevelDebug, "sending automatic heartbeat") 409 | if err := s.sendHeartbeat(ctx); err != nil { 410 | s.log(LogLevelError, "error sending automatic heartbeat: %s", err) 411 | return 412 | } 413 | acked = false 414 | 415 | case <-ctx.Done(): 416 | return 417 | } 418 | } 419 | } 420 | 421 | // gatewayURL returns the Gateway URL with appropriate query parameters 422 | func (s *Shard) gatewayURL() string { 423 | query := url.Values{ 424 | "v": {strconv.FormatUint(uint64(s.opts.Version), 10)}, 425 | "encoding": {"json"}, 426 | "compress": {"zstd-stream"}, 427 | } 428 | 429 | if s.resumeURL != "" { 430 | return s.resumeURL + "/?" + query.Encode() 431 | } else { 432 | return s.Gateway.URL + "/?" + query.Encode() 433 | } 434 | } 435 | 436 | func (s *Shard) idUint() uint { 437 | return uint(s.opts.Identify.Shard[0]) 438 | } 439 | -------------------------------------------------------------------------------- /gateway/shard_options.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "runtime" 7 | "time" 8 | 9 | "github.com/spec-tacles/go/types" 10 | ) 11 | 12 | // Retryer calculates the wait time between retries 13 | type Retryer interface { 14 | FirstTimeout() time.Duration 15 | NextTimeout(time.Duration, int) (time.Duration, error) 16 | } 17 | 18 | // ShardOptions represents NewShard's options 19 | type ShardOptions struct { 20 | Identify *types.Identify 21 | Version uint 22 | Retryer Retryer 23 | Store ShardStore 24 | 25 | OnPacket func(*types.ReceivePacket) 26 | 27 | Logger *log.Logger 28 | LogLevel int 29 | 30 | IdentifyLimiter Limiter 31 | } 32 | 33 | func (opts *ShardOptions) init() { 34 | if opts.Version == 0 { 35 | opts.Version = DefaultVersion 36 | } 37 | 38 | if opts.Logger == nil { 39 | opts.Logger = DefaultLogger 40 | } 41 | opts.Logger = ChildLogger(opts.Logger, fmt.Sprintf("[shard %d]", opts.Identify.Shard[0])) 42 | 43 | if opts.Retryer == nil { 44 | opts.Retryer = defaultRetryer{} 45 | } 46 | 47 | if opts.IdentifyLimiter == nil { 48 | opts.IdentifyLimiter = NewDefaultLimiter(1, 5*time.Second) 49 | } 50 | 51 | if opts.Identify != nil { 52 | if opts.Identify.Properties == nil { 53 | opts.Identify.Properties = &types.IdentifyProperties{ 54 | OS: runtime.GOOS, 55 | Browser: "spectacles", 56 | Device: "spectacles", 57 | } 58 | } 59 | } 60 | 61 | if opts.Store == nil { 62 | opts.Store = NewLocalShardStore() 63 | } 64 | } 65 | 66 | // clone only clones whatever's necessary 67 | func (opts ShardOptions) clone() *ShardOptions { 68 | i := *opts.Identify 69 | opts.Identify = &i 70 | return &opts 71 | } 72 | 73 | type defaultRetryer struct{} 74 | 75 | const maxRetries = 5 76 | const maxRetry = time.Minute * 5 77 | 78 | func (defaultRetryer) FirstTimeout() time.Duration { return time.Second } 79 | func (defaultRetryer) NextTimeout(timeout time.Duration, retries int) (time.Duration, error) { 80 | if retries > maxRetries { 81 | return 0, ErrMaxRetriesExceeded 82 | } 83 | 84 | timeout *= 2 85 | 86 | if timeout > maxRetry { 87 | timeout = maxRetry 88 | } 89 | 90 | return timeout, nil 91 | } 92 | -------------------------------------------------------------------------------- /gateway/shard_store.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import ( 4 | "context" 5 | "strconv" 6 | "sync" 7 | 8 | "github.com/mediocregopher/radix/v4" 9 | "github.com/spec-tacles/go/broker/redis" 10 | ) 11 | 12 | // ShardStore represents a generic structure that can store information about a shard 13 | type ShardStore interface { 14 | GetSeq(ctx context.Context, shardID uint) (seq uint, err error) 15 | SetSeq(ctx context.Context, shardID uint, seq uint) error 16 | GetSession(ctx context.Context, shardID uint) (session string, err error) 17 | SetSession(ctx context.Context, shardID uint, session string) error 18 | } 19 | 20 | // LocalShardStore stores shard information in memory 21 | type LocalShardStore struct { 22 | seqMux *sync.RWMutex 23 | sessionMux *sync.RWMutex 24 | 25 | seqs map[uint]uint 26 | sessions map[uint]string 27 | } 28 | 29 | // NewLocalShardStore initializes a local shard store with the necessary state 30 | func NewLocalShardStore() *LocalShardStore { 31 | return &LocalShardStore{ 32 | seqMux: &sync.RWMutex{}, 33 | sessionMux: &sync.RWMutex{}, 34 | seqs: make(map[uint]uint), 35 | sessions: make(map[uint]string), 36 | } 37 | } 38 | 39 | // GetSeq gets the current sequence of the given shard 40 | func (s *LocalShardStore) GetSeq(ctx context.Context, shardID uint) (seq uint, err error) { 41 | s.seqMux.RLock() 42 | defer s.seqMux.RUnlock() 43 | 44 | seq = s.seqs[shardID] 45 | return 46 | } 47 | 48 | // SetSeq sets the current sequence of the given shard, ignoring values that are less than the current value 49 | func (s *LocalShardStore) SetSeq(ctx context.Context, shardID uint, seq uint) error { 50 | s.seqMux.Lock() 51 | defer s.seqMux.Unlock() 52 | 53 | if seq > s.seqs[shardID] { 54 | s.seqs[shardID] = seq 55 | } 56 | return nil 57 | } 58 | 59 | // GetSession gets the session identifier for the given shard 60 | func (s *LocalShardStore) GetSession(ctx context.Context, shardID uint) (session string, err error) { 61 | s.sessionMux.RLock() 62 | defer s.sessionMux.RUnlock() 63 | 64 | session = s.sessions[shardID] 65 | return 66 | } 67 | 68 | // SetSession sets the session identifier for the given shard 69 | func (s *LocalShardStore) SetSession(ctx context.Context, shardID uint, session string) error { 70 | s.sessionMux.Lock() 71 | defer s.sessionMux.Unlock() 72 | 73 | s.sessions[shardID] = session 74 | return nil 75 | } 76 | 77 | var setMax = radix.NewEvalScript(` 78 | local current = tonumber(redis.call("GET", KEYS[1])) 79 | if current == nil then current = 0 end 80 | if tonumber(ARGV[1]) > current then return redis.call("SET", KEYS[1], ARGV[1]) end 81 | return nil 82 | `) 83 | 84 | // RedisShardStore stores information about shards in Redis 85 | type RedisShardStore struct { 86 | Redis redis.RedisActor 87 | Prefix string 88 | } 89 | 90 | // GetSeq gets the current sequence of the given shard 91 | func (s *RedisShardStore) GetSeq(ctx context.Context, shardID uint) (seq uint, err error) { 92 | err = s.Redis.Do(ctx, radix.Cmd(&seq, "GET", s.shardKey(shardID)+"seq")) 93 | return 94 | } 95 | 96 | // SetSeq sets the current sequence of the given shard, ignoring values that are less than the current value 97 | func (s *RedisShardStore) SetSeq(ctx context.Context, shardID uint, seq uint) error { 98 | return s.Redis.Do(ctx, setMax.Cmd(nil, []string{s.shardKey(shardID) + "seq"}, strconv.FormatUint(uint64(seq), 10))) 99 | } 100 | 101 | // GetSession gets the session identifier for the given shard 102 | func (s *RedisShardStore) GetSession(ctx context.Context, shardID uint) (session string, err error) { 103 | err = s.Redis.Do(ctx, radix.Cmd(&session, "GET", s.shardKey(shardID)+"session")) 104 | return 105 | } 106 | 107 | // SetSession sets the session identifier for the given shard 108 | func (s *RedisShardStore) SetSession(ctx context.Context, shardID uint, session string) error { 109 | return s.Redis.Do(ctx, radix.Cmd(nil, "SET", s.shardKey(shardID)+"session", session)) 110 | } 111 | 112 | func (s *RedisShardStore) shardKey(shardID uint) string { 113 | return s.Prefix + strconv.FormatUint(uint64(shardID), 10) 114 | } 115 | -------------------------------------------------------------------------------- /gateway/types.go: -------------------------------------------------------------------------------- 1 | package gateway 2 | 3 | import "github.com/spec-tacles/go/types" 4 | 5 | // UnknownSendPacket represents a packet to be sent with guild context for determining shard ID 6 | type UnknownSendPacket struct { 7 | GuildID uint64 `json:"guild_id,string"` 8 | Packet *types.SendPacket `json:"packet"` 9 | } 10 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spec-tacles/gateway 2 | 3 | go 1.21 4 | toolchain go1.24.1 5 | 6 | require ( 7 | github.com/BurntSushi/toml v1.3.2 8 | github.com/gorilla/websocket v1.5.1 9 | github.com/mediocregopher/radix/v4 v4.1.4 10 | github.com/prometheus/client_golang v1.19.1 11 | github.com/spec-tacles/go v0.0.0-20240519052238-4bb677db055a 12 | github.com/valyala/gozstd v1.21.1 13 | ) 14 | 15 | require golang.org/x/net v0.38.0 // indirect 16 | 17 | require ( 18 | github.com/beorn7/perks v1.0.1 // indirect 19 | github.com/bwmarrin/snowflake v0.3.0 // indirect 20 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 21 | github.com/google/uuid v1.6.0 // indirect 22 | github.com/prometheus/client_model v0.6.1 // indirect 23 | github.com/prometheus/common v0.53.0 // indirect 24 | github.com/prometheus/procfs v0.15.0 // indirect 25 | github.com/rabbitmq/amqp091-go v1.10.0 26 | github.com/tilinna/clock v1.1.0 // indirect 27 | github.com/ugorji/go/codec v1.2.12 // indirect 28 | golang.org/x/sys v0.31.0 // indirect 29 | google.golang.org/protobuf v1.34.1 // indirect 30 | ) 31 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8= 2 | github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/bwmarrin/snowflake v0.0.0-20180412010544-68117e6bbede/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= 6 | github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= 7 | github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= 8 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 9 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 10 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 12 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 14 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 15 | github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 16 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 17 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 18 | github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= 19 | github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= 20 | github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= 21 | github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= 22 | github.com/mediocregopher/radix/v4 v4.0.0/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= 23 | github.com/mediocregopher/radix/v4 v4.1.4 h1:Uze6DEbEAvL+VHXUEu/EDBTkUk5CLct5h3nVSGpc6Ts= 24 | github.com/mediocregopher/radix/v4 v4.1.4/go.mod h1:ajchozX/6ELmydxWeWM6xCFHVpZ4+67LXHOTOVR0nCE= 25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 28 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 29 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 30 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 31 | github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= 32 | github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= 33 | github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek= 34 | github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= 35 | github.com/rabbitmq/amqp091-go v1.2.0/go.mod h1:ogQDLSOACsLPsIq0NpbtiifNZi2YOz0VTJ0kHRghqbM= 36 | github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= 37 | github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= 38 | github.com/spec-tacles/go v0.0.0-20240519052238-4bb677db055a h1:p0LPFoPq/y3Po0AvYH58B1GS30nyZ1+XABFiiUXYGxs= 39 | github.com/spec-tacles/go v0.0.0-20240519052238-4bb677db055a/go.mod h1:07LxuMgbytI1qF6fktMWLB7K2Q9SdjKDyKzT81Dn2fk= 40 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 41 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 42 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 43 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 44 | github.com/tilinna/clock v1.0.2/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= 45 | github.com/tilinna/clock v1.1.0 h1:6IQQQCo6KoBxVudv6gwtY8o4eDfhHo8ojA5dP0MfhSs= 46 | github.com/tilinna/clock v1.1.0/go.mod h1:ZsP7BcY7sEEz7ktc0IVy8Us6boDrK8VradlKRUGfOao= 47 | github.com/ugorji/go v1.2.6/go.mod h1:anCg0y61KIhDlPZmnH+so+RQbysYVyDko0IMgJv0Nn0= 48 | github.com/ugorji/go/codec v1.2.6/go.mod h1:V6TCNZ4PHqoHGFZuSG1W8nrCzzdgA2DozYxWFFpvxTw= 49 | github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= 50 | github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= 51 | github.com/valyala/gozstd v1.21.1 h1:TQFZVTk5zo7iJcX3o4XYBJujPdO31LFb4fVImwK873A= 52 | github.com/valyala/gozstd v1.21.1/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ= 53 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 54 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 55 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 56 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 57 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 58 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 59 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 60 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 61 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 62 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= 63 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 64 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/spec-tacles/gateway/cmd" 4 | 5 | func main() { 6 | cmd.Run() 7 | } 8 | -------------------------------------------------------------------------------- /stats/metrics.go: -------------------------------------------------------------------------------- 1 | package stats 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | var ( 8 | // PacketsReceived is a counter of packets received 9 | PacketsReceived = prometheus.NewCounterVec(prometheus.CounterOpts{ 10 | Namespace: "gateway", 11 | Name: "packets_received", 12 | Help: "Counter of packets received over all gateway connections.", 13 | }, []string{"t", "op", "shard"}) 14 | 15 | // PacketsSent is a counter of packets sent 16 | PacketsSent = prometheus.NewCounterVec(prometheus.CounterOpts{ 17 | Namespace: "gateway", 18 | Name: "packets_sent", 19 | Help: "Counter of packets sent over all gateway connections.", 20 | }, []string{"t", "op", "shard"}) 21 | 22 | // ShardsAlive is a gauge of the number of shards alive 23 | ShardsAlive = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 24 | Namespace: "gateway", 25 | Name: "shards_alive", 26 | Help: "Number of shards that are online", 27 | }, []string{"id"}) 28 | 29 | // TotalShards is a gauge of the total number of shards 30 | TotalShards = prometheus.NewGauge(prometheus.GaugeOpts{ 31 | Namespace: "gateway", 32 | Name: "total_shards", 33 | Help: "Total number of shards that should be online.", 34 | }) 35 | 36 | // Ping is a summary of shard heartbeat latency 37 | Ping = prometheus.NewSummaryVec(prometheus.SummaryOpts{ 38 | Namespace: "gateway", 39 | Name: "ping", 40 | Help: "Latency between heartbeat and acknowledgement (in milliseconds).", 41 | Objectives: map[float64]float64{ 42 | 0.5: 0.05, 43 | 0.9: 0.01, 44 | 0.95: 0.005, 45 | 0.99: 0.001, 46 | }, 47 | }, []string{"id"}) 48 | ) 49 | 50 | func init() { 51 | prometheus.MustRegister(PacketsReceived, PacketsSent, ShardsAlive, TotalShards, Ping) 52 | } 53 | --------------------------------------------------------------------------------