Compare commits
No commits in common. "f14948e4974f25bb72f469caaba66e67bd196948" and "cfd2e0dadbac927643a96cff73b9f3f125bfb607" have entirely different histories.
f14948e497
...
cfd2e0dadb
14
.env.example
14
.env.example
@ -1,14 +0,0 @@
|
|||||||
# The Discord bot's authentication token
|
|
||||||
DISCORD_TOKEN=<discord-token>
|
|
||||||
|
|
||||||
# LastFM API key for looking up artist and song info
|
|
||||||
LASTFM_API_KEY=<lastfm-key>
|
|
||||||
|
|
||||||
# OpenAI API key for chatbot functionality
|
|
||||||
OPENAI_API_KEY=<openai-key>
|
|
||||||
# Prompt used before each user chat prompt. Set to empty string to disable the
|
|
||||||
# chatbot functionality.
|
|
||||||
CHATBOT_PROMPT="You are a friendly Discord chatbot."
|
|
||||||
|
|
||||||
# Database path for user activity tracking
|
|
||||||
DB_PATH="./activities.db"
|
|
||||||
160
.gitignore
vendored
160
.gitignore
vendored
@ -1,160 +0,0 @@
|
|||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# 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/#use-with-ide
|
|
||||||
.pdm.toml
|
|
||||||
|
|
||||||
# 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/
|
|
||||||
13
Dockerfile
13
Dockerfile
@ -1,13 +0,0 @@
|
|||||||
FROM python:3.8-slim-buster
|
|
||||||
|
|
||||||
COPY . /app
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN pip3 install -r requirements.txt
|
|
||||||
RUN python3 -m pip install -U discord.py[voice]
|
|
||||||
|
|
||||||
RUN apt -y update
|
|
||||||
RUN apt-get -y upgrade
|
|
||||||
RUN apt-get install -y ffmpeg
|
|
||||||
|
|
||||||
CMD python3 boywife_bot.py
|
|
||||||
79
__main__.py
79
__main__.py
@ -1,79 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
"""
|
|
||||||
BoywifeBot - A Discord bot for the Gayming Group Discord server.
|
|
||||||
|
|
||||||
This program provides a bot that plays music in a voice chat and fulfills other
|
|
||||||
commands in text channels.
|
|
||||||
|
|
||||||
Author: Jared Kick <jaredkick@gmail.com>
|
|
||||||
Version: 0.1.0
|
|
||||||
|
|
||||||
For detailed documentation, please refer to:
|
|
||||||
<url>
|
|
||||||
Source Code:
|
|
||||||
https://github.com/jtkick/boywife-bot
|
|
||||||
"""
|
|
||||||
|
|
||||||
PROJECT_VERSION = "0.1.0"
|
|
||||||
|
|
||||||
# Standard imports
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
# Third-part imports
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
# Project imports
|
|
||||||
import database
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# Create custom logging handler
|
|
||||||
console_handler = logging.StreamHandler(sys.stdout)
|
|
||||||
console_formatter = logging.Formatter(
|
|
||||||
"[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s")
|
|
||||||
console_handler.setFormatter(console_formatter)
|
|
||||||
|
|
||||||
# Make sure all loggers use this handler
|
|
||||||
root_logger = logging.getLogger()
|
|
||||||
root_logger.setLevel(logging.INFO)
|
|
||||||
root_logger.addHandler(console_handler)
|
|
||||||
|
|
||||||
# Get bot logger
|
|
||||||
logger = logging.getLogger("boywife-bot")
|
|
||||||
|
|
||||||
# Load credentials
|
|
||||||
load_dotenv()
|
|
||||||
TOKEN = os.getenv('DISCORD_TOKEN')
|
|
||||||
|
|
||||||
# Create custom bot with database connection
|
|
||||||
class BoywifeBot(commands.Bot):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.db = database.Database("boywife_bot.db")
|
|
||||||
self.ai = OpenAI()
|
|
||||||
client = BoywifeBot(
|
|
||||||
command_prefix = '!',
|
|
||||||
intents=discord.Intents.all(),
|
|
||||||
log_hander=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load all bot cogs in directory
|
|
||||||
# You need to import os for this method
|
|
||||||
@client.event
|
|
||||||
async def on_ready():
|
|
||||||
logger.info("%s is now running", client.user)
|
|
||||||
# Load cogs
|
|
||||||
for filename in os.listdir('./cogs'):
|
|
||||||
if filename.endswith('.py'):
|
|
||||||
await client.load_extension(f'cogs.{filename[:-3]}')
|
|
||||||
logger.info("Loaded %s cog", filename)
|
|
||||||
|
|
||||||
client.run(TOKEN, log_handler=None)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
BIN
assets/dj.png
BIN
assets/dj.png
Binary file not shown.
|
Before Width: | Height: | Size: 7.2 KiB |
BIN
assets/pause.png
BIN
assets/pause.png
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
BIN
assets/play.png
BIN
assets/play.png
Binary file not shown.
|
Before Width: | Height: | Size: 4.4 KiB |
BIN
assets/skip.png
BIN
assets/skip.png
Binary file not shown.
|
Before Width: | Height: | Size: 6.6 KiB |
BIN
boywife.png
BIN
boywife.png
Binary file not shown.
|
Before Width: | Height: | Size: 101 KiB |
@ -1,132 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import sqlite3
|
|
||||||
import typing
|
|
||||||
|
|
||||||
class Activities(commands.Cog):
|
|
||||||
"""A cog to track and gather statistics on user activities."""
|
|
||||||
|
|
||||||
__slots__ = ("nerd", "nerds", "fword", "fwords")
|
|
||||||
|
|
||||||
def __init__(self, bot):
|
|
||||||
self.bot = bot
|
|
||||||
self.logger = logging.getLogger("activities")
|
|
||||||
|
|
||||||
async def __local_check(self, ctx):
|
|
||||||
"""A local check which applies to all commands in this cog."""
|
|
||||||
if not ctx.guild:
|
|
||||||
raise commands.NoPrivateMessage
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def __error(self, ctx, error):
|
|
||||||
"""A local error handler for all errors arising from commands in this cog."""
|
|
||||||
if isinstance(error, commands.NoPrivateMessage):
|
|
||||||
try:
|
|
||||||
return await ctx.send(
|
|
||||||
'This command can not be used in private messages.')
|
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr)
|
|
||||||
traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
|
|
||||||
|
|
||||||
@commands.Cog.listener()
|
|
||||||
async def on_presence_update(
|
|
||||||
self,
|
|
||||||
before: discord.Member,
|
|
||||||
after: discord.Member):
|
|
||||||
# Log the activity or status change
|
|
||||||
if after.activity:
|
|
||||||
self.logger.info(
|
|
||||||
f"User '{before.name}' changed activity to "\
|
|
||||||
f"'{after.activity.name}'")
|
|
||||||
else:
|
|
||||||
self.logger.info(
|
|
||||||
f"User '{before.name}' changed status to '{after.status}'")
|
|
||||||
self.bot.db.insert_activity_change(before, after)
|
|
||||||
|
|
||||||
@commands.command(name='nerd', aliases=['nerdscale'],
|
|
||||||
description="Find how nerdy a user is.")
|
|
||||||
async def nerd_(self, ctx, member: typing.Union[discord.Member, int, None]):
|
|
||||||
"""Clowns on users who play League of Legends.
|
|
||||||
|
|
||||||
This command receives a user, computes the amount of time they have
|
|
||||||
spent playing League of Legends, and will make fun of them if they have
|
|
||||||
any time in it at all. It optionally takes no argument, and will find
|
|
||||||
the user in the guild with the most time in League of Legends and call
|
|
||||||
them out.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
member (discord.Member, int, None, optional): The member to check
|
|
||||||
for League stats.
|
|
||||||
"""
|
|
||||||
# If member is not defined, find the user with the most time
|
|
||||||
if not member:
|
|
||||||
members = [member.id for member in ctx.guild.members]
|
|
||||||
else:
|
|
||||||
if isinstance(member, discord.Member):
|
|
||||||
members = [member.id]
|
|
||||||
else:
|
|
||||||
members = [member]
|
|
||||||
|
|
||||||
# Get League stats for every member in guild
|
|
||||||
league_stats = {}
|
|
||||||
for m in members:
|
|
||||||
league_stats[m] = datetime.timedelta()
|
|
||||||
stats = self.db.get_activity_stats(m)
|
|
||||||
sus = True if stats == {} else False
|
|
||||||
for key, value in stats.items():
|
|
||||||
if 'leagueoflegends' in key.lower().strip().replace(' ', ''):
|
|
||||||
league_stats[m] += value
|
|
||||||
|
|
||||||
# Sort all users by time in League
|
|
||||||
league_stats = dict(sorted(
|
|
||||||
league_stats.items(), key=lambda x: x[1],
|
|
||||||
reverse=True
|
|
||||||
))
|
|
||||||
|
|
||||||
# Get top user
|
|
||||||
user_id, time = next(iter(league_stats.items()))
|
|
||||||
time_val = None
|
|
||||||
time_units = None
|
|
||||||
if time.total_seconds() // 3600 > 0:
|
|
||||||
time_val = int(time.total_seconds() // 3600)
|
|
||||||
time_units = "hours" if time_val > 1 else "hour"
|
|
||||||
else:
|
|
||||||
time_val = int(time.total_seconds() // 60)
|
|
||||||
time_units = "minutes" if time_val > 1 else "minute"
|
|
||||||
|
|
||||||
# Send Discord message to clown on user
|
|
||||||
response = ""
|
|
||||||
if member:
|
|
||||||
if time_val != 0:
|
|
||||||
descriptor = ""
|
|
||||||
if time_units in ["hour", "hours"]:
|
|
||||||
descriptor = "a massive fucking nerd"
|
|
||||||
elif time_units in ["minutes", "minute"]:
|
|
||||||
descriptor = "a huge nerd"
|
|
||||||
else:
|
|
||||||
descriptor = "a nerd"
|
|
||||||
response = f"<@{user_id}> has played League for {time_val} "\
|
|
||||||
f"{time_units} in the past month, making them "\
|
|
||||||
f"{descriptor}."
|
|
||||||
else:
|
|
||||||
if sus:
|
|
||||||
response = f"<@{user_id}> doesn't have any activities at "\
|
|
||||||
f"all. They're definitely hiding something."
|
|
||||||
else:
|
|
||||||
response = f"<@{user_id}> doesn't have any time in "\
|
|
||||||
f"League. They're not a nerd."
|
|
||||||
else:
|
|
||||||
response = (
|
|
||||||
f"<@{user_id}> has played League for {time_val} {time_units} "\
|
|
||||||
f"in the past month, making them the biggest nerd."
|
|
||||||
)
|
|
||||||
await ctx.send(response)
|
|
||||||
|
|
||||||
async def setup(bot):
|
|
||||||
await bot.add_cog(Activities(bot))
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
from openai import OpenAI
|
|
||||||
import os
|
|
||||||
|
|
||||||
class Chatbot(commands.Cog):
|
|
||||||
"""Chat related commands."""
|
|
||||||
|
|
||||||
__slots__ = ('bot', 'players')
|
|
||||||
|
|
||||||
def __init__(self, bot, **kwargs):
|
|
||||||
self.bot = bot
|
|
||||||
self.openai_client = OpenAI()
|
|
||||||
self.players = {}
|
|
||||||
|
|
||||||
async def cleanup(self, guild):
|
|
||||||
try:
|
|
||||||
del self.players[guild.id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __local_check(self, ctx):
|
|
||||||
"""A local check which applies to all commands in this cog."""
|
|
||||||
if not ctx.guild:
|
|
||||||
raise commands.NoPrivateMessage
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def __error(self, ctx, error):
|
|
||||||
"""A local error handler for all errors arising from commands in this cog."""
|
|
||||||
if isinstance(error, commands.NoPrivateMessage):
|
|
||||||
try:
|
|
||||||
return await ctx.send('This command can not be used in Private Messages.')
|
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
|
|
||||||
print('Ignoring exception in command {}:'.format(ctx.command), file=sys.stderr)
|
|
||||||
traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
|
|
||||||
|
|
||||||
def get_player(self, ctx):
|
|
||||||
"""Retrieve the guild player, or generate one."""
|
|
||||||
try:
|
|
||||||
player = self.players[ctx.guild.id]
|
|
||||||
except KeyError:
|
|
||||||
player = MusicPlayer(ctx)
|
|
||||||
self.players[ctx.guild.id] = player
|
|
||||||
|
|
||||||
return player
|
|
||||||
|
|
||||||
def prompt(self, user_prompt: str):
|
|
||||||
|
|
||||||
setup_prompt = os.getenv('CHATBOT_PROMPT', '')
|
|
||||||
if setup_prompt == '':
|
|
||||||
return '😴'
|
|
||||||
try:
|
|
||||||
completion =\
|
|
||||||
self.openai_client.chat.completions.create(
|
|
||||||
model="gpt-4o-mini",
|
|
||||||
messages=[
|
|
||||||
{"role": "system", "content": setup_prompt},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": user_prompt
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return completion.choices[0].message.content
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
return '😴'
|
|
||||||
|
|
||||||
@commands.command(name='chat', aliases=['boywife', 'bb', 'bw', 'bot'], description="Command for chatting with chatbot.")
|
|
||||||
async def chat_(self, ctx, *text):
|
|
||||||
await ctx.send(self.prompt(' '.join(text)))
|
|
||||||
|
|
||||||
async def setup(bot):
|
|
||||||
await bot.add_cog(Chatbot(bot))
|
|
||||||
@ -1,959 +0,0 @@
|
|||||||
import ast
|
|
||||||
import atexit
|
|
||||||
import datetime
|
|
||||||
import discord
|
|
||||||
from discord.ext import commands
|
|
||||||
import enum
|
|
||||||
import random
|
|
||||||
import asyncio
|
|
||||||
import itertools
|
|
||||||
import sys
|
|
||||||
import traceback
|
|
||||||
import requests
|
|
||||||
import os
|
|
||||||
import validators
|
|
||||||
import threading
|
|
||||||
import pickle
|
|
||||||
from async_timeout import timeout
|
|
||||||
from functools import partial
|
|
||||||
import yt_dlp
|
|
||||||
from yt_dlp import YoutubeDL
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger("music_player")
|
|
||||||
|
|
||||||
# Get API key for last.fm
|
|
||||||
LASTFM_API_KEY = os.getenv("LASTFM_API_KEY")
|
|
||||||
|
|
||||||
# TEMORARY LIST OF SONGS
|
|
||||||
songs = [
|
|
||||||
"I Love It - Icona Pop",
|
|
||||||
"Vanished - Crystal Castles",
|
|
||||||
"We Like To Party - Vengaboys",
|
|
||||||
"Gimme! Gimme! Gimme! - ABBA",
|
|
||||||
"Dancing Queen - ABBA",
|
|
||||||
"I Wanna Dance With Somebody - Whitney Houston",
|
|
||||||
"Dance in the Dark - Lady Gaga",
|
|
||||||
"Telephone - Lady Gaga",
|
|
||||||
"Just Dance - Lady Gaga",
|
|
||||||
"Rewind - Charli xcx",
|
|
||||||
"Nasty - Tinashe",
|
|
||||||
"Rush - Troy Sivan",
|
|
||||||
"360 - Charli xcx",
|
|
||||||
"Talk talk - Charli xcx",
|
|
||||||
"Von dutch - Charli xcx",
|
|
||||||
"365 - Charli xcx",
|
|
||||||
"HOT TO GO! - Chappell Roan",
|
|
||||||
"Super Graphic Ultra Modern Girl - Chappell Roan",
|
|
||||||
"Womanizer - Britney Spears",
|
|
||||||
"Red Wind Supernova - Chappell Roan",
|
|
||||||
"Toxic - Britney Spears",
|
|
||||||
"We Found Love - Rihanna",
|
|
||||||
"212 - Azealia Banks",
|
|
||||||
"Bad Romance - Lady Gaga",
|
|
||||||
"Girl, so confusing - Charli xcx",
|
|
||||||
"Alter Ego - Doechii",
|
|
||||||
"Break Free - Ariana Grande",
|
|
||||||
"Raingurl - Yaeji",
|
|
||||||
"Thot Shit - Megan Thee Stallion",
|
|
||||||
"BREAK MY SOUL - Beyonce",
|
|
||||||
"She Wolf - Shakira",
|
|
||||||
"Your Love Is My Drug - Ke$ha",
|
|
||||||
"365 featuring shygirl - Charli xcx",
|
|
||||||
"Applause - Lady Gaga",
|
|
||||||
"Lay All Your Love On Me - ABBA",
|
|
||||||
"Apple - Charli xcx",
|
|
||||||
"Pump It Up - Endor",
|
|
||||||
"Everytime We Touch - Cascada",
|
|
||||||
"Fantasy - Mariah Carey",
|
|
||||||
"Water - Tyla",
|
|
||||||
"Be The One - Eli Brown",
|
|
||||||
"3 - Britney Spears",
|
|
||||||
"Guess featuring billie ellish - Charli xcx",
|
|
||||||
"Bunny Is A Rider - Doss Remix - Caroline Polachek",
|
|
||||||
"Do You Miss Me? - PinkPantheress",
|
|
||||||
"Perfect (Exceeder) - Mason",
|
|
||||||
"Better Off Alone (Laidback Luke Remix) - Alice DJ",
|
|
||||||
"Beauty And A Beat - Justin Bieber",
|
|
||||||
"Girl, so confusing - Charli xcx",
|
|
||||||
"Got Me Started - Troy Sivan",
|
|
||||||
"Gimme More - Britney Spears",
|
|
||||||
"Around the World - Daft Punk",
|
|
||||||
"Harder, Better, Faster, Stronger - Daft Punk",
|
|
||||||
"Sweet Dreams - Eurythmics",
|
|
||||||
"Dancing Elephants - DJ Minx Remix - Rochelle Jordan",
|
|
||||||
"MADELINE - INJI",
|
|
||||||
"Baddy On The Floor - Jamix xx",
|
|
||||||
"SWEET HONEY BUCKIIN' - Beyonce",
|
|
||||||
"Boots & Boys - Ke$ha",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Suppress noise about console usage from errors
|
|
||||||
# yt_dlp.utils.bug_reports_message = lambda: ""
|
|
||||||
|
|
||||||
class VoiceConnectionError(commands.CommandError):
|
|
||||||
"""Custom Exception class for connection errors."""
|
|
||||||
|
|
||||||
|
|
||||||
class InvalidVoiceChannel(VoiceConnectionError):
|
|
||||||
"""Exception for cases of invalid Voice Channels."""
|
|
||||||
|
|
||||||
|
|
||||||
class YTDLSource(discord.PCMVolumeTransformer):
|
|
||||||
|
|
||||||
_downloader = YoutubeDL({
|
|
||||||
"format": "bestaudio[ext=opus]/bestaudio", # Use OPUS for FFmpeg
|
|
||||||
"outtmpl": "downloads/%(extractor)s-%(id)s-%(title)s.%(ext)s",
|
|
||||||
"restrictfilenames": True,
|
|
||||||
"noplaylist": True,
|
|
||||||
"nocheckcertificate": True,
|
|
||||||
"ignoreerrors": False,
|
|
||||||
"logtostderr": False,
|
|
||||||
"quiet": True,
|
|
||||||
"no_warnings": True,
|
|
||||||
"default_search": "auto",
|
|
||||||
"source_address": "0.0.0.0", # ipv6 addresses cause issues sometimes
|
|
||||||
"retries": 5,
|
|
||||||
"ignoreerrors": True,
|
|
||||||
'throttled_rate': '1M',
|
|
||||||
"fragment_retries": 10, # Prevents seemingly random stream crashes
|
|
||||||
})
|
|
||||||
|
|
||||||
def __init__(self, source, *, data, requester):
|
|
||||||
super().__init__(source)
|
|
||||||
self.requester = requester
|
|
||||||
|
|
||||||
# YouTube Metadata
|
|
||||||
self.title = data.get("title")
|
|
||||||
self.web_url = data.get("webpage_url")
|
|
||||||
self.thumbnail_url = data.get("thumbnail")
|
|
||||||
self.duration = data.get("duration")
|
|
||||||
|
|
||||||
# Song metadata
|
|
||||||
self.search_term = ""
|
|
||||||
self.artist = ""
|
|
||||||
self.song_title = ""
|
|
||||||
|
|
||||||
# YTDL info dicts (data) have other useful information you might want
|
|
||||||
# https://github.com/rg3/youtube-dl/blob/master/README.md
|
|
||||||
|
|
||||||
def __getitem__(self, item: str):
|
|
||||||
"""Allows us to access attributes similar to a dict.
|
|
||||||
This is only useful when you are NOT downloading.
|
|
||||||
"""
|
|
||||||
return self.__getattribute__(item)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def create_source(
|
|
||||||
cls,
|
|
||||||
ctx,
|
|
||||||
search: str,
|
|
||||||
*,
|
|
||||||
download=False,
|
|
||||||
artist="",
|
|
||||||
song_title="",
|
|
||||||
):
|
|
||||||
loop = ctx.bot.loop if ctx else asyncio.get_event_loop()
|
|
||||||
|
|
||||||
# If we got a YouTube link, get the video title for the song search
|
|
||||||
if validators.url(search):
|
|
||||||
with YoutubeDL() as ydl:
|
|
||||||
info = ydl.extract_info(search, download=False)
|
|
||||||
search_term = info.get("title", "")
|
|
||||||
else:
|
|
||||||
search_term = search
|
|
||||||
|
|
||||||
# Get song metadata
|
|
||||||
logger.info(f"Searching LastFM for: '{search_term}'")
|
|
||||||
url = f"http://ws.audioscrobbler.com/2.0/?method=track.search&"\
|
|
||||||
f"track={search_term}&api_key={LASTFM_API_KEY}&format=json"
|
|
||||||
response = requests.get(url)
|
|
||||||
lastfm_data = response.json()
|
|
||||||
# Let's get the first result, if any
|
|
||||||
if lastfm_data['results']['trackmatches']['track']:
|
|
||||||
track = lastfm_data['results']['trackmatches']['track'][0]
|
|
||||||
artist = track['artist']
|
|
||||||
song_title = track['name']
|
|
||||||
|
|
||||||
# Adjust search term if we didn't get a URL
|
|
||||||
if not validators.url(search):
|
|
||||||
search = f"{song_title} {artist} official audio"
|
|
||||||
|
|
||||||
# Get YouTube video source
|
|
||||||
logger.info(f"Getting YouTube video: {search_term}")
|
|
||||||
to_run = partial(cls._downloader.extract_info, url=search, download=download)
|
|
||||||
data = await loop.run_in_executor(None, to_run)
|
|
||||||
|
|
||||||
# There's an error with yt-dlp that throws a 403: Forbidden error, so
|
|
||||||
# only proceed if it returns anything
|
|
||||||
if data and "entries" in data:
|
|
||||||
# take first item from a playlist
|
|
||||||
data = data["entries"][0]
|
|
||||||
|
|
||||||
# Get either source filename or URL, depending on if we're downloading
|
|
||||||
if download:
|
|
||||||
source = cls._downloader.prepare_filename(data)
|
|
||||||
else:
|
|
||||||
source = data["url"]
|
|
||||||
logger.info(f"Using source: {data["webpage_url"]}")
|
|
||||||
|
|
||||||
ffmpeg_source = cls(
|
|
||||||
discord.FFmpegPCMAudio(source, before_options="-nostdin", options="-vn"),
|
|
||||||
data=data,
|
|
||||||
requester=ctx.author if ctx else None,
|
|
||||||
)
|
|
||||||
# TODO: ADD THESE TO THE CONSTRUCTOR
|
|
||||||
ffmpeg_source.search_term = search_term
|
|
||||||
# ffmpeg_source.song_title = data["title"]
|
|
||||||
ffmpeg_source.artist = artist
|
|
||||||
ffmpeg_source.song_title = song_title
|
|
||||||
ffmpeg_source.filename = source
|
|
||||||
|
|
||||||
return ffmpeg_source
|
|
||||||
|
|
||||||
class MusicPlayer:
|
|
||||||
"""
|
|
||||||
A class used to play music in a voice channel.
|
|
||||||
|
|
||||||
This class implements a queue and play loop that plays music in a single
|
|
||||||
guild. Since each player is assigned to a single voice channel, it allows
|
|
||||||
multiple guilds to use the bot simultaneously.
|
|
||||||
|
|
||||||
Methods:
|
|
||||||
player_loop() -> None:
|
|
||||||
Provides the main loop that waits for requests and plays songs.
|
|
||||||
update_now_playing_message(repost[bool], emoji[str]) -> None:
|
|
||||||
Updates the channel message that states what song is currently
|
|
||||||
being played in the voice channel.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__slots__ = (
|
|
||||||
"bot",
|
|
||||||
"_guild",
|
|
||||||
"_channel",
|
|
||||||
"_cog",
|
|
||||||
"_np",
|
|
||||||
"_state",
|
|
||||||
"_queue",
|
|
||||||
"_next",
|
|
||||||
"_skipped",
|
|
||||||
"current",
|
|
||||||
"np",
|
|
||||||
"volume",
|
|
||||||
"dj_mode",
|
|
||||||
"_view",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Each player is assiciated with a guild, so create a lock for when we do
|
|
||||||
# volatile things in the server like delete previous messages
|
|
||||||
_guild_lock = asyncio.Lock()
|
|
||||||
|
|
||||||
class State(enum.Enum):
|
|
||||||
IDLE=1
|
|
||||||
PLAYING=2
|
|
||||||
PAUSED=3
|
|
||||||
|
|
||||||
def __init__(self, ctx: discord.ext.commands.Context):
|
|
||||||
"""
|
|
||||||
Initializes the music player object associated with the given Discord
|
|
||||||
context.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ctx (discord.ext.commands.Context):
|
|
||||||
The context within the player will connect to play music and
|
|
||||||
respond to requests.
|
|
||||||
"""
|
|
||||||
# Ensure proper cleanup
|
|
||||||
atexit.register(self.__del__)
|
|
||||||
|
|
||||||
self.bot = ctx.bot
|
|
||||||
self._guild = ctx.guild
|
|
||||||
self._channel = ctx.channel
|
|
||||||
self._cog = ctx.cog
|
|
||||||
self._np = None # 'Now Playing' message
|
|
||||||
|
|
||||||
self._state = self.State.IDLE
|
|
||||||
|
|
||||||
self._queue = asyncio.Queue()
|
|
||||||
self._next = asyncio.Event()
|
|
||||||
self._skipped = False # Flag for skipping songs
|
|
||||||
|
|
||||||
self.volume = 0.5
|
|
||||||
self.current = None
|
|
||||||
self.dj_mode = False
|
|
||||||
|
|
||||||
ctx.bot.loop.create_task(self.player_loop())
|
|
||||||
|
|
||||||
def __del__(self):
|
|
||||||
"""
|
|
||||||
Cleanup music player, which includes deleting messages like the
|
|
||||||
'Now Playing' message.
|
|
||||||
"""
|
|
||||||
if self._np:
|
|
||||||
asyncio.run(self._np.delete())
|
|
||||||
|
|
||||||
async def _change_state(self, new_state: "MusicPlayer.State" = None):
|
|
||||||
"""When state changes, update the Discord 'Now Playing' message."""
|
|
||||||
if not self._channel:
|
|
||||||
return
|
|
||||||
|
|
||||||
# 'None' state is used to refresh message without changing state
|
|
||||||
if new_state is not None:
|
|
||||||
self._state = new_state
|
|
||||||
|
|
||||||
logger.info("Updating 'Now Playing' message")
|
|
||||||
await self.bot.wait_until_ready()
|
|
||||||
async with self._guild_lock:
|
|
||||||
# Create new 'Now Playing' message
|
|
||||||
if self._state is self.State.IDLE:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"◻️ Idle", color=discord.Color.light_gray()
|
|
||||||
)
|
|
||||||
elif self._state is self.State.PLAYING:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"▶️ Now Playing", color=discord.Color.blue()
|
|
||||||
)
|
|
||||||
elif self._state is self.State.PAUSED:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"⏸️ Paused", color=discord.Color.light_gray()
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="UNKNOWN STATE", color=discord.Color.red()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get and add the thumbnail
|
|
||||||
if self._state in [self.State.PLAYING, self.State.PAUSED]:
|
|
||||||
embed.set_thumbnail(url=self.current.thumbnail_url)
|
|
||||||
embed.add_field(
|
|
||||||
name="",
|
|
||||||
value=(
|
|
||||||
f"[{self.current.song_title}]({self.current.web_url}) - "
|
|
||||||
f"{self.current.artist}"
|
|
||||||
),
|
|
||||||
inline=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add all upcoming songs
|
|
||||||
# Possibly dangerous, but only obvious solution
|
|
||||||
queue = [s for s in self._queue._queue if s is not None]
|
|
||||||
if len(queue) > 0:
|
|
||||||
value_str = ""
|
|
||||||
for i, song in enumerate(queue):
|
|
||||||
value_str += (
|
|
||||||
f"{i+1}. [{song.song_title}]({song.web_url}) -"
|
|
||||||
f" {song.artist}\n"
|
|
||||||
)
|
|
||||||
embed.add_field(name="Queue", value=value_str, inline=False)
|
|
||||||
|
|
||||||
# Build controls
|
|
||||||
controls = discord.ui.View(timeout=None)
|
|
||||||
# Construct 'back' button
|
|
||||||
prev_button = discord.ui.Button(
|
|
||||||
label="⏮️",
|
|
||||||
style=discord.ButtonStyle.secondary,
|
|
||||||
custom_id="prev"
|
|
||||||
)
|
|
||||||
#prev_button.disabled = self._player.current
|
|
||||||
prev_button.disabled = True
|
|
||||||
#prev_button.callback =
|
|
||||||
controls.add_item(prev_button)
|
|
||||||
|
|
||||||
# Construct 'play/pause' button
|
|
||||||
play_button = discord.ui.Button(
|
|
||||||
label="▶️" if self._state is self.State.PAUSED else "⏸️",
|
|
||||||
style=discord.ButtonStyle.secondary,
|
|
||||||
custom_id="playpause"
|
|
||||||
)
|
|
||||||
play_button.disabled = self._state is self.State.IDLE
|
|
||||||
if self._state is self.State.PLAYING:
|
|
||||||
play_button.callback = self.pause
|
|
||||||
elif self._state is self.State.PAUSED:
|
|
||||||
play_button.callback = self.resume
|
|
||||||
controls.add_item(play_button)
|
|
||||||
|
|
||||||
# Construct 'next' button
|
|
||||||
next_button = discord.ui.Button(
|
|
||||||
label="⏭️",
|
|
||||||
style=discord.ButtonStyle.secondary,
|
|
||||||
custom_id="next"
|
|
||||||
)
|
|
||||||
next_button.disabled = self._state is self.State.IDLE
|
|
||||||
next_button.callback = self.next
|
|
||||||
controls.add_item(next_button)
|
|
||||||
|
|
||||||
# If last post is the 'Now Playing' message, just update it
|
|
||||||
last_message = [m async for m in self._channel.history(limit=1)]
|
|
||||||
if last_message[0] and self._np and last_message[0].id == self._np.id:
|
|
||||||
await self._np.edit(embed=embed, view=controls)
|
|
||||||
else:
|
|
||||||
if self._np:
|
|
||||||
self._np = await self._np.delete()
|
|
||||||
self._np = await self._channel.send(embed=embed, view=controls)
|
|
||||||
|
|
||||||
async def resume(self, interaction: discord.Interaction = None):
|
|
||||||
if interaction: await interaction.response.defer()
|
|
||||||
vc = self._guild.voice_client
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
return
|
|
||||||
if vc.is_paused():
|
|
||||||
vc.resume()
|
|
||||||
await self._change_state(self.State.PLAYING)
|
|
||||||
|
|
||||||
async def pause(self, interaction: discord.Interaction = None):
|
|
||||||
if interaction: await interaction.response.defer()
|
|
||||||
vc = self._guild.voice_client
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
return
|
|
||||||
if vc.is_playing():
|
|
||||||
vc.pause()
|
|
||||||
await self._change_state(self.State.PAUSED)
|
|
||||||
|
|
||||||
async def previous(self, interaction: discord.Interaction = None):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def next(self, interaction: discord.Interaction = None):
|
|
||||||
if interaction: await interaction.response.defer()
|
|
||||||
vc = self._guild.voice_client
|
|
||||||
if not vc.is_playing() and not vc.is_paused():
|
|
||||||
return
|
|
||||||
self._skipped = True # Notify loop that we skipped the song
|
|
||||||
vc.stop()
|
|
||||||
|
|
||||||
async def queue(self, source: YTDLSource):
|
|
||||||
await self._queue.put(source)
|
|
||||||
await self._change_state(None)
|
|
||||||
|
|
||||||
async def player_loop(self, interaction: discord.Interaction = None):
|
|
||||||
"""
|
|
||||||
The main loop that waits for song requests and plays music accordingly.
|
|
||||||
"""
|
|
||||||
await self.bot.wait_until_ready()
|
|
||||||
|
|
||||||
while not self.bot.is_closed():
|
|
||||||
self._next.clear()
|
|
||||||
await self._change_state(self.State.IDLE)
|
|
||||||
|
|
||||||
# Always get a song if there's one in the queue
|
|
||||||
if self._queue.qsize() > 0 or self.dj_mode is False:
|
|
||||||
logger.info("Getting song from play queue")
|
|
||||||
try:
|
|
||||||
# Wait for the next song. If we timeout cancel the player
|
|
||||||
# and disconnect...
|
|
||||||
async with timeout(300): # 5 minutes...
|
|
||||||
source = await self._queue.get()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return await self.destroy()
|
|
||||||
# Otherwise we're in DJ mode and a user hasn't requested one, so
|
|
||||||
# pick a song at random and create a source for it
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
"Queue is empty and DJ mode is on. Picking song at random"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
source = await YTDLSource.create_source(
|
|
||||||
None,
|
|
||||||
random.choice(songs),
|
|
||||||
download=True,
|
|
||||||
)
|
|
||||||
if not source:
|
|
||||||
raise RuntimeError("Could not get YouTube source.")
|
|
||||||
except Exception as e:
|
|
||||||
print(e)
|
|
||||||
await self._channel.send("Failed to get YouTube source.")
|
|
||||||
|
|
||||||
# For the time being, we're going to use 'None' to signal to the
|
|
||||||
# player that it should go back around and check for a song again,
|
|
||||||
# mainly because DJ mode was switched on and it should pick a song
|
|
||||||
# at random this time
|
|
||||||
if source is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(source, YTDLSource):
|
|
||||||
# Source was probably a stream (not downloaded)
|
|
||||||
# So we should regather to prevent stream expiration
|
|
||||||
try:
|
|
||||||
source = await YTDLSource.regather_stream(
|
|
||||||
source, loop=self.bot.loop
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
await self._channel.send(
|
|
||||||
"There was an error processing your"
|
|
||||||
f" song.\n```css\n[{e}]\n```"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
source.volume = self.volume
|
|
||||||
self.current = source
|
|
||||||
|
|
||||||
logger.info(f"Playing '{source.song_title}' by '{source.artist}'")
|
|
||||||
row_id = self.bot.db.insert_song_play(self._channel.id, source)
|
|
||||||
def song_finished(error):
|
|
||||||
# Update database to reflect song finishing
|
|
||||||
if not error:
|
|
||||||
self.bot.db.update_song_play(row_id, not self._skipped)
|
|
||||||
self._skipped = False
|
|
||||||
logger.info(f"Song finiehd with error: {error}")
|
|
||||||
self.bot.loop.call_soon_threadsafe(self._next.set)
|
|
||||||
try:
|
|
||||||
self._guild.voice_client.play(
|
|
||||||
source,
|
|
||||||
after=song_finished
|
|
||||||
)
|
|
||||||
logger.info("Updating presense and 'now playing' message")
|
|
||||||
await self.bot.change_presence(
|
|
||||||
activity=discord.Activity(
|
|
||||||
type=discord.ActivityType.custom,
|
|
||||||
name="custom",
|
|
||||||
state=f"🎵 {source.song_title} by {source.artist}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
# Post error message
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"Error: {str(e)}", color=discord.Color.red()
|
|
||||||
)
|
|
||||||
await self._channel.send(embed=embed)
|
|
||||||
raise e
|
|
||||||
|
|
||||||
logger.info("Waiting for song to finish")
|
|
||||||
await self._change_state(self.State.PLAYING)
|
|
||||||
await self._next.wait()
|
|
||||||
|
|
||||||
if os.path.exists(source.filename):
|
|
||||||
os.remove(source.filename)
|
|
||||||
|
|
||||||
# Make sure the FFmpeg process is cleaned up.
|
|
||||||
try:
|
|
||||||
source.cleanup()
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
self.current = None
|
|
||||||
|
|
||||||
# Update bot statuses to match no song playing
|
|
||||||
await self.bot.change_presence(status=None)
|
|
||||||
|
|
||||||
async def destroy(self):
|
|
||||||
"""Disconnect and cleanup the player."""
|
|
||||||
if self._np:
|
|
||||||
self._np = await self._np.delete()
|
|
||||||
try:
|
|
||||||
return await self._cog.cleanup(self._guild)
|
|
||||||
except:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
class Music(commands.Cog):
|
|
||||||
"""Music related commands."""
|
|
||||||
|
|
||||||
__slots__ = ("bot", "players")
|
|
||||||
|
|
||||||
def __init__(self, bot):
|
|
||||||
self.bot = bot
|
|
||||||
self.players = {}
|
|
||||||
|
|
||||||
async def cleanup(self, guild):
|
|
||||||
try:
|
|
||||||
await guild.voice_client.disconnect()
|
|
||||||
except AttributeError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
try:
|
|
||||||
del self.players[guild.id]
|
|
||||||
except KeyError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __local_check(self, ctx):
|
|
||||||
"""
|
|
||||||
A local check which applies to all commands in this cog and prevents
|
|
||||||
its use in private messages.
|
|
||||||
"""
|
|
||||||
if not ctx.guild:
|
|
||||||
raise commands.NoPrivateMessage
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def __error(self, ctx, error):
|
|
||||||
"""
|
|
||||||
A local error handler for all errors arising from commands in this cog.
|
|
||||||
"""
|
|
||||||
if isinstance(error, commands.NoPrivateMessage):
|
|
||||||
try:
|
|
||||||
return await ctx.send(
|
|
||||||
"This command can not be used in Private Messages."
|
|
||||||
)
|
|
||||||
except discord.HTTPException:
|
|
||||||
pass
|
|
||||||
elif isinstance(error, InvalidVoiceChannel):
|
|
||||||
await ctx.send(
|
|
||||||
"Error connecting to Voice Channel. Please make sure you are"
|
|
||||||
" in a valid channel or provide me with one"
|
|
||||||
)
|
|
||||||
|
|
||||||
print(
|
|
||||||
"Ignoring exception in command {}:".format(ctx.command),
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
traceback.print_exception(
|
|
||||||
type(error), error, error.__traceback__, file=sys.stderr
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_player(self, ctx):
|
|
||||||
"""Retrieve the guild player, or generate one."""
|
|
||||||
try:
|
|
||||||
player = self.players[ctx.guild.id]
|
|
||||||
except KeyError:
|
|
||||||
player = MusicPlayer(ctx)
|
|
||||||
self.players[ctx.guild.id] = player
|
|
||||||
|
|
||||||
return player
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="join", aliases=["connect", "j"], description="connects to voice"
|
|
||||||
)
|
|
||||||
async def connect_(self, ctx, *, channel: discord.VoiceChannel = None):
|
|
||||||
"""Connect to voice.
|
|
||||||
Parameters
|
|
||||||
------------
|
|
||||||
channel: discord.VoiceChannel [Optional]
|
|
||||||
The channel to connect to. If a channel is not specified, an attempt to join the voice channel you are in
|
|
||||||
will be made.
|
|
||||||
This command also handles moving the bot to different channels.
|
|
||||||
"""
|
|
||||||
if not channel:
|
|
||||||
try:
|
|
||||||
channel = ctx.author.voice.channel
|
|
||||||
except AttributeError:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description=(
|
|
||||||
"No channel to join. Please call `,join` from a voice"
|
|
||||||
" channel."
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
await ctx.send(embed=embed)
|
|
||||||
raise InvalidVoiceChannel(
|
|
||||||
"No channel to join. Please either specify a valid channel"
|
|
||||||
" or join one."
|
|
||||||
)
|
|
||||||
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if vc:
|
|
||||||
if vc.channel.id == channel.id:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await vc.move_to(channel)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise VoiceConnectionError(
|
|
||||||
f"Moving to channel: <{channel}> timed out."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
await channel.connect()
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
raise VoiceConnectionError(
|
|
||||||
f"Connecting to channel: <{channel}> timed out."
|
|
||||||
)
|
|
||||||
# await ctx.message.add_reaction('👍')
|
|
||||||
|
|
||||||
@commands.command(name="play", aliases=["p", "queue", "q"])
|
|
||||||
async def play_(self, ctx, *, search: str = None):
|
|
||||||
"""Plays the given song in a voice channel.
|
|
||||||
|
|
||||||
This method takes a string describing the song to play and plays it. In
|
|
||||||
the event that a song is already being played, the new one is added to
|
|
||||||
a queue of songs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
search (str): The search term or URL used to find the song.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
!play Play That Funky Music by Wild Cherry
|
|
||||||
"""
|
|
||||||
# Ensure we're connected to the proper voice channel
|
|
||||||
vc = ctx.voice_client
|
|
||||||
if not vc:
|
|
||||||
await ctx.invoke(self.connect_)
|
|
||||||
|
|
||||||
# Send message to say we're working on it
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"🔎 Searching for:",
|
|
||||||
description=f"{search}",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
message = await ctx.channel.send(embed=embed)
|
|
||||||
|
|
||||||
# Create source
|
|
||||||
try:
|
|
||||||
source = await YTDLSource.create_source(
|
|
||||||
ctx, search, download=True
|
|
||||||
)
|
|
||||||
# Track song requests in database
|
|
||||||
self.bot.db.insert_song_request(message, source)
|
|
||||||
# Add song to the corresponding player object
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
await player.queue(source)
|
|
||||||
# Update previous message to show found song and video
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"Queued",
|
|
||||||
description=(
|
|
||||||
f"[{source.song_title}]({source.web_url}) -"
|
|
||||||
f" {source.artist}"
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
embed.set_thumbnail(url=source.thumbnail_url)
|
|
||||||
await message.edit(embed=embed)
|
|
||||||
except Exception as e:
|
|
||||||
# Gracefully tell user there was an issue
|
|
||||||
embed = discord.Embed(
|
|
||||||
title=f"ERROR",
|
|
||||||
description=f"{str(e)}",
|
|
||||||
color=discord.Color.red(),
|
|
||||||
)
|
|
||||||
await message.edit(embed=embed)
|
|
||||||
raise e
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="djmode", aliases=["dj"], description="Turns DJ mode on or off."
|
|
||||||
)
|
|
||||||
async def djmode_(self, ctx, *, mode: str = "on"):
|
|
||||||
"""Turns DJ mode on or off. When on, the bot will play songs
|
|
||||||
automatically."""
|
|
||||||
# Ensure we're connected to the proper voice channel
|
|
||||||
vc = ctx.voice_client
|
|
||||||
if not vc:
|
|
||||||
await ctx.invoke(self.connect_)
|
|
||||||
# Get desired mode
|
|
||||||
mode = mode.lower().strip()
|
|
||||||
if mode in ("true", "t", "yes", "y", "on"):
|
|
||||||
mode = True
|
|
||||||
elif mode in ("false", "f", "no", "n", "off"):
|
|
||||||
mode = False
|
|
||||||
else:
|
|
||||||
return
|
|
||||||
# Switch to desired mode
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
player.dj_mode = mode
|
|
||||||
# Break player out of waiting on queue so it can pick a song at random
|
|
||||||
if player.dj_mode:
|
|
||||||
await player.queue(None)
|
|
||||||
|
|
||||||
@commands.command(name="pause", description="pauses music")
|
|
||||||
async def pause_(self, ctx):
|
|
||||||
"""Pause the currently playing song."""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if not vc or not vc.is_playing():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I am currently not playing anything",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
elif vc.is_paused():
|
|
||||||
return
|
|
||||||
|
|
||||||
vc.pause()
|
|
||||||
|
|
||||||
# Update the 'Now Playing' message to reflect its paused
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
await player.update_now_playing_message(emoji="⏸️")
|
|
||||||
|
|
||||||
@commands.command(name="resume", description="resumes music")
|
|
||||||
async def resume_(self, ctx):
|
|
||||||
"""Resume the currently paused song."""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I'm not connected to a voice channel",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
elif not vc.is_paused():
|
|
||||||
return
|
|
||||||
|
|
||||||
vc.resume()
|
|
||||||
|
|
||||||
# Update the 'Now Playing' message to reflect its resumed
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
await player.update_now_playing_message()
|
|
||||||
|
|
||||||
@commands.command(name="skip", description="skips to next song in queue")
|
|
||||||
async def skip_(self, ctx):
|
|
||||||
"""Skip the song."""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I'm not connected to a voice channel",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
if vc.is_paused():
|
|
||||||
pass
|
|
||||||
elif not vc.is_playing():
|
|
||||||
return
|
|
||||||
|
|
||||||
vc.stop()
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="remove",
|
|
||||||
aliases=["rm"],
|
|
||||||
description="removes specified song from queue",
|
|
||||||
)
|
|
||||||
async def remove_(self, ctx, pos: int = None):
|
|
||||||
"""Removes specified song from queue"""
|
|
||||||
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I'm not connected to a voice channel",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
if pos == None:
|
|
||||||
player.queue._queue.pop()
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
s = player.queue._queue[pos - 1]
|
|
||||||
del player.queue._queue[pos - 1]
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description=(
|
|
||||||
f"Removed [{s['title']}]({s['webpage_url']})"
|
|
||||||
f" [{s['requester'].mention}]"
|
|
||||||
),
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
await ctx.send(embed=embed)
|
|
||||||
except:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description=f'Could not find a track for "{pos}"',
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="clear",
|
|
||||||
aliases=["clr", "cl", "cr"],
|
|
||||||
description="clears entire queue",
|
|
||||||
)
|
|
||||||
async def clear_(self, ctx):
|
|
||||||
"""
|
|
||||||
Deletes entire queue of upcoming songs.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ctx (discord.ext.commands.Context): The Discord context associated
|
|
||||||
with the message.
|
|
||||||
"""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I am not currently connected to a voice channel.",
|
|
||||||
color=discord.Color.yellow(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
player.queue._queue.clear()
|
|
||||||
await ctx.send("**Cleared**")
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="volume",
|
|
||||||
aliases=["vol", "v"],
|
|
||||||
description="Sets the bot's volume in the voice channel.",
|
|
||||||
)
|
|
||||||
async def change_volume(self, ctx, *, vol: float = None):
|
|
||||||
"""
|
|
||||||
Change the player volume.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ctx (discord.ext.commands.Context): The Discord context associated
|
|
||||||
with the message.
|
|
||||||
volume (float, int, required):
|
|
||||||
The volume to set the player to in percentage. This must be
|
|
||||||
between 1 and 100.
|
|
||||||
"""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I am not currently connected to a voice channel.",
|
|
||||||
color=discord.Color.yellow(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
if not vol:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description=f"🔊 **{(vc.source.volume)*100}%**",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
if not 0 < vol < 101:
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="Please enter a value between 1 and 100",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
player = self.get_player(ctx)
|
|
||||||
|
|
||||||
if vc.source:
|
|
||||||
vc.source.volume = vol / 100
|
|
||||||
|
|
||||||
player.volume = vol / 100
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description=f"**`{ctx.author}`** set the volume to **{vol}%**",
|
|
||||||
color=discord.Color.green(),
|
|
||||||
)
|
|
||||||
await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
@commands.command(
|
|
||||||
name="leave",
|
|
||||||
aliases=["stop", "dc", "disconnect", "bye"],
|
|
||||||
description="Stops music and disconnects from voice.",
|
|
||||||
)
|
|
||||||
async def leave_(self, ctx: discord.ext.commands.Context):
|
|
||||||
"""
|
|
||||||
Stop the currently playing song and destroy the player.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
ctx (discord.ext.commands.Context): The Discord context associated
|
|
||||||
with the message.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
This will destroy the player assigned to your guild, also deleting
|
|
||||||
any queued songs and settings.
|
|
||||||
"""
|
|
||||||
vc = ctx.voice_client
|
|
||||||
if not vc or not vc.is_connected():
|
|
||||||
embed = discord.Embed(
|
|
||||||
title="",
|
|
||||||
description="I am not currently connected to a voice channel.",
|
|
||||||
color=discord.Color.yellow(),
|
|
||||||
)
|
|
||||||
return await ctx.send(embed=embed)
|
|
||||||
|
|
||||||
await ctx.message.add_reaction("👋")
|
|
||||||
await self.cleanup(ctx.guild)
|
|
||||||
|
|
||||||
|
|
||||||
async def setup(bot):
|
|
||||||
await bot.add_cog(Music(bot))
|
|
||||||
466
database.py
466
database.py
@ -1,466 +0,0 @@
|
|||||||
from datetime import datetime, timedelta
|
|
||||||
import discord
|
|
||||||
import sqlite3
|
|
||||||
import typing
|
|
||||||
|
|
||||||
from cogs import music_player
|
|
||||||
|
|
||||||
class Database:
|
|
||||||
def __init__(self, path: str):
|
|
||||||
self.path = path
|
|
||||||
self._ensure_db()
|
|
||||||
|
|
||||||
def _ensure_db(self):
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
|
|
||||||
# Table for keeping track of servers
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS server (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
discord_id INTEGER NOT NULL UNIQUE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Table for keeping track of channels
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS channel (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
discord_id INTEGER NOT NULL UNIQUE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Table for keeping track of users
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS user (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
discord_id INTEGER NOT NULL UNIQUE
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create the activity table
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS activity_change (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
before_activity_type TEXT,
|
|
||||||
before_activity_name TEXT,
|
|
||||||
before_activity_status TEXT NOT NULL,
|
|
||||||
after_activity_type TEXT,
|
|
||||||
after_activity_name TEXT,
|
|
||||||
after_activity_status TEXT NOT NULL,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# Create the song request table
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS song_request (
|
|
||||||
id INTEGER PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL,
|
|
||||||
channel_id INTEGER NOT NULL,
|
|
||||||
search_term TEXT NOT NULL,
|
|
||||||
song_title TEXT,
|
|
||||||
song_artist TEXT,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
|
|
||||||
# # TEMP
|
|
||||||
# conn.execute("""
|
|
||||||
# ALTER TABLE song_play ADD COLUMN finished BOOL;
|
|
||||||
# """)
|
|
||||||
|
|
||||||
|
|
||||||
# Table for songs that actually get played
|
|
||||||
conn.execute("""
|
|
||||||
CREATE TABLE IF NOT EXISTS song_play (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER,
|
|
||||||
channel_id INTEGER NOT NULL,
|
|
||||||
search_term TEXT NOT NULL,
|
|
||||||
song_title TEXT,
|
|
||||||
song_artist TEXT,
|
|
||||||
finished BOOL DEFAULT 0,
|
|
||||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
)
|
|
||||||
""")
|
|
||||||
|
|
||||||
# # ############ TEMP ###############
|
|
||||||
# conn.execute("""DROP TABLE IF EXISTS song_play_old;""")
|
|
||||||
|
|
||||||
# conn.execute("""
|
|
||||||
# ALTER TABLE
|
|
||||||
# song_play
|
|
||||||
# RENAME TO
|
|
||||||
# song_play_old;
|
|
||||||
# """)
|
|
||||||
|
|
||||||
# conn.execute("""
|
|
||||||
# CREATE TABLE song_play (
|
|
||||||
# id INTEGER PRIMARY KEY,
|
|
||||||
# user_id INTEGER,
|
|
||||||
# channel_id INTEGER NOT NULL,
|
|
||||||
# search_term TEXT NOT NULL,
|
|
||||||
# song_title TEXT,
|
|
||||||
# song_artist TEXT,
|
|
||||||
# finished BOOLEAN DEFAULT 0,
|
|
||||||
# timestamp DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
|
|
||||||
# );
|
|
||||||
# """)
|
|
||||||
|
|
||||||
# conn.execute("""
|
|
||||||
# INSERT INTO song_play (
|
|
||||||
# id,
|
|
||||||
# user_id,
|
|
||||||
# channel_id,
|
|
||||||
# search_term,
|
|
||||||
# song_title,
|
|
||||||
# song_artist,
|
|
||||||
# timestamp
|
|
||||||
# ) SELECT
|
|
||||||
# id,
|
|
||||||
# user_id,
|
|
||||||
# channel_id,
|
|
||||||
# search_term,
|
|
||||||
# song_title,
|
|
||||||
# song_artist,
|
|
||||||
# timestamp
|
|
||||||
# FROM song_play_old;
|
|
||||||
# """)
|
|
||||||
|
|
||||||
# conn.execute("""
|
|
||||||
# DROP TABLE song_play_old;
|
|
||||||
# """)
|
|
||||||
# # ##################################
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
def _insert_server(self, discord_id: int = None) -> int:
|
|
||||||
"""
|
|
||||||
Inserts Discord server ID into the 'server' table.
|
|
||||||
|
|
||||||
This method takes an ID for a server used in Discord, and inserts it
|
|
||||||
into the database. It ignores the case where the server ID is already
|
|
||||||
present. It then returns the row ID regardless.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
discord_id (int): The ID used to identify the server in Discord.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: The ID of the server in the server table.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> db = Database("path.db")
|
|
||||||
>>> db._insert_server(850610922256442889)
|
|
||||||
12
|
|
||||||
"""
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# Insert it; ignoring already exists error
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO server (discord_id)
|
|
||||||
VALUES (?)
|
|
||||||
ON CONFLICT(discord_id) DO NOTHING
|
|
||||||
RETURNING id;
|
|
||||||
""", (discord_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
row_id = row[0]
|
|
||||||
else:
|
|
||||||
# Get row ID if it already exists and wasn't inserted
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id FROM server WHERE discord_id = ?
|
|
||||||
""", (discord_id,))
|
|
||||||
row_id = cursor.fetchone()[0]
|
|
||||||
return row_id
|
|
||||||
|
|
||||||
def _insert_channel(self, discord_id: int = None) -> int:
|
|
||||||
"""
|
|
||||||
Inserts Discord channel ID into the 'channel' table.
|
|
||||||
|
|
||||||
This method takes an ID for a channel used in Discord, and inserts it
|
|
||||||
into the database. It ignores the case where the channel ID is already
|
|
||||||
present. It then returns the row ID regardless.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
discord_id (int): The ID used to identify the channel in Discord.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: The ID of the channel in the channel table.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> db = Database("path.db")
|
|
||||||
>>> db._insert_channel(8506109222564428891)
|
|
||||||
12
|
|
||||||
"""
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# Insert it; ignoring already exists error
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO channel (discord_id)
|
|
||||||
VALUES (?)
|
|
||||||
ON CONFLICT(discord_id) DO NOTHING
|
|
||||||
RETURNING id;
|
|
||||||
""", (discord_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
row_id = row[0]
|
|
||||||
else:
|
|
||||||
# Get row ID if it already exists and wasn't inserted
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id FROM channel WHERE discord_id = ?
|
|
||||||
""", (discord_id,))
|
|
||||||
row_id = cursor.fetchone()[0]
|
|
||||||
return row_id
|
|
||||||
|
|
||||||
def _insert_user(self, discord_id: int = None) -> int:
|
|
||||||
"""
|
|
||||||
Inserts Discord user ID into the 'user' table.
|
|
||||||
|
|
||||||
This method takes an ID for a user used in Discord, and inserts it
|
|
||||||
into the database. It ignores the case where the user ID is already
|
|
||||||
present. It then returns the row ID regardless.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
discord_id (int): The ID used to identify the user in Discord.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: The ID of the user in the user table.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> db = Database("path.db")
|
|
||||||
>>> db._insert_user(850610922256442889)
|
|
||||||
12
|
|
||||||
"""
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
# Insert it; ignoring already exists error
|
|
||||||
cursor.execute("""
|
|
||||||
INSERT INTO user (discord_id)
|
|
||||||
VALUES (?)
|
|
||||||
ON CONFLICT(discord_id) DO NOTHING
|
|
||||||
RETURNING id;
|
|
||||||
""", (discord_id,))
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if row:
|
|
||||||
row_id = row[0]
|
|
||||||
else:
|
|
||||||
# Get row ID if it already exists and wasn't inserted
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT id FROM user WHERE discord_id = ?
|
|
||||||
""", (discord_id,))
|
|
||||||
row_id = cursor.fetchone()[0]
|
|
||||||
return row_id
|
|
||||||
|
|
||||||
def insert_activity_change(
|
|
||||||
self,
|
|
||||||
before: discord.Member,
|
|
||||||
after: discord.Member):
|
|
||||||
"""
|
|
||||||
Inserts an activity change into the database.
|
|
||||||
|
|
||||||
This method takes two discord.Memeber objects, and records the change
|
|
||||||
in activity into the 'activity_change' table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
before (discord.Member): The previous user status.
|
|
||||||
after (discord.Member): The current user status.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the before and after activity do not refer to the
|
|
||||||
same user.
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
>>> @commands.Cog.listener()
|
|
||||||
>>> async def on_presence_update(
|
|
||||||
... self,
|
|
||||||
... before: discord.Member,
|
|
||||||
... after: discord.Member):
|
|
||||||
... db = Database("path.db")
|
|
||||||
... db.insert_activity_change(before, after)
|
|
||||||
>>>
|
|
||||||
"""
|
|
||||||
# Ensure the users are the same
|
|
||||||
if before.id != after.id:
|
|
||||||
raise ValueError("User IDs do not match.")
|
|
||||||
user_id = self._insert_user(before.id)
|
|
||||||
# Get activities if they exist
|
|
||||||
before_type = before.activity.type.name if before.activity else None
|
|
||||||
before_name = before.activity.name if before.activity else None
|
|
||||||
after_type = after.activity.type.name if after.activity else None
|
|
||||||
after_name = after.activity.name if after.activity else None
|
|
||||||
# Insert the activity change
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO activity_change (
|
|
||||||
user_id,
|
|
||||||
before_activity_type,
|
|
||||||
before_activity_name,
|
|
||||||
before_activity_status,
|
|
||||||
after_activity_type,
|
|
||||||
after_activity_name,
|
|
||||||
after_activity_status
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
""", (
|
|
||||||
user_id,
|
|
||||||
before_type,
|
|
||||||
before_name,
|
|
||||||
before.status.name,
|
|
||||||
after_type,
|
|
||||||
after_name,
|
|
||||||
after.status.name
|
|
||||||
))
|
|
||||||
|
|
||||||
def insert_song_request(
|
|
||||||
self,
|
|
||||||
message: discord.Message,
|
|
||||||
source: music_player.YTDLSource):
|
|
||||||
"""
|
|
||||||
Inserts a song request into the database.
|
|
||||||
|
|
||||||
This method takes a message and its derived music source and inserts
|
|
||||||
the relevant information into the 'song_request' table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message (discord.Message): The Discord message requesting the song.
|
|
||||||
source (music_player.YTDLSource): The audio source.
|
|
||||||
"""
|
|
||||||
# Insert the information
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
conn.execute("""
|
|
||||||
INSERT INTO song_request (
|
|
||||||
user_id,
|
|
||||||
channel_id,
|
|
||||||
search_term,
|
|
||||||
song_title,
|
|
||||||
song_artist
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
""", (
|
|
||||||
self._insert_user(message.author.id),
|
|
||||||
self._insert_channel(message.channel.id),
|
|
||||||
source.search_term,
|
|
||||||
source.song_title,
|
|
||||||
source.artist
|
|
||||||
))
|
|
||||||
|
|
||||||
def insert_song_play(
|
|
||||||
self,
|
|
||||||
channel_id: int,
|
|
||||||
source: music_player.YTDLSource):
|
|
||||||
"""
|
|
||||||
Inserts a song play into the database.
|
|
||||||
|
|
||||||
This method takes a channel and the song being played and inserts the
|
|
||||||
relevant information into the 'song_play' table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
channel (int): The Discord channel the song is being played in.
|
|
||||||
source (music_player.YTDLSource): The audio source.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: The row ID of the entered song. Used to update 'played' value.
|
|
||||||
"""
|
|
||||||
user_id = self._insert_user(source.requester.id) if source.requester else None
|
|
||||||
channel_id = self._insert_user(channel_id)
|
|
||||||
# Insert the information
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
cur = conn.cursor()
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO song_play (
|
|
||||||
user_id,
|
|
||||||
channel_id,
|
|
||||||
search_term,
|
|
||||||
song_title,
|
|
||||||
song_artist
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, ?, ?
|
|
||||||
)
|
|
||||||
""", (
|
|
||||||
user_id,
|
|
||||||
channel_id,
|
|
||||||
source.search_term,
|
|
||||||
source.song_title,
|
|
||||||
source.artist
|
|
||||||
))
|
|
||||||
return cur.lastrowid
|
|
||||||
|
|
||||||
def update_song_play(self, song_play_id: int, finished: bool):
|
|
||||||
"""
|
|
||||||
Updates a song_play entry on whether or not it was finished.
|
|
||||||
|
|
||||||
When a song plays, we want to know if it was finished or not. This
|
|
||||||
implies that either a user didn't want to hear it anymore, or that the
|
|
||||||
bot chose the wrong song from the search term.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
song_play_id (int): The row ID within the database for the song
|
|
||||||
play.
|
|
||||||
finished (bool): Whether or not the song was completed.
|
|
||||||
"""
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
conn.execute("""
|
|
||||||
UPDATE
|
|
||||||
song_play
|
|
||||||
SET
|
|
||||||
finished = ?
|
|
||||||
WHERE
|
|
||||||
id = ?
|
|
||||||
""", (finished, song_play_id))
|
|
||||||
|
|
||||||
def get_activity_stats(
|
|
||||||
self,
|
|
||||||
member: typing.Union[discord.Member, int],
|
|
||||||
start: datetime = datetime.now() - timedelta(days=30)
|
|
||||||
) -> dict[str, timedelta]:
|
|
||||||
"""
|
|
||||||
Gets stats on the activities of the given member.
|
|
||||||
|
|
||||||
This method searches the database for activity changes by the given
|
|
||||||
user and computes the amount of time spent in each activity.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
member (discord.Member): The Discord member to get stats for.
|
|
||||||
start (datetime): The earliest activity change to get.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, timedelta]: A dictionary of activity names and
|
|
||||||
seconds in each.
|
|
||||||
"""
|
|
||||||
# Get member Discord ID and convert to DB ID
|
|
||||||
member_id = member.id if isinstance(member, discord.Member) else member
|
|
||||||
member_id = self._insert_user(member_id)
|
|
||||||
# Pull all activities for this user
|
|
||||||
with sqlite3.connect(self.path) as conn:
|
|
||||||
cursor = conn.cursor()
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT
|
|
||||||
before_activity_name,
|
|
||||||
after_activity_name,
|
|
||||||
timestamp
|
|
||||||
FROM
|
|
||||||
activity_change
|
|
||||||
WHERE
|
|
||||||
user_id = (?) AND
|
|
||||||
timestamp > (?)
|
|
||||||
""", (member_id, start))
|
|
||||||
activities = cursor.fetchall()
|
|
||||||
# Collect activities
|
|
||||||
activity_stats = {}
|
|
||||||
for first, second in zip(activities, activities[1:]):
|
|
||||||
if first[1] == second[0]:
|
|
||||||
activity_name = first[1]
|
|
||||||
activity_time = \
|
|
||||||
datetime.fromisoformat(second[2]) - \
|
|
||||||
datetime.fromisoformat(first[2])
|
|
||||||
if activity_name in activity_stats:
|
|
||||||
activity_stats[activity_name] += activity_time
|
|
||||||
else:
|
|
||||||
activity_stats[activity_name] = activity_time
|
|
||||||
if None in activity_stats:
|
|
||||||
del activity_stats[None]
|
|
||||||
return activity_stats
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
discord
|
|
||||||
discord[voice]
|
|
||||||
ffmpeg
|
|
||||||
python-dotenv
|
|
||||||
yt-dlp
|
|
||||||
async_timeout
|
|
||||||
validators
|
|
||||||
openai
|
|
||||||
Loading…
x
Reference in New Issue
Block a user