Rewrite bot into Python

This commit is contained in:
2024-07-20 05:13:14 +02:00
parent 03df7678c5
commit b238504a02
16 changed files with 396 additions and 856 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
DISCORD_TOKEN=
OPENAI_TOKEN=

View File

@ -1,4 +1,4 @@
name: Test and Build Docker Image name: Build Docker Image
on: on:
push: push:
@ -7,30 +7,14 @@ on:
schedule: schedule:
- cron: "0 0 * * *" - cron: "0 0 * * *"
jobs: jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
DISCORD_TOKEN: 0
OPENAI_TOKEN: 0
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "stable"
- run: go test ./... -v
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test
permissions: permissions:
contents: read contents: read
packages: write packages: write
env: env:
DISCORD_TOKEN: 0 DISCORD_TOKEN: '0'
OPENAI_TOKEN: 0 OPENAI_TOKEN: '0'
if: github.event_name != 'pull_request' && github.event_name != 'schedule' if: github.event_name != 'pull_request' && github.event_name != 'schedule'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}

172
.gitignore vendored
View File

@ -1,26 +1,162 @@
# If you prefer the allow list template instead of the deny list, see community template: # Byte-compiled / optimized / DLL files
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore __pycache__/
# *.py[cod]
# Binaries for programs and plugins *$py.class
*.exe
*.exe~ # C extensions
*.dll
*.so *.so
*.dylib
# Test binary, built with `go test -c` # Distribution / packaging
*.test .Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Output of the go coverage tool, specifically when used with LiteIDE # PyInstaller
*.out # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Dependency directories # Installer logs
vendor/ pip-log.txt
pip-delete-this-directory.txt
# Go workspace file # Unit test / coverage reports
go.work htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Discord bot token # Translations
settings.json *.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env .env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
.idea/

12
.vscode/launch.json vendored
View File

@ -1,12 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
]
}

29
.vscode/settings.json vendored
View File

@ -1,25 +1,8 @@
{ {
"cSpell.words": [ "cSpell.words": [
"Andreas", "forgefilip",
"anewdawn", "forgor",
"bwmarrin", "lovibot",
"discordgo", "plubplub"
"Filip", ]
"forgefilip",
"Fredrik",
"godotenv",
"GoodAnimemes",
"joho",
"lovibot",
"openai",
"Piplup",
"plubplub",
"Ryouiki",
"sashabaranov",
"startswith",
"vartanbeno",
"waifu",
"waifus",
"Zettai"
]
} }

View File

@ -1,44 +1,10 @@
FROM golang:alpine FROM python:3.12-slim
# Git is required for go mod download ENV PYTHONUNBUFFERED=1
RUN apk update && apk add --no-cache git ca-certificates ENV PYTHONDONTWRITEBYTECODE=1
ENV USER=anewdawn WORKDIR /app
ENV UID=10001
# Create anewdawn user
RUN adduser \
--disabled-password \
--gecos "" \
--home "/nonexistent" \
--shell "/sbin/nologin" \
--no-create-home \
--uid "${UID}" \
"${USER}"
# Set the working directory
WORKDIR /usr/src/app
# Copy the current directory contents into the container at /usr/src/app
COPY . . COPY . .
RUN pip install .
# Download dependencies CMD ["python", "main.py"]
RUN go get -d -v
# Build the binary
RUN GOOS=linux GOARCH=amd64 go build -ldflags="-w -s" -o /usr/local/bin/anewdawn
FROM scratch
COPY --from=0 /etc/passwd /etc/passwd
COPY --from=0 /etc/group /etc/group
COPY --from=0 /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Copy the binary from the first stage
COPY --from=0 /usr/local/bin/anewdawn /usr/local/bin/anewdawn
# Use an unprivileged user.
USER anewdawn:anewdawn
# Command to run the executable
ENTRYPOINT ["/usr/local/bin/anewdawn"]

18
go.mod
View File

@ -1,18 +0,0 @@
module github.com/TheLovinator1/ANewDawn
go 1.22.2
require (
github.com/bwmarrin/discordgo v0.28.1
github.com/sashabaranov/go-openai v1.26.3
github.com/vartanbeno/go-reddit/v2 v2.0.1
)
require (
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
golang.org/x/crypto v0.23.0 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/oauth2 v0.20.0 // indirect
golang.org/x/sys v0.20.0 // indirect
)

