├── lint-requirements.txt ├── tests_offline ├── __init__.py ├── test_fun.py ├── conftest.py ├── test_functions.py └── test_chat.py ├── i18n ├── planned.md └── en │ └── botlist.md ├── tests ├── __init__.py ├── test_issue.py ├── test_ping.py ├── test_dbl.py ├── test_meme.py ├── test_dice.py ├── test_support.py ├── test_reminders.py ├── test_events.py ├── test_context.py ├── test_patreons.py ├── test_help.py ├── test_dev.py ├── test_general.py ├── test_welcome.py ├── test_checks.py ├── test_reddit.py ├── test_fun.py ├── test_config.py ├── test_chat.py └── test_moderation.py ├── .github ├── CODEOWNERS ├── FUNDING.yml └── workflows │ └── push.yml ├── assets ├── friday-logo.png └── friday_april_fools.png ├── docker-compose.canary.yml ├── migrations ├── V5__welcome_ai.sql ├── V2__moving_file_dbs_to_actual_db.sql ├── V4__timezones.sql ├── V3__chatgpt_update.sql ├── V6__multiple_personas_and_chat_channels.sql └── V1__Initial_migration.sql ├── .editorconfig ├── .gitmodules ├── setup.cfg ├── deployment.yaml ├── pyproject.toml ├── cogs ├── __init__.py ├── ping.py ├── dice.py ├── datedevents.py ├── choosegame.py ├── issue.py ├── toyst.py ├── sharding.py ├── meme.py ├── support.py └── cleanup.py ├── .dockerignore ├── functions ├── reply.py ├── messagecolors.py ├── views.py ├── __init__.py ├── relay.py ├── myembed.py ├── exceptions.py ├── formats.py ├── cooldown.py ├── languages.py ├── cache.py ├── db.py ├── fuzzy.py ├── queryIntents.py ├── checks.py └── build_da_docs.py ├── requirements.txt ├── create_trans_key.py ├── asdtest_launcher.py ├── Dockerfile ├── README.md ├── .gitignore ├── docker-compose.yml ├── crowdin.yml.example ├── lavalink.config.yml └── test_will_it_blend.py /lint-requirements.txt: -------------------------------------------------------------------------------- 1 | flake8 -------------------------------------------------------------------------------- /tests_offline/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /i18n/planned.md: -------------------------------------------------------------------------------- 1 | https://lokalise.com/blog/beginners-guide-to-python-i18n/ 2 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import index 2 | import launcher 3 | 4 | __all__ = ("index", "launcher") 5 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Read More about CODEOWNERS file: https://git.io/JT4TZ 2 | 3 | * @brettanda -------------------------------------------------------------------------------- /assets/friday-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brettanda/friday-bot/HEAD/assets/friday-logo.png -------------------------------------------------------------------------------- /assets/friday_april_fools.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Brettanda/friday-bot/HEAD/assets/friday_april_fools.png -------------------------------------------------------------------------------- /docker-compose.canary.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - docker-compose.yml 3 | 4 | services: 5 | bot: 6 | entrypoint: sh -c "exec python3 index.py --canary" -------------------------------------------------------------------------------- /migrations/V5__welcome_ai.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V4 2 | -- Creation Date: 2023-03-28 05:34:33.464061 UTC 3 | -- Reason: welcome ai 4 | 5 | ALTER TABLE welcome ADD COLUMN IF NOT EXISTS ai BOOLEAN DEFAULT FALSE; -------------------------------------------------------------------------------- /migrations/V2__moving_file_dbs_to_actual_db.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V1 2 | -- Creation Date: 2023-02-05 22:08:42.441343 UTC 3 | -- Reason: moving file db's to actual db 4 | 5 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS lang text; -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "spice"] 2 | path = spice 3 | url = git@github.com:Brettanda/friday-bot-spice.git 4 | branch = master 5 | 6 | [submodule "docs"] 7 | path = docs 8 | url = git@github.com:Brettanda/friday-docs.git 9 | branch = master 10 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | indent-size = 2 3 | ignore = E501,E126,C901 4 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist 5 | max-complexity = 30 6 | max-line-length = 128 7 | in-place = false 8 | recursive = true 9 | aggressive = 1 10 | hand-closing = true -------------------------------------------------------------------------------- /migrations/V4__timezones.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V3 2 | -- Creation Date: 2023-03-27 02:47:46.467391 UTC 3 | -- Reason: timezones 4 | CREATE TABLE IF NOT EXISTS user_settings ( 5 | id BIGINT PRIMARY KEY, -- The discord user ID 6 | timezone TEXT -- The user's timezone 7 | ); 8 | 9 | ALTER TABLE reminders ADD COLUMN IF NOT EXISTS timezone TEXT NOT NULL DEFAULT 'UTC'; -------------------------------------------------------------------------------- /deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: friday-bot 5 | spec: 6 | selector: 7 | matchLabels: 8 | app: friday-bot 9 | replicas: 2 10 | template: 11 | metadata: 12 | labels: 13 | app: friday-bot 14 | spec: 15 | containers: 16 | - name: friday-bot 17 | image: friday-bot:latest 18 | imagePullPolicy: Never -------------------------------------------------------------------------------- /migrations/V3__chatgpt_update.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V2 2 | -- Creation Date: 2022-09-03 23:20:04.364877 UTC 3 | -- Reason: custom embeds 4 | ALTER TABLE chats ADD COLUMN IF NOT EXISTS messages JSONB; 5 | ALTER TABLE chats ADD COLUMN IF NOT EXISTS prompt_tokens INT DEFAULT 0; 6 | ALTER TABLE chats ADD COLUMN IF NOT EXISTS completion_tokens INT DEFAULT 0; 7 | ALTER TABLE chats ADD COLUMN IF NOT EXISTS total_tokens INT DEFAULT 0; -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.pyright] 2 | reportUnnecessaryTypeIgnoreComment = "warning" 3 | reportAssertAlwaysTrue = "warning" 4 | reportUnnecessaryIsInstance = "warning" 5 | pythonVersion = "3.12" 6 | typeCheckingMode = "basic" 7 | strictParameterNoneValue = false 8 | include = [ 9 | "cogs", 10 | "functions" 11 | ] 12 | exclude = [ 13 | "**/__pycache__", 14 | "build", 15 | "dist" 16 | ] 17 | 18 | [tool.pytest.ini_options] 19 | testpaths = [ 20 | "tests", 21 | "tests_offline" 22 | ] -------------------------------------------------------------------------------- /cogs/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ignore = [ 4 | "log.py", 5 | "__init__.py", 6 | "database.py", 7 | ] 8 | 9 | default = [ 10 | com[:-3] for com in os.listdir("./cogs") 11 | if com.endswith(".py") and com not in ignore 12 | ] 13 | 14 | try: 15 | spice = [ 16 | com[:-3] for com in os.listdir("./spice/cogs") 17 | if com.endswith(".py") and com not in ignore 18 | ] 19 | except FileNotFoundError: 20 | spice = [] 21 | 22 | __all__ = ( 23 | "default", 24 | "spice", 25 | ) 26 | -------------------------------------------------------------------------------- /tests/test_issue.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from discord import TextChannel 9 | 10 | from .conftest import Friday, UnitTester 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | 15 | @pytest.mark.dependency() 16 | async def test_get_cog(friday: Friday): 17 | assert friday.get_cog("Issue") is not None 18 | 19 | 20 | @pytest.mark.skip("Not implemented") 21 | @pytest.mark.dependency(depends=["test_get_cog"]) 22 | async def test_issue(bot: UnitTester, channel: TextChannel): 23 | ... 24 | -------------------------------------------------------------------------------- /tests/test_ping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord.channel import TextChannel 11 | 12 | from .conftest import UnitTester 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def test_ping(bot: UnitTester, channel: TextChannel): 18 | content = "!ping" 19 | com = await send_command(bot, channel, content) 20 | 21 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 22 | assert msg.embeds[0].title == "Pong!" 23 | assert "API is" in msg.embeds[0].description 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: fridaybot 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/__pycache__ 2 | **/.venv 3 | **/.classpath 4 | **/.dockerignore 5 | **/.env 6 | **/.env.* 7 | **/.git 8 | **/.gitignore 9 | **/.project 10 | **/.settings 11 | **/.toolstarget 12 | **/.vs 13 | **/.vscode 14 | **/*.*proj.user 15 | **/*.dbmdl 16 | **/*.jfm 17 | **/bin 18 | **/charts 19 | **/docker-compose* 20 | **/compose* 21 | **/Dockerfile* 22 | **/node_modules 23 | **/npm-debug.log 24 | **/obj 25 | **/secrets.dev.yaml 26 | **/values.dev.yaml 27 | LICENSE 28 | README.md 29 | */revisions.json 30 | .vscode/ 31 | .github/ 32 | __pycache__/ 33 | wandb/ 34 | hidden/ 35 | ollama/ 36 | database/ 37 | chatml/ 38 | venv/ 39 | spice/ 40 | lavalink.config.yml 41 | *.zip 42 | .mypy_cache/ 43 | .pytest_cache/ 44 | .editorconfig 45 | .gitmodules 46 | *.example 47 | *.sql 48 | *.csv -------------------------------------------------------------------------------- /functions/reply.py: -------------------------------------------------------------------------------- 1 | from discord import Forbidden, HTTPException 2 | 3 | 4 | async def msg_reply(message, content=None, **kwargs): 5 | if not hasattr(kwargs, "mention_author"): 6 | kwargs.update({"mention_author": False}) 7 | try: 8 | return await message.reply(content, **kwargs) 9 | except Forbidden as e: 10 | if "Cannot reply without permission" in str(e): 11 | try: 12 | return await message.channel.send(content, **kwargs) 13 | except Exception: 14 | pass 15 | elif "Missing Permissions" in str(e): 16 | pass 17 | else: 18 | raise e 19 | except HTTPException as e: 20 | if "Unknown message" in str(e): 21 | try: 22 | return await message.channel.send(content, **kwargs) 23 | except Exception: 24 | pass 25 | else: 26 | raise e 27 | -------------------------------------------------------------------------------- /tests/test_dbl.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord import TextChannel 11 | 12 | from .conftest import Friday, UnitTester 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | @pytest.mark.dependency() 18 | async def test_get_cog(friday: Friday): 19 | assert friday.get_cog("TopGG") is not None 20 | 21 | 22 | @pytest.mark.dependency(depends=["test_get_cog"]) 23 | async def test_vote(bot: UnitTester, channel: TextChannel): 24 | content = "!vote" 25 | com = await send_command(bot, channel, content) 26 | 27 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 28 | assert msg.embeds[0].title == "Voting" 29 | -------------------------------------------------------------------------------- /tests_offline/test_fun.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | from async_timeout import timeout 7 | from discord.ext.commands import BadArgument 8 | 9 | from cogs.fun import Fun 10 | 11 | if TYPE_CHECKING: 12 | ... 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | @pytest.mark.parametrize("size", range(0, 10)) 18 | @pytest.mark.parametrize("bombs", range(0, 20)) 19 | async def test_minesweeper(event_loop, size: int, bombs: int): 20 | class bot: 21 | loop = event_loop 22 | fun = Fun(bot) # type: ignore 23 | async with timeout(0.1): 24 | try: 25 | mines = await event_loop.run_in_executor(None, fun.mine_sweeper, size, bombs) 26 | except (BadArgument, ValueError): 27 | assert True 28 | else: 29 | print(f"completed {size} {bombs}") 30 | assert mines.count("💥") <= bombs 31 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp 2 | async-timeout 3 | autopep8 4 | d20 5 | topggpy==1.4.0 6 | discord-ext-menus @ git+https://github.com/Rapptz/discord-ext-menus@fbb8803779373357e274e1540b368365fd9d8074 7 | discord.py[speed,docs,test]==2.4.0 8 | click 9 | fuzzywuzzy==0.18.0 10 | python-Levenshtein 11 | # py-cord>=v2.0.0b4 12 | # flair==0.8.0.post1 13 | grpcio 14 | h5py 15 | lxml 16 | pytz 17 | numpy 18 | oauthlib==3.2.2 19 | Pillow 20 | psutil 21 | expiringdict==1.2.2 22 | python-dateutil==2.8.2 23 | python-dotenv==0.21.1 24 | requests 25 | urllib3 26 | validators==0.20.0 27 | wavelink==3.4.1 28 | youtube-dl==2021.1.24.1 29 | google-cloud-translate==3.10.1 30 | openai>=1.37.0 31 | python-slugify==8.0.0 32 | profanity==1.1 33 | pyfiglet==0.8.post1 34 | asyncpg>=0.27.0 35 | asyncpraw>=7.6.1 36 | pytest-dependency==0.5.1 37 | humanize==4.5.0 38 | lru-dict 39 | aiohttp-cors 40 | parsedatetime==2.6 41 | sympy==1.11 -------------------------------------------------------------------------------- /tests/test_meme.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from functions.messagecolors import MessageColors 8 | 9 | from .conftest import send_command, msg_check 10 | 11 | if TYPE_CHECKING: 12 | from discord import TextChannel 13 | 14 | from .conftest import Friday, UnitTester 15 | 16 | pytestmark = pytest.mark.asyncio 17 | 18 | 19 | @pytest.mark.dependency() 20 | async def test_get_cog(friday: Friday): 21 | assert friday.get_cog("Meme") is not None 22 | 23 | 24 | @pytest.mark.dependency(depends=["test_get_cog"]) 25 | async def test_meme(bot: UnitTester, channel: TextChannel): 26 | content = "!meme" 27 | com = await send_command(bot, channel, content) 28 | 29 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 30 | assert msg.embeds[0].color.value == MessageColors.meme().value 31 | -------------------------------------------------------------------------------- /create_trans_key.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | 6 | load_dotenv() 7 | 8 | 9 | def run(): 10 | with open("friday-trans-key.json", "w") as key: 11 | content = { 12 | "type": "service_account", 13 | "project_id": os.environ.get("PROJECT_ID"), 14 | "private_key_id": os.environ.get("PRIVATE_KEY_ID"), 15 | "private_key": os.environ.get("PRIVATE_KEY"), 16 | "client_email": os.environ.get("CLIENT_EMAIL"), 17 | "client_id": os.environ.get("CLIENT_ID"), 18 | "auth_uri": "https://accounts.google.com/o/oauth2/auth", 19 | "token_uri": "https://oauth2.googleapis.com/token", 20 | "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", 21 | "client_x509_cert_url": os.environ.get("CLIENT_CERT_URL") 22 | } 23 | 24 | key.write(json.dumps(content, ensure_ascii=True, indent=2)) 25 | key.close() 26 | 27 | 28 | if __name__ == "__main__": 29 | run() 30 | -------------------------------------------------------------------------------- /cogs/ping.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from discord.ext import commands 4 | from typing import TYPE_CHECKING 5 | 6 | from functions import embed 7 | 8 | if TYPE_CHECKING: 9 | from functions import MyContext 10 | from index import Friday 11 | 12 | 13 | class Ping(commands.Cog): 14 | """Ping? Pong!""" 15 | 16 | def __init__(self, bot: Friday): 17 | self.bot: Friday = bot 18 | 19 | def __repr__(self) -> str: 20 | return f"" 21 | 22 | @commands.hybrid_command(name="ping") 23 | async def ping(self, ctx: MyContext): 24 | """Pong!""" 25 | shard = ctx.guild and self.bot.get_shard(ctx.guild.shard_id) 26 | latency = f"{shard.latency*1000:,.0f}" if shard is not None else f"{self.bot.latency*1000:,.0f}" 27 | await ctx.send(embed=embed(title=ctx.lang.ping.ping.response_title, description=ctx.lang.ping.ping.response_description.format(ping=latency))) 28 | 29 | 30 | async def setup(bot): 31 | await bot.add_cog(Ping(bot)) 32 | -------------------------------------------------------------------------------- /tests/test_dice.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from functions.messagecolors import MessageColors 8 | 9 | from .conftest import send_command, msg_check 10 | 11 | if TYPE_CHECKING: 12 | from discord import TextChannel 13 | 14 | from .conftest import Friday, UnitTester 15 | 16 | pytestmark = pytest.mark.asyncio 17 | 18 | 19 | @pytest.mark.dependency() 20 | async def test_get_cog(friday: Friday): 21 | assert friday.get_cog("Dice") is not None 22 | 23 | 24 | @pytest.mark.parametrize("roll", ["1d20", "2d8", "1d20k7", "1*3", ""]) 25 | @pytest.mark.dependency(depends=["test_get_cog"]) 26 | async def test_dice(bot: UnitTester, channel: TextChannel, roll: str): 27 | content = f"!dice {roll}" 28 | com = await send_command(bot, channel, content) 29 | 30 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 31 | if roll == "": 32 | assert msg.embeds[0].title == "!dice" 33 | else: 34 | assert "Your total:" in msg.embeds[0].title and msg.embeds[0].color.value == MessageColors.default().value 35 | -------------------------------------------------------------------------------- /functions/messagecolors.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from discord import Colour 6 | 7 | if TYPE_CHECKING: 8 | from typing_extensions import Self 9 | 10 | 11 | class MessageColors(Colour): 12 | MUSIC = 0x7BDCFC 13 | SOUPTIME = 0xFFD700 14 | NOU = 0xFFD700 15 | MEME = 0x00A2E8 16 | RPS = 0xBAFAE5 17 | LOGGING = 0xFFD700 18 | ERROR = 0xD40000 19 | DEFAULT = 0xFDFDFD 20 | 21 | @classmethod 22 | def music(cls) -> Self: 23 | return cls(0x7BDCFC) 24 | 25 | @classmethod 26 | def souptime(cls) -> Self: 27 | return cls(0xFFD700) 28 | 29 | @classmethod 30 | def nou(cls) -> Self: 31 | return cls(0xFFD700) 32 | 33 | @classmethod 34 | def meme(cls) -> Self: 35 | return cls(0x00A2E8) 36 | 37 | @classmethod 38 | def rps(cls) -> Self: 39 | return cls(0xBAFAE5) 40 | 41 | @classmethod 42 | def logging(cls) -> Self: 43 | return cls(0xFFD700) 44 | 45 | @classmethod 46 | def error(cls) -> Self: 47 | return cls(0xD40000) 48 | 49 | @classmethod 50 | def default(cls) -> Self: 51 | return cls(0xFDFDFD) 52 | -------------------------------------------------------------------------------- /functions/views.py: -------------------------------------------------------------------------------- 1 | import discord 2 | # from typing import TYPE_CHECKING 3 | 4 | # if TYPE_CHECKING: 5 | # from index import Friday as Bot 6 | 7 | 8 | class PersistantButtons(discord.ui.View): 9 | def __init__(self): 10 | super().__init__(timeout=None) 11 | 12 | 13 | class StopButton(PersistantButtons): 14 | @discord.ui.button(emoji="⏹", label="Stop", style=discord.ButtonStyle.danger, custom_id="stopbutton-stop") 15 | async def stop(self, interaction: discord.Interaction, button: discord.ui.Button): 16 | if interaction.message: 17 | await interaction.message.delete() 18 | 19 | 20 | class Links(PersistantButtons): 21 | def __init__(self): 22 | super().__init__() 23 | for item in self.links: 24 | self.add_item(item) 25 | 26 | @discord.utils.cached_property 27 | def links(self) -> list: 28 | return [discord.ui.Button(label="Support Server", url="https://discord.gg/paMxRvvZFc", row=1), 29 | discord.ui.Button(label="Patreon", url="https://www.patreon.com/fridaybot", row=1), 30 | discord.ui.Button(label="Docs", url="https://docs.friday-bot.com/", row=1), 31 | discord.ui.Button(label="Vote", url="https://top.gg/bot/476303446547365891/vote", row=1)] 32 | -------------------------------------------------------------------------------- /asdtest_launcher.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | # import logging 3 | import os 4 | 5 | from discord.ext import tasks 6 | from dotenv import load_dotenv 7 | 8 | from launcher import Launcher 9 | from index import Friday 10 | import cogs 11 | 12 | # from create_trans_key import run 13 | 14 | load_dotenv(dotenv_path="./.env") 15 | TOKEN = os.environ.get('TOKENTEST') 16 | 17 | 18 | class Friday_testing(Friday): 19 | def __init__(self, *args, **kwargs): 20 | super().__init__(*args, **kwargs) 21 | 22 | self.test_stop.start() 23 | self.test_message.start() 24 | 25 | def load_cogs(self): 26 | for cog in cogs.default: 27 | self.load_extension(f"cogs.{cog}") 28 | 29 | @tasks.loop() 30 | async def test_message(self): 31 | print("passed") 32 | 33 | @tasks.loop(seconds=1, count=1) 34 | async def test_stop(self): 35 | await self.wait_until_ready() 36 | while not self.ready: 37 | await asyncio.sleep(0.1) 38 | assert await super().close() 39 | 40 | 41 | # TODO: Add a check for functions modules/files not being named the same as the functions/defs 42 | 43 | # def test_translate_key_gen(): 44 | # run() 45 | 46 | 47 | def test_will_it_blend(): 48 | loop = asyncio.get_event_loop() 49 | Launcher(loop).start() 50 | -------------------------------------------------------------------------------- /functions/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from .messagecolors import MessageColors 4 | from . import exceptions, checks, config # , queryIntents # , queryGen 5 | from .myembed import embed 6 | from . import cache 7 | from . import db 8 | from .custom_contexts import MyContext # , FakeInteractionMessage # , MySlashContext 9 | from .reply import msg_reply 10 | from . import time 11 | from . import fuzzy 12 | from .relay import relay_info 13 | from .build_da_docs import build as build_docs 14 | from . import views 15 | from . import formats 16 | from . import paginator 17 | from . import cooldown 18 | 19 | dev_guilds = [243159711237537802, 707441352367013899, 215346091321720832] 20 | 21 | modules = [mod[:-3] for mod in os.listdir("./functions") if mod.endswith(".py") and mod != "__init__.py" and mod != "queryGen.py" and mod != "queryIntents.py"] 22 | 23 | 24 | __all__ = ( 25 | "MessageColors", 26 | "views", 27 | "db", 28 | "cache", 29 | "fuzzy", 30 | "build_docs", 31 | "config", 32 | "MyContext", 33 | "msg_reply", 34 | "relay_info", 35 | "exceptions", 36 | "embed", 37 | "checks", 38 | "time", 39 | "formats", 40 | "cooldown", 41 | "paginator", 42 | ) 43 | -------------------------------------------------------------------------------- /functions/relay.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import logging 3 | import os 4 | 5 | import discord 6 | from typing import TYPE_CHECKING 7 | if TYPE_CHECKING: 8 | from index import Friday 9 | from cogs.log import CustomWebhook 10 | 11 | MISSING = discord.utils.MISSING 12 | 13 | 14 | async def relay_info( 15 | msg: str, 16 | bot: Friday, 17 | embed: discord.Embed = MISSING, 18 | file: discord.File = MISSING, 19 | filefirst: bool = MISSING, 20 | short: str = MISSING, 21 | webhook: CustomWebhook = MISSING, 22 | logger=logging.getLogger(__name__) 23 | ): 24 | if webhook is MISSING: 25 | webhook = bot.log.log_info 26 | if bot.prod or bot.canary: 27 | await bot.wait_until_ready() 28 | thispath = os.getcwd() 29 | if "\\" in thispath: 30 | seperator = "\\\\" 31 | else: 32 | seperator = "/" 33 | avatar_url = bot.user.display_avatar.url 34 | await webhook.safe_send(username=bot.user.name, avatar_url=avatar_url, content=msg, embed=embed if not filefirst else MISSING, file=discord.File(f"{thispath}{seperator}{file}", filename="Error.txt") if filefirst else MISSING) 35 | 36 | if short is not MISSING: 37 | logger.info(short) 38 | else: 39 | logger.info(msg) 40 | -------------------------------------------------------------------------------- /migrations/V6__multiple_personas_and_chat_channels.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V5 2 | -- Creation Date: 2023-04-05 00:45:54.291109 UTC 3 | -- Reason: multiple personas and chat channels 4 | 5 | 6 | CREATE TABLE IF NOT EXISTS chatchannels ( 7 | id TEXT PRIMARY KEY, 8 | guild_id TEXT NOT NULL, 9 | webhook_url TEXT, 10 | persona TEXT NOT NULL DEFAULT 'default', 11 | persona_custom VARCHAR(100), 12 | FOREIGN KEY (guild_id) REFERENCES servers(id) 13 | ); 14 | 15 | 16 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS chatchannel TEXT NULL DEFAULT NULL; 17 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS chatchannel_webhook TEXT NULL DEFAULT NULL; 18 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS persona TEXT NOT NULL DEFAULT 'default'; 19 | ALTER TABLE servers ADD COLUMN IF NOT EXISTS persona_custom TEXT NULL DEFAULT NULL; 20 | 21 | INSERT INTO chatchannels (id, guild_id, webhook_url, persona) 22 | SELECT chatchannel, id, chatchannel_webhook, persona 23 | FROM servers 24 | WHERE chatchannel IS NOT NULL 25 | ON CONFLICT DO NOTHING; 26 | 27 | ALTER TABLE servers DROP COLUMN IF EXISTS chatchannel; 28 | ALTER TABLE servers DROP COLUMN IF EXISTS chatchannel_webhook; 29 | ALTER TABLE servers DROP COLUMN IF EXISTS persona; 30 | ALTER TABLE servers DROP COLUMN IF EXISTS persona_custom; 31 | -------------------------------------------------------------------------------- /tests/test_support.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord.channel import TextChannel 11 | 12 | from .conftest import Friday, UnitTester 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | @pytest.mark.dependency() 18 | async def test_get_cog(friday: Friday): 19 | assert friday.get_cog("Support") is not None 20 | 21 | 22 | @pytest.mark.dependency(depends=["test_get_cog"]) 23 | async def test_support(bot: UnitTester, channel: TextChannel): 24 | content = "!support" 25 | com = await send_command(bot, channel, content) 26 | 27 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout / 2) # type: ignore 28 | assert msg.content == "https://discord.gg/NTRuFjU" 29 | 30 | 31 | @pytest.mark.dependency(depends=["test_get_cog"]) 32 | async def test_donate(bot: UnitTester, channel: TextChannel): 33 | content = "!donate" 34 | com = await send_command(bot, channel, content) 35 | 36 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout / 2) # type: ignore 37 | assert msg.content == "https://www.patreon.com/bePatron?u=42649008" 38 | -------------------------------------------------------------------------------- /tests/test_reminders.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord.channel import TextChannel 11 | 12 | from .conftest import Friday, UnitTester 13 | 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | @pytest.mark.dependency() 19 | async def test_get_cog(friday: Friday): 20 | assert friday.get_cog("Reminder") is not None 21 | 22 | 23 | @pytest.mark.dependency(depends=["test_get_cog"]) 24 | async def test_reminders(bot: UnitTester, channel: TextChannel): 25 | content = "!remind me in 5 minutes to do this" 26 | com = await send_command(bot, channel, content) 27 | 28 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 29 | assert "Reminder set" in msg.embeds[0].title 30 | 31 | 32 | @pytest.mark.dependency(depends=["test_get_cog"]) 33 | async def test_reminders_list(bot: UnitTester, channel: TextChannel): 34 | content = "!remind list" 35 | com = await send_command(bot, channel, content) 36 | 37 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 38 | assert msg.embeds[0].title == "Reminders" 39 | -------------------------------------------------------------------------------- /tests_offline/conftest.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | 5 | # import discord 6 | import pytest 7 | 8 | # import os 9 | # import sys 10 | # import time 11 | # from typing import Callable, Optional 12 | 13 | # from discord.ext import commands 14 | # from dotenv import load_dotenv 15 | 16 | # import index 17 | # from launcher import setup_logging 18 | 19 | 20 | @pytest.fixture(scope="session") 21 | def event_loop() -> asyncio.AbstractEventLoop: 22 | return asyncio.new_event_loop() 23 | 24 | 25 | # @pytest.fixture(scope="session", autouse=True) 26 | # async def cleanup(request, event_loop: asyncio.AbstractEventLoop): 27 | # def close(): 28 | # # try: 29 | # # event_loop.close() 30 | # # event_loop_friday.close() 31 | # # event_loop_user.close() 32 | # # except (RuntimeError, StopAsyncIteration): 33 | # # pass 34 | # try: 35 | # asyncio.get_event_loop().run_until_complete(bot.close()) 36 | # except (RuntimeError, StopAsyncIteration): 37 | # pass 38 | # try: 39 | # asyncio.get_event_loop().run_until_complete(friday.close()) 40 | # except (RuntimeError, StopAsyncIteration): 41 | # pass 42 | # try: 43 | # asyncio.get_event_loop().run_until_complete(bot_user.close()) 44 | # except (RuntimeError, StopAsyncIteration): 45 | # pass 46 | # request.addfinalizer(close) 47 | -------------------------------------------------------------------------------- /tests/test_events.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | # import asyncio 4 | import datetime 5 | from typing import TYPE_CHECKING 6 | 7 | import discord 8 | import pytest 9 | 10 | if TYPE_CHECKING: 11 | from discord import Guild 12 | 13 | from .conftest import Friday, UnitTester 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | @pytest.mark.dependency() 19 | async def test_get_cog(friday: Friday): 20 | assert friday.get_cog("ScheduledEvents") is not None 21 | 22 | 23 | @pytest.fixture(scope="module") 24 | async def event(bot: UnitTester, guild: Guild) -> discord.ScheduledEvent: # type: ignore 25 | await bot.wait_until_ready() 26 | event = await guild.create_scheduled_event( 27 | name="Test event", 28 | description="This is a test", 29 | entity_type=discord.EntityType.external, 30 | start_time=discord.utils.utcnow(), 31 | end_time=discord.utils.utcnow() + datetime.timedelta(hours=1), 32 | location="Test location", 33 | reason="Testing" 34 | ) 35 | yield event 36 | await event.delete() 37 | 38 | 39 | @pytest.fixture(scope="module") 40 | async def role(bot: UnitTester, guild: Guild) -> discord.Role: # type: ignore 41 | await bot.wait_until_ready() 42 | role = await guild.create_role( 43 | name="Test event role", 44 | reason="Testing" 45 | ) 46 | yield role 47 | await role.delete() 48 | 49 | 50 | # async def test_add_event_role(bot: UnitTester, channel: TextChannel, event: event, role: role): 51 | # content = f"!eventrole set {event.url} {role.id}" 52 | # com = await channel.send(content) 53 | # assert com 54 | -------------------------------------------------------------------------------- /cogs/dice.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import d20 6 | from discord import app_commands 7 | from discord.ext import commands 8 | 9 | from functions import MessageColors, embed 10 | 11 | if TYPE_CHECKING: 12 | from functions import MyContext 13 | from index import Friday 14 | 15 | 16 | class Dice(commands.Cog): 17 | """Roll some dice with advantage or just do some basic math.""" 18 | 19 | def __init__(self, bot: Friday) -> None: 20 | self.bot: Friday = bot 21 | 22 | def __repr__(self) -> str: 23 | return f"" 24 | 25 | @commands.hybrid_command(extras={"examples": ["1d20", "5d10k3", "d6"]}, aliases=["d", "r", "roll"]) 26 | @app_commands.describe(roll="The roll to be made. How to: https://d20.readthedocs.io/en/latest/start.html") 27 | async def dice(self, ctx: MyContext, *, roll: str): 28 | """Dungeons and Dragons dice rolling""" 29 | if "bump" in roll.lower(): 30 | raise commands.NotOwner() 31 | 32 | roll = roll.lower() 33 | 34 | result = None 35 | try: 36 | result = d20.roll(roll) 37 | except Exception as e: 38 | return await ctx.send(embed=embed(title=f"{e}", color=MessageColors.error())) 39 | else: 40 | return await ctx.send(embed=embed( 41 | title=ctx.lang.dice.dice.response_title.format(total=str(result.total)), 42 | description=ctx.lang.dice.dice.response_description.format(query=str(result.ast), result=str(result)))) 43 | 44 | 45 | async def setup(bot): 46 | await bot.add_cog(Dice(bot)) 47 | -------------------------------------------------------------------------------- /tests_offline/test_functions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import pytest 4 | 5 | from functions.config import PremiumPerks, PremiumTiersNew 6 | 7 | pytestmark = pytest.mark.asyncio 8 | 9 | 10 | async def test_premium_perks(): 11 | perks = PremiumPerks() 12 | assert perks.tier == PremiumTiersNew.free 13 | assert perks.chat_ratelimit 14 | # assert perks.guild_role is None 15 | # assert perks.max_chat_channels == 1 16 | assert perks.max_chat_tokens == 25 17 | assert perks.max_chat_characters == 100 18 | assert perks.max_chat_history == 3 19 | 20 | perks = PremiumPerks(PremiumTiersNew.voted) 21 | assert perks.tier == PremiumTiersNew.voted 22 | assert perks.chat_ratelimit 23 | # assert perks.guild_role is None 24 | # assert perks.max_chat_channels == 1 25 | assert perks.max_chat_tokens == 25 26 | assert perks.max_chat_characters == 200 27 | assert perks.max_chat_history == 3 28 | 29 | perks = PremiumPerks(PremiumTiersNew.streaked) 30 | assert perks.tier == PremiumTiersNew.streaked 31 | assert perks.chat_ratelimit 32 | # assert perks.guild_role is None 33 | # assert perks.max_chat_channels == 1 34 | assert perks.max_chat_tokens == 25 35 | assert perks.max_chat_characters == 200 36 | assert perks.max_chat_history == 3 37 | 38 | perks = PremiumPerks(PremiumTiersNew.tier_1) 39 | assert perks.tier == PremiumTiersNew.tier_1 40 | assert perks.chat_ratelimit 41 | # assert perks.guild_role is not None 42 | # assert perks.max_chat_channels == 1 43 | assert perks.max_chat_tokens == 50 44 | assert perks.max_chat_characters == 200 45 | assert perks.max_chat_history == 5 46 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Temp Stage 2 | FROM python:3.12-slim-bullseye AS build 3 | 4 | # https://stackoverflow.com/questions/68673221/warning-running-pip-as-the-root-user 5 | ENV PIP_ROOT_USER_ACTION=ignore 6 | 7 | # Keeps Python from generating .pyc files in the container 8 | ENV PYTHONDONTWRITEBYTECODE=1 9 | 10 | # Turns off buffering for easier container logging 11 | ENV PYTHONUNBUFFERED=1 12 | 13 | WORKDIR /usr/src/app 14 | 15 | RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ 16 | --mount=type=cache,target=/var/lib/apt,sharing=locked \ 17 | apt-get update && apt-get install -y ffmpeg curl git 18 | 19 | RUN --mount=type=cache,target=/root/.cache/pip \ 20 | --mount=type=bind,source=requirements.txt,target=/tmp/requirements.txt \ 21 | pip install --no-cache --no-cache-dir --upgrade pip && \ 22 | pip install --no-cache --no-cache-dir --requirement /tmp/requirements.txt 23 | 24 | # # RUN wget -nc -nc https://the-eye.eu/public/AI/models/nomic-ai/gpt4all/gpt4all-lora-quantized-ggml.bin -P /usr/src/app/models 25 | 26 | 27 | # Final Stage 28 | FROM python:3.12-slim-bullseye 29 | 30 | # Keeps Python from generating .pyc files in the container 31 | ENV PYTHONDONTWRITEBYTECODE=1 32 | 33 | # Turns off buffering for easier container logging 34 | ENV PYTHONUNBUFFERED=1 35 | 36 | COPY --from=build /usr/bin/ffmpeg /usr/local/bin/ffmpeg 37 | 38 | WORKDIR /usr/src/app 39 | 40 | COPY --from=build /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages 41 | 42 | COPY . . 43 | 44 | # EXPOSE 443 45 | 46 | HEALTHCHECK --interval=60s --timeout=10s --start-period=60s --retries=5 \ 47 | CMD curl -f http://localhost:443/version || exit 1 48 | 49 | # Just in case https://hynek.me/articles/docker-signals/ 50 | STOPSIGNAL SIGINT 51 | 52 | ENTRYPOINT python index.py db upgrade && exec python index.py -------------------------------------------------------------------------------- /functions/myembed.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Collection, Optional, Union, Any 4 | 5 | import discord 6 | 7 | from functions.messagecolors import MessageColors 8 | 9 | 10 | MISSING = discord.utils.MISSING 11 | 12 | 13 | class embed(discord.Embed): 14 | def __init__(self, 15 | *, 16 | author_name: Optional[str] = None, 17 | author_url: Optional[str] = None, 18 | author_icon: Optional[str] = None, 19 | image: Optional[str] = None, 20 | thumbnail: Optional[str] = None, 21 | footer: Optional[str] = None, 22 | footer_icon: Optional[str] = None, 23 | fieldstitle: Optional[Union[Any, Collection[Any]]] = None, 24 | fieldsval: Optional[Union[Any, Collection[Any]]] = None, 25 | fieldsin: Optional[Union[bool, Collection[bool]]] = None, 26 | **kwargs) -> None: 27 | super().__init__(**kwargs) 28 | if self.color is MISSING or self.color is None: 29 | self.color = MessageColors.default() 30 | 31 | if author_name: 32 | self.set_author(name=author_name, url=author_url, icon_url=author_icon) 33 | 34 | self.set_image(url=image) 35 | self.set_thumbnail(url=thumbnail) 36 | self.set_footer(text=footer, icon_url=footer_icon) 37 | 38 | if fieldstitle and fieldsin is None: 39 | fieldsin = [True] * len(fieldstitle) 40 | 41 | if fieldstitle and fieldsval and fieldsin is not None: 42 | if isinstance(fieldstitle, str) and isinstance(fieldsval, str) and isinstance(fieldsin, bool): 43 | self.add_field(name=fieldstitle, value=fieldsval, inline=fieldsin) 44 | else: 45 | for t, v, i in zip(fieldstitle, fieldsval, fieldsin): # type: ignore 46 | self.add_field(name=t, value=v, inline=i) 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Friday Discord Bot 2 | 3 | [![Codacy Badge](https://app.codacy.com/project/badge/Grade/0ad7826bb256410d885a47fca99ce624)](https://www.codacy.com/gh/Brettanda/friday-discord-python/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Brettanda/friday-discord-python&utm_campaign=Badge_Grade) 4 | [![Discord Chat](https://discord.com/api/guilds/707441352367013899/embed.png)](https://discord.gg/NTRuFjU) 5 | [![Vote](https://img.shields.io/badge/Vote-Friday-blue)](https://top.gg/bot/476303446547365891/vote) 6 | [![Add Friday to your server](https://img.shields.io/badge/Add%20Friday-to%20your%20server-orange)](https://discord.com/api/oauth2/authorize?client_id=476303446547365891&permissions=2469521478&scope=bot%20applications.commands) 7 | [![Become a Patron!](https://img.shields.io/badge/-Become%20a%20Patron!-rgb(232%2C%2091%2C%2070))](https://www.patreon.com/fridaybot) 8 | [![Will it blend?](https://github.com/Brettanda/friday-discord-python/actions/workflows/push.yml/badge.svg)](https://github.com/Brettanda/friday-discord-python/actions/workflows/push.yml) 9 | 10 | [friday-bot.com](https://friday-bot.com) 11 | 12 | Running your own instance of this bot is not prefered. This repository is meant for educational purposes only. 13 | 14 | To add this bot to your server use the `!invite` command or go to [friday-bot.com/invite](https://friday-bot.com/invite). 15 | 16 | ## Commands 17 | 18 | The full list of uptodate commands can be found on the [docs](https://docs.friday-bot.com) page. 19 | 20 | ## License 21 | 22 | Creative Commons Licence
This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. -------------------------------------------------------------------------------- /functions/exceptions.py: -------------------------------------------------------------------------------- 1 | from discord.ext.commands import CommandError 2 | # from discord_slash.error import SlashCommandError 3 | 4 | 5 | class Base(CommandError): 6 | def __init__(self, message=None, *args, **kwargs): 7 | self.log = False 8 | super().__init__(message=message, *args, **kwargs) 9 | 10 | def __str__(self): 11 | return super().__str__() 12 | 13 | 14 | # class SlashBase(SlashCommandError): 15 | # def __init__(self, message=None, *args, **kwargs): 16 | # super().__init__(message=message, *args, **kwargs) 17 | 18 | # def __str__(self): 19 | # return super().__str__() 20 | 21 | 22 | class ArgumentTooLarge(Base): 23 | def __init__(self, message="That argument number is too big", *args, **kwargs): 24 | super().__init__(message=message, *args, **kwargs) 25 | 26 | 27 | class ArgumentTooSmall(Base): 28 | def __init__(self, message="That argument number is too small", *args, **kwargs): 29 | super().__init__(message=message, *args, **kwargs) 30 | 31 | 32 | class OnlySlashCommands(Base): 33 | """ An exception for when I have been added to a server without a bot user""" 34 | 35 | def __init__(self, message="I need to be added to this server with my bot account for this command to work. Please use the link found on ", *args, **kwargs): 36 | super().__init__(message=message, *args, **kwargs) 37 | 38 | 39 | class NotSupporter(Base): 40 | def __init__(self, message="You need to be a Patreon supporter to use this command.", *args, **kwargs): 41 | super().__init__(message=message, *args, **kwargs) 42 | 43 | 44 | class RequiredTier(Base): 45 | def __init__(self, message="You do not have the required Patreon tier for this command.", *args, **kwargs): 46 | super().__init__(message=message, *args, **kwargs) 47 | 48 | 49 | class NotInSupportServer(Base): 50 | def __init__(self, message="You need to be in my support server for this command.", *args, **kwargs): 51 | super().__init__(message=message, *args, **kwargs) 52 | -------------------------------------------------------------------------------- /tests/test_context.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | # import discord 6 | import pytest 7 | 8 | from functions import MyContext 9 | 10 | # import asyncio 11 | 12 | if TYPE_CHECKING: 13 | from discord import TextChannel 14 | 15 | from .conftest import Friday, UnitTester 16 | 17 | pytestmark = pytest.mark.asyncio 18 | 19 | 20 | @pytest.fixture(scope="module") 21 | async def context(friday: Friday, channel: TextChannel) -> MyContext: # type: ignore 22 | await friday.wait_until_ready() 23 | content = "this is a message" 24 | message = await channel.send(content) 25 | assert message 26 | ctx = await friday.get_context(message, cls=MyContext) 27 | yield ctx 28 | 29 | # yield voice 30 | # await voice.disconnect() 31 | # await channel.send("!stop") 32 | 33 | 34 | async def test_all_properties(friday: Friday, context: MyContext): 35 | ctx = context 36 | assert ctx.db is not None 37 | assert ctx.lang is not None 38 | assert ctx.guild is not None 39 | assert ctx.channel is not None 40 | assert ctx.author is not None 41 | assert ctx.message is not None 42 | assert hasattr(ctx, "prompt") 43 | assert hasattr(ctx, "get_lang") 44 | assert hasattr(ctx, "release") 45 | assert hasattr(ctx, "acquire") 46 | assert hasattr(ctx, "multi_select") 47 | assert hasattr(ctx, "safe_send") 48 | 49 | # async def test_prompt(friday: friday, bot: UnitTester, context: context, channel: TextChannel): 50 | 51 | # asyncio.create_task(ctx.prompt("this is a prompt")) 52 | # msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 53 | 54 | 55 | async def test_delete_message_before_response(bot: UnitTester, friday: Friday, channel: TextChannel): 56 | content = "!ping" 57 | msg = await channel.send(content) 58 | assert msg 59 | 60 | await msg.delete() 61 | msg = await bot.wait_for("message", check=lambda m: m.embeds[0].title == "Pong!" and m.author.id == friday.user.id, timeout=pytest.timeout) # type: ignore 62 | assert msg.reference is None 63 | assert "API is" in msg.embeds[0].description 64 | -------------------------------------------------------------------------------- /cogs/datedevents.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | import os 5 | import datetime 6 | from typing import TYPE_CHECKING 7 | 8 | from discord.ext import commands, tasks 9 | 10 | if TYPE_CHECKING: 11 | from index import Friday 12 | 13 | log = logging.getLogger(__name__) 14 | 15 | original_image = "assets\\friday-logo.png" 16 | 17 | 18 | class DatedEvents(commands.Cog): 19 | def __init__(self, bot: Friday): 20 | self.bot: Friday = bot 21 | 22 | def __repr__(self) -> str: 23 | return f"" 24 | 25 | async def cog_load(self) -> None: 26 | self.dated_events.start() 27 | 28 | async def cog_unload(self) -> None: 29 | if self.dated_events.is_running(): 30 | self.dated_events.cancel() 31 | 32 | @tasks.loop(time=datetime.time(0, 0, 0)) 33 | async def dated_events(self): 34 | if not self.bot.prod: 35 | return 36 | today = datetime.datetime.today() 37 | guild = self.bot.get_guild(707441352367013899) 38 | if not guild: 39 | return 40 | user = self.bot.user 41 | thispath = os.getcwd() 42 | if "\\" in thispath: 43 | seperator = "\\\\" 44 | else: 45 | seperator = "/" 46 | if today.month == 4 and today.day == 1: 47 | log.info("april fools") 48 | with open(f"{thispath}{seperator}assets{seperator}friday_april_fools.png", "rb") as image: 49 | f = image.read() 50 | await user.edit(avatar=f) 51 | await guild.edit(icon=f, reason="April Fools") 52 | image.close() 53 | elif today.month == 4 and today.day == 3: 54 | log.info("post-april fools") 55 | with open(f"{thispath}{seperator}assets{seperator}friday-logo.png", "rb") as image: 56 | f = image.read() 57 | await guild.edit(icon=f, reason="Post-april fools") 58 | await user.edit(avatar=f) 59 | image.close() 60 | 61 | @dated_events.before_loop 62 | async def before_dated_events(self): 63 | await self.bot.wait_until_ready() 64 | 65 | 66 | async def setup(bot: Friday): 67 | await bot.add_cog(DatedEvents(bot)) 68 | -------------------------------------------------------------------------------- /functions/formats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Sequence 4 | 5 | 6 | class TabularData: 7 | def __init__(self): 8 | self._widths = [] 9 | self._columns = [] 10 | self._rows = [] 11 | 12 | def set_columns(self, columns): 13 | self._columns = columns 14 | self._widths = [len(c) + 2 for c in columns] 15 | 16 | def add_row(self, row): 17 | rows = [str(r) for r in row] 18 | self._rows.append(rows) 19 | for index, element in enumerate(rows): 20 | width = len(element) + 2 21 | if width > self._widths[index]: 22 | self._widths[index] = width 23 | 24 | def add_rows(self, rows): 25 | for row in rows: 26 | self.add_row(row) 27 | 28 | def render(self): 29 | """Renders a table in rST format. 30 | Example: 31 | +-------+-----+ 32 | | Name | Age | 33 | +-------+-----+ 34 | | Alice | 24 | 35 | | Bob | 19 | 36 | +-------+-----+ 37 | """ 38 | 39 | sep = '+'.join('-' * w for w in self._widths) 40 | sep = f'+{sep}+' 41 | 42 | to_draw = [sep] 43 | 44 | def get_entry(d): 45 | elem = '|'.join(f'{e:^{self._widths[i]}}' for i, e in enumerate(d)) 46 | elem = "\\n".join(elem.splitlines()) 47 | return f'|{elem}|' 48 | 49 | to_draw.append(get_entry(self._columns)) 50 | to_draw.append(sep) 51 | 52 | for row in self._rows: 53 | to_draw.append(get_entry(row)) 54 | 55 | to_draw.append(sep) 56 | return '\n'.join(to_draw) 57 | 58 | 59 | class plural: 60 | def __init__(self, value: int): 61 | self.value: int = value 62 | 63 | def __format__(self, format_spec: str) -> str: 64 | v = self.value 65 | singular, sep, plural = format_spec.partition('|') 66 | plural = plural or f'{singular}s' 67 | if abs(v) != 1: 68 | return f'{v} {plural}' 69 | return f'{v} {singular}' 70 | 71 | 72 | def human_join(seq: Sequence[str], delim: str = ', ', final: str = 'or') -> str: 73 | size = len(seq) 74 | if size == 0: 75 | return '' 76 | 77 | if size == 1: 78 | return seq[0] 79 | 80 | if size == 2: 81 | return f'{seq[0]} {final} {seq[1]}' 82 | 83 | return delim.join(seq[:-1]) + f' {final} {seq[-1]}' 84 | -------------------------------------------------------------------------------- /tests/test_patreons.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord.channel import TextChannel 11 | 12 | from .conftest import UnitTester 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def test_patreon(bot: UnitTester, channel: TextChannel): 18 | content = "!patreon" 19 | com = await send_command(bot, channel, content) 20 | 21 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 22 | assert msg.embeds[0].title == "Become a Patron!" 23 | 24 | 25 | async def test_server_patron_activate(bot: UnitTester, channel: TextChannel): 26 | content = "!dev sudo 813618591878086707 patreon server activate" 27 | com = await send_command(bot, channel, content) 28 | 29 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 30 | assert msg.embeds[0].title == "You have upgraded this server to premium" 31 | 32 | 33 | async def test_server_patron_deactivate(bot: UnitTester, channel: TextChannel): 34 | content = "!dev sudo 813618591878086707 patreon server deactivate" 35 | com = await send_command(bot, channel, content) 36 | 37 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 38 | assert msg.embeds[0].title == "You have successfully removed your server" 39 | 40 | 41 | async def test_server_activate(bot: UnitTester, channel: TextChannel): 42 | content = "!patreon server activate" 43 | com = await send_command(bot, channel, content) 44 | 45 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 46 | assert msg.embeds[0].title == "Your Patronage was not found" 47 | 48 | 49 | async def test_server_deactivate(bot: UnitTester, channel: TextChannel): 50 | content = "!patreon server deactivate" 51 | com = await send_command(bot, channel, content) 52 | 53 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 54 | assert msg.embeds[0].title == "This command requires a premium server and a patron or a mod." 55 | -------------------------------------------------------------------------------- /tests_offline/test_chat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from cogs.chat import ChatHistory 8 | 9 | if TYPE_CHECKING: 10 | ... 11 | 12 | pytestmark = pytest.mark.asyncio 13 | 14 | 15 | async def test_chat_history(): 16 | history = ChatHistory() 17 | 18 | assert len(history) == 0 19 | assert str(history) == "" 20 | assert history.__repr__() == "" 21 | assert history.bot_repeating() is False 22 | assert len(history.history()) == 0 23 | 24 | assert history.banned_nickname("nig ge r") == "Cat" 25 | assert history.banned_nickname("nigger") == "Cat" 26 | assert history.banned_nickname("niger") == "Cat" 27 | assert history.banned_nickname("steve") == "steve" 28 | assert history.banned_nickname("asd") == "asd" 29 | 30 | class msg: 31 | clean_content = "hey there" 32 | 33 | class author: 34 | display_name = "Motostar" 35 | 36 | class guild: 37 | class me: 38 | display_name = "Friday" 39 | 40 | response = "This is a test" 41 | actual_prompt = await history.prompt(msg.clean_content, msg.author.display_name) 42 | expected_prompt = f"{msg.author.display_name}: {msg.clean_content}\n{msg.guild.me.display_name}:" 43 | 44 | assert actual_prompt == expected_prompt 45 | 46 | assert len(history) == 0 47 | assert str(history) == "" 48 | assert history.__repr__() == "" 49 | assert history.bot_repeating() is False 50 | assert len(history.history()) == 0 51 | 52 | assert history.banned_nickname("nig ge r") == "Cat" 53 | assert history.banned_nickname("nigger") == "Cat" 54 | assert history.banned_nickname("niger") == "Cat" 55 | assert history.banned_nickname("steve") == "steve" 56 | assert history.banned_nickname("asd") == "asd" 57 | 58 | await history.add_message(msg, response) # type: ignore 59 | expected_prompt_response = f"{msg.author.display_name}: {msg.clean_content}\n{msg.guild.me.display_name}: {response}" 60 | assert len(history) == len(expected_prompt_response) 61 | assert str(history) == expected_prompt_response 62 | assert history.__repr__() == "" 63 | assert history.bot_repeating() is False 64 | assert len(history.history()) == 2 65 | 66 | assert history.banned_nickname("nig ge r") == "Cat" 67 | assert history.banned_nickname("nigger") == "Cat" 68 | assert history.banned_nickname("niger") == "Cat" 69 | assert history.banned_nickname("steve") == "steve" 70 | assert history.banned_nickname("asd") == "asd" 71 | 72 | await history.add_message(msg, response) # type: ignore 73 | assert history.bot_repeating() is True -------------------------------------------------------------------------------- /i18n/en/botlist.md: -------------------------------------------------------------------------------- 1 | Friday is a chatbot powered by GPT-3, with powerful moderation tools, games, and music. 2 | 3 | ## Web dashboard 4 | 5 | All of Friday's settings can be changed from the web dashboard to make your experience with Friday as smooth as possible. 6 | 7 | ## Chatbot 8 | 9 | Friday's chatbot system is powered by GPT-3 and to make it easy for everyone to chat with Friday, talking with Friday was built as if you were talking to a person. The ways of talking with Friday are as follows: 10 | 11 | - Mentioning Friday (or using a reply) eg. 12 | 13 | ```md 14 | @Friday hey, how are you? 15 | ``` 16 | 17 | - Setting a channel to be the chat channel. Friday will respond to every message in that channel as if they were directed to Friday. Eg: 18 | 19 | ```md 20 | hey, what is it like being a bot? 21 | ``` 22 | 23 | ### Personas 24 | 25 | Personas are only available to patrons. Friday currently has two personas to choose from: Pirate, where Friday will talk like a pirate, and default where Friday is focused on having a good time chatting to everyone. More personas are planned and in-progress. 26 | 27 | ## Automod 28 | 29 | To prevent spam and raiding if your server(s) Friday is equipt with powerful commands such as a phrase blacklist, invite spam, mention spam, etc... 30 | 31 | All of these settings can be modified through the web dashboard or commands found on the documentation site. 32 | 33 | ## Moderation 34 | 35 | Give powerful moderation commands to you and your moderators including mute, kick, ban, etc... 36 | 37 | ## Music 38 | 39 | Friday has a music playback system on par with Rythm, and Groovy. To start playing music using the `!play` command. 40 | 41 | ## Welcome 42 | 43 | Greet new members to your server with a welcome message and/or role with Friday's welcoming features that can be configured on the web dashboard or through commands found on the documentation website. 44 | 45 | ## Reddit Media Extraction 46 | 47 | Tired of having to open Reddit links just to view the media in the posts? Setup Friday's Reddit Media Extraction to have Friday extract that media for you and view the video or images right in Discord. 48 | 49 | When enabled, and someone posts a link to a Reddit post with and image or video, Friday will check to see if there is an available link to grab the video or image from, and then react with a 🔗 emoji. To extract the video or image from the post simply add your own 🔗 reaction to your message. Friday will then send a link the image or download the video and post it. 50 | 51 | ## Logging 52 | 53 | Log moderation action done automatically by Friday or done by your moderation team with Friday's logging feature. 54 | 55 | ## Games 56 | 57 | Play some fun games like minesweeper and rock paper scissors right in Discord with Friday's games commands. -------------------------------------------------------------------------------- /tests/test_help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord import TextChannel 11 | 12 | from .conftest import UnitTester 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def test_help(bot: UnitTester, channel: TextChannel): 18 | content = "!help" 19 | com = await send_command(bot, channel, content) 20 | 21 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 22 | assert msg.embeds[0].title == "Friday - Help links" 23 | # assert len(msg.embeds[0].fields) > 0 24 | 25 | 26 | async def test_command_unknown(bot: UnitTester, channel: TextChannel): 27 | content = "!help asd" 28 | com = await send_command(bot, channel, content) 29 | 30 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 31 | assert msg.embeds[0].title == 'No command called "asd" found.' 32 | 33 | 34 | @pytest.mark.parametrize("command", ["ping", "souptime", "ban", "kick"]) 35 | async def test_command(bot: UnitTester, channel: TextChannel, command: str): 36 | content = f"!help {command}" 37 | com = await send_command(bot, channel, content) 38 | 39 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 40 | assert msg.embeds[0].title == f"!{command}" 41 | 42 | 43 | @pytest.mark.parametrize("cog", ["Music", "Moderation", "Dev", "TopGG"]) 44 | async def test_cog(bot: UnitTester, channel: TextChannel, cog: str): 45 | content = f"!help {cog}" 46 | com = await send_command(bot, channel, content) 47 | 48 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 49 | assert cog in msg.embeds[0].title 50 | assert len(msg.embeds[0].fields) > 0 51 | 52 | 53 | @pytest.mark.parametrize("group", ["blacklist", "custom", "welcome"]) 54 | async def test_group(bot: UnitTester, channel: TextChannel, group: str): 55 | content = f"!help {group}" 56 | com = await send_command(bot, channel, content) 57 | 58 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 59 | assert msg.embeds[0].title == f"!{group}" 60 | assert len(msg.embeds[0].fields) > 0 61 | 62 | 63 | @pytest.mark.parametrize("subcommand", ["blacklist add", "welcome role", "custom add"]) 64 | async def test_subcommand(bot: UnitTester, channel: TextChannel, subcommand: str): 65 | content = f"!help {subcommand}" 66 | com = await send_command(bot, channel, content) 67 | 68 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 69 | assert msg.embeds[0].title == f"!{subcommand}" 70 | assert len(msg.embeds[0].fields) > 0 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | *.log* 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # celery beat schedule file 96 | celerybeat-schedule 97 | 98 | # SageMath parsed files 99 | *.sage.py 100 | 101 | # Environments 102 | .env 103 | .env.* 104 | .venv 105 | env/ 106 | venv/ 107 | ENV/ 108 | env.bak/ 109 | venv.bak/ 110 | venv*/ 111 | 112 | # Spyder project settings 113 | .spyderproject 114 | .spyproject 115 | 116 | # Rope project settings 117 | .ropeproject 118 | 119 | # mkdocs documentation 120 | site/ 121 | # /docs/commands 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | WikiQACorpus 133 | old/ 134 | old_* 135 | .vscode 136 | guilds.json 137 | friday-trans-key.json 138 | *.csv 139 | *.db 140 | *.sql 141 | *.sqlite 142 | *.zip 143 | 144 | blacklist.json 145 | languages.json 146 | crowdin.yml 147 | wandb/ 148 | i18n/locales/ 149 | i18n/en/commands.json 150 | 151 | migrations/*.json 152 | !migrations/V*__*.sql 153 | ollama/ 154 | database/ -------------------------------------------------------------------------------- /tests/test_dev.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING 5 | 6 | import pytest 7 | 8 | from .conftest import send_command, msg_check 9 | 10 | if TYPE_CHECKING: 11 | from discord import TextChannel 12 | 13 | from .conftest import Friday, UnitTester, UnitTesterUser 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | @pytest.mark.parametrize("command", ["dev reload", "dev", "dev say", "dev reload all"]) 19 | async def test_dev(bot_user: UnitTesterUser, channel_user: TextChannel, command: str): 20 | content = f"!dev {command}" 21 | com = await channel_user.send(content) 22 | assert com 23 | 24 | with pytest.raises(asyncio.TimeoutError): 25 | await bot_user.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 26 | 27 | 28 | @pytest.mark.parametrize("command", ["sudo 813618591878086707 dev", ]) 29 | async def test_dev_with_sudo(bot: UnitTester, channel: TextChannel, command: str): 30 | content = f"!dev {command}" 31 | com = await send_command(bot, channel, content) 32 | 33 | with pytest.raises(asyncio.TimeoutError): 34 | await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 35 | 36 | 37 | async def test_global_blacklist(bot: UnitTester, bot_user: UnitTesterUser, friday: Friday, channel: TextChannel, channel_user: TextChannel): 38 | content = "!ping" 39 | com = await send_command(bot_user, channel_user, content) 40 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 41 | assert msg.embeds[0].title == "Pong!" 42 | 43 | content = "!help say" 44 | com = await send_command(bot_user, channel_user, content) 45 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=2.0) 46 | assert msg.embeds[0].title == "!say" 47 | 48 | content = f"!dev block {bot_user.user.id}" 49 | com = await send_command(bot, channel, content) 50 | 51 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=2.0) 52 | assert msg.embeds[0].title == f"{bot_user.user.id} has been blocked" 53 | assert bot_user.user.id in friday.blacklist 54 | 55 | content = "!ping" 56 | com = await send_command(bot_user, channel_user, content) 57 | with pytest.raises(asyncio.TimeoutError): 58 | await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=2.0) 59 | 60 | content = "!help say" 61 | com = await send_command(bot_user, channel_user, content) 62 | with pytest.raises(asyncio.TimeoutError): 63 | await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=2.0) 64 | 65 | content = f"!dev unblock {bot_user.user.id}" 66 | com = await send_command(bot, channel, content) 67 | 68 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=2.0) 69 | assert msg.embeds[0].title == f"{bot_user.user.id} has been unblocked" 70 | assert bot_user.user.id not in friday.blacklist 71 | -------------------------------------------------------------------------------- /tests/test_general.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from functions.messagecolors import MessageColors 8 | 9 | from .conftest import send_command, msg_check 10 | 11 | if TYPE_CHECKING: 12 | from discord import TextChannel 13 | 14 | from .conftest import Friday, UnitTester 15 | 16 | pytestmark = pytest.mark.asyncio 17 | 18 | 19 | @pytest.mark.dependency() 20 | async def test_get_cog(friday: Friday): 21 | assert friday.get_cog("General") is not None 22 | 23 | 24 | @pytest.mark.dependency(depends=["test_get_cog"]) 25 | async def test_bot(bot: UnitTester, channel: TextChannel): 26 | content = "!info" 27 | com = await send_command(bot, channel, content) 28 | 29 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 30 | assert "Friday" in msg.embeds[0].title and "- About" in msg.embeds[0].title 31 | assert msg.embeds[0].color == MessageColors.default() 32 | assert len(msg.embeds[0].fields) == 7 33 | 34 | 35 | @pytest.mark.parametrize("user", ["", "751680714948214855"]) 36 | @pytest.mark.dependency(depends=["test_get_cog"]) 37 | async def test_user(bot: UnitTester, channel: TextChannel, user: str): 38 | content = f"!userinfo {user}" 39 | com = await send_command(bot, channel, content) 40 | 41 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 42 | assert "Friday" in msg.embeds[0].title and "- Info" in msg.embeds[0].title 43 | assert len(msg.embeds[0].fields) == 8 44 | 45 | 46 | @pytest.mark.dependency(depends=["test_get_cog"]) 47 | async def test_guild(bot: UnitTester, channel: TextChannel): 48 | content = "!serverinfo" 49 | com = await send_command(bot, channel, content) 50 | 51 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 52 | assert msg.embeds[0].title == f"{msg.guild.name if msg.guild is not None and msg.guild.name is not None else 'Diary'} - Info" 53 | assert len(msg.embeds[0].fields) == 6 54 | 55 | 56 | @pytest.mark.parametrize("role", ["", "895463648326221854"]) 57 | @pytest.mark.dependency(depends=["test_get_cog"]) 58 | async def test_role(bot: UnitTester, channel: TextChannel, role: str): 59 | content = f"!roleinfo {role}" 60 | com = await send_command(bot, channel, content) 61 | 62 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 63 | if role: 64 | assert "- Info" in msg.embeds[0].title 65 | assert len(msg.embeds[0].fields) == 7 66 | else: 67 | assert msg.embeds[0].title == "!roleinfo" 68 | 69 | 70 | @pytest.mark.dependency(depends=["test_get_cog"]) 71 | async def test_invite(bot: UnitTester, channel: TextChannel): 72 | content = "!invite" 73 | com = await send_command(bot, channel, content) 74 | 75 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 76 | assert msg.embeds[0].title == "Invite me :)" 77 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | bot: 3 | build: . 4 | container_name: bot 5 | depends_on: 6 | db: 7 | condition: service_healthy 8 | restart: true 9 | lavalink: 10 | condition: service_healthy 11 | restart: true 12 | restart: on-failure:3 13 | env_file: .env 14 | environment: 15 | # - DBURL=postgresql://fridaylocal:fridaylocal@db:5432/fridaylocal 16 | - LAVALINKUSHOST=lavalink 17 | - LAVALINKUSPORT=2333 18 | volumes: 19 | - .:/usr/src/app 20 | - /home/certs/:/home/certs/ 21 | ports: 22 | - 5678:5678 23 | # - 443:443 24 | - 5000:5000 25 | networks: 26 | - fridaybot 27 | deploy: 28 | resources: 29 | limits: 30 | cpus: '0.9' 31 | memory: 2000M 32 | lavalink: 33 | image: ghcr.io/lavalink-devs/lavalink:4 34 | container_name: lavalink 35 | restart: unless-stopped 36 | env_file: .env 37 | environment: 38 | - _JAVA_OPTIONS=-Xmx500M 39 | - LAVALINK_SERVER_PASSWORD=${LAVALINK_SERVER_PASSWORD} 40 | - SERVER_PORT=2333 41 | expose: 42 | - 2333 43 | networks: 44 | - fridaybot 45 | volumes: 46 | - ./lavalink.config.yml:/opt/Lavalink/application.yml 47 | healthcheck: 48 | test: 'curl -f -H "Authorization: $$LAVALINK_SERVER_PASSWORD" http://lavalink:2333/version || exit 1' 49 | timeout: 4s 50 | retries: 10 51 | start_period: 5s 52 | # llama: 53 | # image: dhiltgen/ollama:0.1.21-rc2 54 | # container_name: llama 55 | # # expose: 56 | # # - 11434/tcp 57 | # ports: 58 | # - 11434:11434/tcp 59 | # networks: 60 | # - main 61 | # volumes: 62 | # - ./ollama:/root/.ollama 63 | # healthcheck: 64 | # test: ollama --version || exit 1 65 | # deploy: 66 | # resources: 67 | # reservations: 68 | # devices: 69 | # - driver: nvidia 70 | # device_ids: ['all'] 71 | # capabilities: [gpu] 72 | db: 73 | image: postgres:15.1 74 | restart: unless-stopped 75 | container_name: db 76 | env_file: .env 77 | environment: 78 | - POSTGRES_USER=${POSTGRES_USER} 79 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 80 | - POSTGRES_DB=${POSTGRES_DB} 81 | - SERVER_PORT=5432 82 | healthcheck: 83 | test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER'] 84 | interval: 5s 85 | timeout: 5s 86 | retries: 5 87 | expose: 88 | - 5432 89 | networks: 90 | - fridaybot 91 | volumes: 92 | - db:/var/lib/postgresql/data 93 | - ./migrations:/docker-entrypoint-initdb.d 94 | # portainer: 95 | # image: portainer/portainer-ce:latest 96 | # restart: unless-stopped 97 | # security_opt: 98 | # - no-new-privileges:true 99 | # ports: 100 | # - 9000:9000 101 | # - 9443:9443 102 | # volumes: 103 | # - portainer:/data 104 | # - /var/run/docker.sock:/var/run/docker.sock 105 | # # - pgadmin:/var/run/docker.sock 106 | 107 | volumes: 108 | db: 109 | # portainer: 110 | 111 | networks: 112 | fridaybot: 113 | name: fridaybot -------------------------------------------------------------------------------- /tests/test_welcome.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | if TYPE_CHECKING: 8 | from .conftest import UnitTester, Friday 9 | from discord import TextChannel 10 | 11 | from .conftest import send_command, msg_check 12 | 13 | pytestmark = pytest.mark.asyncio 14 | 15 | 16 | @pytest.mark.dependency() 17 | async def test_get_cog(friday: Friday): 18 | assert friday.get_cog("Welcome") is not None 19 | 20 | 21 | @pytest.mark.dependency(depends=["test_get_cog"]) 22 | async def test_welcome(bot: UnitTester, channel: TextChannel): 23 | content = "!welcome" 24 | com = await send_command(bot, channel, content) 25 | 26 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 27 | assert "Current Welcome Settings" == msg.embeds[0].title 28 | 29 | 30 | @pytest.mark.dependency(depends=["test_get_cog"]) 31 | async def test_display(bot: UnitTester, channel: TextChannel): 32 | content = "!welcome display" 33 | com = await send_command(bot, channel, content) 34 | 35 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 36 | assert "Current Welcome Settings" in msg.embeds[0].title 37 | 38 | 39 | @pytest.mark.dependency(depends=["test_get_cog"]) 40 | async def test_role(bot: UnitTester, channel: TextChannel): 41 | content = "!welcome role 895463648326221854" 42 | com = await send_command(bot, channel, content) 43 | 44 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 45 | content = "!welcome role" 46 | com = await send_command(bot, channel, content) 47 | await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 48 | assert "New members will now receive the role " in msg.embeds[0].title 49 | 50 | 51 | @pytest.mark.dependency(depends=["test_get_cog"]) 52 | async def test_channel(bot: UnitTester, channel: TextChannel): 53 | content = f"!welcome channel {channel.id}" 54 | com = await send_command(bot, channel, content) 55 | 56 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 57 | content = "!welcome channel" 58 | com = await send_command(bot, channel, content) 59 | await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 60 | assert "Welcome message will be sent to" in msg.embeds[0].title 61 | 62 | 63 | @pytest.mark.parametrize("args", ["\"this is a message to {user} from {server}\"", ""]) 64 | @pytest.mark.dependency(depends=["test_get_cog"]) 65 | async def test_message(bot: UnitTester, channel: TextChannel, args: str): 66 | content = f'!welcome message {args}' 67 | com = await send_command(bot, channel, content) 68 | 69 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 70 | assert msg.embeds[0].title == "This servers welcome message is now" or msg.embeds[0].title == "Welcome message removed" 71 | 72 | 73 | @pytest.mark.skip("Not implemented") 74 | @pytest.mark.dependency(depends=["test_get_cog"]) 75 | async def test_join_event(bot: UnitTester, channel: TextChannel): 76 | ... 77 | -------------------------------------------------------------------------------- /crowdin.yml.example: -------------------------------------------------------------------------------- 1 | # 2 | # Your Crowdin credentials 3 | # 4 | "project_id" : "CROWDIN_PERSONAL_ID" 5 | "api_token" : "CROWDIN_PERSONAL_TOKEN" 6 | "base_path" : "i18n" 7 | 8 | # 9 | # Choose file structure in Crowdin 10 | # e.g. true or false 11 | # 12 | "preserve_hierarchy": true 13 | 14 | # 15 | # Files configuration 16 | # 17 | files: [ 18 | { 19 | # 20 | # Source files filter 21 | # e.g. "/resources/en/*.json" 22 | # 23 | "source" : "/source/*.json", 24 | 25 | # 26 | # Where translations will be placed 27 | # e.g. "/resources/%two_letters_code%/%original_file_name%" 28 | # 29 | "translation" : "/translations/%two_letters_code%/%original_file_name%", 30 | 31 | # 32 | # Files or directories for ignore 33 | # e.g. ["/**/?.txt", "/**/[0-9].txt", "/**/*\?*.txt"] 34 | # 35 | #"ignore" : [], 36 | 37 | # 38 | # The dest allows you to specify a file name in Crowdin 39 | # e.g. "/messages.json" 40 | # 41 | "dest" : "/commands.json", 42 | 43 | # 44 | # File type 45 | # e.g. "json" 46 | # 47 | "type" : "json", 48 | 49 | # 50 | # The parameter "update_option" is optional. If it is not set, after the files update the translations for changed strings will be removed. Use to fix typos and for minor changes in the source strings 51 | # e.g. "update_as_unapproved" or "update_without_changes" 52 | # 53 | #"update_option" : "", 54 | 55 | # 56 | # Start block (for XML only) 57 | # 58 | 59 | # 60 | # Defines whether to translate tags attributes. 61 | # e.g. 0 or 1 (Default is 1) 62 | # 63 | "translate_attributes" : 0, 64 | 65 | # 66 | # Defines whether to translate texts placed inside the tags. 67 | # e.g. 0 or 1 (Default is 1) 68 | # 69 | # "translate_content" : 1, 70 | 71 | # 72 | # This is an array of strings, where each item is the XPaths to DOM element that should be imported 73 | # e.g. ["/content/text", "/content/text[@value]"] 74 | # 75 | # "translatable_elements" : [], 76 | 77 | # 78 | # Defines whether to split long texts into smaller text segments 79 | # e.g. 0 or 1 (Default is 1) 80 | # 81 | # "content_segmentation" : 1, 82 | 83 | # 84 | # End block (for XML only) 85 | # 86 | 87 | # 88 | # Start .properties block 89 | # 90 | 91 | # 92 | # Defines whether single quote should be escaped by another single quote or backslash in exported translations 93 | # e.g. 0 or 1 or 2 or 3 (Default is 3) 94 | # 0 - do not escape single quote; 95 | # 1 - escape single quote by another single quote; 96 | # 2 - escape single quote by backslash; 97 | # 3 - escape single quote by another single quote only in strings containing variables ( {0} ). 98 | # 99 | # "escape_quotes" : 3, 100 | 101 | # 102 | # Defines whether any special characters (=, :, ! and #) should be escaped by backslash in exported translations. 103 | # e.g. 0 or 1 (Default is 0) 104 | # 0 - do not escape special characters 105 | # 1 - escape special characters by a backslash 106 | # 107 | # "escape_special_characters": 0 108 | # 109 | 110 | # 111 | # End .properties block 112 | # 113 | 114 | # 115 | # Does the first line contain header? 116 | # e.g. true or false 117 | # 118 | "first_line_contains_header" : true, 119 | 120 | # 121 | # for spreadsheets 122 | # e.g. "identifier,source_phrase,context,uk,ru,fr" 123 | # 124 | # "scheme" : "", 125 | } 126 | ] -------------------------------------------------------------------------------- /cogs/choosegame.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import discord 6 | from discord.ext import commands, tasks 7 | from numpy import random 8 | 9 | if TYPE_CHECKING: 10 | from index import Friday 11 | 12 | GAMES = [ 13 | "Developing myself", 14 | "Minecraft 1.20", 15 | "Super Smash Bros. Ultimate", 16 | "Cyberpunk 2078", 17 | "Forza Horizon 6", 18 | "Red Dead Redemption 3", 19 | "Grand Theft Auto V", 20 | "Grand Theft Auto VI", 21 | "Grand Theft Auto IV", 22 | "Grand Theft Auto III", 23 | "Ori and the Will of the Wisps", 24 | "With the internet", 25 | "DOOM Eternal", 26 | "D&D (solo)", 27 | "Muck", 28 | "Big brain time", 29 | "Uploading your consciousness", 30 | "Learning everything on the Internet", 31 | "some games", 32 | "with Machine Learning", 33 | "Escape from Tarkov", 34 | # "Giving out inspirational quotes", 35 | { 36 | "type": discord.ActivityType.listening, "content": "myself" 37 | }, 38 | { 39 | "type": discord.ActivityType.watching, "content": "", "stats": True 40 | } 41 | ] 42 | 43 | 44 | class ChooseGame(commands.Cog): 45 | def __init__(self, bot: Friday): 46 | self.bot: Friday = bot 47 | self.status_update_shards: list[int] = [] 48 | 49 | def __repr__(self) -> str: 50 | return f"" 51 | 52 | @tasks.loop(minutes=10.0) 53 | async def choose_game(self): 54 | self.status_update_shards.clear() 55 | if self.status_updates.is_running(): 56 | self.status_updates.cancel() 57 | 58 | for shard_id in self.bot.shards: 59 | if random.random() < 0.6: 60 | gm = random.choice(GAMES) 61 | 62 | if isinstance(gm, str): 63 | await self.bot.change_presence( 64 | activity=discord.Activity( 65 | type=discord.ActivityType.playing, 66 | name=gm 67 | ), 68 | shard_id=shard_id, 69 | ) 70 | elif gm.get("stats", None) is True: 71 | self.status_update_shards.append(shard_id) 72 | if not self.status_updates.is_running(): 73 | self.status_updates.start() 74 | else: 75 | await self.bot.change_presence( 76 | activity=discord.Activity( 77 | type=gm.get("type", discord.ActivityType.playing), 78 | name=gm["content"] 79 | ), 80 | shard_id=shard_id 81 | ) 82 | else: 83 | await self.bot.change_presence(activity=None, shard_id=shard_id) 84 | 85 | self.choose_game.change_interval(minutes=float(random.randint(5, 45))) 86 | 87 | @tasks.loop(minutes=1) 88 | async def status_updates(self): 89 | member_count = sum(g.member_count for g in self.bot.guilds if g.member_count) 90 | for shard in self.status_update_shards: 91 | await self.bot.change_presence( 92 | activity=discord.Activity( 93 | type=discord.ActivityType.watching, 94 | name=f"{len(self.bot.guilds)} servers with {member_count} members" 95 | ), 96 | shard_id=shard 97 | ) 98 | 99 | @status_updates.before_loop 100 | @choose_game.before_loop 101 | async def before_status_updates(self): 102 | await self.bot.wait_until_ready() 103 | 104 | async def cog_load(self): 105 | self.choose_game.start() 106 | if self.status_updates.is_running(): 107 | self.status_updates.cancel() 108 | 109 | async def cog_unload(self): 110 | self.choose_game.cancel() 111 | self.status_updates.cancel() 112 | 113 | 114 | async def setup(bot): 115 | await bot.add_cog(ChooseGame(bot)) 116 | -------------------------------------------------------------------------------- /functions/cooldown.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from typing import Any 5 | from discord.ext.commands import BucketType 6 | 7 | 8 | class Cooldown: 9 | __slots__ = ( 10 | "initial_tokens", 11 | "tokens", 12 | "max", 13 | "refill_interval", 14 | "refill_amount", 15 | "_last_refill", 16 | "type", 17 | ) 18 | 19 | def __init__( 20 | self, 21 | max: int | float, 22 | tokens: int | float, 23 | refill_amount: int | float, 24 | refill_interval: float, 25 | type: BucketType, 26 | ) -> None: 27 | self.initial_tokens = float(tokens) 28 | self.tokens = float(tokens) 29 | self.max = float(max) 30 | self.refill_interval = float(refill_interval) 31 | self.refill_amount = float(refill_amount) 32 | self._last_refill = time.time() 33 | self.type = type 34 | 35 | def update_tokens(self, current: float) -> None: 36 | time_passed = current - self._last_refill 37 | refills_since = time_passed / self.refill_interval 38 | self._last_refill = current 39 | self.tokens += refills_since * self.refill_amount 40 | if self.tokens > self.max: 41 | self.tokens = self.max 42 | 43 | def get_retry_after(self, current: float = None): 44 | current = current or time.time() 45 | tokens = self.get_tokens(current) # type: ignore 46 | 47 | if tokens < 1: 48 | tokens_needed = 1 - tokens 49 | refills_needed = tokens_needed / self.refill_amount 50 | return self.refill_interval * refills_needed 51 | return 0.0 52 | 53 | def update_rate_limit(self, current: float = None): 54 | current = current or time.time() 55 | self.update_tokens(current) 56 | 57 | if self.tokens >= 1: 58 | self.tokens -= 1 59 | else: 60 | tokens_needed = 1 - self.tokens 61 | refills_needed = tokens_needed / self.refill_amount 62 | return self.refill_interval * refills_needed 63 | 64 | def is_full_at(self, current: float = None): 65 | self.update_tokens(current) # type: ignore 66 | return self.tokens == self.max 67 | 68 | def reset(self): 69 | self.tokens = self.initial_tokens 70 | self._last_refill = time.time() 71 | 72 | def copy(self): 73 | return Cooldown( 74 | self.max, 75 | self.initial_tokens, 76 | self.refill_amount, 77 | self.refill_interval, 78 | self.type, 79 | ) 80 | 81 | def __repr__(self): 82 | return "".format( 83 | self 84 | ) 85 | 86 | 87 | class CooldownMapping: 88 | def __init__(self, original): 89 | self._cache = {} 90 | self._cooldown = original 91 | 92 | def copy(self): 93 | ret = CooldownMapping(self._cooldown) 94 | ret._cache = self._cache.copy() 95 | return ret 96 | 97 | @property 98 | def valid(self): 99 | return self._cooldown is not None 100 | 101 | @classmethod 102 | def from_cooldown(cls, rate: int | float, per: int | float, _type: Any): 103 | return cls(Cooldown(rate, per, _type)) # type: ignore 104 | 105 | def _bucket_key(self, msg): 106 | return self._cooldown.type.get_key(msg) 107 | 108 | def _verify_cache_integrity(self, current=None): 109 | # we want to delete all cache objects that haven't been used 110 | # in a cooldown window. e.g. if we have a command that has a 111 | # cooldown of 60s and it has not been used in 60s then that key should be deleted 112 | current = current or time.time() 113 | dead_keys = [k for k, v in self._cache.items() if v.is_full_at(current)] 114 | for k in dead_keys: 115 | del self._cache[k] 116 | 117 | def get_bucket(self, message, current=None): 118 | if self._cooldown.type is BucketType.default: 119 | return self._cooldown 120 | 121 | self._verify_cache_integrity(current) 122 | key = self._bucket_key(message) 123 | if key not in self._cache: 124 | bucket = self._cooldown.copy() 125 | self._cache[key] = bucket 126 | else: 127 | bucket = self._cache[key] 128 | 129 | return bucket 130 | 131 | def update_rate_limit(self, message, current=None): 132 | bucket = self.get_bucket(message, current) 133 | return bucket.update_rate_limit(current) 134 | -------------------------------------------------------------------------------- /lavalink.config.yml: -------------------------------------------------------------------------------- 1 | server: # REST and WS server 2 | port: 2333 3 | address: 0.0.0.0 4 | lavalink: 5 | plugins: 6 | - dependency: "com.github.topi314.lavasrc:lavasrc-plugin:4.3.0" 7 | - dependency: "com.github.topi314.lavasearch:lavasearch-plugin:1.0.0" 8 | - dependency: "dev.lavalink.youtube:youtube-plugin:1.11.1" 9 | server: 10 | sources: 11 | youtube: false 12 | bandcamp: true 13 | soundcloud: true 14 | twitch: true 15 | vimeo: true 16 | http: false 17 | local: false 18 | filters: # All filters are enabled by default 19 | volume: true 20 | equalizer: true 21 | karaoke: true 22 | timescale: true 23 | tremolo: true 24 | vibrato: true 25 | distortion: true 26 | rotation: true 27 | channelMix: true 28 | lowPass: true 29 | bufferDurationMs: 500 30 | frameBufferDurationMs: 5000 # How many milliseconds of audio to keep buffered 31 | opusEncodingQuality: 9 # Opus encoder quality. Valid values range from 0 to 10, where 10 is best quality but is the most expensive on the CPU. 32 | resamplingQuality: LOW # Quality of resampling operations. Valid values are LOW, MEDIUM and HIGH, where HIGH uses the most CPU. 33 | trackStuckThresholdMs: 10000 # The threshold for how long a track can be stuck. A track is stuck if does not return any audio data. 34 | youtubePlaylistLoadLimit: 6 # Number of pages at 100 each 35 | playerUpdateInterval: 5 # How frequently to send player updates to clients, in seconds 36 | youtubeSearchEnabled: true 37 | soundcloudSearchEnabled: true 38 | gc-warnings: true 39 | #ratelimit: 40 | #ipBlocks: ["1.0.0.0/8", "..."] # list of ip blocks 41 | #excludedIps: ["...", "..."] # ips which should be explicit excluded from usage by lavalink 42 | #strategy: "RotateOnBan" # RotateOnBan | LoadBalance | NanoSwitch | RotatingNanoSwitch 43 | #searchTriggersFail: true # Whether a search 429 should trigger marking the ip as failing 44 | #retryLimit: -1 # -1 = use default lavaplayer value | 0 = infinity | >0 = retry will happen this numbers times 45 | 46 | plugins: 47 | lavasrc: 48 | providers: # Custom providers for track loading. This is the default 49 | - "soundcloud:%ISRC%" # Deezer ISRC provider 50 | - "soundcloud:%QUERY%" # Deezer search provider 51 | - "ytsearch:\"%ISRC%\"" # Will be ignored if track does not have an ISRC. See https://en.wikipedia.org/wiki/International_Standard_Recording_Code 52 | - "ytsearch:%QUERY%" # Will be used if track has no ISRC or no track could be found for the ISRC 53 | - "ytmsearch:%ISRC%" 54 | - "ytmsearch:%QUERY%" 55 | # you can add multiple other fallback sources here 56 | sources: 57 | spotify: true # Enable Spotify source 58 | applemusic: false # Enable Apple Music source 59 | deezer: false # Enable Deezer source 60 | yandexmusic: false # Enable Yandex Music source 61 | flowerytts: false # Enable Flowery TTS source 62 | youtube: false # Enable YouTube search source (https://github.com/topi314/LavaSearch) 63 | spotify: 64 | countryCode: "US" # the country code you want to use for filtering the artists top tracks. See https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2 65 | playlistLoadLimit: 6 # The number of pages at 100 tracks each 66 | albumLoadLimit: 6 # The number of pages at 50 tracks each 67 | youtube: 68 | enabled: true 69 | allowSearch: true # Whether "ytsearch:" and "ytmsearch:" can be used. 70 | allowDirectVideoIds: true # Whether just video IDs can match. If false, only complete URLs will be loaded. 71 | allowDirectPlaylistIds: true # Whether just playlist IDs can match. If false, only complete URLs will be loaded. 72 | clients: 73 | - MUSIC 74 | - ANDROID_VR 75 | - WEB 76 | - WEBEMBEDDED 77 | - MWEB 78 | oauth: 79 | enabled: true 80 | 81 | metrics: 82 | prometheus: 83 | enabled: false 84 | endpoint: /metrics 85 | 86 | sentry: 87 | dsn: "" 88 | environment: "" 89 | # tags: 90 | # some_key: some_value 91 | # another_key: another_value 92 | 93 | logging: 94 | file: 95 | max-history: 30 96 | max-size: 1GB 97 | path: ./logs/ 98 | 99 | level: 100 | root: WARN 101 | lavalink: WARN 102 | dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO -------------------------------------------------------------------------------- /tests/test_checks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from functions.checks import (guild_is_min_tier, is_admin, 8 | is_admin_and_min_tier, is_min_tier, 9 | is_mod_and_min_tier, is_mod_or_guild_permissions, 10 | is_supporter, is_supporter_or_voted, 11 | user_is_min_tier) 12 | from functions.config import PremiumTiersNew 13 | 14 | from functions.exceptions import NotInSupportServer, RequiredTier 15 | 16 | if TYPE_CHECKING: 17 | from discord.channel import TextChannel 18 | 19 | from functions.custom_contexts import GuildContext 20 | 21 | from .conftest import Friday, UnitTester 22 | 23 | pytestmark = pytest.mark.asyncio 24 | 25 | 26 | @pytest.fixture(scope="module") 27 | async def ctx(bot: UnitTester, friday: Friday, channel: TextChannel) -> GuildContext: 28 | content = "checks test" 29 | com = await channel.send(content) 30 | assert com 31 | 32 | ctx: GuildContext = await friday.get_context(com) # type: ignore 33 | return ctx 34 | 35 | # async def test_user_is_tier(bot: UnitTester, friday: Friday, channel: TextChannel): 36 | # content = "user_is_tier" 37 | # com = await channel.send(content) 38 | # assert com 39 | 40 | # ctx: GuildContext | MyContext = await friday.get_context(com) 41 | # assert await user_is_tier().predicate(ctx) 42 | 43 | 44 | async def test_is_min_tier(bot: UnitTester, friday: Friday, user_friday, channel: TextChannel, ctx: GuildContext): 45 | assert await is_min_tier(PremiumTiersNew.free.value).predicate(ctx) 46 | with pytest.raises(NotInSupportServer): 47 | await is_min_tier(PremiumTiersNew.tier_1.value).predicate(ctx) 48 | with pytest.raises(NotInSupportServer): 49 | await is_min_tier(PremiumTiersNew.tier_2.value).predicate(ctx) 50 | with pytest.raises(NotInSupportServer): 51 | await is_min_tier(PremiumTiersNew.tier_3.value).predicate(ctx) 52 | ctx.author = user_friday 53 | assert await is_min_tier(PremiumTiersNew.free.value).predicate(ctx) 54 | assert await is_min_tier(PremiumTiersNew.tier_1.value).predicate(ctx) 55 | with pytest.raises(RequiredTier): 56 | assert await is_min_tier(PremiumTiersNew.tier_2.value).predicate(ctx) 57 | with pytest.raises(RequiredTier): 58 | assert await is_min_tier(PremiumTiersNew.tier_3.value).predicate(ctx) 59 | 60 | 61 | async def test_guild_is_min_tier(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 62 | assert await guild_is_min_tier(PremiumTiersNew.free.value).predicate(ctx) 63 | assert await guild_is_min_tier(PremiumTiersNew.tier_1.value).predicate(ctx) is False 64 | 65 | 66 | async def test_user_is_min_tier(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 67 | assert await user_is_min_tier(PremiumTiersNew.free.value).predicate(ctx) 68 | 69 | 70 | async def test_is_admin_and_min_tier(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 71 | with pytest.raises(RequiredTier): 72 | assert await is_admin_and_min_tier().predicate(ctx) 73 | 74 | 75 | async def test_is_mod_and_min_tier(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 76 | assert await is_mod_and_min_tier(tier=PremiumTiersNew.free.value).predicate(ctx) 77 | 78 | 79 | async def test_is_supporter(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 80 | assert await is_supporter().predicate(ctx) 81 | 82 | 83 | # async def test_user_is_supporter(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 84 | # assert await user_is_supporter().predicate(ctx) 85 | 86 | 87 | async def test_is_supporter_or_voted(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 88 | assert await is_supporter_or_voted().predicate(ctx) 89 | 90 | 91 | # async def test_user_voted(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 92 | # assert await user_voted().predicate(ctx) 93 | 94 | 95 | async def test_is_admin(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 96 | assert await is_admin().predicate(ctx) is False 97 | 98 | 99 | async def test_is_mod_or_guild_permissions(bot: UnitTester, friday: Friday, channel: TextChannel, ctx: GuildContext): 100 | assert await is_mod_or_guild_permissions().predicate(ctx) 101 | -------------------------------------------------------------------------------- /cogs/issue.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from typing import TYPE_CHECKING 5 | 6 | import discord 7 | from discord.ext import commands 8 | import logging 9 | 10 | from functions import embed, MessageColors 11 | 12 | from .log import CustomWebhook 13 | 14 | if TYPE_CHECKING: 15 | from functions.custom_contexts import MyContext 16 | from index import Friday 17 | 18 | log = logging.getLogger(__name__) 19 | 20 | 21 | class SupportServer(discord.ui.View): 22 | def __init__(self): 23 | super().__init__() 24 | self.add_item(discord.ui.Button(label="Support Server", style=discord.ButtonStyle.grey, url="https://discord.gg/NTRuFjU")) 25 | 26 | 27 | class Modal(discord.ui.Modal, title="Give feedback on Friday"): 28 | subject = discord.ui.TextInput( 29 | label="Subject", 30 | required=False, 31 | max_length=150 32 | ) 33 | description = discord.ui.TextInput( 34 | label="Description", 35 | placeholder="Avoid using non-descriptive words like \"it glitches\" or \"its broken\"", 36 | style=discord.TextStyle.paragraph, 37 | required=True, 38 | max_length=1000 39 | ) 40 | reproduce = discord.ui.TextInput( 41 | label="Steps to reproduce", 42 | placeholder="Describe how to reproduce your issue\nThis can be the command you used, button you clicked, etc...", 43 | style=discord.TextStyle.paragraph, 44 | required=True, 45 | max_length=1000 46 | ) 47 | expected = discord.ui.TextInput( 48 | label="Expected result", 49 | placeholder="What should have happened?", 50 | style=discord.TextStyle.paragraph, 51 | required=True, 52 | max_length=1000 53 | ) 54 | actual = discord.ui.TextInput( 55 | label="Actual result", 56 | placeholder="What actually happened?", 57 | style=discord.TextStyle.paragraph, 58 | required=True, 59 | max_length=1000 60 | ) 61 | 62 | def __init__(self, cog: Issue, *args, **kwargs): 63 | self.cog: Issue = cog 64 | super().__init__(*args, **kwargs) 65 | 66 | async def on_submit(self, interaction: discord.Interaction): 67 | if all(x == self.children[0].value for x in self.children): # type: ignore 68 | log.info(f"{interaction.user} ({interaction.user.id}) tried to make a dump ticket") 69 | return await interaction.response.send_message(embed=embed(title="Your issue was not sent", description="Please only submit real tickets", colour=MessageColors.red()), ephemeral=True) 70 | items = [c.value for c in self.children] # type: ignore 71 | hook = self.cog.log_issues 72 | await interaction.response.send_message(embed=embed(title="Your issue has been submitted", description="Please join the support server for followup on your issue."), view=SupportServer(), ephemeral=True) 73 | try: 74 | await hook.send( 75 | embed=embed( 76 | title="Issue", 77 | fieldstitle=["Title", "Description", "Steps to reproduce", "Expected result", "Actual result"], 78 | fieldsval=[f"```\n{i}\n```" for i in items], 79 | fieldsin=[False] * len(items), 80 | author_icon=interaction.user.display_avatar.url, 81 | author_name=interaction.user.name + f" (ID: {interaction.user.id})"), 82 | username=self.cog.bot.user.name, 83 | avatar_url=self.cog.bot.user.display_avatar.url) 84 | except Exception: 85 | pass 86 | 87 | 88 | class Issue(commands.Cog): 89 | """Report your issues you have with Friday""" 90 | 91 | def __init__(self, bot: Friday): 92 | self.bot: Friday = bot 93 | 94 | def __repr__(self) -> str: 95 | return f"" 96 | 97 | @discord.utils.cached_property 98 | def log_issues(self) -> CustomWebhook: 99 | return CustomWebhook.partial(os.environ.get("WEBHOOKISSUESID"), os.environ.get("WEBHOOKISSUESTOKEN"), session=self.bot.session) # type: ignore 100 | 101 | @commands.hybrid_command(name="issue", aliases=["problem"]) 102 | @commands.cooldown(1, 60, commands.BucketType.guild) 103 | @commands.has_guild_permissions(manage_guild=True) 104 | async def issue(self, ctx: MyContext): 105 | """If you have an issue or noticed a bug with Friday, this will send a message to the developer.""" 106 | 107 | await ctx.prompt_modal(Modal(self)) 108 | 109 | 110 | async def setup(bot): 111 | await bot.add_cog(Issue(bot)) 112 | -------------------------------------------------------------------------------- /cogs/toyst.py: -------------------------------------------------------------------------------- 1 | # import asyncio 2 | # import os 3 | # from typing import Optional 4 | 5 | # import discord 6 | # from discord.ext import commands 7 | # from typing import TYPE_CHECKING 8 | 9 | # from functions import MyContext, embed, cache # , queryIntents 10 | 11 | # from .log import CustomWebhook 12 | 13 | # if TYPE_CHECKING: 14 | # from index import Friday as Bot 15 | 16 | 17 | # class FunnyOrNah(discord.ui.View): 18 | # def __init__(self, *, timeout: float, author_id: int, ctx: MyContext) -> None: 19 | # super().__init__(timeout=timeout) 20 | # self.value: Optional[bool] = None 21 | # self.author_id: int = author_id 22 | # self.ctx: MyContext = ctx 23 | # self.message: Optional[discord.Message] = None 24 | 25 | # async def interaction_check(self, interaction: discord.Interaction) -> bool: 26 | # if interaction.user and interaction.user.id == self.author_id: 27 | # return True 28 | # else: 29 | # await interaction.response.send_message('This confirmation dialog is not for you.', ephemeral=True) 30 | # return False 31 | 32 | # async def on_timeout(self) -> None: 33 | # try: 34 | # await self.message.edit(view=None) 35 | # except discord.NotFound: 36 | # pass 37 | 38 | # @discord.ui.button(emoji="\N{ROLLING ON THE FLOOR LAUGHING}", label='Funny', custom_id="funny-funny", style=discord.ButtonStyle.green) 39 | # async def funny(self, button: discord.ui.Button, interaction: discord.Interaction): 40 | # self.value = True 41 | # await interaction.response.defer() 42 | # await interaction.edit_original_response(view=None) 43 | # await interaction.followup.send("Thank you for improving the bot!", ephemeral=True) 44 | # self.stop() 45 | 46 | # @discord.ui.button(emoji="\N{YAWNING FACE}", label='Not funny', custom_id="funny-nah", style=discord.ButtonStyle.red) 47 | # async def nah(self, button: discord.ui.Button, interaction: discord.Interaction): 48 | # self.value = False 49 | # await interaction.response.defer() 50 | # await interaction.delete_original_response() 51 | # await interaction.followup.send("Thank you for improving the bot!", ephemeral=True) 52 | # self.stop() 53 | 54 | 55 | # class Config: 56 | # @classmethod 57 | # async def from_record(cls, record, bot): 58 | # self = cls() 59 | 60 | # self.bot = bot 61 | # self.id: int = int(record["id"], base=10) 62 | # self.enabled: bool = bool(record["toyst_enabled"]) 63 | # return self 64 | 65 | 66 | # class TOYST(commands.Cog): 67 | # def __init__(self, bot: "Bot"): 68 | # self.bot = bot 69 | # self.lock = asyncio.Lock() 70 | 71 | # def __repr__(self) -> str: 72 | # return f"" 73 | 74 | # @discord.utils.cached_property 75 | # def log_new_toyst(self) -> CustomWebhook: 76 | # return CustomWebhook.partial(os.environ.get("WEBHOOKNEWTOYSTID"), os.environ.get("WEBHOOKNEWTOYSTTOKEN"), session=self.bot.session) 77 | 78 | # @cache.cache() 79 | # async def get_guild_config(self, guild_id: int) -> Optional[Config]: 80 | # query = "SELECT * FROM servers WHERE id=$1 LIMIT 1;" 81 | # async with self.bot.pool.acquire(timeout=300.0) as conn: 82 | # record = await conn.fetchrow(query, str(guild_id)) 83 | # self.bot.logger.debug(f"PostgreSQL Query: \"{query}\" + {str(guild_id)}") 84 | # if record is not None: 85 | # return await Config.from_record(record, self.bot) 86 | # return None 87 | 88 | # @commands.Cog.listener() 89 | # async def on_message(self, msg: discord.Message) -> None: 90 | # await self.bot.wait_until_ready() 91 | 92 | # if msg.guild is None: 93 | # return 94 | 95 | # if msg.author.bot: 96 | # return 97 | 98 | # if len(msg.clean_content) == 0 or len(msg.clean_content) > 200: 99 | # return 100 | 101 | # if msg.guild.id in self.bot.blacklist or msg.author.id in self.bot.blacklist: 102 | # return 103 | 104 | # ctx = await self.bot.get_context(msg, cls=MyContext) 105 | # if ctx.command is not None: 106 | # return 107 | 108 | # result, intent, chance, inbag, incomingContext, outgoingContext, sentiment = await queryIntents.classify_local(msg.clean_content) 109 | 110 | # if msg.content == "s": 111 | # view = FunnyOrNah(timeout=20, author_id=msg.author.id, ctx=ctx) 112 | # view.message = await msg.reply(embed=embed(title="something poggers"), view=view) 113 | # await view.wait() 114 | # if view.value: 115 | # await self.log_new_toyst.safe_send(embed=embed(title="This was funny", description=msg.clean_content)) 116 | 117 | 118 | async def setup(bot): 119 | ... 120 | # await bot.add_cog(TOYST(bot)) 121 | -------------------------------------------------------------------------------- /functions/languages.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | import logging 5 | import os 6 | import shutil 7 | import sys 8 | from typing import TYPE_CHECKING 9 | from zipfile import ZipFile 10 | 11 | import aiofiles 12 | import asyncio 13 | import aiofiles.os 14 | from i18n import I18n 15 | 16 | if TYPE_CHECKING: 17 | from index import Friday 18 | 19 | log = logging.getLogger(__name__) 20 | 21 | 22 | def extract_zip(path, zdir): 23 | with ZipFile(path, 'r') as zipf: 24 | zipf.extractall(zdir) 25 | 26 | 27 | extract_zip = aiofiles.os.wrap(extract_zip) # type: ignore 28 | rmtree = aiofiles.os.wrap(shutil.rmtree) # type: ignore 29 | unlink = aiofiles.os.wrap(os.unlink) # type: ignore 30 | 31 | 32 | async def pull_languages(build_id: int, *, bot: Friday, lang_dir: str, base_url: str, key: str) -> None: 33 | locales_path = os.path.join(lang_dir, "locales") 34 | zip_path = os.path.join(lang_dir, "i18n_translations.zip") 35 | async with bot.session.get(f"{base_url}/translations/builds/{build_id}/download", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) as resp: 36 | try: 37 | resp.raise_for_status() 38 | except Exception as e: 39 | log.error(e) 40 | raise e 41 | data = await resp.json() 42 | while data["data"].get("url", None) is None: 43 | await asyncio.sleep(1) 44 | async with bot.session.get(f"{base_url}/translations/builds/{build_id}/download", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) as resp: 45 | try: 46 | resp.raise_for_status() 47 | except Exception as e: 48 | log.error(e) 49 | raise e 50 | data = await resp.json() 51 | 52 | url = data["data"]["url"] 53 | async with bot.session.get(url) as resp: 54 | async with aiofiles.open(zip_path, mode="wb+") as f: 55 | while True: 56 | try: 57 | chunk = await resp.content.read(1024) 58 | if not chunk: 59 | break 60 | await f.write(chunk) 61 | except Exception as e: 62 | log.error(e) 63 | raise e 64 | log.info("Downloaded zipfile of Crowdin translations") 65 | 66 | try: 67 | for fi in os.listdir(locales_path): 68 | fpath = os.path.join(locales_path, fi) 69 | try: 70 | if os.path.isfile(fpath): 71 | await unlink(fpath) 72 | elif os.path.isdir(fpath): 73 | await rmtree(fpath) 74 | except Exception: 75 | log.exception("Failed to remove %s", fi) 76 | except FileNotFoundError: 77 | pass 78 | log.info("Cleaned up i18n/locales") 79 | 80 | await extract_zip(zip_path, lang_dir) # type: ignore 81 | log.info("Extracted zipfile of Crowdin translations") 82 | 83 | await unlink(zip_path) 84 | 85 | 86 | async def load_languages(bot: Friday) -> None: 87 | bot.language_files = {} 88 | base_url = "https://api.crowdin.com/api/v2/projects/484775" 89 | key = os.environ["CROWDIN_KEY"] 90 | lang_dir = os.path.join(os.getcwd(), "i18n") 91 | log.debug(f"path {lang_dir!r}") 92 | 93 | if bot.cluster_idx == 0: 94 | try: 95 | async with bot.session.post(f"{base_url}/translations/builds", headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"}) as resp: 96 | try: 97 | resp.raise_for_status() 98 | except Exception as e: 99 | log.error(e) 100 | raise e 101 | data = await resp.json() 102 | build_id = data["data"]["id"] 103 | log.info("Built Crowdin translations") 104 | 105 | await pull_languages( 106 | build_id, 107 | bot=bot, 108 | lang_dir=lang_dir, 109 | base_url=base_url, 110 | key=key, 111 | ) 112 | except Exception as e: 113 | log.error(e) 114 | pass 115 | 116 | async with aiofiles.open(os.path.join(lang_dir, "en", "commands.json"), mode="r", encoding="utf8") as f: 117 | content = json.loads(await f.read()) 118 | 119 | bot.language_files["en"] = I18n.from_dict(content) 120 | log.debug("Loaded Language 'en'") 121 | 122 | locales_path = os.path.join(lang_dir, "locales") 123 | for fi in os.listdir(locales_path): 124 | fpath = os.path.join(locales_path, fi) 125 | if os.path.isdir(fpath): 126 | json_fpath = os.path.join(fpath, "commands.json") 127 | async with aiofiles.open(json_fpath, "r", encoding="utf8") as f: 128 | content = json.loads(await f.read()) 129 | 130 | bot.language_files[fi] = I18n.from_dict(content) 131 | log.debug(f"Loaded Language {fi!r}") 132 | 133 | log.info('Loaded %i languages (%s bytes)', len(bot.language_files), sys.getsizeof(bot.language_files)) 134 | -------------------------------------------------------------------------------- /functions/cache.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import enum 5 | import time 6 | from functools import wraps 7 | from typing import Any, Callable, Coroutine, MutableMapping, Protocol, TypeVar 8 | 9 | from discord.ext import commands 10 | from lru import LRU 11 | 12 | R = TypeVar('R') 13 | 14 | # Can't use ParamSpec due to https://github.com/python/typing/discussions/946 15 | 16 | 17 | class CacheProtocol(Protocol[R]): 18 | cache: MutableMapping[str, asyncio.Task[R]] 19 | 20 | def __call__(self, *args: Any, **kwargs: Any) -> asyncio.Task[R]: 21 | ... 22 | 23 | def get_key(self, *args: Any, **kwargs: Any) -> str: 24 | ... 25 | 26 | def invalidate(self, cog: commands.Cog, *args: Any, **kwargs: Any) -> bool: 27 | ... 28 | 29 | def invalidate_containing(self, key: str) -> None: 30 | ... 31 | 32 | def get_stats(self) -> tuple[int, int]: 33 | ... 34 | 35 | 36 | class ExpiringCache(dict): 37 | def __init__(self, seconds: float): 38 | self.__ttl: float = seconds 39 | super().__init__() 40 | 41 | def __verify_cache_integrity(self): 42 | # Have to do this in two steps... 43 | current_time = time.monotonic() 44 | to_remove = [k for (k, (v, t)) in self.items() if current_time > (t + self.__ttl)] 45 | for k in to_remove: 46 | del self[k] 47 | 48 | def __contains__(self, key: str): 49 | self.__verify_cache_integrity() 50 | return super().__contains__(key) 51 | 52 | def __getitem__(self, key: str): 53 | self.__verify_cache_integrity() 54 | return super().__getitem__(key) 55 | 56 | def __setitem__(self, key: str, value: Any): 57 | super().__setitem__(key, (value, time.monotonic())) 58 | 59 | 60 | class Strategy(enum.Enum): 61 | lru = 1 62 | raw = 2 63 | timed = 3 64 | 65 | 66 | def cache( 67 | maxsize: int = 128, 68 | strategy: Strategy = Strategy.lru, 69 | ignore_kwargs: bool = False, 70 | ) -> Callable[[Callable[..., Coroutine[Any, Any, R]]], CacheProtocol[R]]: 71 | def decorator(func: Callable[..., Coroutine[Any, Any, R]]) -> CacheProtocol[R]: 72 | if strategy is Strategy.lru: 73 | _internal_cache = LRU(maxsize) 74 | _stats = _internal_cache.get_stats 75 | elif strategy is Strategy.raw: 76 | _internal_cache = {} 77 | 78 | def _stats(): 79 | return (0, 0) 80 | elif strategy is Strategy.timed: 81 | _internal_cache = ExpiringCache(maxsize) 82 | 83 | def _stats(): 84 | return (0, 0) 85 | 86 | def _make_key(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str: 87 | # this is a bit of a cluster fuck 88 | # we do care what 'self' parameter is when we __repr__ it 89 | def _true_repr(o): 90 | if o.__class__.__repr__ is object.__repr__: 91 | return f'<{o.__class__.__module__}.{o.__class__.__name__}>' 92 | return repr(o) 93 | 94 | key = [f'{func.__module__}.{func.__name__}'] 95 | key.extend(_true_repr(o) for o in args) 96 | if not ignore_kwargs: 97 | for k, v in kwargs.items(): 98 | # note: this only really works for this use case in particular 99 | # I want to pass asyncpg.Connection objects to the parameters 100 | # however, they use default __repr__ and I do not care what 101 | # connection is passed in, so I needed a bypass. 102 | if k == 'connection' or k == 'pool': 103 | continue 104 | 105 | key.append(_true_repr(k)) 106 | key.append(_true_repr(v)) 107 | 108 | return ':'.join(key) 109 | 110 | @wraps(func) 111 | def wrapper(*args: Any, **kwargs: Any): 112 | key = _make_key(args, kwargs) 113 | try: 114 | task = _internal_cache[key] 115 | except KeyError: 116 | _internal_cache[key] = task = asyncio.create_task(func(*args, **kwargs)) 117 | return task 118 | else: 119 | return task 120 | 121 | def _invalidate(*args: Any, **kwargs: Any) -> bool: 122 | try: 123 | del _internal_cache[_make_key(args, kwargs)] 124 | except KeyError: 125 | return False 126 | else: 127 | return True 128 | 129 | def _invalidate_containing(key: str) -> None: 130 | to_remove = [] 131 | for k in _internal_cache.keys(): 132 | if key in k: 133 | to_remove.append(k) 134 | for k in to_remove: 135 | try: 136 | del _internal_cache[k] 137 | except KeyError: 138 | continue 139 | 140 | wrapper.cache = _internal_cache # type: ignore 141 | wrapper.get_key = lambda *args, **kwargs: _make_key(args, kwargs) # type: ignore 142 | wrapper.invalidate = _invalidate # type: ignore 143 | wrapper.get_stats = _stats # type: ignore 144 | wrapper.invalidate_containing = _invalidate_containing # type: ignore 145 | return wrapper # type: ignore 146 | 147 | return decorator 148 | -------------------------------------------------------------------------------- /cogs/sharding.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import logging 5 | from typing import TYPE_CHECKING, Any, Literal 6 | from uuid import uuid4 7 | from contextlib import redirect_stdout 8 | from traceback import format_exc 9 | from io import StringIO 10 | 11 | from discord.ext import commands 12 | 13 | 14 | if TYPE_CHECKING: 15 | from index import Friday 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | 20 | class Event: 21 | def __init__(self, command_id: str, *, output=None, scope: Literal["bot", "launcher"] = None, action: str = None, args: dict = {}): 22 | self.command_id = command_id 23 | 24 | self.scope = scope 25 | self.action = action 26 | self.args = args 27 | 28 | self.output = output 29 | 30 | def __repr__(self) -> str: 31 | return f"" 32 | 33 | 34 | class Sharding(commands.Cog): 35 | def __init__(self, bot: Friday): 36 | self.bot: Friday = bot 37 | 38 | self._messages: dict[str, Any] = dict() 39 | 40 | self.tasks_to_launcher = self.bot.tasks 41 | self.tasks_from_launcher = self.bot.tasks_to_complete 42 | self.executer = self.bot.task_executer 43 | 44 | self.router = None 45 | 46 | def __repr__(self) -> str: 47 | return f"" 48 | 49 | async def cog_load(self): 50 | if self.tasks_from_launcher is None: 51 | return 52 | self.router = asyncio.create_task(self.handle_tasks_received()) 53 | 54 | async def cog_unload(self): 55 | if self.router and not self.router.cancelled: 56 | self.router.cancel() 57 | 58 | async def handle_tasks_received(self): 59 | if self.tasks_from_launcher is None: 60 | return 61 | while not self.bot.is_closed(): 62 | task: Event = await self.bot.loop.run_in_executor(self.executer, self.tasks_from_launcher.get) 63 | if task.action: 64 | if task.scope != "bot": 65 | continue 66 | if task.args: 67 | asyncio.create_task( 68 | getattr(self, task.action)( 69 | **task.args, 70 | command_id=task.command_id 71 | ) 72 | ) 73 | else: 74 | asyncio.create_task( 75 | getattr(self, task.action)( 76 | command_id=task.command_id 77 | ) 78 | ) 79 | if task.output and task.command_id in self._messages: 80 | for fut in self._messages[task.command_id]: 81 | if not fut.done(): 82 | fut.set_result(task.output) 83 | break 84 | 85 | async def evaluate(self, body: str, command_id: str): 86 | async def _eval(_body): 87 | env = { 88 | 'bot': self.bot, 89 | 'self': self, 90 | } 91 | 92 | env.update(globals()) 93 | stdout = StringIO() 94 | try: 95 | exec(_body, env) 96 | except Exception as e: 97 | return f"```py\n{e.__class__.__name__}: {e}\n```" 98 | 99 | func = env['func'] 100 | try: 101 | with redirect_stdout(stdout): 102 | ret = await func() 103 | except Exception: 104 | value = stdout.getvalue() 105 | return f"```py\n{value}{format_exc()}\n```" 106 | else: 107 | value = stdout.getvalue() 108 | 109 | if ret is None: 110 | if value: 111 | return f"```py\n{value}\n```" 112 | else: 113 | return f"```py\n{value}{ret}\n```" 114 | return "```py\nNone\n```" 115 | 116 | e = Event(command_id, output=await _eval(body)) 117 | if self.tasks_to_launcher: 118 | await self.bot.loop.run_in_executor(self.executer, self.tasks_to_launcher.put, e) 119 | 120 | async def handler( 121 | self, 122 | action: str, 123 | args: dict = {}, 124 | _timeout: int = 2, 125 | scope: Literal["bot", "launcher"] = "bot" 126 | ) -> list: 127 | command_id = f"{uuid4()}" 128 | results = [] 129 | self._messages[command_id] = [ 130 | asyncio.Future() for _ in range(self.bot.shard_count) 131 | ] 132 | 133 | if self.tasks_to_launcher is None: 134 | raise RuntimeError("tasks_to_launcher is None") 135 | await self.bot.loop.run_in_executor(self.executer, self.tasks_to_launcher.put, Event(command_id, scope=scope, action=action, args=args)) 136 | 137 | try: 138 | done, _ = await asyncio.wait( 139 | self._messages[command_id], timeout=_timeout 140 | ) 141 | for fut in done: 142 | results.append(fut.result()) 143 | except asyncio.TimeoutError: 144 | pass 145 | del self._messages[command_id] 146 | return results 147 | 148 | # async def send_event(self, action: Literal["statuses", "reload_cog"], scope: Literal["all"] = "all", _input: Any = None): 149 | # if self.tasks_to_launcher is None: 150 | # raise RuntimeError("tasks_to_launcher is None") 151 | # event = Event(action, scope, _input) 152 | # await self.bot.loop.run_in_executor(self.executer, self.tasks_to_launcher.put, event) 153 | 154 | 155 | async def setup(bot): 156 | ... 157 | # await bot.add_cog(Sharding(bot)) 158 | -------------------------------------------------------------------------------- /tests/test_reddit.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | import asyncio 7 | 8 | from functions.messagecolors import MessageColors 9 | 10 | from .conftest import send_command, msg_check 11 | 12 | if TYPE_CHECKING: 13 | from discord.channel import TextChannel 14 | from discord import RawReactionActionEvent 15 | 16 | from .conftest import Friday, UnitTester 17 | 18 | pytestmark = pytest.mark.asyncio 19 | 20 | 21 | async def test_text(bot: UnitTester, channel: TextChannel): 22 | content = "!redditextract https://www.reddit.com/r/GPT3/comments/q2gr84/how_to_get_multiple_outputs_from_the_same_prompt/" 23 | com = await send_command(bot, channel, content) 24 | 25 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout * 2.0) # type: ignore 26 | assert msg.embeds[0].type == "image" or msg.embeds[0].color.value != MessageColors.error() 27 | 28 | 29 | async def test_video(bot: UnitTester, channel: TextChannel): 30 | content = "!redditextract https://www.reddit.com/r/WinStupidPrizes/comments/q2o2p8/twerking_in_a_car_wash_with_the_door_open/" 31 | com = await send_command(bot, channel, content) 32 | 33 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout * 10.0) # type: ignore 34 | assert len(msg.attachments) > 0 35 | assert "video/" in msg.attachments[0].content_type 36 | 37 | 38 | async def test_image(bot: UnitTester, channel: TextChannel): 39 | content = "!redditextract https://www.reddit.com/r/woooosh/comments/q2hzu3/this_is_heartbreaking/" 40 | com = await send_command(bot, channel, content) 41 | 42 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout * 3.0) # type: ignore 43 | assert msg.embeds[0].type == "image" 44 | if msg.embeds[0].color is not None: 45 | assert hasattr(msg.embeds[0].color, "value") and msg.embeds[0].color.value != MessageColors.error() 46 | 47 | 48 | async def test_gif(bot: UnitTester, channel: TextChannel): 49 | content = "!redditextract https://reddit.com/r/wholesomememes/comments/pz75c7/dont_worry_mom_i_got_this/" 50 | com = await send_command(bot, channel, content) 51 | 52 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout * 3.0) # type: ignore 53 | assert msg.embeds[0].type == "image" 54 | if msg.embeds[0].color is not None: 55 | assert hasattr(msg.embeds[0].color, "value") and msg.embeds[0].color.value != MessageColors.error() 56 | 57 | 58 | async def test_delete_before_post(bot: UnitTester, friday: Friday, channel: TextChannel): 59 | content = "!redditextract https://reddit.com/r/wholesomememes/comments/pz75c7/dont_worry_mom_i_got_this/" 60 | com = await send_command(bot, channel, content) 61 | 62 | await com.delete() 63 | msg = await bot.wait_for("message", check=lambda m: m.author.id == friday.user.id, timeout=pytest.timeout * 3.0) # type: ignore 64 | assert msg.embeds[0].type == "image" 65 | assert msg.reference is None 66 | if msg.embeds[0].color is not None: 67 | assert hasattr(msg.embeds[0].color, "value") and msg.embeds[0].color.value != MessageColors.error() 68 | 69 | # https://www.reddit.com/r/IsTodayFridayThe13th/comments/uounpa/is_today_friday_the_13th/?utm_medium=android_app&utm_source=share 70 | # https://www.reddit.com/r/deathgrips/comments/uxg2j5/我看過錄像/?utm_medium=android_app&utm_source=share 71 | # add test for the above link 72 | # test should fail 73 | 74 | 75 | @pytest.mark.parametrize("url", [ 76 | "https://reddit.com/r/wholesomememes/comments/pz75c7/dont_worry_mom_i_got_this/", 77 | "https://www.reddit.com/r/woooosh/comments/q2hzu3/this_is_heartbreaking/", 78 | "https://www.reddit.com/r/WinStupidPrizes/comments/q2o2p8/twerking_in_a_car_wash_with_the_door_open/", 79 | "https://v.redd.it/1w1phbekti791" 80 | ]) 81 | async def test_message_reaction(bot: UnitTester, friday: Friday, channel: TextChannel, url: str): 82 | com = await send_command(bot, channel, url) 83 | 84 | def check(payload: RawReactionActionEvent) -> bool: 85 | return payload.message_id == com.id and payload.user_id == friday.user.id 86 | 87 | payload: RawReactionActionEvent = await bot.wait_for("raw_reaction_add", check=check, timeout=pytest.timeout * 3.0) # type: ignore 88 | assert payload.emoji.name == "🔗" 89 | 90 | 91 | @pytest.mark.parametrize("url", [ 92 | "this is not a reddit link", 93 | "https://www.google.com", 94 | "https://www.reddit.com/r/GPT3/comments/q2gr84/how_to_get_multiple_outputs_from_the_same_prompt/" 95 | ]) 96 | async def test_message_no_reaction(bot: UnitTester, friday: Friday, channel: TextChannel, url: str): 97 | com = await send_command(bot, channel, url) 98 | 99 | def check(payload: RawReactionActionEvent) -> bool: 100 | return payload.message_id == com.id and payload.user_id == friday.user.id 101 | 102 | with pytest.raises(asyncio.TimeoutError): 103 | payload: RawReactionActionEvent = await bot.wait_for("raw_reaction_add", check=check, timeout=pytest.timeout * 2.0) # type: ignore 104 | assert payload.emoji.name == "🔗" 105 | -------------------------------------------------------------------------------- /functions/db.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import datetime 4 | import json 5 | import logging 6 | import os 7 | import re 8 | import uuid 9 | from pathlib import Path 10 | from typing import TypedDict 11 | 12 | import asyncpg 13 | import click 14 | 15 | log = logging.getLogger(__name__) 16 | 17 | 18 | class Revisions(TypedDict): 19 | # The version key represents the current activated version 20 | # So v1 means v1 is active and the next revision should be v2 21 | # In order for this to work the number has to be monotonically increasing 22 | # and have no gaps 23 | version: int 24 | database_uri: str 25 | 26 | 27 | REVISION_FILE = re.compile(r'(?PV|U)(?P[0-9]+)__(?P.+).sql') 28 | 29 | 30 | class Revision: 31 | __slots__ = ('kind', 'version', 'description', 'file') 32 | 33 | def __init__(self, *, kind: str, version: int, description: str, file: Path) -> None: 34 | self.kind: str = kind 35 | self.version: int = version 36 | self.description: str = description 37 | self.file: Path = file 38 | 39 | @classmethod 40 | def from_match(cls, match: re.Match[str], file: Path): 41 | return cls( 42 | kind=match.group('kind'), version=int(match.group('version')), description=match.group('description'), file=file 43 | ) 44 | 45 | 46 | class Migrations: 47 | def __init__(self, *, filename: str = 'migrations/revisions.json'): 48 | self.filename: str = filename 49 | self.root: Path = Path(filename).parent 50 | self.revisions: dict[int, Revision] = self.get_revisions() 51 | self.load() 52 | 53 | def ensure_path(self) -> None: 54 | self.root.mkdir(exist_ok=True) 55 | 56 | def load_metadata(self) -> Revisions: 57 | try: 58 | with open(self.filename, 'r', encoding='utf-8') as fp: 59 | return json.load(fp) 60 | except FileNotFoundError: 61 | return { 62 | 'version': 0, 63 | 'database_uri': os.environ["DBURL"], 64 | } 65 | 66 | def get_revisions(self) -> dict[int, Revision]: 67 | result: dict[int, Revision] = {} 68 | for file in self.root.glob('*.sql'): 69 | match = REVISION_FILE.match(file.name) 70 | if match is not None: 71 | rev = Revision.from_match(match, file) 72 | result[rev.version] = rev 73 | 74 | return result 75 | 76 | def dump(self) -> Revisions: 77 | return { 78 | 'version': self.version, 79 | 'database_uri': self.database_uri, 80 | } 81 | 82 | def load(self) -> None: 83 | self.ensure_path() 84 | data = self.load_metadata() 85 | self.version: int = data['version'] 86 | self.database_uri: str = data['database_uri'] 87 | 88 | def save(self): 89 | temp = f'{self.filename}.{uuid.uuid4()}.tmp' 90 | with open(temp, 'w', encoding='utf-8') as tmp: 91 | json.dump(self.dump(), tmp) 92 | 93 | # atomically move the file 94 | os.replace(temp, self.filename) 95 | 96 | def is_next_revision_taken(self) -> bool: 97 | return self.version + 1 in self.revisions 98 | 99 | @property 100 | def ordered_revisions(self) -> list[Revision]: 101 | return sorted(self.revisions.values(), key=lambda r: r.version) 102 | 103 | def create_revision(self, reason: str, *, kind: str = 'V') -> Revision: 104 | cleaned = re.sub(r'\s', '_', reason) 105 | filename = f'{kind}{self.version + 1}__{cleaned}.sql' 106 | path = self.root / filename 107 | 108 | stub = ( 109 | f'-- Revises: V{self.version}\n' 110 | f'-- Creation Date: {datetime.datetime.utcnow()} UTC\n' 111 | f'-- Reason: {reason}\n\n' 112 | ) 113 | 114 | with open(path, 'w', encoding='utf-8', newline='\n') as fp: 115 | fp.write(stub) 116 | 117 | self.save() 118 | return Revision(kind=kind, description=reason, version=self.version + 1, file=path) 119 | 120 | async def upgrade(self, connection: asyncpg.Connection) -> int: 121 | ordered = self.ordered_revisions 122 | successes = 0 123 | async with connection.transaction(): 124 | for revision in ordered: 125 | if revision.version > self.version: 126 | sql = revision.file.read_text('utf-8') 127 | await connection.execute(sql) 128 | successes += 1 129 | 130 | self.version += successes 131 | self.save() 132 | return successes 133 | 134 | def display(self) -> None: 135 | ordered = self.ordered_revisions 136 | for revision in ordered: 137 | if revision.version > self.version: 138 | sql = revision.file.read_text('utf-8') 139 | click.echo(sql) 140 | 141 | 142 | async def create_pool() -> asyncpg.Pool: 143 | def _encode_jsonb(value): 144 | return json.dumps(value) 145 | 146 | def _decode_jsonb(value): 147 | return json.loads(value) 148 | 149 | async def init(con): 150 | await con.set_type_codec( 151 | 'jsonb', 152 | schema='pg_catalog', 153 | encoder=_encode_jsonb, 154 | decoder=_decode_jsonb, 155 | format='text', 156 | ) 157 | 158 | return await asyncpg.create_pool( 159 | os.environ["DBURL"], 160 | init=init, 161 | command_timeout=60, 162 | max_size=20, 163 | min_size=20, 164 | ) 165 | -------------------------------------------------------------------------------- /functions/fuzzy.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | import textwrap 5 | from typing import Callable, Iterable, Literal, Optional, TypeVar, overload 6 | 7 | import numpy as np 8 | from discord.app_commands import Choice 9 | from fuzzywuzzy import fuzz 10 | 11 | T = TypeVar('T') 12 | 13 | 14 | def autocomplete(arr: list[Choice], value: str | float | int) -> list[Choice]: 15 | """Return a list of choices that are at least 90% similar to current.""" 16 | if not value: 17 | # If the value is empty, return the original list 18 | return arr[:25] 19 | 20 | # Create a list of tuples with the choices and their fuzzy match score 21 | choices_with_score = [(choice, fuzz.token_set_ratio(value, textwrap.shorten(choice.value, width=100))) for choice in arr] 22 | # Sort the list by descending score 23 | choices_with_score.sort(key=lambda x: x[1], reverse=True) 24 | # Get the top 25 choices (or less if there are less than 25) 25 | top_choices = [choice for choice, score in choices_with_score[:25]] 26 | 27 | return top_choices 28 | 29 | 30 | def levenshtein_ratio_and_distance(first: str, second: str, ratio_calc: bool = False) -> float: 31 | """ levenshtein_ratio_and_distance: 32 | Calculates levenshtein distance between two strings. 33 | If ratio_calc = True, the function computes the 34 | levenshtein distance ratio of similarity between two strings 35 | For all i and j, distance[i,j] will contain the Levenshtein 36 | distance between the first i characters of first and the 37 | first j characters of second 38 | """ 39 | # Initialize matrix of zeros 40 | rows = len(first) + 1 41 | cols = len(second) + 1 42 | distance = np.zeros((rows, cols), dtype=int) 43 | 44 | # Populate matrix of zeros with the indeces of each character of both strings 45 | for i in range(1, rows): 46 | for k in range(1, cols): 47 | distance[i][0] = i 48 | distance[0][k] = k 49 | 50 | new_row = 0 51 | new_col = 0 52 | 53 | # Iterate over the matrix to compute the cost of deletions,insertions and/or substitutions 54 | for col in range(1, cols): 55 | new_col = col 56 | for row in range(1, rows): 57 | new_row = row 58 | if first[row - 1] == second[col - 1]: 59 | cost = 0 # If the characters are the same in the two strings in a given position [i,j] then the cost is 0 60 | else: 61 | # the cost of a substitution is 2. If we calculate just distance, then the cost of a substitution is 1. 62 | if ratio_calc is True: 63 | cost = 2 64 | else: 65 | cost = 1 66 | distance[row][col] = min(distance[row - 1][col] + 1, # Cost of deletions 67 | distance[row][col - 1] + 1, # Cost of insertions 68 | distance[row - 1][col - 1] + cost) # Cost of substitutions 69 | if ratio_calc is True: 70 | Ratio = ((len(first) + len(second)) - distance[new_row][new_col]) / (len(first) + len(second)) 71 | return Ratio 72 | else: 73 | return distance[new_row][new_col] 74 | 75 | 76 | def levenshtein_string_list(string: str, arr: list[str], *, min_: float = 0.7) -> list[tuple[float, str]]: 77 | """ Return an ordered list in numeric order of the strings in arr that are 78 | at least min_ percent similar to string.""" 79 | return sorted( 80 | [ 81 | (levenshtein_ratio_and_distance(string, arr[x]), i) 82 | for x, i in enumerate(arr) 83 | if levenshtein_ratio_and_distance(string, arr[x]) >= min_ 84 | ], 85 | key=lambda x: x[0], 86 | ) 87 | 88 | 89 | @overload 90 | def finder( 91 | text: str, 92 | collection: Iterable[T], 93 | *, 94 | key: Optional[Callable[[T], str]] = ..., 95 | raw: Literal[True], 96 | ) -> list[tuple[int, int, T]]: 97 | ... 98 | 99 | 100 | @overload 101 | def finder( 102 | text: str, 103 | collection: Iterable[T], 104 | *, 105 | key: Optional[Callable[[T], str]] = ..., 106 | raw: Literal[False], 107 | ) -> list[T]: 108 | ... 109 | 110 | 111 | @overload 112 | def finder( 113 | text: str, 114 | collection: Iterable[T], 115 | *, 116 | key: Optional[Callable[[T], str]] = ..., 117 | raw: bool = ..., 118 | ) -> list[T]: 119 | ... 120 | 121 | 122 | def finder( 123 | text: str, 124 | collection: Iterable[T], 125 | *, 126 | key: Optional[Callable[[T], str]] = None, 127 | raw: bool = False, 128 | ) -> list[tuple[int, int, T]] | list[T]: 129 | suggestions: list[tuple[int, int, T]] = [] 130 | text = str(text) 131 | pat = '.*?'.join(map(re.escape, text)) 132 | regex = re.compile(pat, flags=re.IGNORECASE) 133 | for item in collection: 134 | to_search = key(item) if key else str(item) 135 | r = regex.search(to_search) 136 | if r: 137 | suggestions.append((len(r.group()), r.start(), item)) 138 | 139 | def sort_key(tup: tuple[int, int, T]) -> tuple[int, int, str | T]: 140 | if key: 141 | return tup[0], tup[1], key(tup[2]) 142 | return tup 143 | 144 | if raw: 145 | return sorted(suggestions, key=sort_key) 146 | else: 147 | return [z for _, _, z in sorted(suggestions, key=sort_key)] 148 | -------------------------------------------------------------------------------- /cogs/meme.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | import os 5 | import random 6 | from typing import TYPE_CHECKING, Union 7 | 8 | import asyncpraw 9 | import discord 10 | from discord.ext import commands 11 | from expiringdict import ExpiringDict 12 | 13 | from functions import MessageColors, MyContext, embed 14 | 15 | if TYPE_CHECKING: 16 | from index import Friday 17 | 18 | 19 | class Meme(commands.Cog): 20 | """Get a meme hand delivered to you with Friday's meme command""" 21 | 22 | def __init__(self, bot: Friday): 23 | self.bot: Friday = bot 24 | self.subs = ("dankmemes", "memes", "wholesomememes") 25 | self.posted = ExpiringDict(max_len=1000, max_age_seconds=18000.0) 26 | self.reddit_lock = asyncio.Lock() 27 | self.reddit = asyncpraw.Reddit( 28 | client_id=os.environ.get('REDDITCLIENTID'), 29 | client_secret=os.environ.get('REDDITCLIENTSECRET'), 30 | password=os.environ.get('REDDITPASSWORD'), 31 | user_agent="Friday Discord bot v1.0.0 (by /u/Motostar19)", 32 | username="Friday" 33 | ) 34 | self.reddit.read_only = True 35 | 36 | def __repr__(self) -> str: 37 | return f"" 38 | 39 | async def get_reddit_post(self, ctx: MyContext, sub_reddits: Union[str, tuple], reddit=None) -> dict: # ,hidden:bool=False): 40 | if reddit is None: 41 | raise TypeError("reddit must not be None") 42 | if sub_reddits is None: 43 | raise TypeError("sub_reddits must not be None") 44 | 45 | sub = random.choice(sub_reddits) 46 | # url = "https://www.reddit.com/r/{}.json?sort=top&t=week".format(sub) 47 | 48 | body = None 49 | 50 | # async with ctx.channel.typing(): 51 | # try: 52 | # body = await (await reddit.subreddit(sub)).top("week", params={"count": random.randrange(500)}, limit=10) 53 | async with self.reddit_lock: 54 | body = [i async for i in (await reddit.subreddit(sub)).top("week", params={"count": 500}, limit=10)] 55 | # async for submission in body: 56 | # print(submission.title) 57 | # post, = [submission async for submission in body.top("week")] # , params={"count": random.randrange(1000), "limit": 1}): 58 | # posts += post 59 | # body = await request(url) 60 | # except Exception: 61 | # if hidden: 62 | # return dict(content="Something went wrong, please try again.") 63 | # else: 64 | # return dict(embed=embed(title="Something went wrong, please try again.", color=MessageColors.error())) 65 | 66 | thisposted = ctx.channel and ctx.channel.id 67 | # thisposted = hasattr(ctx, "channel_id") and ctx.channel_id or thisposted 68 | thisposted = ctx.guild and ctx.guild.id or thisposted 69 | 70 | if ctx.channel and ctx.channel.type == discord.ChannelType.private or ctx.channel and getattr(ctx.channel, "nsfw", None): 71 | allowed = body 72 | else: 73 | allowed = [post for post in body if not post.over_18 and post.link_flair_text != "MODPOST" and post.link_flair_text != "Long"] 74 | 75 | x = 0 76 | for post in allowed: 77 | if "https://i.redd.it/" not in post.url: 78 | del allowed[x] 79 | else: 80 | try: 81 | if len(self.posted[thisposted]) > 0 and post.permalink in self.posted[thisposted]: 82 | del allowed[x] 83 | except KeyError: 84 | self.posted[thisposted] = [] 85 | if len(self.posted[thisposted]) > 0 and post.permalink in self.posted[thisposted]: 86 | del allowed[x] 87 | x += 1 88 | 89 | def pickPost(): 90 | post, x = allowed[random.randint(1, len(allowed)) - 1], 0 91 | while post.permalink in self.posted[thisposted] and x < 1000: 92 | post = allowed[random.randint(1, len(allowed)) - 1] 93 | x += 1 94 | 95 | return post 96 | 97 | topost = pickPost() 98 | try: 99 | self.posted[thisposted].append(topost.permalink) 100 | except KeyError: 101 | self.posted[thisposted] = [topost.permalink] 102 | 103 | data = topost 104 | # print(data["url"]) 105 | # if hidden: 106 | # return dict( 107 | # content=f"{data['url']}" 108 | # # content=f"{data.get('title')}\n\n{data['url']}" 109 | # ) 110 | # else: 111 | return dict( 112 | embed=embed( 113 | title=data.title, 114 | url="https://reddit.com" + data.permalink, 115 | # author_name="u/"+data.get("author"), 116 | image=data.url, 117 | color=MessageColors.meme() 118 | ) 119 | ) 120 | 121 | @commands.command(name="meme", aliases=["shitpost"], help="Meme time") 122 | @commands.max_concurrency(1, commands.BucketType.guild, wait=True) 123 | # @commands.cooldown(1, 1, commands.BucketType.user) 124 | async def norm_meme(self, ctx: MyContext): 125 | try: 126 | async with ctx.typing(): 127 | await ctx.reply(**await self.get_reddit_post(ctx, self.subs, self.reddit)) 128 | except discord.Forbidden: 129 | pass 130 | 131 | 132 | async def setup(bot): 133 | await bot.add_cog(Meme(bot)) 134 | -------------------------------------------------------------------------------- /test_will_it_blend.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | # import os 4 | import sys 5 | 6 | import discord 7 | from discord.ext import tasks 8 | from dotenv import load_dotenv 9 | 10 | import functions 11 | from index import Friday 12 | from launcher import setup_logging 13 | 14 | # from create_trans_key import run 15 | 16 | load_dotenv(dotenv_path="./.env") 17 | # TOKEN = os.environ.get('TOKENTEST') 18 | 19 | 20 | class Friday_testing(Friday): 21 | def __init__(self, *args, **kwargs): 22 | super().__init__(*args, **kwargs) 23 | 24 | # self.test_stop.start() 25 | # self.test_message.start() 26 | 27 | # async def setup(self, load_extentions=False): 28 | # for cog in cogs.default: 29 | # await self.load_extension(f"cogs.{cog}") 30 | # return await super().setup(load_extentions=load_extentions) 31 | 32 | async def channel(self) -> discord.TextChannel: 33 | return self.get_channel(892840236781015120) if self.get_channel(892840236781015120) is None else await self.fetch_channel(892840236781015120) # type: ignore 34 | 35 | async def on_ready(self): 36 | await (await self.channel()).send("?ready") 37 | 38 | try: 39 | def online_check(m) -> bool: 40 | return m.author.id == 892865928520413245 and m.channel.id == 892840236781015120 41 | await self.wait_for("message", check=online_check, timeout=3.0) 42 | except asyncio.TimeoutError: 43 | return await self.close() 44 | 45 | def pass_check(m) -> bool: 46 | return m.author.id == 892865928520413245 and (m.content in ("!passed", "!failed", "!complete")) 47 | 48 | await self.wait_for("message", check=pass_check, timeout=120.0) 49 | await self.close() 50 | 51 | @tasks.loop() 52 | async def test_message(self): 53 | print("passed") 54 | 55 | @tasks.loop(seconds=1, count=1) 56 | async def test_stop(self): 57 | await self.wait_until_ready() 58 | while not self.ready: 59 | await asyncio.sleep(0.1) 60 | assert await super().close() 61 | 62 | 63 | # TODO: Add a check for functions modules/files not being named the same as the functions/defs 64 | 65 | # def test_translate_key_gen(): 66 | # run() 67 | 68 | formatter = logging.Formatter("%(levelname)s:%(name)s: %(message)s") 69 | handler = logging.StreamHandler(sys.stdout) 70 | handler.setFormatter(formatter) 71 | 72 | logger = logging.getLogger("Friday") 73 | logger.handlers = [handler] 74 | logger.setLevel(logging.INFO) 75 | 76 | 77 | def test_will_it_blend(): 78 | bot = Friday_testing() 79 | 80 | async def main(bot): 81 | try: 82 | pool = await functions.db.create_pool() 83 | except Exception: 84 | print('Could not set up PostgreSQL. Exiting.') 85 | return 86 | 87 | with setup_logging(): 88 | async with bot: 89 | bot.pool = pool 90 | await bot.start() 91 | 92 | asyncio.run(main(bot)) 93 | 94 | # import asyncio 95 | # import os 96 | # import pytest 97 | # import discord 98 | # # from discord.ext import tasks, commands 99 | # from dotenv import load_dotenv 100 | 101 | # from index import Friday 102 | # import cogs 103 | 104 | # # from create_trans_key import run 105 | 106 | # load_dotenv(dotenv_path="./.env") 107 | # TOKEN = os.environ.get('TOKENTEST') 108 | 109 | 110 | # class Friday_testing(Friday): 111 | # def __init__(self, loop=None, *args, **kwargs): 112 | # self.loop = loop 113 | # super().__init__(loop=self.loop, *args, **kwargs) 114 | 115 | # # self.test_stop.start() 116 | # # self.test_message.start() 117 | 118 | # async def setup(self, load_extentions=False): 119 | # for cog in cogs.default: 120 | # self.load_extension(f"cogs.{cog}") 121 | # return await super().setup(load_extentions=load_extentions) 122 | 123 | # # @tasks.loop() 124 | # # async def test_message(self): 125 | # # print("passed") 126 | 127 | # # @tasks.loop(seconds=1, count=1) 128 | # # async def test_stop(self): 129 | # # await self.wait_until_ready() 130 | # # while not self.ready: 131 | # # await asyncio.sleep(0.1) 132 | # # assert await super().close() 133 | 134 | 135 | # # TODO: Add a check for functions modules/files not being named the same as the functions/defs 136 | 137 | # # def test_translate_key_gen(): 138 | # # run() 139 | 140 | # @pytest.fixture(scope="session") 141 | # def event_loop(): 142 | # return asyncio.get_event_loop() 143 | 144 | 145 | # @pytest.fixture(scope="session", autouse=True) 146 | # async def bot(event_loop) -> Friday_testing: 147 | # bot = Friday_testing(loop=event_loop) 148 | # event_loop.create_task(bot.start(TOKEN)) 149 | # await bot.wait_until_ready() 150 | # return bot 151 | 152 | 153 | # @pytest.fixture(scope="session", autouse=True) 154 | # def cleanup(request, bot): 155 | # def close(): 156 | # asyncio.get_event_loop().run_until_complete(bot.close()) 157 | # request.addfinalizer(close) 158 | 159 | 160 | # @pytest.mark.asyncio 161 | # async def test_will_it_blend(bot): 162 | # assert bot.status == discord.Status.online 163 | # # bot = Friday_testing() 164 | # # loop = asyncio.get_event_loop() 165 | # # try: 166 | # # loop.run_until_complete(bot.start(TOKEN)) 167 | # # except KeyboardInterrupt: 168 | # # # mydb.close() 169 | # # logging.info("STOPED") 170 | # # loop.run_until_complete(bot.close()) 171 | # # finally: 172 | # # loop.close() 173 | -------------------------------------------------------------------------------- /tests/test_fun.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import numpy.random as random 6 | import pytest 7 | 8 | from .conftest import send_command, msg_check 9 | 10 | if TYPE_CHECKING: 11 | from discord import TextChannel 12 | 13 | from .conftest import Friday, UnitTester 14 | 15 | pytestmark = pytest.mark.asyncio 16 | 17 | 18 | @pytest.fixture(autouse=True, scope="module") 19 | async def test_get_cog(friday: Friday): 20 | await friday.wait_until_ready() 21 | assert friday.get_cog("Fun") is not None 22 | return 23 | 24 | 25 | async def test_coinflip(bot: UnitTester, channel: TextChannel): 26 | content = "!coinflip" 27 | com = await send_command(bot, channel, content) 28 | 29 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 30 | assert "The coin landed on: " in msg.embeds[0].title 31 | 32 | 33 | async def test_souptime(bot: UnitTester, channel: TextChannel): 34 | content = "!souptime" 35 | com = await send_command(bot, channel, content) 36 | 37 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 38 | assert "Here is sum soup, just for you" in msg.embeds[0].title 39 | 40 | 41 | @pytest.mark.parametrize("choice", ["rock", "paper", "scissors", "", "asd"]) 42 | async def test_rockpaperscissors(bot: UnitTester, channel: TextChannel, choice): 43 | content = f"!rps {choice}" 44 | com = await send_command(bot, channel, content) 45 | 46 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 47 | if choice == "asd": 48 | assert "`asd` is not Rock, Paper, Scissors. Please choose one of those three." in msg.embeds[0].title 49 | elif choice == "": 50 | assert "!rockpaperscissors" in msg.embeds[0].title 51 | else: 52 | assert "The winner of this round is:" in msg.embeds[0].description 53 | 54 | 55 | async def test_poll(bot: UnitTester, channel: TextChannel): 56 | content = '!poll "this is a title" "yes" "no"' 57 | com = await send_command(bot, channel, content) 58 | 59 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 60 | await bot.wait_for("raw_reaction_add", timeout=pytest.timeout) # type: ignore 61 | await bot.wait_for("raw_reaction_add", timeout=pytest.timeout) # type: ignore 62 | assert msg.embeds[0].title == "Poll: this is a title" 63 | assert "yes" in msg.embeds[0].fields[0].name 64 | assert "no" in msg.embeds[0].fields[1].name 65 | 66 | await msg.add_reaction("1️⃣") 67 | b_edited, a_edited = await bot.wait_for("message_edit", check=lambda b, a: b.embeds[0].title == "Poll: this is a title", timeout=15.0) 68 | assert "0% (0/0)" in b_edited.embeds[0].fields[0].value 69 | assert "0% (0/0)" in b_edited.embeds[0].fields[1].value 70 | assert "100% (1/1)" in a_edited.embeds[0].fields[0].value 71 | assert "0% (0/1)" in a_edited.embeds[0].fields[1].value 72 | 73 | 74 | @pytest.mark.parametrize("size", range(2, 10, 5)) 75 | @pytest.mark.parametrize("bombs", range(2, 15, 5)) 76 | async def test_minesweeper_command(bot: UnitTester, channel: TextChannel, size: int, bombs: int): 77 | content = f"!ms {size} {bombs}" 78 | com = await send_command(bot, channel, content) 79 | 80 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 81 | if bombs >= size * size: 82 | assert msg.embeds[0].title == "Bomb count cannot be larger than the game board" 83 | else: 84 | assert msg.embeds[0].author.name == "Minesweeper" 85 | assert msg.embeds[0].title == f"{size or 5}x{size or 5} with {bombs or 6} bombs" 86 | 87 | 88 | async def test_8ball(bot: UnitTester, channel: TextChannel): 89 | content = "!8ball are you happy?" 90 | com = await send_command(bot, channel, content) 91 | 92 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 93 | assert "🎱 | " in msg.embeds[0].title 94 | 95 | 96 | @pytest.mark.parametrize("start", [*range(-2, 10, 4), 100000000000000000000, -100000000000000000000]) 97 | @pytest.mark.parametrize("end", [*range(-2, 10, 4), 100000000000000000000, -100000000000000000000]) 98 | async def test_rng(bot: UnitTester, channel: TextChannel, start: int, end: int): 99 | content = f"!rng {start} {end}" 100 | com = await send_command(bot, channel, content) 101 | 102 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 103 | if start > end: 104 | assert msg.embeds[0].title == "Start cannot be greater than end" 105 | else: 106 | try: 107 | random.randint(start, end) 108 | except ValueError as e: 109 | if str(e) == "high is out of bounds for int64": 110 | assert msg.embeds[0].title == "One or both of the numbers are too large" 111 | elif str(e) == "low is out of bounds for int64": 112 | assert msg.embeds[0].title == "One or both of the numbers are too small" 113 | else: 114 | assert msg.embeds[0].title == "(╯°□°)╯︵ ┻━┻" 115 | else: 116 | assert start <= int(msg.embeds[0].title) <= end 117 | 118 | 119 | async def test_choice(bot: UnitTester, channel: TextChannel): 120 | choices = range(1, 10) 121 | content = f"!choice {', '.join(str(i) for i in choices)}" 122 | com = await send_command(bot, channel, content) 123 | 124 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 125 | assert int(msg.embeds[0].title) in choices 126 | -------------------------------------------------------------------------------- /migrations/V1__Initial_migration.sql: -------------------------------------------------------------------------------- 1 | -- Revises: V0 2 | -- Creation Date: 2022-09-03 22:44:25.049320 UTC 3 | -- Reason: Initial migration 4 | CREATE TABLE IF NOT EXISTS servers ( 5 | id text PRIMARY KEY NOT NULL, 6 | prefix varchar(5) NOT NULL DEFAULT '!', 7 | max_mentions jsonb NULL DEFAULT NULL, 8 | max_messages jsonb NULL DEFAULT NULL, 9 | max_content jsonb NULL DEFAULT NULL, 10 | remove_invites boolean DEFAULT false, 11 | bot_manager text DEFAULT NULL, 12 | persona text DEFAULT 'default', 13 | customjoinleave text NULL, 14 | chatchannel text NULL DEFAULT NULL, 15 | chatchannel_webhook text NULL, 16 | chatstoprepeating boolean DEFAULT true, 17 | botchannel text NULL DEFAULT NULL, 18 | disabled_commands text [] DEFAULT array []::text [], 19 | restricted_commands text [] DEFAULT array []::text [], 20 | mute_role text NULL DEFAULT NULL, 21 | mod_roles text [] NOT NULL DEFAULT array []::text [], 22 | automod_whitelist text [] DEFAULT array []::text [], 23 | mod_log_channel text NULL DEFAULT NULL, 24 | mod_log_events text [] DEFAULT array ['bans', 'mutes', 'unbans', 'unmutes', 'kicks', 'timeouts']::text [], 25 | muted_members text [] DEFAULT array []::text [], 26 | customsounds jsonb [] NOT NULL DEFAULT array []::jsonb [], 27 | reddit_extract boolean DEFAULT false 28 | ); 29 | CREATE TABLE IF NOT EXISTS joined ( 30 | time TIMESTAMP WITH TIME ZONE, 31 | guild_id text, 32 | joined boolean DEFAULT NULL, 33 | current_count bigint DEFAULT NULL 34 | ); 35 | CREATE TABLE IF NOT EXISTS voting_streaks ( 36 | user_id bigint PRIMARY KEY NOT NULL, 37 | created timestamp NOT NULL DEFAULT (now() at time zone 'utc'), 38 | last_vote timestamp NOT NULL DEFAULT (now() at time zone 'utc'), 39 | days bigint NOT NULL DEFAULT 1, 40 | expires timestamp NOT NULL 41 | ); 42 | CREATE TABLE IF NOT EXISTS reminders ( 43 | id bigserial PRIMARY KEY NOT NULL, 44 | expires timestamp NOT NULL, 45 | created timestamp NOT NULL DEFAULT (now() at time zone 'utc'), 46 | event text, 47 | extra jsonb DEFAULT '{}'::jsonb 48 | ); 49 | CREATE INDEX IF NOT EXISTS reminders_expires_idx ON reminders (expires); 50 | CREATE TABLE IF NOT EXISTS patrons ( 51 | user_id text PRIMARY KEY NOT NULL, 52 | tier smallint NOT NULL DEFAULT 0, 53 | guild_ids text [] NOT NULL DEFAULT array []::text [] 54 | ); 55 | CREATE TABLE IF NOT EXISTS commands ( 56 | id bigserial PRIMARY KEY NOT NULL, 57 | guild_id text NOT NULL, 58 | channel_id text NOT NULL, 59 | author_id text NOT NULL, 60 | used TIMESTAMP WITH TIME ZONE, 61 | prefix text, 62 | command text, 63 | failed boolean 64 | ); 65 | CREATE INDEX IF NOT EXISTS commands_guild_id_idx ON commands (guild_id); 66 | CREATE INDEX IF NOT EXISTS commands_author_id_idx ON commands (author_id); 67 | CREATE INDEX IF NOT EXISTS commands_used_idx ON commands (used); 68 | CREATE INDEX IF NOT EXISTS commands_command_idx ON commands (command); 69 | CREATE INDEX IF NOT EXISTS commands_failed_idx ON commands (failed); 70 | CREATE TABLE IF NOT EXISTS chats ( 71 | id bigserial PRIMARY KEY NOT NULL, 72 | guild_id text NOT NULL, 73 | channel_id text NOT NULL, 74 | author_id text NOT NULL, 75 | used TIMESTAMP WITH TIME ZONE, 76 | user_msg text, 77 | bot_msg text, 78 | prompt text, 79 | failed boolean, 80 | filtered int NULL, 81 | persona text DEFAULT 'friday' 82 | ); 83 | CREATE INDEX IF NOT EXISTS chats_guild_id_idx ON chats (guild_id); 84 | CREATE INDEX IF NOT EXISTS chats_author_id_idx ON chats (author_id); 85 | CREATE INDEX IF NOT EXISTS chats_used_idx ON chats (used); 86 | CREATE INDEX IF NOT EXISTS chats_failed_idx ON chats (failed); 87 | CREATE TABLE IF NOT EXISTS scheduledevents ( 88 | id bigserial PRIMARY KEY NOT NULL, 89 | guild_id bigint NOT NULL, 90 | event_id bigint UNIQUE NOT NULL, 91 | role_id bigint UNIQUE NOT NULL, 92 | subscribers bigint [] NOT NULL DEFAULT array []::bigint [] 93 | ); 94 | CREATE UNIQUE INDEX IF NOT EXISTS scheduledevents_event_id_role_id ON scheduledevents (event_id, role_id); 95 | CREATE TABLE IF NOT EXISTS starboard ( 96 | id bigserial PRIMARY KEY NOT NULL, 97 | channel_id bigint, 98 | threshold int NOT NULL DEFAULT 1, 99 | locked boolean NOT NULL DEFAULT false 100 | ); 101 | CREATE TABLE IF NOT EXISTS starboard_entries ( 102 | id bigserial PRIMARY KEY NOT NULL, 103 | bot_message_id bigint, 104 | message_id bigint UNIQUE NOT NULL, 105 | channel_id bigint, 106 | author_id bigint, 107 | guild_id bigint NOT NULL REFERENCES starboard (id) ON DELETE CASCADE ON UPDATE NO ACTION 108 | ); 109 | CREATE INDEX IF NOT EXISTS starboard_entries_bot_message_id_idx ON starboard_entries (bot_message_id); 110 | CREATE INDEX IF NOT EXISTS starboard_entries_message_id_idx ON starboard_entries (message_id); 111 | CREATE INDEX IF NOT EXISTS starboard_entries_guild_id_idx ON starboard_entries (guild_id); 112 | CREATE TABLE IF NOT EXISTS starrers ( 113 | id bigserial PRIMARY KEY NOT NULL, 114 | author_id bigint NOT NULL, 115 | entry_id bigint NOT NULL REFERENCES starboard_entries (id) ON DELETE CASCADE ON UPDATE NO ACTION 116 | ); 117 | CREATE INDEX IF NOT EXISTS starrers_entry_id_idx ON starrers (entry_id); 118 | CREATE UNIQUE INDEX IF NOT EXISTS starrers_uniq_idx ON starrers (author_id, entry_id); 119 | CREATE TABLE IF NOT EXISTS countdowns ( 120 | guild text NULL, 121 | channel text NOT NULL, 122 | message text PRIMARY KEY NOT NULL, 123 | title text NULL, 124 | time bigint NOT NULL 125 | ); 126 | CREATE TABLE IF NOT EXISTS welcome ( 127 | guild_id text PRIMARY KEY NOT NULL, 128 | role_id text DEFAULT NULL, 129 | channel_id text DEFAULT NULL, 130 | message text DEFAULT NULL 131 | ); 132 | CREATE TABLE IF NOT EXISTS blacklist ( 133 | guild_id text PRIMARY KEY NOT NULL, 134 | punishments text [] DEFAULT array ['delete']::text [], 135 | dmuser bool DEFAULT true, 136 | words text [] 137 | ); -------------------------------------------------------------------------------- /.github/workflows/push.yml: -------------------------------------------------------------------------------- 1 | name: Will it blend? 2 | 3 | on: [push] 4 | 5 | jobs: 6 | type-checking: 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | os: [ubuntu-latest] 12 | python-version: ['3.8','3.11'] 13 | name: Type Checking + Linting ${{ matrix.python-version }} 14 | steps: 15 | - uses: actions/checkout@v3 16 | with: 17 | fetch-depth: 0 18 | ssh-key: ${{ secrets.I18N_SUBMODULE_GH_ACTIONS_PULL }} 19 | submodules: recursive 20 | - uses: actions/setup-python@v4 21 | with: 22 | python-version: ${{ matrix.python-version }} 23 | cache: 'pip' 24 | cache-dependency-path: requirements.txt 25 | 26 | - uses: actions/cache@v3 27 | id: cache 28 | with: 29 | path: ~/venv 30 | key: ${{ matrix.os }}-pip-lint-${{ hashfiles('requirements.txt') }} 31 | 32 | - name: Install dependencies 33 | if: steps.cache.outputs.cache-hit != 'true' 34 | run: | 35 | python -m venv ~/venv 36 | . ~/venv/bin/activate 37 | pip install flake8 38 | pip install -r requirements.txt 39 | 40 | - name: Setup node.js 41 | uses: actions/setup-node@v3 42 | with: 43 | node-version: '16' 44 | 45 | - name: Check pyright 46 | run: | 47 | npm install pyright 48 | cd "$GITHUB_WORKSPACE" 49 | source ~/venv/bin/activate 50 | npx pyright --venv-path ~/venv 51 | 52 | - name: Lint with flake8 53 | run: | 54 | . ~/venv/bin/activate 55 | # stop the build if there are Python syntax errors or undefined names 56 | flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics 57 | # exit-zero treats all errors as warnings. The Github editor is 127 chars wide 58 | flake8 . --count --statistics --config=./setup.cfg 59 | build: 60 | runs-on: ${{ matrix.os }} 61 | name: Launch check os ${{ matrix.os }}, Python ${{ matrix.python-version }} 62 | strategy: 63 | matrix: 64 | os: [ubuntu-latest] 65 | python-version: [3.8] 66 | 67 | steps: 68 | - uses: actions/checkout@v3 69 | with: 70 | ssh-key: ${{ secrets.I18N_SUBMODULE_GH_ACTIONS_PULL }} 71 | submodules: recursive 72 | 73 | - name: Set up Python ${{ matrix.python-version }} 74 | uses: actions/setup-python@v4 75 | with: 76 | python-version: ${{ matrix.python-version }} 77 | cache: 'pip' 78 | cache-dependency-path: requirements.txt 79 | 80 | - uses: actions/cache@v3 81 | id: cache 82 | with: 83 | path: ~/venv 84 | key: ${{ matrix.os }}-pip-pytest-${{ hashfiles('requirements.txt') }} 85 | 86 | - name: Install dependencies 87 | if: steps.cache.outputs.cache-hit != 'true' 88 | run: | 89 | python -m venv ~/venv 90 | . ~/venv/bin/activate 91 | pip install pytest 92 | pip install -r requirements.txt 93 | 94 | - name: Install google key for translations 95 | env: 96 | PROJECT_ID: ${{ secrets.PROJECT_ID }} 97 | PRIVATE_KEY_ID: ${{ secrets.PRIVATE_KEY_ID }} 98 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 99 | CLIENT_EMAIL: ${{ secrets.CLIENT_EMAIL }} 100 | CLIENT_ID: ${{ secrets.CLIENT_ID }} 101 | CLIENT_CERT_URL: ${{ secrets.CLIENT_CERT_URL }} 102 | run: | 103 | . ~/venv/bin/activate 104 | if ! [ -f friday-trans-key.json ]; then python create_trans_key.py; fi 105 | if [ -f friday-trans-key.json ]; then printf "File created"; fi 106 | - name: Test with pytest 107 | env: 108 | DBHOSTNAME: ${{ secrets.DBHOSTNAME }} 109 | DBUSERNAME: ${{ secrets.DBUSERNAME }} 110 | DBUSERNAMECANARY: ${{ secrets.DBUSERNAMECANARY }} 111 | DBUSERNAMELOCAL: ${{ secrets.DBUSERNAMELOCAL }} 112 | DBPASSWORD: ${{ secrets.DBPASSWORD }} 113 | DBPASSWORDCANARY: ${{ secrets.DBPASSWORDCANARY }} 114 | DBPASSWORDLOCAL: ${{ secrets.DBPASSWORDLOCAL }} 115 | DBDATABASE: ${{ secrets.DBDATABASE }} 116 | DBDATABASECANARY: ${{ secrets.DBDATABASECANARY }} 117 | DBDATABASELOCAL: ${{ secrets.DBDATABASELOCAL }} 118 | TOKENTEST: ${{ secrets.TOKENTEST }} 119 | DBLWEBHOOKPASS: ${{ secrets.DBLWEBHOOKPASS }} 120 | WEBHOOKSPAMID: ${{ secrets.WEBHOOKSPAMID }} 121 | WEBHOOKISSUESID: ${{ secrets.WEBHOOKISSUESID }} 122 | WEBHOOKINFOID: ${{ secrets.WEBHOOKINFOID }} 123 | WEBHOOKERRORSID: ${{ secrets.WEBHOOKERRORSID }} 124 | WEBHOOKCHATID: ${{ secrets.WEBHOOKCHATID }} 125 | WEBHOOKJOINID: ${{ secrets.WEBHOOKJOINID }} 126 | WEBHOOKSPAMTOKEN: ${{ secrets.WEBHOOKSPAMTOKEN }} 127 | WEBHOOKISSUESTOKEN: ${{ secrets.WEBHOOKISSUESTOKEN }} 128 | WEBHOOKINFOTOKEN: ${{ secrets.WEBHOOKINFOTOKEN }} 129 | WEBHOOKERRORSTOKEN: ${{ secrets.WEBHOOKERRORSTOKEN }} 130 | WEBHOOKCHATTOKEN: ${{ secrets.WEBHOOKCHATTOKEN }} 131 | WEBHOOKJOINTOKEN: ${{ secrets.WEBHOOKJOINTOKEN }} 132 | PROJECT_ID: ${{ secrets.PROJECT_ID }} 133 | PRIVATE_KEY_ID: ${{ secrets.PRIVATE_KEY_ID }} 134 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 135 | CLIENT_EMAIL: ${{ secrets.CLIENT_EMAIL }} 136 | CLIENT_ID: ${{ secrets.CLIENT_ID }} 137 | CLIENT_CERT_URL: ${{ secrets.CLIENT_CERT_URL }} 138 | GOOGLE_APPLICATION_CREDENTIALS: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS }} 139 | REDDITCLIENTID: ${{ secrets.REDDITCLIENTID }} 140 | REDDITCLIENTSECRET: ${{ secrets.REDDITCLIENTSECRET }} 141 | REDDITPASSWORD: ${{ secrets.REDDITPASSWORD }} 142 | APIREQUESTS: ${{ secrets.APIREQUESTS }} 143 | OPENAI: ${{ secrets.OPENAI }} 144 | CROWDIN_KEY: ${{ secrets.CROWDIN_KEY }} 145 | run: | 146 | . ~/venv/bin/activate 147 | if [ -f friday-trans-key.json ]; then printf "Google file here"; fi 148 | pytest test_will_it_blend.py tests_offline/ -------------------------------------------------------------------------------- /cogs/support.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import logging 4 | from typing import TYPE_CHECKING, Optional, Sequence 5 | 6 | import discord 7 | from discord.ext import commands 8 | 9 | from functions import cache 10 | 11 | # from functions import embed 12 | 13 | if TYPE_CHECKING: 14 | from functions.custom_contexts import MyContext, GuildContext 15 | from index import Friday 16 | 17 | log = logging.getLogger(__name__) 18 | 19 | SUPPORT_SERVER_ID = 707441352367013899 20 | SUPPORT_SERVER_INVITE = "https://discord.gg/NTRuFjU" 21 | SUPPORT_HELP_FORUM = 1019654818962358272 22 | SUPPORT_HELP_FORUM_SOLVED_TAG = 1019679906357055558 23 | PATREON_LINK = "https://www.patreon.com/bePatron?u=42649008" 24 | 25 | 26 | def is_help_thread(): 27 | def predicate(ctx: GuildContext) -> bool: 28 | return isinstance(ctx.channel, discord.Thread) and ctx.channel.parent_id == SUPPORT_HELP_FORUM 29 | 30 | return commands.check(predicate) 31 | 32 | 33 | def can_close_threads(ctx: GuildContext) -> bool: 34 | if not isinstance(ctx.channel, discord.Thread): 35 | return False 36 | 37 | permissions = ctx.channel.permissions_for(ctx.author) 38 | return ctx.channel.parent_id == SUPPORT_HELP_FORUM and ( 39 | permissions.manage_threads or ctx.channel.owner_id == ctx.author.id 40 | ) 41 | 42 | 43 | class Support(commands.Cog): 44 | """Every thing related to the Friday development server""" 45 | 46 | def __init__(self, bot: Friday): 47 | self.bot: Friday = bot 48 | 49 | @cache.cache() 50 | async def is_server_boosted(self, user_id: int) -> bool: 51 | guild = self.bot.get_guild(SUPPORT_SERVER_ID) 52 | if guild is None: 53 | return False 54 | 55 | member = await self.bot.get_or_fetch_member(guild, user_id) 56 | if member is None: 57 | return False 58 | 59 | return member.premium_since is not None 60 | 61 | async def clear_is_server_boosted(self, before: discord.Member, after: discord.Member): 62 | if after.guild.id != SUPPORT_SERVER_ID: 63 | return 64 | 65 | if before.premium_since == after.premium_since: 66 | return 67 | 68 | self.is_server_boosted.invalidate(self, after.id) 69 | 70 | @commands.hybrid_command(name="support") 71 | async def _support(self, ctx: MyContext): 72 | """Get an invite link to my support server""" 73 | await ctx.reply(SUPPORT_SERVER_INVITE) 74 | 75 | @commands.hybrid_command(name="donate") 76 | async def _donate(self, ctx: MyContext): 77 | """Get the Patreon link for Friday""" 78 | await ctx.reply(PATREON_LINK) 79 | 80 | @commands.Cog.listener() 81 | async def on_member_update(self, before: discord.Member, after: discord.Member): 82 | if after.guild.id != SUPPORT_SERVER_ID: 83 | return 84 | 85 | await self.clear_is_server_boosted(before, after) 86 | 87 | if before.roles == after.roles: 88 | return 89 | 90 | before_has = before._roles.has(843941723041300480) # Supporting role 91 | after_has = after._roles.has(843941723041300480) # Supporting role 92 | 93 | if before_has == after_has: 94 | return 95 | 96 | if not after_has and self.bot.patreon: 97 | self.bot.patreon.get_patrons.invalidate(self.bot.patreon) 98 | log.info(f"Lost patreonage for guild {after.guild.id} with user {after.id} :(") 99 | # else: 100 | # welcome new patron 101 | 102 | @commands.Cog.listener() 103 | async def on_ready(self): 104 | guild: Optional[discord.Guild] = self.bot.get_guild(SUPPORT_SERVER_ID) 105 | if self.bot.intents.members and guild and not guild.chunked: 106 | await guild.chunk(cache=True) 107 | 108 | @commands.Cog.listener() 109 | async def on_member_join(self, member: discord.Member): 110 | # await self.bot.request_offline_members() 111 | if self.bot.cluster_idx != 0: 112 | return 113 | 114 | if member.guild.id != SUPPORT_SERVER_ID or member.bot: 115 | return 116 | 117 | if self.bot.get_guild(SUPPORT_SERVER_ID) is None: 118 | return 119 | 120 | async def mark_as_solved(self, thread: discord.Thread, user: discord.abc.User) -> None: 121 | tags: Sequence[discord.abc.Snowflake] = thread.applied_tags 122 | 123 | if not any(tag.id == SUPPORT_HELP_FORUM_SOLVED_TAG for tag in tags): 124 | tags.append(discord.Object(id=SUPPORT_HELP_FORUM_SOLVED_TAG)) # type: ignore 125 | 126 | await thread.edit( 127 | locked=True, 128 | archived=True, 129 | applied_tags=tags[:5], 130 | reason=f'Marked as solved by {user} (ID: {user.id})', 131 | ) 132 | 133 | @commands.hybrid_command(name='solved', aliases=['is_solved']) 134 | @commands.cooldown(1, 20, commands.BucketType.channel) 135 | @commands.guild_only() 136 | @discord.app_commands.guilds(707441352367013899) 137 | @is_help_thread() 138 | async def solved(self, ctx: GuildContext): 139 | """Marks a thread as solved.""" 140 | 141 | assert isinstance(ctx.channel, discord.Thread) 142 | 143 | if can_close_threads(ctx) and ctx.invoked_with == 'solved': 144 | await ctx.message.add_reaction('\u2705') 145 | await self.mark_as_solved(ctx.channel, ctx.author) 146 | else: 147 | msg = f"<@!{ctx.channel.owner_id}>, would you like to mark this thread as solved? This has been requested by {ctx.author.mention}." 148 | confirm = await ctx.prompt(msg, author_id=ctx.channel.owner_id, timeout=300.0) 149 | 150 | if ctx.channel.locked: 151 | return 152 | 153 | if confirm: 154 | await ctx.send( 155 | 'Marking as solved. Note that next time, you can mark the thread as solved yourself with `!solved`.' 156 | ) 157 | await self.mark_as_solved(ctx.channel, ctx.channel.owner or ctx.author) 158 | elif confirm is None: 159 | await ctx.send('Timed out waiting for a response. Not marking as solved.') 160 | else: 161 | await ctx.send('Not marking as solved.') 162 | 163 | @solved.error 164 | async def on_solved_error(self, ctx: GuildContext, error: Exception): 165 | if isinstance(error, commands.CommandOnCooldown): 166 | await ctx.send(f'This command is on cooldown. Try again in {error.retry_after:.2f}s') 167 | 168 | 169 | async def setup(bot): 170 | await bot.add_cog(Support(bot)) 171 | -------------------------------------------------------------------------------- /functions/queryIntents.py: -------------------------------------------------------------------------------- 1 | # import json 2 | # # import logging 3 | # import os 4 | # import random 5 | 6 | # import nltk # type: ignore 7 | # import numpy as np 8 | # import pandas as pd 9 | # # from tensorflow.keras.optimizers import SGD 10 | # from nltk.sentiment import SentimentIntensityAnalyzer # type: ignore 11 | # # from nltk.stem.lancaster import LancasterStemmer 12 | # from nltk.stem import PorterStemmer # type: ignore 13 | # from spellchecker import SpellChecker # type: ignore 14 | # # from flair.data import Sentence 15 | # # from flair.models import MultiTagger # , SequenceTagger 16 | # # from tensorflow.keras.layers import Dropout, Activation, Dense 17 | # from tensorflow.keras.models import load_model # type: ignore 18 | 19 | # spell = SpellChecker() 20 | 21 | # try: 22 | # nltk.data.find('tokenizers/punkt.zip') 23 | # except LookupError: 24 | # nltk.download('punkt') 25 | # try: 26 | # nltk.data.find('vader_lexicon') 27 | # except LookupError: 28 | # nltk.download('vader_lexicon') 29 | # sia = SentimentIntensityAnalyzer() 30 | # stemmer = PorterStemmer() 31 | # os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3' 32 | 33 | # # tagger = MultiTagger.load(["pos","ner"]) 34 | 35 | # words = [] 36 | # classes = [] 37 | # documents = [] 38 | # ignore_words = ['?', '.', ',', '!'] 39 | # context = [] 40 | # # loop through each sentence in our intents patterns 41 | 42 | # model = load_model("ml/models/intent_model.h5") 43 | 44 | 45 | # with open("spice/ml/intents-toyst.json", encoding="utf8") as f: 46 | # intents = json.load(f) 47 | 48 | # for intent in intents: 49 | # for pattern in intent['patterns']: 50 | # # tokenize each word in the sentence 51 | # w = nltk.word_tokenize("".join([p["text"] for p in pattern])) 52 | # # add to our words list 53 | # words.extend(w) 54 | # # add to documents in our corpus 55 | # documents.append((w, intent['tag'])) 56 | # # add to our classes list 57 | # if intent['tag'] not in classes: 58 | # classes.append(intent['tag']) 59 | # # stem and lower each word and remove duplicates 60 | # words = [stemmer.stem(w.lower()) for w in words if w not in ignore_words] 61 | # words = sorted(list(set(words))) 62 | # # sort classes 63 | # # classes = list(set(classes)) 64 | # classes = sorted(list(set(classes))) 65 | 66 | 67 | # def clean_up_sentence(sentence): 68 | # # tokenize the pattern - split words into array 69 | # sentence_words = nltk.word_tokenize(sentence) 70 | # # stem each word - create short form for word 71 | # sentence_words = [stemmer.stem(word.lower()) for word in sentence_words] 72 | # return sentence_words 73 | 74 | # # return bag of words array: 0 or 1 for each word in the bag that exists in the sentence 75 | 76 | 77 | # def bow(sentence, wrds, show_details=True, mentioned=False): 78 | # # tokenize the pattern 79 | # sentence_words = clean_up_sentence(sentence) 80 | # x = 0 81 | # for word in sentence_words: 82 | # corrected_word = spell.correction(word) 83 | # if word == "r": 84 | # sentence_words[x] = "are" 85 | # elif word == "u": 86 | # sentence_words[x] = "you" 87 | # elif word != corrected_word: 88 | # sentence_words[x] = corrected_word 89 | # x += 1 90 | # # bag of words - matrix of N words, vocabulary matrix 91 | # bag = [0] * len(wrds) 92 | # inbag = "" 93 | # for s in sentence_words: 94 | # for i, w in enumerate(wrds): 95 | # if w == s: 96 | # # assign 1 if current word is in the vocabulary position 97 | 98 | # bag[i] = 1 99 | # if show_details: 100 | # inbag += f"{w} " 101 | # # print ("found in bag: ?" % w) 102 | # # sentiment = sia.polarity_scores(" ".join(sentence)) 103 | # # bag.insert(0, sentiment["neg"]) 104 | # # bag.insert(0, sentiment["neu"]) 105 | # # bag.insert(0, sentiment["pos"]) 106 | # # bag.insert(0, 1 if "friday" in sentence else 0) 107 | # # bag.insert(0, sentiment["compound"]) 108 | # # bag.insert(0, 0) 109 | # # print(f"found in bag: {inbag}") 110 | # # logging.info(f"found in bag: {inbag}") 111 | # return (np.array(bag), inbag) 112 | 113 | 114 | # async def classify_local(sentence, mentioned=False): 115 | # ERROR_THRESHOLD = 0.8 116 | 117 | # # generate probabilities from the model 118 | # bows, inbag = bow(sentence, words, mentioned=mentioned) 119 | # input_data = pd.DataFrame([bows], dtype=float, index=['input']) 120 | # # print(inbag) 121 | # results = model.predict([input_data])[0] 122 | # # filter out predictions below a threshold, and provide intent index 123 | # # guesses = [[i, r] for i, r in enumerate(results) if r > ERROR_THRESHOLD / 2] 124 | # results = [[i, r] for i, r in enumerate(results) if r > ERROR_THRESHOLD] 125 | # # sort by strength of probability 126 | # results.sort(key=lambda x: x[1], reverse=True) 127 | # # guesses.sort(key=lambda x: x[1], reverse=True) 128 | # return_list = [] 129 | # for r in results: 130 | # return_list.append((r[0], classes[r[0]], str(r[1]))) 131 | # # return tuple of intent and probability 132 | # # guess_list = [] 133 | # # for r in guesses: 134 | # # guess_list.append((r[0], classes[r[0]], str(r[1]))) 135 | 136 | # # text = Sentence(sentence) 137 | # # tagger.predict(text) 138 | # # for entity in text.get_spans('pos'): 139 | # # print(entity) 140 | # # for entity in text.get_spans('ner'): 141 | # # print(entity) 142 | 143 | # if len(return_list) > 0: 144 | # name, chance = return_list[0][1:] 145 | # tag = [index for index, value in enumerate(intents) if value["tag"] == name] 146 | # intent = intents[tag[0]] 147 | # # guess_index,guess_name,guess_chance = guess_list[0] 148 | # # guess_tag = [guess_index for index,value in enumerate(intents) if value["tag"] == name] 149 | # # guess_intent = intents[guess_tag[0]] 150 | # print(return_list) 151 | # # print(chance) 152 | # # logging.info(chance) 153 | 154 | # if isinstance(intent["responses"], list) and len(intent["responses"]) > 0: 155 | # indresp = random.randint(0, len(intent["responses"]) - 1) 156 | 157 | # response = intent["responses"][indresp] 158 | # # print(intent["incomingContext"],intent["outgoingContext"]) 159 | # # print(len(intent["incomingContext"]),len(intent["outgoingContext"])) 160 | # return response, intent["tag"], chance, inbag, intent["incomingContext"], intent["outgoingContext"], sia.polarity_scores(sentence) 161 | # else: 162 | # return None, None, None, None, None, None, None 163 | # else: 164 | # return None, None, None, None, None, None, None 165 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import pytest 6 | 7 | from .conftest import send_command, msg_check 8 | 9 | if TYPE_CHECKING: 10 | from discord import TextChannel 11 | 12 | from .conftest import UnitTester, UnitTesterUser 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | async def test_prefix(bot: UnitTester, channel: TextChannel): 18 | content = "!prefix ?" 19 | com = await send_command(bot, channel, content) 20 | 21 | f_msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 22 | 23 | content = "?prefix !" 24 | com = await send_command(bot, channel, content) 25 | l_msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 26 | assert f_msg.embeds[0].title == "My new prefix is `?`" and l_msg.embeds[0].title == "My new prefix is `!`" 27 | 28 | 29 | async def test_language(bot: UnitTester, channel: TextChannel): 30 | content = "!serverlang Spanish" 31 | com = await send_command(bot, channel, content) 32 | 33 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 34 | assert msg.embeds[0].title == "New language set to: `Español`" 35 | 36 | content = "!serverlang polish" 37 | com = await send_command(bot, channel, content) 38 | 39 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 40 | assert msg.embeds[0].title == "Language 'polish' does not exist, or is not supported." 41 | 42 | content = "!serverlang en" 43 | com = await send_command(bot, channel, content) 44 | 45 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 46 | assert msg.embeds[0].title == "New language set to: `English`" 47 | 48 | 49 | @pytest.mark.dependency(name="test_botchannel") 50 | async def test_botchannel(bot: UnitTester, channel: TextChannel): 51 | content = "!botchannel 892840236781015120" 52 | com = await send_command(bot, channel, content) 53 | 54 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 55 | assert msg.embeds[0].title == "Bot Channel set" 56 | assert msg.content == "<#892840236781015120>" 57 | 58 | 59 | @pytest.mark.dependency(depends=["test_botchannel"]) 60 | async def test_botchannel_clear(bot: UnitTester, channel: TextChannel): 61 | content = "!botchannel clear" 62 | com = await send_command(bot, channel, content) 63 | 64 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 65 | assert msg.embeds[0].title == "Bot channel cleared" 66 | 67 | 68 | @pytest.mark.dependency(name="test_disable") 69 | @pytest.mark.parametrize("args", ["ping", "serverinfo"]) 70 | async def test_disable(bot: UnitTester, channel: TextChannel, args: str): 71 | content = f"!disable {args}" 72 | com = await send_command(bot, channel, content) 73 | 74 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 75 | assert msg.embeds[0].title == f"**{args}** has been disabled." 76 | 77 | 78 | async def test_disable_list(bot: UnitTester, channel: TextChannel): 79 | content = "!disable list" 80 | com = await send_command(bot, channel, content) 81 | 82 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 83 | assert msg.embeds[0].title == "Disabled Commands" 84 | assert len(msg.embeds[0].description) > 0 85 | 86 | 87 | @pytest.mark.dependency(depends=["test_disable"]) 88 | @pytest.mark.parametrize("args", ["ping", "serverinfo"]) 89 | async def test_enable(bot: UnitTester, channel: TextChannel, args: str): 90 | content = f"!enable {args}" 91 | com = await send_command(bot, channel, content) 92 | 93 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 94 | assert msg.embeds[0].title == f"**{args}** has been enabled." 95 | 96 | 97 | @pytest.mark.dependency(name="test_restrict") 98 | @pytest.mark.parametrize("args", ["ping", "serverinfo"]) 99 | async def test_restrict(bot: UnitTester, channel: TextChannel, args: str): 100 | content = f"!restrict {args}" 101 | com = await send_command(bot, channel, content) 102 | 103 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 104 | assert msg.embeds[0].title == f"**{args}** has been restricted to the bot channel." 105 | 106 | 107 | async def test_restrict_list(bot: UnitTester, channel: TextChannel): 108 | content = "!restrict list" 109 | com = await send_command(bot, channel, content) 110 | 111 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 112 | assert msg.embeds[0].title == "Restricted Commands" 113 | assert len(msg.embeds[0].description) > 0 114 | 115 | 116 | @pytest.mark.dependency(depends=["test_restrict"]) 117 | @pytest.mark.parametrize("args", ["ping", "serverinfo"]) 118 | async def test_unrestrict(bot: UnitTester, channel: TextChannel, args: str): 119 | content = f"!unrestrict {args}" 120 | com = await send_command(bot, channel, content) 121 | 122 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 123 | assert msg.embeds[0].title == f"**{args}** has been unrestricted." 124 | 125 | 126 | async def test_botchannel_restrict_commands(bot: UnitTester, bot_user: UnitTesterUser, channel: TextChannel, channel_user: TextChannel): 127 | other_channel = "824056692098072576" 128 | content = f"!botchannel {other_channel}" 129 | com = await send_command(bot, channel, content) 130 | 131 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 132 | assert msg.embeds[0].title == "Bot Channel set" 133 | assert other_channel in msg.content 134 | 135 | content = "!restrict ping" 136 | com = await send_command(bot, channel, content) 137 | 138 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 139 | assert msg.embeds[0].title == "**ping** has been restricted to the bot channel." 140 | 141 | content = "!ping" 142 | com = await channel_user.send(content) 143 | assert com 144 | 145 | msg = await bot_user.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 146 | assert msg.content == f"<#{other_channel}>" 147 | assert msg.embeds[0].title == "This command is restricted to the bot channel." 148 | 149 | content = f"!botchannel {channel.id}" 150 | com = await send_command(bot, channel, content) 151 | 152 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 153 | assert msg.embeds[0].title == "Bot Channel set" 154 | assert channel.mention in msg.content 155 | 156 | content = "!ping" 157 | com = await channel_user.send(content) 158 | assert com 159 | 160 | msg = await bot_user.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 161 | assert msg.embeds[0].title == "Pong!" 162 | assert "API is" in msg.embeds[0].description 163 | -------------------------------------------------------------------------------- /functions/checks.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Optional 4 | 5 | import asyncpg 6 | import discord 7 | from discord.ext import commands 8 | 9 | from . import config, exceptions 10 | from .config import PremiumTiersNew 11 | 12 | if TYPE_CHECKING: 13 | from cogs.config import Config 14 | from index import Friday 15 | 16 | from .custom_contexts import GuildContext, MyContext 17 | 18 | 19 | # def guild_is_tier(tier: str): 20 | 21 | 22 | def user_is_tier(tier: PremiumTiersNew): 23 | async def predicate(ctx: MyContext) -> bool: 24 | return True 25 | return commands.check(predicate) 26 | 27 | 28 | def is_min_tier(tier: PremiumTiersNew = PremiumTiersNew.tier_1): 29 | async def predicate(ctx: GuildContext) -> bool: 30 | if await ctx.bot.is_owner(ctx.author): 31 | return True 32 | if tier == PremiumTiersNew.free: 33 | return True 34 | guild = ctx.bot.get_guild(config.support_server_id) 35 | if not guild: 36 | raise exceptions.NotInSupportServer() 37 | member = await ctx.bot.get_or_fetch_member(guild, ctx.author.id) 38 | if not member: 39 | raise exceptions.NotInSupportServer() 40 | if await (user_is_min_tier(tier)).predicate(ctx) or await (guild_is_min_tier(tier)).predicate(ctx): 41 | return True 42 | else: 43 | raise exceptions.RequiredTier() 44 | return commands.check(predicate) 45 | 46 | 47 | def guild_is_min_tier(tier: PremiumTiersNew = PremiumTiersNew.tier_1): 48 | """ Checks if a guild has at least patreon 'tier' """ 49 | 50 | async def predicate(ctx: GuildContext) -> bool: 51 | if ctx.guild is None: 52 | raise commands.NoPrivateMessage() 53 | if tier == PremiumTiersNew.free: 54 | return True 55 | pat_cog = ctx.bot.patreon 56 | if pat_cog is None: 57 | return False 58 | user_id = await ctx.db.fetchval("""SELECT user_id FROM patrons WHERE $1 = ANY(patrons.guild_ids) LIMIT 1""", str(ctx.guild.id)) 59 | config_ = [p for p in await pat_cog.get_patrons() if str(p.id) == str(user_id)] 60 | if len(config_) == 0: 61 | return False 62 | 63 | return config_[0].tier >= tier.value 64 | return commands.check(predicate) 65 | 66 | 67 | def user_is_min_tier(tier: PremiumTiersNew = PremiumTiersNew.tier_1): 68 | """ Checks if a user has at least patreon 'tier' """ 69 | 70 | async def predicate(ctx: MyContext) -> bool: 71 | if tier == PremiumTiersNew.free: 72 | return True 73 | pat_cog = ctx.bot.patreon 74 | if pat_cog is None: 75 | return False 76 | 77 | config_ = [p for p in await pat_cog.get_patrons() if p.id == ctx.author.id] 78 | if len(config_) == 0: 79 | return False 80 | return config_[0].tier >= tier.value 81 | return commands.check(predicate) 82 | 83 | 84 | # TODO: Remove this when moved to is_mod_and_min_tier 85 | def is_admin_and_min_tier(tier: PremiumTiersNew = PremiumTiersNew.tier_1): 86 | guild_is_min_tier_ = guild_is_min_tier(tier).predicate 87 | is_admin_ = is_admin().predicate 88 | user_is_min_tier_ = user_is_min_tier(tier).predicate 89 | 90 | async def predicate(ctx: GuildContext) -> bool: 91 | try: 92 | admin = await is_admin_(ctx) 93 | except Exception: 94 | admin = False 95 | if await guild_is_min_tier_(ctx) and (admin or await user_is_min_tier_(ctx)): 96 | return True 97 | err = exceptions.RequiredTier("This command requires a premium server and a patron or an admin.") 98 | err.log = True 99 | raise err 100 | return commands.check(predicate) 101 | 102 | 103 | def is_mod_and_min_tier(*, tier: PremiumTiersNew = PremiumTiersNew.tier_1, **perms: bool): 104 | guild_is_min_tier_ = guild_is_min_tier(tier).predicate 105 | is_mod_or_guild_permissions_ = is_mod_or_guild_permissions(**perms).predicate 106 | user_is_min_tier_ = user_is_min_tier(tier).predicate 107 | 108 | async def predicate(ctx: GuildContext) -> bool: 109 | try: 110 | mod = await is_mod_or_guild_permissions_(ctx) 111 | except Exception: 112 | mod = False 113 | if await guild_is_min_tier_(ctx) and (mod or await user_is_min_tier_(ctx)): 114 | return True 115 | err = exceptions.RequiredTier("This command requires a premium server and a patron or a mod.") 116 | err.log = True 117 | raise err 118 | return commands.check(predicate) 119 | 120 | 121 | def is_supporter(): 122 | """" Checks if the user has the 'is supporting' role that ALL patrons get""" 123 | 124 | async def predicate(ctx: MyContext) -> bool: 125 | if ctx.author.id == ctx.bot.owner_id: 126 | return True 127 | guild = ctx.bot.get_guild(config.support_server_id) 128 | if not guild: 129 | raise exceptions.NotInSupportServer() 130 | member = await ctx.bot.get_or_fetch_member(guild, ctx.author.id) 131 | if member is None: 132 | return False 133 | if await user_is_supporter(ctx.bot, member): 134 | return True 135 | else: 136 | raise exceptions.NotSupporter() 137 | return commands.check(predicate) 138 | 139 | 140 | async def user_is_supporter(bot: Friday, user: discord.Member) -> bool: 141 | if user is None: 142 | raise exceptions.NotInSupportServer() 143 | roles = [role.id for role in user.roles] 144 | if config.patreon_supporting_role not in roles: 145 | raise exceptions.NotSupporter() 146 | return True 147 | 148 | 149 | def is_supporter_or_voted(): 150 | async def predicate(ctx: MyContext) -> bool: 151 | support_guild = ctx.bot.get_guild(config.support_server_id) 152 | if support_guild is None: 153 | raise exceptions.NotInSupportServer() 154 | member = await ctx.bot.get_or_fetch_member(support_guild, ctx.author.id) 155 | if member is None: 156 | return False 157 | if await user_is_supporter(ctx.bot, member): 158 | return True 159 | elif await user_voted(ctx.bot, member): 160 | return True 161 | else: 162 | raise exceptions.NotSupporter() 163 | return commands.check(predicate) 164 | 165 | 166 | async def user_voted(bot: Friday, user: discord.abc.User, *, connection: asyncpg.Pool | asyncpg.Connection = None) -> bool: 167 | dbl_cog = bot.dbl 168 | if dbl_cog is None: 169 | query = """SELECT id 170 | FROM reminders 171 | WHERE event = 'vote' 172 | AND extra #>> '{args,0}' = $1 173 | ORDER BY expires 174 | LIMIT 1;""" 175 | connection = connection or bot.pool 176 | record = await connection.fetchrow(query, str(user.id)) # type: ignore 177 | return True if record else False 178 | return await dbl_cog.user_has_voted(user.id, connection=connection) 179 | 180 | 181 | def is_admin(): 182 | """Do you have permission to change the setting of the bot""" 183 | async def predicate(ctx: GuildContext) -> bool: 184 | is_owner = await ctx.bot.is_owner(ctx.author) 185 | if is_owner: 186 | return True 187 | 188 | if ctx.author.guild_permissions.manage_guild or ctx.author.guild_permissions.administrator: 189 | return True 190 | return False 191 | return commands.check(predicate) 192 | 193 | 194 | def is_mod_or_guild_permissions(**perms: bool): 195 | """User has a mod role or has the following guild permissions""" 196 | async def predicate(ctx: GuildContext) -> bool: 197 | if ctx.guild is None: 198 | raise commands.NoPrivateMessage() 199 | 200 | is_owner = await ctx.bot.is_owner(ctx.author) 201 | if is_owner: 202 | return True 203 | 204 | config_cog: Optional[Config] = ctx.bot.get_cog("Config") # type: ignore 205 | if config_cog is not None: 206 | con = await config_cog.get_guild_config(ctx.guild.id) 207 | if con and any(arole in con.mod_roles for arole in ctx.author.roles): 208 | return True 209 | 210 | resolved = ctx.author.guild_permissions 211 | if all(getattr(resolved, name, None) == value for name, value in perms.items()): 212 | return True 213 | raise commands.MissingPermissions([name for name, value in perms.items() if getattr(resolved, name, None) != value]) 214 | 215 | return commands.check(predicate) 216 | 217 | 218 | def slash(user: bool = False, private: bool = True): 219 | # async def predicate(ctx: SlashContext) -> bool: 220 | # if user is True and ctx.guild_id and ctx.guild is None and ctx.channel is None: 221 | # raise exceptions.OnlySlashCommands() 222 | 223 | # if not private and not ctx.guild and not ctx.guild_id and ctx.channel_id: 224 | # raise commands.NoPrivateMessage() 225 | 226 | # return True 227 | # return commands.check(predicate) 228 | return False 229 | -------------------------------------------------------------------------------- /functions/build_da_docs.py: -------------------------------------------------------------------------------- 1 | import glob 2 | import os 3 | from cogs.help import syntax, get_examples 4 | from typing import TYPE_CHECKING 5 | from discord.ext import commands 6 | 7 | if TYPE_CHECKING: 8 | from index import Friday 9 | 10 | 11 | def write_command(f: glob.glob, com: commands.Command, step: int = 0) -> None: # type: ignore 12 | f.write(f"\n##{''.join('#' for x in range(step))} {com.name.capitalize()}\n") 13 | usage = '\n\t'.join(syntax(com, quotes=False).split('\n')) 14 | f.write("\n" + (com.help + "\n") if com.help is not None else '') 15 | slash = True if hasattr(com.cog, "slash_" + com.qualified_name) or hasattr(com.cog, "_slash_" + com.qualified_name) else False 16 | if (com.extras and "permissions" in com.extras) or (hasattr(com.cog, "extras") and "permissions" in com.cog.extras): 17 | perms = [*(com.cog.extras.get("permissions", []) if hasattr(com.cog, "extras") else []), *com.extras.get("permissions", [])] 18 | f.write("""\n!!! warning "Required Permission(s)"\n\t- """ + "\n\t- ".join(perms) + "\n") 19 | f.write(f"""\n??? {'missing' if not slash else 'check'} "{'Has' if slash else 'Does not have'} a slash command to match"\n\tLearn more about [slash commands](/#slash-commands)\n""") 20 | f.write(f"""\n=== "Usage"\n\t```md\n\t{usage}\n\t```\n""") 21 | if len(com.aliases) > 0: 22 | f.write("""\n=== "Aliases"\n\t```md\n\t""" + (",".join(com.aliases) if len(com.aliases) > 0 else 'None') + """\n\t```\n""") 23 | examples = get_examples(com, "!") 24 | if len(examples) > 0: 25 | examples = "\n\t".join(examples) if len(examples) > 0 else None 26 | f.write(f"""\n=== "Examples"\n\t```md\n\t{examples}\n\t```\n""") 27 | 28 | 29 | def build(bot: "Friday", prefix: str = "!"): 30 | commands = bot.commands 31 | cogs = [] 32 | for command in commands: 33 | if command.hidden is False and command.enabled is True and command.cog_name not in cogs and command.cog is not None: 34 | cogs.append(command.cog) 35 | thispath = os.getcwd() 36 | if "\\" in thispath: 37 | seperator = "\\\\" 38 | else: 39 | seperator = "/" 40 | # # for f in glob.glob(f"{thispath}{seperator}docs{seperator}docs{seperator}commands{seperator}*"): 41 | # # os.remove(f) 42 | # cogs = sorted(cogs, key=lambda x: x.qualified_name) 43 | # # for cog in cogs: 44 | # # category = hasattr(cog, "category") and cog.category or "Commands" 45 | # # for f in glob.glob(f"{thispath}{seperator}docs{seperator}docs{seperator}{category.lower()}{seperator}*"): 46 | # # os.remove(f) 47 | # for cog in cogs: 48 | # category = hasattr(cog, "category") and cog.category or "Commands" 49 | # cog_name = cog.qualified_name 50 | # filename = "index" if cog_name.lower() == category.lower() else cog_name.lower().replace(' ','_') 51 | # path = f"docs/docs/{category.lower()}/{filename}.md" 52 | # # path = f"docs/docs/commands/{cog_name.lower().replace(' ','_')}.md" 53 | # try: 54 | # open(path, "r").close() 55 | # except FileNotFoundError: 56 | # os.makedirs(f"docs/docs/{category.lower()}", exist_ok=True) 57 | 58 | # with open(path, "w") as f: 59 | for f in glob.glob(f"{thispath}{seperator}docs{seperator}docs{seperator}commands{seperator}*"): 60 | os.remove(f) 61 | for cog in sorted(cogs, key=lambda x: x.qualified_name): 62 | cog_name = cog.qualified_name 63 | with open(f"docs/docs/commands/{cog_name.lower().replace(' ','_')}.md", "w") as f: 64 | # desc = cog.description and " ".join("\\n".join(cog.description.split("\n\n")).split("\n")) 65 | desc = cog.description and cog.description.split('\n', 1)[0] 66 | f.write(f"---\ntitle: {cog_name.capitalize()}\n{('description: '+desc) if desc and desc != '' else ''}\n---\n") 67 | f.write(f"# {cog_name.capitalize()}\n\n{cog.description}\n") 68 | for com in sorted(commands, key=lambda x: x.name): 69 | if com.hidden is False and com.enabled is True and com.cog_name == cog_name: 70 | f.write(f"\n## {com.name.capitalize()}\n") 71 | usage = '\n\t'.join(syntax(com, quotes=False).split('\n')) 72 | # usage = discord.utils.escape_markdown(usage) # .replace("<", "\\<") 73 | f.write("\n" + (com.help + "\n") if com.help is not None else '') 74 | # f.write(f"""\n\n!!! example "Usage"\n\n ```md\n {usage}\n ```\n\n""") 75 | # f.write("""\n\n!!! example "Aliases"\n\n ```md\n """ + (",".join(com.aliases) if len(com.aliases) > 0 else 'None') + """\n ```\n\n""") 76 | slash = True if hasattr(com.cog, "slash_" + com.qualified_name) or hasattr(com.cog, "_slash_" + com.qualified_name) else False 77 | # TODO: add docs requirements f.write(f"""\nRequired Permissions: {', '.join([str(i) for i in com.checks])}""") 78 | # if (com.extras and "permissions" in com.extras) or (hasattr(com.cog, "extras") and "permissions" in com.cog.extras): 79 | # perms = [*(com.cog.extras.get("permissions", []) if hasattr(com.cog, "extras") else []), *com.extras.get("permissions", [])] 80 | # f.write("""\n!!! warning "Required Permission(s)"\n\t- """ + "\n\t- ".join(perms) + "\n") 81 | f.write(f"""\n??? {'missing' if not slash else 'check'} "{'Has' if slash else 'Does not have'} a slash command to match"\n\tLearn more about [slash commands](/#slash-commands)\n""") 82 | f.write(f"""\n=== "Usage"\n\t```md\n\t{usage}\n\t```\n""") 83 | if len(com.aliases) > 0: 84 | f.write("""\n=== "Aliases"\n\t```md\n\t""" + (",".join(com.aliases) if len(com.aliases) > 0 else 'None') + """\n\t```\n""") 85 | examples = get_examples(com, "!") 86 | if len(examples) > 0: 87 | examples = "\n\t".join(examples) if len(examples) > 0 else None 88 | f.write(f"""\n=== "Examples"\n\t```md\n\t{examples}\n\t```\n""") 89 | if hasattr(com, "commands"): 90 | # This is a command group 91 | for c in sorted(com.commands, key=lambda x: x.name): # type: ignore 92 | if c.hidden is False and c.enabled is True: 93 | f.write(f"\n### {c.name.capitalize()}\n") 94 | slash = True if hasattr(c.cog, "slash_" + c.qualified_name) or hasattr(c.cog, "_slash_" + c.qualified_name) else False 95 | usage = '\n\t'.join(syntax(c, quotes=False).split('\n')) 96 | # usage = discord.utils.escape_markdown(usage) # .replace("<", "\\<") 97 | # TODO: add docs requirements f.write(f"""\nRequired Permissions: {', '.join(b)}""") 98 | # if (c.extras and "permissions" in c.extras) or (hasattr(c.cog, "extras") and "permissions" in c.cog.extras): 99 | # perms = [*(c.cog.extras.get("permissions", []) if hasattr(c.cog, "extras") else []), *c.extras.get("permissions", [])] 100 | # f.write("""\n!!! warning "Required Permission(s)"\n\t- """ + "\n\t- ".join(perms) + "\n") 101 | f.write(f"""\n??? {'missing' if not slash else 'check'} "{'Has' if slash else 'Does not have'} a slash command to match"\n\tLearn more about [slash commands](/#slash-commands)\n""") 102 | f.write("\n" + (c.help + "\n") if c.help is not None else '') 103 | f.write(f"""\n=== "Usage"\n\n\t```md\n\t{usage}\n\t```\n""") 104 | if len(c.aliases) > 0: 105 | f.write("""\n=== "Aliases"\n\n\t```md\n\t""" + (",".join(c.aliases) if len(c.aliases) > 0 else 'None') + "\n\t```\n") 106 | examples = get_examples(c, "!") 107 | if len(examples) > 0: 108 | examples = "\n\t".join(examples) if len(examples) > 0 else None 109 | f.write(f"""\n=== "Examples"\n\n\t```md\n\t{examples}\n\t```\n""") 110 | # write_command(f, com) 111 | # if hasattr(com, "commands"): 112 | # for c in sorted(com.commands, key=lambda x: x.name): 113 | # if c.hidden is False and c.enabled is True: 114 | # write_command(f, c, step=1) 115 | # if hasattr(c, "commands"): 116 | # for c in sorted(c.commands, key=lambda x: x.name): 117 | # if c.hidden is False and c.enabled is True: 118 | # write_command(f, c, step=2) 119 | # if hasattr(c, "commands"): 120 | # for c in sorted(c.commands, key=lambda x: x.name): 121 | # if c.hidden is False and c.enabled is True: 122 | # write_command(f, c, step=3) 123 | f.close() 124 | -------------------------------------------------------------------------------- /cogs/cleanup.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections import Counter 4 | from typing import TYPE_CHECKING, Optional 5 | 6 | from discord.ext import commands 7 | 8 | if TYPE_CHECKING: 9 | from functions.custom_contexts import GuildContext, MyContext 10 | from index import Friday 11 | 12 | 13 | async def get_delete_time(ctx: MyContext, guild_id: int = None) -> Optional[int]: 14 | if guild_id is None: 15 | guild_id = ctx.guild.id if ctx.guild is not None else None 16 | if ctx is None and guild_id is None: 17 | return None 18 | try: 19 | result = await ctx.db.fetchval("SELECT autoDeleteMSGs FROM servers WHERE id=%s", guild_id) 20 | if result is None or result == 0: 21 | return None 22 | return result 23 | except BaseException: 24 | return 25 | 26 | 27 | class CleanUp(commands.Cog): 28 | def __init__(self, bot: Friday): 29 | self.bot: Friday = bot 30 | # # self.exlusions = ["meme","issue","reactionrole"] 31 | 32 | async def _basic_cleanup_strategy(self, ctx: GuildContext, search): 33 | count = 0 34 | async for msg in ctx.history(limit=search, before=ctx.message): 35 | if msg.author == ctx.me and not (msg.mentions or msg.role_mentions): 36 | await msg.delete() 37 | count += 1 38 | return {'Bot': count} 39 | 40 | async def _complex_cleanup_strategy(self, ctx: GuildContext, search): 41 | prefixes = tuple(self.bot.prefixes[ctx.guild.id]) # thanks startswith 42 | 43 | def check(m): 44 | return m.author == ctx.me or m.content.startswith(prefixes) 45 | 46 | deleted = await ctx.channel.purge(limit=search, check=check, before=ctx.message) 47 | return Counter(m.author.display_name for m in deleted) 48 | 49 | async def _regular_user_cleanup_strategy(self, ctx: GuildContext, search): 50 | prefixes = tuple(self.bot.prefixes[ctx.guild.id]) 51 | 52 | def check(m): 53 | return (m.author == ctx.me or m.content.startswith(prefixes)) and not (m.mentions or m.role_mentions) 54 | 55 | deleted = await ctx.channel.purge(limit=search, check=check, before=ctx.message) 56 | return Counter(m.author.display_name for m in deleted) 57 | 58 | @commands.command("cleanup", help="Deletes the bots commands ignoring anything that is not a command", hidden=True) 59 | @commands.guild_only() 60 | @commands.has_permissions(manage_messages=True) 61 | @commands.bot_has_permissions(manage_messages=True) 62 | @commands.is_owner() 63 | async def cleanup(self, ctx: GuildContext, search: int = 100): 64 | """Cleans up the bot's messages from the channel. 65 | If a search number is specified, it searches that many messages to delete. 66 | If the bot has Manage Messages permissions then it will try to delete 67 | messages that look like they invoked the bot as well. 68 | After the cleanup is completed, the bot will send you a message with 69 | which people got their messages deleted and their count. This is useful 70 | to see which users are spammers. 71 | Members with Manage Messages can search up to 1000 messages. 72 | Members without can search up to 25 messages. 73 | """ 74 | 75 | strategy = self._basic_cleanup_strategy 76 | is_mod = ctx.channel.permissions_for(ctx.author).manage_messages 77 | if ctx.channel.permissions_for(ctx.me).manage_messages: 78 | if is_mod: 79 | strategy = self._complex_cleanup_strategy 80 | else: 81 | strategy = self._regular_user_cleanup_strategy 82 | 83 | if is_mod: 84 | search = min(max(2, search), 1000) 85 | else: 86 | search = min(max(2, search), 25) 87 | 88 | async with ctx.typing(): 89 | spammers = await strategy(ctx, search) 90 | deleted = sum(spammers.values()) 91 | messages = [f'{deleted} message{" was" if deleted == 1 else "s were"} removed.'] 92 | if deleted: 93 | messages.append('') 94 | spammers = sorted(spammers.items(), key=lambda t: t[1], reverse=True) 95 | messages.extend(f'- **{author}**: {count}' for author, count in spammers) 96 | 97 | await ctx.send('\n'.join(messages), delete_after=10) 98 | # user_messages_confirm = await ctx.prompt("Would you like messages that looks like they invoked my commands to be deleted?") 99 | # chat_messages_confirm = await ctx.prompt("Would you like messages from my chatbot system to be deleted?") 100 | 101 | # user_messages_to_remove = [] 102 | 103 | # def bot_predicate(m: discord.Message): 104 | # norm = (m.author == m.guild.me and (not chat_messages_confirm and len(m.embeds) > 0)) 105 | # chat = (chat_messages_confirm and m.reference and hasattr(m.reference.resolved, "author") and m.reference.resolved.author) 106 | # return norm or chat 107 | 108 | # def user_predicate(m: discord.Message): 109 | # if m.author == m.guild.me and m.reference and m.reference.resolved: 110 | # user_messages_to_remove.append(m.id) 111 | # user = (user_messages_confirm and m.content.startswith(ctx.prefix)) 112 | # user_chat = (chat_messages_confirm and m.guild.me in m.mentions and len(m.content) < 200) 113 | # return m.id in user_messages_to_remove or user or user_chat 114 | 115 | # async with ctx.typing(): 116 | # await ctx.channel.purge(limit=search, check=bot_predicate, oldest_first=False, bulk=False) 117 | # await ctx.channel.purge(limit=search, check=user_predicate, oldest_first=False, bulk=True) 118 | # await ctx.send("Done", delete_after=5) 119 | # @commands.command(name="clear", help="Deletes the bots commands ignoring anything that is not a command", hidden=True) 120 | # @commands.is_owner() 121 | # @commands.has_permissions(manage_channels=True) 122 | # @commands.bot_has_permissions(manage_channels=True) 123 | # async def clear(self, ctx): 124 | # def _check(m): 125 | # try: 126 | # coms = [] 127 | # if m.author == self.bot.user and m.reference.resolved is not None and m.reference.resolved.content.startswith(ctx.prefix): 128 | # coms.append(m.reference.resolved.id) 129 | # return m.author == self.bot.user or m.id in coms 130 | # return False 131 | # except AttributeError: 132 | # return False 133 | 134 | # # def _command_check(m): 135 | # # return m.id in commands 136 | # # return ( 137 | # # r.emoji in SEARCHOPTIONS.keys() 138 | # # and u == ctx.author 139 | # # and r.message.id == msg.id 140 | # # ) 141 | 142 | # deleted = await ctx.channel.purge(check=_check) 143 | # # deleted = deleted + await ctx.channel.purge(check=_command_check) 144 | # await asyncio.gather( 145 | # ctx.message.delete(), 146 | # ctx.reply(embed=embed(title=f"Deleted `{len(deleted)}` message(s)"), delete_after=10.0) 147 | # ) 148 | 149 | # @commands.Cog.listener() 150 | # async def on_command(self,ctx): 151 | # if ctx.command.name in self.exlusions: 152 | # return 153 | # delete = 10 #seconds 154 | # if delete > 0: 155 | # await ctx.message.delete(delay=delete) 156 | 157 | # @commands.Cog.listener() 158 | # async def on_command_error(self,ctx,error): 159 | # msg = None 160 | # async for message in ctx.channel.history(limit=10): 161 | # if message.author == self.bot.user and hasattr(message.reference, "resolved") and message.reference.resolved == ctx.message: 162 | # msg = message 163 | 164 | # if msg is not None: 165 | # delete = await get_delete_time(ctx) 166 | # if delete is not None and delete > 0: 167 | # try: 168 | # await asyncio.gather( 169 | # ctx.message.delete(delay=delete), 170 | # msg.delete(delay=delete) 171 | # ) 172 | # except: 173 | # pass 174 | 175 | # @commands.Cog.listener() 176 | # async def on_command_completion(self,ctx): 177 | # if ctx.command.name in self.exlusions: 178 | # return 179 | # msg = None 180 | # async for message in ctx.channel.history(limit=10): 181 | # if message.author == self.bot.user and hasattr(message.reference, "resolved") and message.reference.resolved == ctx.message: 182 | # msg = message 183 | # if msg is not None: 184 | # delete = await get_delete_time(ctx) 185 | # if delete is not None and delete > 0: 186 | # try: 187 | # await asyncio.gather( 188 | # ctx.message.delete(delay=delete), 189 | # msg.delete(delay=delete) 190 | # ) 191 | # except: 192 | # pass 193 | 194 | 195 | async def setup(bot): 196 | ... 197 | # await bot.add_cog(CleanUp(bot)) 198 | -------------------------------------------------------------------------------- /tests/test_chat.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import asyncio 4 | from typing import TYPE_CHECKING, Optional 5 | 6 | import pytest 7 | 8 | from .conftest import send_command, msg_check 9 | 10 | if TYPE_CHECKING: 11 | from discord import TextChannel, VoiceChannel 12 | 13 | from cogs.chat import Chat as ChatCog 14 | 15 | from .conftest import Friday, UnitTester 16 | 17 | pytestmark = pytest.mark.asyncio 18 | 19 | 20 | @pytest.fixture(autouse=True, scope="module") 21 | async def get_cog(friday: Friday) -> ChatCog: 22 | await friday.wait_until_ready() 23 | cog: Optional[ChatCog] = friday.get_cog("Chat") # type: ignore 24 | assert cog is not None 25 | return cog 26 | 27 | 28 | @pytest.mark.parametrize("words", ["hey bruh", '"big bruh moment"', "welcome"]) 29 | async def test_say(bot: UnitTester, channel: TextChannel, words: str): 30 | content = f"!say {words}" 31 | com = await send_command(bot, channel, content) 32 | 33 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 34 | assert msg.content == words 35 | 36 | 37 | async def test_say_no_argument(bot: UnitTester, channel: TextChannel): 38 | content = "!say" 39 | com = await send_command(bot, channel, content) 40 | 41 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 42 | assert msg.embeds[0].title == "!say" 43 | 44 | 45 | async def test_chat(bot: UnitTester, friday: Friday, channel: TextChannel): 46 | assert friday.testing is True 47 | content = f"{friday.user.mention} hey how are you?" 48 | com = await send_command(bot, channel, content) 49 | 50 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 51 | assert msg.clean_content == "This message is a test" 52 | 53 | 54 | async def test_chat_info(bot: UnitTester, channel: TextChannel): 55 | content = "!chat info" 56 | com = await send_command(bot, channel, content) 57 | 58 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 59 | assert msg.embeds[0].title == "Chat Info" 60 | assert len(msg.embeds[0].fields) == 6 61 | 62 | 63 | async def test_chat_added_to_history(bot: UnitTester, friday: Friday, channel: TextChannel, get_cog: ChatCog): 64 | assert friday.testing is True 65 | content = "!reset" 66 | com = await send_command(bot, channel, content) 67 | 68 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 69 | assert msg.embeds[0].title == "My chat history has been reset" 70 | 71 | content = f"{friday.user.mention} hey how are you?" 72 | com = await send_command(bot, channel, content) 73 | 74 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 75 | assert msg.clean_content == "This message is a test" 76 | 77 | chat_history = get_cog.chat_history[msg.channel.id] 78 | assert f"{bot.user.display_name}: @{friday.user.display_name} hey how are you?\n{friday.user.display_name}: This message is a test" in str(chat_history) 79 | 80 | 81 | async def test_chat_command(bot: UnitTester, friday: Friday, channel: TextChannel): 82 | assert friday.testing is True 83 | content = "!chat hey how are you?" 84 | com = await send_command(bot, channel, content) 85 | 86 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 87 | assert msg.clean_content == "This message is a test" 88 | 89 | 90 | async def test_chat_command_after_disabled(bot: UnitTester, friday: Friday, channel: TextChannel): 91 | assert friday.testing is True 92 | 93 | assert friday.get_cog("Config") is not None 94 | content = "!disable chat" 95 | com = await send_command(bot, channel, content) 96 | 97 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 98 | assert msg.embeds[0].title == "**chat** has been disabled." 99 | 100 | content = f"{friday.user.mention} hey how are you?" 101 | com = await send_command(bot, channel, content) 102 | 103 | with pytest.raises(asyncio.TimeoutError): 104 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 105 | assert msg.clean_content == "This message is a test" 106 | 107 | content = "!chat hey how are you?" 108 | com = await send_command(bot, channel, content) 109 | 110 | with pytest.raises(asyncio.TimeoutError): 111 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 112 | assert msg.clean_content == "This message is a test" 113 | 114 | content = "!enable chat" 115 | com = await send_command(bot, channel, content) 116 | 117 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 118 | assert msg.embeds[0].title == "**chat** has been enabled." 119 | 120 | 121 | async def test_chatchannel(bot: UnitTester, channel: TextChannel): 122 | content = "!chatchannel" 123 | com = await send_command(bot, channel, content) 124 | 125 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 126 | assert msg.embeds[0].title == "Current chat channel" 127 | assert msg.embeds[0].description 128 | 129 | 130 | @pytest.mark.dependency() 131 | async def test_chatchannel_set(bot: UnitTester, channel: TextChannel): 132 | content = f"!chatchannel {channel.id}" 133 | com = await send_command(bot, channel, content) 134 | 135 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 136 | assert msg.embeds[0].title == "Chat channel set" 137 | 138 | 139 | @pytest.mark.dependency(depends=["test_chatchannel_set"]) 140 | async def test_chatchannel_chat(bot: UnitTester, channel: TextChannel): 141 | content = "hey how are you?" 142 | com = await send_command(bot, channel, content) 143 | 144 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 145 | assert msg.clean_content == "This message is a test" 146 | 147 | 148 | async def test_chatchannel_voice_channel(bot: UnitTester, voice_channel: VoiceChannel, channel: TextChannel): 149 | content = f"!chatchannel {voice_channel.id}" 150 | com = await send_command(bot, channel, content) 151 | 152 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 153 | assert msg.embeds[0].title == f"Channel \"{voice_channel.id}\" not found." 154 | 155 | 156 | async def test_chatchannel_clear(bot: UnitTester, channel: TextChannel): 157 | content = "!chatchannel clear" 158 | com = await send_command(bot, channel, content) 159 | 160 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 161 | assert msg.embeds[0].title == "Chat channel cleared" 162 | 163 | 164 | # @pytest.mark.dependency(depends=["test_get_cog"]) 165 | # async def test_chat_messages(bot: UnitTester, channel: TextChannel): 166 | # content = "!chat hey" 167 | # com = await channel.send(content) 168 | # assert com 169 | 170 | # msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 171 | # assert msg.content 172 | 173 | async def test_chat_reset(bot: UnitTester, channel: TextChannel, get_cog: ChatCog): 174 | content = "!reset" 175 | com = await send_command(bot, channel, content) 176 | 177 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 178 | assert msg.embeds[0].title == "No history to delete" or msg.embeds[0].title == "My chat history has been reset" 179 | 180 | chat_history = get_cog.chat_history[channel.id] 181 | assert len(chat_history.history()) == 0 182 | 183 | 184 | async def test_persona(bot: UnitTester, channel: TextChannel): 185 | content = "!patreon server deactivate" 186 | com = await send_command(bot, channel, content) 187 | 188 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 189 | # assert msg.embeds[0].title == "This command requires a premium server and a patron or a mod." 190 | 191 | content = "!persona" 192 | com = await send_command(bot, channel, content) 193 | 194 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 195 | assert msg.embeds[0].title == "This command requires a premium server and a patron or a mod." 196 | -------------------------------------------------------------------------------- /tests/test_moderation.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | import discord 6 | import pytest 7 | 8 | from .conftest import send_command, msg_check 9 | 10 | if TYPE_CHECKING: 11 | from .conftest import Friday, UnitTester, UnitTesterUser 12 | 13 | 14 | pytestmark = pytest.mark.asyncio 15 | 16 | 17 | @pytest.mark.dependency() 18 | async def test_get_cog(friday: Friday): 19 | assert friday.get_cog("Moderation") is not None 20 | 21 | 22 | @pytest.fixture(scope="module") 23 | async def voice(bot: discord.Client, voice_channel: discord.VoiceChannel, channel: discord.TextChannel) -> discord.VoiceClient: # type: ignore 24 | await bot.wait_until_ready() 25 | voice = voice_channel.guild.voice_client or await voice_channel.connect() 26 | yield voice 27 | await voice.disconnect(force=True) 28 | await channel.send("!stop") 29 | 30 | 31 | @pytest.mark.dependency(depends=["test_get_cog"]) 32 | @pytest.mark.dependency(depends=["test_get_cog"]) 33 | async def test_lock(bot: UnitTester, voice_channel: discord.VoiceChannel, channel: discord.TextChannel, voice: discord.VoiceClient): 34 | content = f"!lock {voice.channel.id}" 35 | com = await send_command(bot, channel, content) 36 | 37 | f_msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 38 | assert voice.channel.user_limit != 0 39 | com = await send_command(bot, channel, content) 40 | 41 | l_msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 42 | assert voice.channel.user_limit == 0 43 | assert "Locked" in f_msg.embeds[0].title and "Unlocked" in l_msg.embeds[0].title 44 | 45 | 46 | @pytest.mark.dependency(depends=["test_get_cog"]) 47 | @pytest.mark.dependency(depends=["test_get_cog"]) 48 | async def test_massmove(bot: UnitTester, channel: discord.TextChannel, voice_channel, voice: discord.VoiceClient): 49 | _id = 245688124108177411 50 | content = f"!move {_id}" 51 | com = await send_command(bot, channel, content) 52 | 53 | _, _, new_voice = await bot.wait_for("voice_state_update", check=lambda m, b, a: b.channel.id == voice.channel.id and a.channel.id == _id and b.channel != a.channel, timeout=pytest.timeout) # type: ignore 54 | assert len(new_voice.channel.voice_states) > 0 55 | 56 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 57 | assert "Successfully moved" in msg.embeds[0].title 58 | 59 | assert await channel.send(f"!move {voice_channel.id}") 60 | 61 | 62 | @pytest.mark.dependency() 63 | @pytest.mark.dependency(depends=["test_get_cog"]) 64 | @pytest.mark.dependency(depends=["test_get_cog"]) 65 | async def test_ban(bot: UnitTester, channel: discord.TextChannel): 66 | content = "!ban 969513051789361162 testing" 67 | com = await send_command(bot, channel, content) 68 | 69 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 70 | assert msg.embeds[0].title == "Banned Member ID 969513051789361162" 71 | 72 | 73 | @pytest.mark.dependency(depends=["test_ban"]) 74 | @pytest.mark.dependency(depends=["test_get_cog"]) 75 | @pytest.mark.dependency(depends=["test_get_cog"]) 76 | async def test_unban(bot: UnitTester, channel: discord.TextChannel): 77 | content = "!unban <@969513051789361162>" 78 | com = await send_command(bot, channel, content) 79 | 80 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 81 | assert "Unbanned" in msg.embeds[0].title 82 | 83 | 84 | @pytest.mark.dependency() 85 | @pytest.mark.dependency(depends=["test_get_cog"]) 86 | @pytest.mark.dependency(depends=["test_get_cog"]) 87 | async def test_hack_ban(bot: UnitTester, channel: discord.TextChannel): 88 | content = "!ban 969513051789361162 testing" 89 | com = await send_command(bot, channel, content) 90 | 91 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 92 | assert msg.embeds[0].title == "Banned Member ID 969513051789361162" 93 | 94 | 95 | @pytest.mark.dependency(depends=["test_hack_ban"]) 96 | @pytest.mark.dependency(depends=["test_get_cog"]) 97 | @pytest.mark.dependency(depends=["test_get_cog"]) 98 | async def test_hack_unban(bot: UnitTester, channel: discord.TextChannel): 99 | content = "!unban 969513051789361162" 100 | com = await send_command(bot, channel, content) 101 | 102 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 103 | assert "Unbanned" in msg.embeds[0].title 104 | 105 | 106 | @pytest.mark.dependency(depends=["test_get_cog"]) 107 | @pytest.mark.dependency(depends=["test_get_cog"]) 108 | async def test_unban_fake(bot: UnitTester, channel: discord.TextChannel): 109 | content = "!unban 215227961048170496" 110 | com = await send_command(bot, channel, content) 111 | 112 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 113 | assert msg.embeds[0].title == "This member has not been banned." 114 | 115 | 116 | class TestMute: 117 | @pytest.mark.dependency() 118 | async def test_create_mute_role(self, bot: UnitTester, channel: discord.TextChannel, guild: discord.Guild): 119 | role = await guild.create_role(name="Test Mute Role", reason="Testing mute commands") 120 | assert role.name == "Test Mute Role" 121 | 122 | content = f"!mute role {role.id}" 123 | com = await send_command(bot, channel, content) 124 | 125 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 126 | assert msg.embeds[0].title == f"Friday will now use `{role.name}` as the new mute role" 127 | 128 | @pytest.mark.dependency(depends=["test_create_mute_role"], scope="class") 129 | async def test_mute_role_update(self, bot: UnitTester, channel: discord.TextChannel): 130 | content = "!mute role update" 131 | com = await send_command(bot, channel, content) 132 | 133 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=30.0) 134 | assert msg.embeds[0].title == "Mute role successfully updated" 135 | assert len(msg.embeds[0].description) > 10 136 | 137 | @pytest.mark.dependency() 138 | @pytest.mark.dependency(depends=["test_create_mute_role"], scope='class') 139 | async def test_mute(self, bot: UnitTester, bot_user: UnitTesterUser, channel: discord.TextChannel, guild_user: discord.Guild): 140 | content = f"!mute 1m {bot_user.user.id} test" 141 | com = await send_command(bot, channel, content) 142 | 143 | _, member = await bot_user.wait_for("member_update", check=lambda before, after: before.id == bot_user.user.id and before.roles != after.roles, timeout=pytest.timeout) # type: ignore 144 | assert "Test Mute Role" in [r.name for r in member.roles] 145 | 146 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 147 | assert f"Muted {bot_user.user.name}#{bot_user.user.discriminator} and retracted" in msg.embeds[0].title 148 | 149 | @pytest.mark.dependency(depends=["test_create_mute_role", "test_mute"], scope='class') 150 | async def test_unmute(self, bot: UnitTester, bot_user: UnitTesterUser, channel: discord.TextChannel, guild_user: discord.Guild): 151 | content = f"!unmute {bot_user.user.id} test" 152 | com = await send_command(bot, channel, content) 153 | 154 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 155 | assert "Test Mute Role" not in [r.name for r in guild_user.me.roles] 156 | assert msg.embeds[0].title == f"Unmuted {bot_user.user.name}#{bot_user.user.discriminator}" 157 | 158 | @pytest.mark.dependency(depends=["test_create_mute_role"], scope='class') 159 | async def test_mute_role_unbind(self, bot: UnitTester, bot_user: UnitTesterUser, channel: discord.TextChannel, guild: discord.Guild): 160 | content = "!mute role unbind" 161 | com = await send_command(bot, channel, content) 162 | 163 | msg = await bot.wait_for("message", check=lambda message: msg_check(message, com), timeout=pytest.timeout) # type: ignore 164 | assert msg.embeds[0].title == "Unbinding complete." 165 | 166 | # @pytest.mark.dependency(depends=["test_create_mute_role"], scope='class') 167 | # async def test_mute_role_delete(self, bot: UnitTester, bot_user: UnitTesterUser, channel: discord.TextChannel, guild: discord.Guild): 168 | # roles = await guild.fetch_roles() 169 | # mute_roles = [r for r in roles if r.name == "Test Mute Role"] 170 | # failed = 0 171 | # for r in mute_roles: 172 | # try: 173 | # await r.delete(reason="Testing mute commands") 174 | # except discord.HTTPException: 175 | # failed += 1 176 | 177 | # assert failed == 0 178 | --------------------------------------------------------------------------------