Modern packaging

- add pyproject.toml, README, and recommend using pdm
- cleanup dependencies and switch to better ones
- remove mumble stub

fix missing env var call
fix null env var
This commit is contained in:
a 2022-11-21 09:29:03 -06:00
parent 5a0dff92b0
commit 4ac878fecb
12 changed files with 1292 additions and 76 deletions

7
.env.example Normal file
View file

@ -0,0 +1,7 @@
DISCORD_BOT_TOKEN =
DISCORD_GUILD_ID =
DISCORD_BOT_ADMIN_ROLE_ID =
DISCORD_BOT_ADMIN_USER_ID =
DISCORD_VC_CHANNEL =

3
.gitignore vendored
View file

@ -1,6 +1,9 @@
.env .env
.venv
.cache/* .cache/*
.logs/* .logs/*
sounds/originals/* sounds/originals/*
sounds/normalized/* sounds/normalized/*
cogs/__pycache__/* cogs/__pycache__/*
.pdm.toml
__pypackages__/

51
README.md Normal file
View file

@ -0,0 +1,51 @@
# umi
a self-hostable single-guild discord bot for playing music and announcing vc channel joins/leaves
## Pre-requisites
Install dependencies:
- FFI: `libffi`
- NaCl: `libnacl` or `libsodium`
Copy `.env.example` to `.env`:
- `DISCORD_BOT_TOKEN` is obtained from the [Discord Developer Dashboard](https://discord.com/developers/applications)
- Click or create an application
- Get your token from the "Bot" section in the sidebar menu
- `DISCORD_GUILD_ID` is obtained from within the Discord app
- Settings > Advanced > Developer Mode: enabled
- Right click the guild and select "Copy ID"
- `DISCORD_BOT_ADMIN_ROLE_ID` for reloading cogs
- `DISCORD_BOT_ADMIN_USER_ID` for reloading cogs
- `DISCORD_VC_CHANNEL` for the VCJoin cog
## Setup
### ...with pdm
```sh
pdm install --prod
pdm run python app.py
```
### ...with requirements.txt
#### ....and pipenv
```sh
pipenv install
pipenv run python app.py
```
#### ....and virtualenv
```sh
virtualenv .venv
source .venv/bin/activate
pip install -r requirements.txt
python app.py
```

40
app.py
View file

@ -1,5 +1,4 @@
import discord import discord
import pymumble_py3 as pymumble
""" Declare intents that the bot will use """ """ Declare intents that the bot will use """
intents = discord.Intents.default() intents = discord.Intents.default()
@ -14,15 +13,13 @@ intents.reactions = True
intents.voice_states = True intents.voice_states = True
""" Load the bot token """ """ Load the bot token """
from dotenv import load_dotenv from decouple import config
from os import getenv DISCORD_TOKEN: str = config("DISCORD_BOT_TOKEN")
load_dotenv() DISCORD_GUILD: int = config("DISCORD_GUILD_ID", cast=int)
DISCORD_TOKEN = getenv("DISCORD_BOT_TOKEN")
DISCORD_GUILD = int(getenv("DISCORD_GUILD_ID"))
""" Initialize the Discord bot """ """ Initialize the Discord bot """
from discord.ext.commands import Bot, when_mentioned_or from discord.ext.commands import Bot, when_mentioned_or
bot = Bot( bot: Bot = Bot(
command_prefix=when_mentioned_or('..', '>', '.'), command_prefix=when_mentioned_or('..', '>', '.'),
description="A list of commands available", description="A list of commands available",
intents=intents, intents=intents,
@ -37,6 +34,7 @@ async def on_ready():
""" Load cogs """ """ Load cogs """
from os import listdir from os import listdir
def list_all_cogs() -> list[str]: def list_all_cogs() -> list[str]:
return [file[:-3] for file in listdir("cogs") if file.endswith(".py")] return [file[:-3] for file in listdir("cogs") if file.endswith(".py")]
@ -56,19 +54,17 @@ from discord.commands import Option
from discord.ext.commands import Context from discord.ext.commands import Context
from discord import AutocompleteContext, ApplicationContext from discord import AutocompleteContext, ApplicationContext
async def cog_autocomplete(ctx: AutocompleteContext): async def cog_autocomplete(ctx: AutocompleteContext) -> list[str]:
return [cog for cog in list_all_cogs() if cog.lower().startswith( ctx.value.lower() )] return [cog for cog in list_all_cogs() if cog.lower().startswith( ctx.value.lower() )]
ROLE_ADMIN = 518625964763119616 ADMIN_ROLE: int = config("DISCORD_BOT_ADMIN_ROLE_ID", cast=int, default=0)
ROLE_ADMIN_SFW = 727205354353721374 ADMIN_USER: int = config("DISCORD_BOT_ADMIN_USER_ID", cast=int)
ME = 201046736565829632
def allowed_to_reload(ctx: Context | ApplicationContext): def allowed_to_reload(ctx: Context | ApplicationContext) -> bool:
roles = [role.id for role in ctx.author.roles] roles: set[int] = set([role.id for role in ctx.author.roles])
admin = ROLE_ADMIN in roles admin: bool = ADMIN_ROLE in roles
sfw_admin = ROLE_ADMIN_SFW in roles owner: bool = ctx.author.id == ADMIN_USER
owner = ctx.author.id == ME return any([admin, owner])
return any([admin, sfw_admin, owner])
from cogs.music import Music from cogs.music import Music
def reload_music(ctx): def reload_music(ctx):
@ -89,19 +85,17 @@ def reload_music(ctx):
music.search_results = search_results music.search_results = search_results
@bot.command(name='reload') @bot.command(name='reload')
async def reload_prefix(ctx: Context, cog: str = None): async def reload_prefix(ctx: Context, cog: str = "") -> None:
"""Reload an extension (admin command)""" """Reload an extension (admin command)"""
if not allowed_to_reload(ctx): if not allowed_to_reload(ctx):
return await ctx.send( return await ctx.send("You must be an admin or bot owner to use this command")
"You must be an admin or bot owner to use this command"
)
if not cog: if not cog:
return await ctx.send("Please specify a cog to reload") return await ctx.send("Please specify a cog to reload")
elif cog.lower() == "music": elif cog.lower() == "music":
reload_music(ctx) reload_music(ctx)
else: else:
bot.reload_extension(f"cogs.{cog}") bot.reload_extension(f"cogs.{cog}")
await ctx.send(f"Reloaded `{cog}` extension") return await ctx.send(f"Reloaded `{cog}` extension")
@bot.slash_command( @bot.slash_command(
name='reload', name='reload',
@ -121,7 +115,7 @@ async def reload_slash(
reload_music(ctx) reload_music(ctx)
else: else:
bot.reload_extension(f"cogs.{cog}") bot.reload_extension(f"cogs.{cog}")
await ctx.respond(f"Reloaded `{cog}` extension", ephemeral=True) return await ctx.respond(f"Reloaded `{cog}` extension", ephemeral=True)
# ================================== END ======================================= # ================================== END =======================================

View file

@ -1,25 +0,0 @@
import discord
from discord.ext.commands import Cog
from os import getenv, path, makedirs
import logging
if not path.exists('.logs'):
makedirs('.logs')
logger = logging.getLogger(__name__)
logger.setLevel(logging.WARNING)
fh = logging.FileHandler('.logs/mumble.log')
formatter = logging.Formatter('%(asctime)s | %(name)s | [%(levelname)s] %(message)s', '%Y-%m-%d %H:%M:%S')
fh.setFormatter(formatter)
if not len(logger.handlers):
logger.addHandler(fh)
def setup(bot: discord.Bot):
bot.add_cog(Mumble(bot))
class Mumble(Cog):
"""Bridge to Mumble"""
def __init__(self, bot: discord.Bot):
self.bot: discord.Bot = bot
print("Initialized Music cog")

View file

@ -3,7 +3,8 @@ from discord.ext.commands import Cog, command, Context
from discord.ext.pages import Paginator from discord.ext.pages import Paginator
import asyncio # used to run async functions within regular functions import asyncio # used to run async functions within regular functions
import subprocess # for running ffprobe and getting duration of files import subprocess # for running ffprobe and getting duration of files
from os import getenv, path, makedirs from os import path, makedirs
from decouple import config
from time import time # performance tracking from time import time # performance tracking
import random # for shuffling the queue import random # for shuffling the queue
import math # for ceiling function in queue pages import math # for ceiling function in queue pages
@ -831,6 +832,6 @@ ytdl_format_options = {
# "source_address": "0.0.0.0", # Bind to ipv4 since ipv6 addresses cause issues # "source_address": "0.0.0.0", # Bind to ipv4 since ipv6 addresses cause issues
"extract_flat": True, # massive speedup for fetching metadata, at the cost of no upload date "extract_flat": True, # massive speedup for fetching metadata, at the cost of no upload date
} }
username = getenv("YOUTUBE_USERNAME") # username = config("YOUTUBE_USERNAME")
password = getenv("YOUTUBE_PASSWORD") # password = config("YOUTUBE_PASSWORD")
ytdl = youtube_dl.YoutubeDL(ytdl_format_options) ytdl = youtube_dl.YoutubeDL(ytdl_format_options)

View file

@ -1,12 +1,12 @@
import discord import discord
from discord.ext.commands import Cog, command, Context from discord.ext.commands import Cog, command, Context
from discord.commands import slash_command, Option from discord.commands import slash_command, Option
import requests import httpx
from os import getenv from decouple import config
from collections import Counter from collections import Counter
import re import re
GUILD = int(getenv("DISCORD_GUILD_ID")) GUILD = config("DISCORD_GUILD_ID", cast=int)
def setup(bot: discord.Bot): def setup(bot: discord.Bot):
bot.add_cog(Random(bot)) bot.add_cog(Random(bot))
@ -23,7 +23,7 @@ class Random(Cog):
def roll(faces: int = 6, number: int = 1) -> str: def roll(faces: int = 6, number: int = 1) -> str:
"""Roll die(s) using random.org integer generation""" """Roll die(s) using random.org integer generation"""
columns = number if number < 11 else 10 columns = number if number < 11 else 10
return requests.get(f'https://www.random.org/integers/?num={number}&min=1&max={faces}&col={columns}&base=10&format=plain&rnd=new').text return httpx.get(f'https://www.random.org/integers/?num={number}&min=1&max={faces}&col={columns}&base=10&format=plain&rnd=new').text
@staticmethod @staticmethod
def roll_results(faces: int = 6, number: int = 1) -> str: def roll_results(faces: int = 6, number: int = 1) -> str:
@ -43,7 +43,7 @@ class Random(Cog):
number, faces = query.split('d') number, faces = query.split('d')
if not number: # handle raw dN rolls, e.g. 'd6' or 'd20' if not number: # handle raw dN rolls, e.g. 'd6' or 'd20'
number = 1 number = 1
await ctx.send(Random.roll_results(faces=int(faces), number=int(number))) return await ctx.send(Random.roll_results(faces=int(faces), number=int(number)))
@slash_command( @slash_command(
name='roll', name='roll',
@ -55,14 +55,16 @@ class Random(Cog):
number: Option(int, "How many dies should be rolled?", min_value=1, max_value=10_000, default=1) number: Option(int, "How many dies should be rolled?", min_value=1, max_value=10_000, default=1)
): ):
"""Roll some dice (default 1d6)""" """Roll some dice (default 1d6)"""
await ctx.respond(Random.roll_results(faces=faces, number=number)) return await ctx.respond(Random.roll_results(faces=faces, number=number))
# ============================================================================== # ==============================================================================
@staticmethod @staticmethod
def flip(number: int = 1) -> str: def flip(number: int = 1) -> str:
"""Returns a raw list of Heads or Tails outcomes""" """Returns a raw list of Heads or Tails outcomes"""
result = requests.get(f'https://www.random.org/integers/?num={number}&min=1&max=2&col=1&base=10&format=plain&rnd=new').text result: str = httpx.get(
f'https://www.random.org/integers/?num={number}&min=1&max=2&col=1&base=10&format=plain&rnd=new'
).text
result = result.replace('1', 'Heads') result = result.replace('1', 'Heads')
result = result.replace('2', 'Tails') result = result.replace('2', 'Tails')
return result return result
@ -70,7 +72,7 @@ class Random(Cog):
@staticmethod @staticmethod
def flip_results(number: int = 1) -> str: def flip_results(number: int = 1) -> str:
"""Returns a formatted result of Heads and Tails counts""" """Returns a formatted result of Heads and Tails counts"""
result = Random.flip(number) result: str = Random.flip(number)
if len(result) == 6: # \n counts as a character and we don't strip it if len(result) == 6: # \n counts as a character and we don't strip it
return ( return (
f"*You flipped {number} coins and got:*\n" f"*You flipped {number} coins and got:*\n"
@ -89,7 +91,7 @@ class Random(Cog):
) )
async def flip_prefix(self, ctx: Context, number: str = 1): async def flip_prefix(self, ctx: Context, number: str = 1):
"""Flip a coin (default 1)""" """Flip a coin (default 1)"""
await ctx.send(self.flip_results(number)) return await ctx.send(self.flip_results(number))
@slash_command( @slash_command(
name='flip', name='flip',
@ -100,4 +102,4 @@ class Random(Cog):
number: Option(int, "How many coins should be flipped?", min_value=1, max_value=10_000, default=1) number: Option(int, "How many coins should be flipped?", min_value=1, max_value=10_000, default=1)
): ):
"""Flip a coin (default 1)""" """Flip a coin (default 1)"""
await ctx.respond(self.flip_results(number)) return await ctx.respond(self.flip_results(number))

View file

@ -37,3 +37,5 @@ class Unpin(Cog):
reference=message, reference=message,
mention_author=False mention_author=False
) )
if msg:
print("Message unpin sent successfully")

View file

@ -1,5 +1,6 @@
import discord import discord
from discord.ext.commands import Cog from discord.ext.commands import Cog
from decouple import config
def setup(bot: discord.Bot): def setup(bot: discord.Bot):
bot.add_cog(VCJoin(bot)) bot.add_cog(VCJoin(bot))
@ -7,7 +8,7 @@ def setup(bot: discord.Bot):
class VCJoin(Cog): class VCJoin(Cog):
"""Log when a member joins VC, for pinging purposes""" """Log when a member joins VC, for pinging purposes"""
CHANNEL = 949516670630760499 CHANNEL: int = config("DISCORD_VC_CHANNEL", cast=int)
def __init__(self, bot: discord.Bot): def __init__(self, bot: discord.Bot):
self.bot: discord.Bot = bot self.bot: discord.Bot = bot
@ -44,4 +45,6 @@ class VCJoin(Cog):
name = f"{member.display_name} ({member})", name = f"{member.display_name} ({member})",
icon_url = member.display_avatar.url, icon_url = member.display_avatar.url,
) )
return await self.channel.send(embed=embed) msg = await self.channel.send(embed=embed)
if msg:
print(embed.description[2:-2])

1141
pdm.lock Normal file

File diff suppressed because it is too large Load diff

46
pyproject.toml Normal file
View file

@ -0,0 +1,46 @@
[project]
name = "umi"
version = "rolling-waves"
description = "a self-hostable single-guild discord bot for playing music and announcing vc channel joins/leaves"
authors = [
{name = "a", email = "a@trwnh.com"},
]
dependencies = [
"py-cord[voice,speed]>=2.0.0rc1",
"asyncio",
"yt-dlp",
"python-decouple",
"httpx",
]
requires-python = ">=3.10"
license = {text = "AGPL"}
readme = "README.md"
[project.optional-dependencies]
[tool.pdm.scripts]
format = "yapf --in-place --recursive ./umi"
[tool.pdm.dev-dependencies]
dev = [
"yapf>=0.32.0",
"toml>=0.10.2",
]
[tool.yapf]
"use_tabs" = true
"indent_width" = 1
"blank_lines_between_top_level_imports_and_variables" = 0
"dedent_closing_brackets" = true
"coalesce_brackets" = false
"continuation_align_style" = "fixed"
"continuation_indent_width" = 1
"blank_lines_around_top_level_definition" = 1
"indent_blank_lines" = true
"spaces_before_comment" = 1
"split_arguments_when_comma_terminated" = true
"allow_split_before_dict_value" = false
[build-system]
requires = ["pdm-pep517>=1.0.0"]
build-backend = "pdm.pep517.api"

View file

@ -1,14 +1,5 @@
pip py-cord[voice,speed]>=2.0.0rc1
requests
pymumble
py-cord>=2.0.0rc1
py-cord[voice]>=2.0.0rc1
py-cord[speed]>=2.0.0rc1
asyncio asyncio
yt-dlp yt-dlp
pynacl python-decouple
aiodns httpx
brotlipy
cchardet
orjson
python-dotenv