Compare commits
No commits in common. "go" and "main" have entirely different histories.
76 changed files with 8202 additions and 1631 deletions
|
|
@ -1,8 +0,0 @@
|
||||||
.git/
|
|
||||||
.gitignore
|
|
||||||
.devenv/
|
|
||||||
devenv.*
|
|
||||||
*.md
|
|
||||||
unbot.db
|
|
||||||
unbot.db-journal
|
|
||||||
config.toml
|
|
||||||
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
use flake
|
||||||
16
.gitignore
vendored
16
.gitignore
vendored
|
|
@ -1,17 +1,3 @@
|
||||||
.direnv
|
.direnv
|
||||||
config.toml
|
|
||||||
.env
|
.env
|
||||||
|
dictionary.json
|
||||||
# Devenv
|
|
||||||
.devenv*
|
|
||||||
devenv.local.nix
|
|
||||||
devenv.local.yaml
|
|
||||||
|
|
||||||
# direnv
|
|
||||||
.direnv
|
|
||||||
|
|
||||||
# pre-commit
|
|
||||||
.pre-commit-config.yaml
|
|
||||||
|
|
||||||
*.db
|
|
||||||
*.db-journal
|
|
||||||
|
|
|
||||||
23
Dockerfile
23
Dockerfile
|
|
@ -1,23 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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
251
db/db.go
|
|
@ -1,251 +0,0 @@
|
||||||
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
65
devenv.lock
|
|
@ -1,65 +0,0 @@
|
||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{ pkgs, ... }:
|
|
||||||
{
|
|
||||||
languages.go.enable = true;
|
|
||||||
packages = with pkgs; [ opencode ];
|
|
||||||
}
|
|
||||||
18
devenv.yaml
18
devenv.yaml
|
|
@ -1,18 +0,0 @@
|
||||||
# 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
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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:
|
|
||||||
|
|
@ -1,206 +0,0 @@
|
||||||
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())))
|
|
||||||
}
|
|
||||||
|
|
@ -1,207 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,253 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,220 +0,0 @@
|
||||||
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
Normal file
25
flake.lock
generated
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"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
|
||||||
|
}
|
||||||
32
flake.nix
Normal file
32
flake.nix
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
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
29
go.mod
|
|
@ -1,29 +0,0 @@
|
||||||
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
79
go.sum
|
|
@ -1,79 +0,0 @@
|
||||||
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=
|
|
||||||
6
lib/.shards.info
Normal file
6
lib/.shards.info
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
version: 1.0
|
||||||
|
shards:
|
||||||
|
discordcr:
|
||||||
|
git: https://github.com/shardlab/discordcr.git
|
||||||
|
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85
|
||||||
32
lib/discordcr/.github/workflows/build_examples.yml
vendored
Normal file
32
lib/discordcr/.github/workflows/build_examples.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
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
|
||||||
34
lib/discordcr/.github/workflows/deploy_docs.yml
vendored
Normal file
34
lib/discordcr/.github/workflows/deploy_docs.yml
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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 }}
|
||||||
|
|
||||||
14
lib/discordcr/.gitignore
vendored
Normal file
14
lib/discordcr/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.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
|
||||||
10
lib/discordcr/.travis.yml
Normal file
10
lib/discordcr/.travis.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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"
|
||||||
21
lib/discordcr/LICENSE
Normal file
21
lib/discordcr/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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.
|
||||||
101
lib/discordcr/README.md
Normal file
101
lib/discordcr/README.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
[](https://dcr.shardlab.dev/v0.4.0/) [](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
|
||||||
78
lib/discordcr/deploy.sh
Normal file
78
lib/discordcr/deploy.sh
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
#!/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
|
||||||
BIN
lib/discordcr/deploy_key.enc
Normal file
BIN
lib/discordcr/deploy_key.enc
Normal file
Binary file not shown.
50
lib/discordcr/examples/mention_parser.cr
Normal file
50
lib/discordcr/examples/mention_parser.cr
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
# 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
|
||||||
37
lib/discordcr/examples/multicommand.cr
Normal file
37
lib/discordcr/examples/multicommand.cr
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# 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
|
||||||
14
lib/discordcr/examples/ping.cr
Normal file
14
lib/discordcr/examples/ping.cr
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
# 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
|
||||||
18
lib/discordcr/examples/ping_with_response_time.cr
Normal file
18
lib/discordcr/examples/ping_with_response_time.cr
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# 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
|
||||||
160
lib/discordcr/examples/voice_send.cr
Normal file
160
lib/discordcr/examples/voice_send.cr
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
# 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
|
||||||
17
lib/discordcr/examples/welcome.cr
Normal file
17
lib/discordcr/examples/welcome.cr
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# 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
|
||||||
1
lib/discordcr/lib
Symbolic link
1
lib/discordcr/lib
Symbolic link
|
|
@ -0,0 +1 @@
|
||||||
|
..
|
||||||
10
lib/discordcr/shard.yml
Normal file
10
lib/discordcr/shard.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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
|
||||||
216
lib/discordcr/spec/cdn_spec.cr
Normal file
216
lib/discordcr/spec/cdn_spec.cr
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
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
|
||||||
139
lib/discordcr/spec/discordcr_spec.cr
Normal file
139
lib/discordcr/spec/discordcr_spec.cr
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
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
|
||||||
50
lib/discordcr/spec/mention_spec.cr
Normal file
50
lib/discordcr/spec/mention_spec.cr
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
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
|
||||||
63
lib/discordcr/spec/paginator_spec.cr
Normal file
63
lib/discordcr/spec/paginator_spec.cr
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
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
|
||||||
10
lib/discordcr/spec/rest_spec.cr
Normal file
10
lib/discordcr/spec/rest_spec.cr
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
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
|
||||||
57
lib/discordcr/spec/snowflake_spec.cr
Normal file
57
lib/discordcr/spec/snowflake_spec.cr
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
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
|
||||||
2
lib/discordcr/spec/spec_helper.cr
Normal file
2
lib/discordcr/spec/spec_helper.cr
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
require "spec"
|
||||||
|
require "../src/discordcr"
|
||||||
48
lib/discordcr/spec/voice_spec.cr
Normal file
48
lib/discordcr/spec/voice_spec.cr
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
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
|
||||||
6
lib/discordcr/src/discordcr.cr
Normal file
6
lib/discordcr/src/discordcr.cr
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
require "log"
|
||||||
|
require "./discordcr/*"
|
||||||
|
|
||||||
|
module Discord
|
||||||
|
Log = ::Log.for("discord")
|
||||||
|
end
|
||||||
354
lib/discordcr/src/discordcr/cache.cr
Normal file
354
lib/discordcr/src/discordcr/cache.cr
Normal file
|
|
@ -0,0 +1,354 @@
|
||||||
|
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
|
||||||
215
lib/discordcr/src/discordcr/cdn.cr
Normal file
215
lib/discordcr/src/discordcr/cdn.cr
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
# 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
|
||||||
1069
lib/discordcr/src/discordcr/client.cr
Normal file
1069
lib/discordcr/src/discordcr/client.cr
Normal file
File diff suppressed because it is too large
Load diff
150
lib/discordcr/src/discordcr/dca.cr
Normal file
150
lib/discordcr/src/discordcr/dca.cr
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
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
|
||||||
71
lib/discordcr/src/discordcr/errors.cr
Normal file
71
lib/discordcr/src/discordcr/errors.cr
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
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
|
||||||
429
lib/discordcr/src/discordcr/mappings/channel.cr
Normal file
429
lib/discordcr/src/discordcr/mappings/channel.cr
Normal file
|
|
@ -0,0 +1,429 @@
|
||||||
|
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
|
||||||
51
lib/discordcr/src/discordcr/mappings/converters.cr
Normal file
51
lib/discordcr/src/discordcr/mappings/converters.cr
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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
|
||||||
8
lib/discordcr/src/discordcr/mappings/enums.cr
Normal file
8
lib/discordcr/src/discordcr/mappings/enums.cr
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
module Discord::REST
|
||||||
|
# Enum for `parent_id` null significance in
|
||||||
|
# `REST#modify_guild_channel_positions`.
|
||||||
|
enum ChannelParent
|
||||||
|
None
|
||||||
|
Unchanged
|
||||||
|
end
|
||||||
|
end
|
||||||
480
lib/discordcr/src/discordcr/mappings/gateway.cr
Normal file
480
lib/discordcr/src/discordcr/mappings/gateway.cr
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
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
|
||||||
342
lib/discordcr/src/discordcr/mappings/guild.cr
Normal file
342
lib/discordcr/src/discordcr/mappings/guild.cr
Normal file
|
|
@ -0,0 +1,342 @@
|
||||||
|
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
|
||||||
44
lib/discordcr/src/discordcr/mappings/invite.cr
Normal file
44
lib/discordcr/src/discordcr/mappings/invite.cr
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
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
|
||||||
62
lib/discordcr/src/discordcr/mappings/oauth2.cr
Normal file
62
lib/discordcr/src/discordcr/mappings/oauth2.cr
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
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
|
||||||
51
lib/discordcr/src/discordcr/mappings/permissions.cr
Normal file
51
lib/discordcr/src/discordcr/mappings/permissions.cr
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
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
|
||||||
98
lib/discordcr/src/discordcr/mappings/rest.cr
Normal file
98
lib/discordcr/src/discordcr/mappings/rest.cr
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
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
|
||||||
113
lib/discordcr/src/discordcr/mappings/user.cr
Normal file
113
lib/discordcr/src/discordcr/mappings/user.cr
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
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
|
||||||
31
lib/discordcr/src/discordcr/mappings/voice.cr
Normal file
31
lib/discordcr/src/discordcr/mappings/voice.cr
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
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
|
||||||
107
lib/discordcr/src/discordcr/mappings/vws.cr
Normal file
107
lib/discordcr/src/discordcr/mappings/vws.cr
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
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
|
||||||
16
lib/discordcr/src/discordcr/mappings/webhook.cr
Normal file
16
lib/discordcr/src/discordcr/mappings/webhook.cr
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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
|
||||||
135
lib/discordcr/src/discordcr/mention.cr
Normal file
135
lib/discordcr/src/discordcr/mention.cr
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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
|
||||||
39
lib/discordcr/src/discordcr/paginator.cr
Normal file
39
lib/discordcr/src/discordcr/paginator.cr
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
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
|
||||||
2259
lib/discordcr/src/discordcr/rest.cr
Normal file
2259
lib/discordcr/src/discordcr/rest.cr
Normal file
File diff suppressed because it is too large
Load diff
65
lib/discordcr/src/discordcr/snowflake.cr
Normal file
65
lib/discordcr/src/discordcr/snowflake.cr
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
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
|
||||||
22
lib/discordcr/src/discordcr/sodium.cr
Normal file
22
lib/discordcr/src/discordcr/sodium.cr
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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
|
||||||
3
lib/discordcr/src/discordcr/version.cr
Normal file
3
lib/discordcr/src/discordcr/version.cr
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module Discord
|
||||||
|
VERSION = "0.4.0"
|
||||||
|
end
|
||||||
334
lib/discordcr/src/discordcr/voice.cr
Normal file
334
lib/discordcr/src/discordcr/voice.cr
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
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
|
||||||
135
lib/discordcr/src/discordcr/websocket.cr
Normal file
135
lib/discordcr/src/discordcr/websocket.cr
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
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
Normal file
184
main.cr
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
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
185
main.go
|
|
@ -1,185 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
21
reactions.cr
Normal file
21
reactions.cr
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
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
|
||||||
27
responses.cr
Normal file
27
responses.cr
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
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
|
||||||
6
shard.lock
Normal file
6
shard.lock
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
version: 2.0
|
||||||
|
shards:
|
||||||
|
discordcr:
|
||||||
|
git: https://github.com/shardlab/discordcr.git
|
||||||
|
version: 0.4.0+git.commit.0e03deb8ffa247814f2fec4e197cba7a62534f85
|
||||||
|
|
||||||
28
shard.yml
Normal file
28
shard.yml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
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
|
||||||
Loading…
Add table
Reference in a new issue