initial commit

This commit is contained in:
mudkipdev
2023-05-07 14:00:46 -06:00
commit c44f1024da
12 changed files with 868 additions and 0 deletions

10
.github/workflows/black.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
name: Code Formatter
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: psf/black@stable

132
.gitignore vendored Normal file
View File

@@ -0,0 +1,132 @@
# scnewsbot
config.toml
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 mudkip
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

2
README.md Normal file
View File

@@ -0,0 +1,2 @@
# scnewsbot
A bot for the r/starcitizen Discord server.

22
example.config.toml Normal file
View File

@@ -0,0 +1,22 @@
# This is important. Make sure to set this to "false" when running the bot.
debug = true
[bot]
prefix = "&"
extensions = [
"announcements",
"templates"
]
[permissions]
# This works in a whitelist mode. The invoker of the command must be in an allowed guild and
# must have an allowed role. If neither of those are true, allowed_users is checked last.
allowed_guilds = []
allowed_roles = []
allowed_users = []
[permissions.debug]
# This works in addition to the regular permissions, but only if debug mode is enabled.
allowed_guilds = []
allowed_roles = []
allowed_users = []

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[tool.poetry]
name = "scnewsbot"
version = "0.0.0"
description = "A bot for the r/starcitizen Discord server."
authors = ["mudkipdev <mudkip@mudkip.dev>"]
license = "MIT"
readme = "README.md"
packages = [
{ include = "scnewsbot" }
]
[tool.poetry.dependencies]
python = "^3.11"
discord-py = "^2.2.2"
jishaku = "^2.5.1"
python-dotenv = "^1.0.0"
[tool.poetry.group.dev.dependencies]
black = "^23.3.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

0
scnewsbot/__init__.py Normal file
View File

26
scnewsbot/__main__.py Normal file
View File

@@ -0,0 +1,26 @@
import tomllib
import os
from dotenv import load_dotenv
from bot import Bot
from utils import Config
class InvalidTokenException(Exception):
pass
def main() -> None:
DISCORD_TOKEN = os.getenv("DISCORD_TOKEN")
if not DISCORD_TOKEN:
raise InvalidTokenException("A Discord token was not set.")
with open("config.toml", "rb") as config_file:
config = Config(tomllib.load(config_file))
bot = Bot(config)
bot.run(DISCORD_TOKEN)
if __name__ == "__main__":
load_dotenv()
main()

22
scnewsbot/bot.py Normal file
View File

@@ -0,0 +1,22 @@
from discord.ext import commands
import discord
from utils import Config
INTENTS = discord.Intents.default()
INTENTS.message_content = True
INTENTS.members = True
class Bot(commands.Bot):
def __init__(self, config: Config, /) -> None:
super().__init__(
intents=INTENTS,
command_prefix=commands.when_mentioned_or(config.prefix),
allowed_mentions=discord.AllowedMentions(everyone=False),
case_insensitive=True,
)
self.config = config
async def setup_hook(self) -> None:
for extension in self.config.extensions:
await self.load_extension(extension)

View File

