diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aae1f64 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +DISCORD_TOKEN= +OPENAI_TOKEN= diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22bc242..af30054 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -1,4 +1,4 @@ -name: Test and Build Docker Image +name: Build Docker Image on: push: @@ -7,30 +7,14 @@ on: schedule: - cron: "0 0 * * *" 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: runs-on: ubuntu-latest - needs: test permissions: contents: read packages: write env: - DISCORD_TOKEN: 0 - OPENAI_TOKEN: 0 + DISCORD_TOKEN: '0' + OPENAI_TOKEN: '0' if: github.event_name != 'pull_request' && github.event_name != 'schedule' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.gitignore b/.gitignore index 3a261ba..7b6caf3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,162 @@ -# If you prefer the allow list template instead of the deny list, see community template: -# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore -# -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions *.so -*.dylib -# Test binary, built with `go test -c` -*.test +# Distribution / packaging +.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 -*.out +# PyInstaller +# 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 -vendor/ +# Installer logs +pip-log.txt +pip-delete-this-directory.txt -# Go workspace file -go.work +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ -# Discord bot token -settings.json +# Translations +*.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 +.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/ diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index ad46259..0000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "name": "Launch Package", - "type": "go", - "request": "launch", - "mode": "auto", - "program": "${fileDirname}" - } - ] -} diff --git a/.vscode/settings.json b/.vscode/settings.json index 4ce6e1a..247d925 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,25 +1,8 @@ { - "cSpell.words": [ - "Andreas", - "anewdawn", - "bwmarrin", - "discordgo", - "Filip", - "forgefilip", - "Fredrik", - "godotenv", - "GoodAnimemes", - "joho", - "lovibot", - "openai", - "Piplup", - "plubplub", - "Ryouiki", - "sashabaranov", - "startswith", - "vartanbeno", - "waifu", - "waifus", - "Zettai" - ] + "cSpell.words": [ + "forgefilip", + "forgor", + "lovibot", + "plubplub" + ] } diff --git a/Dockerfile b/Dockerfile index bc3704c..1070576 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,44 +1,10 @@ -FROM golang:alpine +FROM python:3.12-slim -# Git is required for go mod download -RUN apk update && apk add --no-cache git ca-certificates +ENV PYTHONUNBUFFERED=1 +ENV PYTHONDONTWRITEBYTECODE=1 -ENV USER=anewdawn -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 +WORKDIR /app COPY . . +RUN pip install . -# Download dependencies -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"] +CMD ["python", "main.py"] diff --git a/go.mod b/go.mod deleted file mode 100644 index af3728a..0000000 --- a/go.mod +++ /dev/null @@ -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 -) diff --git a/go.sum b/go.sum deleted file mode 100644 index a78dd1f..0000000 --- a/go.sum +++ /dev/null @@ -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= diff --git a/main.go b/main.go deleted file mode 100644 index 0a1bf5b..0000000 --- a/main.go +++ /dev/null @@ -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]()\n{URL} - randInt := rand.Intn(len(posts)) - discordMessage := fmt.Sprintf("[%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.") -} diff --git a/main.py b/main.py new file mode 100644 index 0000000..0651756 --- /dev/null +++ b/main.py @@ -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) diff --git a/main_test.go b/main_test.go deleted file mode 100644 index aa46291..0000000 --- a/main_test.go +++ /dev/null @@ -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) - } -} diff --git a/openai.go b/openai.go deleted file mode 100644 index 0ca1697..0000000 --- a/openai.go +++ /dev/null @@ -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 -} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d8edac --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/settings.go b/settings.go deleted file mode 100644 index c658012..0000000 --- a/settings.go +++ /dev/null @@ -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 -} diff --git a/settings.json.example b/settings.json.example deleted file mode 100644 index bc763df..0000000 --- a/settings.json.example +++ /dev/null @@ -1,4 +0,0 @@ -{ - "discord_token": "", - "openai_token": "" -} diff --git a/settings_test.go b/settings_test.go deleted file mode 100644 index 3375869..0000000 --- a/settings_test.go +++ /dev/null @@ -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()) - } -}