Sweeping updates

This commit is contained in:
hencaric
2026-03-14 22:54:52 -04:00
parent 31ed6e515a
commit d72fb3e7e5
9 changed files with 576 additions and 642 deletions

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))

View File

@@ -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))