commit 0a43da51f1d85c511e53c5167b8c9079dec67367 Author: bytedream Date: Wed Apr 20 23:41:25 2022 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d1bedc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.idea/ + +__pycache__/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..663c5cf --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.9 + +ADD . /kaizen-bot + +RUN apt-get update -y && \ + apt-get install mariadb-client mariadb-server python3-pip -y && \ + ./kaizen-bot-old/install.sh + +CMD ["python", "/kaizen-bot/main.py", "run"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2733b66 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 ByteDream + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..370174e --- /dev/null +++ b/README.md @@ -0,0 +1,11 @@ +# Kaizen bot (old) + +Code for the discord bot of the official discord server from the german anime youtuber [kaizen](https://www.youtube.com/c/KaizenAnime). +It is outdated and ran until half a year ago, seen at the time of writing (20.04.2022). + +The only changes I made to the code is removing hardcoded tokens (very secure I know) and add some comments just because I've felt the urge to comment the mess. +Additional comments are prefixed with `ADDED AFTERWARDS: `. + +I don't know if the code is able to run anymore, may some dependencies broke, have changed or something else ¯\_(ツ)_/¯. + +Feel free to use the code for whatever you want, but as a warning: it's ugly and nested af. diff --git a/assets/kaizen-round.png b/assets/kaizen-round.png new file mode 100644 index 0000000..f300bbc Binary files /dev/null and b/assets/kaizen-round.png differ diff --git a/assets/kaizen.jpg b/assets/kaizen.jpg new file mode 100644 index 0000000..991d722 Binary files /dev/null and b/assets/kaizen.jpg differ diff --git a/assets/man_of_culture.jpg b/assets/man_of_culture.jpg new file mode 100644 index 0000000..ec1e03f Binary files /dev/null and b/assets/man_of_culture.jpg differ diff --git a/assets/rules.png b/assets/rules.png new file mode 100644 index 0000000..d4f5e10 Binary files /dev/null and b/assets/rules.png differ diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..26295c5 --- /dev/null +++ b/install.sh @@ -0,0 +1,5 @@ +apt install git libmariadb3 libmariadb-dev +pip3 install aiohttp discord.py dreamutils mariadb tweepy twitchAPI git+git://github.com/Rapptz/discord-ext-menus + +touch /var/log/kaizen.log +chmod ugo+rwx /var/log/kaizen.log diff --git a/kaizenbot/__init__.py b/kaizenbot/__init__.py new file mode 100644 index 0000000..95fcc37 --- /dev/null +++ b/kaizenbot/__init__.py @@ -0,0 +1,26 @@ +import logging + + +logger = logging.getLogger('kaizen') +# sets the logger output format +formatter = logging.Formatter('[%(asctime)s] - %(levelname)s: %(message)s', '%Y-%m-%d %H:%M:%S') +# creates the logger handlers + +# configuring the logger +logger.setLevel(logging.DEBUG) + +# console output +console_handler = logging.StreamHandler() +console_handler.setFormatter(formatter) +logger.addHandler(console_handler) + +# file output +file_handler = logging.FileHandler('/var/log/kaizen.log') +file_handler.setFormatter(formatter) +logger.addHandler(file_handler) + + +# --- # + +# ADDED AFTERWARDS: yes a global variable, why use classes if this works too +glob = {'bot': None, 'transcript': {}, 'timers': [], 'twitter': None} diff --git a/kaizenbot/commands.py b/kaizenbot/commands.py new file mode 100644 index 0000000..eeecf48 --- /dev/null +++ b/kaizenbot/commands.py @@ -0,0 +1,679 @@ +import asyncio +from copy import copy +import datetime +import discord +from discord.ext import commands, menus +from pathlib import Path +import os +import random +import re +import requests +import signal +import tempfile +import time +from typing import List, Optional + +from . import logger, glob +from . import flags +from .utils import Embeds, MenuListPageSource, role_names + + +class Chat(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + self.flag_parser = flags.Flag(raise_errors=False) + self.flag_parser.add_flag('--all', + store_type=flags.StoreType.store_bool, + allowed_roles=['Mod'], + not_allowed_role_message='Nur Mods können die `{flag}` flag benutzen', + wrong_store_type_message='Die `{flag}` flag darf keinen parameter haben', + show_help=False) + self.flag_parser.add_flag('--limit', + store_type=(flags.StoreType.store_value, 100), + parser=self._parse_history, + wrong_store_type_message='Die {flag} muss eine Zahl als parameter haben', + help='') + + async def _parse_history(self, ctx: commands.Context, flag, value): + try: + return int(value) + except ValueError: + await ctx.send(f'Der parameter der `{flag}` muss eine Zahl sein') + return False + + @commands.command(name='export', ignore_extra=False, + usage='export [flags]', help='Exportiert den Chat', + flags='flag_parser') + async def history(self, ctx: commands.Context, *, args): + parsed = await self.flag_parser.parse(args, ctx=ctx) + pass + + +# ADDED AFTERWARS: This never was really active in use +class Economy(commands.Cog): + + def __init__(self, bot): + self.bot = bot + self.cooldown = {} + self.cooldown_date = datetime.datetime.now() + + self.shop_items = { + 'color': [10000, self.bot.database.set_user_color_count, 'Nachrichten Farbe', True, 'Setzt die Farbe die rechts bei den Bot Nachrichten, die von <@!802659482693926943> kommen'], + 'extra vote': [150, self.bot.database.set_user_extra_vote, 'Extra Vote', False, 'Kann eine Umfrage starten, selbst wenn man normalerweise noch Umfrage cooldown hätte'] + } + + @commands.command(name='buy', aliases=['b'], usage='buy ', help='Kauft ein Shop Item. Tippe `$shop` um alle verfügbaren Items zu sehen') + async def buy(self, ctx: commands.Context, *item): + await ctx.send(embed=Embeds.error_embed(description='Der Befehl steht wegen bearbeitung temporär nicht zur Verfügung')) + return + to_buy = ' '.join(item) + if values := self.shop_items.get(to_buy, None): + if self.bot.database.has_remove_user_gold(ctx.author.id, values[0]): + embed = discord.Embed(title='Item kauf') + embed.add_field(name=values[2], value=f'*{values[4]}*\n\n' + f'<@!{ctx.author.id}> erhält:\n' + f'```diff\n' + f'+1 {values[2]}' + f'```\n' + f'<@!{ctx.author.id}> bezahlt:\n' + f'```diff\n' + f'- {values[0]} Gold\n' + f'```') + message = await ctx.send(embed=embed) + await message.add_reaction('❌') + await message.add_reaction('✅') + + def check(reaction: discord.Reaction, user): + return user.id == ctx.author.id and reaction.message.id == message.id and str(reaction.emoji) in ['❌', '✅'] + + try: + reaction, user = await self.bot.wait_for('reaction_add', timeout=30, check=check) + if reaction.emoji == '❌': + embed.colour = 0xff0000 + msg = 'Der Kauf wurde abgebrochen' + elif reaction.emoji == '✅': + values[1](ctx.author.id, 1) + embed.colour = 0x00ff00 + msg = f'**{values[2]}** wurde gekauft{f". Tippe `$use {to_buy}` um das Item zu benutzen!" if values[3] else "!"}' + else: + msg = 'Hackerman hat den Bot überlistet, weshalb diese Nachricht erscheint, die eigentlich niemals hätte erscheinen dürfen' + + embed.clear_fields() + embed.add_field(name=values[2], value=f'*{values[4]}*\n\n' + f'<@!{ctx.author.id}> erhält:\n' + f'```diff\n' + f'+1 {values[2]}' + f'```\n' + f'<@!{ctx.author.id}> bezahlt:\n' + f'```diff\n' + f'- {values[0]} Gold\n' + f'```\n' + f'{msg}') + await message.edit(embed=embed) + except asyncio.exceptions.TimeoutError: + pass + else: + await ctx.send(embed=Embeds.error_embed(description='Du hast nicht genügend Gold um dieses Item zu kaufen')) + else: + await ctx.send(embed=Embeds.error_embed(description=f'Es existiert kein Item mit dem Name `{to_buy}`')) + + @commands.command(name='items', aliases=['i'], usage='items', help='Zeigt alle Items im besitzt an') + async def items(self, ctx: commands.Context, *, user_mention: str = None): + await ctx.send(embed=Embeds.error_embed(description='Der Befehl steht wegen bearbeitung temporär nicht zur Verfügung')) + return + id = ctx.author.id + if user_mention is not None: + regex_id = re.match(r'^<@(!)?(?P\d{18})>', user_mention) + if regex_id: + id = regex_id.group('id') + else: + await Help(self.bot).show_help(ctx, ctx.command) + return + + embeds = [] + embed = discord.Embed(title='Items', description=f'Items von <@!{id}>') + embed.set_footer(text='Alle Items die mit 💫 beginnen können über `$use ` benutzt werden!') + for name, value in self.bot.database.get_user_items(id).items(): + if len(embed.fields) >= 10: + embeds.append(embed) + embed = discord.Embed(title='Items', description=f'Items von <@!{id}>') + embed.set_footer(text='Alle Items die mit 💫 beginnen können über `$use ` benutzt werden!') + if name == 'gold': + embed.description += f'\n\n:moneybag: **{value}** *Gold*' + elif value > 0: + item = self.shop_items[name] + embed.description += f'\n{":dizzy:" if item[3] else ":sparkles:"} **{value}** · `{name}` · *{item[2]}*' + embeds.append(embed) + + send_embeds = menus.MenuPages(source=MenuListPageSource(embeds), clear_reactions_after=True, timeout=30) + await send_embeds.start(ctx) + + @commands.command(name='leaderboard', aliases=['l'], usage='leaderboard', help='Zeigt das Server Leaderboard an') + async def leaderboard(self, ctx: commands.Context): + await ctx.send(embed=Embeds.error_embed(description='Der Befehl steht wegen bearbeitung temporär nicht zur Verfügung')) + return + gold_leaderboard = discord.Embed(title='Gold Leaderboard', description='*Man kann alle 2 Minuten zwischen 5 und 15 Gold bekommen, wenn man in diesen Zeitabständen eine Nachricht schreibt. ' + 'Die gedroppte Gold Anzahl wird mit deinem Kaizen-Sub Level addiert (Server Booster gelten als Sub Level 2).\n' + 'Also je aktiver, desto mehr Gold:moneybag:^^*') + gold_leaderboard.set_footer(text='Tippe `$shop` um dir anzusehen, was du alles kaufen kannst!') + gold_text = '' + + for i, (id, gold) in enumerate(self.bot.database.get_leaderboard().items(), 1): + gold_text += f'\n{i}. - `{gold}` Gold · __**{(await self.bot.guild.fetch_member(id)).display_name}**__' + + gold_leaderboard.add_field(name='Top 10', value=gold_text) + + menu = menus.MenuPages(source=MenuListPageSource([gold_leaderboard])) + await menu.start(ctx) + + @commands.command(name='shop', aliases=['s'], usage='shop', help='Zeigt alle Shop Elemente an') + async def shop(self, ctx: commands.Context): + await ctx.send(embed=Embeds.error_embed(description='Der Befehl steht wegen bearbeitung temporär nicht zur Verfügung')) + return + embeds = [] + embed = discord.Embed(title='Show durchsuchen') + for name, item in self.shop_items.items(): + if len(embed.fields) >= 10: + embeds.append(embed) + embed = discord.Embed(title='Show durchsuchen') + embed.add_field(name=item[2], value=f'*{item[4]}*\n' + f'```diff\n' + f'- {item[0]} Gold\n' + f'+ 1 {item[2]}\n' + f'> $buy {name}\n' + f'```', inline=False) + embeds.append(embed) + + embeds = menus.MenuPages(source=MenuListPageSource(embeds), clear_reactions_after=True, timeout=30) + await embeds.start(ctx) + + @commands.command(name='use', aliases=['u'], usage='use', help='Benutzt ein Item') + async def use(self, ctx: commands.Context, *args): + await ctx.send(embed=Embeds.error_embed(description='Der Befehl steht wegen bearbeitung temporär nicht zur Verfügung')) + return + pass + + # --- # + + @commands.Cog.listener() + @commands.guild_only() + async def on_message(self, message: discord.Message): + return + if not message.author.bot and message.channel.id not in [812436978809307156, # rank abfrage + 822938804461502524, # offene daten + 813135345725341736, # karuta + 813886662841073684, # rin + 813487848027193398]: # quiz erraten + now = time.time() + cooldown = self.cooldown.get(message.author.id, [0.0, 0]) + if cooldown[1] < 120 and cooldown[0] + 120 < now: + cooldown[0] = now + cooldown[1] += 1 + self.cooldown[message.author.id] = cooldown + + gold = random.randint(5, 15) + roles = role_names(message.author) + + if 'Kaizen-Sub: Level 3' in roles: + gold += 3 + elif 'Kaizen-Sub: Level 2' in roles: + gold += 2 + elif 'Kaizen-Sub: Level 1' in roles: + gold += 1 + if 'Server Booster' in roles: + gold += 2 + + self.bot.database.add_user_gold(message.author.id, gold) + + def reset(self): + for key in self.cooldown: + self.cooldown[key] = [0.0, 0] + + +# ADDED AFTERWARDS: I had the idea of implementing familiarly relationship and show it in a tree +# but this was never finished (not that I really started to try working on it) +class Family(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + @commands.command(name='tree', usage='tree', help='Zeigt den Familienstammbaum') + @commands.guild_only() + async def family_tree(self, ctx: commands.Context): + self.bot.database.get_user_parents(ctx.author.id) + + def _family_tree(self, id: int) -> List[List[str]]: + parents = self.bot.database.get_user_parents() + if parents: + + return [].extend() + else: + return [self.bot.guild.fetch_member(id).display_name] + + +class Mod(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + @commands.command(name='error', usage='error') + @commands.guild_only() + @commands.has_role('Mod') + async def error(self, ctx: commands.Context): + logger.info('NOTICE: Raising controlled exception...') + raise Exception('Exception raised by the error command') + + @commands.command(name='restart', usage='restart') + @commands.guild_only() + @commands.has_role('Mod') + async def restart(self, ctx: commands.Context): + for thread in glob['timers']: + thread.cancel() + logger.info('Restarting...') + os.kill(os.getpid(), signal.SIGKILL) + + @commands.command(name='transcript', usage='transcript', help='Schickt eine Datei mit dem gesamten Nachrichtenverlauf der letzten Stunde') + @commands.guild_only() + @commands.has_role('Mod') + async def transcript(self, ctx: commands.Context): + tmp = tempfile.mktemp('.txt') + with open(tmp, 'w+') as file: + for message in glob['transcript'].values(): + file.write(message + '\n') + file.close() + await ctx.send(file=discord.File(tmp, 'transcript.txt')) + os.remove(tmp) + + +class Help(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + async def show_help(self, ctx: commands.Context, command: commands.Command): + embed = discord.Embed(title=f'`{command.name}` command\n\n', color=discord.Color(0xff0000)) + embed.set_footer(text='<...> - required; [...] - optional; <...|...> - or\nDo NOT include <>, [] or | when executing the command') + embed.add_field(name='Usage', value=f'`{self.bot.command_prefix}{command.usage}`', inline=False) + embed.add_field(name='Description', value=command.help, inline=False) + if command_flags := flags.get_flags(command): + all_flags = {} + for flag, info in command_flags.flags().items(): + if info['show_help']: + hash_sum = hash(str(info)) + if hash_sum not in all_flags: + all_flags[hash_sum] = copy(info) + all_flags[hash_sum]['names'] = [flag] + else: + all_flags[hash_sum]['names'].append(flag) + embed.add_field(name='Flags', value='\n'.join([f'• `{"`, `".join(flag["names"])}` - {flag["help"].format(flag=flag["names"][0])}' for flag in all_flags.values()]), inline=False) + await ctx.send(embed=embed) + + @commands.command(name='commands', usage='commands', description='Liste alle verfügbaren Befehle auf') + async def cmds(self, ctx: commands.Context): + commands = {command.name: command for command in self.bot.commands} + groups = {'default': []} + for command in sorted(commands): + if hasattr(command, 'group'): + if command.group not in groups: + groups[command.group] = [] + groups[command.group].append(commands[command]) + new_line = '\n' + embed = discord.Embed(title='Commands', description=f'Um Hilfe zu einem bestimmten Befehl zu bekommen, tippe `{self.bot.command_prefix}help [command]`', + color=discord.Color(0xff0000)) + + for group_name, group in groups.items(): + embed.add_field(name=group_name, value=f'```• {f"{new_line}• ".join([self.bot.command_prefix + command.usage for command in group])}```', inline=False) + await ctx.send(embed=embed) + + @commands.command(name='help', usage='help [command]', description='Zeigt Hilfe an') + async def help(self, ctx: commands.Context, command: Optional[str]): + if command: + if cmd := self.bot.get_command(command): + await self.show_help(ctx, cmd) + else: + embed = discord.Embed(title=f'{self.bot.user.name} help', description=f'Der Befehl {command} existiert nicht!', color=discord.Color(0xff0000)) + embed.add_field(name='Get help', value=f'Um Hilfe zu einem bestimmten Befehl zu bekommen, tippe `{self.bot.command_prefix}help [command]`', inline=False) + embed.add_field(name='Commands', value=f'Tippe `{self.bot.command_prefix}commands`, um eine Liste mit allen Befehlen zu bekommen', inline=False) + embed.set_footer(text='<...> - required; [...] - optional; <...|...> - or\nDo NOT include <>, [] or | when executing the command') + await ctx.send(embed=embed) + else: + embed = discord.Embed(title=f'{self.bot.user.name} help', color=discord.Color(0xff0000)) + embed.add_field(name='Get help', value=f'Um Hilfe zu einem bestimmten Befehl zu bekommen, tippe `{self.bot.command_prefix}help [command]`', inline=False) + embed.add_field(name='Commands', value=f'Tippe `{self.bot.command_prefix}commands`, um eine Liste mit allen Befehlen zu bekommen', inline=False) + embed.set_footer(text='<...> - required; [...] - optional; <...|...> - or\nDo NOT include <>, [] or | when executing the command') + await ctx.send(embed=embed) + + +class Info(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + self.flag_parser = flags.Flag() + + @commands.command(name='info', usage='info [@user]', help='Zeigt Infos über einen Nutzer an') + @commands.guild_only() + async def info(self, ctx: commands.Context, *, user_mention: str = None): + id = ctx.author.id + if user_mention is not None: + regex_id = re.match(r'^<@(!)?(?P\d{18})>', user_mention) + if regex_id: + id = regex_id.group('id') + else: + await Help(self.bot).show_help(ctx, ctx.command) + return + if (infos := self.bot.database.get_user_infos(id)) is None: + await ctx.send(embed=Embeds.error_embed(description="Der Nutzer existiert nicht")) + return + else: + member = await self.bot.guild.fetch_member(int(id)) + name = member.display_name if member.display_name.lower().endswith(('s', 'z')) else member.display_name + "'s" + embed = discord.Embed(title=name + " Infos", color=member.roles[-1].color) + embed.set_thumbnail(url=member.avatar_url) + embed.set_footer(text='Tippe `$help addinfo` um zu sehen, was für Infos noch hinzugefügt / geändert werden können') + for key, value in infos.items(): + if value is not None: + embed.add_field(name=key, value=value, inline=False) + if len(embed.fields) == 0: + embed.description = "Es wurden noch keine Infos eingetragen" + await ctx.send(embed=embed) + + @commands.command(name='addinfo', aliases=['infoadd'], usage='addinfo ', + help='Fügt den Namen / das Alter / eine Anime Liste / einen Lieblings Anime / eine Waifu / ein Husbando zu einem Nutzer hinzu.\n\n' + 'Tippe `$help removeinfo` um Infos wieder zu entfernen') + @commands.guild_only() + async def infoadd(self, ctx: commands.Context, info_type: str, *info_value): + if info_type.lower() == 'name': + self.bot.database.set_user_name(ctx.author.id, ' '.join(info_value)) + await ctx.send(embed=Embeds.success_embed(description=f'Name (__{" ".join(info_value)}__) wurde hinzugefügt')) + elif info_type.lower() == 'age': + try: + age = int(info_value[0]) + except ValueError: + await ctx.send(embed=Embeds.error_embed(description='`age` sollte eine Zahl sein')) + return + if age < 0: + await ctx.send(embed=Embeds.error_embed(description='Hmmm noch nie von einer Person gehört die minus Jahre alt ist')) + return + elif age > 99: + await ctx.send(embed=Embeds.error_embed(description='Uff, bei so einem hohen Alter komm selbst ich in Bedrängnis und kann es nicht zu deinen Infos hinzufügen :/')) + return + + self.bot.database.set_user_age(ctx.author.id, age) + + # ADDED AFTERWARDS: hehe + if age == 69: + embed = Embeds.success_embed(title='Alter wurde hinzugefügt') + embed.description = 'Ah, I see you\'re a man of culture as well' + embed.set_thumbnail(url='attachment://man_of_culture.jpg') + await ctx.send(embed=embed, file=discord.File(Path.cwd().joinpath('assets', 'man_of_culture.jpg'))) + else: + await ctx.send(embed=Embeds.success_embed(description=f'Alter __{age}__ wurde hinzugefügt')) + elif info_type.lower() == 'list': + try: + requests.get(info_value[0]) + except Exception: + await ctx.send(embed=Embeds.error_embed(description='Ich konnte mich nicht mit der gegeben URL verbinden')) + return + self.bot.database.set_user_list(ctx.author.id, info_value[0]) + await ctx.send(embed=Embeds.success_embed(description='Anime Liste wurde hinzugefügt')) + elif info_type.lower() == 'fav': + self.bot.database.set_user_fav(ctx.author.id, ' '.join(info_value)) + await ctx.send(embed=Embeds.success_embed(description=f'Lieblings Anime (__{" ".join(info_value)}__) wurde hinzugefügt')) + elif info_type.lower() == 'waifu': + self.bot.database.set_user_waifu(ctx.author.id, ' '.join(info_value)) + await ctx.send(embed=Embeds.success_embed(description=f'Waifu (__{" ".join(info_value)}__) wurde hinzugefügt')) + elif info_type.lower() == 'husbando': + self.bot.database.set_user_husbando(ctx.author.id, ' '.join(info_value)) + await ctx.send(embed=Embeds.success_embed(description=f'Husbando (__{" ".join(info_value)}__) wurde hinzugefügt')) + else: + await Help(self.bot).show_help(ctx, ctx.command) + + @commands.command(name='removeinfo', aliases=['inforemove'], usage='removeinfo ', + help='Entfernt Name / Alter / Anime Liste / lieblings Anime / Waifu / Husbando von einem Nutzer.\n\n' + 'Tippe `$help addinfo` um Infos hinzuzufügen') + @commands.guild_only() + async def inforemove(self, ctx: commands.Context, info_type: str): + if info_type.lower() == 'name': + self.bot.database.set_user_name(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Name wurde entfernt')) + elif info_type.lower() == 'age': + self.bot.database.set_user_age(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Alter wurde entfernt')) + elif info_type.lower() == "list": + self.bot.database.set_user_list(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Anime Liste wurde entfernt')) + elif info_type.lower() == "fav": + self.bot.database.set_user_fav(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Lieblings Anime wurde entfernt')) + elif info_type.lower() == "waifu": + self.bot.database.set_user_waifu(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Waifu wurde entfernt')) + elif info_type.lower() == "husbando": + self.bot.database.set_user_husbando(ctx.author.id, None) + await ctx.send(embed=Embeds.success_embed(description='Husbando wurde entfernt')) + else: + await Help(self.bot).show_help(ctx, ctx.command) + + +class Kaizen(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + @commands.command(name='kaizen', usage='kaizen', help='Alle Links zu den Social Media Kanälen von Kaizen') + async def links(self, ctx: commands.Context): + await Embeds.send_kaizen_infos(ctx) + + +class Vote(commands.Cog): + + def __init__(self, bot): + self.bot = bot + self.total_votes = [] + self.next_normal_time = datetime.datetime.now() + self.sub_or_booster_time = {} + self.number_emojis = ['1️⃣', '2️⃣', '3️⃣', '4️⃣', '5️⃣', '6️⃣', '7️⃣', '8️⃣', '9️⃣', '🔟'] + + self.flag_parser = flags.Flag(raise_errors=False, + double_flag_error='Die flag `{flag}` wurde schon gegeben', + flag_position_error='Flags müssen als letztes aufgerufen werden', + quotation_error='Es fehlen ein oder mehrere Anführungszeichen (`"` oder `\'`)') + self.flag_parser.add_flag('--duration', + store_type=(flags.StoreType.store_value, 60*5), + parser=self._duration_parser, + allowed_roles=['Mod', 'Server Booster', 'Kaizen-Sub'], + not_allowed_role_message='Nur Nutzer, die Server Booster oder Twich-Sub von Kaizen sind, können die `{flag}` flag benutzen!', + wrong_store_type_message='Die `{flag}` flag muss eine Zeitangabe haben (z.B. `{flag}=12m21s`)', + help='Zeit, bis das Ergebnis bekanntgegeben werden soll. Ist nur für Server Booster und Kaizen Twitch Subs verfügbar! (z.B. {flag}=12m21s)') + self.flag_parser.add_flag('--image', + store_type=flags.StoreType.store_value, + wrong_store_type_message='Die `{flag}` flag muss einen URL zu einem Bild haben (z.B. `{flag}="https://bytedream.org/darling.jpg"`)', + help='Fügt ein extra Bild zur Umfrage hinzu. Bild muss als URL angegeben sein (z.B. {flag}=https://bytedream.org/darling.jpg)') + + super().__init__() + + async def _duration_parser(self, ctx: commands.Context, flag, value): + regex_time = re.match(r'^((?P\d*?)h)?((?P\d*?)m)?((?P\d*?)s)?', str(value)) + time = 0 + if regex_time['hour']: + time += int(regex_time['hour']) * 60 * 60 + if regex_time['min']: + time += int(regex_time['min']) * 60 + if regex_time['sec']: + time += int(regex_time['sec']) + if time == 0: + await ctx.send(embed=Embeds.warn_embed(description=f'Die Zeit der `{flag}` flag sollte in Form von __12m21s__ oder __21m__ sein')) + return False + elif time > 60 * 60: + roles = role_names(ctx.author) + if 'Mod' not in roles and 'A&M Redakteur' not in roles: + await ctx.send(embed=Embeds.warn_embed(description=f'Die Gesamtzeit der `{flag}` flag kann höchstens 60 Minuten betragen!')) + return False + return time + + @commands.command(name='vote', ignore_extra=False, + usage='vote "" <2 bis 10 "Antwortmöglichkeiten"> [flags]', help='Startet eine Umfrage mit maximal 10 Antwortmöglichkeiten', + flags='flag_parser') + @commands.guild_only() + async def vote(self, ctx: commands.Context, *, args): + parsed = await self.flag_parser.parse(args, ctx=ctx) + if not parsed: + return + + args = list(parsed.normal_args) + + if len(args) == 0: + async with ctx.typing(): + await asyncio.sleep(.7) + await ctx.send(embed=Embeds.error_embed(description=f'Syntax für den \'{self.bot.command_prefix}vote\':\n' + f'`{self.bot.command_prefix}vote [titel] [antwortmöglichkeiten] (dauer)`\n\n' + f'Argumente:\n' + f'`titel`: Titel der Umfrage ("Wie findet ihr Anime xy?")\n' + f'`antwortmöglichkeiten`: Bis zu zehn mögliche Antwortmöglichkeiten ("super" "ok" "schlecht")\n' + f'`dauer`: Zeit, bis das Ergebnis bekanntgegeben werden soll.\n\n' + f'Beispiel:\n' + f'`{self.bot.command_prefix}vote "Wie findet Ihr Anime xy?" "sehr gut" super ok schlecht "sehr schlecht" time=12m`\n\n' + f'Um Leerzeichen im Titel oder in den Antwortmöglichkeiten zu benutzen, die entsprechenden Dinge einfach mit Anführungszeichen (`"`) vorne und hinten versehen')) + return + elif len(args) <= 2: + await ctx.send(embed=Embeds.error_embed('Es müssen mindestens zwei Antwortmöglichkeit angegeben sein!')) + return + elif len(args) > 10 + 1: # the +1 is the title + await ctx.send(embed=Embeds.error_embed('Es können nur maximal 10 Antwortmöglichkeiten angegeben werden!')) + return + + roles = role_names(ctx.author) + privileges = 'Mod' in roles or 'A&M Redakteur' in roles + booster = 'Server Booster' in roles + sub = 'Kaizen-Sub' in roles + + now = datetime.datetime.now() + + if len(self.total_votes) >= 3 and not privileges: + difference = divmod(min(self.total_votes) - now.timestamp(), 60) + await ctx.send(embed=Embeds.error_embed(description=f'Es können maximal 3 Umfragen gleichzeitig laufen. In {round(difference[0])} Minuten und {round(difference[1])} Sekunden kann wieder eine neue Umfrage gestartet werden.')) + return + + # check for cooldown + if privileges: + pass + elif booster or sub: + if self.sub_or_booster_time.get(ctx.author.id, now - datetime.timedelta(hours=1)) < now: + self.sub_or_booster_time[ctx.author.id] = now + datetime.timedelta(minutes=15) + elif self.next_normal_time < now: + self.next_normal_time = now + datetime.timedelta(minutes=40) + elif self.bot.database.set_user_extra_vote(ctx.author.id, -1): + pass + else: + if (t := self.sub_or_booster_time[ctx.author.id]) < self.next_normal_time: + difference = divmod((t - now).total_seconds(), 60) + else: + difference = divmod((self.next_normal_time - now).total_seconds(), 60) + await ctx.send(embed=Embeds.error_embed(description=f'Du musst leider noch {round(difference[0])} Minuten und {round(difference[1])} Sekunden warten, bis du den Befehl erneut benutzen kannst.')) + return + elif self.next_normal_time < now: + self.next_normal_time = now + datetime.timedelta(minutes=40) + else: + difference = divmod((self.next_normal_time - now).total_seconds(), 60) + await ctx.send(embed=Embeds.error_embed(description=f'Du musst leider noch {round(difference[0])} Minuten und {round(difference[1])} Sekunden warten, du den Befehl erneut benutzen kannst.\n' + f'Werde Server Booster oder Twich-Sub, um die Wartezeit zu verringern!')) + return + + # send the message embed + args[0] = args[0] if args[0].endswith('?') else args[0] + '?' + embed = discord.Embed(title=f'**{args[0]}**', + description='\n'.join([f'{self.number_emojis[i]}: {answer}' for i, answer in enumerate(args[1:])]), + color=ctx.author.roles[-1].color) + if parsed.image: + embed.set_thumbnail(url=parsed.image) + end_time = datetime.datetime.now() + datetime.timedelta(seconds=parsed.duration) + embed.set_footer(text=f'Umfrage endet am {end_time.strftime("%d.%m.%Y")} um ca. {end_time.strftime("%H:%M")} Uhr') + async with ctx.typing(): + await asyncio.sleep(.5) + message = await ctx.send(embed=embed) + for i in range(len(args[1:])): + await message.add_reaction(self.number_emojis[i]) + + if parsed.pin: + await message.pin() + await ctx.message.delete() + + last_mention_id = message.id + + self.total_votes.append(end_time.timestamp()) + walked = 0 + for _ in range(0, (parsed.duration - 5) // (60 * 10)): + await asyncio.sleep(60 * 10) + is_fresh = False + async for old_message in ctx.channel.history(limit=10): + if old_message.id == last_mention_id: + is_fresh = True + break + if not is_fresh: + last_mention_id = (await message.reply(embed=discord.Embed(description=f'Es läuft aktuell eine Umfrage, stimmt doch zur Frage `{args[0]}` ab!', + color=embed.color))).id + walked += 60 * 10 + await asyncio.sleep(parsed.duration - walked) + + self.total_votes.remove(end_time.timestamp()) + + if parsed.pin: + await message.unpin() + + reactions = [] + users = [] + + for reaction in (await ctx.channel.fetch_message(message.id)).reactions: + reactions.append(reaction) + async for user in reaction.users(): + if not user.bot and user.id not in users: + users.append(user.id) + embed = discord.Embed(title=f'**{args[0]}**', + description='\n'.join([f'{self.number_emojis[i]}: {answer} - {reactions[i].count - 1} {"Stimme" if reactions[i].count - 1 == 1 else "Stimmen"}' for i, answer in enumerate(args[1:])]), + color=embed.color) + if parsed.image: + embed.set_thumbnail(url=parsed.image) + now = datetime.datetime.now() + embed.set_footer(text=f'Umfrage endete am {now.strftime("%d.%m.%Y")} um {now.strftime("%H:%M:%S")} Uhr') + await message.clear_reactions() + await message.edit(embed=embed) + + reaction_dict = {arg: count for arg, count in sorted({arg: reactions[i].count for i, arg in enumerate(args[1:])}.items(), key=lambda item: item[1], reverse=True)} + + result_embed = discord.Embed(title=f'Umfrageergebnisse zu `{args[0]}`\n\n', + description='\n'.join([f'{i + 1}. {arg} - {count - 1} {"Stimme" if count - 1 == 1 else "Stimmen"}' for i, (arg, count) in enumerate(reaction_dict.items())]), + color=embed.color) + + result_embed.description += f'\n\nInsgesamt mitgemacht: {len(users)}' + + await ctx.send(embed=result_embed) + + +# ADDED AFTERWARDS: hehe pt. 2 +class NSFW(commands.Cog): + + def __init__(self, bot): + self.id = 796873618026004481 + self.bot = bot + + @commands.command(name='color', usage='color', help='Shows a random colored hentai image') + @commands.guild_only() + @commands.cooldown(1, 60 * 5, type=commands.BucketType.channel) + async def color(self, ctx: commands.Context): + if ctx.channel.id != self.id: + await ctx.send(embed=Embeds.error_embed(description='Dieser Befehl kann nur in <#796873618026004481> genutzt werden')) + else: + await ctx.send(file=discord.File(os.path.join('/srv/media/hentai/image', random.choice(os.listdir('/srv/media/hentai/image'))))) + + @commands.command(name='lewd', usage='lewd', help='Shows a random lewd') + @commands.guild_only() + @commands.cooldown(1, 60 * 5, type=commands.BucketType.channel) + async def lewd(self, ctx: commands.Context): + if ctx.channel.id != self.id: + await ctx.send(embed=Embeds.error_embed(description='Dieser Befehl kann nur in <#796873618026004481> genutzt werden')) + else: + page = random.randint(0, glob['twitter'].get_user('lewdxsthetic').statuses_count // 20) + await ctx.send(random.choice(glob['twitter'].user_timeline('lewdxsthetic', page=page, count=20))._json['entities']['media'][0]['media_url_https']) diff --git a/kaizenbot/database.py b/kaizenbot/database.py new file mode 100644 index 0000000..c98f89c --- /dev/null +++ b/kaizenbot/database.py @@ -0,0 +1,203 @@ +import mariadb +import datetime +from typing import Any, Dict, List, Union +import discord +from .user import User + + +class Database: + + def __init__(self, user: str, password: str): + self.database: mariadb._mariadb.connection = mariadb.connect(user=user, password=password, host='127.0.0.1', port=3306, database='kaizen') + self.database.autocommit = True + self.cursor = self.database.cursor() + + def sync(self): + self.cursor.execute('INSERT INTO info (id) (SELECT id FROM rules_accepted WHERE id NOT IN (SELECT id FROM info))') + self.cursor.execute('INSERT INTO economy (id) (SELECT id FROM rules_accepted WHERE id NOT IN (SELECT id FROM economy))') + + # --- # + + def get_message_id(self) -> Union[int, None]: + try: + self.cursor.execute('SELECT v FROM stuff WHERE k=?', ('message_id',)) + return int(self.cursor.fetchone()[0]) + except (TypeError, ValueError): + return None + + def set_message_id(self, id: int): + if id is None: + self.cursor.execute('DELETE FROM stuff WHERE k=?', ('message_id',)) + else: + self.cursor.execute('INSERT INTO stuff (k, v) VALUES (?, ?)', ('message_id', str(id))) + + def get_image_id(self) -> Union[int, None]: + try: + self.cursor.execute('SELECT v FROM stuff WHERE k=?', ('image_id',)) + return int(self.cursor.fetchone()[0]) + except (TypeError, ValueError): + return None + + def set_image_id(self, id: int): + if id is None: + self.cursor.execute('DELETE FROM stuff WHERE k=?', ('image_id',)) + else: + self.cursor.execute('INSERT INTO stuff (k, v) VALUES (?, ?)', ('image_id', str(id))) + + # --- user specific --- # + + """def user(self, member: discord.Member) -> Union[User, None]: + self.cursor.execute('SELECT * FROM rules_accepted WHERE id=?', (member.id,)) + result = self.cursor.fetchone() + if result: + return User(result[0], result[1], result[2], member.joined_at, result[3]) + else: + self.cursor.execute('SELECT * FROM normal_user WHERE id=?', (member.id,)) + result = self.cursor.fetchone() + if result: + return User(result[0], result[1], result[2], result[3], None, result[4], result[5]) + else: + return None""" + + def add_user(self, user: discord.Member, join_message_id: int) -> User: + self.cursor.execute('INSERT INTO normal_user (id, name, tag, joined, join_message) VALUES (?, ?, ?, ?, ?)', (user.id, user.display_name, str(user), user.joined_at, join_message_id)) + return User(user.id, user.display_name, str(user), user.joined_at, join_message=join_message_id) + + def get_all_users(self) -> Dict[int, User]: + users = {} + self.cursor.execute('SELECT * FROM normal_user') + for row in self.cursor.fetchall(): + users[row[0]] = User(row[0], row[1], row[2], row[3], None, row[4], row[5]) + + self.cursor.execute('SELECT * FROM rules_accepted') + for row in self.cursor.fetchall(): + users[row[0]] = User(row[0], row[1], row[2], None, row[3]) + + return users + + def set_user_warning(self, user: User, warning_time: datetime.datetime): + self.cursor.execute('UPDATE normal_user SET warned=? WHERE id=?', (warning_time, user.id)) + user.warning_time = warning_time + + def add_user_accepted_rules(self, user: User, accepted_rules_datetime: datetime.datetime): + self.cursor.execute('DELETE FROM normal_user WHERE id=?', (user.id,)) + self.cursor.execute('INSERT INTO rules_accepted (id, name, tag, accepted) VALUES (?, ?, ?, ?)', (user.id, user.name, user.tag, accepted_rules_datetime)) + self.cursor.execute('INSERT INTO info (id) VALUES (?)', (user.id,)) + self.cursor.execute('INSERT INTO economy (id) SELECT id FROM rules_accepted WHERE NOT EXISTS(SELECT id FROM rules_accepted WHERE id=?)', (user.id,)) + user.warning_time = None + user.accepted_rules_date = accepted_rules_datetime + user.join_message = None + + def reset_user(self, user: User): + self.cursor.execute('DELETE FROM rules_accepted WHERE id=?', (user.id,)) + now = datetime.datetime.now() + self.cursor.execute('INSERT INTO normal_user (id, name, tag, joined) VALUES (?, ?, ?, ?)', (user.id, user.name, user.tag, now)) + user.joined = now + user.warning_time = None + user.accepted_rules_date = None + + def remove_user(self, user: Union[User, int]): + if isinstance(user, User): + id = user.id + else: + id = user + self.cursor.execute('DELETE FROM normal_user WHERE id=?', (id,)) + self.cursor.execute('DELETE FROM rules_accepted WHERE id=?', (id,)) + self.cursor.execute('DELETE FROM info WHERE id=?', (id,)) + + def change_user_infos(self, user: User, new_name: str, new_tag: str): + self.cursor.execute('UPDATE normal_user SET name=?, tag=? WHERE id=?', (new_name, new_tag, user.id)) + self.cursor.execute('UPDATE rules_accepted SET name=?, tag=? WHERE id=?', (new_name, new_tag, user.id)) + user.name = new_name + user.tag = new_tag + + # --- user infos --- # + + def get_user_infos(self, id: int) -> Union[Dict[str, Any], None]: + self.cursor.execute('SELECT * FROM info WHERE id=?', (id,)) + result = self.cursor.fetchone() + try: + return { + 'Name': result[1], + 'Alter': result[2], + 'Anime Liste': result[3], + 'Lieblings Anime': result[4], + 'Waifu': result[5], + 'Husbando': result[6], + } + except TypeError: + return None + + def set_user_name(self, id: int, name: str): + self.cursor.execute('UPDATE info SET name=? WHERE id=?', (name, id)) + + def set_user_age(self, id: int, age: [int, None]): + self.cursor.execute('UPDATE info SET age=? WHERE id=?', (age, id)) + + def set_user_list(self, id: int, list: Union[str, None]): + self.cursor.execute('UPDATE info SET list=? WHERE id=?', (list, id)) + + def set_user_fav(self, id: int, fav: str): + self.cursor.execute('UPDATE info SET fav=? WHERE id=?', (fav, id)) + + def set_user_waifu(self, id: int, waifu: str): + self.cursor.execute('UPDATE info SET waifu=? WHERE id=?', (waifu, id)) + + def set_user_husbando(self, id: int, husbando: str): + self.cursor.execute('UPDATE info SET husbando=? WHERE id=?', (husbando, id)) + + # --- points --- # + + def add_user_gold(self, id: int, gold: int): + self.cursor.execute('UPDATE economy SET gold=gold + ? WHERE id=?', (gold, id)) + + def has_remove_user_gold(self, id: int, gold: int) -> bool: + self.cursor.execute('UPDATE economy SET gold=gold - ? WHERE id=? AND gold >= ?', (gold, id, gold)) + return self.cursor.rowcount > 0 + + def get_user_items(self, id: int) -> Dict[str, int]: + self.cursor.execute('SELECT * FROM economy WHERE id=?', (id,)) + result = self.cursor.fetchone() + return { + 'gold': result[1], + 'extra vote': result[2], + 'color': result[3] + } + + def get_leaderboard(self) -> Dict[str, int]: + self.cursor.execute('SELECT id, gold FROM economy ORDER BY gold DESC LIMIT 10') + return {user[0]: user[1] for user in self.cursor.fetchall()} + + def set_user_extra_vote(self, id: int, count: int): + self.cursor.execute('UPDATE economy SET extra_vote=extra_vote + ? WHERE id=? AND extra_vote > 0', (count, id)) + return self.cursor.rowcount > 0 + + def set_user_color_count(self, id: int, count: int): + self.cursor.execute('UPDATE economy SET color=color + ? WHERE id=?', (count, id)) + + def set_user_color(self, id: int, color: str): + self.cursor.execute('UPDATE info SET color=? WHERE id=?', (color, id)) + + # --- family --- # + + def add_user_parent(self, user: int, parent_id: int): + self.cursor.execute('INSERT INTO family (id, parent) SELECT ?, ? WHERE NOT EXISTS(SELECT * FROM family WHERE id=? AND parent=?)', (user, parent_id, user, parent_id)) + + def remove_user_parent(self, user: int, parent_id: int): + self.cursor.execute('DELETE FROM family WHERE id=? AND parent=?', (user, parent_id)) + + def get_user_parents(self, user: int) -> List[int]: + parents = [] + self.cursor.execute('SELECT parent FROM family WHERE id=?', (user,)) + for parent in self.cursor.fetchall: + parents.append(parent[0]) + + return parents + + def get_user_children(self, user: int) -> List[int]: + children = [] + self.cursor.execute('SELECT id FROM family WHERE parent=?', (user,)) + for parent in self.cursor.fetchall: + children.append(parent[0]) + + return children diff --git a/kaizenbot/flags.py b/kaizenbot/flags.py new file mode 100644 index 0000000..d76246f --- /dev/null +++ b/kaizenbot/flags.py @@ -0,0 +1,295 @@ +from copy import copy +from discord.ext import commands +from enum import Enum +import shlex +import typing + +from .utils import Embeds, role_names + + +class AlternativeStoreType: + + class _StoreType: + pass + + class Bool(_StoreType): + pass + + class Int(_StoreType): + def __init__(self, min: int = float('-inf'), max: int = float('inf')): + if min > max: + raise ValueError('min must be higher than max') + self.min = min + self.max = max + + class Value(_StoreType): + pass + + store_bool = Bool() + store_int = Int() + store_value = Value() + + +class StoreType(Enum): + store_bool = 'store_bool' + store_value = 'store_value' + + +class _Parsed: + def __init__(self): + self.normal_args = () + self.ctx: commands.Context = None + + +class FlagErrors: + + class _FlagError(Exception): + + def __init__(self, message: str = None, flag: str = None): + self.flag = flag + super().__init__(message) + + class DoubleFlagError(_FlagError): + pass + + class FlagParseError(_FlagError): + pass + + class FlagPositionError(_FlagError): + + def __init__(self, message: str = None, flag: str = None): + super().__init__(message, flag) + + class FlagStoreError(_FlagError): + + def __init__(self, message: str = None, flag: str = None, store_type: StoreType = None, value=None): + self.store_type = store_type + self.value = value + super().__init__(message, flag) + + class RoleNotAllowedError(_FlagError): + + def __init__(self, message: str = None, flag: str = None, role: str = None): + self.role = role + super().__init__(message, flag) + + class QuotationError(Exception): + + def __init__(self, message: str = None): + super().__init__(message) + + +class _ParsedFlag: + + def __init__(self, flag: str, value): + self.flag = flag + self._value = value + + def __new__(cls, flag: str, value): + return value + + def __add__(self, other): + return self._value + other + + def __bool__(self): + return True if self._value else False + + def __ge__(self, other): + return self._value >= other + + def __getitem__(self, item): + return self._value + + def __gt__(self, other): + return self._value < other + + def __hash__(self): + return hash((self.flag, self._value)) + + def __le__(self, other): + return self._value <= other + + def __len__(self): + return len(self._value) + + def __lt__(self, other): + return self._value < other + + def __repr__(self): + return self._value + + +class Flag: + + def __init__(self, raise_errors=True, + double_flag_error='The flag `{flag}` was already given', + flag_position_error='Flags must be called at the end of a command', + quotation_error='At least one quotation mark is missing (`"` or `\'`)'): + self.double_flag_error = double_flag_error + self.raise_errors = raise_errors + self.flag_position_error = flag_position_error + self.quotation_error = quotation_error + self._flags = {} + + def add_flag(self, *flags, + store_type: typing.Union[StoreType, typing.Tuple[StoreType, typing.Any]], + parser: typing.Callable = None, + allowed_roles: typing.Union[typing.List[str], typing.Tuple[str]] = [], + disallowed_roles: typing.Union[typing.List[str], typing.Tuple[str], typing.Dict[str, str]] = [], + not_allowed_role_message='User has no allowed role for flag {flag}', + wrong_store_type_message=None, + help: str = '', show_help=True): + if store_type == StoreType.store_bool and parser is not None: + raise FlagErrors.FlagParseError('The flag parser cannot be set if the store type is \'store_bool\'') + + if store_type == StoreType.store_bool: + default_value = False + else: + default_value = None + + for allowed in allowed_roles: + if allowed in disallowed_roles: + raise ValueError(f'Role `{allowed}` cannot be allowed and disallowed at the same time') + + flag_information = {'store_type': store_type[0] if isinstance(store_type, tuple) else store_type, 'default': store_type[1] if isinstance(store_type, tuple) else default_value, + 'parser': parser, + 'allowed_roles': allowed_roles, 'disallowed_roles': disallowed_roles, + 'not_allowed_role_message': not_allowed_role_message, + 'wrong_store_type_message': wrong_store_type_message, + 'help': help, 'show_help': show_help} + + for flag in flags: + flag = str(flag) + self._flags[flag] = flag_information + + def flags(self) -> typing.Dict[str, typing.Any]: + return copy(self._flags) + + async def parse(self, args: str, ctx: commands.Context): + # (re)sets the attributes every time + parsed = _Parsed() + + try: + shlex_args = shlex.split(args) + except ValueError as error: + if str(error) == 'No closing quotation': + if self.raise_errors: + raise FlagErrors.QuotationError(self.quotation_error) + else: + await ctx.send(embed=Embeds.error_embed(description=self.quotation_error)) + return + else: + raise error + + for flag, information in self._flags.items(): + parsed.__setattr__(flag[2:], information['default']) + + flag_indexed = False + normal_args = [] + parsed_flags = [] + roles = role_names(ctx.author) + + for i, arg in enumerate(shlex_args): + arg = str(arg).replace('"', '').replace("'", '') + if '=' in arg: + arg, value = arg.split('=', 1) + else: + value = None + + if arg in self._flags: + if arg in parsed_flags: + if self.raise_errors: + raise FlagErrors.DoubleFlagError(self.double_flag_error.format(flag=arg)) + else: + await ctx.send(embed=Embeds.error_embed(description=self.double_flag_error.format(flag=arg))) + return + else: + parsed_flags.append(arg) + + if not flag_indexed: + flag_indexed = True + flag = self._flags[arg] + + # --- # + allowed_roles = flag['allowed_roles'] + if allowed_roles: + if not any(allowed in roles for allowed in allowed_roles): + error = flag['not_allowed_role_message'].format(flag=arg) + if self.raise_errors: + raise FlagErrors.RoleNotAllowedError(error) + else: + await ctx.send(embed=Embeds.error_embed(description=error)) + return + disallowed_roles = flag['disallowed_roles'] + if disallowed_roles: + for disallowed in disallowed_roles: + if disallowed in roles: + error = disallowed_roles[disallowed] if isinstance(disallowed_roles, dict) else 'The role `{role}` is not allowed to use the {flag} flag' + error = error.format(role=disallowed, flag=arg) + if self.raise_errors: + raise FlagErrors.RoleNotAllowedError(message=error, flag=arg, role=disallowed) + else: + await ctx.send(embed=Embeds.error_embed(description=error)) + return + + store_type = flag['store_type'] + arg_without_prefix = arg[2:] + if store_type == StoreType.store_bool: + error = flag['wrong_store_type_message'] if flag['wrong_store_type_message'] else 'Flag `{flag}` must not contain a value' + error = error.format(flag=arg) + if value: + if self.raise_errors: + raise FlagErrors.FlagStoreError(message=error, flag=arg, store_type=store_type, value=value) + else: + await ctx.send(embed=Embeds.error_embed(description=error)) + return + else: + parsed.__setattr__(arg_without_prefix, _ParsedFlag(flag, True)) + elif store_type == StoreType.store_value: + error = flag['wrong_store_type_message'] if flag['wrong_store_type_message'] else 'Flag `{flag}` must not contain a value' + error = error.format(flag=arg) + if not value: + if self.raise_errors: + raise FlagErrors.FlagStoreError(message=error, flag=arg, store_type=store_type) + else: + await ctx.send(embed=Embeds.error_embed(description=error)) + return + else: + if parser := flag['parser']: + value_parsed = await parser(ctx, arg, value) + if isinstance(value_parsed, bool): + if value_parsed: + parsed.__setattr__(arg_without_prefix, _ParsedFlag(flag, True)) + else: + return + else: + parsed.__setattr__(arg_without_prefix, _ParsedFlag(flag, value_parsed)) + else: + parsed.__setattr__(arg_without_prefix, _ParsedFlag(flag, value)) + + elif flag_indexed: + if self.raise_errors: + raise FlagErrors.FlagPositionError(message=self.flag_position_error) + else: + await ctx.send(embed=Embeds.error_embed(description=self.flag_position_error)) + return + + else: + normal_args.append(arg) + + parsed.normal_args = tuple(normal_args) + parsed.ctx = ctx + return parsed + + +def get_flags(command: commands.Command) -> typing.Union[Flag, None]: + flags = command.__original_kwargs__.get('flags', None) + if isinstance(flags, str): + try: + return command.cog.__getattribute__(flags) + except AttributeError: + raise AttributeError(f'The flag `{flags}` does not exist') + elif isinstance(flags, Flag): + return flags + else: + return None diff --git a/kaizenbot/user.py b/kaizenbot/user.py new file mode 100644 index 0000000..cc289a7 --- /dev/null +++ b/kaizenbot/user.py @@ -0,0 +1,13 @@ +import datetime + + +class User: + + def __init__(self, id: int, name: str, tag: str, joined: datetime.datetime, accepted_rules_date: datetime.datetime = None, warning_time: datetime.datetime = None, join_message = None): + self.id = id + self.name = name + self.tag = tag + self.joined = joined + self.accepted_rules_date = accepted_rules_date + self.warning_time = warning_time + self.join_message = join_message diff --git a/kaizenbot/utils.py b/kaizenbot/utils.py new file mode 100644 index 0000000..032d54c --- /dev/null +++ b/kaizenbot/utils.py @@ -0,0 +1,476 @@ +import asyncio +import random +import typing +from pathlib import Path +from threading import Timer as _Timer +from time import sleep + +import discord +from discord.ext import menus + +from . import logger + + +class AsyncTimer: + + def __init__(self, start: float, callback, *args): + self._callback = callback + self._args = args + self._start = start + self._task = asyncio.ensure_future(self._job()) + + async def _job(self): + await asyncio.sleep(self._start) + await self._callback(*self._args) + + def cancel(self): + self._task.cancel() + + +class AsyncIntervalTimer(AsyncTimer): + + def __init__(self, first_start: float, interval: float, callback, *args): + super().__init__(first_start, callback, *args) + self._interval = interval + + async def _job(self): + await super()._job() + while True: + await asyncio.sleep(self._interval) + await self._callback(*self._args) + + def cancel(self): + self._task.cancel() + + +class IntervalTimer: + def __init__(self, first_start: float, interval: float, func, *args): + self.first_start = first_start + self.interval = interval + self.handlerFunction = func + self.args = args + self.running = False + self.timer = _Timer(self.interval, self.run, args) + + def run(self, *args): + sleep(self.first_start) + self.handlerFunction(*args) + while self.running: + sleep(self.interval) + self.handlerFunction(*args) + + def start(self): + self.running = True + self.timer.start() + + def cancel(self): + self.running = False + pass + + +class Embeds: + + @staticmethod + async def send_kaizen_infos(channel): + file = discord.File(Path.cwd().joinpath('assets', 'kaizen-round.png')) + + embed = discord.Embed(title='**Kaizen**', description='Folge Kaizen auf den folgenden Kanälen, um nichts mehr zu verpassen!', color=discord.Color(0xff0000)) + embed.set_thumbnail(url='attachment://kaizen-round.png') + embed.add_field(name='**🎥Youtube Hauptkanal**', value='Abonniere Kaizen auf __**[Youtube](https://www.youtube.com/c/KaizenAnime)**__ um kein Anime Video mehr zu verpassen!', inline=False) + embed.add_field(name='**📑Youtube Toplisten-Kanal**', value='Abonniere Kaizen\'s __**[Toplisten-Kanal](https://www.youtube.com/channel/UCoijG8JqKb1rRZofx5b-LCw)**__ um kein Toplisten-Video mehr zu verpassen!', inline=False) + embed.add_field(name='**📯Youtube Stream-Clips & mehr**', value='Abonniere Kaizen\'s __**[Youtube Kanal](https://www.youtube.com/channel/UCodeTj8SJ-5HhJgC_Elr1Dw)**__ für Stream-Clips & mehr!', inline=False) + embed.add_field(name='**📲Twitch**', value='Folge Kaizen auf __**[Twitch](https://www.twitch.tv/kaizenanime)**__ und verpasse keinen Stream mehr! ' + 'Subbe Kaizen um eine exklusive Rolle auf dem Discord Server zu bekommen!', inline=False) + embed.add_field(name='**📢Twitter**', value='Folge Kaizen auf __**[Twitter](https://twitter.com/Kaizen_Anime)**__ um aktuelle Informationen zu bekommen und in Videos / Streams mitzuwirken!', inline=False) + embed.add_field(name='**📷Instagram**', value='Folge Kaizen auf __**[Instagram](https://www.instagram.com/kaizen.animeyt/)**__!', inline=False) + await channel.send(embed=embed, file=file) + + @staticmethod + def error_embed(title: typing.Union[str, None] = None, description: typing.Union[str, None] = None) -> discord.Embed: + embed = discord.Embed(color=discord.Color(0xff0000)) + if title: + embed.title = title + if description: + embed.description = description + return embed + + @staticmethod + def warn_embed(title: typing.Union[str, None] = None, description: typing.Union[str, None] = None) -> discord.Embed: + embed = discord.Embed(color=discord.Color(0xff9055)) + if title: + embed.title = title + if description: + embed.description = description + return embed + + @staticmethod + def success_embed(title: typing.Union[str, None] = None, description: typing.Union[str, None] = None) -> discord.Embed: + embed = discord.Embed(color=discord.Color(0x00ff00)) + if title: + embed.title = title + if description: + embed.description = description + return embed + + +class MenuListPageSource(menus.ListPageSource): + + def __init__(self, data): + super().__init__(data, per_page=1) + + async def format_page(self, menu, embeds): + return embeds + + +def random_sequence_not_in_string(string: str): + sequence = '+' + while sequence in string: + choice = random.choice('+*~-:%&') + sequence = choice + sequence + choice + + return sequence + + +def role_names(member: discord.Member) -> typing.List[str]: + return [role.name for role in member.roles] + + +# ADDED AFTERWARDS: I've stol- copied the following code from a tweepy (https://github.com/tweepy/tweepy) PR or gist (from where exactly I do not know anymore lul) +# at the time when they didn't support async actions + +# Tweepy +# Copyright 2009-2021 Joshua Roesslein +# See LICENSE for details. + +import json +from math import inf +from platform import python_version + +import aiohttp +from oauthlib.oauth1 import Client as OAuthClient +from yarl import URL + +import tweepy +from tweepy.error import TweepError +from tweepy.models import Status + + +class AsyncStream: + """Stream realtime Tweets asynchronously + Parameters + ---------- + consumer_key: :class:`str` + Consumer key + consumer_secret: :class:`str` + Consuemr secret + access_token: :class:`str` + Access token + access_token_secret: :class:`str` + Access token secret + max_retries: Optional[:class:`int`] + Number of times to attempt to (re)connect the stream. + Defaults to infinite. + proxy: Optional[:class:`str`] + Proxy URL + """ + + def __init__(self, consumer_key, consumer_secret, access_token, + access_token_secret, max_retries=inf, proxy=None): + self.consumer_key = consumer_key + self.consumer_secret = consumer_secret + self.access_token = access_token + self.access_token_secret = access_token_secret + self.max_retries = max_retries + self.proxy = proxy + + self.session = None + self.task = None + self.user_agent = ( + f"Python/{python_version()} " + f"aiohttp/{aiohttp.__version__} " + f"Tweepy/{tweepy.__version__}" + ) + + async def _connect(self, method, endpoint, params={}, headers=None, + body=None): + error_count = 0 + # https://developer.twitter.com/en/docs/twitter-api/v1/tweets/filter-realtime/guides/connecting + stall_timeout = 90 + network_error_wait = network_error_wait_step = 0.25 + network_error_wait_max = 16 + http_error_wait = http_error_wait_start = 5 + http_error_wait_max = 320 + http_420_error_wait_start = 60 + + oauth_client = OAuthClient(self.consumer_key, self.consumer_secret, + self.access_token, self.access_token_secret) + + if self.session is None or self.session.closed: + self.session = aiohttp.ClientSession( + headers={"User-Agent": self.user_agent}, + timeout=aiohttp.ClientTimeout(sock_read=stall_timeout) + ) + + url = f"https://stream.twitter.com/1.1/{endpoint}.json" + url = str(URL(url).with_query(sorted(params.items()))) + + try: + while error_count <= self.max_retries: + request_url, request_headers, request_body = oauth_client.sign( + url, method, body, headers + ) + try: + async with self.session.request( + method, request_url, headers=request_headers, + data=request_body, proxy=self.proxy + ) as resp: + if resp.status == 200: + error_count = 0 + http_error_wait = http_error_wait_start + network_error_wait = network_error_wait_step + + await self.on_connect() + + async for line in resp.content: + line = line.strip() + if line: + await self.on_data(line) + else: + await self.on_keep_alive() + + await self.on_closed(resp) + else: + await self.on_request_error(resp.status) + + error_count += 1 + + if resp.status == 420: + if http_error_wait < http_420_error_wait_start: + http_error_wait = http_420_error_wait_start + + await asyncio.sleep(http_error_wait) + + http_error_wait *= 2 + if resp.status != 420: + if http_error_wait > http_error_wait_max: + http_error_wait = http_error_wait_max + except (aiohttp.ClientConnectionError, + aiohttp.ClientPayloadError) as e: + await self.on_connection_error() + + await asyncio.sleep(network_error_wait) + + network_error_wait += network_error_wait_step + if network_error_wait > network_error_wait_max: + network_error_wait = network_error_wait_max + except asyncio.CancelledError: + return + except Exception as e: + await self.on_exception(e) + finally: + await self.session.close() + await self.on_disconnect() + + async def filter(self, follow=None, track=None, locations=None, + stall_warnings=False): + """This method is a coroutine. + Filter realtime Tweets + https://developer.twitter.com/en/docs/twitter-api/v1/tweets/filter-realtime/api-reference/post-statuses-filter + Parameters + ---------- + follow: Optional[List[Union[:class:`int`, :class:`str`]]] + A list of user IDs, indicating the users to return statuses for in + the stream. See https://developer.twitter.com/en/docs/twitter-api/v1/tweets/filter-realtime/guides/basic-stream-parameters + for more information. + track: Optional[List[:class:`str`]] + Keywords to track. Phrases of keywords are specified by a list. See + https://developer.twitter.com/en/docs/tweets/filter-realtime/guides/basic-stream-parameters + for more information. + locations: Optional[List[:class:`float`]] + Specifies a set of bounding boxes to track. See + https://developer.twitter.com/en/docs/tweets/filter-realtime/guides/basic-stream-parameters + for more information. + stall_warnings: Optional[:class:`bool`] + Specifies whether stall warnings should be delivered. See + https://developer.twitter.com/en/docs/tweets/filter-realtime/guides/basic-stream-parameters + for more information. Def +logger = logging.getLogger('kaizen')aults to False. + Returns :class:`asyncio.Task` + """ + if self.task is not None and not self.task.done(): + raise TweepError("Stream is already connected") + + endpoint = "statuses/filter" + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + body = {} + if follow is not None: + body["follow"] = ','.join(map(str, follow)) + if track is not None: + body["track"] = ','.join(map(str, track)) + if locations is not None: + if len(locations) % 4: + raise TweepError( + "Number of location coordinates should be a multiple of 4" + ) + body["locations"] = ','.join( + f"{location:.4f}" for location in locations + ) + if stall_warnings: + body["stall_warnings"] = "true" + + self.task = asyncio.create_task( + self._connect("POST", endpoint, headers=headers, body=body or None) + ) + return self.task + + async def sample(self, stall_warnings=False): + """This method is a coroutine. + Sample realtime Tweets + https://developer.twitter.com/en/docs/twitter-api/v1/tweets/sample-realtime/api-reference/get-statuses-sample + Parameters + ---------- + stall_warnings: Optional[:class:`bool`] + Specifies whether stall warnings should be delivered. See + https://developer.twitter.com/en/docs/tweets/filter-realtime/guides/basic-stream-parameters + for more information. Defaults to False. + Returns :class:`asyncio.Task` + """ + if self.task is not None and not self.task.done(): + raise TweepError("Stream is already connected") + + endpoint = "statuses/sample" + + params = {} + if stall_warnings: + params["stall_warnings"] = "true" + + self.task = asyncio.create_task( + self._connect("GET", endpoint, params=params) + ) + return self.task + + def disconnect(self): + """Disconnect the stream""" + if self.task is not None: + self.task.cancel() + + async def on_closed(self, resp): + """This method is a coroutine. + This is called when the stream has been closed by Twitter. + """ + logger.error("Stream connection closed by Twitter") + + async def on_connect(self): + """This method is a coroutine. + This is called after successfully connecting to the streaming API. + """ + # logger.info("Stream connected") + + async def on_connection_error(self): + """This method is a coroutine. + This is called when the stream connection errors or times out. + """ + # logger.error("Stream connection has errored or timed out") + + async def on_disconnect(self): + """This method is a coroutine. + This is called when the stream has disconnected. + """ + # logger.info("Stream disconnected") + + async def on_exception(self, exception): + """This method is a coroutine. + This is called when an unhandled exception occurs. + """ + logger.exception("Stream encountered an exception") + + async def on_keep_alive(self): + """This method is a coroutine. + This is called when a keep-alive message is received. + """ + #logger.debug("Received keep-alive message") + + async def on_request_error(self, status_code): + """This method is a coroutine. + This is called when a non-200 HTTP status code is encountered. + """ + # logger.error("Stream encountered HTTP Error: %d", status_code) + + async def on_data(self, raw_data): + """This method is a coroutine. + This is called when raw data is received from the stream. + This method handles sending the data to other methods, depending on the + message type. + https://developer.twitter.com/en/docs/twitter-api/v1/tweets/filter-realtime/guides/streaming-message-types + """ + data = json.loads(raw_data) + + if "in_reply_to_status_id" in data: + status = Status.parse(None, data) + return await self.on_status(status) + if "delete" in data: + delete = data["delete"]["status"] + return await self.on_delete(delete["id"], delete["user_id"]) + if "disconnect" in data: + return await self.on_disconnect_message(data["disconnect"]) + if "limit" in data: + return await self.on_limit(data["limit"]["track"]) + if "scrub_geo" in data: + return await self.on_scrub_geo(data["scrub_geo"]) + if "status_withheld" in data: + return await self.on_status_withheld(data["status_withheld"]) + if "user_withheld" in data: + return await self.on_user_withheld(data["user_withheld"]) + if "warning" in data: + return await self.on_warning(data["warning"]) + + logger.warning("Received unknown message type: %s", raw_data) + + async def on_status(self, status): + """This method is a coroutine. + This is called when a status is received. + """ + # logger.debug("Received status: %d", status.id) + + async def on_delete(self, status_id, user_id): + """This method is a coroutine. + This is called when a status deletion notice is received. + """ + # logger.debug("Received status deletion notice: %d", status_id) + + async def on_disconnect_message(self, message): + """This method is a coroutine. + This is called when a disconnect message is received. + """ + # logger.warning("Received disconnect message: %s", message) + + async def on_limit(self, track): + """This method is a coroutine. + This is called when a limit notice is received. + """ + # logger.debug("Received limit notice: %d", track) + + async def on_scrub_geo(self, notice): + """This method is a coroutine. + This is called when a location deletion notice is received. + """ + # logger.debug("Received location deletion notice: %s", notice) + + async def on_status_withheld(self, notice): + """This method is a coroutine. + This is called when a status withheld content notice is received. + """ + # logger.debug("Received status withheld content notice: %s", notice) + + async def on_user_withheld(self, notice): + """This method is a coroutine. + This is called when a user withheld content notice is received. + """ + # logger.debug("Received user withheld content notice: %s", notice) + + async def on_warning(self, notice): + """This method is a coroutine. + This is called when a stall warning message is received. + """ + # logger.warning("Received stall warning: %s", notice) diff --git a/main.py b/main.py new file mode 100644 index 0000000..0fd92e6 --- /dev/null +++ b/main.py @@ -0,0 +1,444 @@ +#!/usr/local/bin/python3.9 + +import asyncio +from copy import copy +import datetime +import discord +from discord import errors +from discord.ext import commands +from discord.ext.commands import errors as ext_errors +from pathlib import Path +import sys +import traceback +import tweepy +from twitchAPI.twitch import Twitch + +from kaizenbot import glob, logger +from kaizenbot.database import Database +from kaizenbot.user import User +from kaizenbot.utils import AsyncIntervalTimer, AsyncStream, IntervalTimer, Embeds + +import kaizenbot.commands as cogs + + +class Bot(commands.Bot): + + def __init__(self): + intents = discord.Intents(messages=True, members=True, guilds=True, reactions=True) + super().__init__(command_prefix='$', case_insensitive=True, intents=intents) + self.support = Support(self) + + # ADDED AFTERWARDS: hardcoded password, very secure... + self.database = Database('kaizen', '9y*"xF(BxLZ!HpgKn_') + self.image_id = self.database.get_image_id() + self.message_id = self.database.get_message_id() + self.all_users = self.database.get_all_users() + self.normal_users = [] + self.error_users = [] + + for cog in [cogs.Economy, cogs.Mod, cogs.Help, cogs.Info, cogs.Kaizen, cogs.Vote, cogs.NSFW]: + loaded_cog = cog(self) + if cog == cogs.Economy: + now = datetime.datetime.now() + try: + reset_economy = IntervalTimer((datetime.datetime(year=now.year, month=now.month, day=now.day + 1)), 60*60*24, lambda: loaded_cog.reset()) + except ValueError: + reset_economy = IntervalTimer((datetime.datetime(year=now.year, month=now.month + 1, day=1)), 60 * 60 * 24, lambda: loaded_cog.reset()) + reset_economy.start() + self.add_cog(loaded_cog) + + + @commands.Cog.listener() + async def on_ready(self): + logger.info('Logged in') + if not hasattr(self, 'guild'): + if sys.argv[1] == 'test': + self.guild: discord.Guild = self.get_guild(768846874517438535) + self.welcome_channel = self.guild.get_channel(769292296985903184) + self.info_channel = self.welcome_channel + self.twitter_channel = self.welcome_channel + self.kaizenianer = self.guild.get_role(802222058116612136) + self.twitter_abonnenten = self.kaizenianer + self.mod = self.guild.get_role(803296051444056115) + + twitter_id = 1301397827378176007 + elif sys.argv[1] == 'run': + self.guild: discord.Guild = self.get_guild(796132539269382154) + self.welcome_channel = self.guild.get_channel(796876279537336423) + self.info_channel = self.guild.get_channel(803403162056523786) + self.twitter_channel = self.guild.get_channel(797555712943194163) + self.kaizenianer = self.guild.get_role(796870178939994132) + self.twitter_abonnenten = self.guild.get_role(831963234352234578) + self.mod = self.guild.get_role(796864065741258773) + + twitter_id = 982950038610632705 + else: + exit(1) + return + + now = datetime.datetime.now() + if now.hour < 12: + first_call = datetime.datetime(year=now.year, month=now.month, day=now.day, hour=12) + else: + try: + first_call = datetime.datetime(year=now.year, month=now.month, day=now.day + 1, hour=12) + except ValueError: + first_call = datetime.datetime(year=now.year, month=now.month + 1, day=1, hour=12) + + glob['bot'] = self + + daily_kaizen_infos = AsyncIntervalTimer((first_call - now).seconds, 60*60*24, Embeds.send_kaizen_infos, [self.info_channel]) + glob['timers'].append(daily_kaizen_infos) + recheck_users = AsyncIntervalTimer(60*60*24, 60*60*24, self.support.recheck_user_tags_and_names) + glob['timers'].append(recheck_users) + + database_ping = IntervalTimer(0, 60 * 60, lambda: self.database.cursor.execute('SELECT * FROM stuff WHERE k=?', ('0',))) + database_ping.start() + glob['timers'].append(database_ping) + logger.debug('Started database pinger') + transcript_cleaner = IntervalTimer(0, 60 * 5, self.support.transcript_cleaner) + transcript_cleaner.start() + glob['timers'].append(transcript_cleaner) + logger.debug('Started transcript cleaner') + + twitch_stream_status = AsyncIntervalTimer(0, 60 * 5, self.support.twitch_stream_status) + glob['timers'].append(twitch_stream_status) + logger.debug("Started twitch stream status listener") + + # ADDED AFTERWARDS: 2 twitter listeners, why not + + # twitter listener + stream = AsyncStream('...', '...', + '...', '...') + stream.on_status = self.support.twitter(twitter_id) + await stream.filter(follow=[str(twitter_id)]) + + auth = tweepy.OAuthHandler('...', '...') + auth.set_access_token('...', '...') + + glob['twitter'] = tweepy.API(auth) + + logger.debug('Started twitter listener') + + + + await self.support.on_startup() + + self.loop = asyncio.get_event_loop() + self.loop.run_until_complete(await asyncio.gather([await self.support.check_normal_users()], return_exceptions=True)) + + @commands.Cog.listener() + async def help_command(self): + pass + + @commands.Cog.listener() + async def on_guild_join(self, guild: discord.Guild): + if guild.id != self.guild.id: + await guild.leave() + + @commands.Cog.listener() + async def on_connect(self): + logger.info('Connected to discord') + # self.loop = asyncio.get_event_loop() + # self.loop.run_until_complete(await self.support.check_normal_users()) + + @commands.Cog.listener() + async def on_disconnect(self): + logger.info('Disconnected from discord') + # self.loop.close() + + @commands.Cog.listener() + async def on_command_error(self, ctx: commands.Context, error: ext_errors.CommandError): + if isinstance(error, ext_errors.CommandNotFound): + logger.info(error) + #elif isinstance(error, ext_errors.UnexpectedQuoteError): + # await ctx.send(embed=Embeds.error_embed('Please use `\'` as quotation mark for flags')) + elif isinstance(error, ext_errors.CommandOnCooldown): + cooldown = divmod(error.retry_after, 60) + await ctx.send(embed=Embeds.error_embed(description=f'Dieser Befehl kann erst wieder in {round(cooldown[0])} Minuten und {round(cooldown[1])} Sekunden genutzt werden')) + elif isinstance(error, ext_errors.MissingRequiredArgument): + await cogs.Help(self).show_help(ctx, ctx.command) + elif isinstance(error, ext_errors.MissingRole): + logger.info(f'{str(ctx.author)} tried to run `{ctx.command}` for which he has no authorization') + else: + logger.error(''.join(traceback.format_exception(etype=type(error), value=error, tb=error.__traceback__))) + + @commands.Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent): + if not payload.member.bot and payload.message_id == self.message_id: + user = self.all_users[payload.member.id] + if str(payload.emoji) == '✅': + if not user.accepted_rules_date: + await self.support.user_accepted_rules(user) + await payload.member.add_roles(self.kaizenianer) + else: + await payload.member.remove_roles(self.kaizenianer) + # `self.support.reset_user(user)` is not called here, because it gets called in `self.on_member_update(...)` + message = await self.welcome_channel.fetch_message(payload.message_id) + await message.remove_reaction(payload.emoji, payload.member) + + @commands.Cog.listener() + async def on_member_join(self, member: discord.Member): + if not member.bot: + join_message = await self.welcome_channel.send(f'Hey <@{member.id}>, Willkommen auf dem Offiziellen Discord-Server von Kaizen! **Kaizen Anime!**\n' + 'Bitte lese die Regeln und warte 10 Mintuten, damit Du auf sie reacten kannst, um ein Kaizenianer zu werden! Diese findest Du, wenn Du nach oben scrollst, ' + 'oder indem Du zur angepinnten Nachricht springst.') + user = self.database.add_user(member, join_message.id) + self.all_users[user.id] = user + self.normal_users.append(user) + logger.info(f'User {str(member)} joined') + await asyncio.sleep(60*10 + 15) + if user.accepted_rules_date is None: + await member.send(f'Hey <@{member.id}>, Willkommen auf dem Offiziellen Discord-Server von Kaizen! **Kaizen Anime!**\n' + 'Bitte lese und reacte auf die Regeln, um ein Kaizenianer zu werden! Diese findest Du, wenn Du im __**#willkommen**__ Kanal nach oben scrollst, oder indem Du zur dort angepinnten Nachricht springst.') + + @commands.Cog.listener() + async def on_member_remove(self, member: discord.Member): + if not member.bot: + user = self.all_users[member.id] + + self.database.remove_user(user.id) + await self.support.check_and_remove_normal_user(user) + del user + logger.info(f'User {str(member)} left') + + @commands.Cog.listener() + async def on_member_update(self, before: discord.Member, after: discord.Member): + try: + user = self.all_users[before.id] + except KeyError: # this method also gets called if a new user is joined + return + if hash((str(before), before.display_name)) != hash((str(after), after.display_name)): + self.database.change_user_infos(user, after.display_name, str(after)) + logger.info(f'User changed from {str(before)}, {before.display_name} to {str(after)}, {after.display_name}') + + if before.roles != after.roles: + if user.accepted_rules_date and self.kaizenianer in before.roles and self.kaizenianer not in after.roles: + self.support.reset_user(user) + elif not user.accepted_rules_date and self.kaizenianer not in before.roles and self.kaizenianer in after.roles: + await self.support.user_accepted_rules(user) + + @commands.Cog.listener() + async def on_message(self, message: discord.Message): + glob['transcript'][datetime.datetime.now()] = f'[{datetime.datetime.now().strftime("%Y-%d-%m %H:%M:%S")}] - {message.author}: {message.content}' \ + f'{" | Attachments: " + ", ".join([attachments.url for attachments in message.attachments]) if message.attachments else ""}' + + await super().on_message(message) + + +class Support: + + def __init__(self, bot: Bot): + self.bot = bot + + self.streams = False + self.title = "" + + self.twitch = Twitch('...', '...') + self.twitch.authenticate_app([]) + + async def on_startup(self): + # the rules embed to accept to get the kaizenianer role + embed = discord.Embed(description='Hey, bitte lese & akzeptiere unsere Regeln!\n\n' + 'Willkommen auf dem Offiziellen Discord-Server von KaizenAnime. Bitte lest Euch die Regen einmal durch.\n\n' + '-Keine Beleidigungen\n' + '-Kein Rassismus\n' + '-Freundlich bleiben\n' + '-Themen bitte nur in die dafür vorgesehenen Kanäle posten\n' + '-Sprachkanäle bitte mit Respekt betreten\n' + '-Kein Spam oder Eigenwerbung\n' + '-Religiöse und Politische Themen sind nur in Sprachkanälen erlaubt\n' + '-Behandle alle mit Respekt. Belästigung, Hexenjagd, Sexismus, Rassismus oder Volksverhetzung werden absolut NICHT toleriert\n' + '\n' + 'Die Regeln können jederzeit (mit Vorankündigung) geändert werden', + color=000000) + + if not self.bot.image_id: + image = await self.bot.welcome_channel.send(file=discord.File(Path.cwd().joinpath('assets', 'rules.png'))) + self.bot.database.set_image_id(image.id) + self.bot.image_id = image.id + + if self.bot.message_id: + message = await self.bot.welcome_channel.fetch_message(self.bot.message_id) + await message.delete() + self.bot.database.set_message_id(None) + self.bot.message_id = None + + # checks if the posted message if there are differences between `embed` content and the posted content + if self.bot.message_id: + message = await self.bot.welcome_channel.fetch_message(self.bot.message_id) + message_embed = message.embeds[0] + # if differences are there, the posted message will be edited + if hash((embed.title, embed.description, embed.color)) != hash((message_embed.title, message_embed.description, message_embed.color)): + await message.edit(embed=embed) + logger.info('Edited rules message') + # if the message doesn't exists, it gets posted + else: + message = await self.bot.welcome_channel.send(embed=embed) + await message.add_reaction('✅') + self.bot.database.set_message_id(message.id) + self.bot.message_id = message.id + + member_ids = [] + for member in self.bot.guild.members: + member_ids.append(member.id) + if not member.bot: + new = False + if member.id in self.bot.all_users: + user = self.bot.all_users[member.id] + else: + user = self.bot.database.add_user(member, None) + self.bot.all_users[member.id] = user + new = True + logger.info(f'User {user.tag} joined while I was offline') + + if user.accepted_rules_date is None: + if self.bot.kaizenianer in member.roles: + self.bot.database.add_user_accepted_rules(user, datetime.datetime.now()) + await self.check_and_remove_normal_user(user) + logger.info(f'Added kaizenianer {user.tag}') + else: + self.bot.normal_users.append(user) + if new: + join_message = await self.bot.welcome_channel.send(f'Hey <@{member.id}>, Willkommen auf dem Offiziellen Discord-Server von Kaizen! **Kaizen Anime!**\n' + f'Bitte lese und reacte auf die Regeln, um ein Kaizenianer zu werden! ' + f'Diese findest Du, wenn Du nach oben scrollst, oder indem Du zur angepinnten Nachricht springst.') + await member.send(f'Hey <@{member.id}>, Willkommen auf dem Offiziellen Discord-Server von Kaizen! **Kaizen Anime!**\n' + f'Bitte lese und reacte auf die Regeln, um ein Kaizenianer zu werden! ' + f'Diese findest Du, wenn Du im __**#willkommen**__ Kanal nach oben scrollst, oder indem Du zur dort angepinnten Nachricht springst.') + user.join_message = join_message.id + elif self.bot.kaizenianer not in member.roles: + self.bot.database.reset_user(user) + + for user in copy(self.bot.all_users).values(): + if user.id not in member_ids: + tag = user.tag + self.bot.database.remove_user(user.id) + del user + logger.info(f'User {tag} left while I was offline') + + try: + reacted_users = message.reactions[0].users() # the ✅ reaction / emoji + async for reacted_user in reacted_users: + if not reacted_user.bot: + user = self.bot.all_users[reacted_user.id] + if not user.accepted_rules_date: + await reacted_user.add_roles(self.bot.kaizenianer) + await self.user_accepted_rules(user) + else: + self.reset_user(user) + await message.remove_reaction(message.reactions[0], user) + except IndexError: # gets thrown if the embed message was new created with this bot runtime + pass + + bot.database.sync() + + def transcript_cleaner(self): + now = datetime.datetime.now() + for timestamp in copy(glob['transcript']).keys(): + if timestamp + datetime.timedelta(hours=1) < now: + del glob['transcript'][timestamp] + + async def recheck_user_tags_and_names(self): + for member in self.bot.guild.members: + user = self.bot.all_users[member.id] + tag = str(member) + name = member.display_name + if tag != user.tag or name != user.name: + info = f'User changed from {user.tag}, {user.name} to {tag}, {name}' + self.bot.database.change_user_infos(user, name, tag) + logger.info(info) + + async def check_normal_users(self): + temp_error_users = {} + while True: + now = datetime.datetime.now() + for user in self.bot.normal_users: + try: + if user.joined + datetime.timedelta(days=1) < now and user.warning_time is None: + member = self.bot.guild.get_member(user.id) + await member.send('Hey, bitte reacte auf unsere Server-Regeln, da dir sonst wegen Verweigerung ein Kick aus unserem Server (**Kaizen Anime**) bevorstehen wird!\n\n' + 'Mit freundlichen Grüßen,\n' + 'das _Kaizen Server Team_') + self.bot.database.set_user_warning(user, now) + logger.info(f'Warned user {user.tag}, because he didn\'t accepted the rules since he joined') + elif user.warning_time and user.warning_time + datetime.timedelta(days=1, hours=12) < now: + await self.bot.guild.get_member(user.id).kick(reason='Wegen nicht reacten auf unsere Kaizen Anime Server-Regeln trotz Verwarnung.\n\n' + 'MfG das Kaizen Server Team') + # the user won't removed from the database here, because `self.on_member_remove(...)` is called + logger.info(f'Kicked user {user.tag} because he didn\'t accepted the rules') + except Exception as error: + if isinstance(error, errors.Forbidden): + if user not in self.bot.error_users: + if user in temp_error_users: + temp_error_users[user] += 1 + else: + temp_error_users[user] = 1 + + if temp_error_users[user] >= 5: + logger.warning(f'User {user.tag} caused to many error. I hide error log messages for him from now on') + self.bot.error_users.append(user) + del temp_error_users[user] + continue + logger.warning(f'Unexpected exception was thrown while checking all normal users: {"".join(traceback.format_exception(etype=type(error), value=error, tb=error.__traceback__))}') + await asyncio.sleep(60) + + async def check_and_remove_normal_user(self, user: User): + if user in self.bot.normal_users: + self.bot.normal_users.remove(user) + if user in self.bot.error_users: + self.bot.error_users.remove(user) + + if user.join_message: + try: + message = await self.bot.welcome_channel.fetch_message(user.join_message) + await message.delete() + except discord.errors.NotFound: + logger.info(f'Failed to find message {user.join_message}') + user.join_message = None + + def reset_user(self, user: User): + self.bot.database.reset_user(user) + self.bot.normal_users.append(user) + if user in self.bot.error_users: + self.bot.error_users.remove(user) + logger.info(f'Reset kaizenianer {user.tag}') + + async def user_accepted_rules(self, user: User): + await self.check_and_remove_normal_user(user) + self.bot.database.add_user_accepted_rules(user, datetime.datetime.now()) + if user in self.bot.error_users: + self.bot.error_users.remove(user) + logger.info(f'Added kaizenianer {user.tag}') + + def twitter(self, id: int): + async def on_status(status: tweepy.Status): + # checks if the status author is the given id, if the tweet is not a retweet and if the tweet is not a reply + if id == status.author.id and not hasattr(status, 'retweeted_status') and status.in_reply_to_status_id is None: + msg = f'Hey <@&{self.bot.kaizenianer.id}>, **Kaizen!** hat einen neuen Tweet gepostet!\nhttps://twitter.com/twitter/statuses/{status.id}' + await self.bot.twitter_channel.send(msg) + return on_status + + async def twitch_stream_status(self): + if data := self.twitch.get_streams(user_login=['kaizenanime'])['data']: + if not self.streams: + await self.bot.change_presence(activity=discord.Streaming(name=data[0]['title'], url='https://www.twitch.tv/kaizenanime')) + self.streams = True + logger.info('Kaizen started streaming') + elif self.title != data[0]['title']: + self.title = data[0]['title'] + await self.bot.change_presence(activity=discord.Streaming(name=data[0]['title'], url='https://www.twitch.tv/kaizenanime')) + else: + if self.streams: + await self.bot.change_presence(activity=None) + self.streams = False + self.title = '' + logger.info('Kaizen stopped streaming') + + +if __name__ == '__main__': + bot = Bot() + if sys.argv[1] == 'test': + bot.run('...') + elif sys.argv[1] == 'run': + bot.run('...')