Help us shape the future of Go! Take the 2026 Go Developer Survey

thediscordlist

package module
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 18, 2026 License: MIT Imports: 14 Imported by: 0

README

github.com/TheDiscordList/integration-discordgo

Go Reference

Publish your discordgo bot's server count, shard heartbeats and application commands to TheDiscordList automatically, and verify its inbound webhooks.

No dependencies beyond discordgo itself. The webhooks subpackage is standard library only.

go get github.com/TheDiscordList/integration-discordgo

Quickstart

package main

import (
	"os"
	"os/signal"

	"github.com/bwmarrin/discordgo"
	thediscordlist "github.com/TheDiscordList/integration-discordgo"
)

func main() {
	session, _ := discordgo.New("Bot " + os.Getenv("DISCORD_TOKEN"))

	poster := thediscordlist.Autopost(session, os.Getenv("TDL_TOKEN"))

	session.Open()
	defer session.Close()
	defer poster.Stop()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt)
	<-stop
}

That is the whole integration for a single-process bot, and it is also the recommended production setup. It starts three publishers:

Publisher What it sends Cadence
Stats server_count, shard_count 5s after READY, then every 5 min, debounced (20s) on guild join/leave
Heartbeat per-shard status, latency and guild counts every 30–35s, plus once when a shard connects, resumes or drops (at least 10s after the previous beat)
Commands your registered global slash commands once, ~10s after READY

Your stats token is on the listing's manage page, under the API tab. It is scoped to one listing, so it is the only credential this package needs. Surrounding whitespace and a pasted Bearer prefix are stripped.

Autopost is safe to call before or after session.Open(). The bot id is read from READY, and the server count from session.State. With an empty token it logs a warning and returns an inert *Poster — a forgotten environment variable never takes down a deploy.

A full example, webhooks included, is in examples/bot.

Sharding

A discordgo Session is one shard (ShardID of ShardCount). How many sessions a process holds decides what it can publish on its own:

Topology server_count comes from Stats posted by Heartbeats
One session, ShardCount 1 session.State.Guilds this process this process
Every shard as a session in one process the sum over all sessions this process this process, one entry per shard
Shards split across processes your WithServerCount the process with cluster 0 every process, for itself, with WithCluster

All shards in one process — pass every session, so the package sees the whole bot:

sessions := make([]*discordgo.Session, shardCount)
for i := range sessions {
	sessions[i], _ = discordgo.New("Bot " + token)
	sessions[i].ShardID, sessions[i].ShardCount = i, shardCount
}

poster := thediscordlist.AutopostShards(sessions, os.Getenv("TDL_TOKEN"))

for _, s := range sessions {
	s.Open()
}

Calling Autopost once per session instead would make each poster see one shard of many and switch stats and heartbeats off (see below).

Shards split across processes — discordgo has no cross-process channel to sum guild counts over, so each process names itself and one of them supplies the bot-wide total:

poster := thediscordlist.AutopostShards(mySessions, os.Getenv("TDL_TOKEN"),
	thediscordlist.WithCluster(processIndex, processCount),
	thediscordlist.WithServerCount(func(ctx context.Context) (int, error) {
		return redis.SumGuildCounts(ctx) // however your processes share state
	}),
)

Only cluster 0 posts stats and commands. Every process heartbeats for itself on purpose: a manager reporting "all fine" for a process whose host has died is exactly the failure the status page exists to catch.

If the cluster count cannot be determined, heartbeats do not start. The API hard-deletes every cluster row whose id is >= cluster_count in the request, so one process guessing 1 would erase all of its siblings and their monitoring history. The heartbeat publisher reports itself disabled with reason cluster-count-unknown and logs that WithCluster is the fix. Losing a status page beats losing data.

Likewise, a process holding some of the shards only sees some of the guilds. Without WithServerCount, stats are disabled with reason server-count-unknown rather than publishing a wrong number.

Options

thediscordlist.Autopost(session, token,
	thediscordlist.WithBotID("161660517914509312"), // default: the application id from READY
	thediscordlist.WithBaseURL("https://thediscordlist.com/api"),

	thediscordlist.WithStatsInterval(5*time.Minute),
	thediscordlist.WithHeartbeatInterval(30*time.Second), // 15–60s, retuned from the API's response
	thediscordlist.WithPostEmptyCommands(),               // an empty list CLEARS the listing

	thediscordlist.WithoutStats(),
	thediscordlist.WithoutHeartbeat(),
	thediscordlist.WithoutCommands(),

	thediscordlist.WithCluster(0, 1),
	thediscordlist.WithServerCount(func(ctx context.Context) (int, error) { return 1234, nil }),
	thediscordlist.WithLogger(slog.Default()),
)

