Compare commits

..

4 commits
main ... go

Author SHA1 Message Date
97a17097ef
fix: sqlite concurrency 2026-06-02 04:26:19 +02:00
805cf9024b
fix: xd 2026-06-02 04:22:00 +02:00
752c72a81a
chore: docker volume 2026-06-01 18:41:56 +02:00
df59f54ac4
chore: rewrite 2026-06-01 18:37:01 +02:00
76 changed files with 1631 additions and 8202 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git/
.gitignore
.devenv/
devenv.*
*.md
unbot.db
unbot.db-journal
config.toml

1
.envrc
View file

@ -1 +0,0 @@
use flake

16
.gitignore vendored
View file

@ -1,3 +1,17 @@
.direnv
config.toml
.env
dictionary.json
# Devenv
.devenv*
devenv.local.nix
devenv.local.yaml
# direnv
.direnv
# pre-commit
.pre-commit-config.yaml
*.db
*.db-journal

23
Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM golang:1.26-alpine AS builder
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /unbot .
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /unbot /usr/local/bin/unbot
ENTRYPOINT ["unbot"]
CMD ["-config", "/config/config.toml"]

54
config/config.go Normal file
View file

@ -0,0 +1,54 @@
package config
import (
"time"
"github.com/BurntSushi/toml"
)
type Config struct {
Bot BotConfig `toml:"bot"`
Features FeatureConfig `toml:"features"`
}
type BotConfig struct {
Token string `toml:"token"`
GuildID uint64 `toml:"guild_id"`
}
type FeatureConfig struct {
Dictionary DictionaryConfig `toml:"dictionary"`
XDTracker XDTrackerConfig `toml:"xd_tracker"`
IconChanger IconChangerConfig `toml:"icon_changer"`
Starboard StarboardConfig `toml:"starboard"`
}
type DictionaryConfig struct {
Enabled bool `toml:"enabled"`
}
type XDTrackerConfig struct {
Enabled bool `toml:"enabled"`
}
type IconChangerConfig struct {
Enabled bool `toml:"enabled"`
ChannelID uint64 `toml:"channel_id"`
Interval time.Duration `toml:"interval"`
}
type StarboardConfig struct {
Enabled bool `toml:"enabled"`
ChannelID uint64 `toml:"channel_id"`
WebhookURL string `toml:"webhook_url"`
Threshold int `toml:"threshold"`
}
func Load(path string) (*Config, error) {
var cfg Config
_, err := toml.DecodeFile(path, &cfg)
if err != nil {
return nil, err
}
return &cfg, nil
}

251
db/db.go Normal file
View file