62
go.sum
View File

@ -1,62 +0,0 @@
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
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/sashabaranov/go-openai v1.24.0 h1:4H4Pg8Bl2RH/YSnU8DYumZbuHnnkfioor/dtNlB20D4=
github.com/sashabaranov/go-openai v1.24.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.24.1 h1:DWK95XViNb+agQtuzsn+FyHhn3HQJ7Va8z04DQDJ1MI=
github.com/sashabaranov/go-openai v1.24.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.24.2 h1:DZxL5CGahIeRcseuJhvMSMT5SVs1urfVZG9c6/Lyn7M=
github.com/sashabaranov/go-openai v1.24.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.25.0 h1:3h3DtJ55zQJqc+BR4y/iTcPhLk4pewJpyO+MXW2RdW0=
github.com/sashabaranov/go-openai v1.25.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.26.0 h1:upM565hxdqvCxNzuAcEBZ1XsfGehH0/9kgk9rFVpDxQ=
github.com/sashabaranov/go-openai v1.26.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.26.1 h1:B5plrmc/r7hKgYX69oT2VSt5w0O6u9BJYTjB8lNCesI=
github.com/sashabaranov/go-openai v1.26.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.26.2 h1:cVlQa3gn3eYqNXRW03pPlpy6zLG52EU4g0FrWXc0EFI=
github.com/sashabaranov/go-openai v1.26.2/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/sashabaranov/go-openai v1.26.3 h1:Tjnh4rcvsSU68f66r05mys+Zou4vo4qyvkne6AIRJPI=
github.com/sashabaranov/go-openai v1.26.3/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/vartanbeno/go-reddit/v2 v2.0.1 h1:P6ITpf5YHjdy7DHZIbUIDn/iNAoGcEoDQnMa+L4vutw=
github.com/vartanbeno/go-reddit/v2 v2.0.1/go.mod h1:758/S10hwZSLm43NPtwoNQdZFSg3sjB5745Mwjb0ANI=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.20.0 h1:4mQdhULixXKP1rwYBW0vAijoXnkTG0BLCDRzfe1idMo=
golang.org/x/oauth2 v0.20.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

390
main.go
View File

