Skip to content

Instantly share code, notes, and snippets.

@AlperRehaYAZGAN
Created September 17, 2022 17:43
Show Gist options
  • Save AlperRehaYAZGAN/6c0d5eda0ad60e394679d9bc56494048 to your computer and use it in GitHub Desktop.
Save AlperRehaYAZGAN/6c0d5eda0ad60e394679d9bc56494048 to your computer and use it in GitHub Desktop.
A simple Discord Bot written Golanng.
/**
* Author: Alper Reha Yazgan
* Date: 2021-12-18
* Description: ShakeShake Discord Bot
*/
package main
// @host localhost:9090
// @BasePath /v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @securityDefinitions.basic BasicAuth
// @in header
// @name Authentication
// @title Alya API Discord Bot
// @version 1.0
// @description This is a sample Discord bot.
// @contact.name Alya API Support
// @contact.url https://alperreha.yazgan.xyz/
// @contact.email [email protected]
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
import (
// system packages
"bytes"
"fmt"
"log"
"mime/multipart"
"net/http"
"os"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
"time"
// 3th party packages
"github.com/AlperRehaYAZGAN/discord-bot-shakeshake/docs"
osstatus "github.com/fukata/golang-stats-api-handler"
"github.com/joho/godotenv"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
// --- web server packages
"github.com/gin-gonic/gin"
// --- page cacher
"github.com/gin-contrib/cache"
"github.com/gin-contrib/cache/persistence"
// database packages
// "gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
// 3th party dependencies
"github.com/bwmarrin/discordgo"
// --- minio dependencies
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/s3"
)
// appCache pool variable
var appCacheStore *persistence.InMemoryStore
// DiscordGo Pool Variable
var dgSess *discordgo.Session
// Database Pool Variable
var db *gorm.DB
// s3 session variable
var s3Session *session.Session
var s3Public string
var s3Endpoint string
type S3Config struct {
Region string
Bucket string
AccessKey string
SecretKey string
Endpoint string
}
func OpenS3Session(s3Config *S3Config) {
var sessErr error
creds := credentials.NewStaticCredentials(s3Config.AccessKey, s3Config.SecretKey, "")
s3Session, sessErr = session.NewSession(&aws.Config{
Region: aws.String(s3Config.Region),
Endpoint: aws.String(s3Config.Endpoint),
Credentials: creds,
S3ForcePathStyle: aws.Bool(true),
})
if sessErr != nil {
log.Fatal("Fatal error happened while initial connection Minio »", sessErr)
}
}
func InitDiscordConnection(token string) {
var discordErr error
dgSess, discordErr = discordgo.New("Bot " + token)
if discordErr != nil {
log.Fatal("Error creating initial Discord session: ", discordErr)
}
}
func InitDbConnection(dbConnString string) {
var dbErr error
db, dbErr = gorm.Open(sqlite.Open(dbConnString), &gorm.Config{})
if dbErr != nil {
log.Panic("Fatal error happened while initial connection Database » ", dbErr)
}
}
// Resource model for Gorm
type Resource struct {
gorm.Model
Key string `gorm:"column:key;size:32;not null" json:"key" binding:"required,min=1,max=255"`
URL string `gorm:"column:url;size:255;not null" json:"url" binding:"required,min=1,max=255"`
RealName string `gorm:"column:real_name;size:255;not null" json:"real_name" binding:"required,min=1,max=255"`
Title string `gorm:"column:title;size:255;not null" json:"title" binding:"required,min=1,max=255"`
Description string `gorm:"column:description;size:255;not null" json:"description" binding:"required,min=1,max=255"`
}
// CreateResourceDto
type CreateResourceDto struct {
Upload *multipart.FileHeader `form:"upload" binding:"required"`
ID string `form:"id" binding:"required,min=1"`
}
// init database migrations if not exist
func InitDbMigrations() {
db.AutoMigrate(&Resource{})
}
/**
* APP VERSION
*/
// app start time
var startTime = time.Now()
var appVersion = "1.0.0" // -> this will auto update when load from .env
func main() {
// current directory
dir, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
// load .env file from path.join (process.cwd() + .env)
err = godotenv.Load(dir + "/.env")
if err != nil {
// not found .env file. Log print not fatal
log.Print("Error loading .env file ENV variables using if exist instead. ", err)
}
// get env variables
discordToken := os.Getenv("DISCORD_TOKEN")
s3_region := os.Getenv("S3_REGION")
s3_bucket := os.Getenv("S3_BUCKET")
s3_access_key := os.Getenv("S3_ACCESS_KEY")
s3_secret_key := os.Getenv("S3_SECRET_KEY")
s3Endpoint = os.Getenv("S3_ENDPOINT")
s3Public = os.Getenv("S3_BUCKET")
dbConnectionString := os.Getenv("DBCONNSTR")
// init discord connection
if discordToken == "" {
log.Fatal("Error loading Discord token from .env file")
}
InitDiscordConnection(discordToken)
// init s3 session
OpenS3Session(&S3Config{
Region: s3_region,
Bucket: s3_bucket,
AccessKey: s3_access_key,
SecretKey: s3_secret_key,
Endpoint: s3Endpoint,
})
// get db connection string
if dbConnectionString == "" {
log.Fatal("DB_CONN_STRING is not defined in .env file")
}
// init database connection and pool settings
InitDbConnection(dbConnectionString)
dbConn, err := db.DB()
if err != nil {
log.Println("Error initial connection to database")
log.Fatal(err)
}
dbConn.SetMaxOpenConns(10)
dbConn.SetMaxIdleConns(5)
dbConn.SetConnMaxLifetime(time.Minute * 5)
// init database migrations
InitDbMigrations()
// create new gin app
r := gin.Default()
/**
* Kernel Status and Memory Info Endpoint
* (Docs: https://github.com/appleboy/gin-status-api)
*/
// get basic auth credentials from .env file like APP_STAT_AUTH=admin:password
auth := os.Getenv("APP_STAT_AUTH")
var statUsername string
var statPassword string
if auth != "" {
authUser := strings.Split(auth, ":")
statUsername = authUser[0]
statPassword = authUser[1]
// if no username or password exit
if statUsername == "" || statPassword == "" {
log.Fatal("Error loading APP_STAT_AUTH from .env file")
}
}
/**
* ALL APP ENDPOINTS
*/
// create memory store for caching (Look to /cache_health)
appCacheStore = persistence.NewInMemoryStore(time.Second)
// API Endpoints
docs.SwaggerInfo.BasePath = "/v1"
version := r.Group("/v1")
{
api := version.Group("/api")
{
/**
* --------------- APP ROUTES ---------------
*/
api.GET("/items", GetItemsHandler)
// api.GET("/items/:id", GetItemByKeyHandler)
api.POST("/upload", CreateItemHandler)
/**
* --------------- HEALTH ROUTES ---------------
*/
status := api.Group("/_")
{
// if mode is production disable swagger
status.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
status.GET("/app_kernel_stats", AppKernelStatsHandler)
status.GET("/health", gin.BasicAuth(gin.Accounts{statUsername: statPassword}), AppHealthCheckHandler)
status.GET("/cache_health", cache.CachePage(appCacheStore, time.Minute, AppHealthCheckHandler))
}
}
}
// Start Discord Websocket Connection
// Register the messageCreate func as a callback for MessageCreate events.
dgSess.AddHandler(MessageCreateHandler)
// This bot only cares about receiving message events.
dgSess.Identify.Intents = discordgo.IntentsGuildMessages
// Listen and serve on
err = dgSess.Open()
if err != nil {
log.Fatal("Error opening Discord websocket session: ", err)
}
defer dgSess.Close()
fmt.Println("Bot is now running. Press CTRL-C to exit.")
// Wait here until CTRL-C or other term signal is received.
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc
// Start HTTP Server on port APP_PORT
APP_PORT := os.Getenv("APP_PORT")
if APP_PORT == "" {
APP_PORT = "9090"
}
if err := r.Run(":" + APP_PORT); err != nil {
log.Fatal(err)
}
}
// AppHealtCheckHandler godoc
// @Summary Returns container kernel info
// @Schemes
// @Description Returns container kernel info
// @Tags api-service-health
// @Security BasicAuth
// @Accept */*
// @Produce json
// @Success 200 {object} object
// @Router /api/_/app_kernel_stats [get]
func AppKernelStatsHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, osstatus.GetStats())
}
// AppHealtCheckHandler godoc
// @Summary is a simple health check endpoint
// @Schemes
// @Description Checks if app is running and returns container info
// @Tags api-service-health
// @Security BasicAuth
// @Accept */*
// @Produce json
// @Success 200 {object} object
// @Router /api/_/health [get]
// @Router /api/_/cache_health [get]
func AppHealthCheckHandler(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{
"status": true,
"uptime": time.Since(startTime).String(),
"version": appVersion,
})
}
// GetItemsHandler godoc
// @Summary Get Items from database
// @Schemes
// @Description Get Items with limit and page
// @Tags api-service
// @Param limit query int false "limit"
// @Param page query int false "page"
// @Accept application/json
// @Produce json
// @Success 200 {object} object
// @Failure 500 {object} object
// @Router /api/items [get]
func GetItemsHandler(ctx *gin.Context) {
// get pagination params page should be 1<=page<100 and limit should be 1<=limit<50
limitQ := ctx.DefaultQuery("limit", "10")
if limitQ == "" || limitQ < "1" || limitQ > "100" {
limitQ = "10"
}
pageQ := ctx.DefaultQuery("page", "1")
if pageQ == "" || pageQ < "1" || pageQ > "100" {
pageQ = "1"
}
// cast to int
limit, _ := strconv.Atoi(limitQ)
page, _ := strconv.Atoi(pageQ)
offset := (page - 1) * limit
// get all posts by limit and offset
var items []Resource
db.Limit(limit).Offset(offset).Find(&items)
// return posts
ctx.JSON(http.StatusOK, gin.H{
"items": items,
})
}
// UploadFileHandler godoc
// @Summary Upload File by upload key
// @Schemes
// @Description Upload File by upload key
// @Tags api-service
// @Security BasicAuth
// Body CreateItemDto multipart/form-data
// @Produce json
// @Success 200 {object} object
// @Failure 400 {object} object
// @Failure 401 {object} object
// @Failure 422 {object} object
// @Router /upload/ [post]
func CreateItemHandler(ctx *gin.Context) {
// 1 - read file from request
fileHeader, err := ctx.FormFile("upload")
file, err := fileHeader.Open()
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{
"type": "file-open",
"message": "Ensure validate file.",
"error": err.Error(),
})
return
}
var fileSize int64 = fileHeader.Size
var fileName string = fileHeader.Filename
// 3 - create file buffer and upload file to minio(s3)
buffer := make([]byte, fileSize)
file.Read(buffer)
_, err = s3.New(s3Session).PutObject(&s3.PutObjectInput{
Bucket: aws.String(s3Public),
Key: aws.String(fileName),
Body: bytes.NewReader(buffer),
ContentLength: aws.Int64(fileSize),
ContentType: aws.String(http.DetectContentType(buffer)),
ContentDisposition: aws.String("attachment"),
})
if err != nil {
log.Println(err)
ctx.JSON(http.StatusInternalServerError, gin.H{
"type": "file-upload-cdn",
"message": "Error uploading file to CDN",
"error": err.Error(),
})
return
}
imageRealUrl := s3Endpoint + "/" + s3Public + "/" + fileName
var item = Resource{
RealName: fileName,
URL: imageRealUrl,
Title: fileName,
}
db.Create(&item)
if item.ID == 0 {
ctx.JSON(http.StatusInternalServerError, gin.H{
"type": "database-error",
"message": "Error creating item on database",
"error": "Check stdout for more details",
})
return
}
// 6 - return response
ctx.JSON(http.StatusOK, gin.H{
"type": "upload-image",
"message": "File uploaded successfully " + fileName,
"item": item,
})
}
func MessageCreateHandler(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID {
return
}
if m.Content == "ping" {
s.ChannelMessageSend(m.ChannelID, "pong")
}
// if is m.Content chars between "!000" and "!999"
// find regex startwith! and number between 000 and 777 : "^![0-9]{3}$"
// check m.Content with regex
if !regexp.MustCompile("^![0-9]{3}$").MatchString(m.Content) {
// get item by id from database. Clean ! symbol
var resource Resource
db.Where("key = ?", strings.TrimPrefix(m.Content, "!")).First(&resource)
if resource.ID == 0 {
s.ChannelMessageSend(m.ChannelID, "Item not found")
return
}
// get file from minio
file, err := s3.New(s3Session).GetObject(&s3.GetObjectInput{
Bucket: aws.String(s3Public),
Key: aws.String(resource.RealName),
})
if err != nil {
s.ChannelMessageSend(m.ChannelID, "Error getting file from CDN")
return
}
// send file to discord
s.ChannelFileSend(m.ChannelID, resource.RealName, file.Body)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment