Files
SCNewsBot/scnewsbot/extensions/announcements.py

629 lines
22 KiB
Python
Raw Normal View History

2023-05-07 14:00:46 -06:00
from __future__ import annotations
from typing import Any, Optional, Callable, Awaitable
from dataclasses import dataclass
from discord.ext import commands
import discord
from utils import can_publish_announcements
ANNOUNCEMENT_BUILDER_TIMEOUT = 1200
2023-09-20 16:30:07 -06:00
ANNOUNCEMENT_EMOJI = "👍" # <:upvote:354233015842635776>
2023-09-17 21:57:16 -06:00
DEFAULT_IMAGE_URL = "https://media.discordapp.net/attachments/1062905729532571719/1123340546979676311/NewsDefault.jpg?width=810&height=180"
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).\
"""
2023-05-07 14:00:46 -06:00
2023-09-20 18:51:09 -06:00
async def get_follow_up_message(
announcement: discord.Message, /, *, limit: int
) -> Optional[discord.Message]:
index = 0
2023-09-29 18:35:01 -06:00
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
2023-09-20 18:51:09 -06:00
2023-09-29 18:35:01 -06:00
if message.author.id == announcement._state.user.id and index == limit:
return message
def is_announcement(announcement: discord.Message, /) -> bool:
return (
2023-09-29 18:57:59 -06:00
announcement
and announcement.author == announcement.guild.me
2023-09-29 18:35:01 -06:00
and len(announcement.embeds) == 1
and not announcement.content
and not announcement.components
)
2023-05-07 14:00:46 -06:00
2023-09-29 18:57:59 -06:00
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:]
2023-09-17 21:57:16 -06:00
elif line.startswith("+"):
split_description[index] = "ㅤ✦" + line[1:]
return "\n".join(split_description)
2023-05-07 14:00:46 -06:00
class AnnouncementCog(commands.Cog, name="Announcements"):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
2023-09-17 21:57:16 -06:00
@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)
2023-05-07 14:00:46 -06:00
@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)
2023-09-17 21:57:16 -06:00
@announcements.command(
aliases=["post"], brief="Creates and sends a new announcement."
)
2023-05-07 14:00:46 -06:00
async def create(self, ctx: commands.Context) -> None:
announcement_builder = AnnouncementBuilder(owner=ctx.author)
embed = await announcement_builder.get_embed(bot=self.bot)
2023-09-17 21:57:16 -06:00
await ctx.send(
content=announcement_builder.announcement.video_url,
embed=embed,
view=announcement_builder.view,
)
2023-05-07 14:00:46 -06:00
@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)
2023-09-17 21:57:16 -06:00
await ctx.send(
content=announcement_builder.announcement.video_url,
embed=embed,
view=announcement_builder.view,
)
2023-05-07 14:00:46 -06:00
@commands.check(can_publish_announcements)
@announcements.command(brief="Deletes an announcement.")
async def delete(self, ctx: commands.Context, message: discord.Message) -> None:
2023-09-29 18:35:01 -06:00
if not is_announcement(message):
await ctx.reply("That is not an announcement!")
return
2023-05-07 14:00:46 -06:00
2023-09-29 18:35:01 -06:00
video_message = await get_follow_up_message(message, limit=-1)
ping_message = await get_follow_up_message(message, limit=1)
2023-09-20 18:51:09 -06:00
2023-09-29 18:57:59 -06:00
if is_video_message(video_message):
2023-09-20 18:51:09 -06:00
await video_message.delete()
2023-05-07 14:00:46 -06:00
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
class Announcement:
def __init__(
self,
*,
title: str = "Announcement",
url: Optional[str] = None,
description: Optional[str] = None,
2023-09-17 21:57:16 -06:00
video_url: Optional[str] = None,
image_url: Optional[str] = DEFAULT_IMAGE_URL,
2023-05-07 14:00:46 -06:00
channel: Optional[discord.abc.Messageable] = None,
ping: Optional[discord.Role] = None,
ping_preview: Optional[str] = None,
author_id: Optional[int] = None,
2023-09-17 21:57:16 -06:00
is_anonymous: bool = False,
2023-05-07 14:00:46 -06:00
will_notify: bool = False,
) -> None:
self.title = title
self.url = url
self.description = description
2023-09-17 21:57:16 -06:00
self.video_url = video_url
2023-05-07 14:00:46 -06:00
self.image_url = image_url
self.channel = channel
self.ping = ping
self.ping_preview = ping_preview
self.author_id = author_id
2023-09-17 21:57:16 -06:00
self.is_anonymous = is_anonymous
2023-05-07 14:00:46 -06:00
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":
2023-05-07 14:00:46 -06:00
if value.startswith("#"):
value = value[1:]
converted_value = discord.utils.find(
2023-12-17 09:28:30 -07:00
lambda channel: channel.name.lower() == value.lower(),
guild.text_channels,
2023-05-07 14:00:46 -06:00
)
if not converted_value and value.isnumeric():
converted_value = guild.get_channel(int(value))
elif option.id == "ping":
converted_value = discord.utils.find(
2023-12-17 08:42:44 -07:00
lambda role: role.name.lower() == value.lower(), guild.roles
2023-05-07 14:00:46 -06:00
)
if not converted_value and value.isnumeric():
converted_value = guild.get_role(int(value))
else:
2023-09-17 21:57:16 -06:00
if option.id == "video_url" and self.image_url == DEFAULT_IMAGE_URL:
self.image_url = None
2023-05-07 14:00:46 -06:00
converted_value = value
if converted_value is None:
return False
setattr(self, option.id, converted_value)
return True
2023-09-20 16:30:07 -06:00
async def get_embed(
self, *, bot: commands.Bot, show_author: bool = False
) -> discord.Embed:
2023-05-07 14:00:46 -06:00
embed = discord.Embed(
2023-05-07 14:43:24 -06:00
color=bot.config.embed_color,
2023-05-07 14:00:46 -06:00
title=self.title,
description=self.description,
)
embed.set_image(url=self.image_url)
2023-05-07 16:09:03 -06:00
if self.url and self.description:
embed.description = f"{self.url}\n\n" + embed.description
elif self.url:
embed.description = self.url
2023-09-17 21:57:16 -06:00
if not self.is_anonymous or show_author:
2023-05-07 14:00:46 -06:00
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
@classmethod
async def from_message(
cls, message: discord.Message, /, *, bot: commands.Bot
) -> Any:
2023-09-29 18:35:01 -06:00
if not is_announcement(message):
raise InvalidAnnouncementException("That is not an announcement!")
2023-05-07 14:00:46 -06:00
return
embed = message.embeds[0]
2023-09-29 18:35:01 -06:00
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
)
2023-05-07 14:00:46 -06:00
ping = None
ping_preview = None
2023-09-29 18:35:01 -06:00
ping_message = await get_follow_up_message(message, limit=1)
2023-05-07 14:00:46 -06:00
if ping_message:
2023-09-29 18:35:01 -06:00
role_converter = commands.RoleConverter()
2023-05-07 14:00:46 -06:00
split_message = ping_message.content.split(" - ")
2023-09-29 18:35:01 -06:00
ping = await role_converter.convert(
2023-05-07 14:00:46 -06:00
await bot.get_context(ping_message), split_message[0]
)
if len(split_message) == 2:
ping_preview = split_message[1]
2023-09-29 18:57:59 -06:00
url = ""
description = embed.description
2023-09-29 18:35:01 -06:00
video_message = await get_follow_up_message(message, limit=-1)
2023-09-17 21:57:16 -06:00
2023-09-29 18:57:59 -06:00
if not is_video_message(video_message):
video_message = None
2023-09-17 21:57:16 -06:00
if embed.description is not None:
2023-09-29 18:57:59 -06:00
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)
2023-05-07 16:36:44 -06:00
2023-05-07 14:00:46 -06:00
return cls(
title=embed.title,
2023-09-29 18:57:59 -06:00
url=url or None,
description=description,
2023-09-29 18:57:59 -06:00
video_url=video_message.content if video_message else None,
2023-05-07 14:00:46 -06:00
image_url=embed.image.url,
channel=message.channel,
ping=ping,
ping_preview=ping_preview,
author_id=author.id if author else None,
2023-09-17 21:57:16 -06:00
is_anonymous=embed.author is None,
2023-05-07 14:00:46 -06:00
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"))
2023-09-17 21:57:16 -06:00
self.add_option(Option(id="description", name="Description", is_long=True))
self.add_option(Option(id="video_url", name="Video"))
2023-05-07 14:00:46 -06:00
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:
2023-09-29 18:35:01 -06:00
if (
isinstance(item, discord.ui.Button)
and self.edit
and item.label == "Post"
):
item.label = "Edit"
2023-05-07 14:00:46 -06:00
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
)
self.options.append(option)
self.view.add_item(button)
class AnnouncementBuilderView(discord.ui.View):
def __init__(self, announcement_builder: AnnouncementBuilder, /) -> None:
super().__init__(timeout=ANNOUNCEMENT_BUILDER_TIMEOUT)
self.announcement_builder = announcement_builder
2023-09-29 18:35:01 -06:00
def has_permission(self, user: discord.User) -> bool:
2023-05-07 14:00:46 -06:00
return user == self.announcement_builder.owner
2023-09-29 18:35:01 -06:00
async def update(self, interaction: discord.Interaction, /) -> None:
2023-05-07 14:00:46 -06:00
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
await interaction.response.edit_message(
2023-09-17 21:57:16 -06:00
content=self.announcement_builder.announcement.video_url,
2023-05-07 14:00:46 -06:00
embed=await self.announcement_builder.get_embed(bot=interaction.client),
view=self,
allowed_mentions=discord.AllowedMentions.none(),
)
async def button_callback(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
2023-09-29 18:35:01 -06:00
if not self.has_permission(interaction.user):
2023-05-07 14:00:46 -06:00
await interaction.response.send_message(
"You cannot use this menu.", ephemeral=True
)
return
await interaction.response.send_modal(
ChangeOptionModal(
announcement_builder=self.announcement_builder,
option=discord.utils.get(
self.announcement_builder.options, id=button.custom_id
),
)
)
2023-09-20 18:51:09 -06:00
@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:
2023-09-29 18:35:01 -06:00
if not self.has_permission(interaction.user):
2023-09-20 18:51:09 -06:00
await interaction.response.send_message(
"You cannot use this menu.", ephemeral=True
)
return
self.stop()
2023-09-29 18:57:59 -06:00
2023-09-20 18:51:09 -06:00
await interaction.response.send_message(
2023-09-29 18:57:59 -06:00
"Cancelled editing this announcement."
if self.announcement_builder.edit
else "Cancelled posting this announcement."
2023-09-20 18:51:09 -06:00
)
2023-09-17 21:57:16 -06:00
@discord.ui.button(custom_id="anonymous", label="Anonymous?", row=2)
async def toggle_anonymous(
2023-05-07 14:00:46 -06:00
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
2023-09-29 18:35:01 -06:00
if not self.has_permission(interaction.user):
2023-05-07 15:40:22 -06:00
await interaction.response.send_message(
"You cannot use this menu.", ephemeral=True
)
return
2023-05-07 15:58:01 -06:00
2023-09-17 21:57:16 -06:00
self.announcement_builder.announcement.is_anonymous = (
not self.announcement_builder.announcement.is_anonymous
2023-05-07 14:00:46 -06:00
)
2023-09-17 21:57:16 -06:00
if self.announcement_builder.announcement.is_anonymous:
2023-05-07 14:00:46 -06:00
button.style = discord.ButtonStyle.green
else:
button.style = discord.ButtonStyle.gray
2023-09-29 18:35:01 -06:00
await self.update(interaction)
2023-05-07 14:00:46 -06:00
@discord.ui.button(
2023-09-17 21:57:16 -06:00
custom_id="notification", label="Published?", row=2, disabled=True
2023-05-07 14:00:46 -06:00
)
async def toggle_notification(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
2023-09-29 18:35:01 -06:00
if not self.has_permission(interaction.user):
2023-05-07 15:40:22 -06:00
await interaction.response.send_message(
"You cannot use this menu.", ephemeral=True
)
return
2023-05-07 15:58:01 -06:00
2023-05-07 14:00:46 -06:00
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
2023-09-29 18:35:01 -06:00
await self.update(interaction)
2023-05-07 14:00:46 -06:00
@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:
2023-09-29 18:35:01 -06:00
if not self.has_permission(interaction.user):
2023-05-07 14:00:46 -06:00
await interaction.response.send_message(
"You cannot use this menu.", ephemeral=True
)
return
announcement = self.announcement_builder.announcement
role = announcement.ping
2023-05-07 14:05:02 -06:00
allowed_mentions = (
discord.AllowedMentions(everyone=False, users=False, roles=[role])
if role
else discord.AllowedMentions.none()
)
2023-05-07 14:00:46 -06:00
if self.announcement_builder.edit:
2023-09-20 18:51:09 -06:00
await interaction.response.send_message("Your announcement was edited! 🎉")
2023-05-07 14:00:46 -06:00
self.stop()
embed = await self.announcement_builder.get_embed(bot=interaction.client)
embed.remove_footer()
2023-09-20 18:51:09 -06:00
await self.announcement_builder.message.edit(embed=embed)
video_message = await get_follow_up_message(
2023-09-29 18:35:01 -06:00
self.announcement_builder.message, limit=-1
2023-05-07 14:00:46 -06:00
)
2023-09-29 18:57:59 -06:00
if is_video_message(video_message):
await video_message.edit(
content=self.announcement_builder.announcement.video_url
)
2023-09-20 18:51:09 -06:00
2023-05-07 14:00:46 -06:00
return
2023-09-17 21:57:16 -06:00
if not announcement.channel:
2023-05-07 14:00:46 -06:00
await interaction.response.send_message(
2023-09-17 21:57:16 -06:00
"You must have a channel selected!", ephemeral=True
2023-05-07 14:00:46 -06:00
)
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
await interaction.response.send_message(
2023-09-20 18:51:09 -06:00
"Your announcement was posted! 🎉 https://i.imgur.com/HRoxTzg.gif"
2023-05-07 14:00:46 -06:00
)
self.stop()
2023-09-20 18:51:09 -06:00
bot = interaction.client
video_message = None
2023-09-29 18:35:01 -06:00
if announcement.video_url:
video_message = await announcement.channel.send(announcement.video_url)
2023-05-07 14:05:02 -06:00
message = await announcement.channel.send(
2023-09-17 21:57:16 -06:00
embed=await announcement.get_embed(bot=bot),
2023-05-07 14:05:02 -06:00
)
2023-09-17 21:57:16 -06:00
await message.add_reaction(ANNOUNCEMENT_EMOJI)
for channel_id in bot.config.repost_channels:
repost_channel = await bot.fetch_channel(channel_id)
2023-09-29 23:12:52 -06:00
if announcement.video_url:
await repost_channel.send(announcement.video_url)
2023-09-17 21:57:16 -06:00
await repost_channel.send(
2023-09-20 18:51:09 -06:00
embed=await announcement.get_embed(bot=bot, show_author=True)
2023-09-17 21:57:16 -06:00
)
2023-09-29 18:35:01 -06:00
await repost_channel.send(message.jump_url)
2023-09-17 21:57:16 -06:00
2023-05-07 14:00:46 -06:00
if announcement.ping and announcement.ping_preview:
await announcement.channel.send(
f"{announcement.ping.mention} - {announcement.ping_preview}",
2023-05-07 14:05:02 -06:00
allowed_mentions=allowed_mentions,
2023-05-07 14:00:46 -06:00
)
elif announcement.ping:
await announcement.channel.send(
2023-05-07 14:05:02 -06:00
announcement.ping.mention, allowed_mentions=allowed_mentions
2023-05-07 14:00:46 -06:00
)
2023-09-20 18:51:09 -06:00
if announcement.will_notify:
try:
if video_message:
await video_message.publish()
2023-09-29 18:35:01 -06:00
await message.publish()
2023-09-20 18:51:09 -06:00
except discord.Forbidden:
pass
2023-05-07 14:00:46 -06:00
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
2023-09-29 18:35:01 -06:00
await self.announcement_builder.view.update(interaction)
2023-05-07 14:00:46 -06:00
elif conversion_success:
item.style = discord.ButtonStyle.green
2023-09-29 18:35:01 -06:00
await self.announcement_builder.view.update(interaction)
2023-05-07 14:00:46 -06:00
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))