WithBotID also lets the heartbeat start before the gateway connects, so a booting bot reads connecting on its status page rather than down.

There is a hard floor of 60 seconds between two stats POSTs. A join storm, a mass reconnect and a manual PostStats all route through it, so the ceiling is structurally one request per minute. An unchanged count is not re-sent for 30 minutes.

WithPostEmptyCommands is off by default because posting an empty array clears the command list on your listing. Turn it on only if that is what you mean.

Events and manual posting

poster := thediscordlist.Autopost(session, token,
	thediscordlist.OnPosted(func(kind thediscordlist.Kind, status int) {}),
	thediscordlist.OnError(func(err *thediscordlist.Error) {}), // err.Kind, err.Status, err.Retryable
	thediscordlist.OnDisabled(func(kind thediscordlist.Kind, status int, reason string) {}),
)

res := poster.PostStats(ctx) // Result{OK, Status, Skipped}
poster.PostCommands(ctx)     // after re-registering commands in-process
poster.PostHeartbeat(ctx)

poster.Stop()

Nothing in this package panics into your process. Timer goroutines recover, PostX never returns an error, and without OnError or OnDisabled those events are logged as warnings instead. A false res.OK with a res.Skipped reason ("unchanged", "min-gap", "not-elected", "plan", "empty", …) means nothing was sent, which is usually correct rather than a failure.

An explicit PostX also re-arms a publisher that disabled itself, so you can retry after fixing a token or a listing id without restarting.

Webhooks

import "github.com/TheDiscordList/integration-discordgo/webhooks"

The subpackage imports only the standard library, so a webhook-only service never compiles discordgo.

net/http
http.Handle("/webhooks/tdl", webhooks.NewHandler(os.Getenv("TDL_WEBHOOK_SECRET"),
	func(ctx context.Context, event *webhooks.Event, d webhooks.Delivery) error {
		switch event.Event {
		case webhooks.EventListingUpvoted:
			var vote webhooks.Upvoted
			if err := json.Unmarshal(event.Data, &vote); err != nil {
				return nil
			}
			// …
		}
		return nil // unknown events included
	}))

*webhooks.Handler is a plain http.Handler, so it mounts unchanged on chi, gorilla/mux and http.ServeMux, and through gin.WrapH or echo.WrapHandler. It answers 204 when your function returns nil, 500 when it returns an error or panics (so the delivery is retried), 202 when it overruns the 8-second budget (the work carries on, ctx is not cancelled), 401 for a missing or invalid signature, 400 for any other verification failure, and 413 for a body over 1 MiB. An empty secret never verifies: every delivery answers 500 until it is set. Set Tolerance, Timeout and OnError on the returned *Handler to tune it.

Verifying by hand
body, _ := io.ReadAll(r.Body)
event, err := webhooks.Verify(body, r.Header.Get(webhooks.SignatureHeader), secret)
var verr *webhooks.Error
if errors.As(err, &verr) {
	// verr.Code: MISSING_SECRET, MISSING_SIGNATURE, MALFORMED_SIGNATURE,
	// STALE_TIMESTAMP, INVALID_SIGNATURE or MALFORMED_BODY
}

body must be the exact bytes that arrived. Re-encoding a decoded struct with json.Marshal changes key order, escaping and spacing, and can never verify: the signature covers bytes, not meaning. VerifyAt takes an explicit tolerance and clock.

Events
event.Event Fires for Decode event.Data into
listing.bumped servers, bots webhooks.Bumped
listing.bump_ready servers, bots webhooks.BumpReady
listing.upvoted servers, bots webhooks.Upvoted
server.joined servers only webhooks.ServerJoined
bot.added bots webhooks.BotAdded
bot.stats_reported bots webhooks.StatsReported
webhook.ping both webhooks.Ping

Two of them are not what their names suggest. bot.added is a click event, dispatched on the install hand-off — Discord never tells anyone whether the install completed. And bot.stats_reported is not an echo of your own POST: it also fires when the owner edits the listing in the web UI, and from the heartbeat's internal stats forward. event.CreatedAt is enqueue time, not send time; never use it for freshness.