@ -1,390 +0,0 @@
package main
import (
"context"
"fmt"
"log"
"math/rand"
"os"
"os/signal"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/vartanbeno/go-reddit/v2/reddit"
)
var config Config
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
func init() {
loadedConfig, err := Load()
if err != nil {
log.Fatal(err)
}
config = *loadedConfig
}
func GetPostsFromReddit(subreddit string) (string, error) {
if subreddit == "" {
return "", fmt.Errorf("subreddit cannot be empty")
}
client, err := reddit.NewReadonlyClient()
if err != nil {
log.Println("Failed to create Reddit client:", err)
return "", err
}
posts, _, err := client.Subreddit.TopPosts(context.Background(), subreddit, &reddit.ListPostOptions{
ListOptions: reddit.ListOptions{
Limit: 100,
},
Time: "all",
})
if err != nil {
return "", fmt.Errorf("failed to get posts from Reddit: %v", err)
}
// Check if the subreddit exists
if len(posts) == 0 {
return "", fmt.Errorf("subreddit '%v' does not exist", subreddit)
}
// [Title](<https://old.reddit.com{Permalink}>)\n{URL}
randInt := rand.Intn(len(posts))
discordMessage := fmt.Sprintf("[%v](<https://old.reddit.com%v>)\n%v", posts[randInt].Title, posts[randInt].Permalink, posts[randInt].URL)
return discordMessage, nil
}
func handleRedditCommand(s *discordgo.Session, i *discordgo.InteractionCreate, subreddit string) {
post, err := GetPostsFromReddit(subreddit)
if err = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: fmt.Sprintf("Cannot get a random post: %v", err),
Flags: discordgo.MessageFlagsEphemeral,
},
}); err != nil {
log.Println("Failed to respond to interaction:", err)
return
}
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: post,
},
}); err != nil {
log.Println("Failed to respond to interaction:", err)
return
}
}
var (
commands = []*discordgo.ApplicationCommand{
{
Name: "dank_memes",
Description: "Sends dank meme from /r/GoodAnimemes",
},
{
Name: "waifus",
Description: "Sends waifu from /r/WatchItForThePlot",
},
{
Name: "milkers",
Description: "Sends milkers from /r/RetrousseTits",
},
{
Name: "thighs",
Description: "Sends thighs from /r/ZettaiRyouiki",
},
}
commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
// Dank memes command
"dank_memes": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleRedditCommand(s, i, "GoodAnimemes")
},
// Waifus command
"waifus": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleRedditCommand(s, i, "WatchItForThePlot")
},
// Milkers command
"milkers": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleRedditCommand(s, i, "RetrousseTits")
},
// Thighs command
"thighs": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
handleRedditCommand(s, i, "ZettaiRyouiki")
},
// Echo command
"echo": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
// Check if the user provided a message
if len(i.ApplicationCommandData().Options) == 0 {
// If not, send an ephemeral message to the user
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You need to provide a message!",
Flags: discordgo.MessageFlagsEphemeral,
},
})
if err != nil {
log.Println("Failed to respond to interaction:", err)
return
}
}
// Check that the option is not empty
if i.ApplicationCommandData().Options[0].StringValue() == "" {
// If not, send an ephemeral message to the user
err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "The message cannot be empty!",
Flags: discordgo.MessageFlagsEphemeral,
},
})
if err != nil {
log.Println("Failed to respond to interaction:", err)
return
}
}
// Respond to the original message so we don't get "This interaction failed" error
if _, err := s.ChannelMessageSend(i.ChannelID, i.ApplicationCommandData().Options[0].StringValue()); err != nil {
log.Println("Failed to send message to channel:", err)
return
}
},
}
)
func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID {
return
}
// Don't allow DMs
if m.GuildID == "" {
return
}
allowedUsers := []string{
"thelovinator",
"killyoy",
"forgefilip",
"plubplub",
"nobot",
"kao172",
}
// Have a 1/100 chance of replying to a message if written by a user in allowedUsers
randInt := rand.Intn(100)
log.Println("Random number:", randInt)
log.Println("Mentions:", m.Mentions)
if len(m.Mentions) == 0 && randInt == 4 {
for _, user := range allowedUsers {
log.Println("User:", user)
if m.Author.Username == user {
log.Println("User is in allowedUsers")
r, err := GenerateGPT4Response(m.Content, m.Author.Username)
if err != nil {
log.Println("Failed to get OpenAI response:", err)
return
}
log.Println("OpenAI response:", r)
log.Println("Channel ID:", m.ChannelID)
_, err = s.ChannelMessageSend(m.ChannelID, r)
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
}
}
}
if m.Mentions != nil {
for _, mention := range m.Mentions {
if mention.ID == s.State.User.ID {
r, err := GenerateGPT4Response(m.Content, m.Author.Username)
if err != nil {
if strings.Contains(err.Error(), "prompt is too long") {
if _, err := s.ChannelMessageSend(m.ChannelID, "Message is too long!"); err != nil {
log.Println("Failed to send message to channel:", err)
return
}
return
}
if strings.Contains(err.Error(), "prompt is too short") {
if _, err := s.ChannelMessageSend(m.ChannelID, "Message is too short!"); err != nil {
log.Println("Failed to send message to channel:", err)
return
}
return
}
message, err := s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Brain broke :flushed: %v", err))
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
_ = message
return
}
_, err = s.ChannelMessageSend(m.ChannelID, r)
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
}
}
}
if strings.HasPrefix(strings.ToLower(m.Content), "lovibot") {
r, err := GenerateGPT4Response(m.Content, m.Author.Username)
if err != nil {
if strings.Contains(err.Error(), "prompt is too long") {
_, err := s.ChannelMessageSend(m.ChannelID, "Message is too long!")
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
return
}
if strings.Contains(err.Error(), "prompt is too short") {
_, err := s.ChannelMessageSend(m.ChannelID, "Message is too short!")
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
return
}
message, err := s.ChannelMessageSend(m.ChannelID, fmt.Sprintf("Brain broke :flushed: %v", err))
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
_ = message
return
}
_, err = s.ChannelMessageSend(m.ChannelID, r)
if err != nil {
log.Println("Failed to send message to channel:", err)
return
}
}
}
func main() {
discordToken := config.DiscordToken
// Create a new Discord session using the provided bot token.
session, err := discordgo.New("Bot " + discordToken)
if err != nil {
log.Fatalf("Cannot create a new Discord session: %v", err)
}
// Add a handler function to the discordgo.Session that is triggered when a slash command is received.
session.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok {
log.Printf("Handling '%v' command. %+v", i.ApplicationCommandData().Name, i.ApplicationCommandData())
h(s, i)
}
})
// Add a handler function to the discordgo.Session that is triggered when a message is received.
session.AddHandler(onMessageCreate)
// Print the user we are logging in as.
session.AddHandler(func(s *discordgo.Session, _ *discordgo.Ready) {
log.Printf("Logged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator)
})
// Open a websocket connection to Discord and begin listening.
err = session.Open()
if err != nil {
log.Fatalf("Cannot open the session: %v", err)
}
// Remove all existing commands.
appID := session.State.User.ID
log.Println("Removing existing commands from all servers for the bot", appID)
// Remove the commands for guild 98905546077241344
log.Println("Removing commands for Killyoy's server...")
old_commands, err := session.ApplicationCommands(appID, "98905546077241344")
if err != nil {
log.Panicf("Cannot get commands for guild 98905546077241344: %v", err)
}
for _, v := range old_commands {
err := session.ApplicationCommandDelete(session.State.User.ID, "98905546077241344", v.ID)
if err != nil {
log.Panicf("Cannot delete '%v' command: %v", v.Name, err)
}
log.Printf("Deleted '%v' command.", v.Name)
}
// Remove the commands for guild 341001473661992962
log.Println("Removing commands for TheLovinator's server...")
old_commands2, err := session.ApplicationCommands(appID, "341001473661992962")
if err != nil {
log.Panicf("Cannot get commands for guild 341001473661992962: %v", err)
}
for _, v := range old_commands2 {
err := session.ApplicationCommandDelete(session.State.User.ID, "341001473661992962", v.ID)
if err != nil {
log.Panicf("Cannot delete '%v' command: %v", v.Name, err)
}
log.Printf("Deleted '%v' command.", v.Name)
}
// Register the commands for guild 98905546077241344
log.Println("Registering commands for Killyoy's server...")
registeredCommands := make([]*discordgo.ApplicationCommand, len(commands))
for i, v := range commands {
cmd, err := session.ApplicationCommandCreate(session.State.User.ID, "98905546077241344", v)
if err != nil {
log.Panicf("Cannot create '%v' command: %v", v.Name, err)
}
registeredCommands[i] = cmd
log.Printf("Registered '%v' command.", cmd.Name)
}
// Register the commands for guild 341001473661992962
log.Println("Registering commands for TheLovinator's server...")
registeredCommands2 := make([]*discordgo.ApplicationCommand, len(commands))
for i, v := range commands {
cmd, err := session.ApplicationCommandCreate(session.State.User.ID, "341001473661992962", v)
if err != nil {
log.Panicf("Cannot create '%v' command: %v", v.Name, err)
}
registeredCommands2[i] = cmd
log.Printf("Registered '%v' command.", cmd.Name)
}
// Run s.Close() when the program exits.
defer session.Close()
// Wait here until CTRL-C or other term signal is received.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
log.Println("Press Ctrl+C to exit")
<-stop
// Bye bye!
log.Println("Gracefully shutting down.")
}