@ -0,0 +1,251 @@
package db
import (
"database/sql"
"time"
_ "modernc.org/sqlite"
)
type DB struct {
*sql.DB
}
type DictEntry struct {
ID int64
Word string
Definition string
AuthorID string
CreatedAt time.Time
Upvotes int
Downvotes int
}
type XDCount struct {
UserID string
Count int
}
func Open(path string) (*DB, error) {
d, err := sql.Open("sqlite", path+"?_journal_mode=WAL&_busy_timeout=5000")
if err != nil {
return nil, err
}
d.SetMaxOpenConns(1)
db := &DB{d}
if err := db.migrate(); err != nil {
return nil, err
}
return db, nil
}
func (db *DB) migrate() error {
queries := []string{
`CREATE TABLE IF NOT EXISTS dictionary_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL UNIQUE,
definition TEXT NOT NULL,
author_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
upvotes INTEGER DEFAULT 0,
downvotes INTEGER DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS xd_counts (
user_id TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
)`,
`CREATE TABLE IF NOT EXISTS starboard_messages (
original_message_id TEXT PRIMARY KEY,
starboard_message_id TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS xd_processed_messages (
message_id TEXT PRIMARY KEY
)`,
}
for _, q := range queries {
if _, err := db.Exec(q); err != nil {
return err
}
}
return nil
}
func (db *DB) AddDefinition(word, definition, authorID string) error {
_, err := db.Exec(
`INSERT INTO dictionary_entries (word, definition, author_id) VALUES (?, ?, ?)
ON CONFLICT(word) DO UPDATE SET definition = ?, author_id = ?`,
word, definition, authorID, definition, authorID,
)
return err
}
func (db *DB) GetDefinition(word string) (*DictEntry, error) {
var e DictEntry
err := db.QueryRow(
`SELECT id, word, definition, author_id, created_at, upvotes, downvotes
FROM dictionary_entries WHERE word = ?`, word,
).Scan(&e.ID, &e.Word, &e.Definition, &e.AuthorID, &e.CreatedAt, &e.Upvotes, &e.Downvotes)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
return &e, nil
}
func (db *DB) RemoveDefinition(word string) error {
_, err := db.Exec(`DELETE FROM dictionary_entries WHERE word = ?`, word)
return err
}
func (db *DB) ListWords() ([]DictEntry, error) {
rows, err := db.Query(
`SELECT id, word, definition, author_id, created_at, upvotes, downvotes
FROM dictionary_entries ORDER BY word ASC`,
)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []DictEntry
for rows.Next() {
var e DictEntry
if err := rows.Scan(&e.ID, &e.Word, &e.Definition, &e.AuthorID, &e.CreatedAt, &e.Upvotes, &e.Downvotes); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func (db *DB) IncrementXD(userID string, count int) error {
_, err := db.Exec(
`INSERT INTO xd_counts (user_id, count) VALUES (?, ?)
ON CONFLICT(user_id) DO UPDATE SET count = count + ?`,
userID, count, count,
)
return err
}
func (db *DB) GetXDCount(userID string) (int, error) {
var count int
err := db.QueryRow(`SELECT count FROM xd_counts WHERE user_id = ?`, userID).Scan(&count)
if err == sql.ErrNoRows {
return 0, nil
}
if err != nil {
return 0, err
}
return count, nil
}
func (db *DB) GetXDLeaderboard(limit int) ([]XDCount, error) {
rows, err := db.Query(
`SELECT user_id, count FROM xd_counts ORDER BY count DESC LIMIT ?`, limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
var entries []XDCount
for rows.Next() {
var e XDCount
if err := rows.Scan(&e.UserID, &e.Count); err != nil {
return nil, err
}
entries = append(entries, e)
}
return entries, nil
}
func (db *DB) IsXDMessageProcessed(messageID string) (bool, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM xd_processed_messages WHERE message_id = ?`, messageID).Scan(&count)
return count > 0, err
}
func (db *DB) MarkXDMessageProcessed(messageID string) error {
_, err := db.Exec(`INSERT OR IGNORE INTO xd_processed_messages (message_id) VALUES (?)`, messageID)
return err
}
func (db *DB) BulkMarkXDMessageProcessed(messageIDs []string) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.Prepare(`INSERT OR IGNORE INTO xd_processed_messages (message_id) VALUES (?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, id := range messageIDs {
if _, err := stmt.Exec(id); err != nil {
return err
}
}
return tx.Commit()
}
func (db *DB) GetTotalXDCount() (int, error) {
var total int
err := db.QueryRow(`SELECT COALESCE(SUM(count), 0) FROM xd_counts`).Scan(&total)
return total, err
}
func (db *DB) GetXDUserCount() (int, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM xd_counts`).Scan(&count)
return count, err
}
func (db *DB) GetUserRank(userID string) (int, error) {
var rank int
err := db.QueryRow(`
SELECT COUNT(*) + 1 FROM xd_counts
WHERE count > (SELECT COALESCE(count, 0) FROM xd_counts WHERE user_id = ?)
`, userID).Scan(&rank)
if err != nil {
return 0, err
}
return rank, nil
}
func (db *DB) IsStarboardEntry(originalMsgID string) (bool, error) {
var count int
err := db.QueryRow(
`SELECT COUNT(*) FROM starboard_messages WHERE original_message_id = ?`, originalMsgID,
).Scan(&count)
return count > 0, err
}
func (db *DB) AddStarboardEntry(originalMsgID, starboardMsgID string) error {
_, err := db.Exec(
`INSERT INTO starboard_messages (original_message_id, starboard_message_id) VALUES (?, ?)`,
originalMsgID, starboardMsgID,
)
return err
}
func (db *DB) GetStarboardMessageID(originalMsgID string) (string, error) {
var starboardMsgID string
err := db.QueryRow(
`SELECT starboard_message_id FROM starboard_messages WHERE original_message_id = ?`, originalMsgID,
).Scan(&starboardMsgID)
if err == sql.ErrNoRows {
return "", nil
}
if err != nil {
return "", err
}
return starboardMsgID, nil
}
func (db *DB) RemoveStarboardEntry(originalMsgID string) error {
_, err := db.Exec(`DELETE FROM starboard_messages WHERE original_message_id = ?`, originalMsgID)
return err
}

65
devenv.lock Normal file
View file

@ -0,0 +1,65 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1780316214,
"narHash": "sha256-X3EG0oxt03MegwC/MnQv1saoq9nphEGSSGEAj8mZQOg=",
"owner": "cachix",
"repo": "devenv",
"rev": "d5e9138bae90fe199fbe5de7675014d76d28873b",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1778507786,
"narHash": "sha256-HzSQCKMsMr8r55LwM1JuzIOB+8bzk0FEv6sItKvsfoY=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "8f24a228a782e24576b155d1e39f0d914b380691",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1778274207,
"narHash": "sha256-I4puXmX1iovcCHZlRmztO3vW0mAbbRvq4F8wgIMQ1MM=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "b3da656039dc7a6240f27b2ef8cc6a3ef3bccae7",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

5
devenv.nix Normal file
View file

@ -0,0 +1,5 @@
{ pkgs, ... }:
{
languages.go.enable = true;
packages = with pkgs; [ opencode ];
}

18
devenv.yaml Normal file
View file

@ -0,0 +1,18 @@
# yaml-language-server: $schema=https://devenv.sh/devenv.schema.json
inputs:
nixpkgs:
url: github:cachix/devenv-nixpkgs/rolling
# If you're using non-OSS software, you can set allow_unfree to true.
# allow_unfree: true
# If you're not willing to allow unsupported packages:
# allow_unsupported_system: false
# If you're willing to use a package that's vulnerable
# permitted_insecure_packages:
# - "openssl-1.1.1w"
# If you have more than one devenv you can merge them
#imports:
# - ./backend

13
docker-compose.yml Normal file
View file

@ -0,0 +1,13 @@
services:
unbot:
build: .
container_name: unbot
restart: unless-stopped
volumes:
- ./config.toml:/config/config.toml:ro
- unbot-data:/app
environment:
- TZ=UTC
volumes:
unbot-data:

206
features/dictionary.go Normal file
View file

@ -0,0 +1,206 @@
package features
import (
"fmt"
"strings"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/snowflake/v2"
"github.com/elisiei/unbot/db"
)
type Dictionary struct {
db *db.DB
config DictionaryConfig
}
type DictionaryConfig struct {
Enabled bool
}
func NewDictionary(database *db.DB, enabled bool) *Dictionary {
return &Dictionary{db: database, config: DictionaryConfig{Enabled: enabled}}
}
func (d *Dictionary) Command() discord.SlashCommandCreate {
return discord.SlashCommandCreate{
Name: "dict",
Description: "Urban dictionary for the server",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionSubCommand{
Name: "add",
Description: "Add a definition",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "word",
Description: "The word to define",
Required: true,
},
discord.ApplicationCommandOptionString{
Name: "definition",
Description: "The definition",
Required: true,
},
},
},
discord.ApplicationCommandOptionSubCommand{
Name: "get",
Description: "Get a definition",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "word",
Description: "The word to look up",
Required: true,
Autocomplete: true,
},
},
},
discord.ApplicationCommandOptionSubCommand{
Name: "remove",
Description: "Remove your definition",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionString{
Name: "word",
Description: "The word to remove",
Required: true,
},
},
},
discord.ApplicationCommandOptionSubCommand{
Name: "list",
Description: "List all defined words",
},
},
}
}
func (d *Dictionary) HandleAutocomplete(e *events.AutocompleteInteractionCreate) {
focused := e.Data.Focused()
input := strings.ToLower(focused.String())
allWords, err := d.db.ListWords()
if err != nil {
return
}
var choices []discord.AutocompleteChoice
for _, w := range allWords {
if strings.HasPrefix(strings.ToLower(w.Word), input) {
if len(choices) >= 25 {
break
}
choices = append(choices, discord.AutocompleteChoiceString{
Name: w.Word,
Value: w.Word,
})
}
}
e.AutocompleteResult(choices)
}
func (d *Dictionary) HandleAdd(e *events.ApplicationCommandInteractionCreate) {
data := e.SlashCommandInteractionData()
word := strings.ToLower(strings.TrimSpace(data.String("word")))
definition := strings.TrimSpace(data.String("definition"))
if word == "" || definition == "" {
e.CreateMessage(discord.NewMessageCreate().WithContent("Word and definition cannot be empty.").WithEphemeral(true))
return
}
err := d.db.AddDefinition(word, definition, e.User().ID.String())
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to save definition.").WithEphemeral(true))
return
}
e.CreateMessage(discord.NewMessageCreate().WithContent(fmt.Sprintf("Added definition for **%s**", word)))
}
func (d *Dictionary) HandleGet(e *events.ApplicationCommandInteractionCreate) {
data := e.SlashCommandInteractionData()
word := strings.ToLower(strings.TrimSpace(data.String("word")))
entry, err := d.db.GetDefinition(word)
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to look up definition.").WithEphemeral(true))
return
}
if entry == nil {
e.CreateMessage(discord.NewMessageCreate().WithContent(fmt.Sprintf("No definition found for **%s**", word)).WithEphemeral(true))
return
}
var footerText string
authorID, parseErr := snowflake.Parse(entry.AuthorID)
if parseErr == nil && authorID != 0 {
footerText = "Added by <@" + authorID.String() + ">"
}
embed := discord.Embed{
Title: entry.Word,
Description: entry.Definition,
Color: 0x00AE86,
Fields: []discord.EmbedField{
{Name: "Rating", Value: fmt.Sprintf("👍 %d | 👎 %d", entry.Upvotes, entry.Downvotes), Inline: new(true)},
},
Footer: &discord.EmbedFooter{Text: footerText},
}
e.CreateMessage(discord.NewMessageCreate().AddEmbeds(embed))
}
func (d *Dictionary) HandleRemove(e *events.ApplicationCommandInteractionCreate) {
data := e.SlashCommandInteractionData()
word := strings.ToLower(strings.TrimSpace(data.String("word")))
entry, err := d.db.GetDefinition(word)
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to look up definition.").WithEphemeral(true))
return
}
if entry == nil {
e.CreateMessage(discord.NewMessageCreate().WithContent(fmt.Sprintf("No definition found for **%s**", word)).WithEphemeral(true))
return
}
if entry.AuthorID != e.User().ID.String() {
e.CreateMessage(discord.NewMessageCreate().WithContent("You can only remove your own definitions.").WithEphemeral(true))
return
}
if err := d.db.RemoveDefinition(word); err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to remove definition.").WithEphemeral(true))
return
}
e.CreateMessage(discord.NewMessageCreate().WithContent(fmt.Sprintf("Removed definition for **%s**", word)))
}
func (d *Dictionary) HandleList(e *events.ApplicationCommandInteractionCreate) {
words, err := d.db.ListWords()
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to list words.").WithEphemeral(true))
return
}
if len(words) == 0 {
e.CreateMessage(discord.NewMessageCreate().WithContent("No words defined yet.").WithEphemeral(true))
return
}
var sb strings.Builder
for i, w := range words {
if i > 0 {
sb.WriteString(", ")
}
fmt.Fprintf(&sb, "**%s**", w.Word)
if i >= 50 {
sb.WriteString("...")
break
}
}
e.CreateMessage(discord.NewMessageCreate().WithContent(fmt.Sprintf("Defined words (%d): %s", len(words), sb.String())))
}

207
features/iconchanger.go Normal file
View file

@ -0,0 +1,207 @@
package features
import (
"io"
"log/slog"
"math/rand"
"net/http"
"strings"
"sync"
"time"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/omit"
"github.com/disgoorg/snowflake/v2"
)
type IconChanger struct {
client *bot.Client
guildID snowflake.ID
channelID snowflake.ID
interval time.Duration
enabled bool
mu sync.RWMutex
imageURLs []string
stop chan struct{}
}
func NewIconChanger(client *bot.Client, guildID, channelID uint64, interval time.Duration, enabled bool) *IconChanger {
return &IconChanger{
client: client,
guildID: snowflake.ID(guildID),
channelID: snowflake.ID(channelID),
interval: interval,
enabled: enabled,
stop: make(chan struct{}),
}
}
func (ic *IconChanger) HandleMessage(e *events.MessageCreate) {
if !ic.enabled || e.ChannelID != ic.channelID || e.Message.Author.Bot {
return
}
for _, a := range e.Message.Attachments {
if a.ContentType != nil && strings.HasPrefix(*a.ContentType, "image/") {
ic.mu.Lock()
ic.imageURLs = append(ic.imageURLs, a.URL)
ic.mu.Unlock()
}
}
for _, embed := range e.Message.Embeds {
if embed.Image != nil {
ic.mu.Lock()
ic.imageURLs = append(ic.imageURLs, embed.Image.URL)
ic.mu.Unlock()
}
}
}
func (ic *IconChanger) Start() {
if !ic.enabled {
return
}
interval := ic.interval
if interval <= 0 {
interval = 1 * time.Hour
}
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
ic.changeIcon()
case <-ic.stop:
return
}
}
}()
}
func (ic *IconChanger) Stop() {
close(ic.stop)
}
func (ic *IconChanger) BackfillHistory() {
if !ic.enabled {
return
}
slog.Info("starting icon history backfill", slog.String("channel_id", ic.channelID.String()))
var lastID snowflake.ID
seen := make(map[string]struct{})
for {
messages, err := ic.client.Rest.GetMessages(ic.channelID, 0, lastID, 0, 100)
if err != nil {
slog.Error("failed to get messages for icon backfill", slog.Any("err", err))
return
}
if len(messages) == 0 {
break
}
for _, msg := range messages {
if msg.Author.Bot {
continue
}
for _, a := range msg.Attachments {
if a.ContentType != nil && strings.HasPrefix(*a.ContentType, "image/") {
if _, ok := seen[a.URL]; !ok {
seen[a.URL] = struct{}{}
ic.mu.Lock()
ic.imageURLs = append(ic.imageURLs, a.URL)
ic.mu.Unlock()
}
}
}
for _, embed := range msg.Embeds {
if embed.Image != nil {
if _, ok := seen[embed.Image.URL]; !ok {
seen[embed.Image.URL] = struct{}{}
ic.mu.Lock()
ic.imageURLs = append(ic.imageURLs, embed.Image.URL)
ic.mu.Unlock()
}
}
}
}
lastID = messages[len(messages)-1].ID
time.Sleep(200 * time.Millisecond)
}
slog.Info("icon history backfill complete", slog.Int("images_found", len(ic.imageURLs)))
}
func (ic *IconChanger) changeIcon() {
ic.mu.RLock()
urls := make([]string, len(ic.imageURLs))
copy(urls, ic.imageURLs)
ic.mu.RUnlock()
if len(urls) == 0 {
return
}
url := urls[rand.Intn(len(urls))]
icon, err := downloadIcon(url)
if err != nil {
slog.Error("failed to download icon", slog.Any("err", err))
return
}
_, err = ic.client.Rest.UpdateGuild(ic.guildID, discord.GuildUpdate{
Icon: omit.New(icon),
})
if err != nil {
slog.Error("failed to update guild icon", slog.Any("err", err))
return
}
slog.Info("guild icon changed", slog.String("url", url))
}
func downloadIcon(url string) (*discord.Icon, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
mediaType := resp.Header.Get("Content-Type")
if mediaType == "" {
mediaType = "image/png"
}
var iconType discord.IconType
switch {
case mediaType == "image/png":
iconType = discord.IconTypePNG
case mediaType == "image/jpeg":
iconType = discord.IconTypeJPEG
case mediaType == "image/webp":
iconType = discord.IconTypeWEBP
case mediaType == "image/gif":
iconType = discord.IconTypeGIF
default:
iconType = discord.IconTypePNG
}
return discord.NewIconRaw(iconType, data), nil
}

253
features/starboard.go Normal file
View file

@ -0,0 +1,253 @@
package features
import (
"bytes"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/disgo/rest"
"github.com/disgoorg/disgo/webhook"
"github.com/disgoorg/snowflake/v2"
"github.com/elisiei/unbot/db"
)
type Starboard struct {
db *db.DB
config StarboardConfig
webhook *webhook.Client
}
type StarboardConfig struct {
Enabled bool
WebhookURL string
ChannelID uint64
Threshold int
}
func NewStarboard(database *db.DB, webhookURL string, channelID uint64, threshold int, enabled bool) (*Starboard, error) {
var wh *webhook.Client
if webhookURL != "" {
var err error
wh, err = webhook.NewWithURL(webhookURL)
if err != nil {
return nil, fmt.Errorf("failed to create webhook: %w", err)
}
}
return &Starboard{
db: database,
webhook: wh,
config: StarboardConfig{
Enabled: enabled,
WebhookURL: webhookURL,
ChannelID: channelID,
Threshold: threshold,
},
}, nil
}
func (s *Starboard) HandleReactionAdd(e *events.GuildMessageReactionAdd) {
if !s.config.Enabled || s.webhook == nil {
return
}
if e.ChannelID == snowflake.ID(s.config.ChannelID) {
return
}
originalMsgID := e.MessageID.String()
msg, err := e.Client().Rest.GetMessage(e.ChannelID, e.MessageID)
if err != nil {
slog.Error("failed to get message for starboard", slog.Any("err", err))
return
}
totalReactions := 0
for _, r := range msg.Reactions {
totalReactions += r.Count
}
starboardMsgIDStr, err := s.db.GetStarboardMessageID(originalMsgID)
if err != nil {
slog.Error("failed to check starboard entry", slog.Any("err", err))
return
}
if starboardMsgIDStr != "" {
if totalReactions >= s.config.Threshold {
s.updateStarboardEntry(starboardMsgIDStr, msg, e.ChannelID, e.GuildID, e.MessageID)
} else {
s.removeStarboardEntry(starboardMsgIDStr, originalMsgID)
}
return
}
if totalReactions < s.config.Threshold {
return
}
content, embeds, files := s.buildStarboardMessage(msg, e.ChannelID, e.GuildID, e.MessageID)
msgCreate := discord.WebhookMessageCreate{
Content: content,
Embeds: embeds,
Files: files,
Username: msg.Author.EffectiveName(),
AvatarURL: msg.Author.EffectiveAvatarURL(),
}
webhookMsg, err := s.webhook.CreateMessage(msgCreate, rest.CreateWebhookMessageParams{Wait: true})
if err != nil {
slog.Error("failed to send starboard webhook", slog.Any("err", err))
return
}
if webhookMsg == nil {
slog.Error("starboard webhook returned nil message")
return
}
if err := s.db.AddStarboardEntry(originalMsgID, webhookMsg.ID.String()); err != nil {
slog.Error("failed to save starboard entry", slog.Any("err", err))
}
}
func (s *Starboard) HandleReactionRemove(e *events.GuildMessageReactionRemove) {
if !s.config.Enabled || s.webhook == nil {
return
}
if e.ChannelID == snowflake.ID(s.config.ChannelID) {
return
}
originalMsgID := e.MessageID.String()
starboardMsgIDStr, err := s.db.GetStarboardMessageID(originalMsgID)
if err != nil {
slog.Error("failed to check starboard entry", slog.Any("err", err))
return
}
if starboardMsgIDStr == "" {
return
}
msg, err := e.Client().Rest.GetMessage(e.ChannelID, e.MessageID)
if err != nil {
slog.Warn("original message gone, removing starboard entry", slog.Any("err", err))
s.removeStarboardEntry(starboardMsgIDStr, originalMsgID)
return
}
totalReactions := 0
for _, r := range msg.Reactions {
totalReactions += r.Count
}
if totalReactions >= s.config.Threshold {
s.updateStarboardEntry(starboardMsgIDStr, msg, e.ChannelID, e.GuildID, e.MessageID)
} else {
s.removeStarboardEntry(starboardMsgIDStr, originalMsgID)
}
}
func (s *Starboard) buildStarboardMessage(msg *discord.Message, channelID, guildID, messageID snowflake.ID) (content string, embeds []discord.Embed, files []*discord.File) {
jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", guildID, channelID, messageID)
content = jumpURL
embed := discord.Embed{
Author: &discord.EmbedAuthor{
Name: msg.Author.EffectiveName(),
IconURL: msg.Author.EffectiveAvatarURL(),
},
}
if msg.Content != "" {
desc := msg.Content
if len(desc) > 4096 {
desc = desc[:4093] + "..."
}
embed.Description = desc
}
for _, a := range msg.Attachments {
resp, err := http.Get(a.URL)
if err != nil {
slog.Warn("failed to download attachment for starboard", slog.Any("err", err), slog.String("url", a.URL))
continue
}
data, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
slog.Warn("failed to read attachment for starboard", slog.Any("err", err), slog.String("url", a.URL))
continue
}
files = append(files, discord.NewFile(a.Filename, "", bytes.NewReader(data)))
}
if len(msg.Reactions) > 0 {
var parts []string
for _, r := range msg.Reactions {
var e string
if r.Emoji.ID == 0 {
e = r.Emoji.Name
} else if r.Emoji.Animated {
e = fmt.Sprintf("<a:%s:%d>", r.Emoji.Name, r.Emoji.ID)
} else {
e = fmt.Sprintf("<:%s:%d>", r.Emoji.Name, r.Emoji.ID)
}
parts = append(parts, fmt.Sprintf("%s %d", e, r.Count))
}
embed.Fields = append(embed.Fields, discord.EmbedField{
Name: "Reactions",
Value: strings.Join(parts, " | "),
})
}
embeds = append(embeds, embed)
return
}
func (s *Starboard) updateStarboardEntry(starboardMsgIDStr string, msg *discord.Message, channelID, guildID, messageID snowflake.ID) {
content, embeds, files := s.buildStarboardMessage(msg, channelID, guildID, messageID)
starboardMsgID, err := snowflake.Parse(starboardMsgIDStr)
if err != nil {
slog.Error("failed to parse starboard message ID", slog.Any("err", err))
return
}
emptyAttachments := []discord.AttachmentUpdate{}
update := discord.WebhookMessageUpdate{
Content: &content,
Embeds: &embeds,
Attachments: &emptyAttachments,
Files: files,
}
if _, err := s.webhook.UpdateMessage(starboardMsgID, update, rest.UpdateWebhookMessageParams{}); err != nil {
slog.Error("failed to update starboard webhook", slog.Any("err", err))
}
}
func (s *Starboard) removeStarboardEntry(starboardMsgIDStr, originalMsgID string) {
starboardMsgID, err := snowflake.Parse(starboardMsgIDStr)
if err == nil {
if err := s.webhook.Rest.DeleteWebhookMessage(s.webhook.ID, s.webhook.Token, starboardMsgID, 0); err != nil {
slog.Error("failed to delete starboard webhook message", slog.Any("err", err))
}
} else {
slog.Error("failed to parse starboard message ID for deletion", slog.Any("err", err))
}
if err := s.db.RemoveStarboardEntry(originalMsgID); err != nil {
slog.Error("failed to remove starboard entry", slog.Any("err", err))
}
}

220
features/xdtracker.go Normal file
View file

@ -0,0 +1,220 @@
package features
import (
"fmt"
"log/slog"
"strings"
"time"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/snowflake/v2"
"github.com/elisiei/unbot/db"
)
type XDTracker struct {
db *db.DB
config XDTrackerConfig
client *bot.Client
guildID snowflake.ID
}
type XDTrackerConfig struct {
Enabled bool
}
func NewXDTracker(database *db.DB, client *bot.Client, guildID snowflake.ID, enabled bool) *XDTracker {
return &XDTracker{
db: database,
client: client,
guildID: guildID,
config: XDTrackerConfig{Enabled: enabled},
}
}
func (x *XDTracker) HandleMessage(e *events.MessageCreate) {
if e.Message.Author.Bot {
return
}
msgID := e.Message.ID.String()
processed, err := x.db.IsXDMessageProcessed(msgID)
if err != nil || processed {
return
}
content := strings.ToLower(e.Message.Content)
count := strings.Count(content, "xd")
if count == 0 {
return
}
if err := x.db.IncrementXD(e.Message.Author.ID.String(), count); err != nil {
slog.Error("failed to increment xd count", slog.Any("err", err))
return
}
if err := x.db.MarkXDMessageProcessed(msgID); err != nil {
slog.Error("failed to mark message as processed", slog.Any("err", err))
}
}
func (x *XDTracker) Command() discord.SlashCommandCreate {
return discord.SlashCommandCreate{
Name: "xd",
Description: "XD tracker commands",
Options: []discord.ApplicationCommandOption{
discord.ApplicationCommandOptionSubCommand{
Name: "leaderboard",
Description: "Show XD leaderboard",
},
discord.ApplicationCommandOptionSubCommand{
Name: "stats",
Description: "Show your XD stats",
},
},
}
}
func (x *XDTracker) HandleLeaderboard(e *events.ApplicationCommandInteractionCreate) {
entries, err := x.db.GetXDLeaderboard(10)
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to get leaderboard.").WithEphemeral(true))
return
}
if len(entries) == 0 {
e.CreateMessage(discord.NewMessageCreate().WithContent("No XD data yet.").WithEphemeral(true))
return
}
total, err := x.db.GetTotalXDCount()
if err != nil {
total = 0
}
userCount, err := x.db.GetXDUserCount()
if err != nil {
userCount = 0
}
var sb strings.Builder
for i, entry := range entries {
sb.WriteString(fmt.Sprintf("%d. <@%s> - **%d** xd\n", i+1, entry.UserID, entry.Count))
}
sb.WriteString(fmt.Sprintf("\nTotal: **%d** xd across **%d** users", total, userCount))
e.CreateMessage(discord.NewMessageCreate().WithEmbeds(discord.NewEmbed().WithTitle("xd leaderboard").WithDescription(sb.String())))
}
func (x *XDTracker) HandleStats(e *events.ApplicationCommandInteractionCreate) {
userID := e.User().ID.String()
count, err := x.db.GetXDCount(userID)
if err != nil {
e.CreateMessage(discord.NewMessageCreate().WithContent("Failed to get stats.").WithEphemeral(true))
return
}
total, err := x.db.GetTotalXDCount()
if err != nil {
total = 0
}
userCount, err := x.db.GetXDUserCount()
if err != nil {
userCount = 0
}
rank, err := x.db.GetUserRank(userID)
if err != nil {
rank = 0
}
var pct string
if total > 0 {
pct = fmt.Sprintf(" (%.1f%%)", float64(count)/float64(total)*100)
}
var sb strings.Builder
sb.WriteString(fmt.Sprintf("**<@%s>'s XD Stats**\n", userID))
sb.WriteString(fmt.Sprintf("Rank: **#%d** of **%d**\n", rank, userCount))
sb.WriteString(fmt.Sprintf("Messages: **%d** xd%s\n", count, pct))
sb.WriteString(fmt.Sprintf("Server total: **%d** xd", total))
e.CreateMessage(discord.NewMessageCreate().WithContent(sb.String()))
}
func (x *XDTracker) BackfillHistory() {
if !x.config.Enabled {
return
}
slog.Info("starting XD history backfill")
channels, err := x.client.Rest.GetGuildChannels(x.guildID)
if err != nil {
slog.Error("failed to get guild channels for backfill", slog.Any("err", err))
return
}
for _, ch := range channels {
switch ch.Type() {
case discord.ChannelTypeGuildText, discord.ChannelTypeGuildNews:
go x.backfillChannel(ch.ID())
}
}
slog.Info("XD history backfill complete")
}
func (x *XDTracker) backfillChannel(channelID snowflake.ID) {
var lastID snowflake.ID
total := 0
for {
messages, err := x.client.Rest.GetMessages(channelID, 0, lastID, 0, 100)
if err != nil {
slog.Error("failed to get messages for backfill", slog.String("channel_id", channelID.String()), slog.Any("err", err))
return
}
if len(messages) == 0 {
break
}
for _, msg := range messages {
if msg.Author.Bot {
continue
}
msgID := msg.ID.String()
processed, err := x.db.IsXDMessageProcessed(msgID)
if err != nil || processed {
continue
}
content := strings.ToLower(msg.Content)
if count := strings.Count(content, "xd"); count > 0 {
if err := x.db.IncrementXD(msg.Author.ID.String(), count); err != nil {
slog.Error("failed to increment xd count during backfill", slog.Any("err", err))
continue
}
}
if err := x.db.MarkXDMessageProcessed(msgID); err != nil {
slog.Error("failed to mark message as processed", slog.Any("err", err))
}
}
total += len(messages)
lastID = messages[len(messages)-1].ID
time.Sleep(200 * time.Millisecond)
}
if total > 0 {
slog.Info("backfilled channel", slog.String("channel_id", channelID.String()), slog.Int("messages", total))
}
}

25
flake.lock generated
View file

@ -1,25 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1772773019,
"narHash": "sha256-E1bxHxNKfDoQUuvriG71+f+s/NT0qWkImXsYZNFFfCs=",
"rev": "aca4d95fce4914b3892661bcb80b8087293536c6",
"revCount": 958961,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/NixOS/nixpkgs/0.1.958961%2Brev-aca4d95fce4914b3892661bcb80b8087293536c6/019cc7ad-65c5-7d4e-9860-842d09d8f4fa/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/NixOS/nixpkgs/0.1"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

View file

@ -1,32 +0,0 @@
{
description = "minimal flake for crystal dev";
inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1";
outputs = inputs: let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forEachSupportedSystem = f:
inputs.nixpkgs.lib.genAttrs supportedSystems (
system:
f {
pkgs = import inputs.nixpkgs {inherit system;};
}
);
in {
devShells = forEachSupportedSystem (
{pkgs}: {
default = pkgs.mkShell {
packages = with pkgs; [
crystal
shards
];
};
}
);
};
}

29
go.mod Normal file
View file

@ -0,0 +1,29 @@
module github.com/elisiei/unbot
go 1.26.2
require (
github.com/BurntSushi/toml v1.6.0
github.com/disgoorg/disgo v0.19.3
github.com/disgoorg/omit v1.0.0
github.com/disgoorg/snowflake/v2 v2.0.3
modernc.org/sqlite v1.51.0
)
require (
github.com/disgoorg/godave v0.1.0 // indirect
github.com/disgoorg/json/v2 v2.0.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

79
go.sum Normal file
View file

@ -0,0 +1,79 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disgoorg/disgo v0.19.3 h1:kCfez2nkyXZCxQoaspvZbRNOuOIHWjOMjIhuZ6XOxUw=
github.com/disgoorg/disgo v0.19.3/go.mod h1:NnV63iw4lJdF1fnV0gX27XR43ZgRGqnL122svRMTgTE=
github.com/disgoorg/godave v0.1.0 h1:3g0Zqzz+zNaxQTVLfCnl5eZKGqZk6cM/JLLbMKCtZQQ=
github.com/disgoorg/godave v0.1.0/go.mod h1:OreAC3hpabr39bMVA+jwOVDq1EUPXH5A0XUBiZaDI1Y=
github.com/disgoorg/json/v2 v2.0.0 h1:U16yy/ARK7/aEpzjjqK1b/KaqqGHozUdeVw/DViEzQI=
github.com/disgoorg/json/v2 v2.0.0/go.mod h1:jZTBC0nIE1WeetSEI3/Dka8g+qglb4FPVmp5I5HpEfI=
github.com/disgoorg/omit v1.0.0 h1:y0LkVUOyUHT8ZlnhIAeOZEA22UYykeysK8bLJ0SfT78=
github.com/disgoorg/omit v1.0.0/go.mod h1:RTmSARkf6PWT/UckwI0bV8XgWkWQoPppaT01rYKLcFQ=
github.com/disgoorg/snowflake/v2 v2.0.3 h1:3B+PpFjr7j4ad7oeJu4RlQ+nYOTadsKapJIzgvSI2Ro=
github.com/disgoorg/snowflake/v2 v2.0.3/go.mod h1:W6r7NUA7DwfZLwr00km6G4UnZ0zcoLBRufhkFWgAc4c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad h1:qIQkSlF5vAUHxEmTbaqt1hkJ/t6skqEGYiMag343ucI=
github.com/sasha-s/go-csync v0.0.0-20240107134140-fcbab37b09ad/go.mod h1:/pA7k3zsXKdjjAiUhB5CjuKib9KJGCaLvZwtxGC8U0s=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.51.0 h1:aH/MMSoayAIhozZ7uJbVTT9QO/VhzBf0J9tymmmuC/U=
modernc.org/sqlite v1.51.0/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -1,6 +0,0 @@
---
version: 1.0
shards:
discordcr:
git: https://github.com/shardlab/discordcr.git
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85

View file

@ -1,32 +0,0 @@
name: Build
on:
push:
branches:
- master
tags:
- v*
paths-ignore:
- "**.md"
pull_request:
branches:
- master
paths-ignore:
- "**.md"
jobs:
deploy:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: MeilCli/setup-crystal-action@v4
with:
crystal_version: 1.2.1
shards_version: 0.16.0
- name: Install dependencies
run: shards install
- name: Run tests
run: crystal spec
- name: Run crystal tool format
run: crystal tool format --check
- name: Build examples
run: find examples -name "*.cr" | xargs -L 1 crystal build --no-codegen

View file

@ -1,34 +0,0 @@
name: Deploy Docs
on:
push:
branches:
- master
tags:
- v*
paths-ignore:
- "CHANGELOG.md"
jobs:
deploy:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: MeilCli/setup-crystal-action@v4
with:
crystal_version: 1.2.1
shards_version: 0.16.0
- name: Install dependencies
run: shards install
- name: Run crystal doc
run: crystal doc
- name: Extract branch name
shell: bash
run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})"
id: extract_branch
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v3.7.0-8
with:
personal_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: docs
destination_dir: ${{ steps.extract_branch.outputs.branch }}

View file

@ -1,14 +0,0 @@
.DS_Store
/doc/
/docs/
/libs/
/.crystal/
/.shards/
# Libraries don't need dependency lock
# Dependencies will be locked in application that uses them
/shard.lock
deploy_key

View file

@ -1,10 +0,0 @@
language: crystal
script:
- crystal spec
- crystal tool format --check
- find examples -name "*.cr" | xargs -L 1 crystal build --no-codegen
- bash ./deploy.sh
env:
global:
- ENCRYPTION_LABEL: "65183d8b3ae9"
- COMMIT_AUTHOR_EMAIL: "blactbt@live.de"

View file

@ -1,21 +0,0 @@
The MIT License (MIT)
Copyright (c) 2016 meew0
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View file

@ -1,101 +0,0 @@
[![docs](https://img.shields.io/badge/docs-v0.4.0-green.svg?style=flat-square)](https://dcr.shardlab.dev/v0.4.0/) [![docs](https://img.shields.io/badge/docs-master-red.svg?style=flat-square)](https://dcr.shardlab.dev/master/)
### Important Notice
This is the **new official source of discordcr**!\
The [old repo](https://github.com/discordcr/discordcr) has not been updated for countless months
and new features, along with library breaking changes, are coming fast.\
There is no guarantee any code/updates will be pushed to the old repo again, so as it stands,\
this will be where all new code will be pushed and where all new PRs and Issues should be created.
Thanks!
# discordcr
(The "cr" stands for "creative name".)
discordcr is a minimalist [Discord](https://discord.com/) API library for
[Crystal](https://crystal-lang.org/), designed to be a complement to
[discordrb](https://github.com/shardlab/discordrb) for users who want more control
and performance and who care less about ease-of-use.
discordcr isn't designed for beginners to the Discord API - while experience
with making bots isn't *required*, it's certainly recommended. If you feel
overwhelmed by the complex documentation, try
[discordrb](https://github.com/shardlab/discordrb) first and then check back.
Unlike many other libs which handle a lot of stuff, like caching or resolving,
themselves automatically, discordcr requires the user to do such things
manually. It also doesn't provide any advanced abstractions for REST calls;
the methods perform the HTTP request with the given data but nothing else.
This means that the user has full control over them, but also full
responsibility. discordcr does not support user accounts; it may work but
likely doesn't.
## Installation
Add this to your application's `shard.yml`:
```yaml
dependencies:
discordcr:
github: shardlab/discordcr
```
## Usage
An example bot can be found
[here](https://github.com/shardlab/discordcr/blob/master/examples/ping.cr). More
examples will come in the future.
A short overview of library structure: the `Client` class includes the `REST`
module, which handles the REST parts of Discord's API; the `Client` itself
handles the gateway, i. e. the interactive parts such as receiving messages. It
is possible to use only the REST parts by never calling the `#run` method on a
`Client`, which is what does the actual gateway connection.
The example linked above has an example of an event (`on_message_create`) that
is called through the gateway, and of a REST call (`client.create_message`).
Other gateway events and REST calls work much in the same way - see the
documentation for what specific events and REST calls do.
Caching is done using a separate `Cache` class that needs to be added into
clients manually:
```cr
client = Discord::Client.new # ...
cache = Discord::Cache.new(client)
client.cache = cache
```
Resolution requests for objects can now be done on the `cache` object instead of
directly over REST, this ensures that if an object is needed more than once
there will still only be one request to Discord. (There may even be no request
at all, if the requested data has already been obtained over the gateway.)
An example of how to use the cache once it has been instantiated:
```cr
# Get the username of the user with ID 66237334693085184
user = cache.resolve_user(66237334693085184_u64)
user = cache.resolve_user(66237334693085184_u64) # won't do a request to Discord
puts user.username
```
Apart from this, API documentation is also available, at
https://dcr.shardlab.dev/v0.4.0 for v0.4.0
https://dcr.shardlab.dev/master for latest
## Contributing
1. Fork it (https://github.com/shardlab/discordcr/fork)
2. Create your feature branch (`git checkout -b my-new-feature`)
3. Commit your changes (`git commit -am 'Add some feature'`)
4. Push to the branch (`git push origin my-new-feature`)
5. Create a new Pull Request
## Contributors
- [meew0](https://github.com/meew0) - creator, maintainer
- [RX14](https://github.com/RX14) - Crystal expert, maintainer
- [PixeL](https://github.com/PixelInc) - Maintainer

View file

@ -1,78 +0,0 @@
#!/bin/bash
# Script adapted from https://gist.github.com/domenic/ec8b0fc8ab45f39403dd
set -e # Exit with nonzero exit code if anything fails
SOURCE_BRANCH="master"
TARGET_BRANCH="gh-pages"
function doCompile {
crystal doc
}
# Pull requests and commits to other branches shouldn't try to deploy, just build to verify
if [ "$TRAVIS_PULL_REQUEST" != "false" ] || { [ "$TRAVIS_BRANCH" != "$SOURCE_BRANCH" ] && [ -z "$TRAVIS_TAG" ]; }; then
echo "Skipping deploy; just doing a build."
doCompile
exit 0
fi
if [ -n "$TRAVIS_TAG" ]; then
SOURCE_BRANCH=$TRAVIS_TAG
fi
# Save some useful information
REPO=`git config remote.origin.url`
SSH_REPO=${REPO/https:\/\/github.com\//git@github.com:}
SHA=`git rev-parse --verify HEAD`
# Clone the existing gh-pages for this repo into out/
# Create a new empty branch if gh-pages doesn't exist yet (should only happen on first deply)
git clone $REPO out
cd out
git checkout $TARGET_BRANCH || git checkout --orphan $TARGET_BRANCH
cd ..
mkdir -p out/doc/$SOURCE_BRANCH
# Clean out existing contents
rm -rf out/doc/$SOURCE_BRANCH/**/* || exit 0
# Run our compile script
doCompile
# Move results
mv docs/* out/doc/$SOURCE_BRANCH/
# Now let's go have some fun with the cloned repo
cd out
git config user.name "Travis CI"
git config user.email "$COMMIT_AUTHOR_EMAIL"
git add -N doc/$SOURCE_BRANCH
# If there are no changes to the compiled out (e.g. this is a README update) then just bail.
DIFF_RESULT=`git diff`
if [ -z "$DIFF_RESULT" ]; then
echo "No changes to the output on this push; exiting."
exit 0
fi
# Commit the "changes", i.e. the new version.
# The delta will show diffs between new and old versions.
git add .
git commit -m "Deploy to GitHub Pages: ${SHA}"
# Get the deploy key by using Travis's stored variables to decrypt deploy_key.enc
ENCRYPTED_KEY_VAR="encrypted_${ENCRYPTION_LABEL}_key"
ENCRYPTED_IV_VAR="encrypted_${ENCRYPTION_LABEL}_iv"
ENCRYPTED_KEY=${!ENCRYPTED_KEY_VAR}
ENCRYPTED_IV=${!ENCRYPTED_IV_VAR}
openssl aes-256-cbc -K $ENCRYPTED_KEY -iv $ENCRYPTED_IV -in deploy_key.enc -out deploy_key -d
chmod 600 deploy_key
eval `ssh-agent -s`
ssh-add deploy_key
# Now that we're all set up, we can push.
git push $SSH_REPO $TARGET_BRANCH

Binary file not shown.

View file

@ -1,50 +0,0 @@
# This example demonstrates usage of `Discord::Mention.parse` to parse
# and handle different kinds of mentions appearing in a message.
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm")
client.on_message_create do |payload|
next unless payload.content.starts_with?("parse:")
mentions = String.build do |string|
index = 0
Discord::Mention.parse(payload.content) do |mention|
index += 1
string << "`[" << index << " @ " << mention.start << "]` "
case mention
when Discord::Mention::User
string.puts "**User:** #{mention.id}"
when Discord::Mention::Role
string.puts "**Role:** #{mention.id}"
when Discord::Mention::Channel
string.puts "**Channel:** #{mention.id}"
when Discord::Mention::Emoji
string << "**Emoji:** #{mention.name} #{mention.id}"
string << " (animated)" if mention.animated
string.puts
when Discord::Mention::Everyone
string.puts "**Everyone**"
when Discord::Mention::Here
string.puts "**Here**"
end
end
end
mentions = "no mentions found in your message" if mentions.empty?
begin
client.create_message(
payload.channel_id,
mentions)
rescue ex
client.create_message(
payload.channel_id,
"`#{ex.inspect}`")
raise ex
end
end
client.run

View file

@ -1,37 +0,0 @@
# multicommand.cr is an example that uses a simple command "dispatcher"
# via a case statement.
# This example features a few commands:
# » !help ==> sends a dm (direct message) to the user
# with information
# » !about ==> prints about information in a code block
# » !echo <args> ==> echos args
# » !date ==> prints the current date
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
# Command Prefix
PREFIX = "!"
client.on_message_create do |payload|
command = payload.content
case command
when PREFIX + "help"
client.create_message(client.create_dm(payload.author.id).id, "Help is on the way!")
when PREFIX + "about"
block = "```\nBot developed by discordcr\n```"
client.create_message(payload.channel_id, block)
when .starts_with? PREFIX + "echo"
# !echo is a good example of a command with arguments (suffix)
suffix = command.split(' ')[1..-1].join(" ")
client.create_message(payload.channel_id, suffix)
when PREFIX + "date"
client.create_message(payload.channel_id, Time.utc.to_s("%D"))
else
# Ignore.
end
end
client.run

View file

@ -1,14 +0,0 @@
# This simple example bot replies to every "!ping" message with "Pong!".
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
client.on_message_create do |payload|
if payload.content.starts_with? "!ping"
client.create_message(payload.channel_id, "Pong!")
end
end
client.run

View file

@ -1,18 +0,0 @@
# This example is nearly the same as the normal ping example, but rather than simply
# responding with "Pong!", it also responds with the time it took to send the message.
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
client.on_message_create do |payload|
if payload.content.starts_with? "!ping"
# We first create a new Message, and then we check how long it took to send the message by comparing it to the current time
m = client.create_message(payload.channel_id, "Pong!")
time = Time.utc - payload.timestamp
client.edit_message(m.channel_id, m.id, "Pong! Time taken: #{time.total_milliseconds} ms.")
end
end
client.run

View file

@ -1,160 +0,0 @@
# This is a simple music bot that can connect to a voice channel and play back
# some music in DCA format. It demonstrates how to use VoiceClient and
# DCAParser.
#
# For more information on the DCA file format, see
# https://github.com/bwmarrin/dca.
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
cache = Discord::Cache.new(client)
client.cache = cache
# ID of the current user, required to create a voice client
current_user_id = nil
# The ID of the (text) channel in which the connect command was run, so the
# "Voice connected." message is sent to the correct channel
connect_channel_id = nil
# Where the created voice client will eventually be stored
voice_client = nil
client.on_ready do |payload|
current_user_id = payload.user.id
end
client.on_message_create do |payload|
if payload.content.starts_with? "!connect "
# Used as:
# !connect <guild ID> <channel ID>
# Parse the command arguments
ids = payload.content[9..-1].split(' ').map(&.to_u64)
client.create_message(payload.channel_id, "Connecting...")
connect_channel_id = payload.channel_id
client.voice_state_update(ids[0].to_u64, ids[1].to_u64, false, false)
elsif payload.content.starts_with? "!vs"
# Used as:
# !vs
reply = begin
vs = cache.resolve_voice_state(payload.guild_id.not_nil!, payload.author.id)
vc = cache.resolve_channel(vs.channel_id.not_nil!)
# The voice region will be nil if the channel is set to automatically determine it.
rtc_region = vc.rtc_region || "Automatic"
"You are connected to channel #{vs.channel_id} (region: #{rtc_region}) at guild #{vs.guild_id}"
rescue
"No voice state"
end
client.create_message(payload.channel_id, reply)
elsif payload.content.starts_with? "!vr"
# Used as:
# !vr [guild ID]
regions : Array(Discord::VoiceRegion) = if payload.content.size > 4
id = payload.content[4..-1]
client.get_guild_voice_regions(id.to_u64)
else
client.list_voice_regions
end
client.create_message(payload.channel_id, "Voice Regions: #{regions.map(&.name).join(", ")}")
elsif payload.content.starts_with? "!play_dca "
# Used as:
# !play_dca <filename>
#
# Make sure the DCA file you play back is valid according to the spec
# (including metadata), otherwise playback will fail.
unless voice_client
client.create_message(payload.channel_id, "Voice client is nil!")
next
end
filename = payload.content[10..-1]
file = File.open(filename)
# The DCAParser class handles parsing of the DCA file. It doesn't do any
# sending of audio data to Discord itself that has to be done by
# VoiceClient.
parser = Discord::DCAParser.new(file)
# A proper DCA(1) file contains metadata, which is exposed by DCAParser.
# This metadata may be of interest, so here is some example code that uses
# it.
if metadata = parser.metadata
tool = metadata.dca.tool
client.create_message(payload.channel_id, "DCA file was created by #{tool.name}, version #{tool.version}.")
if info = metadata.info
client.create_message(payload.channel_id, "Song info: #{info.title} by #{info.artist}.") if info.title && info.artist
end
else
client.create_message(payload.channel_id, "DCA file metadata is invalid!")
end
# Set the bot as speaking (green circle). This is important and has to be
# done at least once in every voice connection, otherwise the Discord client
# will not know who the packets we're sending belongs to.
voice_client.not_nil!.send_speaking(true)
client.create_message(payload.channel_id, "Playing DCA file `#{filename}`.")
# For smooth audio streams Discord requires one packet every
# 20 milliseconds. The `every` method measures the time it takes to run the
# block and then sleeps 20 milliseconds minus that time before moving on to
# the next iteration, ensuring accurate timing.
#
# When simply reading from DCA, the time it takes to read, process and
# send the frame is small enough that `every` doesn't make much of a
# difference (in fact, some users report that it actually makes things
# worse). If the processing time is not negligibly slow because you're
# doing something else than DCA parsing, or because you're reading from a
# slow source, or for any other reason, then it is recommended to use
# `every`. Otherwise, simply using a loop and `sleep`ing `20.milliseconds`
# each time may suffice.
Discord.every(20.milliseconds) do
frame = parser.next_frame(reuse_buffer: true)
break unless frame
# Perform the actual sending of the frame to Discord.
voice_client.not_nil!.play_opus(frame)
end
# Alternatively, the above code can be realised as the following:
#
# parser.parse do |frame|
# Discord.timed_run(20.milliseconds) do
# voice_client.not_nil!.play_opus(frame)
# end
# end
#
# (The `parse` method reads the frames consecutively and passes them to the
# block.)
file.close
end
end
# The VOICE_SERVER_UPDATE dispatch is sent by Discord once the op4 packet sent
# by voice_state_update has been processed. It tells the client the endpoint
# to connect to.
client.on_voice_server_update do |payload|
begin
vc = voice_client = Discord::VoiceClient.new(payload, client.session.not_nil!, current_user_id.not_nil!)
vc.on_ready do
client.create_message(connect_channel_id.not_nil!, "Voice connected.")
end
vc.run
rescue e
e.inspect_with_backtrace(STDOUT)
end
end
client.run

View file

@ -1,17 +0,0 @@
# This simple example bot creates a message whenever a new user joins the server
require "../src/discordcr"
# Make sure to replace this fake data with actual data when running.
client = Discord::Client.new(token: "Bot MjI5NDU5NjgxOTU1NjUyMzM3.Cpnz31.GQ7K9xwZtvC40y8MPY3eTqjEIXm", client_id: 229459681955652337_u64)
cache = Discord::Cache.new(client)
client.cache = cache
client.on_guild_member_add do |payload|
# get the guild/server information
guild = cache.resolve_guild(payload.guild_id)
client.create_message(guild.id, "Please welcome <@#{payload.user.id}> to #{guild.name}.")
end
client.run

View file

@ -1 +0,0 @@
..

View file

@ -1,10 +0,0 @@
name: discordcr
version: 0.4.0
crystal: 1.0.0
authors:
- meew0 <blactbt@live.de>
- Chris Hobbs (RX14) <chris@rx14.co.uk>
- z64 <zachnowicki@gmail.com>
license: MIT

View file

@ -1,216 +0,0 @@
require "./spec_helper"
describe Discord::CDN do
it "builds a custom emoji URL" do
url = Discord::CDN.custom_emoji(1, :png, 16)
url.should eq "https://cdn.discordapp.com/emojis/1.png?size=16"
end
it "builds a guild icon URL" do
url = Discord::CDN.guild_icon(1, "hash", :png, 16)
url.should eq "https://cdn.discordapp.com/icons/1/hash.png?size=16"
end
it "builds a guild splash URL" do
url = Discord::CDN.guild_splash(1, "hash", :png, 16)
url.should eq "https://cdn.discordapp.com/splashes/1/hash.png?size=16"
end
it "builds a default user avatar URL" do
url = Discord::CDN.default_user_avatar("0001")
url.should eq "https://cdn.discordapp.com/embed/avatars/1.png"
url = Discord::CDN.default_user_avatar("0007")
url.should eq "https://cdn.discordapp.com/embed/avatars/2.png"
end
describe "user_avatar" do
it "builds a user avatar URL" do
url = Discord::CDN.user_avatar(1, "hash", :png, 16)
url.should eq "https://cdn.discordapp.com/avatars/1/hash.png?size=16"
end
context "without format" do
it "detects an animated avatar" do
url = Discord::CDN.user_avatar(1_u64, "a_hash", 16)
url.should eq "https://cdn.discordapp.com/avatars/1/a_hash.gif?size=16"
end
it "defaults to webp" do
url = Discord::CDN.user_avatar(1_u64, "hash", 16)
url.should eq "https://cdn.discordapp.com/avatars/1/hash.webp?size=16"
end
end
end
it "builds an application icon URL" do
url = Discord::CDN.application_icon(1, "hash", :png, 16)
url.should eq "https://cdn.discordapp.com/app-icons/1/hash.png?size=16"
end
it "builds an application asset URL" do
url = Discord::CDN.application_asset(1, 2, :png, 16)
url.should eq "https://cdn.discordapp.com/app-assets/1/2.png?size=16"
end
it "raises on an invalid size" do
expect_raises(ArgumentError, "Size 17 is not between 16 and 2048 and a power of 2") do
Discord::CDN.custom_emoji(1, :png, 17)
end
expect_raises(ArgumentError, "Size 0 is not between 16 and 2048 and a power of 2") do
Discord::CDN.custom_emoji(1, :png, 0)
end
end
end
describe Discord::User do
user_with_default_avatar = Discord::User.from_json <<-JSON
{
"id": "1",
"username": "foo",
"avatar": null,
"discriminator": "0007"
}
JSON
user_with_avatar = Discord::User.from_json <<-JSON
{
"id": "1",
"username": "foo",
"avatar": "hash",
"discriminator": "0007"
}
JSON
user_with_animated_avatar = Discord::User.from_json <<-JSON
{
"id": "1",
"username": "foo",
"avatar": "a_hash",
"discriminator": "0007"
}
JSON
describe "#avatar_url" do
it "returns avatar URL with the given format and size" do
user = user_with_avatar
user.avatar_url(:png, 16).should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!, :png, 16)
end
it "returns default avatar URL with the given format and size" do
user = user_with_default_avatar
user.avatar_url(:png, 16).should eq Discord::CDN.default_user_avatar(user.discriminator)
end
context "without format" do
it "returns default avatar URL" do
user = user_with_default_avatar
user.avatar_url.should eq Discord::CDN.default_user_avatar(user.discriminator)
end
it "returns avatar URL" do
user = user_with_avatar
user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!)
end
it "returns animated avatar URL" do
user = user_with_animated_avatar
user.avatar_url.should eq Discord::CDN.user_avatar(user.id, user.avatar.not_nil!)
end
end
end
end
describe Discord::Guild do
guild_with_icon_and_splash = Discord::Guild.from_json <<-JSON
{
"id": "1",
"name": "name",
"icon": "hash",
"splash": "hash",
"owner_id": "2",
"region": "region",
"verification_level": 1,
"roles": [],
"emojis": [],
"features": [],
"default_message_notifications": 1,
"explicit_content_filter": 1,
"premium_tier": 0
}
JSON
it "#icon_url" do
guild = guild_with_icon_and_splash
guild.icon_url(:png, 16).should eq Discord::CDN.guild_icon(guild.id, guild.icon.not_nil!, :png, 16)
end
it "#splash_url" do
guild = guild_with_icon_and_splash
guild.splash_url(:png, 16).should eq Discord::CDN.guild_splash(guild.id, guild.splash.not_nil!, :png, 16)
end
end
describe Discord::Emoji do
emoji = Discord::Emoji.from_json <<-JSON
{
"id": "1",
"name": "name",
"roles": [],
"require_colons": true,
"managed": false,
"animated": false
}
JSON
animated_emoji = Discord::Emoji.from_json <<-JSON
{
"id": "1",
"name": "name",
"roles": [],
"require_colons": true,
"managed": false,
"animated": true
}
JSON
describe "#image_url" do
it "returns an image URL with given format and size" do
emoji.image_url(:png, 16).should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 16)
end
context "without format" do
it "returns a webp, or gif if animated" do
emoji.image_url.should eq Discord::CDN.custom_emoji(emoji.id.not_nil!, :png, 128)
animated_emoji.image_url.should eq Discord::CDN.custom_emoji(animated_emoji.id.not_nil!, :gif, 128)
end
end
end
end
describe Discord::OAuth2Application do
describe "#icon_url" do
application_with_icon = Discord::OAuth2Application.from_json <<-JSON
{
"id": "1",
"name": "name",
"icon": "hash",
"bot_public": true,
"bot_require_code_grant": false,
"owner": {
"id": "1",
"username": "username",
"discriminator": "0001"
},
"summary": "some summary",
"verify_key": "key"
}
JSON
it "returns a CDN URL with the given format and size" do
application = application_with_icon
application.icon_url(:png, 16).should eq Discord::CDN.application_icon(application.id, application.icon.not_nil!, :png, 16)
end
end
end

View file

@ -1,139 +0,0 @@
require "yaml"
require "./spec_helper"
struct StructWithTime
include JSON::Serializable
@[JSON::Field(converter: Discord::TimestampConverter)]
property data : Time
end
struct StructWithMaybeTime
include JSON::Serializable
@[JSON::Field(converter: Discord::MaybeTimestampConverter, emit_null: true)]
property data : Time?
end
describe Discord do
describe "VERSION" do
it "matches shards.yml" do
version = YAML.parse(File.read(File.join(__DIR__, "..", "shard.yml")))["version"].as_s
version.should eq(Discord::VERSION)
end
end
describe Discord::TimestampConverter do
it "parses a time with floating point accuracy" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
obj = StructWithTime.from_json(json)
obj.data.should be_a Time
end
it "parses a time without floating point accuracy" do
json = %({"data":"2017-11-15T02:23:35+00:00"})
obj = StructWithTime.from_json(json)
obj.data.should be_a Time
end
it "serializes" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
obj = StructWithTime.from_json(json)
obj.to_json.should eq json
end
it "raises on null" do
json = %({"data":null})
expect_raises(JSON::ParseException) do
StructWithTime.from_json(json)
end
end
end
describe Discord::MaybeTimestampConverter do
it "parses a time" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
StructWithMaybeTime.from_json(json).data.should be_a Time
end
it "parses null" do
json = %({"data":null})
StructWithMaybeTime.from_json(json).data.should be_nil
end
it "serializes a time" do
json = %({"data":"2017-11-16T13:09:18.291000+00:00"})
obj = StructWithMaybeTime.from_json(json)
obj.to_json.should eq json
end
it "serializes null" do
json = %({"data":null})
obj = StructWithMaybeTime.from_json(json)
obj.to_json.should eq json
end
end
describe Discord::REST::ModifyChannelPositionPayload do
describe "#to_json" do
context "parent_id is ChannelParent::Unchanged" do
it "doesn't emit parent_id" do
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::Unchanged, true)}
payload.to_json.should eq %([{"id":"0","position":0,"lock_permissions":true}])
end
end
context "parent_id is ChannelParent::None" do
it "emits null for parent_id" do
payload = {Discord::REST::ModifyChannelPositionPayload.new(0_u64, 0, Discord::REST::ChannelParent::None, true)}
payload.to_json.should eq %([{"id":"0","position":0,"parent_id":null,"lock_permissions":true}])
end
end
end
end
describe Discord::WebSocket::Packet do
it "inspects" do
packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new("foo"), "test")
packet.inspect.should eq %(Discord::WebSocket::Packet(@opcode=0 @sequence=1 @data="foo" @event_type="test"))
end
it "serializes" do
json = %({"op":0,"s":1,"d":"foo","t":"test"})
packet = Discord::WebSocket::Packet.new(0_i64, 1_i64, IO::Memory.new(%("foo")), "test")
packet.to_json.should eq json
end
it "parses" do
json = %({"op":0,"s":1,"d":"foo","t":"test"})
packet = Discord::WebSocket::Packet.from_json(json)
packet.opcode.should eq 0
packet.sequence.should eq 1
packet.data.to_s.should eq %("foo")
packet.event_type.should eq "test"
end
end
describe Discord::TimeSpanMillisecondsConverter do
it ".from_json" do
parser = JSON::PullParser.new("300")
span = Discord::TimeSpanMillisecondsConverter.from_json(parser)
span.should eq 300.milliseconds
end
it ".to_json" do
json = JSON.build do |builder|
Discord::TimeSpanMillisecondsConverter.to_json(300.milliseconds, builder)
end
json.should eq "300"
end
end
it ".shard_id" do
part = 3_u64 << 22
shard = Discord.shard_id(part, 2)
shard.should eq 1
end
end

View file

@ -1,50 +0,0 @@
require "./spec_helper"
def it_parses_message(string, into expected)
it "parses #{string.inspect} into #{expected}" do
parsed = Discord::Mention.parse(string)
parsed.should eq expected
end
end
describe Discord::Mention do
describe ".parse" do
it_parses_message(
"<@123><@!456>",
into: [
Discord::Mention::User.new(123_u64, 0, 6),
Discord::Mention::User.new(456_u64, 6, 7),
]
)
it_parses_message(
"<@&123>",
into: [Discord::Mention::Role.new(123_u64, 0, 7)])
it_parses_message(
"<#123>",
into: [Discord::Mention::Channel.new(123_u64, 0, 6)])
it_parses_message(
"<:foo:123><a:bar:456>",
into: [
Discord::Mention::Emoji.new(false, "foo", 123_u64, 0, 10),
Discord::Mention::Emoji.new(true, "bar", 456_u64, 10, 11),
]
)
it_parses_message(
"@everyone@here",
into: [
Discord::Mention::Everyone.new(0),
Discord::Mention::Here.new(9),
]
)
context "with invalid mentions" do
it_parses_message(
"<<@123<@?123><#123<:foo:123<b:foo:123><@abc><@!abc>",
into: [] of Discord::Mention)
end
end
end

View file

@ -1,63 +0,0 @@
require "./spec_helper"
describe Discord::Paginator do
context "direction up" do
it "requests all pages until empty" do
data = {
[1, 2, 3],
[4, 5],
[] of Int32,
[6, 7],
}
index = 0
paginator = Discord::Paginator(Int32).new(nil, :down) do |last_page|
if last_page
last_page.should eq data[index - 1]
end
index += 1
data[index - 1]
end
paginator.to_a.should eq [1, 2, 3, 4, 5]
end
end
context "direction down" do
it "requests all pages until empty" do
data = {
[6, 7],
[4, 5],
[] of Int32,
[1, 2, 3],
}
index = 0
paginator = Discord::Paginator(Int32).new(nil, :up) do |last_page|
if last_page
last_page.should eq data[index - 1]
end
index += 1
data[index - 1]
end
paginator.to_a.should eq [7, 6, 5, 4]
end
end
it "only returns up to limit items" do
data = {
[1, 2, 3],
[4, 5],
[] of Int32,
}
index = 0
paginator = Discord::Paginator(Int32).new(2, :down) do |last_page|
index += 1
data[index - 1]
end
paginator.to_a.should eq [1, 2]
end
end

View file

@ -1,10 +0,0 @@
require "./spec_helper"
describe Discord::REST do
describe "#encode_tuple" do
it "doesn't emit null values" do
client = Discord::Client.new("foo", 0_u64)
client.encode_tuple(foo: ["bar", 1, 2], baz: nil).should eq(%({"foo":["bar",1,2]}))
end
end
end

View file

@ -1,57 +0,0 @@
require "./spec_helper"
describe Discord::Snowflake do
describe Discord::DISCORD_EPOCH do
it "is 2015-01-01" do
expected = Time.utc(2015, 1, 1)
Discord::DISCORD_EPOCH.should eq expected.to_unix_ms
end
end
it "#to_json" do
snowflake = Discord::Snowflake.new(0_u64)
json = JSON.build do |builder|
snowflake.to_json(builder)
end
json.should eq %("0")
end
it ".from_json" do
parser = JSON::PullParser.new(%("0"))
snowflake = Discord::Snowflake.new(parser)
snowflake.value.should eq 0_u64
end
describe Array(Discord::Snowflake) do
it "can be sorted" do
snowflake_a = Discord::Snowflake.new(2_u64)
snowflake_b = Discord::Snowflake.new(1_u64)
snowflake_c = Discord::Snowflake.new(0_u64)
array = [snowflake_a, snowflake_b, snowflake_c]
array.sort.should eq [snowflake_c, snowflake_b, snowflake_a]
end
end
describe "#creation_time" do
it "returns the time the snowflake was created" do
time = Time.utc(2018, 4, 18)
snowflake = Discord::Snowflake.new(time)
snowflake.creation_time.should eq time
end
end
it "compares to uint64" do
snowflake = Discord::Snowflake.new(1_u64)
(snowflake == 1_u64).should be_true
(snowflake == 0_u64).should be_false
end
end
describe UInt64 do
it "compares to snowflake" do
snowflake = Discord::Snowflake.new(1_u64)
(1_u64 == snowflake).should be_true
(0_u64 == snowflake).should be_false
end
end

View file

@ -1,2 +0,0 @@
require "spec"
require "../src/discordcr"

View file

@ -1,48 +0,0 @@
require "./spec_helper"
private def with_voice_udp
server = UDPSocket.new
server.bind("localhost", 0)
port = server.local_address.port
client = Discord::VoiceUDP.new
client.connect("localhost", port.to_u32, 1_u32)
yield server, client
server.close
client.socket.close
end
describe Discord::VoiceUDP do
it "sends discovery" do
with_voice_udp do |server, client|
client.send_discovery
data = Bytes.new(74)
server.receive(data)
data[4, 4].should eq Bytes[0, 0, 0, 1]
end
end
it "receives discovery reply" do
with_voice_udp do |server, client|
io = IO::Memory.new
io.write Bytes.new(8)
io.print("ip address".ljust(64, '\0'))
io.write_bytes(2_u16, IO::ByteFormat::BigEndian)
data = io.to_slice
server.send(data, to: client.socket.local_address)
ip, port = client.receive_discovery_reply
ip.should eq "ip address"
port.should eq 2_u16
end
end
it "creates voice header" do
with_voice_udp do |server, client|
data = client.create_header(1_u16, 2_u32)
data[0, 2].should eq Bytes[0x80, 0x78]
data[2, 2].should eq Bytes[0, 1]
data[4, 4].should eq Bytes[0, 0, 0, 2]
data[8, 4].should eq Bytes[0, 0, 0, 1]
end
end
end

View file

@ -1,6 +0,0 @@
require "log"
require "./discordcr/*"
module Discord
Log = ::Log.for("discord")
end

View file

@ -1,354 +0,0 @@
require "./mappings/*"
module Discord
# A cache is a utility class that stores various kinds of Discord objects,
# like `User`s, `Role`s etc. Its purpose is to reduce both the load on
# Discord's servers and reduce the latency caused by having to do an API call.
# It is recommended to use caching for bots that interact heavily with
# Discord-provided data, like for example administration bots, as opposed to
# bots that only interact by sending and receiving messages. For that latter
# kind, caching is usually even counter-productive as it only unnecessarily
# increases memory usage.
#
# Caching can either be used standalone, in a purely REST-based way:
# ```
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
# cache = Discord::Cache.new(client)
#
# puts cache.resolve_user(66237334693085184) # will perform API call
# puts cache.resolve_user(66237334693085184) # will not perform an API call, as the data is now cached
# ```
#
# It can also be integrated more deeply into a `Client` (specifically one that
# uses a gateway connection) to reduce cache misses even more by automatically
# caching data received over the gateway:
# ```
# client = Discord::Client.new(token: "Bot token", client_id: 123_u64)
# cache = Discord::Cache.new(client)
# client.cache = cache # Integrate the cache into the client
# ```
#
# Note that if a cache is *not* used this way, its data will slowly go out of
# sync with Discord, and unless it is used in an environment with few changes
# likely to occur, a client without a gateway connection should probably
# refrain from caching at all.
class Cache
# A map of cached users. These aren't necessarily all the users in servers
# the bot has access to, but rather all the users that have been seen by
# the bot in the past (and haven't been deleted by means of `delete_user`).
getter users
# A map of cached channels, i. e. all channels on all servers the bot is on,
# as well as all DM channels.
getter channels
# A map of guilds (servers) the bot is on. Doesn't ignore guilds temporarily
# deleted due to an outage; so if an outage is going on right now the
# affected guilds would be missing here too.
getter guilds
# A map of cached stage instances, i. e. all stage instances on all servers
# the bot is on, represented as {channel ID => Stage instance}.
getter stage_instances
# A double map of members on servers, represented as {guild ID => {user ID
# => member}}. Will only contain previously and currently online members as
# well as all members that have been chunked (see
# `Client#request_guild_members`).
getter members
# A map of all roles on servers the bot is on. Does not discriminate by
# guild, as role IDs are unique even across guilds.
getter roles
# Mapping of users to the respective DM channels the bot has open with them,
# represented as {user ID => channel ID}.
getter dm_channels
# Mapping of guilds to the roles on them, represented as {guild ID =>
# [role IDs]}.
getter guild_roles
# Mapping of guilds to the channels on them, represented as {guild ID =>
# [channel IDs]}.
getter guild_channels
# Mapping of guilds to the channels with Stage instances on them, represented as {guild ID =>
# [channel IDs]}.
getter guild_stage_instances
# Mapping of users in guild to voice states, represented as {guild ID =>
# {user ID => voice state}}
getter voice_states
# Creates a new cache with a *client* that requests (in case of cache
# misses) should be done on.
def initialize(@client : Client)
@users = Hash(UInt64, User).new
@channels = Hash(UInt64, Channel).new
@guilds = Hash(UInt64, Guild).new
@members = Hash(UInt64, Hash(UInt64, GuildMember)).new
@roles = Hash(UInt64, Role).new
@stage_instances = Hash(UInt64, StageInstance).new
@dm_channels = Hash(UInt64, UInt64).new
@guild_roles = Hash(UInt64, Array(UInt64)).new
@guild_channels = Hash(UInt64, Array(UInt64)).new
@guild_stage_instances = Hash(UInt64, Array(UInt64)).new
@voice_states = Hash(UInt64, Hash(UInt64, VoiceState)).new
end
# Resolves a user by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_user(id : UInt64 | Snowflake) : User
id = id.to_u64
@users.fetch(id) { @users[id] = @client.get_user(id) }
end
# Resolves a channel by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_channel(id : UInt64 | Snowflake) : Channel
id = id.to_u64
@channels.fetch(id) { @channels[id] = @client.get_channel(id) }
end
# Resolves a guild by its *ID*. If the requested object is not cached, it
# will do an API call.
def resolve_guild(id : UInt64 | Snowflake) : Guild
id = id.to_u64
@guilds.fetch(id) { @guilds[id] = @client.get_guild(id) }
end
# Resolves a member by the *guild_id* of the guild the member is on, and the
# *user_id* of the member itself. An API request will be performed if the
# object is not cached.
def resolve_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) : GuildMember
guild_id = guild_id.to_u64
user_id = user_id.to_u64
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
local_members.fetch(user_id) { local_members[user_id] = @client.get_guild_member(guild_id, user_id) }
end
# Resolves a role by its *ID*. No API request will be performed if the role
# is not cached, because there is no endpoint for individual roles; however
# all roles should be cached at all times so it won't be a problem.
def resolve_role(id : UInt64 | Snowflake) : Role
@roles[id.to_u64] # There is no endpoint for getting an individual role, so we will have to ignore that case for now.
end
# Resolves a Stage instance by the *channel ID* it is on.
# An API request will be performed if the object is not cached.
def resolve_stage_instance(channel_id : UInt64 | Snowflake) : StageInstance
channel_id = channel_id.to_u64
@stage_instances.fetch(channel_id) do
stage_instance = @client.get_stage_instance(channel_id)
cache(stage_instance)
add_guild_stage_instance(stage_instance.guild_id, stage_instance.channel_id)
stage_instance
end
end
# Resolves the ID of a DM channel with a particular user by the recipient's
# *recipient_id*. If there is no such channel cached, one will be created.
def resolve_dm_channel(recipient_id : UInt64 | Snowflake) : UInt64
recipient_id = recipient_id.to_u64
@dm_channels.fetch(recipient_id) do
channel = @client.create_dm(recipient_id)
cache(Channel.new(channel))
@dm_channels[recipient_id] = channel.id.to_u64
end
end
# Resolves the current user's profile. Requires no parameters since the
# endpoint has none either. If there is a gateway connection this should
# always be cached.
def resolve_current_user : User
@current_user ||= @client.get_current_user
end
# Resolves a voice state by *guild ID* and *user ID*. No API request will be
# performed if voice state is not cached, because there is no endpoint for
# it. If there is a gateway connection this should always be cached.
def resolve_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake) : VoiceState
@voice_states[guild_id.to_u64][user_id.to_u64]
end
# Deletes a user from the cache given its *ID*.
def delete_user(id : UInt64 | Snowflake)
@users.delete(id.to_u64)
end
# Deletes a channel from the cache given its *ID*.
def delete_channel(id : UInt64 | Snowflake)
@channels.delete(id.to_u64)
end
# Deletes a guild from the cache given its *ID*.
def delete_guild(id : UInt64 | Snowflake)
@guilds.delete(id.to_u64)
end
# Deletes a stage instance from the cache given the *channel_id* it belongs to.
def delete_stage_instance(channel_id : UInt64 | Snowflake)
@stage_instances.delete(channel_id.to_u64)
end
# Deletes a member from the cache given its *user_id* and the *guild_id* it
# is on.
def delete_member(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
user_id = user_id.to_u64
@members[guild_id]?.try &.delete(user_id)
end
# Deletes a role from the cache given its *ID*.
def delete_role(id : UInt64 | Snowflake)
@roles.delete(id.to_u64)
end
# Deletes a DM channel with a particular user given the *recipient_id*.
def delete_dm_channel(recipient_id : UInt64 | Snowflake)
@dm_channels.delete(recipient_id.to_u64)
end
# Deletes the current user from the cache, if that will ever be necessary.
def delete_current_user
@current_user = nil
end
# Deletes voice state for user in guild from cache.
def delete_voice_state(guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
user_id = user_id.to_u64
@voice_states[guild_id]?.try &.delete(user_id)
end
# Adds a specific *user* to the cache.
def cache(user : User)
@users[user.id.to_u64] = user
end
# Adds a specific *channel* to the cache.
def cache(channel : Channel)
@channels[channel.id.to_u64] = channel
end
# Adds a specific *guild* to the cache.
def cache(guild : Guild)
@guilds[guild.id.to_u64] = guild
end
# Adds a specific *member* to the cache, given the *guild_id* it is on.
def cache(member : GuildMember, guild_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
local_members[member.user.id.to_u64] = member
end
# Adds a specific *role* to the cache.
def cache(role : Role)
@roles[role.id.to_u64] = role
end
# Adds a specific *Stage instance* to the cache.
def cache(stage_instance : StageInstance)
@stage_instances[stage_instance.channel_id.to_u64] = stage_instance
end
# Adds a specific *voice state* to the cache.
def cache(voice_state : VoiceState)
user_id = voice_state.user_id.to_u64
guild_id = voice_state.guild_id.not_nil!.to_u64
user_voice_states = @voice_states[guild_id] ||= Hash(UInt64, VoiceState).new
user_voice_states[user_id] = voice_state
end
# Adds a particular DM channel to the cache, given the *channel_id* and the
# *recipient_id*.
def cache_dm_channel(channel_id : UInt64 | Snowflake, recipient_id : UInt64 | Snowflake)
channel_id = channel_id.to_u64
recipient_id = recipient_id.to_u64
@dm_channels[recipient_id] = channel_id
end
# Caches the current user.
def cache_current_user(@current_user : User); end
# Adds multiple *members* at once to the cache, given the *guild_id* they
# all share. This method exists to slightly reduce the overhead of
# processing chunks; outside of that it is likely not of much use.
def cache_multiple_members(members : Array(GuildMember), guild_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
local_members = @members[guild_id] ||= Hash(UInt64, GuildMember).new
members.each do |member|
local_members[member.user.id.to_u64] = member
end
end
# Returns all roles of a guild, identified by its *guild_id*.
def guild_roles(guild_id : UInt64 | Snowflake) : Array(UInt64)
@guild_roles[guild_id.to_u64]
end
# Marks a role, identified by the *role_id*, as belonging to a particular
# guild, identified by the *guild_id*.
def add_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
role_id = role_id.to_u64
local_roles = @guild_roles[guild_id] ||= [] of UInt64
local_roles << role_id
end
# Marks a role as not belonging to a particular guild anymore.
def remove_guild_role(guild_id : UInt64 | Snowflake, role_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
role_id = role_id.to_u64
@guild_roles[guild_id]?.try { |local_roles| local_roles.delete(role_id) }
end
# Returns all channels of a guild, identified by its *guild_id*.
def guild_channels(guild_id : UInt64 | Snowflake) : Array(UInt64)
@guild_channels[guild_id.to_u64]
end
# Marks a channel, identified by the *channel_id*, as belonging to a particular
# guild, identified by the *guild_id*.
def add_guild_channel(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
channel_id = channel_id.to_u64
local_channels = @guild_channels[guild_id] ||= [] of UInt64
local_channels << channel_id
end
# Marks a channel as not belonging to a particular guild anymore.
def remove_guild_channel(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
channel_id = channel_id.to_u64
@guild_channels[guild_id]?.try { |local_channels| local_channels.delete(channel_id) }
end
# Returns all Stage instances of a guild, identified by its *guild_id*.
def guild_stage_instances(guild_id : UInt64 | Snowflake) : Array(UInt64)
@guild_stage_instances[guild_id.to_u64]
end
# Marks a Stage instance, identified by the *channel_id* it is on, as belonging to a particular
# guild, identified by the *guild_id*.
def add_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
channel_id = channel_id.to_u64
local_instances = @guild_stage_instances[guild_id] ||= [] of UInt64
local_instances << channel_id
end
# Marks a Stage instance, identified by the *channel_id* it is on, as not belonging to a particular
# guild, identified by the *guild_id*, anymore.
def remove_guild_stage_instance(guild_id : UInt64 | Snowflake, channel_id : UInt64 | Snowflake)
guild_id = guild_id.to_u64
channel_id = channel_id.to_u64
@guild_stage_instances[guild_id]?.try { |local_instances| local_instances.delete(channel_id) }
end
end
end

View file

@ -1,215 +0,0 @@
# This module contains methods for building URLs to resources on Discord's CDN
# for things like guild icons and avatars.
#
# NOTE: All `size` arguments for CDN methods must be a power of 2 between 16
# and 2048. If an invalid size is given, `ArgumentError` will be raised.
#
# [API Documentation for image formatting](https://discord.com/developers/docs/reference#image-formatting)
module Discord::CDN
extend self
# Base CDN URL
BASE_URL = "https://cdn.discordapp.com"
# Available image formats for custom emoji
enum CustomEmojiFormat
PNG
GIF
def to_s
case self
when PNG
"png"
when GIF
"gif"
end
end
def to_s(io : IO)
io << to_s
end
end
# Available image formats for guild icons
enum GuildIconFormat
PNG
JPEG
WebP
def to_s
case self
when PNG
"png"
when JPEG
"jpeg"
when WebP
"webp"
end
end
def to_s(io : IO)
io << to_s
end
end
# Available image formats for guild splashes
enum GuildSplashFormat
PNG
JPEG
WebP
def to_s
case self
when PNG
"png"
when JPEG
"jpeg"
when WebP
"webp"
end
end
def to_s(io : IO)
io << to_s
end
end
# Available image formats for user avatars
enum UserAvatarFormat
PNG
JPEG
WebP
GIF
def to_s
case self
when PNG
"png"
when JPEG
"jpeg"
when WebP
"webp"
when GIF
"gif"
end
end
def to_s(io : IO)
io << to_s
end
end
# Available image formats for application icons
enum ApplicationIconFormat
PNG
JPEG
WebP
GIF
def to_s
case self
when PNG
"png"
when JPEG
"jpeg"
when WebP
"webp"
when GIF
"gif"
end
end
def to_s(io : IO)
io << to_s
end
end
enum ApplicationAssetFormat
PNG
JPEG
WebP
def to_s
case self
when PNG
"png"
when JPEG
"jpeg"
when WebP
"webp"
end
end
end
private def check_size(value : Int32)
in_range = (16..2048).includes?(value)
power_of_two = (value > 0) && ((value & (value - 1)) == 0)
unless in_range && power_of_two
raise ArgumentError.new("Size #{value} is not between 16 and 2048 and a power of 2")
end
end
# Produces a CDN URL for a custom emoji in the given `format` and `size`
def custom_emoji(id : UInt64 | Snowflake,
format : CustomEmojiFormat = CustomEmojiFormat::PNG,
size : Int32 = 128)
check_size(size)
"#{BASE_URL}/emojis/#{id}.#{format}?size=#{size}"
end
# Produces a CDN URL for a guild icon in the given `format` and `size`
def guild_icon(id : UInt64 | Snowflake, icon : String,
format : GuildIconFormat = GuildIconFormat::WebP,
size : Int32 = 128)
check_size(size)
"#{BASE_URL}/icons/#{id}/#{icon}.#{format}?size=#{size}"
end
# Produces a CDN URL for a guild splash in the given `format` and `size`
def guild_splash(id : UInt64 | Snowflake, splash : String,
format : GuildSplashFormat = GuildSplashFormat::WebP,
size : Int32 = 128)
check_size(size)
"#{BASE_URL}/splashes/#{id}/#{splash}.#{format}?size=#{size}"
end
# Produces a CDN URL for a default user avatar, calculated from the given
# discriminator value.
def default_user_avatar(user_discriminator : String)
index = user_discriminator.to_i % 5
"#{BASE_URL}/embed/avatars/#{index}.png"
end
# Produces a CDN URL for a user avatar in the given `size`. Given the `avatar`
# string, this will return a WebP or GIF based on the animated avatar hint.
def user_avatar(id : UInt64 | Snowflake, avatar : String, size : Int32 = 128)
if avatar.starts_with?("a_")
user_avatar(id, avatar, UserAvatarFormat::GIF, size)
else
user_avatar(id, avatar, UserAvatarFormat::WebP, size)
end
end
# Produces a CDN URL for a user avatar in the given `format` and `size`
def user_avatar(id : UInt64 | Snowflake, avatar : String,
format : UserAvatarFormat, size : Int32 = 128)
check_size(size)
"#{BASE_URL}/avatars/#{id}/#{avatar}.#{format}?size=#{size}"
end
# Produces a CDN URL for an application icon in the given `format` and `size`
def application_icon(id : UInt64 | Snowflake, icon : String,
format : ApplicationIconFormat = ApplicationIconFormat::WebP,
size : Int32 = 128)
check_size(size)
"#{BASE_URL}/app-icons/#{id}/#{icon}.#{format}?size=#{size}"
end
# Produces a CDN URL for an application asset in the given `format` and `size`
def application_asset(application_id : UInt64 | Snowflake, asset_id : UInt64 | Snowflake,
format : ApplicationAssetFormat = ApplicationAssetFormat::PNG,
size : Int32 = 128)
check_size(size)
"#{BASE_URL}/app-assets/#{application_id}/#{asset_id}.#{format}?size=#{size}"
end
end

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
require "json"
module Discord
# Parser for the DCA file format, a simple wrapper around Opus made
# specifically for Discord bots.
class DCAParser
# Magic string that identifies a DCA1 file
DCA1_MAGIC = "DCA1"
# The parsed metadata, or nil if it could not be parsed.
getter metadata : DCA1Mappings::Metadata?
# Create a new parser. It will read from the given *io*. If *raw* is set,
# the file is assumed to be a DCA0 file, without any metadata. If the file's
# metadata doesn't conform to the DCA1 specification and *strict_metadata*
# is set, then the parsing will fail with an error; if it is not set then
# the metadata will silently be `nil`.
def initialize(@io : IO, raw = false, @strict_metadata = true)
unless raw
verify_magic
parse_metadata
end
end
# Reads the next frame from the IO. If there is nothing left to read, it
# will return `nil`.
#
# If *reuse_buffer* is true, a large buffer will be allocated once and
# reused for future calls of this method, reducing the load on the GC and
# potentially reusing memory use overall; if it is false, a new buffer of
# just the correct size will be allocated every time. Note that if the
# buffer is reused, the returned data is only valid until the next call to
# `next_frame`.
def next_frame(reuse_buffer = false) : Bytes?
begin
header = @io.read_bytes(Int16, IO::ByteFormat::LittleEndian)
raise "Negative frame header (#{header} < 0)" if header < 0
buf = if reuse_buffer
full_buf = @reused_buffer ||= Bytes.new(Int16::MAX)
full_buf[0, header]
else
Bytes.new(header)
end
@io.read_fully(buf)
buf
rescue IO::EOFError
nil
end
end
# Continually reads frames from the IO until there are none left. Each frame
# is passed to the given *block*.
def parse(&block : Bytes ->)
loop do
buf = next_frame
if buf
block.call(buf)
else
break
end
end
end
private def verify_magic
magic = @io.read_string(4)
if magic != DCA1_MAGIC
raise "File is not a DCA1 file (magic is #{magic}, should be DCA1)"
end
end
private def parse_metadata
# The header of the metadata part is the four-byte size of the following
# metadata payload.
metadata_size = @io.read_bytes(Int32, IO::ByteFormat::LittleEndian)
metadata_io = IO::Sized.new(@io, read_size: metadata_size)
begin
@metadata = DCA1Mappings::Metadata.from_json(metadata_io)
rescue e : JSON::ParseException
raise e if @strict_metadata
end
metadata_io.skip_to_end
end
end
# Mappings for DCA1 metadata
module DCA1Mappings
struct Metadata
include JSON::Serializable
property dca : DCA
property opus : Opus
property info : Info?
property origin : Origin?
property extra : JSON::Any
end
struct DCA
include JSON::Serializable
property version : Int32
property tool : Tool
end
struct Tool
include JSON::Serializable
property name : String
property version : String
property url : String?
property author : String?
end
struct Opus
include JSON::Serializable
property mode : String
property sample_rate : Int32
property frame_size : Int32
property abr : Int32?
property vbr : Bool
property channels : Int32
end
struct Info
include JSON::Serializable
property title : String?
property artist : String?
property album : String?
property genre : String?
property comments : String?
property cover : String?
end
struct Origin
include JSON::Serializable
property source : String?
property abr : Int32?
property channels : Int32?
property encoding : String?
property url : String?
end
end
end

View file

@ -1,71 +0,0 @@
require "http/client/response"
require "json"
module Discord
# This exception is raised in `REST#request` when a request fails in general,
# without returning a special error response.
class StatusException < Exception
getter response : HTTP::Client::Response
def initialize(@response : HTTP::Client::Response)
end
# The status code of the response that caused this exception, for example
# 500 or 418.
def status_code : Int32
@response.status_code
end
# The status message of the response that caused this exception, for example
# "Internal Server Error" or "I'm A Teapot".
def status_message : String
@response.status_message
end
def message
"#{@response.status_code} #{@response.status_message}"
end
def to_s(io)
io << @response.status_code << " " << @response.status_message
end
end
# An API error response.
struct APIError
include JSON::Serializable
property code : Int32
property message : String
end
# This exception is raised in `REST#request` when a request fails with an
# API error response that has a code and a descriptive message.
class CodeException < StatusException
getter error : APIError
def initialize(@response : HTTP::Client::Response, @error : APIError)
end
# The API error code that was returned by Discord, for example 20001 or
# 50016.
def error_code : Int32
@error.code
end
# The API error message that was returned by Discord, for example "Bots
# cannot use this endpoint" or "Provided too few or too many messages to
# delete. Must provide at least 2 and fewer than 100 messages to delete.".
def error_message : String
@error.message
end
def message
"#{@response.status_code} #{@response.status_message}: Code #{@error.code} - #{@error.message}"
end
def to_s(io)
io << @response.status_code << " " << @response.status_message << ": Code " << @error.code << " - " << @error.message
end
end
end

View file

@ -1,429 +0,0 @@
require "./converters"
module Discord
enum MessageType : UInt8
Default = 0
RecipientAdd = 1
RecipientRemove = 2
Call = 3
ChannelNameChange = 4
ChannelIconChange = 5
ChannelPinnedMessage = 6
GuildMemberJoin = 7
UserPremiumGuildSubscription = 8
UserPremiumGuildSubscriptionTier1 = 9
UserPremiumGuildSubscriptionTier2 = 10
UserPremiumGuildSubscriptionTier3 = 11
ChannelFollowAdd = 12
GuildDiscoveryDisqualified = 14
GuildDiscoveryRequalified = 15
GuildDiscoveryGracePeriodInitialWarning = 16
GuildDiscoveryGracePeriodFinalWarning = 17
ThreadCreated = 18
Reply = 19
ApplicationCommand = 20
ThreadStarterMessage = 21
GuildInviteReminder = 22
def self.new(pull : JSON::PullParser)
MessageType.new(pull.read_int.to_u8)
end
end
@[Flags]
enum MessageFlags : UInt32
Crossposted = 1 << 0
IsCrosspost = 1 << 1
SuppressEmbeds = 1 << 2
SourceMessageDeleted = 1 << 3
Urgent = 1 << 4
HasThread = 1 << 5
Ephemeral = 1 << 6
Loading = 1 << 7
FailedToMentionSomeRolesInThread = 1 << 8
def self.new(pull : JSON::PullParser)
MessageFlags.new(pull.read_int.to_u32)
end
end
enum AutoArchiveDuration : UInt16
Hour = 60
Day = 1440
ThreeDays = 4320
Week = 10080
def self.new(pull : JSON::PullParser)
AutoArchiveDuration.new(pull.read_int.to_u16)
end
def to_json(json : JSON::Builder)
json.number(value)
end
end
class Message
include JSON::Serializable
property type : MessageType
property content : String
property id : Snowflake
property channel_id : Snowflake
property guild_id : Snowflake?
property author : User
property member : PartialGuildMember?
@[JSON::Field(converter: Discord::TimestampConverter)]
property timestamp : Time
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property edited_timestamp : Time?
property tts : Bool
property mention_everyone : Bool
property mentions : Array(User)
property mention_roles : Array(Snowflake)
property mention_channels : Array(Snowflake)?
property attachments : Array(Attachment)
property embeds : Array(Embed)
property pinned : Bool?
property reactions : Array(Reaction)?
property nonce : String | Int64?
property activity : Activity?
property application : OAuth2Application?
property webhook_id : Snowflake?
property flags : MessageFlags?
property thread : Channel?
property referenced_message : Message?
def message_reference : MessageReference
MessageReference.new(@id, @channel_id, @guild_id)
end
end
struct MessageReference
include JSON::Serializable
property message_id : Snowflake?
property channel_id : Snowflake?
property guild_id : Snowflake?
property fail_if_not_exists : Bool?
def initialize(@message_id = nil, @channel_id = nil,
@guild_id = nil, @fail_if_not_exists = nil)
end
end
struct AllowedMentions
include JSON::Serializable
property parse : Array(String)?
property roles : Array(Snowflake)?
property users : Array(Snowflake)?
property replied_user : Bool
def initialize(@parse : Array(String)? = nil, @roles : Array(Snowflake)? = nil,
@users : Array(Snowflake)? = nil, @replied_user : Bool = false)
end
end
enum ActivityType : UInt8
Join = 1
Spectate = 2
Listen = 3
JoinRequest = 5
def self.new(pull : JSON::PullParser)
ActivityType.new(pull.read_int.to_u8)
end
end
struct Activity
include JSON::Serializable
property type : ActivityType
property party_id : String?
end
enum ChannelType : UInt8
GuildText = 0
DM = 1
GuildVoice = 2
GroupDM = 3
GuildCategory = 4
GuildNews = 5
GuildStore = 6
GuildNewsThread = 10
GuildPublicThread = 11
GuildPrivateThread = 12
GuildStageVoice = 13
def self.new(pull : JSON::PullParser)
ChannelType.new(pull.read_int.to_u8)
end
def to_json(json : JSON::Builder)
json.number(value)
end
end
enum VideoQualityMode : UInt8
Auto = 1
Full = 2
def self.new(pull : JSON::PullParser)
VideoQualityMode.new(pull.read_int.to_u8)
end
def to_json(json : JSON::Builder)
json.number(value)
end
end
struct Channel
include JSON::Serializable
property id : Snowflake
property type : ChannelType
property guild_id : Snowflake?
property name : String?
property permission_overwrites : Array(Overwrite)?
property topic : String?
property last_message_id : Snowflake?
property bitrate : UInt32?
property user_limit : UInt32?
property recipients : Array(User)?
property nsfw : Bool?
property icon : String?
property owner_id : Snowflake?
property application_id : Snowflake?
property position : Int32?
property parent_id : Snowflake?
property rate_limit_per_user : Int32?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property last_pin_timestamp : Time?
property rtc_region : String?
property video_quality_mode : VideoQualityMode?
property thread_metadata : ThreadMetaData?
property message_count : UInt32?
property member_count : UInt32?
property member : ThreadMember?
property default_auto_archive_duration : AutoArchiveDuration?
property last_message_id : Snowflake?
# :nodoc:
def initialize(private_channel : PrivateChannel)
@id = private_channel.id
@type = private_channel.type
@recipients = private_channel.recipients
@last_message_id = private_channel.last_message_id
end
# Produces a string to mention this channel in a message
def mention
"<##{id}>"
end
end
struct ThreadMetaData
include JSON::Serializable
property archived : Bool
property auto_archive_duration : AutoArchiveDuration
property archive_timestamp : Time
property locked : Bool
property invitable : Bool?
end
enum StagePrivacyLevel : UInt8
PUBLIC = 1
GUILD_ONLY = 2
def self.new(pull : JSON::PullParser)
StagePrivacyLevel.new(pull.read_int.to_u8)
end
def to_json(json : JSON::Builder)
json.number(value)
end
end
struct StageInstance
include JSON::Serializable
getter id : Snowflake
getter guild_id : Snowflake
getter channel_id : Snowflake
getter topic : String
getter privacy_level : StagePrivacyLevel
getter discoverable_disabled : Bool
end
struct PrivateChannel
include JSON::Serializable
property id : Snowflake
property type : ChannelType
property recipients : Array(User)
property last_message_id : Snowflake?
end
struct Overwrite
include JSON::Serializable
property id : Snowflake
property type : Int8
property allow : Permissions
property deny : Permissions
end
struct Reaction
include JSON::Serializable
property emoji : ReactionEmoji
property count : UInt32
property me : Bool
end
struct ReactionEmoji
include JSON::Serializable
property id : Snowflake?
property name : String
end
struct Embed
include JSON::Serializable
property title : String?
property type : String
property description : String?
property url : String?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property timestamp : Time?
@[JSON::Field(key: "color")]
property colour : UInt32?
property footer : EmbedFooter?
property image : EmbedImage?
property thumbnail : EmbedThumbnail?
property video : EmbedVideo?
property provider : EmbedProvider?
property author : EmbedAuthor?
property fields : Array(EmbedField)?
def initialize(@title : String? = nil, @type : String = "rich",
@description : String? = nil, @url : String? = nil,
@timestamp : Time? = nil, @colour : UInt32? = nil,
@footer : EmbedFooter? = nil, @image : EmbedImage? = nil,
@thumbnail : EmbedThumbnail? = nil, @author : EmbedAuthor? = nil,
@fields : Array(EmbedField)? = nil)
end
{% unless flag?(:correct_english) %}
def color
colour
end
{% end %}
end
struct EmbedThumbnail
include JSON::Serializable
property url : String
property proxy_url : String?
property height : UInt32?
property width : UInt32?
def initialize(@url : String)
end
end
struct EmbedVideo
include JSON::Serializable
property url : String
property height : UInt32
property width : UInt32
end
struct EmbedImage
include JSON::Serializable
property url : String
property proxy_url : String?
property height : UInt32?
property width : UInt32?
def initialize(@url : String)
end
end
struct EmbedProvider
include JSON::Serializable
property name : String
property url : String?
end
struct EmbedAuthor
include JSON::Serializable
property name : String?
property url : String?
property icon_url : String?
property proxy_icon_url : String?
def initialize(
@name : String? = nil,
@url : String? = nil,
@icon_url : String? = nil
)
end
end
struct EmbedFooter
include JSON::Serializable
property text : String?
property icon_url : String?
property proxy_icon_url : String?
def initialize(
@text : String? = nil,
@icon_url : String? = nil
)
end
end
struct EmbedField
include JSON::Serializable
property name : String
property value : String
property inline : Bool
def initialize(
@name : String,
@value : String,
@inline : Bool = false
)
end
end
struct Attachment
include JSON::Serializable
property id : Snowflake
property filename : String
property size : UInt32
property url : String
property proxy_url : String
property height : UInt32?
property width : UInt32?
end
struct ThreadMember
include JSON::Serializable
property id : Snowflake?
property user_id : Snowflake?
property join_timestamp : Time
property flags : UInt32
end
end

View file

@ -1,51 +0,0 @@
require "json"
require "time/format"
module Discord
# :nodoc:
module TimestampConverter
def self.from_json(parser : JSON::PullParser)
time_str = parser.read_string
begin
Time::Format.new("%FT%T.%6N%:z").parse(time_str)
rescue Time::Format::Error
Time::Format.new("%FT%T%:z").parse(time_str)
end
end
def self.to_json(value : Time, builder : JSON::Builder)
Time::Format.new("%FT%T.%6N%:z").to_json(value, builder)
end
end
# :nodoc:
module MaybeTimestampConverter
def self.from_json(parser : JSON::PullParser)
if parser.kind.null?
parser.read_null
return nil
end
TimestampConverter.from_json(parser)
end
def self.to_json(value : Time?, builder : JSON::Builder)
if value
TimestampConverter.to_json(value, builder)
else
builder.null
end
end
end
# :nodoc:
module TimeSpanMillisecondsConverter
def self.from_json(parser : JSON::PullParser)
parser.read_int.milliseconds
end
def self.to_json(value : Time::Span, builder : JSON::Builder)
builder.scalar(value.milliseconds)
end
end
end

View file

@ -1,8 +0,0 @@
module Discord::REST
# Enum for `parent_id` null significance in
# `REST#modify_guild_channel_positions`.
enum ChannelParent
None
Unchanged
end
end

View file

@ -1,480 +0,0 @@
require "./converters"
require "./user"
require "./channel"
require "./guild"
module Discord
module Gateway
struct ReadyPayload
include JSON::Serializable
property v : UInt8
property user : User
property private_channels : Array(PrivateChannel)
property guilds : Array(UnavailableGuild)
property session_id : String
property resume_gateway_url : String
end
struct ResumedPayload
include JSON::Serializable
property _trace : Array(String)
end
struct IdentifyPacket
include JSON::Serializable
property op : Int32
property d : IdentifyPayload
def initialize(token, properties, large_threshold, compress, shard, intents)
@op = Discord::Client::OP_IDENTIFY
@d = IdentifyPayload.new(token, properties, large_threshold, compress, shard, intents)
end
end
struct IdentifyPayload
include JSON::Serializable
property token : String
property properties : IdentifyProperties
property compress : Bool
property large_threshold : Int32
property shard : Tuple(Int32, Int32)?
@[JSON::Field(converter: Enum::ValueConverter)]
property intents : Intents?
def initialize(@token, @properties, @compress, @large_threshold, @shard, @intents)
end
end
struct IdentifyProperties
include JSON::Serializable
@[JSON::Field(key: "$os")]
property os : String
@[JSON::Field(key: "$browser")]
property browser : String
@[JSON::Field(key: "$device")]
property device : String
@[JSON::Field(key: "$referrer")]
property referrer : String
@[JSON::Field(key: "$referring_domain")]
property referring_domain : String
def initialize(@os, @browser, @device, @referrer, @referring_domain)
end
end
@[Flags]
enum Intents
Guilds = 1 << 0
GuildMembers = 1 << 1
GuildBans = 1 << 2
GuildEmojis = 1 << 3
GuildIntegrations = 1 << 4
GuildWebhooks = 1 << 5
GuildInvites = 1 << 6
GuildVoiceStates = 1 << 7
GuildPresences = 1 << 8
GuildMessages = 1 << 9
GuildMessageReactions = 1 << 10
GuildMessageTyping = 1 << 11
DirectMessages = 1 << 12
DirectMessageReactions = 1 << 13
DirectMessageTyping = 1 << 14
# Generates an Unprivileged intents constant, removing GuildMembers and GuildPresences.
{% begin %}
Unprivileged = {{ @type.constants.reject { |e| ["All", "None", "GuildMembers", "GuildPresences"].includes?(e.stringify) }.join("|").id }}
{% end %}
end
struct ResumePacket
include JSON::Serializable
property op : Int32
property d : ResumePayload
def initialize(token, session_id, seq)
@op = Discord::Client::OP_RESUME
@d = ResumePayload.new(token, session_id, seq)
end
end
# :nodoc:
struct ResumePayload
include JSON::Serializable
property token : String
property session_id : String
property seq : Int64
def initialize(@token, @session_id, @seq)
end
end
struct StatusUpdatePacket
include JSON::Serializable
property op : Int32
property d : StatusUpdatePayload
def initialize(status, game, afk, since)
@op = Discord::Client::OP_STATUS_UPDATE
@d = StatusUpdatePayload.new(status, game, afk, since)
end
end
# :nodoc:
struct StatusUpdatePayload
include JSON::Serializable
@[JSON::Field(emit_null: true)]
property status : String?
@[JSON::Field(emit_null: true)]
property game : GamePlaying?
property afk : Bool
@[JSON::Field(emit_null: true)]
property since : Int64?
def initialize(@status, @game, @afk, @since)
end
end
struct VoiceStateUpdatePacket
include JSON::Serializable
property op : Int32
property d : VoiceStateUpdatePayload
def initialize(guild_id, channel_id, self_mute, self_deaf)
@op = Discord::Client::OP_VOICE_STATE_UPDATE
@d = VoiceStateUpdatePayload.new(guild_id, channel_id, self_mute, self_deaf)
end
end
# :nodoc:
struct VoiceStateUpdatePayload
include JSON::Serializable
property guild_id : UInt64
@[JSON::Field(emit_null: true)]
property channel_id : UInt64?
property self_mute : Bool
property self_deaf : Bool
def initialize(@guild_id, @channel_id, @self_mute, @self_deaf)
end
end
struct RequestGuildMembersPacket
include JSON::Serializable
property op : Int32
property d : RequestGuildMembersPayload
def initialize(guild_id, query, limit)
@op = Discord::Client::OP_REQUEST_GUILD_MEMBERS
@d = RequestGuildMembersPayload.new(guild_id, query, limit)
end
end
# :nodoc:
struct RequestGuildMembersPayload
include JSON::Serializable
property guild_id : UInt64
property query : String
property limit : Int32
def initialize(@guild_id, @query, @limit)
end
end
struct HelloPayload
include JSON::Serializable
property heartbeat_interval : UInt32
property _trace : Array(String)
end
# This one is special from simply Guild since it also has fields for members
# and presences.
struct GuildCreatePayload
include JSON::Serializable
property id : Snowflake
property name : String
property icon : String?
property splash : String?
property owner_id : Snowflake
property region : String
property afk_channel_id : Snowflake?
property afk_timeout : Int32?
property verification_level : UInt8
property premium_tier : UInt8
property premium_subscription_count : UInt8?
property roles : Array(Role)
@[JSON::Field(key: "emojis")]
property emoji : Array(Emoji)
property features : Array(String)
property large : Bool
property voice_states : Array(VoiceState)
property unavailable : Bool?
property member_count : Int32
property members : Array(GuildMember)
property channels : Array(Channel)
property presences : Array(Presence)
property widget_channel_id : Snowflake?
property default_message_notifications : UInt8
property explicit_content_filter : UInt8
property system_channel_id : Snowflake?
property stage_instances : Array(StageInstance)
property threads : Array(Channel)
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
end
struct GuildDeletePayload
include JSON::Serializable
property id : Snowflake
property unavailable : Bool?
end
struct GuildBanPayload
include JSON::Serializable
property user : User
property guild_id : Snowflake
end
struct GuildEmojiUpdatePayload
include JSON::Serializable
property guild_id : Snowflake
@[JSON::Field(key: "emojis")]
property emoji : Array(Emoji)
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
end
struct GuildIntegrationsUpdatePayload
include JSON::Serializable
property guild_id : Snowflake
end
struct GuildMemberAddPayload
include JSON::Serializable
property user : User
property nick : String?
property roles : Array(Snowflake)
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property joined_at : Time?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property premium_since : Time?
property deaf : Bool
property mute : Bool
property guild_id : Snowflake
end
struct GuildMemberUpdatePayload
include JSON::Serializable
property user : User
property roles : Array(Snowflake)
property nick : String?
property guild_id : Snowflake
end
struct GuildMemberRemovePayload
include JSON::Serializable
property user : User
property guild_id : Snowflake
end
struct GuildMembersChunkPayload
include JSON::Serializable
property guild_id : Snowflake
property members : Array(GuildMember)
end
struct GuildRolePayload
include JSON::Serializable
property guild_id : Snowflake
property role : Role
end
struct GuildRoleDeletePayload
include JSON::Serializable
property guild_id : Snowflake
property role_id : Snowflake
end
struct InviteCreatePayload
include JSON::Serializable
property channel_id : Snowflake
property code : String
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property created_at : Time?
property guild_id : Snowflake?
property inviter : User?
property max_age : Int32
property max_uses : Int32
property temporary : Bool
property uses : Int32
end
struct InviteDeletePayload
include JSON::Serializable
property channel_id : Snowflake
property guild_id : Snowflake?
property code : String
end
struct MessageReactionPayload
include JSON::Serializable
property user_id : Snowflake
property channel_id : Snowflake
property message_id : Snowflake
property guild_id : Snowflake?
property emoji : ReactionEmoji
end
struct MessageReactionRemoveAllPayload
include JSON::Serializable
property channel_id : Snowflake
property message_id : Snowflake
property guild_id : Snowflake?
end
struct MessageReactionRemoveEmojiPayload
include JSON::Serializable
property channel_id : Snowflake
property guild_id : Snowflake
property message_id : Snowflake
property emoji : ReactionEmoji
end
struct MessageUpdatePayload
include JSON::Serializable
property type : UInt8?
property content : String?
property id : Snowflake
property channel_id : Snowflake
property guild_id : Snowflake?
property author : User?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property timestamp : Time?
property tts : Bool?
property mention_everyone : Bool?
property mentions : Array(User)?
property mention_roles : Array(Snowflake)?
property attachments : Array(Attachment)?
property embeds : Array(Embed)?
property pinned : Bool?
end
struct MessageDeletePayload
include JSON::Serializable
property id : Snowflake
property channel_id : Snowflake
property guild_id : Snowflake?
end
struct MessageDeleteBulkPayload
include JSON::Serializable
property ids : Array(Snowflake)
property channel_id : Snowflake
property guild_id : Snowflake?
end
struct PresenceUpdatePayload
include JSON::Serializable
property user : PartialUser
property game : GamePlaying?
property guild_id : Snowflake
property status : String
property activities : Array(GamePlaying)
end
struct TypingStartPayload
include JSON::Serializable
property channel_id : Snowflake
property user_id : Snowflake
property guild_id : Snowflake?
property member : GuildMember?
@[JSON::Field(converter: Time::EpochConverter)]
property timestamp : Time
end
struct VoiceServerUpdatePayload
include JSON::Serializable
property token : String
property guild_id : Snowflake
property endpoint : String
end
struct WebhooksUpdatePayload
include JSON::Serializable
property guild_id : Snowflake
property channel_id : Snowflake
end
struct ChannelPinsUpdatePayload
include JSON::Serializable
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property last_pin_timestamp : Time?
property channel_id : Snowflake
end
struct ThreadMembersUpdatePayload
include JSON::Serializable
property id : Snowflake
property guild_id : Snowflake
property member_count : UInt32
property added_members : Array(ThreadMember)?
property removed_member_ids : Array(Snowflake)?
end
struct ThreadListSyncPayload
include JSON::Serializable
property guild_id : Snowflake
property channel_ids : Array(Snowflake)?
property threads : Array(Channel)
property members : Array(ThreadMember)
end
end
end

View file

@ -1,342 +0,0 @@
require "./converters"
require "./voice"
module Discord
struct Guild
include JSON::Serializable
property id : Snowflake
property name : String
property icon : String?
property splash : String?
property owner_id : Snowflake
property region : String
property afk_channel_id : Snowflake?
property afk_timeout : Int32?
# Removed in v8
# property embed_enabled : Bool?
# property embed_channel_id : Snowflake?
property verification_level : UInt8
property premium_tier : UInt8
property premium_subscription_count : UInt8?
property roles : Array(Role)
@[JSON::Field(key: "emojis")]
property emoji : Array(Emoji)
property features : Array(String)
property widget_enabled : Bool?
property widget_channel_id : Snowflake?
property default_message_notifications : UInt8
property explicit_content_filter : UInt8
property system_channel_id : Snowflake?
# :nodoc:
def initialize(payload : Gateway::GuildCreatePayload)
@id = payload.id
@name = payload.name
@icon = payload.icon
@splash = payload.splash
@owner_id = payload.owner_id
@region = payload.region
@afk_channel_id = payload.afk_channel_id
@afk_timeout = payload.afk_timeout
@verification_level = payload.verification_level
@premium_tier = payload.premium_tier
@roles = payload.roles
@emoji = payload.emoji
@features = payload.features
@widget_channel_id = payload.widget_channel_id
@default_message_notifications = payload.default_message_notifications
@explicit_content_filter = payload.explicit_content_filter
@system_channel_id = payload.system_channel_id
end
{% unless flag?(:correct_english) %}
def emojis
emoji
end
{% end %}
# Produces a CDN URL to this guild's icon in the given `format` and `size`,
# or `nil` if no icon is set.
def icon_url(format : CDN::GuildIconFormat = CDN::GuildIconFormat::WebP,
size : Int32 = 128)
if icon = @icon
CDN.guild_icon(id, icon, format, size)
end
end
# Produces a CDN URL to this guild's splash in the given `format` and `size`,
# or `nil` if no splash is set.
def splash_url(format : CDN::GuildSplashFormat = CDN::GuildSplashFormat::WebP,
size : Int32 = 128)
if splash = @splash
CDN.guild_splash(id, splash, format, size)
end
end
end
struct UnavailableGuild
include JSON::Serializable
property id : Snowflake
property unavailable : Bool
end
struct GuildEmbed
include JSON::Serializable
property enabled : Bool
property channel_id : Snowflake?
end
struct GuildMember
include JSON::Serializable
property user : User
property nick : String?
property roles : Array(Snowflake)?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property joined_at : Time?
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property premium_since : Time?
property deaf : Bool?
property mute : Bool?
property communication_disabled_until : Time?
# :nodoc:
def initialize(user : User, partial_member : PartialGuildMember)
@user = user
@roles = partial_member.roles
@nick = partial_member.nick
@joined_at = partial_member.joined_at
@premium_since = partial_member.premium_since
@mute = partial_member.mute
@deaf = partial_member.deaf
@communication_disabled_until = partial_member.communication_disabled_until
end
# :nodoc:
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember, roles : Array(Snowflake), nick : String?)
initialize(payload)
@nick = nick
@roles = roles
end
# :nodoc:
def initialize(payload : Gateway::GuildMemberAddPayload | GuildMember)
@user = payload.user
@nick = payload.nick
@roles = payload.roles
@joined_at = payload.joined_at
@premium_since = payload.premium_since
@deaf = payload.deaf
@mute = payload.mute
end
# :nodoc:
def initialize(payload : Gateway::PresenceUpdatePayload)
@user = User.new(payload.user)
# Presence updates have no joined_at or deaf/mute, thanks Discord
end
# Produces a string to mention this member in a message
def mention
if nick
"<@!#{user.id}>"
else
"<@#{user.id}>"
end
end
end
struct PartialGuildMember
include JSON::Serializable
property nick : String?
property roles : Array(Snowflake)
@[JSON::Field(converter: Discord::TimestampConverter)]
property joined_at : Time
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property premium_since : Time?
property deaf : Bool
property mute : Bool
property communication_disabled_until : Time?
end
struct Integration
include JSON::Serializable
property id : Snowflake
property name : String
property type : String
property enabled : Bool
property syncing : Bool
property role_id : Snowflake
@[JSON::Field(key: "expire_behavior")]
property expire_behaviour : UInt8
property expire_grace_period : Int32
property user : User
property account : IntegrationAccount
@[JSON::Field(converter: Time::EpochConverter)]
property synced_at : Time
{% unless flag?(:correct_english) %}
def expire_behavior
expire_behaviour
end
{% end %}
end
struct IntegrationAccount
include JSON::Serializable
property id : String
property name : String
end
struct Emoji
include JSON::Serializable
property id : Snowflake?
property name : String
property roles : Array(Snowflake)?
property require_colons : Bool?
property managed : Bool?
property animated : Bool?
# Produces a CDN URL to this emoji's image in the given `size`. Will return
# a PNG, or GIF if the emoji is animated.
def image_url(size : Int32 = 128)
if animated
image_url(:gif, size)
else
image_url(:png, size)
end
end
# Produces a CDN URL to this emoji's image in the given `format` and `size`
# or `nil` if the emoji has no id.
def image_url(format : CDN::CustomEmojiFormat, size : Int32 = 128)
if emoji_id = id
CDN.custom_emoji(emoji_id, format, size)
end
end
# Produces a string to mention this emoji in a message
def mention
if animated
"<a:#{name}:#{id}>"
else
"<:#{name}:#{id}>"
end
end
end
struct Role
include JSON::Serializable
property id : Snowflake
property name : String
property permissions : Permissions
@[JSON::Field(key: "color")]
property colour : UInt32
property hoist : Bool
property position : Int32
property managed : Bool
property mentionable : Bool
@[JSON::Field(converter: Discord::RoleTags)]
property tags : RoleTags?
{% unless flag?(:correct_english) %}
def color
colour
end
{% end %}
# Produces a string to mention this role in a message
def mention
"<@&#{id}>"
end
end
struct RoleTags
include JSON::Serializable
property bot_id : Snowflake?
property integration_id : Snowflake?
property premium_subscriber : Bool = false
def initialize(@bot_id : Snowflake?, @integration_id : Snowflake?,
@premium_subscriber : Bool?)
end
# This struct requires a special parsing routine because Discord
# decided to send dumb values for it.
# This can be removed whenever premium_subscriber doesnt return only null.
def self.from_json(pull : JSON::PullParser)
bot_id = nil
integration_id = nil
premium_subscriber = false
pull.read_object do |key|
case key
when "bot_id" then bot_id = Snowflake.new(pull)
when "integration_id" then integration_id = Snowflake.new(pull)
when "premium_subscriber"
premium_subscriber = true
pull.skip
else
pull.skip
end
end
RoleTags.new(bot_id, integration_id, premium_subscriber)
end
end
struct GuildBan
include JSON::Serializable
property user : User
property reason : String?
end
struct GamePlaying
include JSON::Serializable
enum Type : UInt8
Playing = 0
Streaming = 1
Listening = 2
Watching = 3
Custom = 4
Competing = 5
end
property name : String?
@[JSON::Field(converter: Enum::ValueConverter(Discord::GamePlaying::Type))]
property type : Type?
property url : String?
property state : String?
property emoji : Emoji?
def initialize(
@name = nil,
@type : Type? = nil,
@url = nil,
@state = nil,
@emoji = nil
)
end
end
struct Presence
include JSON::Serializable
property user : PartialUser
property game : GamePlaying?
property status : String
property activities : Array(GamePlaying)
end
end

View file

@ -1,44 +0,0 @@
require "./converters"
require "./user"
module Discord
struct Invite
include JSON::Serializable
property code : String
property guild : InviteGuild
property channel : InviteChannel
end
struct InviteMetadata
include JSON::Serializable
property code : String
property guild : InviteGuild
property channel : InviteChannel
property inviter : User
property users : UInt32
property max_uses : UInt32
property max_age : UInt32
property temporary : Bool
@[JSON::Field(converter: Discord::TimestampConverter)]
property created_at : Time
property revoked : Bool
end
struct InviteGuild
include JSON::Serializable
property id : Snowflake
property name : String
property splash_hash : String?
end
struct InviteChannel
include JSON::Serializable
property id : Snowflake
property name : String
property type : UInt8
end
end

View file

@ -1,62 +0,0 @@
require "./converters"
require "./user"
module Discord
# An OAuth2 application, as registered with Discord, that can hold
# information about a `Client`'s associated bot user account and owner,
# among other OAuth2 properties.
struct OAuth2Application
include JSON::Serializable
property id : Snowflake
property name : String
property icon : String?
property description : String?
property rpc_origins : Array(String)?
property bot_public : Bool
property bot_require_code_grant : Bool
property owner : User
property summary : String
property verify_key : String
property team : Team?
property guild_id : Snowflake?
property primary_sku_id : String?
property slug : String?
property cover_image : String?
# Produces a CDN URL for this application's icon in the given `format` and `size`
def icon_url(format : CDN::ApplicationIconFormat = CDN::ApplicationIconFormat::WebP,
size : Int32 = 128)
if icon = @icon
CDN.application_icon(id, icon, format, size)
end
end
end
struct Team
include JSON::Serializable
property icon : String?
property id : Snowflake
property members : Array(TeamMember)
property owner_user_id : Snowflake
end
struct TeamMember
include JSON::Serializable
property membership_state : TeamMembershipState
property permissions : Array(String)
property team_id : Snowflake
property user : User
end
enum TeamMembershipState : UInt8
Invited = 1
Accepted = 2
def self.new(pull : JSON::PullParser)
TeamMembershipState.new(pull.read_int.to_u8)
end
end
end

View file

@ -1,51 +0,0 @@
module Discord
@[Flags]
enum Permissions : UInt64
CreateInstantInvite = 1
KickMembers = 1 << 1
BanMembers = 1 << 2
Administrator = 1 << 3
ManageChannels = 1 << 4
ManageGuild = 1 << 5
AddReactions = 1 << 6
ViewAuditLog = 1 << 7
PrioritySpeaker = 1 << 8
Stream = 1 << 9
ReadMessages = 1 << 10
SendMessages = 1 << 11
SendTTSMessages = 1 << 12
ManageMessages = 1 << 13
EmbedLinks = 1 << 14
AttachFiles = 1 << 15
ReadMessageHistory = 1 << 16
MentionEveryone = 1 << 17
UseExternalEmojis = 1 << 18
Connect = 1 << 20
Speak = 1 << 21
MuteMembers = 1 << 22
DeafenMembers = 1 << 23
MoveMembers = 1 << 24
UseVAD = 1 << 25
ChangeNickname = 1 << 26
ManageNicknames = 1 << 27
ManageRoles = 1 << 28
ManageWebhooks = 1 << 29
ManageEmojis = 1 << 30
UseApplicationCommands = 1 << 31
RequestToSpeak = 1 << 32
ManageThreads = 1 << 34
UsePrivateThreads = 1 << 36
UseExternalStickers = 1 << 37
SendMessagesInThreads = 1 << 38
UseEmbeddedActivities = 1 << 39
ModerateMembers = 1 << 40
def self.new(pull : JSON::PullParser)
Permissions.new(pull.read_string.to_u64)
end
def to_json(json : JSON::Builder)
json.string(value)
end
end
end

View file

@ -1,98 +0,0 @@
require "./converters"
module Discord
module REST
# A response to the Get Gateway REST API call.
struct GatewayResponse
include JSON::Serializable
property url : String
end
# A response to the Get Gateway Bot REST API call.
struct GatewayBotResponse
include JSON::Serializable
property url : String
property shards : Int32
property session_start_limit : SessionStartLimit
end
# Session start limit details included in the Get Gateway Bot REST API call.
struct SessionStartLimit
include JSON::Serializable
property total : Int32
property remaining : Int32
@[JSON::Field(converter: Discord::TimeSpanMillisecondsConverter)]
property reset_after : Time::Span
end
# A response to the Get Guild Prune Count REST API call.
struct PruneCountResponse
include JSON::Serializable
property pruned : UInt32
end
# A response to the Get Guild Vanity URL REST API call.
struct GuildVanityURLResponse
include JSON::Serializable
property code : String
end
# A request payload to rearrange channels in a `Guild` by a REST API call.
struct ModifyChannelPositionPayload
@id : Snowflake
def initialize(id : UInt64 | Snowflake, @position : Int32,
@parent_id : UInt64 | Snowflake | ChannelParent = ChannelParent::Unchanged,
@lock_permissions : Bool? = nil)
id = Snowflake.new(id) unless id.is_a?(Snowflake)
@id = id
end
def to_json(builder : JSON::Builder)
builder.object do
builder.field("id") { @id.to_json(builder) }
builder.field("position", @position)
case parent = @parent_id
when UInt64, Snowflake
parent.to_json(builder)
when ChannelParent::None
builder.field("parent_id", nil)
when ChannelParent::Unchanged
# no field
end
builder.field("lock_permissions", @lock_permissions) unless @lock_permissions.nil?
end
end
end
# A request payload to rearrange roles in a `Guild` by a REST API call.
struct ModifyRolePositionPayload
include JSON::Serializable
property id : Snowflake
property position : Int32
def initialize(id : UInt64 | Snowflake, @position : Int32)
id = Snowflake.new(id) unless id.is_a?(Snowflake)
@id = id
end
end
# Response payload to a thread list request
struct ThreadsPayload
include JSON::Serializable
property threads : Array(Channel)
property members : Array(ThreadMember)
property has_more : Bool?
end
end
end

View file

@ -1,113 +0,0 @@
require "./converters"
module Discord
struct User
include JSON::Serializable
property username : String
property id : Snowflake
property discriminator : String
property avatar : String?
property email : String?
property bot : Bool?
property system : Bool?
property mfa_enabled : Bool?
property verified : Bool?
property member : PartialGuildMember?
property flags : UserFlags?
# :nodoc:
def initialize(partial : PartialUser)
@username = partial.username.not_nil!
@id = partial.id
@discriminator = partial.discriminator.not_nil!
@avatar = partial.avatar
@email = partial.email
@bot = partial.bot
end
# Produces a CDN URL to this user's avatar in the given `size`.
# If the user has an avatar a WebP will be returned, or a GIF
# if the avatar is animated. If the user has no avatar, a default
# avatar URL is returned.
def avatar_url(size : Int32 = 128)
if avatar = @avatar
CDN.user_avatar(id, avatar, size)
else
CDN.default_user_avatar(discriminator)
end
end
# Produces a CDN URL to this user's avatar, in the given `format` and
# `size`. If the user has no avatar, a default avatar URL is returned.
def avatar_url(format : CDN::UserAvatarFormat, size : Int32 = 128)
if avatar = @avatar
CDN.user_avatar(id, avatar, format, size)
else
CDN.default_user_avatar(discriminator)
end
end
# Produces a string to mention this user in a message
def mention
"<@#{id}>"
end
end
@[Flags]
enum UserFlags : UInt32
DiscordEmployee = 1 << 0
PartneredServerOwner = 1 << 1
HypeSquadEvents = 1 << 2
BugHunterLevel1 = 1 << 3
HouseBravery = 1 << 6
HouseBrilliance = 1 << 7
HouseBalance = 1 << 8
EarlySupporter = 1 << 9
TeamUser = 1 << 10
System = 1 << 12
BugHunterLevel2 = 1 << 14
VerifiedBot = 1 << 16
EarlyVerifiedBotDeveloper = 1 << 17
CertifiedModerator = 1 << 18
BotHttpInteractions = 1 << 19
def self.new(pull : JSON::PullParser)
UserFlags.new(pull.read_int.to_u32)
end
end
struct PartialUser
include JSON::Serializable
property username : String?
property id : Snowflake
property discriminator : String?
property avatar : String?
property email : String?
property bot : Bool?
def full? : Bool
!@username.nil? && !@discriminator.nil? && !@avatar.nil?
end
end
struct UserGuild
include JSON::Serializable
property id : Snowflake
property name : String
property icon : String?
property owner : Bool
property permissions : Permissions
end
struct Connection
include JSON::Serializable
property id : Snowflake
property name : String
property type : String
property revoked : Bool
end
end

View file

@ -1,31 +0,0 @@
require "./converters"
module Discord
struct VoiceState
include JSON::Serializable
property guild_id : Snowflake?
property channel_id : Snowflake?
property user_id : Snowflake
property member : GuildMember?
property session_id : String
property deaf : Bool
property mute : Bool
property self_deaf : Bool
property self_mute : Bool
property suppress : Bool
@[JSON::Field(converter: Discord::MaybeTimestampConverter)]
property request_to_speak_timestamp : Time?
end
struct VoiceRegion
include JSON::Serializable
property id : String
property name : String
property custom : Bool
property deprecated : Bool
property optimal : Bool
end
end

View file

@ -1,107 +0,0 @@
require "./converters"
module Discord
# :nodoc:
module VWS
struct IdentifyPacket
include JSON::Serializable
property op : Int32
property d : IdentifyPayload
def initialize(server_id, user_id, session_id, token)
@op = Discord::VoiceClient::OP_IDENTIFY
@d = IdentifyPayload.new(server_id, user_id, session_id, token)
end
end
struct IdentifyPayload
include JSON::Serializable
property server_id : UInt64
property user_id : UInt64
property session_id : String
property token : String
def initialize(@server_id, @user_id, @session_id, @token)
end
end
struct SelectProtocolPacket
include JSON::Serializable
property op : Int32
property d : SelectProtocolPayload
def initialize(protocol, data)
@op = Discord::VoiceClient::OP_SELECT_PROTOCOL
@d = SelectProtocolPayload.new(protocol, data)
end
end
struct SelectProtocolPayload
include JSON::Serializable
property protocol : String
property data : ProtocolData
def initialize(@protocol, @data)
end
end
struct ProtocolData
include JSON::Serializable
property address : String
property port : UInt16
property mode : String
def initialize(@address, @port, @mode)
end
end
struct ReadyPayload
include JSON::Serializable
property ssrc : Int32
property port : Int32
property modes : Array(String)
property ip : String
end
struct SessionDescriptionPayload
include JSON::Serializable
property secret_key : Array(UInt8)
property mode : String
end
struct SpeakingPacket
include JSON::Serializable
property op : Int32
property d : SpeakingPayload
def initialize(speaking, delay)
@op = Discord::VoiceClient::OP_SPEAKING
@d = SpeakingPayload.new(speaking, delay)
end
end
struct SpeakingPayload
include JSON::Serializable
property speaking : Bool
property delay : Int32
def initialize(@speaking, @delay)
end
end
struct HelloPayload
include JSON::Serializable
property heartbeat_interval : Float32
end
end
end

View file

@ -1,16 +0,0 @@
require "./converters"
require "./user"
module Discord
struct Webhook
include JSON::Serializable
property id : Snowflake
property guild_id : Snowflake?
property channel_id : Snowflake
property user : User?
property name : String
property avatar : String?
property token : String
end
end

View file

@ -1,135 +0,0 @@
module Discord::Mention
record User, id : UInt64, start : Int32, size : Int32
record Role, id : UInt64, start : Int32, size : Int32
record Channel, id : UInt64, start : Int32, size : Int32
record Emoji, animated : Bool, name : String, id : UInt64, start : Int32, size : Int32
record Everyone, start : Int32 do
def size
9
end
end
record Here, start : Int32 do
def size
5
end
end
alias MentionType = User | Role | Channel | Emoji | Everyone | Here
# Returns an array of mentions found in a string
def self.parse(string : String)
Parser.new(string).parse
end
# Parses a string for mentions, yielding for each mention found
def self.parse(string : String, &block : MentionType ->)
Parser.new(string).parse(&block)
end
# :nodoc:
class Parser
def initialize(@string : String)
@reader = Char::Reader.new string
end
delegate has_next?, pos, current_char, next_char, peek_next_char, to: @reader
def parse(&block : MentionType ->)
while has_next?
start = pos
animated = false
case current_char
when '<'
case next_char
when '@'
case peek_next_char
when '&'
next_char # Skip role mention indicator
if next_char.ascii_number?
snowflake = scan_snowflake(pos)
yield Role.new(snowflake, start, pos - start + 1) if has_next? && current_char == '>'
end
when .ascii_number?, '!'
next_char # Skip mention indicator
next_char if current_char == '!' # Skip optional nickname indicator
if current_char.ascii_number?
snowflake = scan_snowflake(pos)
yield User.new(snowflake, start, pos - start + 1) if current_char == '>'
end
else
# Continue parsing.
end
when '#'
next_char # Skip channel mention indicator
if peek_next_char.ascii_number?
snowflake = scan_snowflake(pos)
yield Channel.new(snowflake, start, pos - start + 1) if current_char == '>'
end
when ':', 'a'
if current_char == 'a'
next unless peek_next_char == ':'
animated = true
next_char
end
next_char
name = scan_word(pos)
if current_char == ':' && peek_next_char.ascii_number?
next_char
snowflake = scan_snowflake(pos)
yield Emoji.new(animated, name, snowflake, start, pos - start + 1) if current_char == '>'
end
else
# Continue parsing.
end
when '@'
word = scan_word(pos)
case word
when "@everyone"
yield Everyone.new(start)
when "@here"
yield Here.new(start)
else
# Continue parsing.
end
else
next_char
end
end
end
def parse
results = [] of MentionType
parse { |mention| results << mention }
results
end
private def scan_snowflake(start)
while next_char.ascii_number?
# Nothing to do
end
@string[start..pos - 1].to_u64
end
private def scan_word(start)
while has_next?
case next_char
when .ascii_letter?, .ascii_number?
# Nothing to do
else
break
end
end
@string[start..pos - 1]
end
end
end

View file

@ -1,39 +0,0 @@
module Discord
class Paginator(T)
include ::Enumerable(T)
enum Direction
Up
Down
end
def initialize(@limit : Int32?, @direction : Direction,
&@block : Array(T)? -> Array(T))
@count = 0
end
def each
last_page = nil
loop do
page = @block.call(last_page)
return if page.empty?
if @direction.up?
page.reverse_each do |item|
yield(item)
@count += 1
@limit.try { |l| return if @count >= l }
end
else
page.each do |item|
yield(item)
@count += 1
@limit.try { |l| return if @count >= l }
end
end
last_page = page
end
end
end
end

File diff suppressed because it is too large Load diff

View file

@ -1,65 +0,0 @@
module Discord
DISCORD_EPOCH = 1420070400000_u64
# Struct representing a Discord ID
struct Snowflake
include Comparable(Snowflake)
include Comparable(UInt64)
getter value : UInt64
def self.new(string : String)
new(string.to_u64)
end
def self.new(parser : JSON::PullParser)
string = parser.read_string
new(string.to_u64)
end
# Creates a `Snowflake` embedded with the given timestamp
def self.new(time : Time)
ms = time.to_unix_ms.to_u64
value = (ms - DISCORD_EPOCH) << 22
new(value)
end
def initialize(@value : UInt64)
end
# Compatibility with UInt64 API
def to_u64
@value
end
def to_s(io : IO)
io << @value
end
# The time at which this snowflake was created
def creation_time
ms = (value >> 22) + DISCORD_EPOCH
Time.unix_ms(ms)
end
def to_json(builder : JSON::Builder)
builder.scalar value.to_s
end
def <=>(other : Snowflake)
value <=> other.value
end
def <=>(int : UInt64)
value <=> int
end
end
end
struct UInt64
include Comparable(Discord::Snowflake)
def <=>(snowflake : Discord::Snowflake)
self <=> snowflake.value
end
end

View file

@ -1,22 +0,0 @@
module Discord
# Bindings to libsodium. These aren't intended to be general bindings, just
# for the specific xsalsa20poly1305 encryption Discord uses.
@[Link("sodium")]
lib Sodium
# Encrypt something using xsalsa20poly1305
fun crypto_secretbox_xsalsa20poly1305(c : UInt8*, message : UInt8*,
mlen : UInt64, nonce : UInt8*,
key : UInt8*) : LibC::Int
# Decrypt something using xsalsa20poly1305 ("open a secretbox")
fun crypto_secretbox_xsalsa20poly1305_open(message : UInt8*, c : UInt8*,
mlen : UInt64, nonce : UInt8*,
key : UInt8*) : LibC::Int
# Constants
fun crypto_secretbox_xsalsa20poly1305_keybytes : LibC::SizeT # Key size in bytes
fun crypto_secretbox_xsalsa20poly1305_noncebytes : LibC::SizeT # Nonce size in bytes
fun crypto_secretbox_xsalsa20poly1305_zerobytes : LibC::SizeT # Zero bytes before a plaintext
fun crypto_secretbox_xsalsa20poly1305_boxzerobytes : LibC::SizeT # Zero bytes before a ciphertext
end
end

View file

@ -1,3 +0,0 @@
module Discord
VERSION = "0.4.0"
end

View file

@ -1,334 +0,0 @@
require "uri"
require "./mappings/gateway"
require "./mappings/vws"
require "./websocket"
require "./sodium"
module Discord
class VoiceClient
UDP_PROTOCOL = "udp"
Log = Discord::Log.for("voice")
# Supported encryption modes. Sorted by preference
ENCRYPTION_MODES = {"xsalsa20_poly1305_lite", "xsalsa20_poly1305_suffix", "xsalsa20_poly1305"}
OP_IDENTIFY = 0
OP_SELECT_PROTOCOL = 1
OP_READY = 2
OP_HEARTBEAT = 3
OP_SESSION_DESCRIPTION = 4
OP_SPEAKING = 5
OP_HELLO = 8
@udp : VoiceUDP
@sequence : UInt16 = 0_u16
@time : UInt32 = 0_u32
@endpoint : String
@server_id : UInt64
@user_id : UInt64
@session_id : String
@token : String
@heartbeat_interval : Float32?
@send_heartbeats = false
# Creates a new voice client. The *payload* should be a payload received
# from Discord as part of a VOICE_SERVER_UPDATE dispatch, received after
# sending a voice state update (gateway op 4) packet. The *session* should
# be the session currently in use by the gateway client on which the
# aforementioned dispatch was received, and the *user_id* should be the
# user ID of the account on which the voice client is created. (It is
# received as part of the gateway READY dispatch, for example)
def initialize(payload : Discord::Gateway::VoiceServerUpdatePayload,
session : Discord::Gateway::Session, user_id : UInt64 | Snowflake)
initialize(payload.endpoint, payload.token, session.session_id, payload.guild_id, user_id)
end
# :nodoc:
def initialize(@endpoint, @token, @session_id, guild_id : UInt64 | Snowflake, user_id : UInt64 | Snowflake)
@user_id = user_id.to_u64
host, port = @endpoint.split(':')
@server_id = guild_id.to_u64
@websocket = Discord::WebSocket.new(
host: host,
path: "/?v=4",
port: port.to_i,
tls: true
)
@websocket.on_message(&->on_message(Discord::WebSocket::Packet))
@websocket.on_close(&->on_close(HTTP::WebSocket::CloseCode, String))
@udp = VoiceUDP.new
end
# Initiates the connection process and blocks forever afterwards.
def run
@send_heartbeats = true
spawn { heartbeat_loop }
@websocket.run
end
# Closes the VWS connection, in effect disconnecting from voice.
def close
@send_heartbeats = false
@websocket.close
end
# Sets the handler that should be run once the voice client has connected
# successfully.
def on_ready(&@ready_handler : ->)
end
# Sends a packet to indicate to Discord whether or not we are speaking
# right now
def send_speaking(speaking : Bool, delay : Int32 = 0)
packet = VWS::SpeakingPacket.new(speaking, delay)
@websocket.send(packet.to_json)
end
# Plays a single opus packet
def play_opus(buf : Bytes)
increment_packet_metadata
@udp.send_audio(buf, @sequence, @time)
end
# Increment sequence and time
private def increment_packet_metadata
@sequence &+= 1
@time &+= 960
end
private def heartbeat_loop
while @send_heartbeats
if @heartbeat_interval
@websocket.send({op: 3, d: Time.utc.to_unix_ms}.to_json)
sleep @heartbeat_interval.not_nil!.milliseconds
else
sleep 1
end
end
end
private def on_message(packet : Discord::WebSocket::Packet)
Log.debug { "VWS packet received: #{packet} #{packet.data.to_s}" }
case packet.opcode
when OP_READY
payload = VWS::ReadyPayload.from_json(packet.data)
handle_ready(payload)
when OP_SESSION_DESCRIPTION
payload = VWS::SessionDescriptionPayload.from_json(packet.data)
handle_session_description(payload)
when OP_HELLO
payload = VWS::HelloPayload.from_json(packet.data)
handle_hello(payload)
else
# TODO: Debug log unknown opcodes?
end
end
private def on_close(code : HTTP::WebSocket::CloseCode, message : String)
@send_heartbeats = false
reason = message.empty? ? "(none)" : message
Log.warn { "VWS closed with code: #{code}, reason: #{reason}" }
end
private def handle_ready(payload : VWS::ReadyPayload)
if selected_crypto = ENCRYPTION_MODES.find { |preferred| payload.modes.includes?(preferred) }
udp_connect(payload.ip, payload.port.to_u32, payload.ssrc.to_u32, selected_crypto)
else
raise "No supported crypto modes found in #{payload.modes}"
end
end
private def udp_connect(ip, port, ssrc, encryption_mode)
@udp.connect(ip, port, ssrc)
@udp.send_discovery
ip, port = @udp.receive_discovery_reply
send_select_protocol(UDP_PROTOCOL, ip, port, encryption_mode)
end
private def send_identify(server_id, user_id, session_id, token)
packet = VWS::IdentifyPacket.new(server_id, user_id, session_id, token)
@websocket.send(packet.to_json)
end
private def send_select_protocol(protocol, address, port, mode)
data = VWS::ProtocolData.new(address, port, mode)
packet = VWS::SelectProtocolPacket.new(protocol, data)
@websocket.send(packet.to_json)
end
private def handle_session_description(payload : VWS::SessionDescriptionPayload)
@udp.secret_key = Bytes.new(payload.secret_key.to_unsafe, payload.secret_key.size)
@udp.mode = payload.mode
# Once the secret key has been received, we are ready to send audio data.
# Notify the user of this
spawn { @ready_handler.try(&.call) }
end
private def handle_hello(payload : VWS::HelloPayload)
@heartbeat_interval = payload.heartbeat_interval
send_identify(@server_id, @user_id, @session_id, @token)
end
end
# Client for Discord's voice UDP protocol, on which the actual audio data is
# sent. There should be no reason to manually use this class: use
# `VoiceClient` instead which uses this class internally.
class VoiceUDP
@secret_key : Bytes?
@mode : String?
@lite_nonce : UInt32 = 0
property secret_key
property mode
getter socket
def initialize
@socket = UDPSocket.new
end
def connect(endpoint : String, port : UInt32, ssrc : UInt32)
@ssrc = ssrc
@socket.connect(endpoint, port)
end
# Sends a discovery packet to Discord, telling them that we want to know our
# IP so we can select the protocol on the VWS
def send_discovery
data = Bytes.new(74)
IO::ByteFormat::BigEndian.encode(1_u16, data[0, 2]) # Mark as request
IO::ByteFormat::BigEndian.encode(70_u16, data[2, 2]) # Message size
IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, data[4, 4])
@socket.write(data)
end
# Awaits a response to the discovery request and returns our local IP and
# port once the response is received
def receive_discovery_reply : {String, UInt16}
buf = Bytes.new(74)
@socket.receive(buf)
# The first 8 bytes are utility and the SSRC again, we don't care about that
data = buf[8, buf.size - 8]
ip = String.new(data[0, 64]).delete("\0")
port = IO::ByteFormat::BigEndian.decode(UInt16, data[64, 2])
{ip, port}
end
# Sends 20 ms of opus audio data to Discord, with the specified sequence and
# time (used on the receiving client to synchronise packets)
def send_audio(buf, sequence, time)
header = create_header(sequence, time)
nonce = create_nonce(header)
buf = encrypt_audio(nonce, buf)
new_buf = if @mode == "xsalsa20_poly1305"
Bytes.new(header.size + buf.size)
else
Bytes.new(header.size + buf.size + nonce.size)
end
header.copy_to(new_buf)
buf.copy_to(new_buf + header.size)
nonce.copy_to(new_buf + header.size + buf.size) unless @mode == "xsalsa20_poly1305"
@socket.write(new_buf)
end
# :nodoc:
def create_header(sequence : UInt16, time : UInt32) : Bytes
bytes = Bytes.new(12)
# Write the magic bytes required by Discord
bytes[0] = 0x80_u8
bytes[1] = 0x78_u8
IO::ByteFormat::BigEndian.encode(sequence, bytes[2, 2])
IO::ByteFormat::BigEndian.encode(time, bytes[4, 4])
IO::ByteFormat::BigEndian.encode(@ssrc.not_nil!, bytes[8, 4])
bytes
end
private def create_nonce(header : Bytes)
nonce = nil
case @mode
when "xsalsa20_poly1305"
nonce = Bytes.new(header.size)
header.copy_to(nonce)
when "xsalsa20_poly1305_suffix"
nonce = Random::Secure.random_bytes(24)
when "xsalsa20_poly1305_lite"
nonce = Bytes.new(4)
IO::ByteFormat::BigEndian.encode(@lite_nonce, nonce)
@lite_nonce &+= 1
else
raise "Cannot create a nonce for unsupported audio mode #{@mode.inspect}"
end
nonce
end
private def encrypt_audio(nonce : Bytes, buf : Bytes) : Bytes
raise "No secret key was set!" unless @secret_key
sodium_nonce = Bytes.new(24, 0_u8)
nonce.copy_to(sodium_nonce)
# Sodium constants
zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_zerobytes
box_zero_bytes = Sodium.crypto_secretbox_xsalsa20poly1305_boxzerobytes
# Prepend the buf with zero_bytes zero bytes
message = Bytes.new(buf.size + zero_bytes, 0_u8)
buf.copy_to(message + zero_bytes)
# Create a buffer for the ciphertext
c = Bytes.new(message.size)
# Encrypt
Sodium.crypto_secretbox_xsalsa20poly1305(c, message, message.bytesize, sodium_nonce, @secret_key.not_nil!)
# The resulting ciphertext buffer has box_zero_bytes zero bytes prepended;
# we don't want them in the result, so move the slice forward by that many
# bytes
c + box_zero_bytes
end
end
# Utility function that runs the given block and measures the time it takes,
# then sleeps the given time minus that time. This is useful for voice code
# because (in most cases) voice data should be sent to Discord at a rate of
# one frame every 20 ms, and if the processing and sending takes a certain
# amount of time, then noticeable choppiness can be heard.
def self.timed_run(total_time : Time::Span)
delta = Time.measure { yield }
sleep_time = {total_time - delta, Time::Span.zero}.max
sleep sleep_time
end
# Runs the given block every *time_span*. This method takes into account the
# execution time for the block to keep the intervals accurate.
#
# Note that if the block takes longer to execute than the given *time_span*,
# there will be no delay: the next iteration follows immediately, with no
# attempt to get in sync.
def self.every(time_span : Time::Span)
loop do
timed_run(time_span) { yield }
end
end
end

View file

@ -1,135 +0,0 @@
require "http"
require "compress/zlib"
module Discord
# Internal wrapper around HTTP::WebSocket to decode the Discord-specific
# payload format used in the gateway and VWS.
class WebSocket
Log = Discord::Log.for("ws")
# :nodoc:
struct Packet
include JSON::Serializable
module DataConverter
def self.from_json(parser)
data = IO::Memory.new
JSON.build(data) do |builder|
parser.read_raw(builder)
end
data.rewind
end
def self.to_json(value, builder)
builder.raw(value.to_s)
end
end
@[JSON::Field(key: "op")]
getter opcode : Int64
@[JSON::Field(key: "s")]
getter sequence : Int64?
@[JSON::Field(key: "d", converter: Discord::WebSocket::Packet::DataConverter)]
getter data : IO::Memory
@[JSON::Field(key: "t")]
getter event_type : String?
def initialize(@opcode : Int64, @sequence : Int64?, @data : IO::Memory, @event_type : String?)
end
def inspect(io : IO)
io << "Discord::WebSocket::Packet(@opcode="
opcode.inspect(io)
io << " @sequence="
sequence.inspect(io)
io << " @data="
data.to_s.inspect(io)
io << " @event_type="
event_type.inspect(io)
io << ')'
end
end
ZLIB_SUFFIX = Bytes[0x0, 0x0, 0xFF, 0xFF]
@zlib_reader : Compress::Zlib::Reader?
@buffer : Bytes
def initialize(@host : String, @path : String, @port : Int32, @tls : Bool,
@zlib_buffer_size : Int32 = 10 * 1024 * 1024)
Log.info { "Connecting to #{@host}:#{@port}#{@path}" }
@websocket = HTTP::WebSocket.new(
host: @host,
path: @path,
port: @port,
tls: @tls
)
# Buffer for zlib-stream
@buffer_memory = Bytes.empty
@buffer = @buffer_memory[0, 0]
@zlib_io = IO::Memory.new
@zlib_reader = nil
end
def on_compressed(&handler : Packet ->)
@websocket.on_binary do |binary|
io = IO::Memory.new(binary)
Compress::Zlib::Reader.open(io) do |reader|
payload = Packet.from_json(reader)
Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" }
handler.call(payload)
end
end
end
def on_compressed_stream(&handler : Packet ->)
@buffer_memory = Bytes.new(@zlib_buffer_size)
@websocket.on_binary do |binary|
@zlib_io.write binary
next if binary.size < 4 || binary[binary.size - 4, 4] != ZLIB_SUFFIX
@zlib_io.rewind
zlib_reader = (@zlib_reader ||= Compress::Zlib::Reader.new(@zlib_io))
read_size = zlib_reader.read(@buffer_memory)
@buffer = @buffer_memory[0, read_size]
payload = Packet.from_json(IO::Memory.new(@buffer))
Log.debug { "[WS IN] (compressed, #{binary.size} bytes) #{payload.to_json}" }
handler.call(payload)
@zlib_io.clear
end
end
def on_message(&handler : Packet ->)
@websocket.on_message do |message|
Log.debug { "[WS IN] #{message}" }
payload = Packet.from_json(message)
handler.call(payload)
end
end
def on_close(&handler : HTTP::WebSocket::CloseCode, String ->)
@websocket.on_close(&handler)
end
def run
@websocket.run
end
def close(code : HTTP::WebSocket::CloseCode | Int? = nil, message = nil)
Log.info { "Closing with code: #{code} #{message || "(no message)"}" }
@websocket.close(code, message)
end
def send(message)
Log.debug { "[WS OUT] #{message}" }
@websocket.send(message)
end
end
end

184
main.cr
View file

@ -1,184 +0,0 @@
require "discordcr"
require "json"
require "uuid"
require "./responses"
require "./reactions"
token = ENV["DISCORD_BOT_TOKEN"]? || raise "Missing DISCORD_BOT_TOKEN env variable"
puts("Bot #{token}")
client = Discord::Client.new(
token: "Bot #{token}",
client_id: 1475668463363293398_u64
)
DICT_FILE = "./dictionary.json"
USER_LIMIT = 100
def load_dict
if File.exists?(DICT_FILE)
JSON.parse(File.read(DICT_FILE)).as_h.transform_values do |defs|
defs.as_a.map do |d|
{
"id" => d["id"].as_s,
"definition" => d["definition"].as_s,
"author" => d["author"].as_s,
"upvotes" => d["upvotes"].as_i,
"downvotes" => d["downvotes"].as_i
}
end
end
else
{} of String => Array(Hash(String, String|Int32))
end
end
def save_dict(dict)
File.write(DICT_FILE, dict.to_json)
end
dictionary = load_dict
def user_def_count(dict, username)
dict.values.flatten.count { |d| d["author"] == username }
end
client.on_message_create do |payload|
content = payload.content
args = content.split(" ", 3)
command = args[0]?
user = payload.author.username
if payload.author.bot
next
end
case command
when "!add"
word = args[1]?
definition = args[2]?
if word.nil? || definition.nil?
client.create_message(payload.channel_id, "usage: !add <word> <definition>")
next
end
if user_def_count(dictionary, user) >= USER_LIMIT
client.create_message(payload.channel_id, "#{user}, you reached the limit of #{USER_LIMIT} definitions.")
next
end
dictionary[word] ||= [] of Hash(String, String | Int32)
dictionary[word] << {
"id" => UUID.random.to_s,
"definition" => definition,
"author" => user,
"upvotes" => 0,
"downvotes" => 0
}
save_dict(dictionary)
client.create_message(payload.channel_id, "added definition for **#{word}** by #{user}.")
when "!define"
word = args[1]?
if word.nil?
client.create_message(payload.channel_id, "usage: !define <word>")
next
end
defs = dictionary[word]?
if defs
text = defs.map_with_index do |d, i|
"#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}"
end.join("\n")
client.create_message(payload.channel_id, "**#{word}**:\n#{text}")
else
client.create_message(payload.channel_id, "no definition for **#{word}**.")
end
when "!list"
word = args[1]?
if word.nil?
client.create_message(payload.channel_id, "usage: !list <word>")
next
end
defs = dictionary[word]?
if defs
text = defs.map_with_index do |d, i|
"#{i+1}. #{d["definition"]} (by #{d["author"]}) 👍#{d["upvotes"]} 👎#{d["downvotes"]}"
end.join("\n")
client.create_message(payload.channel_id, "**#{word}** definitions:\n#{text}")
else
client.create_message(payload.channel_id, "no definitions found.")
end
when "!upvote", "!downvote"
word = args[1]?
index = args[2]? ? args[2].to_i : nil
if word.nil? || index.nil?
client.create_message(payload.channel_id, "usage: #{command} <word> <definition_number>")
next
end
defs = dictionary[word]?
if defs && index > 0 && index <= defs.size
target = defs[index - 1]
if command == "!upvote"
target["upvotes"] = target["upvotes"].to_i + 1
else
target["downvotes"] = target["downvotes"].to_i + 1
end
save_dict(dictionary)
client.create_message(payload.channel_id, "updated votes for definition #{index} of **#{word}**.")
else
client.create_message(payload.channel_id, "invalid word or definition number.")
end
when "!random"
if dictionary.empty?
client.create_message(payload.channel_id, "nichectionary is empty.")
next
end
word = dictionary.keys.sample
defs = dictionary[word]
def_choice = defs.sample
client.create_message(payload.channel_id,
"**#{word}**:\n#{def_choice["definition"]} (by #{def_choice["author"]}) 👍#{def_choice["upvotes"]} 👎#{def_choice["downvotes"]}"
)
when "!search"
term = args[1]?
if term.nil?
client.create_message(payload.channel_id, "usage: !search <term>")
next
end
matches = dictionary.select { |w, _| w.includes?(term) }
if matches.empty?
client.create_message(payload.channel_id, "no words matched `#{term}`.")
next
end
text = matches.keys.join(", ")
client.create_message(payload.channel_id, "words matching `#{term}`: #{text}")
end
Reactions.check_message(content).each do |emoji|
client.create_reaction(payload.channel_id, payload.id, emoji)
end
preset_reply = Responses.check_message(content)
if preset_reply
client.trigger_typing_indicator(payload.channel_id)
sleep 500.milliseconds
client.create_message(payload.channel_id, preset_reply, message_reference: payload.message_reference())
next
end
end
client.run

185
main.go Normal file
View file

@ -0,0 +1,185 @@
package main
import (
"context"
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/disgoorg/disgo"
"github.com/disgoorg/disgo/bot"
"github.com/disgoorg/disgo/discord"
"github.com/disgoorg/disgo/events"
"github.com/disgoorg/disgo/gateway"
"github.com/disgoorg/snowflake/v2"
"github.com/elisiei/unbot/config"
"github.com/elisiei/unbot/db"
"github.com/elisiei/unbot/features"
)
func main() {
configPath := flag.String("config", "config.toml", "path to config file")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
slog.Error("failed to load config", slog.Any("err", err))
os.Exit(1)
}
database, err := db.Open("unbot.db")
if err != nil {
slog.Error("failed to open database", slog.Any("err", err))
os.Exit(1)
}
dict := features.NewDictionary(database, cfg.Features.Dictionary.Enabled)
starboard, err := features.NewStarboard(
database,
cfg.Features.Starboard.WebhookURL,
cfg.Features.Starboard.ChannelID,
cfg.Features.Starboard.Threshold,
cfg.Features.Starboard.Enabled,
)
if err != nil {
slog.Error("failed to create starboard", slog.Any("err", err))
os.Exit(1)
}
client, err := disgo.New(cfg.Bot.Token,
bot.WithGatewayConfigOpts(
gateway.WithIntents(
gateway.IntentGuilds,
gateway.IntentGuildMessages,
gateway.IntentMessageContent,
gateway.IntentGuildMessageReactions,
),
),
bot.WithEventListenerFunc(func(e *events.AutocompleteInteractionCreate) {
if cfg.Features.Dictionary.Enabled {
dict.HandleAutocomplete(e)
}
}),
bot.WithEventListenerFunc(func(e *events.GuildMessageReactionAdd) {
if cfg.Features.Starboard.Enabled {
starboard.HandleReactionAdd(e)
}
}),
bot.WithEventListenerFunc(func(e *events.GuildMessageReactionRemove) {
if cfg.Features.Starboard.Enabled {
starboard.HandleReactionRemove(e)
}
}),
)
if err != nil {
slog.Error("failed to create client", slog.Any("err", err))
os.Exit(1)
}
xdTracker := features.NewXDTracker(
database,
client,
snowflake.ID(cfg.Bot.GuildID),
cfg.Features.XDTracker.Enabled,
)
client.AddEventListeners(
bot.NewListenerFunc(func(e *events.ApplicationCommandInteractionCreate) {
handleCommand(e, dict, xdTracker, cfg)
}),
bot.NewListenerFunc(func(e *events.MessageCreate) {
if cfg.Features.XDTracker.Enabled {
xdTracker.HandleMessage(e)
}
}),
)
iconChanger := features.NewIconChanger(
client,
cfg.Bot.GuildID,
cfg.Features.IconChanger.ChannelID,
cfg.Features.IconChanger.Interval,
cfg.Features.IconChanger.Enabled,
)
client.AddEventListeners(bot.NewListenerFunc(func(e *events.MessageCreate) {
iconChanger.HandleMessage(e)
}))
iconChanger.Start()
guildID := snowflake.ID(cfg.Bot.GuildID)
var commands []discord.ApplicationCommandCreate
if cfg.Features.Dictionary.Enabled {
commands = append(commands, dict.Command())
}
if cfg.Features.XDTracker.Enabled {
commands = append(commands, xdTracker.Command())
}
if len(commands) > 0 {
if _, err := client.Rest.SetGuildCommands(client.ApplicationID, guildID, commands); err != nil {
slog.Error("failed to register commands", slog.Any("err", err))
os.Exit(1)
}
slog.Info("registered commands")
}
if err := client.OpenGateway(context.TODO()); err != nil {
slog.Error("failed to open gateway", slog.Any("err", err))
os.Exit(1)
}
slog.Info("bot is running")
go xdTracker.BackfillHistory()
go iconChanger.BackfillHistory()
s := make(chan os.Signal, 1)
signal.Notify(s, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
<-s
iconChanger.Stop()
client.Close(context.TODO())
}
func handleCommand(e *events.ApplicationCommandInteractionCreate, dict *features.Dictionary, xd *features.XDTracker, cfg *config.Config) {
data := e.SlashCommandInteractionData()
switch data.CommandName() {
case "dict":
if !cfg.Features.Dictionary.Enabled {
return
}
if data.SubCommandName == nil {
return
}
switch *data.SubCommandName {
case "add":
dict.HandleAdd(e)
case "get":
dict.HandleGet(e)
case "remove":
dict.HandleRemove(e)
case "list":
dict.HandleList(e)
}
case "xd":
if !cfg.Features.XDTracker.Enabled {
return
}
if data.SubCommandName == nil {
xd.HandleStats(e)
return
}
switch *data.SubCommandName {
case "leaderboard":
xd.HandleLeaderboard(e)
case "stats":
xd.HandleStats(e)
}
}
}

View file

@ -1,21 +0,0 @@
module Reactions
TRIGGERS = {
"void" => ["👀"],
"cursed" => ["😳"],
"based" => ["🗿"],
"niche" => ["👀", "🗣️"]
}
def self.check_message(content : String) : Array(String)
lower = content.downcase
emojis = [] of String
TRIGGERS.each do |keyword, reactions|
if lower.matches?(/\b#{Regex.escape(keyword)}\b/)
emojis.concat(reactions)
end
end
emojis
end
end

View file

@ -1,27 +0,0 @@
module Responses
PRESETS = {
"wachin" => ["wachin vos", "k onda wachinovich", "qn te juna a vos wachin", "te vamos a follar villero del orto"],
"mamita" => ["\"mamita\" Villacoño :round_pushpin:"],
"niche" => ["very niche indeed :eyes:", "nichoide"],
"parezco musulmana" => ["TIA K DICES :woman_facepalming:"],
"eh?" => ["jajaja"],
"jaja" => ["q te reís dobolu jajaja", "se jijeaba de la nada el pelotuDO", "sisi k risa pero un laburito no pinta?"],
"rust" => [":eyes:"],
"tranki" => ["piola sin berretin"],
"67" => ["<:67:1478470308678865066>"],
"pinto" => ["y si, pintó", "pintó wacho atr"],
"deadlock" => ["che garpa un deadlock??? :eyes:", "unos deadlock xhikos?", "pintó un deadlock"]
}
PROBABILITY = 0.55
def self.check_message(message : String)
PRESETS.each do |keyword, responses|
if message.downcase.includes?(keyword)
prob = rand
return responses.sample if prob < PROBABILITY
end
end
nil
end
end

View file

@ -1,6 +0,0 @@
version: 2.0
shards:
discordcr:
git: https://github.com/shardlab/discordcr.git
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85

View file

@ -1,28 +0,0 @@
name: cr
version: 0.1.0
dependencies:
discordcr:
github: shardlab/discordcr
branch: master
targets:
cr:
main: main.cr
# authors:
# - name <email@example.com>
# description: |
# Short description of cr
# dependencies:
# pg:
# github: will/crystal-pg
# version: "~> 0.5"
# development_dependencies:
# webmock:
# github: manastech/webmock.cr
# license: MIT