From d72fb3e7e5f7653dc9ac2c61ef1e8d27d26bf1d2 Mon Sep 17 00:00:00 2001 From: hencaric Date: Sat, 14 Mar 2026 22:54:52 -0400 Subject: [PATCH] Sweeping updates --- .gitignore | 1 + LICENSE | 2 +- README.md | 6 +- leaderboard.json | 64 ++ pyproject.toml | 4 +- scnewsbot/Retired/rstarcitizen.py | 30 - scnewsbot/extensions/announcements.py | 840 ++++++++------------------ scnewsbot/extensions/leaderboard.py | 248 ++++++++ scnewsbot/extensions/templates.py | 23 +- 9 files changed, 576 insertions(+), 642 deletions(-) create mode 100644 leaderboard.json delete mode 100644 scnewsbot/Retired/rstarcitizen.py create mode 100644 scnewsbot/extensions/leaderboard.py diff --git a/.gitignore b/.gitignore index 4ff7bce..4a81518 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # scnewsbot config.toml +rstarcitizen.py # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/LICENSE b/LICENSE index 7871a52..3b8fb50 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 mudkip +Copyright (c) 2026 hencaric Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index b0241bc..1cce912 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,12 @@ The production version of the SC News Bot used by the r/StarCitizen Discord serv - Join https://discord.gg/starcitizen for the best Star Citizen community on the internet ## Version -1.0.4 +3.0.2 ## Authors - Ian (hencaric) https://github.com/hencaric -- mudkip -https://github.com/mudkipdev + +Existing code is inspired by the 1.0 version produced by mudkip ![](https://cdn.discordapp.com/attachments/1113146864804573285/1214590384001515560/41bannerEisenlowe.png?ex=65f9aa71&is=65e73571&hm=1cd99b04925a778d05b6d1cd1ae804af083760b0c11cc7ae96d44491b1fcb3ff&) diff --git a/leaderboard.json b/leaderboard.json new file mode 100644 index 0000000..c4f670e --- /dev/null +++ b/leaderboard.json @@ -0,0 +1,64 @@ +{ + "288522211164160010": { + "count": 3, + "last_post": "2026-03-03T18:22:49.689738", + "posts": [ + "2026-03-03T18:22:49.689738", + "2026-03-03T18:22:49.689738", + "2026-03-03T18:22:49.689738" + ] + }, + "251146366980456448": { + "count": 17, + "last_post": "2026-03-12T21:08:34.082167", + "posts": [ + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167", + "2026-03-12T21:08:34.082167" + ] + }, + "295679494096814080": { + "count": 17, + "last_post": "2026-03-13T02:14:42.801894", + "posts": [ + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894", + "2026-03-13T02:14:42.801894" + ] + }, + "138019022766604288": { + "count": 1, + "last_post": "2026-03-05T01:00:39.004903", + "posts": [ + "2026-03-05T01:00:39.004903" + ] + } +} diff --git a/pyproject.toml b/pyproject.toml index ec31bc4..dbb9b4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [tool.poetry] name = "scnewsbot" -version = "1.0.3" +version = "3.0.1" description = "A bot for the r/starcitizen Discord server." -authors = ["mudkipdev "] +authors = ["hencaric"] license = "MIT" readme = "README.md" packages = [ diff --git a/scnewsbot/Retired/rstarcitizen.py b/scnewsbot/Retired/rstarcitizen.py deleted file mode 100644 index 604bb39..0000000 --- a/scnewsbot/Retired/rstarcitizen.py +++ /dev/null @@ -1,30 +0,0 @@ -import discord -import asyncio -from discord.ext import commands, tasks -from utils import Config -from typing import Union - -class RStarCitizen(commands.Cog): - def __init__(self, bot): - self.bot = bot - self._last_member = None - - @commands.command(description='Modmail Close Message') - async def mmc(self, ctx): - await ctx.reply("```=aclose Situation resolved, please only reply if there is something else.```") - - @tasks.loop(hours=1) - async def update_member_count(self): - guild = self.bot.get_guild(82210263440306176) # Replace YOUR_GUILD_ID with your actual guild ID - channel = guild.get_channel(1223778123472965755) # Replace YOUR_CHANNEL_ID with your actual channel ID - - member_count = len(guild.members) - await channel.edit(name=f'Members: {member_count}') - - @commands.Cog.listener() - async def on_ready(self): - print('MemberCount cog is ready.') - self.update_member_count.start() - -async def setup(bot): - await bot.add_cog(RStarCitizen(bot)) \ No newline at end of file diff --git a/scnewsbot/extensions/announcements.py b/scnewsbot/extensions/announcements.py index 0d7c32b..0eb5e1e 100644 --- a/scnewsbot/extensions/announcements.py +++ b/scnewsbot/extensions/announcements.py @@ -1,637 +1,289 @@ from __future__ import annotations - -from typing import Any, Optional, Callable, Awaitable -from dataclasses import dataclass -from discord.ext import commands -from discord.ui import Button import discord -from utils import can_publish_announcements +from discord.ext import commands +from datetime import datetime +from extensions import leaderboard -ANNOUNCEMENT_BUILDER_TIMEOUT = 1200 -ANNOUNCEMENT_EMOJI = "<:upvote:354233015842635776>" -DEFAULT_IMAGE_URL = "https://cdn.discordapp.com/attachments/611922107345141760/1348673800874754088/Polaris_over_Yela_bright.png?ex=67eff5db&is=67eea45b&hm=f851f8aa07e09ab2c506d6dc395ce91a2db623b0a0c6c090eb19360b0c464c7e&" -INSTRUCTIONS = """\ -1. Title should not use any formatting. -2. "Video" should only be used for YouTube or video links with pretty embeds. -3. URL should be used for any regular link such as a comm-link. -4. In the description box, use `-` and it will replace it with `➣`, use `+` and it will replace it with `✦` preceeded by three spaces. -5. Use the `&ids` commands to get the channel and role IDs. -6. Do not ping for every post if there are consecutive posts in the same channel, instead ping only on the final post and provide an overall preview.\n7. **ALWAYS** include a ping preview, you can find these using `&previews`. -8. Always select publish unless explicitly not needed (server only announcements).\ -""" +# CONFIG +DEFAULT_IMAGE_URL = "https://cdn.discordapp.com/attachments/611922107345141760/1348673800874754088/Polaris_over_Yela_bright.png" +LOGGING_CHANNEL_IDS = [1091876261938343986] +CHANNEL_OPTIONS = [ + ("Server News", 1113146864804573285), + ("Community Feed", 1388237591845011516), + ("SC News", 569635458183856149), + ("General News", 803341100618219540), + ("Patch Notes", 585952222853201941), + ("Testing", 1062905729532571719), +] -async def get_follow_up_message( - announcement: discord.Message, /, *, limit: int -) -> Optional[discord.Message]: - index = 0 +PING_ROLE_OPTIONS = [ + ("Server News", 1113152142300156004), + ("Community Feed", 1402452172595265596), + ("SC News", 620025828079697920), + ("General News", 803343410794594385), + ("Patch Notes", 620025894559547412), + ("Evocati Patch Notes", 1305975151858552862), + ("MOTD", 1310721100392435797), + ("Testing", 1473004437034242260), +] - if limit == 0: - return announcement - elif limit < 0: - async for message in announcement.channel.history( - before=announcement.created_at - ): - index -= 1 - - if message.author.id == announcement._state.user.id and index == limit: - return message - elif limit > 0: - async for message in announcement.channel.history( - after=announcement.created_at - ): - index += 1 - - if message.author.id == announcement._state.user.id and index == limit: - return message - - -def is_announcement(announcement: discord.Message, /) -> bool: - return ( - announcement - and announcement.author == announcement.guild.me - and len(announcement.embeds) == 1 - and not announcement.content - and not announcement.components - ) - - -def is_video_message(announcement: discord.Message, /) -> bool: - return ( - announcement - and announcement.author == announcement.guild.me - and len(announcement.embeds) == 1 - and not announcement.mentions - and announcement.content - ) - - -def reformat_description(description: str) -> str: - split_description = description.split("\n") - - for index, line in enumerate(split_description): - if line.startswith("-"): - split_description[index] = "➣" + line[1:] - elif line.startswith("+"): - split_description[index] = "ㅤ✦" + line[1:] - - return "\n".join(split_description) - - -class AnnouncementCog(commands.Cog, name="Announcements"): - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - @commands.check(can_publish_announcements) - @commands.command(brief="Gives you instructions for using the announcement system.") - async def instructions(self, ctx: commands.Context) -> None: - embed = discord.Embed( - color=self.bot.config.embed_color, - title="Instructions", - description=INSTRUCTIONS, - ) - await ctx.reply(embed=embed) - - @commands.guild_only() - @commands.group( - brief="Commands relating to the r/starcitizen Discord news system.", - aliases=["announcement", "news", "embed"], - invoke_without_command=True, - ) - async def announcements(self, ctx: commands.Context) -> None: - await ctx.send_help(ctx.command) - - @commands.check(can_publish_announcements) - @announcements.command( - aliases=["post"], brief="Creates and sends a new announcement." - ) - async def create(self, ctx: commands.Context) -> None: - announcement_builder = AnnouncementBuilder(owner=ctx.author) - embed = await announcement_builder.get_embed(bot=self.bot) - await ctx.send( - content=announcement_builder.announcement.video_url, - embed=embed, - view=announcement_builder.view, - ) - - @commands.check(can_publish_announcements) - @announcements.command(brief="Edits an existing announcement.") - async def edit(self, ctx: commands.Context, message: discord.Message) -> None: - announcement = await Announcement.from_message(message, bot=self.bot) - announcement_builder = AnnouncementBuilder( - edit_message=message, edit_announcement=announcement, owner=ctx.author - ) - embed = await announcement_builder.get_embed(bot=self.bot) - await ctx.send( - content=announcement_builder.announcement.video_url, - embed=embed, - view=announcement_builder.view, - ) - - @commands.check(can_publish_announcements) - @announcements.command(brief="Deletes an announcement.") - async def delete(self, ctx: commands.Context, message: discord.Message) -> None: - if not is_announcement(message): - await ctx.reply("That is not an announcement!") - return - - video_message = await get_follow_up_message(message, limit=-1) - ping_message = await get_follow_up_message(message, limit=1) - - if is_video_message(video_message): - await video_message.delete() - if ping_message: - await ping_message.delete() - - await message.delete() - await ctx.reply("Deleted that announcement. 👌") - - @commands.Cog.listener() - async def on_command_error( - self, ctx: commands.Context, error: commands.CommandError - ) -> None: - if isinstance(error, commands.MessageNotFound): - await ctx.send("Could not find that message.") - else: - raise error - - -class InvalidAnnouncementException(Exception): - pass - - -@dataclass -class Option: - id: str - name: str - directions: Optional[str] = None - row: int = 0 - is_long: bool = False +LEADERBOARD_CHANNEL_IDS = { + 1113146864804573285, + 1388237591845011516, + 569635458183856149, + 803341100618219540, + 585952222853201941, +} +EMBED_COLOR = discord.Color.blurple() +TIMEOUT = 900 +# DATA MODEL class Announcement: def __init__( self, - *, - title: str = "Announcement", - url: Optional[str] = None, - description: Optional[str] = None, - video_url: Optional[str] = None, - image_url: Optional[str] = DEFAULT_IMAGE_URL, - channel: Optional[discord.abc.Messageable] = None, - ping: Optional[discord.Role] = None, - ping_preview: Optional[str] = None, - author_id: Optional[int] = None, - is_anonymous: bool = False, - will_notify: bool = False, - ) -> None: + title: str = "", + description: str = "", + url: str | None = None, + image_url: str | None = None, + video_url: str | None = None, + channel: discord.TextChannel | None = None, + ping: discord.Role | None = None, + ping_preview: str | None = None, + publish: bool = False, + ): self.title = title - self.url = url self.description = description + self.url = url + self.image_url = image_url or DEFAULT_IMAGE_URL self.video_url = video_url - self.image_url = image_url self.channel = channel self.ping = ping self.ping_preview = ping_preview - - self.author_id = author_id - self.is_anonymous = is_anonymous - self.will_notify = will_notify - - async def set_option( - self, option: Option, value: Any, *, guild: discord.Guild - ) -> bool: - if option is None: - setattr(self, option.id, converted_value) - return True - - if option.id == "description": - converted_value = reformat_description(value) - elif option.id == "channel": - if value.startswith("#"): - value = value[1:] - - converted_value = discord.utils.find( - lambda channel: channel.name.lower() == value.lower(), - guild.text_channels, - ) - if not converted_value and value.isnumeric(): - converted_value = guild.get_channel(int(value)) - elif option.id == "ping": - converted_value = discord.utils.find( - lambda role: role.name.lower() == value.lower(), guild.roles - ) - if not converted_value and value.isnumeric(): - converted_value = guild.get_role(int(value)) - else: - if option.id == "video_url" and self.image_url == DEFAULT_IMAGE_URL: - self.image_url = None - - converted_value = value - - if converted_value is None: - return False - - setattr(self, option.id, converted_value) - return True - - async def get_embed( - self, *, bot: commands.Bot, show_author: bool = False - ) -> discord.Embed: - embed = discord.Embed( - color=bot.config.embed_color, - title=self.title, - description=self.description, - ) - embed.set_image(url=self.image_url) - - if self.url and self.description: - embed.description = f"{self.url}\n\n" + embed.description - elif self.url: - embed.description = self.url - - if not self.is_anonymous or show_author: - if self.author_id: - author = await bot.fetch_user(self.author_id) - embed.set_footer(text=f"This post was written by {author}") - else: - embed.set_footer(text="Unknown Author") - - return embed + self.publish = publish @classmethod - async def from_message( - cls, message: discord.Message, /, *, bot: commands.Bot - ) -> Any: - if not is_announcement(message): - raise InvalidAnnouncementException("That is not an announcement!") - return - + def from_message(cls, message: discord.Message): embed = message.embeds[0] - author = None - - if embed.footer.text: - parsed_footer = embed.footer.text.replace("This post was written by ", "") - author = discord.utils.find( - lambda member: str(member) == parsed_footer, message.guild.members - ) - - ping = None - ping_preview = None - ping_message = await get_follow_up_message(message, limit=1) - - if ping_message: - role_converter = commands.RoleConverter() - split_message = ping_message.content.split(" - ") - ping = await role_converter.convert( - await bot.get_context(ping_message), split_message[0] - ) - - if len(split_message) == 2: - ping_preview = split_message[1] - - url = "" - description = embed.description - video_message = await get_follow_up_message(message, limit=-1) - - if not is_video_message(video_message): - video_message = None - - if embed.description is not None: - content = embed.description - - if "\n\n" in embed.description: - url = embed.description.split("\n\n")[0] + "\n\n" - content = embed.description.split("\n\n")[1:] - - description = url + "\n".join(content) - return cls( - title=embed.title, - url=url or None, - description=description, - video_url=video_message.content if video_message else None, - image_url=embed.image.url, + title=embed.title or "", + description=embed.description or "", + url=embed.url, + image_url=embed.image.url if embed.image else DEFAULT_IMAGE_URL, channel=message.channel, - ping=ping, - ping_preview=ping_preview, - author_id=author.id if author else None, - is_anonymous=embed.author is None, - will_notify=False, ) - -class AnnouncementBuilder: - def __init__( - self, - *, - edit_announcement: Optional[Announcement] = None, - edit_message: Optional[discord.Message] = None, - owner: discord.User = None, - ) -> None: - self.announcement = edit_announcement or Announcement(author_id=owner.id) - self.message = edit_message - self.edit = edit_announcement is not None - self.view = AnnouncementBuilderView(self) - self.owner = owner - - _items = self.view.children - self.view.clear_items() - - self.options: list[Option] = [] - self.add_option(Option(id="title", name="Title")) - self.add_option(Option(id="url", name="URL")) - self.add_option(Option(id="description", name="Description", is_long=True)) - self.add_option(Option(id="video_url", name="Video")) - self.add_option(Option(id="image_url", name="Image")) - self.add_option(Option(id="channel", name="Channel", row=1)) - self.add_option(Option(id="ping", name="Ping", row=1)) - self.add_option(Option(id="ping_preview", name="Ping Preview", row=1)) - - for item in _items: - if ( - isinstance(item, discord.ui.Button) - and self.edit - and item.label == "Post" - ): - item.label = "Edit" - - self.view.add_item(item) - - async def get_embed(self, bot: commands.Bot) -> discord.Embed: - return await self.announcement.get_embed(bot=bot) - - def add_option(self, option: Option, /) -> None: - button = OptionButton( - self, custom_id=option.id, label=option.name, row=option.row + def embed(self) -> discord.Embed: + e = discord.Embed( + title=self.title or None, + description=self.description or None, + url=self.url, + color=EMBED_COLOR, ) - self.options.append(option) - self.view.add_item(button) + if self.image_url: + e.set_image(url=self.image_url) + return e +# MODAL +class TextModal(discord.ui.Modal): + def __init__(self, builder, field: str, label: str, long: bool = False): + super().__init__(title=f"Edit {label}") + self.builder = builder + self.field = field + default_value = getattr(builder.announcement, field) or "" + self.input = discord.ui.TextInput( + label=label, + default=default_value, + style=discord.TextStyle.long if long else discord.TextStyle.short, + required=False, + ) + self.add_item(self.input) -class AnnouncementBuilderView(discord.ui.View): - def __init__(self, announcement_builder: AnnouncementBuilder, /) -> None: - super().__init__(timeout=ANNOUNCEMENT_BUILDER_TIMEOUT) - self.announcement_builder = announcement_builder - - def has_permission(self, user: discord.User) -> bool: - return user == self.announcement_builder.owner - - async def update(self, interaction: discord.Interaction, /) -> None: - if ( - self.announcement_builder.announcement.channel - and self.announcement_builder.announcement.channel.type - is discord.ChannelType.news - ): - self.toggle_notification.disabled = False - else: - self.announcement_builder.announcement.will_notify = False - self.toggle_notification.style = discord.ButtonStyle.gray - self.toggle_notification.disabled = True - + async def on_submit(self, interaction: discord.Interaction): + setattr(self.builder.announcement, self.field, self.input.value) + self.builder.update_field_buttons() await interaction.response.edit_message( - content=self.announcement_builder.announcement.video_url, - embed=await self.announcement_builder.get_embed(bot=interaction.client), - view=self, - allowed_mentions=discord.AllowedMentions.none(), + embed=self.builder.announcement.embed(), + view=self.builder.view, ) - async def button_callback( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - if not self.has_permission(interaction.user): - await interaction.response.send_message( - "You cannot use this menu.", ephemeral=True - ) - return +# SELECTS +class ChannelSelect(discord.ui.Select): + def __init__(self, builder): + self.builder = builder + options = [discord.SelectOption(label=name, value=str(cid)) for name, cid in CHANNEL_OPTIONS] + super().__init__(placeholder="Select Channel", options=options, row=0) + async def callback(self, interaction: discord.Interaction): + cid = int(self.values[0]) + self.builder.announcement.channel = interaction.guild.get_channel(cid) + self.placeholder = next((name for name, id in CHANNEL_OPTIONS if id == cid), "Select Channel") + await interaction.response.edit_message(embed=self.builder.announcement.embed(), view=self.builder.view) + +class PingSelect(discord.ui.Select): + def __init__(self, builder): + self.builder = builder + options = [discord.SelectOption(label=name, value=str(rid)) for name, rid in PING_ROLE_OPTIONS] + super().__init__(placeholder="Select Ping Role", options=options, row=1) + + async def callback(self, interaction: discord.Interaction): + rid = int(self.values[0]) + self.builder.announcement.ping = interaction.guild.get_role(rid) + self.placeholder = next((name for name, id in PING_ROLE_OPTIONS if id == rid), "Select Ping Role") + await interaction.response.edit_message(embed=self.builder.announcement.embed(), view=self.builder.view) + +# FIELD BUTTON +class FieldButton(discord.ui.Button): + def __init__(self, builder, field, label, row, style): + super().__init__(label=label, row=row, style=style) + self.builder = builder + self.field = field + + async def callback(self, interaction: discord.Interaction): await interaction.response.send_modal( - ChangeOptionModal( - announcement_builder=self.announcement_builder, - option=discord.utils.get( - self.announcement_builder.options, id=button.custom_id - ), - ) + TextModal(self.builder, self.field, self.label, long=self.field=="description") ) - @discord.ui.button( - custom_id="cancel", label="Cancel", style=discord.ButtonStyle.danger, row=2 - ) - async def cancel( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - if not self.has_permission(interaction.user): - await interaction.response.send_message( - "You cannot use this menu.", ephemeral=True - ) +# CANCEL / POST / PUBLISH BUTTONS +class PublishButton(discord.ui.Button): + def __init__(self, builder): + label = "Published: ✅" if builder.announcement.publish else "Published: ❌" + super().__init__(label=label, style=discord.ButtonStyle.secondary, row=3) + self.builder = builder + + async def callback(self, interaction: discord.Interaction): + self.builder.announcement.publish = not self.builder.announcement.publish + self.label = "Published: ✅" if self.builder.announcement.publish else "Published: ❌" + await interaction.response.edit_message(embed=self.builder.announcement.embed(), view=self.builder.view) + +class CancelButton(discord.ui.Button): + def __init__(self, builder, editing=False): + super().__init__(label="Cancel", style=discord.ButtonStyle.danger, row=4) + self.builder = builder + self.editing = editing + + async def callback(self, interaction: discord.Interaction): + await interaction.message.delete() + msg_text = "Announcement editing cancelled." if self.editing else "Announcement creation cancelled." + await interaction.response.send_message(msg_text, ephemeral=True) + self.view.stop() + +class PostButton(discord.ui.Button): + def __init__(self, builder, editing=False): + label = "Edit" if editing else "Post" + super().__init__(label=label, style=discord.ButtonStyle.blurple, row=4) + self.builder = builder + self.editing = editing + + async def callback(self, interaction: discord.Interaction): + ann = self.builder.announcement + if not ann.channel: + await interaction.response.send_message("Select a channel first.", ephemeral=True) return - self.stop() + if self.editing and self.builder.target: + msg = self.builder.target + await msg.edit(embed=ann.embed()) + else: + msg = await ann.channel.send(embed=ann.embed()) + + if ann.publish and isinstance(ann.channel, discord.TextChannel) and ann.channel.is_news(): + await msg.publish() + + if ann.video_url: + await ann.channel.send(ann.video_url) + + ping_msg = "" + if ann.ping: + ping_msg += f"{ann.ping.mention}" + if ann.ping_preview: + ping_msg += f" - {ann.ping_preview}" + if ping_msg: + await ann.channel.send(ping_msg) + + for cid in LOGGING_CHANNEL_IDS: + ch = self.builder.ctx.guild.get_channel(cid) + if ch: + await ch.send(embed=ann.embed()) + + if ann.channel.id in LEADERBOARD_CHANNEL_IDS: + leaderboard.record_announcement_post(interaction.user.id) await interaction.response.send_message( - "Cancelled editing this announcement." - if self.announcement_builder.edit - else "Cancelled posting this announcement." + "The announcement has been posted! \nhttps://i.postimg.cc/J48Vk8my/meme-8-1.gif" + if not self.editing else "Announcement edited!", + ephemeral=False ) - @discord.ui.button(custom_id="anonymous", label="Anonymous?", row=2) - async def toggle_anonymous( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - if not self.has_permission(interaction.user): - await interaction.response.send_message( - "You cannot use this menu.", ephemeral=True - ) + self.view.stop() + +# VIEW +class BuilderView(discord.ui.View): + def __init__(self, builder, editing=False): + super().__init__(timeout=TIMEOUT) + self.builder = builder + self.editing = editing + + self.add_item(ChannelSelect(builder)) + self.add_item(PingSelect(builder)) + self.update_field_buttons() + self.add_item(PublishButton(builder)) + self.add_item(CancelButton(builder, editing=editing)) + self.add_item(PostButton(builder, editing=editing)) + + def update_field_buttons(self): + field_buttons = [item for item in self.children if isinstance(item, FieldButton)] + for btn in field_buttons: + self.remove_item(btn) + + for field, label, row, long in [ + ("title", "Title", 2, False), + ("description", "Description", 2, True), + ("url", "URL", 2, False), + ("image_url", "Image", 3, False), + ("video_url", "Video", 3, False), + ("ping_preview", "Ping Preview", 3, False), + ]: + style = discord.ButtonStyle.success if getattr(self.builder.announcement, field) else discord.ButtonStyle.gray + self.add_item(FieldButton(self.builder, field, label, row, style)) + +# BUILDER +class Builder: + def __init__(self, ctx, announcement, target=None, editing=False): + self.ctx = ctx + self.announcement = announcement + self.target = target + self.editing = editing + self.view = BuilderView(self, editing=editing) + + def update_field_buttons(self): + self.view.update_field_buttons() + + async def start(self): + await self.ctx.send(embed=self.announcement.embed(), view=self.view) + +# COG +class Announcements(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.group(invoke_without_command=True) + async def embed(self, ctx): + await ctx.send_help(ctx.command) + + @embed.command() + async def create(self, ctx): + await Builder(ctx, Announcement(channel=ctx.channel)).start() + + @embed.command() + async def edit(self, ctx, message: discord.Message): + if not message.embeds: + await ctx.reply("That message has no embed.") return + await Builder(ctx, Announcement.from_message(message), target=message, editing=True).start() - self.announcement_builder.announcement.is_anonymous = ( - not self.announcement_builder.announcement.is_anonymous - ) - if self.announcement_builder.announcement.is_anonymous: - button.style = discord.ButtonStyle.green - else: - button.style = discord.ButtonStyle.gray +# SETUP +async def setup(bot: commands.Bot): + await bot.add_cog(Announcements(bot)) - await self.update(interaction) - - @discord.ui.button( - custom_id="notification", label="Published?", row=2, disabled=True - ) - async def toggle_notification( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - if not self.has_permission(interaction.user): - await interaction.response.send_message( - "You cannot use this menu.", ephemeral=True - ) - return - - self.announcement_builder.announcement.will_notify = ( - not self.announcement_builder.announcement.will_notify - ) - if self.announcement_builder.announcement.will_notify: - button.style = discord.ButtonStyle.green - else: - button.style = discord.ButtonStyle.gray - - await self.update(interaction) - - @discord.ui.button( - custom_id="publish", label="Post", style=discord.ButtonStyle.blurple, row=2 - ) - async def publish( - self, interaction: discord.Interaction, button: discord.ui.Button - ) -> None: - if not self.has_permission(interaction.user): - await interaction.response.send_message( - "You cannot use this menu.", ephemeral=True - ) - return - - announcement = self.announcement_builder.announcement - role = announcement.ping - allowed_mentions = ( - discord.AllowedMentions(everyone=False, users=False, roles=[role]) - if role - else discord.AllowedMentions.none() - ) - - if self.announcement_builder.edit: - await interaction.response.send_message( - f"Your announcement was edited! 🎉" - + f" ({self.announcement_builder.message.jump_url})" - ) - self.stop() - - embed = await self.announcement_builder.get_embed(bot=interaction.client) - embed.remove_footer() - - await self.announcement_builder.message.edit(embed=embed) - video_message = await get_follow_up_message( - self.announcement_builder.message, limit=-1 - ) - - if is_video_message(video_message): - await video_message.edit( - content=self.announcement_builder.announcement.video_url - ) - - return - - if not announcement.channel: - await interaction.response.send_message( - "You must have a channel selected!", ephemeral=True - ) - return - - if announcement.ping_preview and not announcement.ping: - await interaction.response.send_message( - "You cannot have a ping preview selected without a ping!", - ephemeral=True, - ) - return - - self.stop() - - bot = interaction.client - video_message = None - if announcement.video_url: - video_message = await announcement.channel.send(announcement.video_url) - - message = await announcement.channel.send( - embed=await announcement.get_embed(bot=bot), - ) - await message.add_reaction(ANNOUNCEMENT_EMOJI) - - await interaction.response.send_message( - f"Your announcement was posted! 🎉 ({message.jump_url})" - + "\nhttps://i.postimg.cc/J48Vk8my/meme-8-1.gif" - + "\n<@288522211164160010> tweet this shit brotha" - ) - - for channel_id in bot.config.repost_channels: - repost_channel = await bot.fetch_channel(channel_id) - - if announcement.video_url: - await repost_channel.send(announcement.video_url) - - await repost_channel.send( - embed=await announcement.get_embed(bot=bot, show_author=True) - ) - await repost_channel.send(message.jump_url) - - if announcement.ping and announcement.ping_preview: - await announcement.channel.send( - f"{announcement.ping.mention} - {announcement.ping_preview}", - allowed_mentions=allowed_mentions, - ) - elif announcement.ping: - await announcement.channel.send( - announcement.ping.mention, allowed_mentions=allowed_mentions - ) - - if announcement.will_notify: - try: - if video_message: - await video_message.publish() - - await message.publish() - except discord.Forbidden: - pass - - -class ChangeOptionModal(discord.ui.Modal): - def __init__( - self, announcement_builder: AnnouncementBuilder, option: Option - ) -> None: - super().__init__(title=option.name) - self.announcement_builder = announcement_builder - self.option = option - - self.option_input = discord.ui.TextInput( - custom_id=self.option.id, - label=self.option.name, - required=self.option.id in ("title", "channel"), - placeholder=self.option.directions, - style=( - discord.TextStyle.long - if self.option.is_long - else discord.TextStyle.short - ), - ) - self.add_item(self.option_input) - - async def on_submit(self, interaction: discord.Interaction, /) -> None: - option_value = self.option_input.value - conversion_success = await self.announcement_builder.announcement.set_option( - self.option, option_value, guild=interaction.guild - ) - item = discord.utils.get( - self.announcement_builder.view.children, custom_id=self.option.id - ) - - if conversion_success and not option_value: - item.style = discord.ButtonStyle.gray - await self.announcement_builder.view.update(interaction) - elif conversion_success: - item.style = discord.ButtonStyle.green - await self.announcement_builder.view.update(interaction) - else: - await interaction.response.send_message( - "Could not find that role or channel.", ephemeral=True - ) - - -class OptionButton(discord.ui.Button): - def __init__( - self, announcement_builder: AnnouncementBuilder, *args, **kwargs - ) -> None: - super().__init__(*args, **kwargs) - self.announcement_builder = announcement_builder - - async def callback(self, interaction: discord.Interaction, /) -> None: - await self.announcement_builder.view.button_callback(interaction, self) - - -async def setup(bot: commands.Bot) -> None: - await bot.add_cog(AnnouncementCog(bot)) diff --git a/scnewsbot/extensions/leaderboard.py b/scnewsbot/extensions/leaderboard.py new file mode 100644 index 0000000..3330751 --- /dev/null +++ b/scnewsbot/extensions/leaderboard.py @@ -0,0 +1,248 @@ +import discord +from discord.ext import commands +from datetime import datetime, timedelta +import json +import os + +# CONFIG +LEADERBOARD_FILE = "leaderboard.json" +EMBED_COLOR = discord.Color.gold() +TOP_EMOJIS = ["🥇", "🥈", "🥉"] +USERS_PER_PAGE = 5 + +# DATA +class LeaderboardData: + + def __init__(self, filepath=LEADERBOARD_FILE): + self.filepath = filepath + self.data = {} + self.load() + + def load(self): + if os.path.exists(self.filepath): + try: + with open(self.filepath, "r", encoding="utf-8") as f: + self.data = json.load(f) + except Exception: + self.data = {} + else: + self.data = {} + + def save(self): + with open(self.filepath, "w", encoding="utf-8") as f: + json.dump(self.data, f, indent=4) + + # RECORD POST + def record_post(self, user_id: int): + + uid = str(user_id) + now = datetime.utcnow().isoformat() + + if uid not in self.data: + self.data[uid] = { + "count": 0, + "last_post": now, + "posts": [] + } + + self.data[uid]["count"] += 1 + self.data[uid]["last_post"] = now + self.data[uid]["posts"].append(now) + + self.save() + + # SORT + def get_sorted(self): + + return sorted( + self.data.items(), + key=lambda item: (-item[1]["count"], item[1]["last_post"]) + ) + + # USER STATS + def user_30_days(self, posts): + + cutoff = datetime.utcnow() - timedelta(days=30) + + return sum( + 1 for p in posts + if datetime.fromisoformat(p) >= cutoff + ) + + def user_year(self, posts): + + year = datetime.utcnow().year + + return sum( + 1 for p in posts + if datetime.fromisoformat(p).year == year + ) + + # GLOBAL STATS + def global_stats(self): + + cutoff = datetime.utcnow() - timedelta(days=30) + year = datetime.utcnow().year + + total30 = 0 + totalYear = 0 + totalAll = 0 + + for user in self.data.values(): + + posts = user.get("posts", []) + + totalAll += user.get("count", 0) + + for p in posts: + + dt = datetime.fromisoformat(p) + + if dt >= cutoff: + total30 += 1 + + if dt.year == year: + totalYear += 1 + + return total30, totalYear, totalAll + + +leaderboard_data = LeaderboardData() + +# VIEW +class LeaderboardView(discord.ui.View): + + def __init__(self, ctx, users): + + super().__init__(timeout=180) + + self.ctx = ctx + self.users = users + self.page = 0 + self.max_page = (len(users) - 1) // USERS_PER_PAGE + + def build_embed(self): + + embed = discord.Embed( + title="📊 Announcement Leaderboard", + color=EMBED_COLOR, + timestamp=datetime.utcnow() + ) + + start = self.page * USERS_PER_PAGE + end = start + USERS_PER_PAGE + + page_users = self.users[start:end] + + for i, (uid, stats) in enumerate(page_users, start=start+1): + + member = self.ctx.guild.get_member(int(uid)) + name = member.display_name if member else f"User {uid}" + + posts = stats.get("posts", []) + + alltime = stats["count"] + last30 = leaderboard_data.user_30_days(posts) + year = leaderboard_data.user_year(posts) + + last_post = datetime.fromisoformat(stats["last_post"]).strftime("%Y-%m-%d %H:%M UTC") + + if i <= 3: + title = f"{TOP_EMOJIS[i-1]} {name}" + else: + title = name + + value = ( + f"**All Time:** {alltime}\n" + f"**30 Days:** {last30}\n" + f"**Year:** {year}\n" + f"**Last:** {last_post}" + ) + + if int(uid) == self.ctx.author.id: + value += " 👈 You" + + embed.add_field(name=title, value=value, inline=False) + + # GLOBAL STATS + total30, totalYear, totalAll = leaderboard_data.global_stats() + + embed.add_field( + name="📈 Server Announcement Stats", + value=( + f"**Last 30 Days:** {total30}\n" + f"**This Year:** {totalYear}\n" + f"**All Time:** {totalAll}" + ), + inline=False + ) + + embed.set_footer(text=f"Page {self.page+1}/{self.max_page+1}") + + return embed + + # BUTTONS + @discord.ui.button(label="⬅ Previous", style=discord.ButtonStyle.secondary) + async def previous(self, interaction: discord.Interaction, button: discord.ui.Button): + + if self.page > 0: + self.page -= 1 + + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="🧑 My Rank", style=discord.ButtonStyle.primary) + async def my_rank(self, interaction: discord.Interaction, button: discord.ui.Button): + + uid = str(interaction.user.id) + + for index, (user_id, _) in enumerate(self.users): + + if user_id == uid: + + self.page = index // USERS_PER_PAGE + break + + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + @discord.ui.button(label="Next ➡", style=discord.ButtonStyle.secondary) + async def next(self, interaction: discord.Interaction, button: discord.ui.Button): + + if self.page < self.max_page: + self.page += 1 + + await interaction.response.edit_message(embed=self.build_embed(), view=self) + + +# COG +class Leaderboard(commands.Cog): + + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def leaderboard(self, ctx): + + users = leaderboard_data.get_sorted() + + if not users: + await ctx.send("No announcements have been posted yet.") + return + + view = LeaderboardView(ctx, users) + + await ctx.send(embed=view.build_embed(), view=view) + + +# HELPER +def record_announcement_post(user_id: int): + + leaderboard_data.record_post(user_id) + + +# SETUP +async def setup(bot): + + await bot.add_cog(Leaderboard(bot)) + + + diff --git a/scnewsbot/extensions/templates.py b/scnewsbot/extensions/templates.py index f7c1c97..406a232 100644 --- a/scnewsbot/extensions/templates.py +++ b/scnewsbot/extensions/templates.py @@ -3,20 +3,18 @@ import discord from extensions.announcements import Announcement from utils import can_publish_announcements +# TEMPLATES TEMPLATES: dict[str, Announcement] = { "isc": Announcement(title="Inside Star Citizen | [topic] - [subtopic]"), "scl": Announcement(title="Star Citizen Live | [topic] - [subtopic]"), "tracker": Announcement(title="Progress Tracker Update | [date]"), "roundup": Announcement(title="Roadmap Roundup | [date]"), - "patchnotes": Announcement( - title="Star Citizen Alpha X.XX.X XPTU.XXXXXXX Patch Notes" - ), + "patchnotes": Announcement(title="Star Citizen Alpha X.XX.X XPTU.XXXXXXX Patch Notes"), "galactapedia": Announcement(title="Weekly Sneak Peek | [date]"), "devreply": Announcement(title="Dev Reply | Topic"), - "twisc": Announcement( - title="This Week in Star Citizen | Week of [date]", - ), + "twisc": Announcement(title="This Week in Star Citizen | Week of [date]"), } + PING_PREVIEWS = """\ **Patch Notes** - New Wave: `3.XX Wave X Release` @@ -40,6 +38,7 @@ PING_PREVIEWS = """\ - Subscriber Items: `Month Subscriber Promotions` - JP: `Jump Point` """ + IDS = """\ __**Server News:**__ Channel - `1113146864804573285` @@ -67,6 +66,7 @@ __**Posting Locations:**__ [Check here](https://discord.com/channels/82210263440306176/611922107345141760/1113905662217441330) for a guide on what post types go where. """ +# COG class TemplatesCog(commands.Cog, name="Templates"): def __init__(self, bot: commands.Bot) -> None: self.bot = bot @@ -97,8 +97,9 @@ class TemplatesCog(commands.Cog, name="Templates"): await ctx.reply( f"Could not find that template. Use `{ctx.prefix}templates list` to list all available templates." ) + return - embed = await template.get_embed(bot=self.bot) + embed = template.embed() embed.remove_author() await ctx.reply(embed=embed) @@ -114,9 +115,7 @@ class TemplatesCog(commands.Cog, name="Templates"): @commands.command(name="previews", brief="Shows all the possible ping previews.") async def ping_previews(self, ctx: commands.Context) -> None: await ctx.reply( - embed=discord.Embed( - color=self.bot.config.embed_color, description=PING_PREVIEWS - ), + embed=discord.Embed(color=self.bot.config.embed_color, description=PING_PREVIEWS), allowed_mentions=discord.AllowedMentions.none(), ) @@ -127,6 +126,6 @@ class TemplatesCog(commands.Cog, name="Templates"): embed=discord.Embed(color=self.bot.config.embed_color, description=IDS) ) - +# SETUP async def setup(bot: commands.Bot) -> None: - await bot.add_cog(TemplatesCog(bot)) + await bot.add_cog(TemplatesCog(bot)) \ No newline at end of file