174
main.py Normal file
View File

@ -0,0 +1,174 @@
from __future__ import annotations
import logging
import os
import sys
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from openai import OpenAI
if TYPE_CHECKING:
from openai.types.chat.chat_completion import ChatCompletion
from dotenv import load_dotenv
logger: logging.Logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Load the environment variables from the .env file
load_dotenv(verbose=True)
# Get the Discord token and OpenAI API key from the environment variables
discord_token: str | None = os.getenv("DISCORD_TOKEN")
openai_api_key: str | None = os.getenv("OPENAI_TOKEN")
if not discord_token or not openai_api_key:
logger.error("You haven't configured the bot correctly. Please set the environment variables.")
sys.exit(1)
# Use OpenAI for chatting with the bot
openai_client = OpenAI(api_key=openai_api_key)
# Create a bot with the necessary intents
# TODO(TheLovinator): We should only enable the intents we need # noqa: TD003
intents: discord.Intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)
@bot.event
async def on_ready() -> None: # noqa: RUF029
"""Print a message when the bot is ready."""
logger.info("Logged on as %s", bot.user)
def chat(msg: str) -> str | None:
"""Chat with the bot using the OpenAI API.
Args:
msg: The message to send to the bot.
Returns:
The response from the bot.
"""
completion: ChatCompletion = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are a chatbot. Use Markdown to format your messages if you want.",
},
{"role": "user", "content": msg},
],
)
response: str | None = completion.choices[0].message.content
logger.info("AI response: %s from message: %s", response, msg)
return response
def get_allowed_users() -> list[str]:
"""Get the list of allowed users to interact with the bot.
Returns:
The list of allowed users.
"""
return [
"thelovinator",
"killyoy",
"forgefilip",
"plubplub",
"nobot",
"kao172",
]
def remove_mentions(message_content: str) -> str:
"""Remove mentions of the bot from the message content.
Args:
message_content: The message content to process.
Returns:
The message content without the mentions of the bot.
"""
message_content = message_content.removeprefix("lovibot").strip()
message_content = message_content.removeprefix(",").strip()
if bot.user:
message_content = message_content.replace(f"<@!{bot.user.id}>", "").strip()
message_content = message_content.replace(f"<@{bot.user.id}>", "").strip()
return message_content
@bot.event
async def on_message(message: discord.Message) -> None:
"""Respond to a message."""
logger.info("Message received: %s", message.content)
message_content: str = message.content.lower()
# Ignore messages from the bot itself to prevent an infinite loop
if message.author == bot.user:
return
# Only allow certain users to interact with the bot
allowed_users: list[str] = get_allowed_users()
if message.author.name not in allowed_users:
logger.info("Ignoring message from: %s", message.author.name)
return
# Check if the message mentions the bot or starts with the bot's name
things_to_notify_on: list[str] = ["lovibot"]
if bot.user:
things_to_notify_on.extend((f"<@!{bot.user.id}>", f"<@{bot.user.id}>"))
# Only respond to messages that mention the bot or are a reply to a bot message
if any(thing.lower() in message_content for thing in things_to_notify_on) or message.reference:
if message.reference:
# Get the message that the current message is replying to
message_id: int | None = message.reference.message_id
if message_id is None:
return
try:
reply_message: discord.Message | None = await message.channel.fetch_message(message_id)
except discord.errors.NotFound:
return
# Get the message content and author
reply_content: str = reply_message.content
reply_author: str = reply_message.author.name
# Add the reply message to the current message
message.content = f"{reply_author}: {reply_content}\n{message.author.name}: {message.content}"
# Remove the mention of the bot from the message
message_content = remove_mentions(message_content)
# Grab 10 messages before the current one to provide context
old_messages: list[str] = [
f"{old_message.author.name}: {old_message.content}"
async for old_message in message.channel.history(limit=10)
]
old_messages.reverse()
# Get the response from OpenAI
response: str | None = chat("\n".join(old_messages) + "\n" + f"{message.author.name}: {message.content}")
# Remove LoviBot: from the response
if response:
response = response.removeprefix("LoviBot:").strip()
if response:
logger.info("Responding to message: %s with: %s", message.content, response)
await message.channel.send(response)
else:
logger.warning("No response from the AI model. Message: %s", message.content)
await message.channel.send("I forgor how to think 💀")
if __name__ == "__main__":
logger.info("Starting the bot.")
bot.run(token=discord_token, root_logger=True)