Rules for a healthy endpoint
  • Always answer 2xx, including for webhook.ping and for event names you do not recognise. The sender disables an endpoint after 10 consecutive failed deliveries. Return nil for anything you do not handle.
  • A disabled endpoint is probed with a webhook.ping every 6 hours for 7 days, and a 2xx to that probe re-enables it.
  • Register an exact https:// URL with no redirect. The sender treats 3xx as a failure, so an http → https or trailing-slash redirect burns all five retries and counts toward the disable streak.
  • Deduplicate on Delivery.ID. It equals X-TDL-Delivery and is stable across all five retries of one delivery. There is no in-process cache here on purpose — it would be wrong as soon as you run two replicas. A manual "redeliver" from the UI mints a new id deliberately, so it is not dropped.
  • Rotating the secret has no grace window. It is a destructive overwrite, and deliveries already queued under the old secret become unverifiable.

Plans, limits and known behaviour

  • Heartbeats need Gold or higher. On a 403 the heartbeat publisher suspends and reprobes every 30 minutes rather than disabling: the plan is checked per request, so a bot can gain it mid-run.
  • Rate limits are shared. The API counts stats and commands against one budget per bot (60/min; heartbeats have their own 120/min). On a 429 this package honours Retry-After and X-RateLimit-Reset (default 60s, at most an hour) with one client-wide suspension for stats and commands; heartbeats bypass it — a stale number beats a false "down" on your status page.
  • Retries never outrun the next tick. A network error, 408, 425 or 5xx is retried up to 3 attempts with backoff, and a 429 once its wait has passed, unless the publisher's next tick comes first. Any other 4xx is reported once and not retried. A manual PostX waits for its retries, so pass a ctx with a deadline to bound it.
  • A 401 disables every publisher: the token is wrong for all of them.
  • 404 never means "bad token". It means the id is unknown or belongs to a server listing. The distinction is deliberate, so probing cannot enumerate.
  • Discord outages do not dent the count. discordgo drops a guild from State when it goes unavailable; this package keeps counting it until it comes back or Discord says the bot left.
  • session.StateEnabled must stay on, or the guild cache is empty. With it off, pass WithServerCount; per-shard guild counts are then omitted from heartbeats.
  • Without discordgo.IntentsGuilds, join and leave events never arrive and the count only moves on the next READY. The package warns once.
  • Shard latency comes from session.HeartbeatLatency() and is omitted until the first gateway heartbeat has been acknowledged.

Requirements

  • Go 1.25 or newer (CI runs the two latest releases)
  • discordgo v0.28.1 or newer, tested through v0.29.x

Publishing (maintainers)

Go has no package registry to upload to: a version is a semver git tag on this repository, served by the module proxy and indexed by pkg.go.dev. There are no secrets to configure; release.yml uses the built-in GITHUB_TOKEN.

One-time setup:

  1. The repository must be public at github.com/TheDiscordList/integration-discordgo, matching the module line in go.mod exactly.

Each release:

  1. Move the Unreleased entries in CHANGELOG.md under the new version and merge to main.
  2. Tag main and push the tag: git tag v0.1.0 && git push origin v0.1.0.
  3. release.yml runs vet and the tests, creates the GitHub Release with generated notes, and runs GOPROXY=proxy.golang.org go list -m github.com/TheDiscordList/integration-discordgo@v0.1.0 so pkg.go.dev picks the version up within minutes.

Never move or delete a pushed tag: the checksum database has already recorded it. To withdraw a bad version, add a retract directive to go.mod and tag the next patch release. A v2 or later needs the /v2 suffix on the module path.

License

MIT

Documentation

Overview

Package thediscordlist publishes a discordgo bot's server count, shard heartbeats and slash commands to TheDiscordList.

session, _ := discordgo.New("Bot " + os.Getenv("DISCORD_TOKEN"))
poster := thediscordlist.Autopost(session, os.Getenv("TDL_TOKEN"))
defer poster.Stop()
session.Open()

Nothing in this package panics into the host process: background loops recover, and every Post method returns a Result instead of an error. Webhook verification lives in the separate webhooks subpackage.

Index

Constants

View Source
const DefaultBaseURL = "https://thediscordlist.com/api"

DefaultBaseURL is the TheDiscordList API root.

Variables

This section is empty.

Functions

This section is empty.

Types

type Cluster

type Cluster struct {
	ID    int
	Count int
}

Cluster names this process within a multi-process bot.

type Error

type Error struct {
	Kind Kind
	// Status is the HTTP status, or 0 for a network failure.
	Status    int
	Retryable bool
	Err       error
}

Error is reported through OnError for failures that did not disable a publisher.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Kind

type Kind string

Kind names one publisher.

