Created
September 17, 2022 17:43
-
-
Save AlperRehaYAZGAN/6c0d5eda0ad60e394679d9bc56494048 to your computer and use it in GitHub Desktop.
A simple Discord Bot written Golanng.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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