View File

@ -1,40 +0,0 @@
package main
import (
"os"
"testing"
)
// Returns a string with a length between 1 and 2000 characters
func TestGetPostsFromReddit_ReturnsPostWithValidLength(t *testing.T) {
if os.Getenv("CI") == "" {
subreddit := "celebs"
post, err := GetPostsFromReddit(subreddit)
if err != nil {
t.Errorf("Unexpected error: %v", err)
}
if len(post) < 1 || len(post) > 2000 {
t.Errorf("Post length is not within the valid range")
}
} else {
t.Skip("Skipping test in CI environment as the IP is probably blocked by Reddit")
}
}
// Returns an error when the subreddit does not exist
func TestGetPostsFromReddit_ReturnsErrorWhenSubredditDoesNotExist(t *testing.T) {
subreddit := "nonexistent"
_, err := GetPostsFromReddit(subreddit)
if err == nil {
t.Errorf("Expected error, but got nil")
}
}
// Returns an error when the subreddit is empty
func TestGetPostsFromReddit_ReturnsErrorWhenSubredditIsEmpty(t *testing.T) {
subreddit := ""
_, err := GetPostsFromReddit(subreddit)
if err.Error() != "subreddit cannot be empty" {
t.Errorf("Expected error 'subreddit cannot be empty', but got '%v'", err)
}
}

