diff --git a/requirements.txt b/requirements.txt index e3ec73a..dd397dd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,8 @@ # TODO pytio==0.3.1 -aiohttp==3.9.3 -aiosqlite==0.19.0 -nextcord==2.3.2 -numpy==1.26 +aiosqlite +discord.py +numpy<2 prometheus-async==19.2.0 prometheus-client==0.15.0 pydot==1.4.2 @@ -11,4 +10,4 @@ toml==0.10.2 requests==2.28.1 python-dateutil==2.8.2 irc==20.1.0 -parsedatetime \ No newline at end of file +parsedatetime diff --git a/src/achievement.py b/src/achievement.py index 1e65ae1..4cd3b27 100644 --- a/src/achievement.py +++ b/src/achievement.py @@ -15,7 +15,7 @@ Achievement = collections.namedtuple("Achievement", ["name", "condition", "descr achievements = { "spectre_of_communism": Achievement("Containment Efforts Ongoing", "Refer to the 'spectre of communism' in a message.", "A spectre is haunting Europe. The spectre of communism. Containment efforts are ongoing and full containment is projected by 2036."), "test": Achievement("Test", "Test achievement. Obtained for testing.", "Great job, you ran the test command!"), - "spam": Achievement("beesbeesbeesbeesbeesbeesbeesbeesbees", "Send a long message containing the same thing repeatedly.", "You should probably not do this, nobody* likes spam!"), + "spam": Achievement("beesbeesbeesbeesbeesbeesbeesbeesbees", "Send a long message containing the same thing repeatedly.", "You should probably not do this; nobody* likes spam!"), "unicode_abuse": Achievement("Anomalous Unicode", "Send a high proportion of weird Unicode characters in a message.", "h̵͖̻̮̗̹̆͛͆̎ͮͤͫ͛ͦ̓̅ͤ́͢é͒ͧ̌̀ͪ̈͂̈́̉ͣ̅̿̄̌̋̿̽̚͏̛͏͚̯͉̟͇̼̹͎ͅa̠̹̘͎̫̜̞̩͖̟̟͍͇͈͍̝͕͛ͥ͊̾̈́ͩͯͩͭ̆̋͐͗̉͋̓̀͝v͎͖̜͎͔̞͚͉̺̞̘̥͖̝͚̺̍ͤ̌͂ͨ̃̅ͫ̿͛ͯ̓̉̆̎͊̀̚̕͟s̪̠̟̣̝̹̭̻̈́ͤ͗̏ͮ̂ͯ̈́̊ͩ̓̆̌̆͌̽̓̈́̚͢͞e̛̞̙̜̗̰͕͕͎̺͍̭̲̟̭̲̫̬͓ͯ̅̓̆̂̔̃͟r̷̛̮̮͇̳̳̾ͯͮͩ̏͂ͤ̿̽ͧ͒͋́̕ͅͅv̴̠͉̼̮̭̘ͪͯͦ͌́ͯ̒̃̀́̃͜͝ͅe̵̷̢͕̣̻̥̲͓̼͍̱͕̮̯̱̤̹̱̝̎̓̈́̿ͤ̔̍ͭͭ͐ͅŗ̔ͮͯ͂́͏̻͈̱ͅ ̣͇̼͊̄ͫ̆̍̄̀̀̓͊͐͋̌͘͠į̱͔̰̭̫̱̫̊ͪ̅ͥ̈́ͥ̐͌̅ͪ̅ͨ̎̀͘͝s̍͑̌̋̅͌͂ͨͬͯ̇͊҉̛̱̺͕̰͓̗̖̬͡͡ ̥̤̺̖̪̪́ͯͣ̏̅̈ͣ̿̀͠͠͞i̢̛̭̰̻͈̦̣̮̞̤̩̊̌̾͛ͭͦ̆ͮ̃̎ͪ̔ͬ͊̆͂ͫͅn̸̖͚̣̪̩̏ͥ̈́̅ͯ̔͆́ͦ͗͛͒̃̃ͫ͟͜͝͠ȩ̸͎̟̣̞͉̫̗̙̻̯͍̰̣̌ͪͨ͛̆̕͡v̙͙̲͕͔̦̣̺͔̖͉̜̲̩̈̿ͥ̎͊̈́̊ͯͯ͒ͭ̊̀͢i̪͈̣̱̞̥̰̟̣̩̼̻̪̳̤͇̻̹͉͗ͭ͆̆̎̀͑͑̆͋̏̏͊ͣͦ͆ͣ̈́̓͟͢ţ̵̘̫̯͓̻̗͕̘͙̯̞̪̪̲̤̬̜͕ͫ̄̌̓̎͌ͧ̔͟͢ͅa̸̧̭̲̯̳̔́͋̐͂̇ͪ̔̐́̚͢b͐̅̔ͭ͗̊̂̾̀̓ͭͭ͑ͤ̏̐̃ͩͬ҉̞̼̮̤̝̲̳͓̗̤̫̭̝̹̙͘͟͝ļ̷͈̭̖͓̜̬͔̻͔̀̎ͯ͗̐̽̏ͦ̊͗ͧ́͘ͅe̢͍̦̗̬̝̠͔̳̣̯̮̣̹͍͙̞̜ͣ̉͆̊̀̎ͦ͌̂̋̊ͨ͛́"), "rtfm": Achievement("RTFM", "Tell someone to read the documentation.", "Apparently, people won't do this without prompting half the time."), "error": Achievement("You broke it", "Cause an internal error in the bot", "I should probably fix this.") @@ -48,7 +48,7 @@ async def achieve(bot: commands.Bot, message: discord.Message, achievement): await bot.database.commit() logging.info("Awarded achievement %s to %s", achievement, message.author.name) -def setup(bot): +async def setup(bot): @bot.group(name="achievements", aliases=["ach", "achieve", "achievement"], brief="Achieve a wide variety of fun achievements!", help=f""" Do things and get arbitrary achievements for them! Note that due to reasons messages for achievements will not be shown except in opted-in servers, although achievements will be gained regardless. @@ -73,4 +73,4 @@ def setup(bot): if re.match("spect(re|er).{,20}(communism|☭)", content, re.IGNORECASE): await achieve(bot, msg, "spectre_of_communism") if re.match(r"^(.+)\1+$", content) and len(content) >= 1950: await achieve(bot, msg, "spam") if content_len > 30 and (len(re.findall("[\u0300-\u036f\U00040000-\U0010FFFF]", content)) / content_len) > 0.35: await achieve(bot, msg, "unicode_abuse") - if re.match("(RTFM|(read|look|view|use).{,8}(document|man(page|ual)s?|instruction))", content, re.IGNORECASE): await achieve(bot, msg, "rtfm") \ No newline at end of file + if re.match("(RTFM|(read|look|view|use).{,8}(document|man(page|ual)s?|instruction))", content, re.IGNORECASE): await achieve(bot, msg, "rtfm") diff --git a/src/commands.py b/src/commands.py index 165bb1f..9104f8b 100644 --- a/src/commands.py +++ b/src/commands.py @@ -101,7 +101,7 @@ class GeneralCommands(commands.Cog): await ctx.send(embed=util.error_embed(util.gen_codeblock(result), "Execution failed")) else: out = result - if flags.verbose: + if flags.verbose: debug_block = "\n" + util.gen_codeblock(f"""{debug}\nLanguage: {real_lang}""") out = out[:2000 - len(debug_block)] + debug_block else: @@ -195,5 +195,5 @@ AutoBotRobot is operated by gollark/osmarks. await ctx.send("\n".join(map(lambda x: f"{x[0]} x{x[1]}", results))) -def setup(bot): - bot.add_cog(GeneralCommands(bot)) +async def setup(bot): + await bot.add_cog(GeneralCommands(bot)) diff --git a/src/debug.py b/src/debug.py index 18a64f7..333f80b 100644 --- a/src/debug.py +++ b/src/debug.py @@ -6,7 +6,7 @@ from discord.ext import commands import util import eventbus -def setup(bot): +async def setup(bot): @bot.group(help="Debug/random messing around utilities. Owner-only.") @commands.check(util.admin_check) async def magic(ctx): @@ -76,6 +76,6 @@ def setup(bot): @magic.command(help="Reload extensions (all or the specified one).") async def reload_ext(ctx, ext="all"): if ext == "all": - for ext in util.extensions: bot.reload_extension(ext) - else: bot.reload_extension(ext) + for ext in util.extensions: await bot.reload_extension(ext) + else: await bot.reload_extension(ext) await ctx.send("Done!") diff --git a/src/esoserver.py b/src/esoserver.py index 789fac8..7c342e4 100644 --- a/src/esoserver.py +++ b/src/esoserver.py @@ -8,7 +8,7 @@ import metrics role_transfer_lock = asyncio.Lock() -def setup(bot): +async def setup(bot): @bot.listen() async def on_message(message): if message.guild and message.guild.id == util.config["esoserver"]["id"]: @@ -26,4 +26,4 @@ def setup(bot): await user.remove_roles(role, reason="untransfer unrole") await message.author.add_roles(role, reason="transfer role") metrics.role_transfers.inc() - return \ No newline at end of file + return diff --git a/src/heavserver.py b/src/heavserver.py index 46a9a7b..15787cb 100644 --- a/src/heavserver.py +++ b/src/heavserver.py @@ -5,11 +5,11 @@ import discord import metrics -def setup(bot): +async def setup(bot): @bot.listen() async def on_member_join(member): if member.guild and member.guild.id == util.config["heavserver"]["id"]: logging.info("%s (%d) joined heavserver", member.display_name, member.id) if member.bot: await member.add_roles(discord.utils.get(member.guild.roles, id=util.config["heavserver"]["quarantine_role"])) - await member.add_roles(discord.utils.get(member.guild.roles, id=random.choice(util.config["heavserver"]["moderator_roles"][:]))) \ No newline at end of file + await member.add_roles(discord.utils.get(member.guild.roles, id=random.choice(util.config["heavserver"]["moderator_roles"][:]))) diff --git a/src/irc_link.py b/src/irc_link.py index 11c5d4d..698e86f 100644 --- a/src/irc_link.py +++ b/src/irc_link.py @@ -113,10 +113,10 @@ async def initialize(): global unlisten unlisten = eventbus.add_listener(util.config["irc"]["name"], on_bridge_message) -def setup(bot): +async def setup(bot): asyncio.create_task(initialize()) -def teardown(bot=None): +async def teardown(bot=None): if global_conn: global_conn.planned_disconnection = True global_conn.disconnect() diff --git a/src/main.py b/src/main.py index e354fad..0611a18 100644 --- a/src/main.py +++ b/src/main.py @@ -74,7 +74,7 @@ async def on_command_error(ctx, err): @bot.check async def andrew_bad(ctx): - return ctx.message.author.id != 543131534685765673 + return ctx.message.author.id != 543131534685765673 or ctx.message.author.id != 739032871087374408 or ctx.message.author.id in config.get("bans", []) @bot.event async def on_ready(): @@ -108,7 +108,7 @@ async def run_bot(): await eventbus.initial_load(bot.database) for ext in util.extensions: logging.info("Loaded %s", ext) - bot.load_extension(ext) + await bot.load_extension(ext) asyncio.create_task(autogollark.run_bot()) await bot.start(config["token"]) diff --git a/src/reminders.py b/src/reminders.py index aae7f0c..0c11494 100644 --- a/src/reminders.py +++ b/src/reminders.py @@ -71,7 +71,7 @@ class Reminders(commands.Cog): await ctx.send(embed=util.error_embed("Invalid time (wrong format/too large months or years)")) return utc_time, local_time = util.in_timezone(time, tz) - id = (await self.bot.database.execute_insert("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)", + id = (await self.bot.database.execute_insert("INSERT INTO reminders (remind_timestamp, created_timestamp, reminder, expired, extra) VALUES (?, ?, ?, ?, ?)", (utc_time.timestamp(), now.timestamp(), reminder, 0, util.json_encode(extra_data))))["last_insert_rowid()"] await self.bot.database.commit() await ctx.send(f"Reminder scheduled for {util.format_time(local_time)} ({util.format_timedelta(now, utc_time)}).") @@ -178,10 +178,10 @@ class Reminders(commands.Cog): self.reminder_queue.pop(0) await self.fire_reminder(next_id) -def setup(bot): +async def setup(bot): cog = Reminders(bot) + await bot.add_cog(cog) asyncio.create_task(cog.init_reminders()) - bot.add_cog(cog) -def teardown(bot): - bot.rloop_task.cancel() \ No newline at end of file +async def teardown(bot): + bot.rloop_task.cancel() diff --git a/src/search.py b/src/search.py index 68f2020..4c40d83 100644 --- a/src/search.py +++ b/src/search.py @@ -18,7 +18,7 @@ class Parser(html.parser.HTMLParser): attrs = dict(attrs) if tag == "a" and attrs.get("class") == "result__a" and "https://duckduckgo.com/y.js?ad_provider" not in attrs["href"]: self.links.append(attrs["href"]) - + class Search(commands.Cog): def __init__(self, bot): self.bot = bot @@ -43,7 +43,7 @@ class Search(commands.Cog): return await ctx.send(p.links[0], reference=ctx.message) except IndexError: return await ctx.send("No results.", reference=ctx.message) - + async def wp_search(self, query): async with self.session.get("https://en.wikipedia.org/w/api.php", params={ "action": "query", "list": "search", "srsearch": query, "utf8": "1", "format": "json", "srlimit": 1 }) as resp: @@ -62,10 +62,10 @@ class Search(commands.Cog): return await self.wp_fetch(new_page, fallback=False) if page in self.wp_cache: return self.wp_cache[page] - if page in self.wp_search_cache: + if page in self.wp_search_cache: if self.wp_search_cache[page] is None: return None return await self.wp_fetch(self.wp_search_cache[page], fallback=False) - async with self.session.get("https://en.wikipedia.org/w/api.php", + async with self.session.get("https://en.wikipedia.org/w/api.php", params={ "action": "query", "format": "json", "titles": page, "prop": "extracts", "exintro": 1, "explaintext": 1 }) as resp: data = (await resp.json())["query"] if "-1" in data["pages"]: @@ -94,6 +94,6 @@ class Search(commands.Cog): if self.pool is not None: self.pool.shutdown() -def setup(bot): +async def setup(bot): cog = Search(bot) - bot.add_cog(cog) \ No newline at end of file + await bot.add_cog(cog) diff --git a/src/sentience.py b/src/sentience.py index 1b5fdbb..c95bc1a 100644 --- a/src/sentience.py +++ b/src/sentience.py @@ -1,16 +1,14 @@ -import asyncio -import argparse + import random -from numpy.random import default_rng -import re import aiohttp -import subprocess +from collections import defaultdict, deque import discord.ext.commands as commands import discord -from datetime import datetime +from datetime import datetime, timedelta, timezone from pathlib import Path +import asyncio +import logging -import tio import util cleaner = commands.clean_content() @@ -20,7 +18,11 @@ def clean(ctx, text): class Sentience(commands.Cog): def __init__(self, bot): self.bot = bot + self.timeouts = {} self.session = aiohttp.ClientSession() + self.autopraise_spontaneous_times = {} + self.autopraise_triggered_times = {} + self.praise_context_buffers = defaultdict(deque) async def serialize_history(self, ctx, n=20): PREFIXES = [ ctx.prefix + "ai", ctx.prefix + "ag", ctx.prefix + "autogollark", ctx.prefix + "gollark" ] @@ -52,11 +54,19 @@ class Sentience(commands.Cog): @commands.command(help="Highly advanced AI Assistant.") async def ai(self, ctx, *, query=None): + if timeout := self.timeouts.get(ctx.channel.id): + if timeout > datetime.now(): + return prompt = await self.serialize_history(ctx) - prompt.append(f'[{util.render_time(datetime.utcnow())}] {util.config["ai"]["own_name"]}:') + prompt.append(f'[{util.render_time(datetime.now(timezone.utc))}] {util.config["ai"]["own_name"]}:') generation = await util.generate(self.session, util.config["ai"]["prompt_start"] + "".join(prompt)) - if generation.strip(): - await ctx.send(generation.strip()) + assert generation, "backend failed" + generation = generation.strip() + if generation: + await ctx.send(generation) + if generation.endswith("/quit"): + await ctx.send("Disconnecting AI as requested.") + self.timeouts[ctx.channel.id] = datetime.now() + timedelta(seconds=1200) @commands.command(help="Search meme library.", aliases=["memes"]) async def meme(self, ctx, *, query=None): @@ -74,5 +84,46 @@ class Sentience(commands.Cog): o_files = [ discord.File(Path(util.config["memetics"]["memes_local"]) / util.meme_thumbnail(results, m)) for m in mat ] await ctx.send(files=o_files) -def setup(bot): - bot.add_cog(Sentience(bot)) + async def spontaneous_praise(self, target, delay): + await asyncio.sleep(delay) + del self.autopraise_spontaneous_times[target["user"]] + await self.praise(target, target["spontaneous_channel"], util.config["autopraise"]["spontaneous_prompt"]) + + async def praise(self, target, channel, prompt): + chan = self.bot.get_channel(channel) + if chan: + context = "\n".join(self.praise_context_buffers[target["user"]]) + praise_message = await util.generate_raw_chatcompletion(self.session, util.config["ai"]["chat_completions"], prompt + "\n" + context) + praise_message = praise_message.strip() + if praise_message and praise_message != util.config["autopraise"]["no_praise"]: + await chan.send(praise_message) + else: + # if no praise occurred, reset the timer + del self.autopraise_triggered_times[target["user"]] + + @commands.Cog.listener("on_message") + async def auto_praise(self, msg): + now = util.timestamp() + # if anyone uses this, rearrange to dict users → spec, for efficiency + for target in util.config["autopraise"]["targets"]: + if target["guild"] == msg.guild.id and target["user"] == msg.author.id: + if msg.channel.id in target["channels"]: + if msg.content and msg.content.strip(): self.praise_context_buffers[msg.author.id].append(f"{msg.author.name}: {msg.content.strip()}") + if len(self.praise_context_buffers[msg.author.id]) >= target["context_length"]: + self.praise_context_buffers[msg.author.id].popleft() + + # no spontaneous praise event within window: dispatch + if msg.author.id not in self.autopraise_spontaneous_times: + logging.info("Scheduling spontaneous praise for %d", msg.author.id) + spontaneous_praise_delay = random.expovariate(target["spontaneous_interval"] / 2) + target["spontaneous_interval"] / 2 + self.autopraise_spontaneous_times[msg.author.id] = now + spontaneous_praise_delay + asyncio.create_task(self.spontaneous_praise(target, spontaneous_praise_delay)) + + may_praise_at = self.autopraise_triggered_times.get(msg.author.id) + if may_praise_at is None or may_praise_at < now: + logging.info("Triggered praise event for %d", msg.author.id) + self.autopraise_triggered_times[msg.author.id] = now + target["triggered_interval"] + await self.praise(target, msg.channel.id, util.config["autopraise"]["triggered_prompt"]) + +async def setup(bot): + await bot.add_cog(Sentience(bot)) diff --git a/src/telephone.py b/src/telephone.py index e914735..fc370a4 100644 --- a/src/telephone.py +++ b/src/telephone.py @@ -135,7 +135,7 @@ class Telephone(commands.Cog): reply = (eventbus.AuthorInfo(replying_to.author.name, replying_to.author.id, str(replying_to.author.display_avatar.url), replying_to.author.bot), parse_formatting(self.bot, replying_to.content)) else: reply = (None, None) - msg = eventbus.Message(eventbus.AuthorInfo(msg.author.name, msg.author.id, str(msg.author.display_avatar.url), msg.author.bot), + msg = eventbus.Message(eventbus.AuthorInfo(msg.author.name, msg.author.id, str(msg.author.display_avatar.url), msg.author.bot), parse_formatting(self.bot, msg.content), ("discord", channel_id), msg.id, [ at for at in msg.attachments if not at.is_spoiler() ], reply=reply) await eventbus.push(msg) @@ -315,7 +315,7 @@ When you want to end a call, use hangup. return await ctx.send(embed=util.error_embed("Target channel no longer exists.")) _, call_message = await asyncio.gather( ctx.send(embed=util.info_embed("Outgoing call", f"Dialing {address}...")), - recv_channel.send(embed=util.info_embed("Incoming call", + recv_channel.send(embed=util.info_embed("Incoming call", f"Call from {originating_address}. Click :white_check_mark: to accept or :negative_squared_cross_mark: to decline.")) ) # add clickable reactions to it @@ -429,7 +429,7 @@ When you want to end a call, use hangup. finally: os.unlink(tmppath) -def setup(bot): +async def setup(bot): cog = Telephone(bot) - bot.add_cog(cog) + await bot.add_cog(cog) asyncio.create_task(cog.initial_load_webhooks()) diff --git a/src/userdata.py b/src/userdata.py index afd3436..ee3dfcc 100644 --- a/src/userdata.py +++ b/src/userdata.py @@ -87,5 +87,5 @@ class Userdata(commands.Cog): await self.bot.database.commit() await ctx.send(f"**{key}** deleted") -def setup(bot): - bot.add_cog(Userdata(bot)) \ No newline at end of file +async def setup(bot): + await bot.add_cog(Userdata(bot)) diff --git a/src/util.py b/src/util.py index cad7a89..9d80b68 100644 --- a/src/util.py +++ b/src/util.py @@ -378,7 +378,8 @@ async def generate(sess: aiohttp.ClientSession, prompt, stop=["\n"]): # high to low def sort_key(backend): failure_stats = last_failures[backend["url"]] - return (failure_stats.avoid_until is None or failure_stats.avoid_until < now), backend["priority"], -failure_stats.consecutive_failures + currently_ok = failure_stats.avoid_until is None or failure_stats.avoid_until < now + return currently_ok, -(not currently_ok and failure_stats.consecutive_failures), backend["priority"] backends = sorted(backends, key=sort_key, reverse=True) @@ -396,6 +397,16 @@ async def generate(sess: aiohttp.ClientSession, prompt, stop=["\n"]): failure_stats.avoid_until = now + datetime.timedelta(seconds=2 ** failure_stats.consecutive_failures) failure_stats.consecutive_failures += 1 +async def generate_raw_chatcompletion(sess: aiohttp.ClientSession, backend, prompt): + async with sess.post(backend["url"], json={ + "messages": [{"role": "user", "content": prompt}], + "client": "abr", + **backend.get("params", {}) + }, headers=backend.get("headers", {}), timeout=aiohttp.ClientTimeout(total=30)) as res: + data = await res.json() + print(data) + return data["choices"][0]["message"]["content"] + filesafe_charset = string.ascii_letters + string.digits + "-" TARGET_FORMAT = "jpegh" diff --git a/src/voice.py b/src/voice.py index 37e4426..6e93833 100644 --- a/src/voice.py +++ b/src/voice.py @@ -16,7 +16,7 @@ class HTTPSource(discord.AudioSource): def read(self): return next(self.packets, b"") def is_opus(self): return True -def setup(bot): +async def setup(bot): # experimental, thus limit to me only @bot.group() @commands.check(util.admin_check) @@ -43,11 +43,11 @@ def setup(bot): @radio.command() async def disconnect(ctx): - if ctx.guild.voice_client: + if ctx.guild.voice_client: ctx.guild.voice_client.stop() await ctx.guild.voice_client.disconnect() -def teardown(bot): +async def teardown(bot): for guild in bot.guilds: if guild.voice_client: - guild.voice_client.stop() \ No newline at end of file + guild.voice_client.stop()