@@ -0,0 +1,455 @@
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
EMBED_COLOR = 0x1ABC9C
EMBED_THUMBNAIL = "https://imgur.com/MJnM3LU.png"
async def get_ping_message(message: discord.Message, /) -> Optional[discord.Message]:
async for ping in message.channel.history(limit=1, after=message.created_at):
print(ping.content)
if ping.author.id == message._state.user.id and ping.mentions:
return ping
class AnnouncementCog(commands.Cog, name="Announcements"):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@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(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(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(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 message.author != self.bot.user and not message.embeds:
await ctx.reply("That is not an announcement.")
ping_message = await get_ping_message(message)
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,
image_url: Optional[str] = None,
channel: Optional[discord.abc.Messageable] = None,
ping: Optional[discord.Role] = None,
ping_preview: Optional[str] = None,
author_id: Optional[int] = None,
is_private: bool = False,
will_notify: bool = False,
) -> None:
self.title = title
self.url = url
self.description = description
self.image_url = image_url
self.channel = channel
self.ping = ping
self.ping_preview = ping_preview
self.author_id = author_id
self.is_private = is_private
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 == "channel":
if value.startswith("#"):
value = value[1:]
converted_value = discord.utils.find(
lambda c: c.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 r: r.name.lower() == value.lower(), guild.roles
)
if not converted_value and value.isnumeric():
converted_value = guild.get_role(int(value))
else:
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) -> discord.Embed:
embed = discord.Embed(
color=EMBED_COLOR,
title=self.title,
url=self.url,
description=self.description,
)
embed.set_image(url=self.image_url)
if EMBED_THUMBNAIL:
embed.set_thumbnail(url=EMBED_THUMBNAIL)
if not self.is_private:
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:
if message.author != message.guild.me:
raise InvalidAnnouncementException("This is not a bot message.")
return
if not len(message.embeds) == 1:
raise InvalidAnnouncementException("This message does not have any embeds.")
return
if message.components:
raise InvalidAnnouncementException("This message has not been posted yet.")
return
embed = message.embeds[0]
author = discord.utils.find(
lambda member: str(member) == embed.author.name, message.guild.members
)
ping = None
ping_preview = None
ping_message = await get_ping_message(message)
if ping_message:
split_message = ping_message.content.split(" - ")
ping = await commands.RoleConverter.convert(
await bot.get_context(ping_message), split_message[0]
)
if len(split_message) == 2:
ping_preview = split_message[1]
return cls(
title=embed.title,
url=embed.url,
description=embed.description,
image_url=embed.image.url,
channel=message.channel,
ping=ping,
ping_preview=ping_preview,
author_id=author.id if author else None,
is_private=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",
directions="Here are some directions.",
is_long=True,
)
)
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:
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
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
await interaction.response.edit_message(
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:
if not self._has_permission(interaction.user):
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
),
)
)
@discord.ui.button(custom_id="private", label="Private", row=2)
async def toggle_private(
self, interaction: discord.Interaction, button: discord.ui.Button
) -> None:
self.announcement_builder.announcement.is_private = (
not self.announcement_builder.announcement.is_private
)
if self.announcement_builder.announcement.is_private:
button.style = discord.ButtonStyle.green
else:
button.style = discord.ButtonStyle.gray
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:
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(
"Your announcement was edited! 🎉", ephemeral=True
)
self.stop()
embed = await self.announcement_builder.get_embed(bot=interaction.client)
embed.remove_footer()
await self.announcement_builder.message.edit(
content=role.mention if role else None,
embed=embed
)
return
if not announcement.title and announcement.channel:
await interaction.response.send_message(
"You must have a title and 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
await interaction.response.send_message(
"Your announcement was posted! 🎉", ephemeral=True
)
self.stop()
message = await announcement.channel.send(embed=await announcement.get_embed(bot=interaction.client),)
if announcement.will_notify:
await message.publish()
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
)
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,103 @@
from discord.ext import commands
import discord
from extensions.announcements import Announcement
EMBED_COLOR = 0x1ABC9C
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"
),
"galactapedia": Announcement(title="Weekly Sneak Peek | [date]"),
"devreply": Announcement(title="Dev Reply | Topic"),
"twisc": Announcement(
title="This Week in Star Citizen | Week of [date]",
),
}
PING_PREVIEWS = """\
**Patch Notes**
- New Wave: `@SC Patch Notes - 3.XX Wave X Release`
- PTU Update: `@SC Patch Notes - 3.XX PTU Update`
- Live Update: `@SC Patch Notes - 3.XX LIVE Update`
**SC News**
- ISC: `@SC News - Inside Star Citizen`
- SCL: `@SC News - Star Citizen Live`
- Progress Tracker: `@SC News - Progress Tracker Update`
- Roadmap: `@SC News - Roadmap Roundup`
- SC Monthly Report: `@SC News - Star Citizen Monthly Report`
- SQ42 Monthly Report: `@SC News - Squadron 42 Monthly Report`
- Dynamic Event: `@SC News - Event Name PU/PTU`
**General News**
- Sneak Peek: `@General News - Weekly Sneak Peek`
- Lore Post: `@General News - Lore Post: Name`
- Dev Reply: `@General News - Dev Reply`
- Subscriber Items: `@General News - Month Subscriber Promotions`
- JP: `@General News - Jump Point`
"""
CHANNELS = """\
Patch Notes - `585952222853201941`
SC News - `569635458183856149`
General News - `803341100618219540`
"""
class TemplatesCog(commands.Cog, name="Templates"):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
self.templates = TEMPLATES
@commands.guild_only()
@commands.group(
brief="Commands that help with designing embeds for the news system.",
invoke_without_command=True,
)
async def templates(self, ctx: commands.Context) -> None:
await ctx.send_help(ctx.command)
@templates.command(name="list")
async def _list(self, ctx: commands.Context) -> None:
templates = "\n".join([f"- {template}" for template in self.templates])
await ctx.reply(f"Here are all of the available templates: ```\n{templates}```")
@templates.command()
async def view(self, ctx: commands.Context, *, template_name: str) -> None:
templates = {name.lower(): value for name, value in self.templates.items()}
template_name = template_name.lower()
template = templates.get(template_name)
if not template:
await ctx.reply(
f"Could not find that template. Use `{ctx.prefix}templates list` to list all available templates."
)
embed = await template.get_embed(bot=self.bot)
embed.remove_author()
await ctx.reply(embed=embed)
@commands.command(brief='A shortcut to the "templates view" command.')
async def template(self, ctx: commands.Context, *, template_name: str) -> None:
await ctx.invoke(
self.bot.get_command("templates").get_command("view"),
template_name=template_name,
)
@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=EMBED_COLOR, description=PING_PREVIEWS),
allowed_mentions=discord.AllowedMentions.none(),
)
@commands.command(brief="Shows all the channel IDs for announcements.")
async def channels(self, ctx: commands.Context) -> None:
await ctx.reply(embed=discord.Embed(color=EMBED_COLOR, description=CHANNELS))
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(TemplatesCog(bot))

52
scnewsbot/utils.py Normal file
View File

@@ -0,0 +1,52 @@
from discord.ext import commands
class Config:
def __init__(self, config: dict):
self.config = config
@property
def debug(self) -> bool:
return self.config.get("debug", False)
@property
def prefix(self) -> str:
return self.config["bot"].get("prefix", "sc ")
@property
def extensions(self) -> list:
return self.config["bot"].get("extensions", ["jishaku"])
@property
def allowed_guilds(self) -> list:
return self._get_allowed_objects("allowed_guilds")
@property
def allowed_roles(self) -> list:
return self._get_allowed_objects("allowed_roles")
@property
def allowed_users(self) -> list:
return self._get_allowed_objects("allowed_users")
def _get_allowed_objects(self, object_name, /) -> list:
allowed_objects = self.config["permissions"].get(object_name, [])
if self.debug:
allowed_objects += self.config["permissions"]["debug"].get(object_name, [])
return allowed_objects
def can_publish_announcements(ctx: commands.Context) -> bool:
if not ctx.guild:
return False
if ctx.author.id in ctx.bot.config.allowed_users:
return True
if ctx.guild.id in ctx.bot.config.allowed_guilds:
for allowed_role in ctx.bot.config.allowed_roles:
if allowed_role in ctx.author._roles:
return True
return False