View File

@ -1,89 +0,0 @@
package main
import (
"context"
"fmt"
openai "github.com/sashabaranov/go-openai"
)
/*
Prompt is the Discord message that the user sent to the bot.
*/
func GenerateGPT4Response(prompt string, author string) (string, error) {
openAIToken := config.OpenAIToken
if openAIToken == "" {
return "", fmt.Errorf("OPENAI_API_KEY is not set")
}
// Print the prompt
fmt.Println("Prompt:", author, ":", prompt)
// Check if the prompt is too long
if len(prompt) > 2048 {
return "", fmt.Errorf("prompt is too long")
}
// Add additional information to the system message
var additionalInfo string
switch author {
case "thelovinator":
additionalInfo = "User (TheLovinator) is a programmer. Wants to live in the woods. Real name is Joakim. He made the bot."
case "killyoy":
additionalInfo = "User (KillYoy) likes to play video games. Real name is Andreas. Good at CSS."
case "forgefilip":
additionalInfo = "User (ForgeFilip) likes watches. Real name is Filip."
case "plubplub":
additionalInfo = "User (Piplup) likes to play WoW and Path of Exile. Real name is Axel. Is also called Bambi."
case "nobot":
additionalInfo = "User (Nobot) likes to play WoW. Real name is Gustav. Really good at programming."
case "kao172":
additionalInfo = "User (kao172) likes cars. Real name is Fredrik."
}
// Create a new client
client := openai.NewClient(openAIToken)
// System message
var systemMessage string
systemMessage = `You are in a Discord server.
You are Swedish.
Use Markdown for formatting.
Please respond with a short message.
`
// Add additional information to the system message
if additionalInfo != "" {
systemMessage = fmt.Sprintf("%s\n%s", systemMessage, additionalInfo)
}
// Print the system message
fmt.Println("System message:", systemMessage)
// Create a completion
resp, err := client.CreateChatCompletion(
context.Background(),
openai.ChatCompletionRequest{
Model: openai.GPT4o,
Messages: []openai.ChatCompletionMessage{
{
Role: openai.ChatMessageRoleSystem,
Content: systemMessage,
},
{
Role: openai.ChatMessageRoleUser,
Content: prompt,
},
},
},
)
if err != nil {
return "", fmt.Errorf("failed to get response from OpenAI: %w", err)
}
ourResponse := resp.Choices[0].Message.Content
fmt.Println("Response:", ourResponse)
return ourResponse, nil
}

51
pyproject.toml Normal file
View File

@ -0,0 +1,51 @@
[project]
name = "anewdawn"
version = "0.1.0"
description = "My shit bot"
dependencies = ["discord.py", "openai", "python-dotenv"]
requires-python = ">=3.12"
[tool.ruff]
# https://docs.astral.sh/ruff/
line-length = 120
fix = true
unsafe-fixes = true
extend-exclude = [".venv"]
show-fixes = true
[tool.ruff.lint]
# https://docs.astral.sh/ruff/linter/
preview = true
select = ["ALL"]
ignore = [
"CPY001", # Checks for the absence of copyright notices within Python files.
"D100", # Checks for undocumented public module definitions.
"FIX002", # Checks for "TODO" comments.
"D104", # Checks for undocumented public package definitions.
# https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
"W191",
"E111",
"E114",
"E117",
"D206",
"D300",
"Q000",
"Q001",
"Q002",
"Q003",
"COM812",
"COM819",
"ISC001",
"ISC002",
]
pydocstyle.convention = "google"
isort.required-imports = ["from __future__ import annotations"]
pycodestyle.ignore-overlong-task-comments = true
[tool.ruff.format]
# https://docs.astral.sh/ruff/formatter/
docstring-code-format = true
docstring-code-line-length = 20
[tool.ruff.lint.per-file-ignores]
"tests/**/*.py" = ["S101", "ARG", "FBT"]