const (
	KindStats     Kind = "stats"
	KindHeartbeat Kind = "heartbeat"
	KindCommands  Kind = "commands"
)

type Option

type Option func(*config)

Option configures Autopost.

func OnDisabled

func OnDisabled(fn func(kind Kind, status int, reason string)) Option

OnDisabled is called once when a publisher switches itself off. Reasons: "unauthorized", "not-found", "conflict", "cluster-count-unknown", "server-count-unknown".

func OnError

func OnError(fn func(err *Error)) Option

OnError is called for failures that leave the publisher running.

func OnPosted

func OnPosted(fn func(kind Kind, status int)) Option

OnPosted is called after every successful POST.

func WithBaseURL

func WithBaseURL(url string) Option

WithBaseURL overrides the API root. Default DefaultBaseURL.

func WithBotID

func WithBotID(id string) Option

WithBotID sets the Discord application id. By default it is read from the READY event. Setting it lets heartbeats start before the gateway connects, so a booting bot reads "connecting" rather than down.

func WithCluster

func WithCluster(id, count int) Option

WithCluster names this process for a bot split across processes. Only cluster 0 posts stats and commands; every cluster heartbeats for itself.

func WithHeartbeatInterval

func WithHeartbeatInterval(d time.Duration) Option

WithHeartbeatInterval sets the starting heartbeat cadence, clamped to 15–60 seconds. It is retuned from the API's response. Default 30 seconds.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger for warnings, and for errors and disables that have no callback. Default slog.Default.

func WithPostEmptyCommands

func WithPostEmptyCommands() Option

WithPostEmptyCommands allows posting an empty command list, which CLEARS the listing's commands.

func WithServerCount

func WithServerCount(fn func(ctx context.Context) (int, error)) Option

WithServerCount supplies the bot-wide guild total, for bots whose shards live in more than one process. A returned error skips that post.

func WithStatsInterval

func WithStatsInterval(d time.Duration) Option

WithStatsInterval sets the stats cadence. Default 5 minutes.

func WithoutCommands

func WithoutCommands() Option

WithoutCommands disables the commands publisher.

func WithoutHeartbeat

func WithoutHeartbeat() Option

WithoutHeartbeat disables the heartbeat publisher.

func WithoutStats

func WithoutStats() Option

WithoutStats disables the stats publisher.

type Poster

type Poster struct {
	// contains filtered or unexported fields
}

Poster runs the publishers for one process. All methods are safe for concurrent use, and every method is a no-op on an inert Poster.

func Autopost

func Autopost(session *discordgo.Session, token string, opts ...Option) *Poster

Autopost wires one discordgo session to a TheDiscordList listing. Call it before or after session.Open.

A discordgo Session is one shard. A process that runs several sessions must pass all of them to AutopostShards instead.

With an empty token it logs a warning and returns an inert Poster, so a missing environment variable never takes down a deploy.

func AutopostShards

func AutopostShards(sessions []*discordgo.Session, token string, opts ...Option) *Poster

AutopostShards wires every session (shard) this process runs. When the sessions cover all of ShardCount, the process is the whole bot and needs no further configuration. Otherwise pass WithCluster and WithServerCount.

func (*Poster) PostCommands

func (p *Poster) PostCommands(ctx context.Context) (res Result)

PostCommands posts the registered global application commands now, re-arming a disabled commands publisher.

func (*Poster) PostHeartbeat

func (p *Poster) PostHeartbeat(ctx context.Context) (res Result)

PostHeartbeat sends a heartbeat now, re-arming a disabled heartbeat publisher.

func (*Poster) PostStats

func (p *Poster) PostStats(ctx context.Context) (res Result)

PostStats posts the server count now, re-arming a disabled stats publisher. It still honours the 60-second floor between two stats posts.

func (*Poster) Stop

func (p *Poster) Stop()

Stop cancels every timer and removes every session handler. It is not restartable; manual Post calls keep working.

type Result

type Result struct {
	OK bool
	// Status is the HTTP status, or 0 when no response arrived.
	Status int
	// Skipped is set when nothing was sent: "unchanged", "min-gap",
	// "not-elected", "not-ready", "suspended", "plan", "empty", "disabled", …
	Skipped string
}

Result describes one publish attempt.

Directories

Path Synopsis
examples
bot command
A bot that keeps its TheDiscordList listing up to date and receives its webhooks.
A bot that keeps its TheDiscordList listing up to date and receives its webhooks.
Package webhooks verifies and serves the webhooks TheDiscordList sends.
Package webhooks verifies and serves the webhooks TheDiscordList sends.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL