diff --git a/config.example.toml b/config.example.toml index 5d052d7..f40b85f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -62,7 +62,8 @@ foo = "bar" # Redis configuration. If you are using the docker-compose setup, then you do not need to configure this. host = "redis" # the host[name or IP] of the redis server port = 6379 # self-explanatory -no_ping = false # disables the startup health-check. Not recommended to set to true. +# the no_ping option was removed after g+808D621F. All options in this section are now passed directly to redis.Redis(). +# See the python redis docs for more info. [responder] # Configures the auto-responder. diff --git a/src/cogs/ollama.py b/src/cogs/ollama.py index d7f144c..577d73e 100644 --- a/src/cogs/ollama.py +++ b/src/cogs/ollama.py @@ -265,10 +265,7 @@ class ChatHistory: def __init__(self): self._internal = {} self.log = logging.getLogger("jimmy.cogs.ollama.history") - no_ping = CONFIG["redis"].pop("no_ping", False) - self.redis = redis.Redis(**CONFIG["redis"]) - if no_ping is False: - assert self.redis.ping(), "Redis appears to be offline." + self.redis = redis.Redis(**CONFIG["redis"], db="jimmy-v2-ollama") def load_thread(self, thread_id: str): value: str = self.redis.get("threads:" + thread_id) diff --git a/src/cogs/starboard.py b/src/cogs/starboard.py new file mode 100644 index 0000000..6bcf9d8 --- /dev/null +++ b/src/cogs/starboard.py @@ -0,0 +1,216 @@ +import logging + +import discord +import json +from discord.ext import commands +from redis.asyncio import Redis + +from conf import CONFIG + + +class Starboard(commands.Cog): + DOWNVOTE_EMOJI = discord.PartialEmoji.from_str("\N{collision symbol}") + + def __init__(self, bot): + self.bot: commands.Bot = bot + self.config = CONFIG["starboard"] + self.emoji = discord.PartialEmoji.from_str(self.config.get("emoji", "\N{white medium star}")) + self.log = logging.getLogger("jimmy.cogs.starboard") + self.redis = Redis(**CONFIG["redis"], db="jimmy-v2-starboard") + + async def generate_starboard_embed(self, message: discord.Message) -> tuple[discord.Embed, int]: + """ + Generates an embed ready for a starboard message. + + :param message: The message to base off of. + :return: The created embed + """ + reactions: list[discord.Reaction] = [x for x in message.reactions if x.emoji == self.emoji] + downvote_reactions = [x for x in message.reactions if x.emoji == self.DOWNVOTE_EMOJI] + if not reactions: + # Nobody has added the star reaction. + star_count = 0 + else: + # Count the number of reactions + star_count = sum([x.count for x in reactions]) + + if downvote_reactions: + star_count -= sum([x.count for x in downvote_reactions]) + + if star_count >= 0: + star_emoji_count = (str(self.emoji) * star_count)[:10] + else: + star_emoji_count = str(self.DOWNVOTE_EMOJI) * star_count + + embed = discord.Embed( + colour=discord.Colour.gold(), + url=message.jump_url, + timestamp=message.created_at, + author=discord.EmbedAuthor( + message.author.display_name, + message.author.jump_url, + message.author.display_avatar.url + ), + fields=[ + discord.EmbedField( + name="Info", + value=f"[Stars: {star_emoji_count} ({star_count:,})]({message.jump_url})" + ) + ] + ) + if message.content: + embed.description = message.content + elif message.embeds: + for message_embed in message.embeds: + if message_embed.type != "rich": + if message_embed.type == "image": + if message_embed.thumbnail and message_embed.thumbnail.proxy_url: + embed.set_image(url=message_embed.thumbnail.proxy_url) + continue + if message_embed.description: + embed.description = message_embed.description + if not message.attachments and not embed.description: + raise ValueError("Message does not appear to contain any text, embeds, or attachments.") + + if message.attachments: + new_fields = [] + for n, attachment in reversed(tuple(enumerate(message.attachments, start=1))): + attachment: discord.Attachment + if attachment.size >= 1024 * 1024: + size = f"{attachment.size / 1024 / 1024:,.1f}MiB" + elif attachment.size >= 1024: + size = f"{attachment.size / 1024:,.1f}KiB" + else: + size = f"{attachment.size:,} bytes" + new_fields.append( + { + "name": "Attachment #%d:" % n, + "value": f"[{attachment.filename} ({size})]({attachment.url})", + "inline": True + } + ) + if attachment.content_type.startswith("image/"): + embed.set_image(url=attachment.url) + new_fields.reverse() + for field in new_fields: + embed.add_field(**field) + # This whacky reverse -> perform -> reverse basically just means we can set the first image/* + # attachment as the image. + + return embed, star_count + + async def get_or_fetch_message(self, channel_id: int, message_id: int) -> discord.Message: + """ + Fetches a message from cache where possible, falling back to the API. + """ + message: discord.Message | None = discord.utils.get(self.bot.cached_messages, id=message_id) + if not message: + message: discord.Message = await self.bot.get_channel(channel_id).fetch_message(message_id) + return message + + @commands.Cog.listener("on_raw_reaction_add") + @commands.Cog.listener("on_raw_reaction_remove") + async def handle_raw_reaction(self, payload: discord.RawReactionActionEvent): + if self.config.get("enabled", True) is not True: + self.log.debug("Starboard is disabled.") + return + if not payload.guild_id: + # A direct message - has no starboard + return + elif payload.emoji != self.emoji: + # Was not a star emoji, ignore. + return + + if not await self.redis.ping(): + # Could not contact redis, not much point trying anything else without the database + return self.log.critical("Failed to contact redis (ping failed)!") + + guild: discord.Guild = self.bot.get_guild(payload.guild_id) + starboard_channel: discord.TextChannel | None = discord.utils.get( + guild.text_channels, + name="starboard" + ) + if not starboard_channel: + self.log.warning("Could not find starboard channel in %s (%d)", guild, guild.id) + return + + message = await self.get_or_fetch_message(payload.channel_id, payload.message_id) + + if payload.user_id == message.author.id: + self.log.info("%s tried to star their own message.", message.author) + return await message.reply( + "You can't star your own message you pretentious dick. Go outside, %s." % message.author.mention, + delete_after=30 + ) + + embed, star_count = await self.generate_starboard_embed(message) + + data = await self.redis.get(str(message.id)) + if data is None: + if star_count <= 0: + self.log.info("%s has no stars, not processing.", message.jump_url) + return + data = { + "source_guild_id": payload.guild_id, + "source_channel_id": payload.channel_id, + "source_message_id": payload.message_id, + "history": [], + "starboard_channel_id": starboard_channel, + "starboard_message_id": None + } + if not starboard_channel.can_send(embed): + self.log.warning( + "Cannot send starboard messages in %d, %d (#%s @ %s)", + starboard_channel.id, + payload.guild_id, + starboard_channel.name, + guild.name + ) + return + + starboard_message = await starboard_channel.send( + embed=embed, + silent=True + ) + data["starboard_message_id"] = starboard_message.id + await self.redis.set(str(message.id), json.dumps(data)) + else: + try: + starboard_message = await self.get_or_fetch_message( + data["starboard_channel_id"], + data["starboard_message_id"] + ) + except discord.NotFound: + if star_count <= 0: + return + starboard_message = await starboard_channel.send( + embed=embed, + silent=True + ) + data["starboard_message_id"] = starboard_message.id + data["starboard_channel_id"] = starboard_message.channel.id + await self.redis.set(str(message.id), json.dumps(data)) + else: + if star_count <= 0: + await starboard_message.delete(delay=0.1, reason="Lost all stars.") + self.log.info( + "Deleted message %s in %s, %s - lost all stars", + starboard_message.id, + starboard_message.channel.name, + starboard_message.guild.name + ) + elif starboard_message.embeds[0] != embed: + await starboard_message.edit(embed=embed) + + @commands.message_command(name="Preview Starboard Message") + async def preview_starboard_message(self, ctx: discord.ApplicationContext, message: discord.Message): + embed, stars = await self.generate_starboard_embed(message) + data = await self.redis.get(str(message.id)) + return await ctx.respond( + f"```json\n{json.dumps(data, indent=4)}\n```\nStars: {stars:,}", + embed=embed + ) + + +def setup(bot): + bot.add_cog(Starboard(bot)) diff --git a/src/conf.py b/src/conf.py index 2a0ec68..3018339 100644 --- a/src/conf.py +++ b/src/conf.py @@ -44,6 +44,14 @@ CONFIG.setdefault("network", {}) CONFIG.setdefault("quote_a", {"channel": None}) CONFIG.setdefault("redis", {"host": "redis", "port": 6379, "decode_responses": True}) +if CONFIG["redis"].pop("db", None) is not None: + log.warning("`redis.db` cannot be manually specified, each cog that uses redis has its own db value! Value ignored") + +if CONFIG["redis"].pop("no_ping", None) is not None: + log.warning("`redis.no_ping` was deprecated after 808D621F. Ping is now always mandatory.") + +CONFIG["redis"]["decode_responses"] = True + if _t := os.getenv("JIMMY_TOKEN"): log.info("Overriding config with token from $JIMMY_TOKEN.") CONFIG["jimmy"]["token"] = _t