View File

@ -1,66 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"os"
)
// Config holds the configuration parameters
type Config struct {
DiscordToken string `json:"discord_token"`
OpenAIToken string `json:"openai_token"`
}
// Load reads configuration from settings.json or environment variables
func Load() (*Config, error) {
// Try reading from settings.json file first
config, err := loadFromJSONFile("settings.json")
if err != nil {
// If reading from file fails, try reading from environment variables
config, err = loadFromEnvironment()
if err != nil {
return nil, err
}
}
return config, nil
}
// loadFromJSONFile reads configuration from a JSON file
func loadFromJSONFile(filename string) (*Config, error) {
file, err := os.Open(filename)
if err != nil {
return nil, fmt.Errorf("failed to open settings file: %v", err)
}
defer file.Close()
config := &Config{}
decoder := json.NewDecoder(file)
err = decoder.Decode(config)
if err != nil {
return nil, fmt.Errorf("failed to decode settings file: %v", err)
}
return config, nil
}
// loadFromEnvironment reads configuration from environment variables
func loadFromEnvironment() (*Config, error) {
discordToken := os.Getenv("DISCORD_TOKEN")
if discordToken == "" {
return nil, fmt.Errorf("DISCORD_TOKEN environment variable not set or empty. Also tried reading from settings.json file")
}
openAIToken := os.Getenv("OPENAI_TOKEN")
if openAIToken == "" {
return nil, fmt.Errorf("OPENAI_TOKEN environment variable not set or empty. Also tried reading from settings.json file")
}
config := &Config{
DiscordToken: discordToken,
OpenAIToken: openAIToken,
}
return config, nil
}

View File

@ -1,4 +0,0 @@
{
"discord_token": "",
"openai_token": ""
}

View File

@ -1,75 +0,0 @@
package main
import (
"os"
"reflect"
"testing"
)
// Returns a Config object with DiscordToken set when DISCORD_TOKEN environment variable is set
func TestLoadFromEnvironment_DiscordTokenSet(t *testing.T) {
os.Setenv("DISCORD_TOKEN", "test_token")
os.Setenv("OPENAI_TOKEN", "test_token2")
defer os.Unsetenv("DISCORD_TOKEN")
defer os.Unsetenv("OPENAI_TOKEN")
config, err := loadFromEnvironment()
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
expected := &Config{
DiscordToken: "test_token",
OpenAIToken: "test_token2",
}
if !reflect.DeepEqual(config, expected) {
t.Errorf("Expected config to be %v, but got %v", expected, config)
}
}
// Returns an error when DISCORD_TOKEN environment variable is empty
func TestLoadFromEnvironment_EmptyDiscordToken(t *testing.T) {
os.Setenv("DISCORD_TOKEN", "")
defer os.Unsetenv("DISCORD_TOKEN")
_, err := loadFromEnvironment()
if err == nil {
t.Error("Expected an error, but got nil")
}
expected := "DISCORD_TOKEN environment variable not set or empty. Also tried reading from settings.json file"
if err.Error() != expected {
t.Errorf("Expected error message to be '%s', but got '%s'", expected, err.Error())
}
}
// Returns an error when DISCORD_TOKEN environment variable is not set and settings.json file is not present
func TestLoadFromEnvironment_NoDiscordTokenNoSettingsFile(t *testing.T) {
os.Unsetenv("DISCORD_TOKEN")
_, err := loadFromEnvironment()
if err == nil {
t.Error("Expected an error, but got nil")
}
expected := "DISCORD_TOKEN environment variable not set or empty. Also tried reading from settings.json file"
if err.Error() != expected {
t.Errorf("Expected error message to be '%s', but got '%s'", expected, err.Error())
}
}
// Returns an error when settings.json file is present but DiscordToken is not set
func TestLoadFromEnvironment_SettingsFileNoDiscordToken(t *testing.T) {
os.Setenv("DISCORD_TOKEN", "")
defer os.Unsetenv("DISCORD_TOKEN")
_, err := loadFromEnvironment()
if err == nil {
t.Error("Expected an error, but got nil")
}
expected := "DISCORD_TOKEN environment variable not set or empty. Also tried reading from settings.json file"
if err.Error() != expected {
t.Errorf("Expected error message to be '%s', but got '%s'", expected, err.Error())
}
}