Release v2.0

This commit is contained in:
Zeva Rose 2022-05-07 05:06:21 +00:00
commit 1a20dfaf5d
70 changed files with 7756 additions and 5444 deletions

16
.flake8 Normal file
View file

@ -0,0 +1,16 @@
[flake8]
extend-ignore =
Q0, E501, C812, E203, W503, # These default to arguing with Black. We might configure some of them eventually
ANN002, ANN003, # Ignore *args, **kwargs
ANN1, # Ignore self and cls annotations
ANN204, ANN206, # return annotations for special methods and class methods
D105, D107, # Missing Docstrings in magic method and __init__
S311, # Standard pseudo-random generators are not suitable for security/cryptographic purposes.
D401, # First line should be in imperative mood; try rephrasing
D400, # First line should end with a period
D101, # Missing docstring in public class
# Plugins we don't currently include: flake8-return
R502, # do not implicitly return None in function able to return non-None value.
R503, # missing explicit return at the end of function ableto return non-None value.
max-line-length=100

View file

@ -1,12 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: check-toml
- id: check-yaml
args: [--unsafe]
- id: check-merge-conflict
- id: requirements-txt-fixer
- id: end-of-file-fixer
- id: debug-statements
language_version: python3.10
- id: trailing-whitespace
args: [--markdown-linebreak-ext=md]
@ -16,23 +19,31 @@ repos:
- id: python-check-blanket-noqa
- repo: https://github.com/psf/black
rev: 21.7b0
rev: 22.3.0
hooks:
- id: black
args: [--line-length=120]
args: [--line-length=100, --target-version=py310]
language_version: python3.10
- repo: https://github.com/pre-commit/mirrors-isort
rev: V5.9.3
rev: v5.10.1
hooks:
- id: isort
args: ["--profile", "black"]
- repo: https://github.com/pycqa/flake8
rev: 3.9.2
rev: 4.0.1
hooks:
- id: flake8
additional_dependencies:
- flake8-annotations~=2.0
- flake8-bandit~=2.1
#- flake8-bandit~=2.1
- flake8-docstrings~=1.5
args: [--max-line-length=120, --ignore=ANN101 D107 ANN102 ANN206 D105 ANN204]
- flake8-bugbear
- flake8-comprehensions
- flake8-quotes
- flake8-raise
- flake8-deprecated
- flake8-print
- flake8-return
language_version: python3.10

13
LICENSE Normal file
View file

@ -0,0 +1,13 @@
“Commons Clause” License Condition v1.0
The Software is provided to you by the Licensor under the License, as defined below, subject to the following condition.
Without limiting other conditions in the License, the grant of rights under the License will not include, and the License does not grant to you, the right to Sell the Software.
For purposes of the foregoing, “Sell” means practicing any or all of the rights granted to you under the License to provide to third parties, for a fee or other consideration (including without limitation fees for hosting or consulting/ support services related to the Software), a product or service whose value derives, entirely or substantially, from the functionality of the Software. Any license notice or attribution required by the License must also include this Commons Clause License Condition notice.
Software: JARVIS
License: Expat License
Licensor: zevaryx

55
PRIVACY.md Normal file
View file

@ -0,0 +1,55 @@
# Privacy Policy
Your privacy is important to us. It is JARVIS' policy to respect your privacy and comply with any applicable law and regulation regarding any personal information we may collect about you through our JARVIS bot.
This policy is effective as of 20 March 2022 and was last updated on 10 March 2022.
## Information We Collect
Information we collect includes both information you knowingly and actively provide us when using or participating in any of our services and promotions, and any information automatically sent by your devices in the course of accessing our products and services.
## Log Data
When you use our JARVIS services, if opted in to usage data collection services, we may collect data in certain cicumstances:
- Administrative activity (i.e. ban, warn, mute)
- Your User ID (either as admin or as the recipient of the activity)
- Guild ID of activity origin
- Your discriminator at time of activity (bans only)
- Your username at time of activity (bans only)
- Admin commands
- User ID of admin who executes admin command
- Reminders
- Your User ID
- The guild in which the command originated
- The channel in which the command originated
- Private text entered via the command
- Starboard
- Message ID of starred message
- Channel ID of starred message
- Guild ID of origin guild
- Automated activity logging
- We store no information about users who edit nicknames, join/leave servers, or other related activities. However, this information, if configured by server admins, is relayed into a Discord channel and is not automatically deleted, nor do we have control over this information.
- This information is also stored by Discord via their Audit Log, which we also have no control over. Please contact Discord for their own privacy policy and asking about your rights on their platform.
## Use of Information
We use the information we collect to provide, maintain, and improve our services. Common uses where this data may be used includes sending reminders, helping administrate Discord servers, and providing extra utilities into Discord based on user content.
## Security of Your Personal Information
Although we will do our best to protect the personal information you provide to us, we advise that no method of electronic transmission or storage is 100% secure, and no one can guarantee absolute data security. We will comply with laws applicable to us in respect of any data breach.
## How Long We Keep Your Personal Information
We keep your personal information only for as long as we need to. This time period may depend on what we are using your information for, in accordance with this privacy policy. If your personal information is no longer required, we will delete it or make it anonymous by removing all details that identify you.
## Your Rights and Controlling Your Personal Information
You may request access to your personal information, and change what you are okay with us collecting from you. You may also request that we delete your personal identifying information. Please message **zevaryx#5779** on Discord, or join the Discord server at https://discord.gg/4TuFvW5n and ask in there.
## Limits of Our Policy
Our website may link to external sites that are not operated by us (ie. discord.com). Please be aware that we have no control over the content and policies of those sites, and cannot accept responsibility or liability for their respective privacy practices.
## Changes to This Policy
At our discretion, we may change our privacy policy to reflect updates to our business processes, current acceptable practices, or legislative or regulatory changes. If we decide to change this privacy policy, we will post the changes here at the same link by which you are accessing this privacy policy.
## Contact Us
For any questions or concerns regarding your privacy, you may contact us using the following details:
### Discord
#### zevaryx#5779
#### https://discord.gg/4TuFvW5n

View file

@ -1,12 +1,16 @@
<div align="center">
<img width=15% alt="J.A.R.V.I.S" src="https://git.zevaryx.com/stark-industries/j.a.r.v.i.s./-/raw/main/jarvis_small.png">
# Just Another Rather Very Intelligent System
<br />
[![python 3.8+](https://img.shields.io/badge/python-3.8+-blue)]()
[![tokei lines of code](https://tokei.rs/b1/git.zevaryx.com/stark-industries/j.a.r.v.i.s.?category=code)](https://git.zevaryx.com/stark-industries/j.a.r.v.i.s.)
[![discord chat widget](https://img.shields.io/discord/862402786116763668?style=social&logo=discord)](https://discord.gg/VtgZntXcnZ)
</div>
<br />
<img width="150" height="150" align="left" alt="J.A.R.V.I.S" src="https://i.imgur.com/7bnHam2.png">
# Just Another Very Intelligent System (J.A.R.V.I.S.)
Welcome to the J.A.R.V.I.S. Initiative! While the main goal is to create the best discord bot there can be, a great achievement would be to present him to the Robots and have him integrated into the dbrand server. Feel free to suggest anything you may think to be useful… or cool.
**Note:** Some commands have been custom made to be used in the dbrand server.
@ -34,19 +38,17 @@ If you wish to contribute to the J.A.R.V.I.S codebase or documentation, join the
Join the [Stark R&D Department Discord server](https://discord.gg/VtgZntXcnZ) to be kept up-to-date on code updates and issues.
## Requirements
- MongoDB 4.4 or higher
- Python 3.8 or higher
- MongoDB 5.0 or higher
- Python 3.10 or higher
- [tokei](https://github.com/XAMPPRocky/tokei) 12.1 or higher
On top of the above requirements, the following pip packages are also required:
- `discord-py>=1.7, <2`
- `dis-snek>=5.0.0`
- `psutil>=5.8, <6`
- `GitPython>=3.1, <4`
- `PyYaml>=5.4, <6`
- `discord-py-slash-command>=2.3.2, <3`
- `pymongo>=3.12.0, <4`
- `opencv-python>=4.5, <5`
- `ButtonPaginator>=0.0.3`
- `Pillow>=8.2.0, <9`
- `python-gitlab>=2.9.0, <3`
- `ulid-py>=1.1.0, <2`

15
TERMS.md Normal file
View file

@ -0,0 +1,15 @@
# Terms Of Use
Please just be reasonable do not try to mess with the bot in a malicious way. This includes but is not limited to:
- Spamming
- Flooding
- Hacking
- DOS Attacks
## Contact Us
For any questions or concerns regarding the terms, feel free to contact us:
### Discord
#### zevaryx#5779
#### https://discord.gg/4TuFvW5n

View file

@ -1,24 +1,34 @@
---
token: api key here
client_id: 123456789012345678
logo: alligator2
token: bot token
twitter:
consumer_key: key
consumer_secret: secret
access_token: access token
access_secret: access secret
mongo:
connect:
username: user
password: pass
host: localhost
username: username
password: password
host: hostname
port: 27017
database: database
urls:
url_name: url
url_name2: url2
max_messages: 1000
gitlab_token: null
extra: urls
max_messages: 10000
gitlab_token: token
cogs:
- list
- of
- enabled
- cogs
- all
- if
- empty
- admin
- autoreact
- dev
- image
- gl
- remindme
- rolegiver
# - settings
- starboard
- twitter
- util
- verify
log_level: INFO
sync: false
#sync_commands: True

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.5 MiB

View file

@ -1,16 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15360" height="15360" viewBox="0 0 1080 1080">
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">
<defs>
<style>
.a {
fill: #3498DB;
fill: #3498db;
}
</style>
</defs>
<title>logotests</title>
<title>jarvis</title>
<g>
<path class="a" d="M500.16,699.361c-15.864,0-31.731-.186-47.589.121-4.415.085-6.757-1.462-8.868-5.129Q358.817,546.94,273.657,399.686c-2.385-4.122-1.979-6.914.278-10.773q23.957-40.968,47.4-82.235c2.583-4.557,5.44-6.172,10.731-6.164q168.183.256,336.365,0c5.351-.009,8.141,1.751,10.688,6.244q23.394,41.288,47.384,82.233c2.6,4.409,2.259,7.322-.148,11.48q-84.848,146.517-169.341,293.24c-2.477,4.3-5.14,5.879-10.072,5.776C531.354,699.156,515.755,699.362,500.16,699.361Z"/>
<path class="a" d="M830.455,402.341c3.487-6.006,3.164-10.053-.2-15.857L679.691,124.975a5.046,5.046,0,0,1,4.38-7.558c31.658.014,62.194.087,92.727-.117,5.027-.034,6.191,3.24,8.042,6.444L938,388.752c2.542,4.305,2.461,7.329,0,11.574Q853.239,546.711,768.849,693.311c-2.624,4.561-5.4,6.337-10.714,6.291-25.437-.22-57.755-.138-85.331-.118a8.44,8.44,0,0,1-7.3-12.679Z"/>
<path class="a" d="M281.535,209.29c-6.873-.016-10.394,2.148-13.747,7.989L116.067,479.618a4.479,4.479,0,0,1-7.758,0c-15.635-27.113-31.377-54.571-47.067-81.306-2.578-4.394-.135-7.026,1.659-10.136L215.492,123.665c2.542-4.576,5.217-6.387,10.594-6.379q168.249.252,336.5.156c3.287,0,6.7-.987,9.082,3.193,15.182,26.623,31.107,54.043,46.743,81.068a5.139,5.139,0,0,1-4.389,7.71Z"/>
<path class="a" d="M705.242,800.514a6.557,6.557,0,0,0-5.583-9.992c-45.182-.007-209.265-.024-294.784.122-5.965.01-9.493-1.368-12.719-6.924C340.927,695.465,229.08,504.187,224.47,496.4a6.562,6.562,0,0,0-11.329.073c-15.415,26.744-30.437,52.884-45.654,78.911-2.168,3.708-.393,5.992,1.218,8.719Q255,730.093,341.177,876.15c2.812,4.774,5.763,6.589,11.348,6.564q148.016-.678,296.032-.843c4.973-.007,7.683-1.729,10.232-5.823Z"/>
<path class="a" d="M500.163,703.949c-16.228,0-32.461-.191-48.684.123-4.516.088-6.912-1.5-9.072-5.247q-86.839-150.8-173.959-301.448c-2.44-4.217-2.024-7.072.285-11.02q24.507-41.912,48.491-84.128c2.641-4.662,5.564-6.314,10.978-6.306q172.052.264,344.105,0c5.474-.009,8.329,1.791,10.934,6.388q23.933,42.237,48.474,84.125c2.658,4.511,2.312,7.491-.151,11.744q-86.8,149.889-173.238,299.989c-2.534,4.4-5.259,6.014-10.3,5.909C532.075,703.739,516.117,703.95,500.163,703.949Z"/>
<path class="a" d="M838.06,400.093c3.567-6.144,3.236-10.284-.206-16.221L683.826,116.344a5.162,5.162,0,0,1,4.481-7.731c32.387.014,63.625.088,94.861-.12,5.143-.035,6.334,3.315,8.227,6.592L948.08,386.192c2.6,4.4,2.518,7.5,0,11.84Q861.369,547.786,775.037,697.76c-2.685,4.665-5.529,6.483-10.962,6.436-26.022-.225-59.084-.142-87.294-.121A8.635,8.635,0,0,1,669.31,691.1Z"/>
<path class="a" d="M276.507,202.6c-7.031-.016-10.633,2.2-14.063,8.172L107.231,479.149a4.582,4.582,0,0,1-7.937,0C83.3,451.414,67.2,423.324,51.145,395.974c-2.638-4.5-.138-7.188,1.7-10.369L208.944,115c2.6-4.682,5.338-6.534,10.838-6.526q172.121.258,344.243.16c3.362,0,6.852-1.01,9.291,3.267,15.531,27.235,31.822,55.286,47.818,82.933a5.258,5.258,0,0,1-4.49,7.888Z"/>
<path class="a" d="M709.965,807.43a6.707,6.707,0,0,0-5.711-10.222c-46.221-.007-214.081-.024-301.569.125-6.1.01-9.711-1.4-13.01-7.083-52.409-90.286-166.83-285.967-171.546-293.935a6.714,6.714,0,0,0-11.59.074c-15.769,27.36-31.137,54.1-46.7,80.728-2.218,3.794-.4,6.13,1.247,8.92q88.278,149.351,176.441,298.769c2.877,4.885,5.9,6.741,11.609,6.715q151.422-.693,302.845-.862c5.088-.007,7.859-1.768,10.467-5.957Z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -1,110 +1,64 @@
"""Main J.A.R.V.I.S. package."""
import asyncio
"""Main JARVIS package."""
import logging
from pathlib import Path
from typing import Optional
from discord import Intents
from discord.ext import commands
from discord.utils import find
from discord_slash import SlashCommand
from mongoengine import connect
from psutil import Process
import aioredis
import jurigged
import rook
from jarvis_core.db import connect
from jarvis_core.log import get_logger
from naff import Intents
from jarvis import logo # noqa: F401
from jarvis import tasks, utils
from jarvis.config import get_config
from jarvis.events import guild, member, message
from jarvis import const
from jarvis.client import Jarvis
from jarvis.cogs import __path__ as cogs_path
from jarvis.config import JarvisConfig
from jarvis.utils import get_extensions
jconfig = get_config()
logger = logging.getLogger("discord")
logger.setLevel(logging.getLevelName(jconfig.log_level))
file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w")
file_handler.setFormatter(logging.Formatter("[%(asctime)s][%(levelname)s][%(name)s] %(message)s"))
logger.addHandler(file_handler)
if asyncio.get_event_loop().is_closed():
asyncio.set_event_loop(asyncio.new_event_loop())
intents = Intents.default()
intents.members = True
restart_ctx = None
__version__ = const.__version__
jarvis = commands.Bot(
command_prefix=utils.get_prefix,
async def run() -> None:
"""Run JARVIS"""
jconfig = JarvisConfig.from_yaml()
logger = get_logger("jarvis", show_locals=jconfig.log_level == "DEBUG")
logger.setLevel(jconfig.log_level)
file_handler = logging.FileHandler(filename="jarvis.log", encoding="UTF-8", mode="w")
file_handler.setFormatter(
logging.Formatter("[%(asctime)s] [%(name)s] [%(levelname)8s] %(message)s")
)
logger.addHandler(file_handler)
intents = (
Intents.DEFAULT | Intents.MESSAGES | Intents.GUILD_MEMBERS | Intents.GUILD_MESSAGE_CONTENT
)
redis_config = jconfig.redis.copy()
redis_host = redis_config.pop("host")
redis = await aioredis.from_url(redis_host, decode_responses=True, **redis_config)
jarvis = Jarvis(
intents=intents,
help_command=None,
max_messages=jconfig.max_messages,
)
slash = SlashCommand(jarvis, sync_commands=False, sync_on_cog_reload=True)
jarvis_self = Process()
__version__ = "1.11.4"
@jarvis.event
async def on_ready() -> None:
"""d.py on_ready override."""
global restart_ctx
print(" Logged in as {0.user}".format(jarvis))
print(" Connected to {} guild(s)".format(len(jarvis.guilds)))
with jarvis_self.oneshot():
print(f" Current PID: {jarvis_self.pid}")
Path(f"jarvis.{jarvis_self.pid}.pid").touch()
if restart_ctx:
channel = None
if "guild" in restart_ctx:
guild = find(lambda x: x.id == restart_ctx["guild"], jarvis.guilds)
if guild:
channel = find(lambda x: x.id == restart_ctx["channel"], guild.channels)
elif "user" in restart_ctx:
channel = jarvis.get_user(restart_ctx["user"])
if channel:
await channel.send("Core systems restarted and back online.")
restart_ctx = None
def run(ctx: dict = None) -> Optional[dict]:
"""Run J.A.R.V.I.S."""
global restart_ctx
if ctx:
restart_ctx = ctx
connect(
db="ctc2",
alias="ctc2",
authentication_source="admin",
**jconfig.mongo["connect"],
sync_interactions=jconfig.sync,
delete_unused_application_cmds=True,
send_command_tracebacks=False,
redis=redis,
)
connect(
db=jconfig.mongo["database"],
alias="main",
authentication_source="admin",
**jconfig.mongo["connect"],
)
jconfig.get_db_config()
for extension in utils.get_extensions():
if jconfig.log_level == "DEBUG":
jurigged.watch()
if jconfig.rook_token:
rook.start(token=jconfig.rook_token, labels={"env": "dev"})
logger.info("Starting JARVIS")
logger.debug("Connecting to database")
connect(**jconfig.mongo["connect"], testing=jconfig.mongo["database"] != "jarvis")
logger.debug("Loading configuration from database")
# jconfig.get_db_config()
logger.debug("Loading extensions")
for extension in get_extensions(cogs_path):
jarvis.load_extension(extension)
print(
" https://discord.com/api/oauth2/authorize?client_id="
+ "{}&permissions=8&scope=bot%20applications.commands".format(jconfig.client_id) # noqa: W503
)
logger.debug("Loaded %s", extension)
jarvis.max_messages = jconfig.max_messages
tasks.init()
# Add event listeners
if jconfig.events:
_ = [
guild.GuildEventHandler(jarvis),
member.MemberEventHandler(jarvis),
message.MessageEventHandler(jarvis),
]
jarvis.run(jconfig.token, bot=True, reconnect=True)
for cog in jarvis.cogs:
session = getattr(cog, "_session", None)
if session:
session.close()
if restart_ctx:
return restart_ctx
logger.debug("Running JARVIS")
await jarvis.astart(jconfig.token)

838
jarvis/client.py Normal file
View file

@ -0,0 +1,838 @@
"""Custom JARVIS client."""
import asyncio
import logging
import re
import traceback
from datetime import datetime, timedelta, timezone
from aiohttp import ClientSession
from jarvis_core.db import q
from jarvis_core.db.models import (
Action,
Autopurge,
Autoreact,
Modlog,
Note,
Roleping,
Setting,
Warning,
)
from jarvis_core.filters import invites, url
from jarvis_core.util.ansi import RESET, Fore, Format, fmt
from naff import Client, listen
from naff.api.events.discord import (
MemberAdd,
MemberRemove,
MemberUpdate,
MessageCreate,
MessageDelete,
MessageUpdate,
)
from naff.api.events.internal import Button
from naff.client.errors import CommandCheckFailure, CommandOnCooldown, HTTPException
from naff.client.utils.misc_utils import find_all, get
from naff.models.discord.channel import DMChannel
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.enums import AuditLogEventType, Permissions
from naff.models.discord.message import Message
from naff.models.discord.user import Member
from naff.models.naff.context import Context, InteractionContext, PrefixedContext
from naff.models.naff.tasks.task import Task
from naff.models.naff.tasks.triggers import IntervalTrigger
from pastypy import AsyncPaste as Paste
from jarvis import const
from jarvis.utils import build_embed
from jarvis.utils.embeds import warning_embed
DEFAULT_GUILD = 862402786116763668
DEFAULT_ERROR_CHANNEL = 943395824560394250
DEFAULT_SITE = "https://paste.zevs.me"
ERROR_MSG = """
Command Information:
Guild: {guild_name}
Name: {invoked_name}
Args:
{arg_str}
Callback:
Args:
{callback_args}
Kwargs:
{callback_kwargs}
"""
KEY_FMT = fmt(Fore.GRAY)
VAL_FMT = fmt(Fore.WHITE)
CMD_FMT = fmt(Fore.GREEN, Format.BOLD)
class Jarvis(Client):
def __init__(self, *args, **kwargs): # noqa: ANN002 ANN003
redis = kwargs.pop("redis")
super().__init__(*args, **kwargs)
self.redis = redis
self.logger = logging.getLogger(__name__)
self.phishing_domains = []
self.pre_run_callback = self._prerun
@Task.create(IntervalTrigger(days=1))
async def _update_domains(self) -> None:
self.logger.debug("Updating phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/recent/86415")
response.raise_for_status()
data = await response.json()
self.logger.debug(f"Found {len(data)} changes to phishing domains")
add = 0
sub = 0
for update in data:
if update["type"] == "add":
for domain in update["domains"]:
if domain not in self.phishing_domains:
add += 1
self.phishing_domains.append(domain)
elif update["type"] == "delete":
for domain in update["domains"]:
if domain in self.phishing_domains:
sub -= 1
self.phishing_domains.remove(domain)
self.logger.debug(f"{add} additions, {sub} removals")
async def _prerun(self, ctx: Context, *args, **kwargs) -> None:
name = ctx.invoke_target
if isinstance(ctx, InteractionContext) and ctx.target_id:
kwargs["context target"] = ctx.target
args = " ".join(f"{k}:{v}" for k, v in kwargs.items())
elif isinstance(ctx, PrefixedContext):
args = " ".join(args)
self.logger.debug(f"Running command `{name}` with args: {args or 'None'}")
async def _sync_domains(self) -> None:
self.logger.debug("Loading phishing domains")
async with ClientSession(headers={"X-Identity": "Discord: zevaryx#5779"}) as session:
response = await session.get("https://phish.sinking.yachts/v2/all")
response.raise_for_status()
self.phishing_domains = await response.json()
self.logger.info(f"Protected from {len(self.phishing_domains)} phishing domains")
@listen()
async def on_ready(self) -> None:
"""NAFF on_ready override."""
try:
await self._sync_domains()
self._update_domains.start()
except Exception as e:
self.logger.error("Failed to load anti-phishing", exc_info=e)
self.logger.info("Logged in as {}".format(self.user)) # noqa: T001
self.logger.info("Connected to {} guild(s)".format(len(self.guilds))) # noqa: T001
self.logger.info("Current version: {}".format(const.__version__))
self.logger.info( # noqa: T001
"https://discord.com/api/oauth2/authorize?client_id="
"{}&permissions=8&scope=bot%20applications.commands".format(self.user.id)
)
async def on_error(self, source: str, error: Exception, *args, **kwargs) -> None:
"""NAFF on_error override."""
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
out = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
self.logger.error(out, exc_info=error)
else:
self.logger.error(f"Ignoring exception in {source}", exc_info=error)
async def on_command_error(
self, ctx: Context, error: Exception, *args: list, **kwargs: dict
) -> None:
"""NAFF on_command_error override."""
name = ctx.invoke_target
self.logger.debug(f"Handling error in {name}: {error}")
if isinstance(error, CommandOnCooldown):
await ctx.send(str(error), ephemeral=True)
return
elif isinstance(error, CommandCheckFailure):
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
guild = await self.fetch_guild(DEFAULT_GUILD)
channel = await guild.fetch_channel(DEFAULT_ERROR_CHANNEL)
error_time = datetime.now(tz=timezone.utc).strftime("%d-%m-%Y %H:%M-%S.%f UTC")
timestamp = int(datetime.now(tz=timezone.utc).timestamp())
timestamp = f"<t:{timestamp}:T>"
arg_str = ""
if isinstance(ctx, InteractionContext) and ctx.target_id:
ctx.kwargs["context target"] = ctx.target
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
arg_str += f" {k}: "
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f"{v}\n"
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
arg_str += f" - {v}"
callback_args = "\n".join(f" - {i}" for i in args) if args else " None"
callback_kwargs = (
"\n".join(f" {k}: {v}" for k, v in kwargs.items()) if kwargs else " None"
)
full_message = ERROR_MSG.format(
guild_name=ctx.guild.name,
error_time=error_time,
invoked_name=name,
arg_str=arg_str,
callback_args=callback_args,
callback_kwargs=callback_kwargs,
)
tb = traceback.format_exception(error)
if isinstance(error, HTTPException):
errors = error.search_for_message(error.errors)
tb[-1] = f"HTTPException: {error.status}|{error.response.reason}: " + "\n".join(errors)
error_message = "".join(traceback.format_exception(error))
if len(full_message + error_message) >= 1800:
error_message = "\n ".join(error_message.split("\n"))
full_message += "Exception: |\n " + error_message
paste = Paste(content=full_message, site=DEFAULT_SITE)
key = await paste.save()
self.logger.debug(f"Large traceback, saved to Pasty {paste.id}, {key=}")
await channel.send(
f"JARVIS encountered an error at {timestamp}. Log too big to send over Discord."
f"\nPlease see log at {paste.url}"
)
else:
await channel.send(
f"JARVIS encountered an error at {timestamp}:"
f"\n```yaml\n{full_message}\n```"
f"\nException:\n```py\n{error_message}\n```"
)
await ctx.send("Whoops! Encountered an error. The error has been logged.", ephemeral=True)
try:
return await super().on_command_error(ctx, error, *args, **kwargs)
except Exception as e:
self.logger.error("Uncaught exception", exc_info=e)
# Modlog
async def on_command(self, ctx: Context) -> None:
"""NAFF on_command override."""
name = ctx.invoke_target
if not isinstance(ctx.channel, DMChannel) and name not in ["pw"]:
modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="activitylog"))
if modlog:
channel = await ctx.guild.fetch_channel(modlog.value)
args = []
if isinstance(ctx, InteractionContext) and ctx.target_id:
args.append(f"{KEY_FMT}context target:{VAL_FMT}{ctx.target}{RESET}")
if isinstance(ctx, InteractionContext):
for k, v in ctx.kwargs.items():
if isinstance(v, str):
v = v.replace("`", "\\`")
if len(v) > 100:
v = v[:97] + "..."
args.append(f"{KEY_FMT}{k}:{VAL_FMT}{v}{RESET}")
elif isinstance(ctx, PrefixedContext):
for v in ctx.args:
if isinstance(v, str) and len(v) > 100:
v = v[97] + "..."
args.append(f"{VAL_FMT}{v}{RESET}")
args = " ".join(args)
fields = [
EmbedField(
name="Command",
value=f"```ansi\n{CMD_FMT}{ctx.invoke_target}{RESET} {args}\n```",
inline=False,
),
]
embed = build_embed(
title="Command Invoked",
description=f"{ctx.author.mention} invoked a command in {ctx.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=ctx.author.username, icon_url=ctx.author.display_avatar.url)
embed.set_footer(
text=f"{ctx.author.user.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
if channel:
await channel.send(embed=embed)
else:
self.logger.warning(
f"Activitylog channel no longer exists in {ctx.guild.name}, removing"
)
await modlog.delete()
# Events
# Member
@listen()
async def on_member_add(self, event: MemberAdd) -> None:
"""Handle on_member_add event."""
user = event.member
guild = event.guild
unverified = await Setting.find_one(q(guild=guild.id, setting="unverified"))
if unverified:
self.logger.debug(f"Applying unverified role to {user.id} in {guild.id}")
role = await guild.fetch_role(unverified.value)
if role not in user.roles:
await user.add_role(role, reason="User just joined and is unverified")
@listen()
async def on_member_remove(self, event: MemberRemove) -> None:
"""Handle on_member_remove event."""
user = event.member
guild = event.guild
log = await Setting.find_one(q(guild=guild.id, setting="activitylog"))
if log:
self.logger.debug(f"User {user.id} left {guild.id}")
channel = await guild.fetch_channel(log.channel)
embed = build_embed(
title="Member Left",
description=f"{user.username}#{user.discriminator} left {guild.name}",
fields=[],
)
embed.set_author(name=user.username, icon_url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await channel.send(embed=embed)
async def process_verify(self, before: Member, after: Member) -> Embed:
"""Process user verification."""
auditlog = await after.guild.fetch_audit_log(
user_id=before.id, action_type=AuditLogEventType.MEMBER_ROLE_UPDATE
)
audit_event = get(auditlog.events, reason="Verification passed")
if audit_event:
admin_mention = "[N/A]"
admin_text = "[N/A]"
if admin := await after.guild.fet_member(audit_event.user_id):
admin_mention = admin.mention
admin_text = f"{admin.username}#{admin.discriminator}"
fields = (
EmbedField(name="Moderator", value=f"{admin_mention} ({admin_text})"),
EmbedField(name="Reason", value=audit_event.reason),
)
embed = build_embed(
title="User Verified",
description=f"{after.mention} was verified",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rolechange(self, before: Member, after: Member) -> Embed:
"""Process role changes."""
if before.roles == after.roles:
return
new_roles = []
removed_roles = []
for role in before.roles:
if role not in after.roles:
removed_roles.append(role)
for role in after.roles:
if role not in before.roles:
new_roles.append(role)
new_text = "\n".join(role.mention for role in new_roles) or "None"
removed_text = "\n".join(role.mention for role in removed_roles) or "None"
fields = (
EmbedField(name="Added Roles", value=new_text),
EmbedField(name="Removed Roles", value=removed_text),
)
embed = build_embed(
title="User Roles Changed",
description=f"{after.mention} had roles changed",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
async def process_rename(self, before: Member, after: Member) -> None:
"""Process name change."""
if (
before.nickname == after.nickname
and before.discriminator == after.discriminator
and before.username == after.username
):
return
fields = (
EmbedField(
name="Before",
value=f"{before.display_name} ({before.username}#{before.discriminator})",
),
EmbedField(
name="After", value=f"{after.display_name} ({after.username}#{after.discriminator})"
),
)
embed = build_embed(
title="User Renamed",
description=f"{after.mention} changed their name",
fields=fields,
color="#fc9e3f",
)
embed.set_author(name=after.display_name, icon_url=after.display_avatar.url)
embed.set_footer(text=f"{after.username}#{after.discriminator} | {after.id}")
return embed
@listen()
async def on_member_update(self, event: MemberUpdate) -> None:
"""Handle on_member_update event."""
before = event.before
after = event.after
if (before.display_name == after.display_name and before.roles == after.roles) or (
not after or not before
):
return
log = await Setting.find_one(q(guild=before.guild.id, setting="activitylog"))
if log:
channel = await before.guild.fetch_channel(log.value)
await asyncio.sleep(0.5) # Wait for audit log
embed = None
if before._role_ids != after._role_ids:
verified = await Setting.find_one(q(guild=before.guild.id, setting="verified"))
v_role = None
if verified:
v_role = await before.guild.fetch_role(verified.value)
if not v_role:
self.logger.debug(f"Guild {before.guild.id} verified role no longer exists")
await verified.delete()
else:
if not before.has_role(v_role) and after.has_role(v_role):
embed = await self.process_verify(before, after)
embed = embed or await self.process_rolechange(before, after)
embed = embed or await self.process_rename(before, after)
if embed:
await channel.send(embed=embed)
# Message
async def autopurge(self, message: Message) -> None:
"""Handle autopurge events."""
autopurge = await Autopurge.find_one(q(guild=message.guild.id, channel=message.channel.id))
if autopurge:
self.logger.debug(
f"Autopurging message {message.guild.id}/{message.channel.id}/{message.id}"
)
await message.delete(delay=autopurge.delay)
async def autoreact(self, message: Message) -> None:
"""Handle autoreact events."""
autoreact = await Autoreact.find_one(
q(
guild=message.guild.id,
channel=message.channel.id,
)
)
if autoreact:
self.logger.debug(
f"Autoreacting to message {message.guild.id}/{message.channel.id}/{message.id}"
)
for reaction in autoreact.reactions:
await message.add_reaction(reaction)
if autoreact.thread:
name = message.content
if len(name) > 100:
name = name[:97] + "..."
await message.create_thread(name=message.content, reason="Autoreact")
async def checks(self, message: Message) -> None:
"""Other message checks."""
# #tech
# channel = find(lambda x: x.id == 599068193339736096, message._mention_ids)
# if channel and message.author.id == 293795462752894976:
# await channel.send(
# content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif" # noqa: E501
# )
content = re.sub(r"\s+", "", message.content)
match = invites.search(content)
setting = await Setting.find_one(q(guild=message.guild.id, setting="noinvite"))
if not setting:
setting = Setting(guild=message.guild.id, setting="noinvite", value=True)
await setting.commit()
if match:
guild_invites = await message.guild.fetch_invites()
if message.guild.vanity_url_code:
guild_invites.append(message.guild.vanity_url_code)
allowed = [x.code for x in guild_invites] + [
"dbrand",
"VtgZntXcnZ",
"gPfYGbvTCE",
]
if (m := match.group(1)) not in allowed and setting.value:
self.logger.debug(f"Removing non-allowed invite `{m}` from {message.guild.id}")
try:
await message.delete()
except Exception:
self.logger.debug("Message deleted before action taken")
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Sent an invite link",
user=message.author.id,
).commit()
embed = warning_embed(message.author, "Sent an invite link")
await message.channel.send(embed=embed)
async def massmention(self, message: Message) -> None:
"""Handle massmention events."""
massmention = await Setting.find_one(
q(
guild=message.guild.id,
setting="massmention",
)
)
if (
massmention
and massmention.value > 0 # noqa: W503
and len(message._mention_ids + message._mention_roles) # noqa: W503
- (1 if message.author.id in message._mention_ids else 0) # noqa: W503
> massmention.value # noqa: W503
):
self.logger.debug(
f"Massmention threshold on {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Mass Mention",
user=message.author.id,
).commit()
embed = warning_embed(message.author, "Mass Mention")
await message.channel.send(embed=embed)
async def roleping(self, message: Message) -> None:
"""Handle roleping events."""
try:
if message.author.has_permission(Permissions.MANAGE_GUILD):
return
except Exception as e:
self.logger.error("Failed to get permissions, pretending check failed", exc_info=e)
if await Roleping.collection.count_documents(q(guild=message.guild.id, active=True)) == 0:
return
rolepings = await Roleping.find(q(guild=message.guild.id, active=True)).to_list(None)
# Get all role IDs involved with message
roles = [x.id async for x in message.mention_roles]
async for mention in message.mention_users:
roles += [x.id for x in mention.roles]
if not roles:
return
# Get all roles that are rolepinged
roleping_ids = [r.role for r in rolepings]
# Get roles in rolepings
role_in_rolepings = find_all(lambda x: x in roleping_ids, roles)
# Check if the user has the role, so they are allowed to ping it
user_missing_role = any(x.id not in roleping_ids for x in message.author.roles)
# Admins can ping whoever
user_is_admin = message.author.has_permission(Permissions.ADMINISTRATOR)
# Check if user in a bypass list
def check_has_role(roleping: Roleping) -> bool:
return any(role.id in roleping.bypass["roles"] for role in message.author.roles)
user_has_bypass = False
for roleping in rolepings:
if message.author.id in roleping.bypass["users"]:
user_has_bypass = True
break
if check_has_role(roleping):
user_has_bypass = True
break
if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass:
self.logger.debug(
f"Rolepinged role in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Pinged a blocked role/user with a blocked role",
user=message.author.id,
).commit()
embed = warning_embed(message.author, "Pinged a blocked role/user with a blocked role")
await message.channel.send(embed=embed)
async def phishing(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
if (m := match.group("domain")) in self.phishing_domains:
self.logger.debug(
f"Phishing url `{m}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Phishing URL",
user=message.author.id,
).commit()
embed = warning_embed(message.author, "Phishing URL")
await message.channel.send(embed=embed)
await message.delete()
return True
return False
async def malicious_url(self, message: Message) -> None:
"""Check if the message contains any known phishing domains."""
for match in url.finditer(message.content):
async with ClientSession() as session:
resp = await session.get(
"https://spoopy.oceanlord.me/api/check_website", json={"website": match.string}
)
if resp.status != 200:
break
data = await resp.json()
for item in data["processed"]["urls"].values():
if not item["safe"]:
self.logger.debug(
f"Scam url `{match.string}` detected in {message.guild.id}/{message.channel.id}/{message.id}"
)
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=24)
await Warning(
active=True,
admin=self.user.id,
duration=24,
expires_at=expires_at,
guild=message.guild.id,
reason="Unsafe URL",
user=message.author.id,
).commit()
reasons = ", ".join(item["not_safe_reasons"])
embed = warning_embed(message.author, reasons)
await message.channel.send(embed=embed)
await message.delete()
return True
return False
@listen()
async def on_message(self, event: MessageCreate) -> None:
"""Handle on_message event. Calls other event handlers."""
message = event.message
if not isinstance(message.channel, DMChannel) and not message.author.bot:
await self.autoreact(message)
await self.massmention(message)
await self.roleping(message)
await self.autopurge(message)
await self.checks(message)
if not await self.phishing(message):
await self.malicious_url(message)
@listen()
async def on_message_edit(self, event: MessageUpdate) -> None:
"""Process on_message_edit events."""
before = event.before
after = event.after
if not after.author.bot:
modlog = await Setting.find_one(q(guild=after.guild.id, setting="activitylog"))
if modlog:
if not before or before.content == after.content or before.content is None:
return
try:
channel = before.guild.get_channel(modlog.value)
fields = [
EmbedField(
"Original Message",
before.content if before.content else "N/A",
False,
),
EmbedField(
"New Message",
after.content if after.content else "N/A",
False,
),
]
embed = build_embed(
title="Message Edited",
description=f"{after.author.mention} edited a message in {before.channel.mention}",
fields=fields,
color="#fc9e3f",
timestamp=after.edited_timestamp,
url=after.jump_url,
)
embed.set_author(
name=after.author.username,
icon_url=after.author.display_avatar.url,
url=after.jump_url,
)
embed.set_footer(
text=f"{after.author.username}#{after.author.discriminator} | {after.author.id}"
)
await channel.send(embed=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {before.guild.id}/{before.channel.id}/{before.id}: {e}"
)
if not isinstance(after.channel, DMChannel) and not after.author.bot:
await self.massmention(after)
await self.roleping(after)
await self.checks(after)
await self.roleping(after)
await self.checks(after)
if not await self.phishing(after):
await self.malicious_url(after)
@listen()
async def on_message_delete(self, event: MessageDelete) -> None:
"""Process on_message_delete events."""
message = event.message
modlog = await Setting.find_one(q(guild=message.guild.id, setting="activitylog"))
if modlog:
try:
content = message.content or "N/A"
except AttributeError:
content = "N/A"
fields = [EmbedField("Original Message", content, False)]
try:
if message.attachments:
value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments])
fields.append(
EmbedField(
name="Attachments",
value=value,
inline=False,
)
)
if message.sticker_items:
value = "\n".join([f"Sticker: {x.name}" for x in message.sticker_items])
fields.append(
EmbedField(
name="Stickers",
value=value,
inline=False,
)
)
if message.embeds:
value = str(len(message.embeds)) + " embeds"
fields.append(
EmbedField(
name="Embeds",
value=value,
inline=False,
)
)
channel = message.guild.get_channel(modlog.value)
embed = build_embed(
title="Message Deleted",
description=f"{message.author.mention}'s message was deleted from {message.channel.mention}",
fields=fields,
color="#fc9e3f",
)
embed.set_author(
name=message.author.username,
icon_url=message.author.display_avatar.url,
url=message.jump_url,
)
embed.set_footer(
text=(
f"{message.author.username}#{message.author.discriminator} | "
f"{message.author.id}"
)
)
await channel.send(embed=embed)
except Exception as e:
self.logger.warning(
f"Failed to process edit {message.guild.id}/{message.channel.id}/{message.id}: {e}"
)
@listen()
async def on_button(self, event: Button) -> None:
"""Process button events."""
context = event.context
if not context.deferred and not context.responded:
await context.defer(ephemeral=True)
if not context.custom_id.startswith("modcase|"):
return await super().on_button(event)
if not context.author.has_permission(Permissions.MODERATE_MEMBERS):
return
user_key = f"msg|{context.message.id}"
action_key = ""
if context.custom_id == "modcase|yes":
if user_id := await self.redis.get(user_key):
action_key = f"{user_id}|{context.guild.id}"
if (user := await context.guild.fetch_member(user_id)) and (
action_data := await self.redis.get(action_key)
):
name, parent = action_data.split("|")[:2]
action = Action(action_type=name, parent=parent)
note = Note(
admin=context.author.id, content="Moderation case opened via message"
)
modlog = Modlog(
user=user.id,
admin=context.author.id,
guild=context.guild.id,
actions=[action],
notes=[note],
)
await modlog.commit()
fields = (
EmbedField(name="Admin", value=context.author.mention),
EmbedField(name="Opening Action", value=f"{name} {parent}"),
)
embed = build_embed(
title="Moderation Case Opened",
description=f"Moderation case opened against {user.mention}",
fields=fields,
)
embed.set_author(
name=user.username + "#" + user.discriminator,
icon_url=user.display_avatar.url,
)
await context.message.edit(embed=embed)
elif not user:
self.logger.debug("User no longer in guild")
await context.send("User no longer in guild", ephemeral=True)
else:
self.logger.warn("Unable to get action data ( %s )", action_key)
await context.send("Unable to get action data", ephemeral=True)
for row in context.message.components:
for component in row.components:
component.disabled = True
await context.message.edit(components=context.message.components)
msg = "Cancelled" if context.custom_id == "modcase|no" else "Moderation case opened"
await context.send(msg)
await self.redis.delete(user_key)
await self.redis.delete(action_key)

View file

@ -1,16 +1,40 @@
"""J.A.R.V.I.S. Admin Cogs."""
from discord.ext.commands import Bot
"""JARVIS Admin Cogs."""
import logging
from jarvis.cogs.admin import ban, kick, lock, lockdown, mute, purge, roleping, warning
from naff import Client
from jarvis.cogs.admin import (
ban,
kick,
lock,
lockdown,
modcase,
mute,
purge,
roleping,
warning,
)
def setup(bot: Bot) -> None:
"""Add admin cogs to J.A.R.V.I.S."""
bot.add_cog(ban.BanCog(bot))
bot.add_cog(kick.KickCog(bot))
bot.add_cog(lock.LockCog(bot))
bot.add_cog(lockdown.LockdownCog(bot))
bot.add_cog(mute.MuteCog(bot))
bot.add_cog(purge.PurgeCog(bot))
bot.add_cog(roleping.RolepingCog(bot))
bot.add_cog(warning.WarningCog(bot))
def setup(bot: Client) -> None:
"""Add admin cogs to JARVIS"""
logger = logging.getLogger(__name__)
msg = "Loaded jarvis.cogs.admin.{}"
ban.BanCog(bot)
logger.debug(msg.format("ban"))
kick.KickCog(bot)
logger.debug(msg.format("kick"))
lock.LockCog(bot)
logger.debug(msg.format("lock"))
lockdown.LockdownCog(bot)
logger.debug(msg.format("lockdown"))
modcase.CaseCog(bot)
logger.debug(msg.format("modcase"))
mute.MuteCog(bot)
logger.debug(msg.format("mute"))
purge.PurgeCog(bot)
logger.debug(msg.format("purge"))
roleping.RolepingCog(bot)
logger.debug(msg.format("roleping"))
warning.WarningCog(bot)
logger.debug(msg.format("warning"))

View file

@ -1,31 +1,33 @@
"""J.A.R.V.I.S. BanCog."""
"""JARVIS BanCog."""
import re
from datetime import datetime, timedelta
from ButtonPaginator import Paginator
from discord import User
from discord.ext import commands
from discord.utils import find
from discord_slash import SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from discord_slash.utils.manage_commands import create_choice, create_option
from jarvis_core.db import q
from jarvis_core.db.models import Ban, Unban
from naff import InteractionContext, Permissions
from naff.client.utils.misc_utils import find, find_all
from naff.ext.paginators import Paginator
from naff.models.discord.embed import EmbedField
from naff.models.discord.user import User
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
SlashCommandChoice,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Ban, Unban
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.permissions import admin_or_permissions
class BanCog(CacheCog):
"""J.A.R.V.I.S. BanCog."""
def __init__(self, bot: commands.Bot):
super().__init__(bot)
class BanCog(ModcaseCog):
"""JARVIS BanCog."""
async def discord_apply_ban(
self,
ctx: SlashContext,
ctx: InteractionContext,
reason: str,
user: User,
duration: int,
@ -35,9 +37,9 @@ class BanCog(CacheCog):
) -> None:
"""Apply a Discord ban."""
await ctx.guild.ban(user, reason=reason)
_ = Ban(
b = Ban(
user=user.id,
username=user.name,
username=user.username,
discrim=user.discriminator,
reason=reason,
admin=ctx.author.id,
@ -45,7 +47,8 @@ class BanCog(CacheCog):
type=mtype,
duration=duration,
active=active,
).save()
)
await b.commit()
embed = build_embed(
title="User Banned",
@ -54,100 +57,86 @@ class BanCog(CacheCog):
)
embed.set_author(
name=user.nick if user.nick else user.name,
icon_url=user.avatar_url,
name=user.display_name,
icon_url=user.avatar.url,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
embed.set_thumbnail(url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed)
async def discord_apply_unban(self, ctx: SlashContext, user: User, reason: str) -> None:
async def discord_apply_unban(self, ctx: InteractionContext, user: User, reason: str) -> None:
"""Apply a Discord unban."""
await ctx.guild.unban(user, reason=reason)
_ = Unban(
u = Unban(
user=user.id,
username=user.name,
username=user.username,
discrim=user.discriminator,
guild=ctx.guild.id,
admin=ctx.author.id,
reason=reason,
).save()
)
await u.commit()
embed = build_embed(
title="User Unbanned",
description=f"<@{user.id}> was unbanned",
fields=[Field(name="Reason", value=reason)],
fields=[EmbedField(name="Reason", value=reason)],
)
embed.set_author(
name=user.name,
icon_url=user.avatar_url,
name=user.username,
icon_url=user.avatar.url,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
embed.set_thumbnail(url=user.avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="ban",
description="Ban a user",
options=[
create_option(
name="user",
description="User to ban",
option_type=6,
required=True,
),
create_option(
name="reason",
description="Ban reason",
required=True,
option_type=3,
),
create_option(
@slash_command(name="ban", description="Ban a user")
@slash_option(name="user", description="User to ban", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="reason", description="Ban reason", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="btype",
description="Ban type",
option_type=3,
required=False,
opt_type=OptionTypes.STRING,
required=True,
choices=[
create_choice(value="perm", name="Permanent"),
create_choice(value="temp", name="Temporary"),
create_choice(value="soft", name="Soft"),
],
),
create_option(
name="duration",
description="Ban duration in hours if temporary",
required=False,
option_type=4,
),
SlashCommandChoice(name="Permanent", value="perm"),
SlashCommandChoice(name="Temporary", value="temp"),
SlashCommandChoice(name="Soft", value="soft"),
],
)
@admin_or_permissions(ban_members=True)
@slash_option(
name="duration",
description="Temp ban duration in hours",
opt_type=OptionTypes.INTEGER,
required=False,
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _ban(
self,
ctx: SlashContext,
user: User = None,
reason: str = None,
ctx: InteractionContext,
user: User,
reason: str,
btype: str = "perm",
duration: int = 4,
) -> None:
if not user or user == ctx.author:
await ctx.send("You cannot ban yourself.", hidden=True)
if user.id == ctx.author.id:
await ctx.send("You cannot ban yourself.", ephemeral=True)
return
if user == self.bot.user:
await ctx.send("I'm afraid I can't let you do that", hidden=True)
if user.id == self.bot.user.id:
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
if btype == "temp" and duration < 0:
await ctx.send("You cannot set a temp ban to < 0 hours.", hidden=True)
await ctx.send("You cannot set a temp ban to < 0 hours.", ephemeral=True)
return
elif btype == "temp" and duration > 744:
await ctx.send("You cannot set a temp ban to > 1 month", hidden=True)
await ctx.send("You cannot set a temp ban to > 1 month", ephemeral=True)
return
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
await ctx.send("Reason must be < 100 characters", ephemeral=True)
return
if not reason:
reason = "Mr. Stark is displeased with your presence. Please leave."
await ctx.defer()
@ -160,10 +149,10 @@ class BanCog(CacheCog):
if mtype == "temp":
user_message += f"\nDuration: {duration} hours"
fields = [Field(name="Type", value=mtype)]
fields = [EmbedField(name="Type", value=mtype)]
if mtype == "temp":
fields.append(Field(name="Duration", value=f"{duration} hour(s)"))
fields.append(EmbedField(name="Duration", value=f"{duration} hour(s)"))
user_embed = build_embed(
title=f"You have been banned from {ctx.guild.name}",
@ -172,10 +161,10 @@ class BanCog(CacheCog):
)
user_embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url,
name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar,
)
user_embed.set_thumbnail(url=ctx.guild.icon_url)
user_embed.set_thumbnail(url=ctx.guild.icon.url)
try:
await user.send(embed=user_embed)
@ -184,13 +173,13 @@ class BanCog(CacheCog):
try:
await ctx.guild.ban(user, reason=reason)
except Exception as e:
await ctx.send(f"Failed to ban user:\n```\n{e}\n```", hidden=True)
await ctx.send(f"Failed to ban user:\n```\n{e}\n```", ephemeral=True)
return
send_failed = False
if mtype == "soft":
await ctx.guild.unban(user, reason="Ban was softban")
fields.append(Field(name="DM Sent?", value=str(not send_failed)))
fields.append(EmbedField(name="DM Sent?", value=str(not send_failed)))
if btype != "temp":
duration = None
active = True
@ -199,33 +188,22 @@ class BanCog(CacheCog):
await self.discord_apply_ban(ctx, reason, user, duration, active, fields, mtype)
@cog_ext.cog_slash(
name="unban",
description="Unban a user",
options=[
create_option(
name="user",
description="User to unban",
option_type=3,
required=True,
),
create_option(
name="reason",
description="Unban reason",
required=True,
option_type=3,
),
],
@slash_command(name="unban", description="Unban a user")
@slash_option(
name="user", description="User to unban", opt_type=OptionTypes.STRING, required=True
)
@admin_or_permissions(ban_members=True)
@slash_option(
name="reason", description="Unban reason", opt_type=OptionTypes.STRING, required=True
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _unban(
self,
ctx: SlashContext,
ctx: InteractionContext,
user: str,
reason: str,
) -> None:
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
await ctx.send("Reason must be < 100 characters", ephemeral=True)
return
orig_user = user
@ -233,29 +211,35 @@ class BanCog(CacheCog):
discord_ban_info = None
database_ban_info = None
bans = await ctx.guild.bans()
bans = await ctx.guild.fetch_bans()
# Try to get ban information out of Discord
if re.match("^[0-9]{1,}$", user): # User ID
self.logger.debug(f"{user}")
if re.match(r"^[0-9]{1,}$", user): # User ID
user = int(user)
discord_ban_info = find(lambda x: x.user.id == user, bans)
else: # User name
if re.match("#[0-9]{4}$", user): # User name has discrim
if re.match(r"#[0-9]{4}$", user): # User name has discrim
user, discrim = user.split("#")
if discrim:
discord_ban_info = find(
lambda x: x.user.name == user and x.user.discriminator == discrim,
lambda x: x.user.username == user and x.user.discriminator == discrim,
bans,
)
else:
results = [x for x in filter(lambda x: x.user.name == user, bans)]
results = find_all(lambda x: x.user.username == user, bans)
if results:
if len(results) > 1:
active_bans = []
for ban in bans:
active_bans.append("{0} ({1}): {2}".format(ban.user.name, ban.user.id, ban.reason))
active_bans.append(
"{0} ({1}): {2}".format(ban.user.username, ban.user.id, ban.reason)
)
ab_message = "\n".join(active_bans)
message = f"More than one result. Please use one of the following IDs:\n```{ab_message}\n```"
message = (
"More than one result. "
f"Please use one of the following IDs:\n```{ab_message}\n```"
)
await ctx.send(message)
return
else:
@ -265,8 +249,10 @@ class BanCog(CacheCog):
# try to find the relevant information in the database.
# We take advantage of the previous checks to save CPU cycles
if not discord_ban_info:
if isinstance(user, int):
database_ban_info = Ban.objects(guild=ctx.guild.id, user=user, active=True).first()
if isinstance(user, User):
database_ban_info = await Ban.find_one(
q(guild=ctx.guild.id, user=user.id, active=True)
)
else:
search = {
"guild": ctx.guild.id,
@ -275,15 +261,16 @@ class BanCog(CacheCog):
}
if discrim:
search["discrim"] = discrim
database_ban_info = Ban.objects(**search).first()
database_ban_info = await Ban.find_one(q(**search))
if not discord_ban_info and not database_ban_info:
await ctx.send(f"Unable to find user {orig_user}", hidden=True)
await ctx.send(f"Unable to find user {orig_user}", ephemeral=True)
elif discord_ban_info:
elif discord_ban_info and not database_ban_info:
await self.discord_apply_unban(ctx, discord_ban_info.user, reason)
else:
discord_ban_info = find(lambda x: x.user.id == database_ban_info["id"], bans)
discord_ban_info = find(lambda x: x.user.id == database_ban_info.id, bans)
if discord_ban_info:
await self.discord_apply_unban(ctx, discord_ban_info.user, reason)
else:
@ -297,63 +284,48 @@ class BanCog(CacheCog):
admin=ctx.author.id,
reason=reason,
).save()
await ctx.send("Unable to find user in Discord, " + "but removed entry from database.")
await ctx.send("Unable to find user in Discord, but removed entry from database.")
@cog_ext.cog_subcommand(
base="bans",
name="list",
description="List bans",
options=[
create_option(
name="type",
bans = SlashCommand(name="bans", description="User bans")
@bans.subcommand(sub_cmd_name="list", sub_cmd_description="List bans")
@slash_option(
name="btype",
description="Ban type",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=False,
choices=[
create_choice(value=0, name="All"),
create_choice(value=1, name="Permanent"),
create_choice(value=2, name="Temporary"),
create_choice(value=3, name="Soft"),
SlashCommandChoice(name="All", value=0),
SlashCommandChoice(name="Permanent", value=1),
SlashCommandChoice(name="Temporary", value=2),
SlashCommandChoice(name="Soft", value=3),
],
),
create_option(
)
@slash_option(
name="active",
description="Active bans",
option_type=4,
opt_type=OptionTypes.BOOLEAN,
required=False,
choices=[
create_choice(value=1, name="Yes"),
create_choice(value=0, name="No"),
],
),
],
)
@admin_or_permissions(ban_members=True)
async def _bans_list(self, ctx: SlashContext, type: int = 0, active: int = 1) -> None:
active = bool(active)
exists = self.check_cache(ctx, type=type, active=active)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _bans_list(
self, ctx: InteractionContext, btype: int = 0, active: bool = True
) -> None:
types = [0, "perm", "temp", "soft"]
search = {"guild": ctx.guild.id}
if active:
search["active"] = True
if type > 0:
search["type"] = types[type]
bans = Ban.objects(**search).order_by("-created_at")
if btype > 0:
search["type"] = types[btype]
bans = await Ban.find(search).sort([("created_at", -1)]).to_list(None)
db_bans = []
fields = []
for ban in bans:
if not ban.username:
user = await self.bot.fetch_user(ban.user)
ban.username = user.name if user else "[deleted user]"
ban.username = user.username if user else "[deleted user]"
fields.append(
Field(
EmbedField(
name=f"Username: {ban.username}#{ban.discrim}",
value=(
f"Date: {ban.created_at.strftime('%d-%m-%Y')}\n"
@ -370,8 +342,8 @@ class BanCog(CacheCog):
for ban in bans:
if ban.user.id not in db_bans:
fields.append(
Field(
name=f"Username: {ban.user.name}#" + f"{ban.user.discriminator}",
EmbedField(
name=f"Username: {ban.user.username}#" + f"{ban.user.discriminator}",
value=(
f"Date: [unknown]\n"
f"User ID: {ban.user.id}\n"
@ -384,9 +356,9 @@ class BanCog(CacheCog):
pages = []
title = "Active " if active else "Inactive "
if type > 0:
title += types[type]
if type == 1:
if btype > 0:
title += types[btype]
if btype == 1:
title += "a"
title += "bans"
if len(fields) == 0:
@ -395,35 +367,14 @@ class BanCog(CacheCog):
description=f"No {'in' if not active else ''}active bans",
fields=[],
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed)
else:
for i in range(0, len(bans), 5):
embed = build_embed(title=title, description="", fields=fields[i : i + 5]) # noqa: E203
embed.set_thumbnail(url=ctx.guild.icon_url)
embed = build_embed(title=title, description="", fields=fields[i : i + 5])
embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed)
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
self.cache[hash(paginator)] = {
"guild": ctx.guild.id,
"user": ctx.author.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"type": type,
"active": active,
"paginator": paginator,
}
await paginator.start()
await paginator.send(ctx)

View file

@ -1,53 +1,40 @@
"""J.A.R.V.I.S. KickCog."""
from discord import User
from discord.ext.commands import Bot
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
"""JARVIS KickCog."""
from jarvis_core.db.models import Kick
from naff import InteractionContext, Permissions
from naff.models.discord.embed import EmbedField
from naff.models.discord.user import User
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Kick
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.permissions import admin_or_permissions
class KickCog(CacheCog):
"""J.A.R.V.I.S. KickCog."""
class KickCog(ModcaseCog):
"""JARVIS KickCog."""
def __init__(self, bot: Bot):
super().__init__(bot)
@cog_ext.cog_slash(
name="kick",
description="Kick a user",
options=[
create_option(
name="user",
description="User to kick",
option_type=6,
required=True,
),
create_option(
name="reason",
description="Kick reason",
required=False,
option_type=3,
),
],
@slash_command(name="kick", description="Kick a user")
@slash_option(name="user", description="User to kick", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="reason", description="Kick reason", opt_type=OptionTypes.STRING, required=True
)
@admin_or_permissions(kick_members=True)
async def _kick(self, ctx: SlashContext, user: User, reason: str = None) -> None:
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _kick(self, ctx: InteractionContext, user: User, reason: str) -> None:
if not user or user == ctx.author:
await ctx.send("You cannot kick yourself.", hidden=True)
await ctx.send("You cannot kick yourself.", ephemeral=True)
return
if user == self.bot.user:
await ctx.send("I'm afraid I can't let you do that", hidden=True)
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
await ctx.send("Reason must be < 100 characters", ephemeral=True)
return
if not reason:
reason = "Mr. Stark is displeased with your presence. Please leave."
guild_name = ctx.guild.name
embed = build_embed(
title=f"You have been kicked from {guild_name}",
@ -56,36 +43,38 @@ class KickCog(CacheCog):
)
embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url,
name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.display_avatar.url,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
send_failed = False
try:
await user.send(embed=embed)
except Exception:
send_failed = True
try:
await ctx.guild.kick(user, reason=reason)
except Exception as e:
await ctx.send(f"Failed to kick user:\n```\n{e}\n```", ephemeral=True)
return
fields = [Field(name="DM Sent?", value=str(not send_failed))]
fields = [EmbedField(name="DM Sent?", value=str(not send_failed))]
embed = build_embed(
title="User Kicked",
description=f"Reason: {reason}",
fields=fields,
)
embed.set_author(
name=user.nick if user.nick else user.name,
icon_url=user.avatar_url,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed)
_ = Kick(
k = Kick(
user=user.id,
reason=reason,
admin=ctx.author.id,
guild=ctx.guild.id,
).save()
)
await k.commit()
await ctx.send(embed=embed)

View file

@ -1,134 +1,118 @@
"""J.A.R.V.I.S. LockCog."""
from contextlib import suppress
"""JARVIS LockCog."""
import logging
from typing import Union
from discord import Role, TextChannel, User, VoiceChannel
from discord.ext.commands import Bot
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from jarvis_core.db import q
from jarvis_core.db.models import Lock, Permission
from naff import Client, Cog, InteractionContext
from naff.client.utils.misc_utils import get
from naff.models.discord.channel import GuildText, GuildVoice
from naff.models.discord.enums import Permissions
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Lock
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.permissions import admin_or_permissions
class LockCog(CacheCog):
"""J.A.R.V.I.S. LockCog."""
class LockCog(Cog):
"""JARVIS LockCog."""
def __init__(self, bot: Bot):
super().__init__(bot)
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
async def _lock_channel(
self,
channel: Union[TextChannel, VoiceChannel],
role: Role,
admin: User,
reason: str,
allow_send: bool = False,
) -> None:
overrides = channel.overwrites_for(role)
if isinstance(channel, TextChannel):
overrides.send_messages = allow_send
elif isinstance(channel, VoiceChannel):
overrides.speak = allow_send
await channel.set_permissions(role, overwrite=overrides, reason=reason)
async def _unlock_channel(
self,
channel: Union[TextChannel, VoiceChannel],
role: Role,
admin: User,
) -> None:
overrides = channel.overwrites_for(role)
if isinstance(channel, TextChannel):
overrides.send_messages = None
elif isinstance(channel, VoiceChannel):
overrides.speak = None
await channel.set_permissions(role, overwrite=overrides)
@cog_ext.cog_slash(
name="lock",
description="Locks a channel",
options=[
create_option(
@slash_command(name="lock", description="Lock a channel")
@slash_option(
name="reason",
description="Lock Reason",
option_type=3,
opt_type=3,
required=True,
),
create_option(
)
@slash_option(
name="duration",
description="Lock duration in minutes (default 10)",
option_type=4,
opt_type=4,
required=False,
),
create_option(
)
@slash_option(
name="channel",
description="Channel to lock",
option_type=7,
opt_type=7,
required=False,
),
],
)
@admin_or_permissions(manage_channels=True)
@check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _lock(
self,
ctx: SlashContext,
ctx: InteractionContext,
reason: str,
duration: int = 10,
channel: Union[TextChannel, VoiceChannel] = None,
channel: Union[GuildText, GuildVoice] = None,
) -> None:
await ctx.defer(hidden=True)
await ctx.defer(ephemeral=True)
if duration <= 0:
await ctx.send("Duration must be > 0", hidden=True)
await ctx.send("Duration must be > 0", ephemeral=True)
return
elif duration >= 300:
await ctx.send("Duration must be < 5 hours", hidden=True)
elif duration > 60 * 12:
await ctx.send("Duration must be <= 12 hours", ephemeral=True)
return
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
await ctx.send("Reason must be <= 100 characters", ephemeral=True)
return
if not channel:
channel = ctx.channel
for role in ctx.guild.roles:
with suppress(Exception):
await self._lock_channel(channel, role, ctx.author, reason)
_ = Lock(
to_deny = Permissions.CONNECT | Permissions.SPEAK | Permissions.SEND_MESSAGES
current = get(channel.permission_overwrites, id=ctx.guild.id)
if current:
current = Permission(id=ctx.guild.id, allow=int(current.allow), deny=int(current.deny))
role = await ctx.guild.fetch_role(ctx.guild.id)
await channel.add_permission(target=role, deny=to_deny, reason="Locked")
await Lock(
channel=channel.id,
guild=ctx.guild.id,
admin=ctx.author.id,
reason=reason,
duration=duration,
).save()
original_perms=current,
).commit()
await ctx.send(f"{channel.mention} locked for {duration} minute(s)")
@cog_ext.cog_slash(
name="unlock",
description="Unlocks a channel",
options=[
create_option(
@slash_command(name="unlock", description="Unlock a channel")
@slash_option(
name="channel",
description="Channel to lock",
option_type=7,
description="Channel to unlock",
opt_type=OptionTypes.CHANNEL,
required=False,
),
],
)
@admin_or_permissions(manage_channels=True)
@check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _unlock(
self,
ctx: SlashContext,
channel: Union[TextChannel, VoiceChannel] = None,
ctx: InteractionContext,
channel: Union[GuildText, GuildVoice] = None,
) -> None:
if not channel:
channel = ctx.channel
lock = Lock.objects(guild=ctx.guild.id, channel=channel.id, active=True).first()
lock = await Lock.find_one(q(guild=ctx.guild.id, channel=channel.id, active=True))
if not lock:
await ctx.send(f"{channel.mention} not locked.", hidden=True)
await ctx.send(f"{channel.mention} not locked.", ephemeral=True)
return
for role in ctx.guild.roles:
with suppress(Exception):
await self._unlock_channel(channel, role, ctx.author)
overwrite = get(channel.permission_overwrites, id=ctx.guild.id)
if overwrite and lock.original_perms:
overwrite.allow = lock.original_perms.allow
overwrite.deny = lock.original_perms.deny
await channel.edit_permission(overwrite, reason="Unlock")
elif overwrite and not lock.original_perms:
await channel.delete_permission(target=overwrite, reason="Unlock")
lock.active = False
lock.save()
await lock.commit()
await ctx.send(f"{channel.mention} unlocked")

View file

@ -1,100 +1,170 @@
"""J.A.R.V.I.S. LockdownCog."""
from contextlib import suppress
from datetime import datetime
"""JARVIS LockdownCog."""
import logging
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from jarvis_core.db import q
from jarvis_core.db.models import Lock, Lockdown, Permission
from naff import Client, Cog, InteractionContext
from naff.client.utils.misc_utils import find_all, get
from naff.models.discord.channel import GuildCategory, GuildChannel
from naff.models.discord.enums import Permissions
from naff.models.discord.guild import Guild
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Lock
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.permissions import admin_or_permissions
class LockdownCog(CacheCog):
"""J.A.R.V.I.S. LockdownCog."""
async def lock(
bot: Client, target: GuildChannel, admin: Member, reason: str, duration: int
) -> None:
"""
Lock an existing channel
def __init__(self, bot: commands.Bot):
super().__init__(bot)
Args:
bot: Bot instance
target: Target channel
admin: Admin who initiated lockdown
"""
to_deny = Permissions.SEND_MESSAGES | Permissions.CONNECT | Permissions.SPEAK
current = get(target.permission_overwrites, id=target.guild.id)
if current:
current = Permission(id=target.guild.id, allow=int(current.allow), deny=int(current.deny))
role = await target.guild.fetch_role(target.guild.id)
await target.add_permission(target=role, deny=to_deny, reason="Lockdown")
await Lock(
channel=target.id,
guild=target.guild.id,
admin=admin.id,
reason=reason,
duration=duration,
original_perms=current,
).commit()
@cog_ext.cog_subcommand(
base="lockdown",
name="start",
description="Locks a server",
options=[
create_option(
name="reason",
description="Lockdown Reason",
option_type=3,
required=True,
),
create_option(
name="duration",
description="Lockdown duration in minutes (default 10)",
option_type=4,
required=False,
),
],
async def lock_all(bot: Client, guild: Guild, admin: Member, reason: str, duration: int) -> None:
"""
Lock all channels
Args:
bot: Bot instance
guild: Target guild
admin: Admin who initiated lockdown
"""
role = await guild.fetch_role(guild.id)
categories = find_all(lambda x: isinstance(x, GuildCategory), guild.channels)
for category in categories:
await lock(bot, category, admin, reason, duration)
perms = category.permissions_for(role)
for channel in category.channels:
if perms != channel.permissions_for(role):
await lock(bot, channel, admin, reason, duration)
async def unlock_all(bot: Client, guild: Guild, admin: Member) -> None:
"""
Unlock all locked channels
Args:
bot: Bot instance
target: Target channel
admin: Admin who ended lockdown
"""
locks = Lock.find(q(guild=guild.id, active=True))
async for lock in locks:
target = await guild.fetch_channel(lock.channel)
if target:
overwrite = get(target.permission_overwrites, id=guild.id)
if overwrite and lock.original_perms:
overwrite.allow = lock.original_perms.allow
overwrite.deny = lock.original_perms.deny
await target.edit_permission(overwrite, reason="Lockdown end")
elif overwrite and not lock.original_perms:
await target.delete_permission(target=overwrite, reason="Lockdown end")
lock.active = False
await lock.commit()
lockdown = await Lockdown.find_one(q(guild=guild.id, active=True))
if lockdown:
lockdown.active = False
await lockdown.commit()
class LockdownCog(Cog):
"""JARVIS LockdownCog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
lockdown = SlashCommand(
name="lockdown",
description="Manage server-wide lockdown",
)
@admin_or_permissions(manage_channels=True)
@lockdown.subcommand(
sub_cmd_name="start",
sub_cmd_description="Lockdown the server",
)
@slash_option(
name="reason", description="Lockdown reason", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="duration",
description="Duration in minutes",
opt_type=OptionTypes.INTEGER,
required=False,
)
@check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _lockdown_start(
self,
ctx: SlashContext,
ctx: InteractionContext,
reason: str,
duration: int = 10,
) -> None:
await ctx.defer(hidden=True)
await ctx.defer()
if duration <= 0:
await ctx.send("Duration must be > 0", hidden=True)
await ctx.send("Duration must be > 0", ephemeral=True)
return
elif duration >= 300:
await ctx.send("Duration must be < 5 hours", hidden=True)
await ctx.send("Duration must be < 5 hours", ephemeral=True)
return
channels = ctx.guild.channels
roles = ctx.guild.roles
updates = []
for channel in channels:
for role in roles:
with suppress(Exception):
await self._lock_channel(channel, role, ctx.author, reason)
updates.append(
Lock(
channel=channel.id,
guild=ctx.guild.id,
admin=ctx.author.id,
reason=reason,
duration=duration,
active=True,
created_at=datetime.utcnow(),
)
)
if updates:
Lock.objects().insert(updates)
await ctx.send(f"Server locked for {duration} minute(s)")
@cog_ext.cog_subcommand(
base="lockdown",
name="end",
description="Unlocks a server",
)
@commands.has_permissions(administrator=True)
exists = await Lockdown.find_one(q(guild=ctx.guild.id, active=True))
if exists:
await ctx.send("Server already in lockdown", ephemeral=True)
return
await lock_all(self.bot, ctx.guild, ctx.author, reason, duration)
role = await ctx.guild.fetch_role(ctx.guild.id)
original_perms = role.permissions
new_perms = role.permissions & ~Permissions.SEND_MESSAGES
await role.edit(permissions=new_perms)
await Lockdown(
admin=ctx.author.id,
duration=duration,
guild=ctx.guild.id,
reason=reason,
original_perms=int(original_perms),
).commit()
await ctx.send("Server now in lockdown.")
@lockdown.subcommand(sub_cmd_name="end", sub_cmd_description="End a lockdown")
@check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _lockdown_end(
self,
ctx: SlashContext,
ctx: InteractionContext,
) -> None:
channels = ctx.guild.channels
roles = ctx.guild.roles
update = False
locks = Lock.objects(guild=ctx.guild.id, active=True)
if not locks:
await ctx.send("No lockdown detected.", hidden=True)
return
await ctx.defer()
for channel in channels:
for role in roles:
with suppress(Exception):
await self._unlock_channel(channel, role, ctx.author)
update = True
if update:
Lock.objects(guild=ctx.guild.id, active=True).update(set__active=False)
await ctx.send("Server unlocked")
lockdown = await Lockdown.find_one(q(guild=ctx.guild.id, active=True))
if not lockdown:
await ctx.send("Server not in lockdown", ephemeral=True)
return
await unlock_all(self.bot, ctx.guild, ctx.author)
await ctx.send("Server no longer in lockdown.")

View file

@ -0,0 +1,332 @@
"""JARVIS Moderation Case management."""
from typing import TYPE_CHECKING, List, Optional
from jarvis_core.db import q
from jarvis_core.db.models import Modlog, Note, actions
from naff import Cog, InteractionContext, Permissions
from naff.ext.paginators import Paginator
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from rich.console import Console
from rich.table import Table
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
if TYPE_CHECKING:
from naff.models.discord.guild import Guild
ACTIONS_LOOKUP = {
"ban": actions.Ban,
"kick": actions.Kick,
"mute": actions.Mute,
"unban": actions.Unban,
"warning": actions.Warning,
}
class CaseCog(Cog):
"""JARVIS CaseCog."""
async def get_summary_embed(self, mod_case: Modlog, guild: "Guild") -> Embed:
"""
Get Moderation case summary embed.
Args:
mod_case: Moderation case
guild: Originating guild
"""
action_table = Table()
action_table.add_column(header="Type", justify="left", style="orange4", no_wrap=True)
action_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True)
action_table.add_column(header="Reason", justify="left", style="white")
note_table = Table()
note_table.add_column(header="Admin", justify="left", style="cyan", no_wrap=True)
note_table.add_column(header="Content", justify="left", style="white")
console = Console()
action_output = ""
action_output_extra = ""
for idx, action in enumerate(mod_case.actions):
parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent))
if not parent_action:
action.orphaned = True
action_table.add_row(action.action_type.title(), "[N/A]", "[N/A]")
else:
admin = await self.bot.fetch_user(parent_action.admin)
admin_text = "[N/A]"
if admin:
admin_text = f"{admin.username}#{admin.discriminator}"
action_table.add_row(action.action_type.title(), admin_text, parent_action.reason)
with console.capture() as cap:
console.print(action_table)
tmp_output = cap.get()
if len(tmp_output) >= 800:
action_output_extra = f"... and {len(mod_case.actions[idx:])} more actions"
break
action_output = tmp_output
note_output = ""
note_output_extra = ""
notes = sorted(mod_case.notes, key=lambda x: x.created_at)
for idx, note in enumerate(notes):
admin = await self.bot.fetch_user(note.admin)
admin_text = "[N/A]"
if admin:
admin_text = f"{admin.username}#{admin.discriminator}"
note_table.add_row(admin_text, note.content)
with console.capture() as cap:
console.print(note_table)
tmp_output = cap.get()
if len(tmp_output) >= 1000:
note_output_extra = f"... and {len(notes[idx:])} more notes"
break
note_output = tmp_output
status = "Open" if mod_case.open else "Closed"
user = await self.bot.fetch_user(mod_case.user)
username = "[N/A]"
user_text = "[N/A]"
if user:
username = f"{user.username}#{user.discriminator}"
user_text = user.mention
admin = await self.bot.fetch_user(mod_case.admin)
admin_text = "[N/A]"
if admin:
admin_text = admin.mention
action_output = f"```ansi\n{action_output}\n{action_output_extra}\n```"
note_output = f"```ansi\n{note_output}\n{note_output_extra}\n```"
fields = (
EmbedField(
name="Actions", value=action_output if mod_case.actions else "No Actions Found"
),
EmbedField(name="Notes", value=note_output if mod_case.notes else "No Notes Found"),
)
embed = build_embed(
title=f"Moderation Case [`{mod_case.nanoid}`]",
description=f"{status} case against {user_text} [**opened by {admin_text}**]",
fields=fields,
timestamp=mod_case.created_at,
)
icon_url = None
if user:
icon_url = user.avatar.url
embed.set_author(name=username, icon_url=icon_url)
embed.set_footer(text=str(mod_case.user))
await mod_case.commit()
return embed
async def get_action_embeds(self, mod_case: Modlog, guild: "Guild") -> List[Embed]:
"""
Get Moderation case action embeds.
Args:
mod_case: Moderation case
guild: Originating guild
"""
embeds = []
user = await self.bot.fetch_user(mod_case.user)
username = "[N/A]"
user_mention = "[N/A]"
avatar_url = None
if user:
username = f"{user.username}#{user.discriminator}"
avatar_url = user.avatar.url
user_mention = user.mention
for action in mod_case.actions:
if action.orphaned:
continue
parent_action = await ACTIONS_LOOKUP[action.action_type].find_one(q(id=action.parent))
if not parent_action:
action.orphaned = True
continue
admin = await self.bot.fetch_user(parent_action.admin)
admin_text = "[N/A]"
if admin:
admin_text = admin.mention
fields = (EmbedField(name=action.action_type.title(), value=parent_action.reason),)
embed = build_embed(
title="Moderation Case Action",
description=f"{admin_text} initiated an action against {user_mention}",
fields=fields,
timestamp=parent_action.created_at,
)
embed.set_author(name=username, icon_url=avatar_url)
embeds.append(embed)
await mod_case.commit()
return embeds
cases = SlashCommand(name="cases", description="Manage moderation cases")
@cases.subcommand(sub_cmd_name="list", sub_cmd_description="List moderation cases")
@slash_option(
name="user",
description="User to filter cases to",
opt_type=OptionTypes.USER,
required=False,
)
@slash_option(
name="closed",
description="Include closed cases",
opt_type=OptionTypes.BOOLEAN,
required=False,
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _cases_list(
self, ctx: InteractionContext, user: Optional[Member] = None, closed: bool = False
) -> None:
query = q(guild=ctx.guild.id)
if not closed:
query.update(q(open=True))
if user:
query.update(q(user=user.id))
cases = await Modlog.find(query).sort("created_at", -1).to_list(None)
if len(cases) == 0:
await ctx.send("No cases to view", ephemeral=True)
return
pages = [await self.get_summary_embed(c, ctx.guild) for c in cases]
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
case = SlashCommand(name="case", description="Manage a moderation case")
show = case.group(name="show", description="Show information about a specific case")
@show.subcommand(sub_cmd_name="summary", sub_cmd_description="Summarize a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_show_summary(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@show.subcommand(sub_cmd_name="actions", sub_cmd_description="Get case actions")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_show_actions(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
pages = await self.get_action_embeds(case, ctx.guild)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
@case.subcommand(sub_cmd_name="close", sub_cmd_description="Show a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_close(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
case.open = False
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="repoen", sub_cmd_description="Reopen a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_reopen(self, ctx: InteractionContext, cid: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
case.open = True
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="note", sub_cmd_description="Add a note to a specific case")
@slash_option(name="cid", description="Case ID", opt_type=OptionTypes.STRING, required=True)
@slash_option(
name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_note(self, ctx: InteractionContext, cid: str, note: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, nanoid=cid))
if not case:
await ctx.send(f"Could not find case with ID {cid}", ephemeral=True)
return
if not case.open:
await ctx.send("Case is closed, please re-open to add a new comment", ephemeral=True)
return
if len(note) > 50:
await ctx.send("Note must be <= 50 characters", ephemeral=True)
return
note = Note(admin=ctx.author.id, content=note)
case.notes.append(note)
await case.commit()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)
@case.subcommand(sub_cmd_name="new", sub_cmd_description="Open a new case")
@slash_option(name="user", description="Target user", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="note", description="Note to add", opt_type=OptionTypes.STRING, required=True
)
@check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _case_new(self, ctx: InteractionContext, user: Member, note: str) -> None:
case = await Modlog.find_one(q(guild=ctx.guild.id, user=user.id, open=True))
if case:
await ctx.send(f"Case already open with ID `{case.nanoid}`", ephemeral=True)
return
if not isinstance(user, Member):
await ctx.send("User must be in this guild", ephemeral=True)
return
if len(note) > 50:
await ctx.send("Note must be <= 50 characters", ephemeral=True)
return
note = Note(admin=ctx.author.id, content=note)
case = Modlog(
user=user.id, guild=ctx.guild.id, admin=ctx.author.id, notes=[note], actions=[]
)
await case.commit()
await case.reload()
embed = await self.get_summary_embed(case, ctx.guild)
await ctx.send(embed=embed)

View file

@ -1,132 +1,215 @@
"""J.A.R.V.I.S. MuteCog."""
from discord import Member
from discord.ext import commands
from discord.utils import get
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
"""JARVIS MuteCog."""
import asyncio
from datetime import datetime, timedelta, timezone
from dateparser import parse
from dateparser_data.settings import default_parsers
from jarvis_core.db.models import Mute
from naff import InteractionContext, Permissions
from naff.client.errors import Forbidden
from naff.models.discord.embed import EmbedField
from naff.models.discord.modal import InputText, Modal, TextStyles
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
CommandTypes,
OptionTypes,
SlashCommandChoice,
context_menu,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Mute, Setting
from jarvis.utils import build_embed
from jarvis.utils.field import Field
from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.permissions import admin_or_permissions
class MuteCog(commands.Cog):
"""J.A.R.V.I.S. MuteCog."""
class MuteCog(ModcaseCog):
"""JARVIS MuteCog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@cog_ext.cog_slash(
name="mute",
description="Mute a user",
options=[
create_option(
name="user",
description="User to mute",
option_type=6,
required=True,
),
create_option(
name="reason",
description="Reason for mute",
option_type=3,
required=True,
),
create_option(
name="duration",
description="Duration of mute in minutes, default 30",
option_type=4,
required=False,
),
],
)
@admin_or_permissions(mute_members=True)
async def _mute(self, ctx: SlashContext, user: Member, reason: str, duration: int = 30) -> None:
if user == ctx.author:
await ctx.send("You cannot mute yourself.", hidden=True)
return
if user == self.bot.user:
await ctx.send("I'm afraid I can't let you do that", hidden=True)
return
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
return
mute_setting = Setting.objects(guild=ctx.guild.id, setting="mute").first()
if not mute_setting:
await ctx.send(
"Please configure a mute role with /settings mute <role> first",
hidden=True,
)
return
role = get(ctx.guild.roles, id=mute_setting.value)
if role in user.roles:
await ctx.send("User already muted", hidden=True)
return
await user.add_roles(role, reason=reason)
if duration < 0 or duration > 300:
duration = -1
_ = Mute(
async def _apply_timeout(
self, ctx: InteractionContext, user: Member, reason: str, until: datetime
) -> None:
await user.timeout(communication_disabled_until=until, reason=reason)
duration = int((until - datetime.now(tz=timezone.utc)).seconds / 60)
await Mute(
user=user.id,
reason=reason,
admin=ctx.author.id,
guild=ctx.guild.id,
duration=duration,
active=True if duration >= 0 else False,
).save()
active=True,
).commit()
ts = int(until.timestamp())
embed = build_embed(
title="User Muted",
description=f"{user.mention} has been muted",
fields=[Field(name="Reason", value=reason)],
)
embed.set_author(
name=user.nick if user.nick else user.name,
icon_url=user.avatar_url,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="unmute",
description="Unmute a user",
options=[
create_option(
name="user",
description="User to unmute",
option_type=6,
required=True,
)
fields=[
EmbedField(name="Reason", value=reason),
EmbedField(name="Until", value=f"<t:{ts}:F> <t:{ts}:R>"),
],
)
@admin_or_permissions(mute_members=True)
async def _unmute(self, ctx: SlashContext, user: Member) -> None:
mute_setting = Setting.objects(guild=ctx.guild.id, setting="mute").first()
if not mute_setting:
await ctx.send(
"Please configure a mute role with /settings mute <role> first.",
hidden=True,
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
return embed
@context_menu(name="Mute User", context_type=CommandTypes.USER)
@check(
admin_or_permissions(
Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS
)
)
async def _timeout_cm(self, ctx: InteractionContext) -> None:
modal = Modal(
title=f"Muting {ctx.target.mention}",
components=[
InputText(
label="Reason?",
placeholder="Spamming, harrassment, etc",
style=TextStyles.SHORT,
custom_id="reason",
max_length=100,
),
InputText(
label="Duration",
placeholder="1h 30m | in 5 minutes | in 4 weeks",
style=TextStyles.SHORT,
custom_id="until",
max_length=100,
),
],
)
await ctx.send_modal(modal)
try:
response = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5)
reason = response.responses.get("reason")
until = response.responses.get("until")
except asyncio.TimeoutError:
return
base_settings = {
"PREFER_DATES_FROM": "future",
"TIMEZONE": "UTC",
"RETURN_AS_TIMEZONE_AWARE": True,
}
rt_settings = base_settings.copy()
rt_settings["PARSERS"] = [
x for x in default_parsers if x not in ["absolute-time", "timestamp"]
]
rt_until = parse(until, settings=rt_settings)
at_settings = base_settings.copy()
at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"]
at_until = parse(until, settings=at_settings)
old_until = until
if rt_until:
until = rt_until
elif at_until:
until = at_until
else:
self.logger.debug(f"Failed to parse delay: {until}")
await response.send(
f"`{until}` is not a parsable date, please try again", ephemeral=True
)
return
if until < datetime.now(tz=timezone.utc):
await response.send(
f"`{old_until}` is in the past, which isn't allowed", ephemeral=True
)
return
try:
embed = await self._apply_timeout(ctx, ctx.target, reason, until)
await response.send(embed=embed)
except Forbidden:
await response.send("Unable to mute this user", ephemeral=True)
role = get(ctx.guild.roles, id=mute_setting.value)
if role in user.roles:
await user.remove_roles(role, reason="Unmute")
else:
await ctx.send("User is not muted.", hidden=True)
@slash_command(name="mute", description="Mute a user")
@slash_option(name="user", description="User to mute", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="reason",
description="Reason for mute",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="time",
description="Duration of mute, default 1",
opt_type=OptionTypes.INTEGER,
required=False,
)
@slash_option(
name="scale",
description="Time scale, default Hour(s)",
opt_type=OptionTypes.INTEGER,
required=False,
choices=[
SlashCommandChoice(name="Minute(s)", value=1),
SlashCommandChoice(name="Hour(s)", value=60),
SlashCommandChoice(name="Day(s)", value=3600),
SlashCommandChoice(name="Week(s)", value=604800),
],
)
@check(
admin_or_permissions(
Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS
)
)
async def _timeout(
self, ctx: InteractionContext, user: Member, reason: str, time: int = 1, scale: int = 60
) -> None:
if user == ctx.author:
await ctx.send("You cannot mute yourself.", ephemeral=True)
return
if user == self.bot.user:
await ctx.send("I'm afraid I can't let you do that", ephemeral=True)
return
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", ephemeral=True)
return
_ = Mute.objects(guild=ctx.guild.id, user=user.id).update(set__active=False)
# Max 4 weeks (2419200 seconds) per API
duration = time * scale
if duration > 2419200:
await ctx.send("Mute must be less than 4 weeks (2419200 seconds)", ephemeral=True)
return
until = datetime.now(tz=timezone.utc) + timedelta(minutes=duration)
try:
embed = await self._apply_timeout(ctx, user, reason, until)
await ctx.send(embed=embed)
except Forbidden:
await ctx.send("Unable to mute this user", ephemeral=True)
@slash_command(name="unmute", description="Unmute a user")
@slash_option(
name="user", description="User to unmute", opt_type=OptionTypes.USER, required=True
)
@check(
admin_or_permissions(
Permissions.MUTE_MEMBERS, Permissions.BAN_MEMBERS, Permissions.KICK_MEMBERS
)
)
async def _unmute(self, ctx: InteractionContext, user: Member) -> None:
if (
not user.communication_disabled_until
or user.communication_disabled_until.timestamp()
< datetime.now(tz=timezone.utc).timestamp() # noqa: W503
):
await ctx.send("User is not muted", ephemeral=True)
return
await user.timeout(communication_disabled_until=datetime.now(tz=timezone.utc))
embed = build_embed(
title="User Unmuted",
description=f"{user.mention} has been unmuted",
fields=[],
)
embed.set_author(
name=user.nick if user.nick else user.name,
icon_url=user.avatar_url,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed)

View file

@ -1,138 +1,140 @@
"""J.A.R.V.I.S. PurgeCog."""
from discord import TextChannel
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
"""JARVIS PurgeCog."""
import logging
from jarvis_core.db import q
from jarvis_core.db.models import Autopurge, Purge
from naff import Client, Cog, InteractionContext, Permissions
from naff.models.discord.channel import GuildText
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Autopurge, Purge
from jarvis.utils.permissions import admin_or_permissions
class PurgeCog(commands.Cog):
"""J.A.R.V.I.S. PurgeCog."""
class PurgeCog(Cog):
"""JARVIS PurgeCog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash(
name="purge",
description="Purge messages from channel",
options=[
create_option(
@slash_command(name="purge", description="Purge messages from channel")
@slash_option(
name="amount",
description="Amount of messages to purge",
description="Amount of messages to purge, default 10",
opt_type=OptionTypes.INTEGER,
required=False,
option_type=4,
)
],
)
@admin_or_permissions(manage_messages=True)
async def _purge(self, ctx: SlashContext, amount: int = 10) -> None:
@check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _purge(self, ctx: InteractionContext, amount: int = 10) -> None:
if amount < 1:
await ctx.send("Amount must be >= 1", hidden=True)
await ctx.send("Amount must be >= 1", ephemeral=True)
return
await ctx.defer()
channel = ctx.channel
messages = []
async for message in channel.history(limit=amount + 1):
async for message in ctx.channel.history(limit=amount + 1):
messages.append(message)
await channel.delete_messages(messages)
_ = Purge(
await ctx.channel.delete_messages(messages, reason=f"Purge by {ctx.author.username}")
await Purge(
channel=ctx.channel.id,
guild=ctx.guild.id,
admin=ctx.author.id,
count=amount,
).save()
).commit()
@cog_ext.cog_subcommand(
base="autopurge",
name="add",
description="Automatically purge messages after x seconds",
options=[
create_option(
@slash_command(
name="autopurge", sub_cmd_name="add", sub_cmd_description="Automatically purge messages"
)
@slash_option(
name="channel",
description="Channel to autopurge",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
create_option(
)
@slash_option(
name="delay",
description="Seconds to keep message before purge, default 30",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=False,
),
],
)
@admin_or_permissions(manage_messages=True)
async def _autopurge_add(self, ctx: SlashContext, channel: TextChannel, delay: int = 30) -> None:
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
@check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_add(
self, ctx: InteractionContext, channel: GuildText, delay: int = 30
) -> None:
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a GuildText channel", ephemeral=True)
return
if delay <= 0:
await ctx.send("Delay must be > 0", hidden=True)
await ctx.send("Delay must be > 0", ephemeral=True)
return
elif delay > 300:
await ctx.send("Delay must be < 5 minutes", hidden=True)
await ctx.send("Delay must be < 5 minutes", ephemeral=True)
return
autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id).first()
autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id))
if autopurge:
await ctx.send("Autopurge already exists.", hidden=True)
await ctx.send("Autopurge already exists.", ephemeral=True)
return
_ = Autopurge(
await Autopurge(
guild=ctx.guild.id,
channel=channel.id,
admin=ctx.author.id,
delay=delay,
).save()
).commit()
await ctx.send(f"Autopurge set up on {channel.mention}, delay is {delay} seconds")
@cog_ext.cog_subcommand(
base="autopurge",
name="remove",
description="Remove an autopurge",
options=[
create_option(
@slash_command(
name="autopurge", sub_cmd_name="remove", sub_cmd_description="Remove an autopurge"
)
@slash_option(
name="channel",
description="Channel to remove from autopurge",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
],
)
@admin_or_permissions(manage_messages=True)
async def _autopurge_remove(self, ctx: SlashContext, channel: TextChannel) -> None:
autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id)
@check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_remove(self, ctx: InteractionContext, channel: GuildText) -> None:
autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autopurge:
await ctx.send("Autopurge does not exist.", hidden=True)
await ctx.send("Autopurge does not exist.", ephemeral=True)
return
autopurge.delete()
await autopurge.delete()
await ctx.send(f"Autopurge removed from {channel.mention}.")
@cog_ext.cog_subcommand(
base="autopurge",
name="update",
description="Update autopurge on a channel",
options=[
create_option(
@slash_command(
name="autopurge",
sub_cmd_name="update",
sub_cmd_description="Update autopurge on a channel",
)
@slash_option(
name="channel",
description="Channel to update",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
create_option(
)
@slash_option(
name="delay",
description="New time to save",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=True,
),
],
)
@admin_or_permissions(manage_messages=True)
async def _autopurge_update(self, ctx: SlashContext, channel: TextChannel, delay: int) -> None:
autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id)
@check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_update(
self, ctx: InteractionContext, channel: GuildText, delay: int
) -> None:
autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autopurge:
await ctx.send("Autopurge does not exist.", hidden=True)
await ctx.send("Autopurge does not exist.", ephemeral=True)
return
autopurge.delay = delay
autopurge.save()
await autopurge.commit()
await ctx.send(f"Autopurge delay updated to {delay} seconds on {channel.mention}.")

View file

@ -1,103 +1,98 @@
"""J.A.R.V.I.S. RolepingCog."""
from datetime import datetime, timedelta
"""JARVIS RolepingCog."""
import logging
from ButtonPaginator import Paginator
from discord import Member, Role
from discord.ext.commands import Bot
from discord_slash import SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from discord_slash.utils.manage_commands import create_option
from jarvis_core.db import q
from jarvis_core.db.models import Roleping
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import find_all
from naff.ext.paginators import Paginator
from naff.models.discord.embed import EmbedField
from naff.models.discord.role import Role
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Roleping
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions
class RolepingCog(CacheCog):
"""J.A.R.V.I.S. RolepingCog."""
class RolepingCog(Cog):
"""JARVIS RolepingCog."""
def __init__(self, bot: Bot):
super().__init__(bot)
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand(
base="roleping",
name="add",
description="Add a role to roleping",
options=[
create_option(
name="role",
description="Role to add to roleping",
option_type=8,
required=True,
roleping = SlashCommand(
name="roleping", description="Set up warnings for pinging specific roles"
)
],
@roleping.subcommand(
sub_cmd_name="add",
sub_cmd_description="Add a role to roleping",
)
@admin_or_permissions(manage_guild=True)
async def _roleping_add(self, ctx: SlashContext, role: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=role.id).first()
@slash_option(name="role", description="Role to add", opt_type=OptionTypes.ROLE, required=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_add(self, ctx: InteractionContext, role: Role) -> None:
roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if roleping:
await ctx.send(f"Role `{role.name}` already in roleping.", hidden=True)
await ctx.send(f"Role `{role.name}` already in roleping.", ephemeral=True)
return
_ = Roleping(
if role.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` to roleping", ephemeral=True)
return
_ = await Roleping(
role=role.id,
guild=ctx.guild.id,
admin=ctx.author.id,
active=True,
bypass={"roles": [], "users": []},
).save()
).commit()
await ctx.send(f"Role `{role.name}` added to roleping.")
@cog_ext.cog_subcommand(
base="roleping",
name="remove",
description="Remove a role from the roleping",
options=[
create_option(
name="role",
description="Role to remove from roleping",
option_type=8,
required=True,
@roleping.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role")
@slash_option(
name="role", description="Role to remove", opt_type=OptionTypes.ROLE, required=True
)
],
)
@admin_or_permissions(manage_guild=True)
async def _roleping_remove(self, ctx: SlashContext, role: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=role.id)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_remove(self, ctx: InteractionContext, role: Role) -> None:
roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if not roleping:
await ctx.send("Roleping does not exist", hidden=True)
await ctx.send("Roleping does not exist", ephemeral=True)
return
roleping.delete()
try:
await roleping.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
await ctx.send(f"Role `{role.name}` removed from roleping.")
@cog_ext.cog_subcommand(
base="roleping",
name="list",
description="List all blocklisted roles",
)
async def _roleping_list(self, ctx: SlashContext) -> None:
exists = self.check_cache(ctx)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
@roleping.subcommand(sub_cmd_name="list", sub_cmd_description="Lick all blocklisted roles")
async def _roleping_list(self, ctx: InteractionContext) -> None:
rolepings = Roleping.objects(guild=ctx.guild.id)
rolepings = await Roleping.find(q(guild=ctx.guild.id)).to_list(None)
if not rolepings:
await ctx.send("No rolepings configured", hidden=True)
await ctx.send("No rolepings configured", ephemeral=True)
return
embeds = []
for roleping in rolepings:
role = ctx.guild.get_role(roleping.role)
bypass_roles = list(filter(lambda x: x.id in roleping.bypass["roles"], ctx.guild.roles))
bypass_roles = [r.mention or "||`[redacted]`||" for r in bypass_roles]
bypass_users = [ctx.guild.get_member(u).mention or "||`[redacted]`||" for u in roleping.bypass["users"]]
role = await ctx.guild.fetch_role(roleping.role)
if not role:
await roleping.delete()
continue
broles = find_all(lambda x: x.id in roleping.bypass["roles"], ctx.guild.roles)
bypass_roles = [r.mention or "||`[redacted]`||" for r in broles]
bypass_users = [
(await ctx.guild.fetch_member(u)).mention or "||`[redacted]`||"
for u in roleping.bypass["users"]
]
bypass_roles = bypass_roles or ["None"]
bypass_users = bypass_users or ["None"]
embed = build_embed(
@ -105,229 +100,175 @@ class RolepingCog(CacheCog):
description=role.mention,
color=str(role.color),
fields=[
Field(
EmbedField(
name="Created At",
value=roleping.created_at.strftime("%a, %b %d, %Y %I:%M %p"),
inline=False,
),
Field(name="Active", value=str(roleping.active)),
Field(
# EmbedField(name="Active", value=str(roleping.active), inline=True),
EmbedField(
name="Bypass Users",
value="\n".join(bypass_users),
inline=True,
),
Field(
EmbedField(
name="Bypass Roles",
value="\n".join(bypass_roles),
inline=True,
),
],
)
admin = ctx.guild.get_member(roleping.admin)
admin = await ctx.guild.fetch_member(roleping.admin)
if not admin:
admin = self.bot.user
embed.set_author(name=admin.nick or admin.name, icon_url=admin.avatar_url)
embed.set_footer(text=f"{admin.name}#{admin.discriminator} | {admin.id}")
embed.set_author(name=admin.display_name, icon_url=admin.display_avatar.url)
embed.set_footer(text=f"{admin.username}#{admin.discriminator} | {admin.id}")
embeds.append(embed)
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=embeds,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(embeds) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
paginator = Paginator.create_from_embeds(self.bot, *embeds, timeout=300)
await paginator.send(ctx)
bypass = roleping.group(
name="bypass", description="Allow specific users/roles to ping rolepings"
)
self.cache[hash(paginator)] = {
"user": ctx.author.id,
"guild": ctx.guild.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"paginator": paginator,
}
await paginator.start()
@cog_ext.cog_subcommand(
base="roleping",
subcommand_group="bypass",
name="user",
description="Add a user as a bypass to a roleping",
base_desc="Block roles from being pinged",
sub_group_desc="Allow specific users/roles to ping rolepings",
options=[
create_option(
name="user",
description="User to add",
option_type=6,
required=True,
),
create_option(
name="rping",
description="Rolepinged role",
option_type=8,
required=True,
),
],
@bypass.subcommand(
sub_cmd_name="user",
sub_cmd_description="Add a user as a bypass to a roleping",
)
@admin_or_permissions(manage_guild=True)
async def _roleping_bypass_user(self, ctx: SlashContext, user: Member, rping: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first()
@slash_option(
name="bypass", description="User to add", opt_type=OptionTypes.USER, required=True
)
@slash_option(
name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_bypass_user(
self, ctx: InteractionContext, bypass: Member, role: Role
) -> None:
roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if not roleping:
await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True)
await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True)
return
if user.id in roleping.bypass["users"]:
await ctx.send(f"{user.mention} already in bypass", hidden=True)
if bypass.id in roleping.bypass["users"]:
await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True)
return
if len(roleping.bypass["users"]) == 10:
await ctx.send(
"Already have 10 users in bypass. Please consider using roles for roleping bypass",
hidden=True,
ephemeral=True,
)
return
matching_role = list(filter(lambda x: x.id in roleping.bypass["roles"], user.roles))
matching_role = list(filter(lambda x: x.id in roleping.bypass["roles"], bypass.roles))
if matching_role:
await ctx.send(
f"{user.mention} already has bypass via {matching_role[0].mention}",
hidden=True,
f"{bypass.mention} already has bypass via {matching_role[0].mention}",
ephemeral=True,
)
return
roleping.bypass["users"].append(user.id)
roleping.save()
await ctx.send(f"{user.nick or user.name} user bypass added for `{rping.name}`")
roleping.bypass["users"].append(bypass.id)
await roleping.commit()
await ctx.send(f"{bypass.display_name} user bypass added for `{role.name}`")
@cog_ext.cog_subcommand(
base="roleping",
subcommand_group="bypass",
name="role",
description="Add a role as a bypass to a roleping",
base_desc="Block roles from being pinged",
sub_group_desc="Allow specific users/roles to ping rolepings",
options=[
create_option(
name="role",
description="Role to add",
option_type=8,
required=True,
),
create_option(
name="rping",
description="Rolepinged role",
option_type=8,
required=True,
),
],
@bypass.subcommand(
sub_cmd_name="role",
sub_cmd_description="Add a role as a bypass to roleping",
)
@admin_or_permissions(manage_guild=True)
async def _roleping_bypass_role(self, ctx: SlashContext, role: Role, rping: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first()
@slash_option(
name="bypass", description="Role to add", opt_type=OptionTypes.ROLE, required=True
)
@slash_option(
name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_bypass_role(
self, ctx: InteractionContext, bypass: Role, role: Role
) -> None:
if bypass.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` as a bypass", ephemeral=True)
return
roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if not roleping:
await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True)
await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True)
return
if role.id in roleping.bypass["roles"]:
await ctx.send(f"{role.mention} already in bypass", hidden=True)
if bypass.id in roleping.bypass["roles"]:
await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True)
return
if len(roleping.bypass["roles"]) == 10:
await ctx.send(
"Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass",
hidden=True,
"Already have 10 roles in bypass. "
"Please consider consolidating roles for roleping bypass",
ephemeral=True,
)
return
roleping.bypass["roles"].append(role.id)
roleping.save()
await ctx.send(f"{role.name} role bypass added for `{rping.name}`")
roleping.bypass["roles"].append(bypass.id)
await roleping.commit()
await ctx.send(f"{bypass.name} role bypass added for `{role.name}`")
@cog_ext.cog_subcommand(
base="roleping",
subcommand_group="restore",
name="user",
description="Remove a role bypass",
base_desc="Block roles from being pinged",
sub_group_desc="Remove a bypass from a roleping (restoring it)",
options=[
create_option(
name="user",
description="User to add",
option_type=6,
required=True,
),
create_option(
name="rping",
description="Rolepinged role",
option_type=8,
required=True,
),
],
restore = roleping.group(name="restore", description="Remove a roleping bypass")
@restore.subcommand(
sub_cmd_name="user",
sub_cmd_description="Remove a bypass from a roleping (restoring it)",
)
@admin_or_permissions(manage_guild=True)
async def _roleping_restore_user(self, ctx: SlashContext, user: Member, rping: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first()
@slash_option(
name="bypass", description="User to remove", opt_type=OptionTypes.USER, required=True
)
@slash_option(
name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_restore_user(
self, ctx: InteractionContext, bypass: Member, role: Role
) -> None:
roleping: Roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if not roleping:
await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True)
await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True)
return
if user.id not in roleping.bypass["users"]:
await ctx.send(f"{user.mention} not in bypass", hidden=True)
if bypass.id not in roleping.bypass.users:
await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True)
return
roleping.bypass["users"].delete(user.id)
roleping.save()
await ctx.send(f"{user.nick or user.name} user bypass removed for `{rping.name}`")
roleping.bypass.users.remove(bypass.id)
await roleping.commit()
await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`")
@cog_ext.cog_subcommand(
base="roleping",
subcommand_group="restore",
name="role",
description="Remove a role bypass",
base_desc="Block roles from being pinged",
sub_group_desc="Remove a bypass from a roleping (restoring it)",
options=[
create_option(
name="role",
description="Role to add",
option_type=8,
required=True,
),
create_option(
name="rping",
description="Rolepinged role",
option_type=8,
required=True,
),
],
@restore.subcommand(
sub_cmd_name="role",
sub_cmd_description="Remove a bypass from a roleping (restoring it)",
)
@admin_or_permissions(manage_guild=True)
async def _roleping_restore_role(self, ctx: SlashContext, role: Role, rping: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first()
@slash_option(
name="bypass", description="Role to remove", opt_type=OptionTypes.ROLE, required=True
)
@slash_option(
name="role", description="Rolepinged role", opt_type=OptionTypes.ROLE, required=True
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _roleping_restore_role(
self, ctx: InteractionContext, bypass: Role, role: Role
) -> None:
roleping: Roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
if not roleping:
await ctx.send(f"Roleping not configured for {rping.mention}", hidden=True)
await ctx.send(f"Roleping not configured for {role.mention}", ephemeral=True)
return
if role.id in roleping.bypass["roles"]:
await ctx.send(f"{role.mention} already in bypass", hidden=True)
if bypass.id not in roleping.bypass.roles:
await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True)
return
if len(roleping.bypass["roles"]) == 10:
await ctx.send(
"Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass",
hidden=True,
)
return
roleping.bypass["roles"].append(role.id)
roleping.save()
await ctx.send(f"{role.name} role bypass added for `{rping.name}`")
roleping.bypass.roles.remove(bypass.id)
await roleping.commit()
await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`")

View file

@ -1,144 +1,120 @@
"""J.A.R.V.I.S. WarningCog."""
from datetime import datetime, timedelta
"""JARVIS WarningCog."""
from datetime import datetime, timedelta, timezone
from ButtonPaginator import Paginator
from discord import User
from discord.ext.commands import Bot
from discord_slash import SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from discord_slash.utils.manage_commands import create_choice, create_option
from jarvis_core.db import q
from jarvis_core.db.models import Warning
from naff import InteractionContext, Permissions
from naff.client.utils.misc_utils import get_all
from naff.ext.paginators import Paginator
from naff.models.discord.embed import EmbedField
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Warning
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.embeds import warning_embed
from jarvis.utils.permissions import admin_or_permissions
class WarningCog(CacheCog):
"""J.A.R.V.I.S. WarningCog."""
class WarningCog(ModcaseCog):
"""JARVIS WarningCog."""
def __init__(self, bot: Bot):
super().__init__(bot)
@cog_ext.cog_slash(
name="warn",
description="Warn a user",
options=[
create_option(
name="user",
description="User to warn",
option_type=6,
required=True,
),
create_option(
@slash_command(name="warn", description="Warn a user")
@slash_option(name="user", description="User to warn", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="reason",
description="Reason for warning",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
),
create_option(
)
@slash_option(
name="duration",
description="Duration of warning in hours, default 24",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=False,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _warn(self, ctx: SlashContext, user: User, reason: str, duration: int = 24) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _warn(
self, ctx: InteractionContext, user: Member, reason: str, duration: int = 24
) -> None:
if len(reason) > 100:
await ctx.send("Reason must be < 100 characters", hidden=True)
await ctx.send("Reason must be < 100 characters", ephemeral=True)
return
if duration <= 0:
await ctx.send("Duration must be > 0", hidden=True)
await ctx.send("Duration must be > 0", ephemeral=True)
return
elif duration >= 120:
await ctx.send("Duration must be < 5 days", hidden=True)
await ctx.send("Duration must be < 5 days", ephemeral=True)
return
if not await ctx.guild.fetch_member(user.id):
await ctx.send("User not in guild", ephemeral=True)
return
await ctx.defer()
_ = Warning(
expires_at = datetime.now(tz=timezone.utc) + timedelta(hours=duration)
await Warning(
user=user.id,
reason=reason,
admin=ctx.author.id,
guild=ctx.guild.id,
duration=duration,
expires_at=expires_at,
active=True,
).save()
fields = [Field("Reason", reason, False)]
embed = build_embed(
title="Warning",
description=f"{user.mention} has been warned",
fields=fields,
)
embed.set_author(
name=user.nick if user.nick else user.name,
icon_url=user.avatar_url,
)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
).commit()
embed = warning_embed(user, reason)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="warnings",
description="Get count of user warnings",
options=[
create_option(
name="user",
description="User to view",
option_type=6,
required=True,
),
create_option(
@slash_command(name="warnings", description="Get count of user warnings")
@slash_option(name="user", description="User to view", opt_type=OptionTypes.USER, required=True)
@slash_option(
name="active",
description="View only active",
option_type=4,
description="View active only",
opt_type=OptionTypes.BOOLEAN,
required=False,
choices=[
create_choice(name="Yes", value=1),
create_choice(name="No", value=0),
],
),
],
)
@admin_or_permissions(manage_guild=True)
async def _warnings(self, ctx: SlashContext, user: User, active: bool = 1) -> None:
active = bool(active)
exists = self.check_cache(ctx, user_id=user.id, active=active)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
warnings = Warning.objects(
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _warnings(self, ctx: InteractionContext, user: Member, active: bool = True) -> None:
warnings = (
await Warning.find(
q(
user=user.id,
guild=ctx.guild.id,
).order_by("-created_at")
active_warns = Warning.objects(user=user.id, guild=ctx.guild.id, active=True).order_by("-created_at")
)
)
.sort("created_at", -1)
.to_list(None)
)
if len(warnings) == 0:
await ctx.send("That user has no warnings.", ephemeral=True)
return
active_warns = get_all(warnings, active=True)
pages = []
if active:
if active_warns.count() == 0:
if len(active_warns) == 0:
embed = build_embed(
title="Warnings",
description=f"{warnings.count()} total | 0 currently active",
description=f"{len(warnings)} total | 0 currently active",
fields=[],
)
embed.set_author(name=user.name, icon_url=user.avatar_url)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_author(name=user.username, icon_url=user.display_avatar.url)
embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed)
else:
fields = []
for warn in active_warns:
admin = ctx.guild.get_member(warn.admin)
admin = await ctx.guild.fetch_member(warn.admin)
ts = int(warn.created_at.timestamp())
admin_name = "||`[redacted]`||"
if admin:
admin_name = f"{admin.name}#{admin.discriminator}"
admin_name = f"{admin.username}#{admin.discriminator}"
fields.append(
Field(
name=warn.created_at.strftime("%Y-%m-%d %H:%M:%S UTC"),
EmbedField(
name=f"<t:{ts}:F>",
value=f"{warn.reason}\nAdmin: {admin_name}\n\u200b",
inline=False,
)
@ -146,23 +122,26 @@ class WarningCog(CacheCog):
for i in range(0, len(fields), 5):
embed = build_embed(
title="Warnings",
description=f"{warnings.count()} total | {active_warns.count()} currently active",
fields=fields[i : i + 5], # noqa: E203
description=(
f"{len(warnings)} total | {len(active_warns)} currently active"
),
fields=fields[i : i + 5],
)
embed.set_author(
name=user.name + "#" + user.discriminator,
icon_url=user.avatar_url,
name=user.username + "#" + user.discriminator,
icon_url=user.display_avatar.url,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
pages.append(embed)
else:
fields = []
for warn in warnings:
ts = int(warn.created_at.timestamp())
title = "[A] " if warn.active else "[I] "
title += warn.created_at.strftime("%Y-%m-%d %H:%M:%S UTC")
title += f"<t:{ts}:F>"
fields.append(
Field(
EmbedField(
name=title,
value=warn.reason + "\n\u200b",
inline=False,
@ -171,37 +150,15 @@ class WarningCog(CacheCog):
for i in range(0, len(fields), 5):
embed = build_embed(
title="Warnings",
description=f"{warnings.count()} total | {active_warns.count()} currently active",
fields=fields[i : i + 5], # noqa: E203
description=(f"{len(warnings)} total | {len(active_warns)} currently active"),
fields=fields[i : i + 5],
)
embed.set_author(
name=user.name + "#" + user.discriminator,
icon_url=user.avatar_url,
name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed)
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
self.cache[hash(paginator)] = {
"guild": ctx.guild.id,
"user": ctx.author.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"user_id": user.id,
"active": active,
"paginator": paginator,
}
await paginator.start()
await paginator.send(ctx)

View file

@ -1,199 +1,211 @@
"""J.A.R.V.I.S. Autoreact Cog."""
"""JARVIS Autoreact Cog."""
import logging
import re
from typing import Optional, Tuple
from discord import TextChannel
from discord.ext import commands
from discord.utils import find
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from jarvis_core.db import q
from jarvis_core.db.models import Autoreact
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import find
from naff.models.discord.channel import GuildText
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from jarvis.data.unicode import emoji_list
from jarvis.db.models import Autoreact
from jarvis.utils.permissions import admin_or_permissions
class AutoReactCog(commands.Cog):
"""J.A.R.V.I.S. Autoreact Cog."""
class AutoReactCog(Cog):
"""JARVIS Autoreact Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self.custom_emote = re.compile(r"^<:\w+:(\d+)>$")
@cog_ext.cog_subcommand(
base="autoreact",
name="create",
description="Add an autoreact to a channel",
options=[
create_option(
name="channel",
description="Channel to monitor",
option_type=7,
required=True,
)
],
)
@admin_or_permissions(manage_guild=True)
async def _autoreact_create(self, ctx: SlashContext, channel: TextChannel) -> None:
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a text channel", hidden=True)
return
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first()
if exists:
await ctx.send(f"Autoreact already exists for {channel.mention}.", hidden=True)
return
async def create_autoreact(
self, ctx: InteractionContext, channel: GuildText, thread: bool
) -> Tuple[bool, Optional[str]]:
"""
Create an autoreact monitor on a channel.
_ = Autoreact(
Args:
ctx: Interaction context of command
channel: Channel to monitor
thread: Create a thread
Returns:
Tuple of success? and error message
"""
exists = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if exists:
return False, f"Autoreact already exists for {channel.mention}."
await Autoreact(
guild=ctx.guild.id,
channel=channel.id,
reactions=[],
thread=thread,
admin=ctx.author.id,
).save()
await ctx.send(f"Autoreact created for {channel.mention}!")
).commit()
@cog_ext.cog_subcommand(
base="autoreact",
name="delete",
description="Delete an autoreact from a channel",
options=[
create_option(
name="channel",
description="Channel to stop monitoring",
option_type=7,
required=True,
)
],
)
@admin_or_permissions(manage_guild=True)
async def _autoreact_delete(self, ctx: SlashContext, channel: TextChannel) -> None:
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).delete()
if exists:
await ctx.send(f"Autoreact removed from {channel.mention}")
else:
await ctx.send(f"Autoreact not found on {channel.mention}", hidden=True)
return True, None
@cog_ext.cog_subcommand(
base="autoreact",
name="add",
description="Add an autoreact emote to an existing autoreact",
options=[
create_option(
async def delete_autoreact(self, ctx: InteractionContext, channel: GuildText) -> bool:
"""
Remove an autoreact monitor on a channel.
Args:
ctx: Interaction context of command
channel: Channel to stop monitoring
Returns:
Success?
"""
ar = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if ar:
await ar.delete()
return True
return False
autoreact = SlashCommand(name="autoreact", description="Channel message autoreacts")
@autoreact.subcommand(
sub_cmd_name="add",
sub_cmd_description="Add an autoreact emote to a channel",
)
@slash_option(
name="channel",
description="Autoreact channel to add emote to",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
create_option(
name="emote",
description="Emote to add",
option_type=3,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _autoreact_add(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None:
@slash_option(
name="thread", description="Create a thread?", opt_type=OptionTypes.BOOLEAN, required=False
)
@slash_option(
name="emote", description="Emote to add", opt_type=OptionTypes.STRING, required=False
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _autoreact_add(
self, ctx: InteractionContext, channel: GuildText, thread: bool = True, emote: str = None
) -> None:
await ctx.defer()
if emote:
custom_emoji = self.custom_emote.match(emote)
standard_emoji = emote in emoji_list
if not custom_emoji and not standard_emoji:
await ctx.send(
"Please use either an emote from this server or a unicode emoji.",
hidden=True,
ephemeral=True,
)
return
if custom_emoji:
emoji_id = int(custom_emoji.group(1))
if not find(lambda x: x.id == emoji_id, ctx.guild.emojis):
await ctx.send("Please use a custom emote from this server.", hidden=True)
if not find(lambda x: x.id == emoji_id, await ctx.guild.fetch_all_custom_emojis()):
await ctx.send("Please use a custom emote from this server.", ephemeral=True)
return
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first()
if not exists:
await ctx.send(f"Please create autoreact first with /autoreact create {channel.mention}")
return
if emote in exists.reactions:
autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autoreact:
await self.create_autoreact(ctx, channel, thread)
autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if emote and emote in autoreact.reactions:
await ctx.send(
f"Emote already added to {channel.mention} autoreactions.",
hidden=True,
ephemeral=True,
)
return
if len(exists.reactions) >= 5:
if emote and len(autoreact.reactions) >= 5:
await ctx.send(
"Max number of reactions hit. Remove a different one to add this one",
hidden=True,
ephemeral=True,
)
return
exists.reactions.append(emote)
exists.save()
await ctx.send(f"Added {emote} to {channel.mention} autoreact.")
if emote:
autoreact.reactions.append(emote)
autoreact.thread = thread
await autoreact.commit()
message = ""
if emote:
message += f" Added {emote} to {channel.mention} autoreact."
message += f" Set autoreact thread creation to {thread} in {channel.mention}"
await ctx.send(message)
@cog_ext.cog_subcommand(
base="autoreact",
name="remove",
description="Remove an autoreact emote from an existing autoreact",
options=[
create_option(
@autoreact.subcommand(
sub_cmd_name="remove",
sub_cmd_description="Remove an autoreact emote to a channel",
)
@slash_option(
name="channel",
description="Autoreact channel to remove emote from",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
create_option(
name="emote",
description="Emote to remove",
option_type=3,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _autoreact_remove(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None:
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first()
if not exists:
@slash_option(
name="emote",
description="Emote to remove (use all to delete)",
opt_type=OptionTypes.STRING,
required=True,
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _autoreact_remove(
self, ctx: InteractionContext, channel: GuildText, emote: str
) -> None:
autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autoreact:
await ctx.send(
f"Please create autoreact first with /autoreact create {channel.mention}",
hidden=True,
f"Please create autoreact first with /autoreact add {channel.mention} {emote}",
ephemeral=True,
)
return
if emote not in exists.reactions:
if emote.lower() == "all":
await self.delete_autoreact(ctx, channel)
await ctx.send(f"Autoreact removed from {channel.mention}")
elif emote not in autoreact.reactions:
await ctx.send(
f"{emote} not used in {channel.mention} autoreactions.",
hidden=True,
ephemeral=True,
)
return
exists.reactions.remove(emote)
exists.save()
else:
autoreact.reactions.remove(emote)
await autoreact.commit()
if len(autoreact.reactions) == 0 and not autoreact.thread:
await self.delete_autoreact(ctx, channel)
await ctx.send(f"Removed {emote} from {channel.mention} autoreact.")
@cog_ext.cog_subcommand(
base="autoreact",
name="list",
description="List all autoreacts on a channel",
options=[
create_option(
@autoreact.subcommand(
sub_cmd_name="list",
sub_cmd_description="List all autoreacts on a channel",
)
@slash_option(
name="channel",
description="Autoreact channel to list",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _autoreact_list(self, ctx: SlashContext, channel: TextChannel) -> None:
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first()
async def _autoreact_list(self, ctx: InteractionContext, channel: GuildText) -> None:
exists = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not exists:
await ctx.send(
f"Please create autoreact first with /autoreact create {channel.mention}",
hidden=True,
f"Please create autoreact first with /autoreact add {channel.mention} <emote>",
ephemeral=True,
)
return
message = ""
if len(exists.reactions) > 0:
message = f"Current active autoreacts on {channel.mention}:\n" + "\n".join(exists.reactions)
message = f"Current active autoreacts on {channel.mention}:\n" + "\n".join(
exists.reactions
)
else:
message = f"No reactions set on {channel.mention}"
await ctx.send(message)
def setup(bot: commands.Bot) -> None:
"""Add AutoReactCog to J.A.R.V.I.S."""
bot.add_cog(AutoReactCog(bot))
def setup(bot: Client) -> None:
"""Add AutoReactCog to JARVIS"""
AutoReactCog(bot)

120
jarvis/cogs/botutil.py Normal file
View file

@ -0,0 +1,120 @@
"""JARVIS bot utility commands."""
import logging
import platform
from io import BytesIO
import psutil
from aiofile import AIOFile, LineReader
from naff import Client, Cog, PrefixedContext, prefixed_command
from naff.models.discord.embed import EmbedField
from naff.models.discord.file import File
from rich.console import Console
from jarvis.utils import build_embed
from jarvis.utils.updates import update
class BotutilCog(Cog):
"""JARVIS Bot Utility Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self.add_cog_check(self.is_owner)
async def is_owner(self, ctx: PrefixedContext) -> bool:
"""Checks if author is bot owner."""
return ctx.author.id == self.bot.owner.id
@prefixed_command(name="tail")
async def _tail(self, ctx: PrefixedContext, count: int = 10) -> None:
lines = []
async with AIOFile("jarvis.log", "r") as af:
async for line in LineReader(af):
lines.append(line)
if len(lines) == count + 1:
lines.pop(0)
log = "".join(lines)
if len(log) > 1500:
with BytesIO() as file_bytes:
file_bytes.write(log.encode("UTF8"))
file_bytes.seek(0)
log = File(file_bytes, file_name=f"tail_{count}.log")
await ctx.reply(content=f"Here's the last {count} lines of the log", file=log)
else:
await ctx.reply(content=f"```\n{log}\n```")
@prefixed_command(name="log")
async def _log(self, ctx: PrefixedContext) -> None:
async with AIOFile("jarvis.log", "r") as af:
with BytesIO() as file_bytes:
raw = await af.read_bytes()
file_bytes.write(raw)
file_bytes.seek(0)
log = File(file_bytes, file_name="jarvis.log")
await ctx.reply(content="Here's the latest log", file=log)
@prefixed_command(name="crash")
async def _crash(self, ctx: PrefixedContext) -> None:
raise Exception("As you wish")
@prefixed_command(name="sysinfo")
async def _sysinfo(self, ctx: PrefixedContext) -> None:
st_ts = int(self.bot.start_time.timestamp())
ut_ts = int(psutil.boot_time())
fields = (
EmbedField(name="Operation System", value=platform.system() or "Unknown", inline=False),
EmbedField(name="Version", value=platform.release() or "N/A", inline=False),
EmbedField(name="System Start Time", value=f"<t:{ut_ts}:F> (<t:{ut_ts}:R>)"),
EmbedField(name="Python Version", value=platform.python_version()),
EmbedField(name="Bot Start Time", value=f"<t:{st_ts}:F> (<t:{st_ts}:R>)"),
)
embed = build_embed(title="System Info", description="", fields=fields)
embed.set_image(url=self.bot.user.avatar.url)
await ctx.send(embed=embed)
@prefixed_command(name="update")
async def _update(self, ctx: PrefixedContext) -> None:
status = await update(self.bot)
if status:
console = Console()
with console.capture() as capture:
console.print(status.table)
self.logger.debug(capture.get())
self.logger.debug(len(capture.get()))
added = "\n".join(status.added)
removed = "\n".join(status.removed)
changed = "\n".join(status.changed)
fields = [
EmbedField(name="Old Commit", value=status.old_hash),
EmbedField(name="New Commit", value=status.new_hash),
]
if added:
fields.append(EmbedField(name="New Modules", value=f"```\n{added}\n```"))
if removed:
fields.append(EmbedField(name="Removed Modules", value=f"```\n{removed}\n```"))
if changed:
fields.append(EmbedField(name="Changed Modules", value=f"```\n{changed}\n```"))
embed = build_embed(
"Update Status", description="Updates have been applied", fields=fields
)
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
self.logger.info("Updates applied")
content = f"```ansi\n{capture.get()}\n```"
if len(content) < 3000:
await ctx.reply(content, embed=embed)
else:
await ctx.reply(f"Total Changes: {status.lines['total_lines']}", embed=embed)
else:
embed = build_embed(title="Update Status", description="No changes applied", fields=[])
embed.set_thumbnail(url="https://dev.zevaryx.com/git.png")
await ctx.reply(embed=embed)
def setup(bot: Client) -> None:
"""Add BotutilCog to JARVIS"""
BotutilCog(bot)

View file

@ -1,119 +1,126 @@
"""J.A.R.V.I.S. Complete the Code 2 Cog."""
"""JARVIS Complete the Code 2 Cog."""
import logging
import re
from datetime import datetime, timedelta
import aiohttp
from ButtonPaginator import Paginator
from discord import Member, User
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from jarvis_core.db import q
from jarvis_core.db.models import Guess
from naff import Client, Cog, InteractionContext
from naff.ext.paginators import Paginator
from naff.models.discord.components import ActionRow, Button, ButtonStyles
from naff.models.discord.embed import EmbedField
from naff.models.discord.user import Member, User
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
from jarvis.db.models import Guess
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
guild_ids = [578757004059738142, 520021794380447745, 862402786116763668]
guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668]
valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>,\u0080-\U000E0FFF]*")
invites = re.compile(
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)",
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", # noqa: E501
flags=re.IGNORECASE,
)
class CTCCog(CacheCog):
"""J.A.R.V.I.S. Complete the Code 2 Cog."""
class CTCCog(Cog):
"""JARVIS Complete the Code 2 Cog."""
def __init__(self, bot: commands.Bot):
super().__init__(bot)
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession()
self.url = "https://completethecodetwo.cards/pw"
def __del__(self):
self._session.close()
@cog_ext.cog_subcommand(
base="ctc2",
name="about",
description="CTC2 related commands",
guild_ids=guild_ids,
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _about(self, ctx: SlashContext) -> None:
await ctx.send("See https://completethecode.com for more information")
ctc2 = SlashCommand(name="ctc2", description="CTC2 related commands", scopes=guild_ids)
@cog_ext.cog_subcommand(
base="ctc2",
name="pw",
description="Guess a password for https://completethecodetwo.cards",
guild_ids=guild_ids,
@ctc2.subcommand(sub_cmd_name="about")
@cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _about(self, ctx: InteractionContext) -> None:
components = [
ActionRow(
Button(style=ButtonStyles.URL, url="https://completethecode.com", label="More Info")
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _pw(self, ctx: SlashContext, guess: str) -> None:
]
await ctx.send(
"See https://completethecode.com for more information", components=components
)
@ctc2.subcommand(
sub_cmd_name="pw",
sub_cmd_description="Guess a password for https://completethecodetwo.cards",
)
@slash_option(
name="guess", description="Guess a password", opt_type=OptionTypes.STRING, required=True
)
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _pw(self, ctx: InteractionContext, guess: str) -> None:
if len(guess) > 800:
await ctx.send(
"Listen here, dipshit. Don't be like <@256110768724901889>. Make your guesses < 800 characters.",
hidden=True,
(
"Listen here, dipshit. Don't be like <@256110768724901889>. "
"Make your guesses < 800 characters."
),
ephemeral=True,
)
return
elif not valid.fullmatch(guess):
await ctx.send(
"Listen here, dipshit. Don't be like <@256110768724901889>. Make your guesses *readable*.",
hidden=True,
(
"Listen here, dipshit. Don't be like <@256110768724901889>. "
"Make your guesses *readable*."
),
ephemeral=True,
)
return
elif invites.search(guess):
await ctx.send(
"Listen here, dipshit. No using this to bypass sending invite links.",
hidden=True,
ephemeral=True,
)
return
guessed = Guess.objects(guess=guess).first()
guessed = await Guess.find_one(q(guess=guess))
if guessed:
await ctx.send("Already guessed, dipshit.", hidden=True)
await ctx.send("Already guessed, dipshit.", ephemeral=True)
return
result = await self._session.post(self.url, data=guess)
correct = False
if 200 <= result.status < 400:
await ctx.send(f"{ctx.author.mention} got it! Password is {guess}!")
correct = True
else:
await ctx.send("Nope.", hidden=True)
_ = Guess(guess=guess, user=ctx.author.id, correct=correct).save()
await ctx.send("Nope.", ephemeral=True)
await Guess(guess=guess, user=ctx.author.id, correct=correct).commit()
@cog_ext.cog_subcommand(
base="ctc2",
name="guesses",
description="Show guesses made for https://completethecodetwo.cards",
guild_ids=guild_ids,
@ctc2.subcommand(
sub_cmd_name="guesses",
sub_cmd_description="Show guesses made for https://completethecodetwo.cards",
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _guesses(self, ctx: SlashContext) -> None:
exists = self.check_cache(ctx)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
guesses = Guess.objects().order_by("-correct", "-id")
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _guesses(self, ctx: InteractionContext) -> None:
await ctx.defer()
guesses = Guess.find().sort("correct", -1).sort("id", -1)
fields = []
for guess in guesses:
user = ctx.guild.get_member(guess["user"])
if not user:
async for guess in guesses:
user = await self.bot.fetch_user(guess["user"])
if not user:
user = "[redacted]"
if isinstance(user, (Member, User)):
user = user.name + "#" + user.discriminator
user = user.username + "#" + user.discriminator
name = "Correctly" if guess["correct"] else "Incorrectly"
name += " guessed by: " + user
fields.append(
Field(
EmbedField(
name=name,
value=guess["guess"] + "\n\u200b",
inline=False,
@ -123,9 +130,9 @@ class CTCCog(CacheCog):
for i in range(0, len(fields), 5):
embed = build_embed(
title="completethecodetwo.cards guesses",
description=f"{len(fields)} guesses so far",
fields=fields[i : i + 5], # noqa: E203
url="https://completethecodetwo.cards",
description=f"**{len(fields)} guesses so far**",
fields=fields[i : i + 5],
url="https://ctc2.zevaryx.com/gueses",
)
embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png")
embed.set_footer(
@ -134,30 +141,11 @@ class CTCCog(CacheCog):
)
pages.append(embed)
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
timeout=60 * 5, # 5 minute timeout
only=ctx.author,
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
self.cache[hash(paginator)] = {
"guild": ctx.guild.id,
"user": ctx.author.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"paginator": paginator,
}
await paginator.start()
await paginator.send(ctx)
def setup(bot: commands.Bot) -> None:
"""Add CTCCog to J.A.R.V.I.S."""
bot.add_cog(CTCCog(bot))
def setup(bot: Client) -> None:
"""Add CTCCog to JARVIS"""
CTCCog(bot)

View file

@ -1,165 +1,100 @@
"""J.A.R.V.I.S. dbrand cog."""
"""JARVIS dbrand cog."""
import logging
import re
import aiohttp
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from naff import Client, Cog, InteractionContext
from naff.models.discord.embed import EmbedField
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
from jarvis.config import get_config
from jarvis.config import JarvisConfig
from jarvis.data.dbrand import shipping_lookup
from jarvis.utils import build_embed
from jarvis.utils.field import Field
guild_ids = [578757004059738142, 520021794380447745, 862402786116763668]
guild_ids = [862402786116763668] # [578757004059738142, 520021794380447745, 862402786116763668]
class DbrandCog(commands.Cog):
class DbrandCog(Cog):
"""
dbrand functions for J.A.R.V.I.S.
dbrand functions for JARVIS
Mostly support functions. Credit @cpixl for the shipping API
"""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self.base_url = "https://dbrand.com/"
self._session = aiohttp.ClientSession()
self._session.headers.update({"Content-Type": "application/json"})
self.api_url = get_config().urls["dbrand_shipping"]
self.api_url = JarvisConfig.from_yaml().urls["dbrand_shipping"]
self.cache = {}
def __del__(self):
self._session.close()
@cog_ext.cog_subcommand(
base="db",
name="skin",
guild_ids=guild_ids,
description="See what skins are available",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _skin(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "/skins")
db = SlashCommand(name="db", description="dbrand commands", scopes=guild_ids)
@cog_ext.cog_subcommand(
base="db",
name="robotcamo",
guild_ids=guild_ids,
description="Get some robot camo. Make Tony Stark proud",
@db.subcommand(sub_cmd_name="info", sub_cmd_description="Get useful links")
@cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _info(self, ctx: InteractionContext) -> None:
urls = [
f"[Get Skins]({self.base_url + 'skins'})",
f"[Robot Camo]({self.base_url + 'robot-camo'})",
f"[Get a Grip]({self.base_url + 'grip'})",
f"[Shop All Products]({self.base_url + 'shop'})",
f"[Order Status]({self.base_url + 'order-status'})",
f"[dbrand Status]({self.base_url + 'status'})",
f"[Be (not) extorted]({self.base_url + 'not-extortion'})",
"[Robot Camo Wallpapers](https://db.io/wallpapers)",
]
embed = build_embed(
title="Useful Links", description="\n\n".join(urls), fields=[], color="#FFBB00"
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _camo(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "robot-camo")
embed.set_footer(
text="dbrand.com",
icon_url="https://dev.zevaryx.com/db_logo.png",
)
embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png")
embed.set_author(
name="dbrand", url=self.base_url, icon_url="https://dev.zevaryx.com/db_logo.png"
)
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="db",
name="grip",
guild_ids=guild_ids,
description="See devices with Grip support",
@db.subcommand(
sub_cmd_name="contact",
sub_cmd_description="Contact support",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _grip(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "grip")
@cog_ext.cog_subcommand(
base="db",
name="contact",
guild_ids=guild_ids,
description="Contact support",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _contact(self, ctx: SlashContext) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _contact(self, ctx: InteractionContext) -> None:
await ctx.send("Contact dbrand support here: " + self.base_url + "contact")
@cog_ext.cog_subcommand(
base="db",
name="support",
guild_ids=guild_ids,
description="Contact support",
@db.subcommand(
sub_cmd_name="support",
sub_cmd_description="Contact support",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _support(self, ctx: SlashContext) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _support(self, ctx: InteractionContext) -> None:
await ctx.send("Contact dbrand support here: " + self.base_url + "contact")
@cog_ext.cog_subcommand(
base="db",
name="orderstat",
guild_ids=guild_ids,
description="Get your order status",
@db.subcommand(
sub_cmd_name="ship",
sub_cmd_description="Get shipping information for your country",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _orderstat(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "order-status")
@cog_ext.cog_subcommand(
base="db",
name="orders",
guild_ids=guild_ids,
description="Get your order status",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _orders(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "order-status")
@cog_ext.cog_subcommand(
base="db",
name="status",
guild_ids=guild_ids,
description="dbrand status",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _status(self, ctx: SlashContext) -> None:
await ctx.send(self.base_url + "status")
@cog_ext.cog_subcommand(
base="db",
name="buy",
guild_ids=guild_ids,
description="Give us your money!",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _buy(self, ctx: SlashContext) -> None:
await ctx.send("Give us your money! " + self.base_url + "shop")
@cog_ext.cog_subcommand(
base="db",
name="extortion",
guild_ids=guild_ids,
description="(not) extortion",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _extort(self, ctx: SlashContext) -> None:
await ctx.send("Be (not) extorted here: " + self.base_url + "not-extortion")
@cog_ext.cog_subcommand(
base="db",
name="wallpapers",
description="Robot Camo Wallpapers",
guild_ids=guild_ids,
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _wallpapers(self, ctx: SlashContext) -> None:
await ctx.send("Get robot camo wallpapers here: https://db.io/wallpapers")
@cog_ext.cog_subcommand(
base="db",
name="ship",
description="Get shipping information for your country",
guild_ids=guild_ids,
options=[
(
create_option(
@slash_option(
name="search",
description="Country search query (2 character code, country name, emoji)",
option_type=3,
description="Country search query (2 character code, country name, flag emoji)",
opt_type=OptionTypes.STRING,
required=True,
)
)
],
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _shipping(self, ctx: SlashContext, search: str) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _shipping(self, ctx: InteractionContext, search: str) -> None:
await ctx.defer()
if not re.match(r"^[A-Z- ]+$", search, re.IGNORECASE):
if re.match(
@ -173,7 +108,6 @@ class DbrandCog(commands.Cog):
elif search == "🏳️":
search = "fr"
else:
print(search)
await ctx.send("Please use text to search for shipping.")
return
if len(search) > 2:
@ -193,14 +127,14 @@ class DbrandCog(commands.Cog):
fields = None
if data is not None and data["is_valid"] and data["shipping_available"]:
fields = []
fields.append(Field(data["short-name"], data["time-title"]))
fields.append(EmbedField(data["short-name"], data["time-title"]))
for service in data["shipping_services_available"][1:]:
service_data = await self._session.get(self.api_url + dest + "/" + service["url"])
if service_data.status > 400:
continue
service_data = await service_data.json()
fields.append(
Field(
EmbedField(
service_data["short-name"],
service_data["time-title"],
)
@ -230,7 +164,8 @@ class DbrandCog(commands.Cog):
embed = build_embed(
title="Check Shipping Times",
description=(
"Country not found.\nYou can [view all shipping " "destinations here](https://dbrand.com/shipping)"
"Country not found.\nYou can [view all shipping "
"destinations here](https://dbrand.com/shipping)"
),
fields=[],
url="https://dbrand.com/shipping",
@ -262,6 +197,6 @@ class DbrandCog(commands.Cog):
await ctx.send(embed=embed)
def setup(bot: commands.Bot) -> None:
"""Add dbrandcog to J.A.R.V.I.S."""
bot.add_cog(DbrandCog(bot))
def setup(bot: Client) -> None:
"""Add dbrandcog to JARVIS"""
DbrandCog(bot)

View file

@ -1,26 +1,38 @@
"""J.A.R.V.I.S. Developer Cog."""
"""JARVIS Developer Cog."""
import base64
import hashlib
import logging
import re
import subprocess # noqa: S404
import uuid as uuidpy
from typing import Any, Union
import ulid as ulidpy
from bson import ObjectId
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_choice, create_option
from jarvis_core.filters import invites, url
from jarvis_core.util import convert_bytesize, hash
from jarvis_core.util.http import get_size
from naff import Client, Cog, InteractionContext
from naff.models.discord.embed import EmbedField
from naff.models.discord.message import Attachment
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommandChoice,
slash_command,
slash_option,
)
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
from jarvis.utils import build_embed, convert_bytesize
from jarvis.utils.field import Field
from jarvis.utils import build_embed
supported_hashes = {x for x in hashlib.algorithms_guaranteed if "shake" not in x}
OID_VERIFY = re.compile(r"^([1-9][0-9]{0,3}|0)(\.([1-9][0-9]{0,3}|0)){5,13}$")
URL_VERIFY = re.compile(r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+")
URL_VERIFY = re.compile(
r"http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+"
)
DN_VERIFY = re.compile(
r"^(?:(?P<cn>CN=(?P<name>[^,]*)),)?(?:(?P<path>(?:(?:CN|OU)=[^,]+,?)+),)?(?P<domain>(?:DC=[^,]+,?)+)$"
r"^(?:(?P<cn>CN=(?P<name>[^,]*)),)?(?:(?P<path>(?:(?:CN|OU)=[^,]+,?)+),)?(?P<domain>(?:DC=[^,]+,?)+)$" # noqa: E501
)
ULID_VERIFY = re.compile(r"^[0-9a-z]{26}$", re.IGNORECASE)
UUID_VERIFY = re.compile(
@ -28,102 +40,99 @@ UUID_VERIFY = re.compile(
re.IGNORECASE,
)
invites = re.compile(
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)",
flags=re.IGNORECASE,
)
UUID_GET = {3: uuidpy.uuid3, 5: uuidpy.uuid5}
def hash_obj(hash: Any, data: Union[str, bytes], text: bool = True) -> str:
"""Hash data with hash object.
Data can be text or binary
"""
if text:
hash.update(data.encode("UTF-8"))
return hash.hexdigest()
BSIZE = 65536
block_idx = 0
while block_idx * BSIZE < len(data):
block = data[BSIZE * block_idx : BSIZE * (block_idx + 1)] # noqa: E203
hash.update(block)
block_idx += 1
return hash.hexdigest()
MAX_FILESIZE = 5 * (1024**3) # 5GB
class DevCog(commands.Cog):
"""J.A.R.V.I.S. Developer Cog."""
class DevCog(Cog):
"""JARVIS Developer Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash(
name="hash",
description="Hash some data",
options=[
create_option(
@slash_command(name="hash", description="Hash some data")
@slash_option(
name="method",
description="Hash method",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
choices=[create_choice(name=x, value=x) for x in supported_hashes],
),
create_option(
choices=[SlashCommandChoice(name=x, value=x) for x in supported_hashes],
)
@slash_option(
name="data",
description="Data to hash",
option_type=3,
required=True,
),
],
opt_type=OptionTypes.STRING,
required=False,
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _hash(self, ctx: SlashContext, method: str, data: str) -> None:
if not data:
@slash_option(
name="attach", description="File to hash", opt_type=OptionTypes.ATTACHMENT, required=False
)
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _hash(
self, ctx: InteractionContext, method: str, data: str = None, attach: Attachment = None
) -> None:
if not data and not attach:
await ctx.send(
"No data to hash",
hidden=True,
ephemeral=True,
)
return
text = True
# Default to sha256, just in case
hash = getattr(hashlib, method, hashlib.sha256)()
hex = hash_obj(hash, data, text)
data_size = convert_bytesize(len(data))
title = data if text else ctx.message.attachments[0].filename
if data and invites.match(data):
await ctx.send("No hashing invites", ephemeral=True)
return
title = data
if attach:
data = attach.url
title = attach.filename
elif url.match(data):
try:
if (size := await get_size(data)) > MAX_FILESIZE:
await ctx.send("Please hash files that are <= 5GB in size", ephemeral=True)
self.logger.debug(f"Refused to hash file of size {convert_bytesize(size)}")
return
except Exception as e:
await ctx.send(f"Failed to retrieve URL: ```\n{e}\n```", ephemeral=True)
return
title = data.split("/")[-1]
await ctx.defer()
try:
hexstr, size, c_type = await hash(data, method)
except Exception as e:
await ctx.send(f"Failed to hash data: ```\n{e}\n```", ephemeral=True)
return
data_size = convert_bytesize(size)
description = "Hashed using " + method
fields = [
Field("Data Size", data_size, False),
Field("Hash", f"`{hex}`", False),
EmbedField("Content Type", c_type, False),
EmbedField("Data Size", data_size, False),
EmbedField("Hash", f"`{hexstr}`", False),
]
embed = build_embed(title=title, description=description, fields=fields)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="uuid",
description="Generate a UUID",
options=[
create_option(
@slash_command(name="uuid", description="Generate a UUID")
@slash_option(
name="version",
description="UUID version",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
choices=[create_choice(name=x, value=x) for x in ["3", "4", "5"]],
),
create_option(
choices=[SlashCommandChoice(name=x, value=x) for x in ["3", "4", "5"]],
)
@slash_option(
name="data",
description="Data for UUID version 3,5",
option_type=3,
opt_type=OptionTypes.STRING,
required=False,
),
],
)
async def _uuid(self, ctx: SlashContext, version: str, data: str = None) -> None:
async def _uuid(self, ctx: InteractionContext, version: str, data: str = None) -> None:
version = int(version)
if version in [3, 5] and not data:
await ctx.send(f"UUID{version} requires data.", hidden=True)
await ctx.send(f"UUID{version} requires data.", ephemeral=True)
return
if version == 4:
await ctx.send(f"UUID4: `{uuidpy.uuid4()}`")
@ -139,40 +148,46 @@ class DevCog(commands.Cog):
to_send = UUID_GET[version](uuidpy.NAMESPACE_DNS, data)
await ctx.send(f"UUID{version}: `{to_send}`")
@cog_ext.cog_slash(
@slash_command(
name="objectid",
description="Generate an ObjectID",
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _objectid(self, ctx: SlashContext) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _objectid(self, ctx: InteractionContext) -> None:
await ctx.send(f"ObjectId: `{str(ObjectId())}`")
@cog_ext.cog_slash(
@slash_command(
name="ulid",
description="Generate a ULID",
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _ulid(self, ctx: SlashContext) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _ulid(self, ctx: InteractionContext) -> None:
await ctx.send(f"ULID: `{ulidpy.new().str}`")
@cog_ext.cog_slash(
@slash_command(
name="uuid2ulid",
description="Convert a UUID to a ULID",
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _uuid2ulid(self, ctx: SlashContext, uuid: str) -> None:
@slash_option(
name="uuid", description="UUID to convert", opt_type=OptionTypes.STRING, required=True
)
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _uuid2ulid(self, ctx: InteractionContext, uuid: str) -> None:
if UUID_VERIFY.match(uuid):
u = ulidpy.parse(uuid)
await ctx.send(f"ULID: `{u.str}`")
else:
await ctx.send("Invalid UUID")
@cog_ext.cog_slash(
@slash_command(
name="ulid2uuid",
description="Convert a ULID to a UUID",
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _ulid2uuid(self, ctx: SlashContext, ulid: str) -> None:
@slash_option(
name="ulid", description="ULID to convert", opt_type=OptionTypes.STRING, required=True
)
@cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _ulid2uuid(self, ctx: InteractionContext, ulid: str) -> None:
if ULID_VERIFY.match(ulid):
ulid = ulidpy.parse(ulid)
await ctx.send(f"UUID: `{ulid.uuid}`")
@ -181,82 +196,85 @@ class DevCog(commands.Cog):
base64_methods = ["b64", "b16", "b32", "a85", "b85"]
@cog_ext.cog_slash(
name="encode",
description="Encode some data",
options=[
create_option(
@slash_command(name="encode", description="Encode some data")
@slash_option(
name="method",
description="Encode method",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
choices=[create_choice(name=x, value=x) for x in base64_methods],
),
create_option(
choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods],
)
@slash_option(
name="data",
description="Data to encode",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
),
],
)
async def _encode(self, ctx: SlashContext, method: str, data: str) -> None:
async def _encode(self, ctx: InteractionContext, method: str, data: str) -> None:
if invites.search(data):
await ctx.send(
"Please don't use this to bypass invite restrictions",
ephemeral=True,
)
return
mstr = method
method = getattr(base64, method + "encode")
try:
encoded = method(data.encode("UTF-8")).decode("UTF-8")
except Exception as e:
await ctx.send(f"Failed to encode data: {e}")
return
fields = [
Field(name="Plaintext", value=f"`{data}`", inline=False),
Field(name=mstr, value=f"`{encoded}`", inline=False),
EmbedField(name="Plaintext", value=f"`{data}`", inline=False),
EmbedField(name=mstr, value=f"`{encoded}`", inline=False),
]
embed = build_embed(title="Decoded Data", description="", fields=fields)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="decode",
description="Decode some data",
options=[
create_option(
@slash_command(name="decode", description="Decode some data")
@slash_option(
name="method",
description="Decode method",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
choices=[create_choice(name=x, value=x) for x in base64_methods],
),
create_option(
choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods],
)
@slash_option(
name="data",
description="Data to encode",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
),
],
)
async def _decode(self, ctx: SlashContext, method: str, data: str) -> None:
async def _decode(self, ctx: InteractionContext, method: str, data: str) -> None:
mstr = method
method = getattr(base64, method + "decode")
try:
decoded = method(data.encode("UTF-8")).decode("UTF-8")
except Exception as e:
await ctx.send(f"Failed to decode data: {e}")
return
if invites.search(decoded):
await ctx.send(
"Please don't use this to bypass invite restrictions",
hidden=True,
ephemeral=True,
)
return
fields = [
Field(name="Plaintext", value=f"`{data}`", inline=False),
Field(name=mstr, value=f"`{decoded}`", inline=False),
EmbedField(name="Plaintext", value=f"`{data}`", inline=False),
EmbedField(name=mstr, value=f"`{decoded}`", inline=False),
]
embed = build_embed(title="Decoded Data", description="", fields=fields)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
name="cloc",
description="Get J.A.R.V.I.S. lines of code",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _cloc(self, ctx: SlashContext) -> None:
output = subprocess.check_output(["tokei", "-C", "--sort", "code"]).decode("UTF-8") # noqa: S603, S607
await ctx.send(f"```\n{output}\n```")
@slash_command(name="cloc", description="Get JARVIS lines of code")
@cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
async def _cloc(self, ctx: InteractionContext) -> None:
output = subprocess.check_output( # noqa: S603, S607
["tokei", "-C", "--sort", "code"]
).decode("UTF-8")
await ctx.send(f"```haskell\n{output}\n```")
def setup(bot: commands.Bot) -> None:
"""Add DevCog to J.A.R.V.I.S."""
bot.add_cog(DevCog(bot))
def setup(bot: Client) -> None:
"""Add DevCog to JARVIS"""
DevCog(bot)

View file

@ -1,52 +0,0 @@
"""J.A.R.V.I.S. error handling cog."""
from discord.ext import commands
from discord_slash import SlashContext
from jarvis import slash
class ErrorHandlerCog(commands.Cog):
"""J.A.R.V.I.S. error handling cog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_command_error(self, ctx: commands.Context, error: Exception) -> None:
"""d.py on_command_error override."""
if isinstance(error, commands.errors.MissingPermissions):
await ctx.send("I'm afraid I can't let you do that.")
elif isinstance(error, commands.errors.CommandNotFound):
return
elif isinstance(error, commands.errors.CommandOnCooldown):
await ctx.send(
"Command on cooldown. " + f"Please wait {error.retry_after:0.2f}s before trying again",
)
else:
await ctx.send(f"Error processing command:\n```{error}```")
ctx.command.reset_cooldown(ctx)
@commands.Cog.listener()
async def on_slash_command_error(self, ctx: SlashContext, error: Exception) -> None:
"""discord_slash on_slash_command_error override."""
if isinstance(error, commands.errors.MissingPermissions) or isinstance(error, commands.errors.CheckFailure):
await ctx.send("I'm afraid I can't let you do that.", hidden=True)
elif isinstance(error, commands.errors.CommandNotFound):
return
elif isinstance(error, commands.errors.CommandOnCooldown):
await ctx.send(
"Command on cooldown. " + f"Please wait {error.retry_after:0.2f}s before trying again",
hidden=True,
)
else:
await ctx.send(
f"Error processing command:\n```{error}```",
hidden=True,
)
raise error
slash.commands[ctx.command].reset_cooldown(ctx)
def setup(bot: commands.Bot) -> None:
"""Add ErrorHandlerCog to J.A.R.V.I.S."""
bot.add_cog(ErrorHandlerCog(bot))

View file

@ -1,495 +0,0 @@
"""J.A.R.V.I.S. GitLab Cog."""
from datetime import datetime, timedelta
import gitlab
from ButtonPaginator import Paginator
from discord import Embed
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from discord_slash.utils.manage_commands import create_choice, create_option
from jarvis.config import get_config
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
guild_ids = [862402786116763668]
class GitlabCog(CacheCog):
"""J.A.R.V.I.S. GitLab Cog."""
def __init__(self, bot: commands.Bot):
super().__init__(bot)
config = get_config()
self._gitlab = gitlab.Gitlab("https://git.zevaryx.com", private_token=config.gitlab_token)
# J.A.R.V.I.S. GitLab ID is 29
self.project = self._gitlab.projects.get(29)
@cog_ext.cog_subcommand(
base="gl",
name="issue",
description="Get an issue from GitLab",
guild_ids=guild_ids,
options=[create_option(name="id", description="Issue ID", option_type=4, required=True)],
)
async def _issue(self, ctx: SlashContext, id: int) -> None:
try:
issue = self.project.issues.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Issue does not exist.", hidden=True)
return
assignee = issue.assignee
if assignee:
assignee = assignee["name"]
else:
assignee = "None"
created_at = datetime.strptime(issue.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
labels = issue.labels
if labels:
labels = "\n".join(issue.labels)
if not labels:
labels = "None"
fields = [
Field(name="State", value=issue.state[0].upper() + issue.state[1:]),
Field(name="Assignee", value=assignee),
Field(name="Labels", value=labels),
]
color = self.project.labels.get(issue.labels[0]).color
fields.append(Field(name="Created At", value=created_at))
if issue.state == "closed":
closed_at = datetime.strptime(issue.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
fields.append(Field(name="Closed At", value=closed_at))
if issue.milestone:
fields.append(
Field(
name="Milestone",
value=f"[{issue.milestone['title']}]({issue.milestone['web_url']})",
inline=False,
)
)
if len(issue.title) > 200:
issue.title = issue.title[:200] + "..."
embed = build_embed(
title=f"[#{issue.iid}] {issue.title}",
description=issue.description,
fields=fields,
color=color,
url=issue.web_url,
)
embed.set_author(
name=issue.author["name"],
icon_url=issue.author["avatar_url"],
url=issue.author["web_url"],
)
embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png")
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="gl",
name="milestone",
description="Get a milestone from GitLab",
guild_ids=guild_ids,
options=[
create_option(
name="id",
description="Milestone ID",
option_type=4,
required=True,
)
],
)
async def _milestone(self, ctx: SlashContext, id: int) -> None:
try:
milestone = self.project.milestones.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Milestone does not exist.", hidden=True)
return
created_at = datetime.strptime(milestone.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
fields = [
Field(
name="State",
value=milestone.state[0].upper() + milestone.state[1:],
),
Field(name="Start Date", value=milestone.start_date),
Field(name="Due Date", value=milestone.due_date),
Field(name="Created At", value=created_at),
]
if milestone.updated_at:
updated_at = datetime.strptime(milestone.updated_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields.append(Field(name="Updated At", value=updated_at))
if len(milestone.title) > 200:
milestone.title = milestone.title[:200] + "..."
embed = build_embed(
title=f"[#{milestone.iid}] {milestone.title}",
description=milestone.description,
fields=fields,
color="#00FFEE",
url=milestone.web_url,
)
embed.set_author(
name="J.A.R.V.I.S.",
url="https://git.zevaryx.com/jarvis",
icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png",
)
embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png")
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="gl",
name="mergerequest",
description="Get an merge request from GitLab",
guild_ids=guild_ids,
options=[
create_option(
name="id",
description="Merge Request ID",
option_type=4,
required=True,
)
],
)
async def _mergerequest(self, ctx: SlashContext, id: int) -> None:
try:
mr = self.project.mergerequests.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Merge request does not exist.", hidden=True)
return
assignee = mr.assignee
if assignee:
assignee = assignee["name"]
else:
assignee = "None"
created_at = datetime.strptime(mr.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
labels = mr.labels
if labels:
labels = "\n".join(mr.labels)
if not labels:
labels = "None"
fields = [
Field(name="State", value=mr.state[0].upper() + mr.state[1:]),
Field(name="Assignee", value=assignee),
Field(name="Labels", value=labels),
]
if mr.labels:
color = self.project.labels.get(mr.labels[0]).color
else:
color = "#00FFEE"
fields.append(Field(name="Created At", value=created_at))
if mr.state == "merged":
merged_at = datetime.strptime(mr.merged_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
fields.append(Field(name="Merged At", value=merged_at))
elif mr.state == "closed":
closed_at = datetime.strptime(mr.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime("%Y-%m-%d %H:%M:%S UTC")
fields.append(Field(name="Closed At", value=closed_at))
if mr.milestone:
fields.append(
Field(
name="Milestone",
value=f"[{mr.milestone['title']}]({mr.milestone['web_url']})",
inline=False,
)
)
if len(mr.title) > 200:
mr.title = mr.title[:200] + "..."
embed = build_embed(
title=f"[#{mr.iid}] {mr.title}",
description=mr.description,
fields=fields,
color=color,
url=mr.web_url,
)
embed.set_author(
name=mr.author["name"],
icon_url=mr.author["avatar_url"],
url=mr.author["web_url"],
)
embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png")
await ctx.send(embed=embed)
def build_embed_page(self, api_list: list, t_state: str, name: str) -> Embed:
"""Build an embed page for the paginator."""
title = ""
if t_state:
title = f"{t_state} "
title += f"J.A.R.V.I.S. {name}s"
fields = []
for item in api_list:
fields.append(
Field(
name=f"[#{item.iid}] {item.title}",
value=item.description + f"\n\n[View this {name}]({item.web_url})",
inline=False,
)
)
embed = build_embed(
title=title,
description="",
fields=fields,
url=f"https://git.zevaryx.com/stark-industries/j.a.r.v.i.s./{name.replace(' ', '_')}s",
)
embed.set_author(
name="J.A.R.V.I.S.",
url="https://git.zevaryx.com/jarvis",
icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png",
)
embed.set_thumbnail(url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png")
return embed
@cog_ext.cog_subcommand(
base="gl",
name="issues",
description="Get open issues from GitLab",
guild_ids=guild_ids,
options=[
create_option(
name="state",
description="State of issues to get",
option_type=3,
required=False,
choices=[
create_choice(name="Open", value="opened"),
create_choice(name="Closed", value="closed"),
create_choice(name="All", value="all"),
],
)
],
)
async def _issues(self, ctx: SlashContext, state: str = "opened") -> None:
exists = self.check_cache(ctx, state=state)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
"Please use existing interaction: " + f"{exists['paginator']._message.jump_url}",
hidden=True,
)
return
await ctx.defer()
m_state = state
if m_state == "all":
m_state = None
issues = []
page = 1
try:
while curr_page := self.project.issues.list(
page=page,
state=m_state,
order_by="created_at",
sort="desc",
per_page=100,
):
issues += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get issues")
return
if len(issues) == 0:
await ctx.send("No issues match that criteria")
return
t_state = state
if t_state == "opened":
t_state = "open"
pages = []
t_state = t_state[0].upper() + t_state[1:]
for i in range(0, len(issues), 5):
pages.append(self.build_embed_page(issues[i : i + 5], t_state=t_state, name="issue")) # noqa: E203
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
self.cache[hash(paginator)] = {
"user": ctx.author.id,
"guild": ctx.guild.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"state": state,
"paginator": paginator,
}
await paginator.start()
@cog_ext.cog_subcommand(
base="gl",
name="mergerequests",
description="Get open issues from GitLab",
guild_ids=guild_ids,
options=[
create_option(
name="state",
description="State of issues to get",
option_type=3,
required=False,
choices=[
create_choice(name="Open", value="opened"),
create_choice(name="Closed", value="closed"),
create_choice(name="Merged", value="merged"),
create_choice(name="All", value="all"),
],
)
],
)
async def _mergerequests(self, ctx: SlashContext, state: str = "opened") -> None:
exists = self.check_cache(ctx, state=state)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
"Please use existing interaction: " + f"{exists['paginator']._message.jump_url}",
hidden=True,
)
return
await ctx.defer()
m_state = state
if m_state == "all":
m_state = None
merges = []
page = 1
try:
while curr_page := self.project.mergerequests.list(
page=page,
state=m_state,
order_by="created_at",
sort="desc",
per_page=100,
):
merges += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get merge requests")
return
if len(merges) == 0:
await ctx.send("No merge requests match that criteria")
return
t_state = state
if t_state == "opened":
t_state = "open"
pages = []
t_state = t_state[0].upper() + t_state[1:]
for i in range(0, len(merges), 5):
pages.append(self.build_embed_page(merges[i : i + 5], t_state=t_state, name="merge request")) # noqa: E203
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
self.cache[hash(paginator)] = {
"user": ctx.author.id,
"guild": ctx.guild.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"state": state,
"paginator": paginator,
}
await paginator.start()
@cog_ext.cog_subcommand(
base="gl",
name="milestones",
description="Get open issues from GitLab",
guild_ids=guild_ids,
)
async def _milestones(self, ctx: SlashContext) -> None:
exists = self.check_cache(ctx)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
await ctx.defer()
milestones = []
page = 1
try:
while curr_page := self.project.milestones.list(
page=page,
order_by="created_at",
sort="desc",
per_page=100,
):
milestones += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get milestones")
return
if len(milestones) == 0:
await ctx.send("No milestones exist")
return
pages = []
for i in range(0, len(milestones), 5):
pages.append(self.build_embed_page(milestones[i : i + 5], t_state=None, name="milestone")) # noqa: E203
paginator = Paginator(
bot=self.bot,
ctx=ctx,
embeds=pages,
only=ctx.author,
timeout=60 * 5, # 5 minute timeout
disable_after_timeout=True,
use_extend=len(pages) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
)
self.cache[hash(paginator)] = {
"user": ctx.author.id,
"guild": ctx.guild.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"paginator": paginator,
}
await paginator.start()
def setup(bot: commands.Bot) -> None:
"""Add GitlabCog to J.A.R.V.I.S. if Gitlab token exists."""
if get_config().gitlab_token:
bot.add_cog(GitlabCog(bot))

471
jarvis/cogs/gl.py Normal file
View file

@ -0,0 +1,471 @@
"""JARVIS GitLab Cog."""
import asyncio
import logging
from datetime import datetime
import gitlab
from naff import Client, Cog, InteractionContext
from naff.ext.paginators import Paginator
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.modal import InputText, Modal, TextStyles
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
SlashCommandChoice,
slash_command,
slash_option,
)
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
from jarvis.config import JarvisConfig
from jarvis.utils import build_embed
guild_ids = [862402786116763668]
class GitlabCog(Cog):
"""JARVIS GitLab Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml()
self._gitlab = gitlab.Gitlab("https://git.zevaryx.com", private_token=config.gitlab_token)
# JARVIS GitLab ID is 29
self.project = self._gitlab.projects.get(29)
gl = SlashCommand(name="gl", description="Get GitLab info", scopes=guild_ids)
@gl.subcommand(
sub_cmd_name="issue",
sub_cmd_description="Get an issue from GitLab",
)
@slash_option(name="id", description="Issue ID", opt_type=OptionTypes.INTEGER, required=True)
async def _issue(self, ctx: InteractionContext, id: int) -> None:
try:
issue = self.project.issues.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Issue does not exist.", ephemeral=True)
return
assignee = issue.assignee
if assignee:
assignee = assignee["name"]
else:
assignee = "None"
created_at = datetime.strptime(issue.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
labels = issue.labels
if labels:
labels = "\n".join(issue.labels)
else:
labels = "None"
fields = [
EmbedField(name="State", value=issue.state.title()),
EmbedField(name="Assignee", value=assignee),
EmbedField(name="Labels", value=labels),
]
color = "#FC6D27"
if issue.labels:
color = self.project.labels.get(issue.labels[0]).color
fields.append(EmbedField(name="Created At", value=created_at))
if issue.state == "closed":
closed_at = datetime.strptime(issue.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields.append(EmbedField(name="Closed At", value=closed_at))
if issue.milestone:
fields.append(
EmbedField(
name="Milestone",
value=f"[{issue.milestone['title']}]({issue.milestone['web_url']})",
inline=False,
)
)
if len(issue.title) > 200:
issue.title = issue.title[:200] + "..."
embed = build_embed(
title=f"[#{issue.iid}] {issue.title}",
description=issue.description,
fields=fields,
color=color,
url=issue.web_url,
)
embed.set_author(
name=issue.author["name"],
icon_url=issue.author["avatar_url"],
url=issue.author["web_url"],
)
embed.set_thumbnail(
url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png"
)
await ctx.send(embed=embed)
@gl.subcommand(
sub_cmd_name="milestone",
sub_cmd_description="Get a milestone from GitLab",
)
@slash_option(
name="id", description="Milestone ID", opt_type=OptionTypes.INTEGER, required=True
)
async def _milestone(self, ctx: InteractionContext, id: int) -> None:
try:
milestone = self.project.milestones.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Milestone does not exist.", ephemeral=True)
return
created_at = datetime.strptime(milestone.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields = [
EmbedField(
name="State",
value=milestone.state[0].upper() + milestone.state[1:],
),
EmbedField(name="Start Date", value=milestone.start_date),
EmbedField(name="Due Date", value=milestone.due_date),
EmbedField(name="Created At", value=created_at),
]
if milestone.updated_at:
updated_at = datetime.strptime(milestone.updated_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields.append(EmbedField(name="Updated At", value=updated_at))
if len(milestone.title) > 200:
milestone.title = milestone.title[:200] + "..."
embed = build_embed(
title=f"[#{milestone.iid}] {milestone.title}",
description=milestone.description,
fields=fields,
color="#00FFEE",
url=milestone.web_url,
)
embed.set_author(
name="JARVIS",
url="https://git.zevaryx.com/jarvis",
icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png",
)
embed.set_thumbnail(
url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png"
)
await ctx.send(embed=embed)
@gl.subcommand(
sub_cmd_name="mr",
sub_cmd_description="Get a merge request from GitLab",
)
@slash_option(
name="id", description="Merge Request ID", opt_type=OptionTypes.INTEGER, required=True
)
async def _mergerequest(self, ctx: InteractionContext, id: int) -> None:
try:
mr = self.project.mergerequests.get(int(id))
except gitlab.exceptions.GitlabGetError:
await ctx.send("Merge request does not exist.", ephemeral=True)
return
assignee = mr.assignee
if assignee:
assignee = assignee["name"]
else:
assignee = "None"
created_at = datetime.strptime(mr.created_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
labels = mr.labels
if labels:
labels = "\n".join(mr.labels)
if not labels:
labels = "None"
fields = [
EmbedField(name="State", value=mr.state[0].upper() + mr.state[1:], inline=True),
EmbedField(name="Draft?", value=str(mr.draft), inline=True),
EmbedField(name="Assignee", value=assignee, inline=True),
EmbedField(name="Labels", value=labels, inline=True),
]
if mr.labels:
color = self.project.labels.get(mr.labels[0]).color
else:
color = "#00FFEE"
fields.append(EmbedField(name="Created At", value=created_at, inline=True))
if mr.state == "merged":
merged_at = datetime.strptime(mr.merged_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields.append(EmbedField(name="Merged At", value=merged_at, inline=True))
elif mr.state == "closed":
closed_at = datetime.strptime(mr.closed_at, "%Y-%m-%dT%H:%M:%S.%fZ").strftime(
"%Y-%m-%d %H:%M:%S UTC"
)
fields.append(EmbedField(name="Closed At", value=closed_at, inline=True))
if mr.milestone:
fields.append(
EmbedField(
name="Milestone",
value=f"[{mr.milestone['title']}]({mr.milestone['web_url']})",
inline=False,
)
)
if len(mr.title) > 200:
mr.title = mr.title[:200] + "..."
embed = build_embed(
title=f"[#{mr.iid}] {mr.title}",
description=mr.description,
fields=fields,
color=color,
url=mr.web_url,
)
embed.set_author(
name=mr.author["name"],
icon_url=mr.author["avatar_url"],
url=mr.author["web_url"],
)
embed.set_thumbnail(
url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png"
)
await ctx.send(embed=embed)
def build_embed_page(self, api_list: list, t_state: str, name: str) -> Embed:
"""Build an embed page for the paginator."""
title = ""
if t_state:
title = f"{t_state} "
title += f"JARVIS {name}s"
fields = []
for item in api_list:
description = item.description or "No description"
fields.append(
EmbedField(
name=f"[#{item.iid}] {item.title}",
value=(description[:200] + f"...\n\n[View this {name}]({item.web_url})"),
inline=False,
)
)
embed = build_embed(
title=title,
description="",
fields=fields,
url=f"https://git.zevaryx.com/stark-industries/JARVIS/{name.replace(' ', '_')}s",
)
embed.set_author(
name="JARVIS",
url="https://git.zevaryx.com/jarvis",
icon_url="https://git.zevaryx.com/uploads/-/system/user/avatar/11/avatar.png",
)
embed.set_thumbnail(
url="https://about.gitlab.com/images/press/logo/png/gitlab-icon-rgb.png"
)
return embed
@gl.subcommand(
sub_cmd_name="issues",
sub_cmd_description="Get issues from GitLab",
)
@slash_option(
name="state",
description="State of issues to get",
opt_type=OptionTypes.STRING,
required=False,
choices=[
SlashCommandChoice(name="Open", value="opened"),
SlashCommandChoice(name="Closed", value="closed"),
SlashCommandChoice(name="All", value="all"),
],
)
async def _issues(self, ctx: InteractionContext, state: str = "opened") -> None:
await ctx.defer()
m_state = state
if m_state == "all":
m_state = None
issues = []
page = 1
try:
while curr_page := self.project.issues.list(
page=page,
state=m_state,
order_by="created_at",
sort="desc",
per_page=100,
):
issues += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get issues")
return
if len(issues) == 0:
await ctx.send("No issues match that criteria")
return
t_state = state
if t_state == "opened":
t_state = "open"
pages = []
t_state = t_state[0].upper() + t_state[1:]
for i in range(0, len(issues), 5):
pages.append(self.build_embed_page(issues[i : i + 5], t_state=t_state, name="issue"))
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
@gl.subcommand(
sub_cmd_name="mrs",
sub_cmd_description="Get merge requests from GitLab",
)
@slash_option(
name="state",
description="State of merge requests to get",
opt_type=OptionTypes.STRING,
required=False,
choices=[
SlashCommandChoice(name="Open", value="opened"),
SlashCommandChoice(name="Closed", value="closed"),
SlashCommandChoice(name="All", value="all"),
],
)
async def _mergerequests(self, ctx: InteractionContext, state: str = "opened") -> None:
await ctx.defer()
m_state = state
if m_state == "all":
m_state = None
merges = []
page = 1
try:
while curr_page := self.project.mergerequests.list(
page=page,
state=m_state,
order_by="created_at",
sort="desc",
per_page=100,
):
merges += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get merge requests")
return
if len(merges) == 0:
await ctx.send("No merge requests match that criteria")
return
t_state = state
if t_state == "opened":
t_state = "open"
pages = []
t_state = t_state[0].upper() + t_state[1:]
for i in range(0, len(merges), 5):
pages.append(
self.build_embed_page(merges[i : i + 5], t_state=t_state, name="merge request")
)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
@gl.subcommand(
sub_cmd_name="milestones",
sub_cmd_description="Get milestones from GitLab",
)
async def _milestones(self, ctx: InteractionContext) -> None:
await ctx.defer()
milestones = []
page = 1
try:
while curr_page := self.project.milestones.list(
page=page,
order_by="created_at",
sort="desc",
per_page=100,
):
milestones += curr_page
page += 1
except gitlab.exceptions.GitlabGetError:
# Only send error on first page. Otherwise, use pages retrieved
if page == 1:
await ctx.send("Unable to get milestones")
return
if len(milestones) == 0:
await ctx.send("No milestones exist")
return
pages = []
for i in range(0, len(milestones), 5):
pages.append(
self.build_embed_page(milestones[i : i + 5], t_state=None, name="milestone")
)
paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
await paginator.send(ctx)
@slash_command(name="issue", description="Report an issue on GitLab", scopes=guild_ids)
@slash_option(
name="user",
description="Credit someone else for this issue",
opt_type=OptionTypes.USER,
required=False,
)
@cooldown(bucket=Buckets.USER, rate=1, interval=600)
async def _open_issue(self, ctx: InteractionContext, user: Member = None) -> None:
user = user or ctx.author
modal = Modal(
title="Open a new issue on GitLab",
components=[
InputText(
label="Issue Title",
placeholder="Descriptive Title",
style=TextStyles.SHORT,
custom_id="title",
max_length=200,
),
InputText(
label="Description (supports Markdown!)",
placeholder="Detailed Description",
style=TextStyles.PARAGRAPH,
custom_id="description",
),
],
)
await ctx.send_modal(modal)
try:
resp = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5)
title = resp.responses.get("title")
desc = resp.responses.get("description")
except asyncio.TimeoutError:
return
if not title.startswith("[Discord]"):
title = "[Discord] " + title
desc = f"Opened by `@{user.username}` on Discord\n\n" + desc
issue = self.project.issues.create(data={"title": title, "description": desc})
embed = build_embed(
title=f"Issue #{issue.id} Created",
description=("Thank you for opening an issue!\n\n[View it online]({issue['web_url']})"),
fields=[],
color="#00FFEE",
)
await resp.send(embed=embed)
def setup(bot: Client) -> None:
"""Add GitlabCog to JARVIS if Gitlab token exists."""
if JarvisConfig.from_yaml().gitlab_token:
GitlabCog(bot)

View file

@ -1,106 +1,158 @@
"""J.A.R.V.I.S. image processing cog."""
"""JARVIS image processing cog."""
import logging
import re
from io import BytesIO
import aiohttp
import cv2
import numpy as np
from discord import File
from discord.ext import commands
from jarvis_core.util import convert_bytesize, unconvert_bytesize
from naff import Client, Cog, InteractionContext
from naff.models.discord.embed import EmbedField
from naff.models.discord.file import File
from naff.models.discord.message import Attachment
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from jarvis.utils import build_embed, convert_bytesize, unconvert_bytesize
from jarvis.utils.field import Field
from jarvis.utils import build_embed
MIN_ACCURACY = 0.80
class ImageCog(commands.Cog):
class ImageCog(Cog):
"""
Image processing functions for J.A.R.V.I.S.
Image processing functions for JARVIS
May be categorized under util later
"""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession()
self.tgt_match = re.compile(r"([0-9]*\.?[0-9]*?) ?([KMGTP]?B)", re.IGNORECASE)
self.tgt_match = re.compile(r"([0-9]*\.?[0-9]*?) ?([KMGTP]?B?)", re.IGNORECASE)
def __del__(self):
self._session.close()
async def _resize(self, ctx: commands.Context, target: str, url: str = None) -> None:
if not target:
await ctx.send("Missing target size, i.e. 200KB.")
@slash_command(name="resize", description="Resize an image")
@slash_option(
name="target",
description="Target size, i.e. 200KB",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="attachment",
description="Image to resize",
opt_type=OptionTypes.ATTACHMENT,
required=False,
)
@slash_option(
name="url",
description="URL to download and resize",
opt_type=OptionTypes.STRING,
required=False,
)
async def _resize(
self, ctx: InteractionContext, target: str, attachment: Attachment = None, url: str = None
) -> None:
await ctx.defer()
if not attachment and not url:
await ctx.send("A URL or attachment is required", ephemeral=True)
return
if attachment and not attachment.content_type.startswith("image"):
await ctx.send("Attachment must be an image", ephemeral=True)
return
tgt = self.tgt_match.match(target)
if not tgt:
await ctx.send(f"Invalid target format ({target}). Expected format like 200KB")
await ctx.send(
f"Invalid target format ({target}). Expected format like 200KB", ephemeral=True
)
return
try:
tgt_size = unconvert_bytesize(float(tgt.groups()[0]), tgt.groups()[1])
if tgt_size > unconvert_bytesize(8, "MB"):
await ctx.send("Target too large to send. Please make target < 8MB")
except ValueError:
await ctx.send("Failed to read your target size. Try a more sane one", ephemeral=True)
return
file = None
filename = None
if ctx.message.attachments is not None and len(ctx.message.attachments) > 0:
file = await ctx.message.attachments[0].read()
filename = ctx.message.attachments[0].filename
elif url is not None:
async with self._session.get(url) as resp:
if resp.status == 200:
file = await resp.read()
filename = url.split("/")[-1]
else:
ctx.send("Missing file as either attachment or URL.")
size = len(file)
if tgt_size > unconvert_bytesize(8, "MB"):
await ctx.send("Target too large to send. Please make target < 8MB", ephemeral=True)
return
if tgt_size < 1024:
await ctx.send("Sizes < 1KB are extremely unreliable and are disabled", ephemeral=True)
return
if attachment:
url = attachment.url
filename = attachment.filename
else:
filename = url.split("/")[-1]
data = None
try:
async with self._session.get(url) as resp:
resp.raise_for_status()
if resp.content_type in ["image/jpeg", "image/png"]:
data = await resp.read()
else:
await ctx.send(
"Unsupported content type. Please send a URL to a JPEG or PNG",
ephemeral=True,
)
return
except Exception:
await ctx.send("Failed to retrieve image. Please verify url", ephemeral=True)
return
size = len(data)
if size <= tgt_size:
await ctx.send("Image already meets target.")
await ctx.send("Image already meets target.", ephemeral=True)
return
ratio = max(tgt_size / size - 0.02, 0.50)
accuracy = 0.0
# TODO: Optimize to not run multiple times
while len(file) > tgt_size or (len(file) <= tgt_size and accuracy < 0.65):
old_file = file
buffer = np.frombuffer(file, dtype=np.uint8)
while len(data) > tgt_size or (len(data) <= tgt_size and accuracy < MIN_ACCURACY):
old_file = data
buffer = np.frombuffer(data, dtype=np.uint8)
img = cv2.imdecode(buffer, flags=-1)
width = int(img.shape[1] * ratio)
height = int(img.shape[0] * ratio)
new_img = cv2.resize(img, (width, height))
file = cv2.imencode(".png", new_img)[1].tobytes()
accuracy = (len(file) / tgt_size) * 100
data = cv2.imencode(".png", new_img)[1].tobytes()
accuracy = (len(data) / tgt_size) * 100
if accuracy <= 0.50:
file = old_file
data = old_file
ratio += 0.1
else:
ratio = max(tgt_size / len(file) - 0.02, 0.65)
ratio = max(tgt_size / len(data) - 0.02, 0.65)
bufio = BytesIO(file)
accuracy = (len(file) / tgt_size) * 100
bufio = BytesIO(data)
accuracy = (len(data) / tgt_size) * 100
fields = [
Field("Original Size", convert_bytesize(size), False),
Field("New Size", convert_bytesize(len(file)), False),
Field("Accuracy", f"{accuracy:.02f}%", False),
EmbedField("Original Size", convert_bytesize(size), False),
EmbedField("New Size", convert_bytesize(len(data)), False),
EmbedField("Accuracy", f"{accuracy:.02f}%", False),
]
embed = build_embed(title=filename, description="", fields=fields)
embed.set_image(url="attachment://resized.png")
await ctx.send(
embed=embed,
file=File(bufio, filename="resized.png"),
file=File(file=bufio, file_name="resized.png"),
)
@commands.command(name="resize", help="Resize an image")
@commands.cooldown(1, 60, commands.BucketType.user)
async def _resize_pref(self, ctx: commands.Context, target: str, url: str = None) -> None:
await self._resize(ctx, target, url)
def setup(bot: commands.Bot) -> None:
"""Add ImageCog to J.A.R.V.I.S."""
bot.add_cog(ImageCog(bot))
def setup(bot: Client) -> None:
"""Add ImageCog to JARVIS"""
ImageCog(bot)

View file

@ -1,114 +0,0 @@
"""J.A.R.V.I.S. Jokes module."""
import html
import re
import traceback
from datetime import datetime
from random import randint
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from jarvis.db.models import Joke
from jarvis.utils import build_embed
from jarvis.utils.field import Field
class JokeCog(commands.Cog):
"""
Joke library for J.A.R.V.I.S.
May adapt over time to create jokes using machine learning
"""
def __init__(self, bot: commands.Bot):
self.bot = bot
# TODO: Make this a command group with subcommands
@cog_ext.cog_slash(
name="joke",
description="Hear a joke",
)
@commands.cooldown(1, 10, commands.BucketType.channel)
async def _joke(self, ctx: SlashContext, id: str = None) -> None:
"""Get a joke from the database."""
try:
if randint(1, 100_000) == 5779 and id is None: # noqa: S311
await ctx.send(f"<@{ctx.message.author.id}>")
return
# TODO: Add this as a parameter that can be passed in
threshold = 500 # Minimum score
result = None
if id:
result = Joke.objects(rid=id).first()
else:
pipeline = [
{"$match": {"score": {"$gt": threshold}}},
{"$sample": {"size": 1}},
]
result = Joke.objects().aggregate(pipeline).next()
while result["body"] in ["[removed]", "[deleted]"]:
result = Joke.objects().aggregate(pipeline).next()
if result is None:
await ctx.send("Humor module failed. Please try again later.", hidden=True)
return
emotes = re.findall(r"(&#x[a-fA-F0-9]*;)", result["body"])
for match in emotes:
result["body"] = result["body"].replace(match, html.unescape(match))
emotes = re.findall(r"(&#x[a-fA-F0-9]*;)", result["title"])
for match in emotes:
result["title"] = result["title"].replace(match, html.unescape(match))
body_chunks = []
body = ""
for word in result["body"].split(" "):
if len(body) + 1 + len(word) > 1024:
body_chunks.append(Field("", body, False))
body = ""
if word == "\n" and body == "":
continue
elif word == "\n":
body += word
else:
body += " " + word
desc = ""
title = result["title"]
if len(title) > 256:
new_title = ""
limit = False
for word in title.split(" "):
if len(new_title) + len(word) + 1 > 253 and not limit:
new_title += "..."
desc = "..."
limit = True
if not limit:
new_title += word + " "
else:
desc += word + " "
body_chunks.append(Field("", body, False))
fields = body_chunks
fields.append(Field("Score", result["score"]))
# Field(
# "Created At",
# str(datetime.fromtimestamp(result["created_utc"])),
# ),
fields.append(Field("ID", result["rid"]))
embed = build_embed(
title=title,
description=desc,
fields=fields,
url=f"https://reddit.com/r/jokes/comments/{result['rid']}",
timestamp=datetime.fromtimestamp(result["created_utc"]),
)
await ctx.send(embed=embed)
except Exception:
await ctx.send("Encountered error:\n```\n" + traceback.format_exc() + "\n```")
# await ctx.send(f"**{result['title']}**\n\n{result['body']}")
def setup(bot: commands.Bot) -> None:
"""Add JokeCog to J.A.R.V.I.S."""
bot.add_cog(JokeCog(bot))

View file

@ -1,11 +0,0 @@
"""J.A.R.V.I.S. Modlog Cogs."""
from discord.ext.commands import Bot
from jarvis.cogs.modlog import command, member, message
def setup(bot: Bot) -> None:
"""Add modlog cogs to J.A.R.V.I.S."""
bot.add_cog(command.ModlogCommandCog(bot))
bot.add_cog(member.ModlogMemberCog(bot))
bot.add_cog(message.ModlogMessageCog(bot))

View file

@ -1,49 +0,0 @@
"""J.A.R.V.I.S. ModlogCommandCog."""
from discord import DMChannel
from discord.ext import commands
from discord_slash import SlashContext
from jarvis.db.models import Setting
from jarvis.utils import build_embed
from jarvis.utils.field import Field
class ModlogCommandCog(commands.Cog):
"""J.A.R.V.I.S. ModlogCommandCog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_slash_command(self, ctx: SlashContext) -> None:
"""Process on_slash_command events."""
if not isinstance(ctx.channel, DMChannel) and ctx.name not in ["pw"]:
modlog = Setting.objects(guild=ctx.guild.id, setting="modlog").first()
if modlog:
channel = ctx.guild.get_channel(modlog.value)
fields = [
Field("Command", ctx.name),
]
if ctx.kwargs:
kwargs_string = " ".join(f"{k}: {str(ctx.kwargs[k])}" for k in ctx.kwargs)
fields.append(
Field(
"Keyword Args",
kwargs_string,
False,
)
)
if ctx.subcommand_name:
fields.insert(1, Field("Subcommand", ctx.subcommand_name))
embed = build_embed(
title="Command Invoked",
description=f"{ctx.author.mention} invoked a command",
fields=fields,
color="#fc9e3f",
)
embed.set_author(
name=ctx.author.name,
icon_url=ctx.author.avatar_url,
)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
await channel.send(embed=embed)

View file

@ -1,333 +0,0 @@
"""J.A.R.V.I.S. ModlogMemberCog."""
import asyncio
from datetime import datetime, timedelta
import discord
from discord.ext import commands
from discord.utils import find
from jarvis.cogs.modlog.utils import get_latest_log, modlog_embed
from jarvis.config import get_config
from jarvis.db.models import Ban, Kick, Mute, Setting, Unban
from jarvis.utils import build_embed
from jarvis.utils.field import Field
class ModlogMemberCog(commands.Cog):
"""J.A.R.V.I.S. ModlogMemberCog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.cache = []
@commands.Cog.listener()
async def on_member_ban(self, guild: discord.Guild, user: discord.User) -> None:
"""Process on_member_ban events."""
modlog = Setting.objects(guild=guild.id, setting="modlog").first()
if modlog:
channel = guild.get_channel(modlog.value)
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await guild.audit_logs(
limit=50,
action=discord.AuditLogAction.ban,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, user)
admin: discord.User = log.user
if admin.id == get_config().client_id:
await asyncio.sleep(3)
ban = (
Ban.objects(
guild=guild.id,
user=user.id,
active=True,
)
.order_by("-created_at")
.first()
)
if ban:
admin = guild.get_member(ban.admin)
embed = modlog_embed(
user,
admin,
log,
"User banned",
f"{user.mention} was banned from {guild.name}",
)
await channel.send(embed=embed)
@commands.Cog.listener()
async def on_member_unban(self, guild: discord.Guild, user: discord.User) -> None:
"""Process on_member_unban events."""
modlog = Setting.objects(guild=guild.id, setting="modlog").first()
if modlog:
channel = guild.get_channel(modlog.value)
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await guild.audit_logs(
limit=50,
action=discord.AuditLogAction.unban,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, user)
admin: discord.User = log.user
if admin.id == get_config().client_id:
await asyncio.sleep(3)
unban = (
Unban.objects(
guild=guild.id,
user=user.id,
)
.order_by("-created_at")
.first()
)
admin = guild.get_member(unban.admin)
embed = modlog_embed(
user,
admin,
log,
"User unbanned",
f"{user.mention} was unbanned from {guild.name}",
)
await channel.send(embed=embed)
@commands.Cog.listener()
async def on_member_remove(self, user: discord.Member) -> None:
"""Process on_member_remove events."""
modlog = Setting.objects(guild=user.guild.id, setting="modlog").first()
if modlog:
channel = user.guild.get_channel(modlog.value)
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await user.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.kick,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
count = 0
log: discord.AuditLogEntry = get_latest_log(auditlog, user)
while not log:
if count == 30:
break
await asyncio.sleep(0.5)
log: discord.AuditLogEntry = get_latest_log(auditlog, user)
count += 1
if not log:
return
admin: discord.User = log.user
if admin.id == get_config().client_id:
await asyncio.sleep(3)
kick = (
Kick.objects(
guild=user.guild.id,
user=user.id,
)
.order_by("-created_at")
.first()
)
if kick:
admin = user.guild.get_member(kick.admin)
embed = modlog_embed(
user,
admin,
log,
"User Kicked",
f"{user.mention} was kicked from {user.guild.name}",
)
await channel.send(embed=embed)
async def process_mute(self, before: discord.Member, after: discord.Member) -> discord.Embed:
"""Process mute event."""
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await before.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.member_role_update,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, before)
admin: discord.User = log.user
if admin.id == get_config().client_id:
await asyncio.sleep(3)
mute = (
Mute.objects(
guild=before.guild.id,
user=before.id,
active=True,
)
.order_by("-created_at")
.first()
)
if mute:
admin = before.guild.get_member(mute.admin)
return modlog_embed(
member=before,
admin=admin,
log=log,
title="User Muted",
desc=f"{before.mention} was muted",
)
async def process_unmute(self, before: discord.Member, after: discord.Member) -> discord.Embed:
"""Process unmute event."""
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await before.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.member_role_update,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, before)
admin: discord.User = log.user
if admin.id == get_config().client_id:
await asyncio.sleep(3)
mute = (
Mute.objects(
guild=before.guild.id,
user=before.id,
active=True,
)
.order_by("-created_at")
.first()
)
if mute:
admin = before.guild.get_member(mute.admin)
return modlog_embed(
member=before,
admin=admin,
log=log,
title="User Muted",
desc=f"{before.mention} was muted",
)
async def process_verify(self, before: discord.Member, after: discord.Member) -> discord.Embed:
"""Process verification event."""
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await before.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.member_role_update,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, before)
admin: discord.User = log.user
return modlog_embed(
member=before,
admin=admin,
log=log,
title="User Verified",
desc=f"{before.mention} was verified",
)
async def process_rolechange(self, before: discord.Member, after: discord.Member) -> discord.Embed:
"""Process rolechange event."""
await asyncio.sleep(0.5) # Need to wait for audit log
auditlog = await before.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.member_role_update,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, before)
admin: discord.User = log.user
role = None
title = "User Given Role"
verb = "was given"
if len(before.roles) > len(after.roles):
title = "User Forfeited Role"
verb = "forfeited"
role = find(lambda x: x not in after.roles, before.roles)
elif len(before.roles) < len(after.roles):
role = find(lambda x: x not in before.roles, after.roles)
role_text = role.mention if role else "||`[redacted]`||"
return modlog_embed(
member=before,
admin=admin,
log=log,
title=title,
desc=f"{before.mention} {verb} role {role_text}",
)
@commands.Cog.listener()
async def on_member_update(self, before: discord.Member, after: discord.Member) -> None:
"""Process on_member_update events.
Caches events due to double-send bug
"""
h = hash(hash(before) * hash(after))
if h not in self.cache:
self.cache.append(h)
else:
return
modlog = Setting.objects(guild=before.guild.id, setting="modlog").first()
if modlog:
channel = after.guild.get_channel(modlog.value)
await asyncio.sleep(0.5) # Need to wait for audit log
embed = None
mute = Setting.objects(guild=before.guild.id, setting="mute").first()
verified = Setting.objects(guild=before.guild.id, setting="verified").first()
mute_role = None
verified_role = None
if mute:
mute_role = before.guild.get_role(mute.value)
if verified:
verified_role = before.guild.get_role(verified.value)
if mute and mute_role in after.roles and mute_role not in before.roles:
embed = await self.process_mute(before, after)
elif mute and mute_role in before.roles and mute_role not in after.roles:
embed = await self.process_unmute(before, after)
elif verified and verified_role not in before.roles and verified_role in after.roles:
embed = await self.process_verify(before, after)
elif before.nick != after.nick:
auditlog = await before.guild.audit_logs(
limit=50,
action=discord.AuditLogAction.member_update,
after=datetime.utcnow() - timedelta(seconds=15),
oldest_first=False,
).flatten()
log: discord.AuditLogEntry = get_latest_log(auditlog, before)
bname = before.nick if before.nick else before.name
aname = after.nick if after.nick else after.name
fields = [
Field(
name="Before",
value=f"{bname} ({before.name}#{before.discriminator})",
),
Field(
name="After",
value=f"{aname} ({after.name}#{after.discriminator})",
),
]
if log.user.id != before.id:
fields.append(
Field(
name="Moderator",
value=f"{log.user.mention} ({log.user.name}#{log.user.discriminator})",
)
)
if log.reason:
fields.append(
Field(name="Reason", value=log.reason, inline=False),
)
embed = build_embed(
title="User Nick Changed",
description=f"{after.mention} changed their nickname",
color="#fc9e3f",
fields=fields,
timestamp=log.created_at,
)
embed.set_author(
name=f"{after.name}",
icon_url=after.avatar_url,
)
embed.set_footer(text=f"{after.name}#{after.discriminator} | {after.id}")
elif len(before.roles) != len(after.roles):
# TODO: User got a new role
embed = await self.process_rolechange(before, after)
if embed:
await channel.send(embed=embed)
self.cache.remove(h)

View file

@ -1,104 +0,0 @@
"""J.A.R.V.I.S. ModlogMessageCog."""
import discord
from discord.ext import commands
from jarvis.db.models import Setting
from jarvis.utils import build_embed
from jarvis.utils.field import Field
class ModlogMessageCog(commands.Cog):
"""J.A.R.V.I.S. ModlogMessageCog."""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message) -> None:
"""Process on_message_edit events."""
if not before.author.bot:
modlog = Setting.objects(guild=after.guild.id, setting="modlog").first()
if modlog:
if before.content == after.content or before.content is None:
return
channel = before.guild.get_channel(modlog.value)
fields = [
Field(
"Original Message",
before.content if before.content else "N/A",
False,
),
Field(
"New Message",
after.content if after.content else "N/A",
False,
),
]
embed = build_embed(
title="Message Edited",
description=f"{before.author.mention} edited a message",
fields=fields,
color="#fc9e3f",
timestamp=after.edited_at,
url=after.jump_url,
)
embed.set_author(
name=before.author.name,
icon_url=before.author.avatar_url,
url=after.jump_url,
)
embed.set_footer(text=f"{before.author.name}#{before.author.discriminator} | {before.author.id}")
await channel.send(embed=embed)
@commands.Cog.listener()
async def on_message_delete(self, message: discord.Message) -> None:
"""Process on_message_delete events."""
modlog = Setting.objects(guild=message.guild.id, setting="modlog").first()
if modlog:
fields = [Field("Original Message", message.content or "N/A", False)]
if message.attachments:
value = "\n".join([f"[{x.filename}]({x.url})" for x in message.attachments])
fields.append(
Field(
name="Attachments",
value=value,
inline=False,
)
)
if message.stickers:
value = "\n".join([f"[{x.name}]({x.image_url})" for x in message.stickers])
fields.append(
Field(
name="Stickers",
value=value,
inline=False,
)
)
if message.embeds:
value = str(len(message.embeds)) + " embeds"
fields.append(
Field(
name="Embeds",
value=value,
inline=False,
)
)
channel = message.guild.get_channel(modlog.value)
embed = build_embed(
title="Message Deleted",
description=f"{message.author.mention}'s message was deleted",
fields=fields,
color="#fc9e3f",
)
embed.set_author(
name=message.author.name,
icon_url=message.author.avatar_url,
url=message.jump_url,
)
embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}")
await channel.send(embed=embed)

View file

@ -1,50 +0,0 @@
"""J.A.R.V.I.S. Modlog Cog Utilities."""
from datetime import datetime, timedelta
from typing import List
import discord
from discord import AuditLogEntry, Member
from discord.utils import find
from jarvis.utils import build_embed
from jarvis.utils.field import Field
def modlog_embed(
member: discord.Member,
admin: discord.Member,
log: discord.AuditLogEntry,
title: str,
desc: str,
) -> discord.Embed:
"""Get modlog embed."""
fields = [
Field(
name="Moderator",
value=f"{admin.mention} ({admin.name}#{admin.discriminator})",
),
]
if log.reason:
fields.append(Field(name="Reason", value=log.reason, inline=False))
embed = build_embed(
title=title,
description=desc,
color="#fc9e3f",
fields=fields,
timestamp=log.created_at,
)
embed.set_author(
name=f"{member.name}",
icon_url=member.avatar_url,
)
embed.set_footer(text=f"{member.name}#{member.discriminator} | {member.id}")
return embed
def get_latest_log(auditlog: List[AuditLogEntry], target: Member) -> AuditLogEntry:
"""Filter AuditLog to get latest entry."""
before = datetime.utcnow() - timedelta(seconds=10)
return find(
lambda x: x.target.id == target.id and x.created_at > before,
auditlog,
)

View file

@ -1,246 +0,0 @@
"""J.A.R.V.I.S. Owner Cog."""
import os
import sys
import traceback
from inspect import getsource
from time import time
from typing import Any
import discord
from discord import DMChannel, User
from discord.ext import commands
import jarvis
from jarvis.config import reload_config
from jarvis.db.models import Config
from jarvis.utils import update
from jarvis.utils.permissions import user_is_bot_admin
class OwnerCog(commands.Cog):
"""
J.A.R.V.I.S. management cog.
Used by admins to control core J.A.R.V.I.S. systems
"""
def __init__(self, bot: commands.Cog):
self.bot = bot
self.admins = Config.objects(key="admins").first()
@commands.command(name="load", hidden=True)
@user_is_bot_admin()
async def _load_cog(self, ctx: commands.Context, *, cog: str) -> None:
info = await self.bot.application_info()
if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value:
try:
if "jarvis.cogs." not in cog:
cog = "jarvis.cogs." + cog.split(".")[-1]
self.bot.load_extension(cog)
except commands.errors.ExtensionAlreadyLoaded:
await ctx.send(f"Cog `{cog}` already loaded")
except Exception as e:
await ctx.send(f"Failed to load new cog `{cog}`: {type(e).name} - {e}")
else:
await ctx.send(f"Successfully loaded new cog `{cog}`")
else:
await ctx.send("I'm afraid I can't let you do that")
@commands.command(name="unload", hidden=True)
@user_is_bot_admin()
async def _unload_cog(self, ctx: commands.Context, *, cog: str) -> None:
if cog in ["jarvis.cogs.owner", "owner"]:
await ctx.send("Cannot unload `owner` cog")
return
info = await self.bot.application_info()
if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value:
try:
if "jarvis.cogs." not in cog:
cog = "jarvis.cogs." + cog.split(".")[-1]
self.bot.unload_extension(cog)
except commands.errors.ExtensionNotLoaded:
await ctx.send(f"Cog `{cog}` not loaded")
except Exception as e:
await ctx.send(f"Failed to unload cog `{cog}` {type(e).__name__} - {e}")
else:
await ctx.send(f"Successfully unloaded cog `{cog}`")
else:
await ctx.send("I'm afraid I can't let you do that")
@commands.command(name="reload", hidden=True)
@user_is_bot_admin()
async def _cog_reload(self, ctx: commands.Context, *, cog: str) -> None:
if cog in ["jarvis.cogs.owner", "owner"]:
await ctx.send("Cannot reload `owner` cog")
return
info = await self.bot.application_info()
if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value:
try:
if "jarvis.cogs." not in cog:
cog = "jarvis.cogs." + cog.split(".")[-1]
try:
self.bot.load_extension(cog)
except commands.errors.ExtensionNotLoaded:
pass
self.bot.unload_extension(cog)
except Exception as e:
await ctx.send(f"Failed to reload cog `{cog}` {type(e).__name__} - {e}")
else:
await ctx.send(f"Successfully reloaded cog `{cog}`")
else:
await ctx.send("I'm afraid I can't let you do that")
@commands.group(name="system", hidden=True, pass_context=True)
@user_is_bot_admin()
async def _system(self, ctx: commands.Context) -> None:
if ctx.invoked_subcommand is None:
await ctx.send("Usage: `system <subcommand>`\n" + "Subcommands: `restart`, `update`")
@_system.command(name="restart", hidden=True)
@user_is_bot_admin()
async def _restart(self, ctx: commands.Context) -> None:
info = await self.bot.application_info()
if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value:
await ctx.send("Restarting core systems...")
if isinstance(ctx.channel, discord.channel.DMChannel):
jarvis.restart_ctx = {
"user": ctx.message.author.id,
"channel": ctx.channel.id,
}
else:
jarvis.restart_ctx = {
"guild": ctx.message.guild.id,
"channel": ctx.channel.id,
}
await self.bot.close()
else:
await ctx.send("I'm afraid I can't let you do that")
@_system.command(name="update", hidden=True)
@user_is_bot_admin()
async def _update(self, ctx: commands.Context) -> None:
info = await self.bot.application_info()
if ctx.message.author == info.owner or ctx.message.author.id in self.admins.value:
await ctx.send("Updating core systems...")
status = update()
if status == 0:
await ctx.send("Core systems updated. Restarting...")
if isinstance(ctx.channel, discord.channel.DMChannel):
jarvis.restart_ctx = {
"user": ctx.message.author.id,
"channel": ctx.channel.id,
}
else:
jarvis.restart_ctx = {
"guild": ctx.message.guild.id,
"channel": ctx.channel.id,
}
await self.bot.close()
elif status == 1:
await ctx.send("Core systems already up to date.")
elif status == 2:
await ctx.send("Core system update available, but core is dirty.")
else:
await ctx.send("I'm afraid I can't let you do that")
@_system.command(name="refresh", hidden=True)
@user_is_bot_admin()
async def _refresh(self, ctx: commands.Context) -> None:
reload_config()
await ctx.send("System refreshed")
@commands.group(name="admin", hidden=True, pass_context=True)
@commands.is_owner()
async def _admin(self, ctx: commands.Context) -> None:
if ctx.invoked_subcommand is None:
await ctx.send("Usage: `admin <subcommand>`\n" + "Subcommands: `add`, `remove`")
@_admin.command(name="add", hidden=True)
@commands.is_owner()
async def _add(self, ctx: commands.Context, user: User) -> None:
if user.id in self.admins.value:
await ctx.send(f"{user.mention} is already an admin.")
return
self.admins.value.append(user.id)
self.admins.save()
reload_config()
await ctx.send(f"{user.mention} is now an admin. Use this power carefully.")
@_admin.command(name="remove", hidden=True)
@commands.is_owner()
async def _remove(self, ctx: commands.Context, user: User) -> None:
if user.id not in self.admins.value:
await ctx.send(f"{user.mention} is not an admin.")
return
self.admins.value.remove(user.id)
self.admins.save()
reload_config()
await ctx.send(f"{user.mention} is no longer an admin.")
def resolve_variable(self, variable: Any) -> Any:
"""Resolve a variable from eval."""
if hasattr(variable, "__iter__"):
var_length = len(list(variable))
if (var_length > 100) and (not isinstance(variable, str)):
return f"<a {type(variable).__name__} iterable " + f"with more than 100 values ({var_length})>"
elif not var_length:
return f"<an empty {type(variable).__name__} iterable>"
if (not variable) and (not isinstance(variable, bool)):
return f"<an empty {type(variable).__name__} object>"
return (
variable
if (len(f"{variable}") <= 1000)
else f"<a long {type(variable).__name__} object " + f"with the length of {len(f'{variable}'):,}>"
)
def prepare(self, string: str) -> str:
"""Prepare string for eval."""
arr = string.strip("```").replace("py\n", "").replace("python\n", "").split("\n")
if not arr[::-1][0].replace(" ", "").startswith("return"):
arr[len(arr) - 1] = "return " + arr[::-1][0]
return "".join(f"\n\t{i}" for i in arr)
@commands.command(pass_context=True, aliases=["eval", "exec", "evaluate"])
@user_is_bot_admin()
async def _eval(self, ctx: commands.Context, *, code: str) -> None:
if not isinstance(ctx.message.channel, DMChannel):
return
code = self.prepare(code)
args = {
"discord": discord,
"sauce": getsource,
"sys": sys,
"os": os,
"imp": __import__,
"this": self,
"ctx": ctx,
}
try:
exec( # noqa: S102
f"async def func():{code}",
globals().update(args),
locals(),
)
a = time()
response = await eval("func()", globals().update(args), locals()) # noqa: S307
if response is None or isinstance(response, discord.Message):
del args, code
return
if isinstance(response, str):
response = response.replace("`", "")
await ctx.send(
f"```py\n{self.resolve_variable(response)}```\n`{type(response).__name__} | {(time() - a) / 1000} ms`"
)
except Exception:
await ctx.send(f"Error occurred:```\n{traceback.format_exc()}```")
del args, code
def setup(bot: commands.Bot) -> None:
"""Add OwnerCog to J.A.R.V.I.S."""
bot.add_cog(OwnerCog(bot))

425
jarvis/cogs/reddit.py Normal file
View file

@ -0,0 +1,425 @@
"""JARVIS Reddit cog."""
import asyncio
import logging
from typing import List, Optional
from asyncpraw import Reddit
from asyncpraw.models.reddit.submission import Submission
from asyncpraw.models.reddit.submission import Subreddit as Sub
from asyncprawcore.exceptions import Forbidden, NotFound, Redirect
from jarvis_core.db import q
from jarvis_core.db.models import Subreddit, SubredditFollow
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import get
from naff.models.discord.channel import ChannelTypes, GuildText
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.embed import Embed, EmbedField
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
SlashCommandChoice,
slash_option,
)
from naff.models.naff.command import check
from jarvis import const
from jarvis.config import JarvisConfig
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
DEFAULT_USER_AGENT = f"python:JARVIS:{const.__version__} (by u/zevaryx)"
class RedditCog(Cog):
"""JARVIS Reddit Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml()
config.reddit["user_agent"] = config.reddit.get("user_agent", DEFAULT_USER_AGENT)
self.api = Reddit(**config.reddit)
async def post_embeds(self, sub: Sub, post: Submission) -> Optional[List[Embed]]:
"""
Build a post embeds.
Args:
post: Post to build embeds
"""
url = "https://reddit.com" + post.permalink
await post.author.load()
author_url = f"https://reddit.com/u/{post.author.name}"
author_icon = post.author.icon_img
images = []
title = f"{post.title}"
fields = []
content = ""
og_post = None
if not post.is_self:
og_post = post # noqa: F841
post = await self.api.submission(post.crosspost_parent_list[0]["id"])
await post.load()
fields.append(EmbedField(name="Crossposted From", value=post.subreddit_name_prefixed))
content = f"> **{post.title}**"
if "url" in vars(post):
if any(post.url.endswith(x) for x in ["jpeg", "jpg", "png", "gif"]):
images = [post.url]
if "media_metadata" in vars(post):
for k, v in post.media_metadata.items():
if v["status"] != "valid" or v["m"] not in ["image/jpg", "image/png", "image/gif"]:
continue
ext = v["m"].split("/")[-1]
i_url = f"https://i.redd.it/{k}.{ext}"
images.append(i_url)
if len(images) == 4:
break
if "selftext" in vars(post) and post.selftext:
content += "\n\n" + post.selftext
if len(content) > 900:
content = content[:900] + "..."
content += f"\n\n[View this post]({url})"
if not images and not content:
self.logger.debug(f"Post {post.id} had neither content nor images?")
return None
color = "#FF4500"
if "primary_color" in vars(sub):
color = sub.primary_color
base_embed = build_embed(
title=title,
description=content,
fields=fields,
timestamp=post.created_utc,
url=url,
color=color,
)
base_embed.set_author(
name="u/" + post.author.name, url=author_url, icon_url=author_icon
)
base_embed.set_footer(
text="Reddit", icon_url="https://www.redditinc.com/assets/images/site/reddit-logo.png"
)
embeds = [base_embed]
if len(images) > 0:
embeds[0].set_image(url=images[0])
for image in images[1:4]:
embed = Embed(url=url)
embed.set_image(url=image)
embeds.append(embed)
return embeds
reddit = SlashCommand(name="reddit", description="Manage Reddit follows")
@reddit.subcommand(sub_cmd_name="follow", sub_cmd_description="Follow a Subreddit")
@slash_option(
name="name",
description="Subreddit display name",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="channel",
description="Channel to post to",
opt_type=OptionTypes.CHANNEL,
channel_types=[ChannelTypes.GUILD_TEXT],
required=True,
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _reddit_follow(self, ctx: InteractionContext, name: str, channel: GuildText) -> None:
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a text channel", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} on add")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
exists = await SubredditFollow.find_one(
q(display_name=subreddit.display_name, guild=ctx.guild.id)
)
if exists:
await ctx.send("Subreddit already being followed in this guild", ephemeral=True)
return
count = len([i async for i in SubredditFollow.find(q(guild=ctx.guild.id))])
if count >= 12:
await ctx.send("Cannot follow more than 12 Subreddits", ephemeral=True)
return
if subreddit.over18 and not channel.nsfw:
await ctx.send(
"Subreddit is nsfw, but channel is not. Mark the channel NSFW first.",
ephemeral=True,
)
return
sr = await Subreddit.find_one(q(display_name=subreddit.display_name))
if not sr:
sr = Subreddit(display_name=subreddit.display_name, over18=subreddit.over18)
await sr.commit()
srf = SubredditFollow(
display_name=subreddit.display_name,
channel=channel.id,
guild=ctx.guild.id,
admin=ctx.author.id,
)
await srf.commit()
await ctx.send(f"Now following `r/{name}` in {channel.mention}")
@reddit.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Subreddits")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _subreddit_unfollow(self, ctx: InteractionContext) -> None:
subs = SubredditFollow.find(q(guild=ctx.guild.id))
subreddits = []
async for sub in subs:
subreddits.append(sub)
if not subreddits:
await ctx.send("You need to follow a Subreddit first", ephemeral=True)
return
options = []
names = []
for idx, subreddit in enumerate(subreddits):
sub = await Subreddit.find_one(q(display_name=subreddit.display_name))
names.append(sub.display_name)
option = SelectOption(label=sub.display_name, value=str(idx))
options.append(option)
select = Select(
options=options, custom_id="to_delete", min_values=1, max_values=len(subreddits)
)
components = [ActionRow(select)]
block = "\n".join(x for x in names)
message = await ctx.send(
content=f"You are following the following subreddits:\n```\n{block}\n```\n\n"
"Please choose subreddits to unfollow",
components=components,
)
try:
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
for to_delete in context.context.values:
follow = get(subreddits, guild=ctx.guild.id, display_name=names[int(to_delete)])
try:
await follow.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
for row in components:
for component in row.components:
component.disabled = True
block = "\n".join(names[int(x)] for x in context.context.values)
await context.context.edit_origin(
content=f"Unfollowed the following:\n```\n{block}\n```", components=components
)
except asyncio.TimeoutError:
for row in components:
for component in row.components:
component.disabled = True
await message.edit(components=components)
@reddit.subcommand(sub_cmd_name="hot", sub_cmd_description="Get the hot post of a subreddit")
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_hot(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in hot")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.hot(limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(sub_cmd_name="top", sub_cmd_description="Get the top post of a subreddit")
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="time",
description="Top time",
opt_type=OptionTypes.STRING,
required=False,
choices=[
SlashCommandChoice(name="All", value="all"),
SlashCommandChoice(name="Day", value="day"),
SlashCommandChoice(name="Hour", value="hour"),
SlashCommandChoice(name="Month", value="month"),
SlashCommandChoice(name="Week", value="week"),
SlashCommandChoice(name="Year", value="year"),
],
)
async def _subreddit_top(self, ctx: InteractionContext, name: str, time: str = "all") -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in top")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.top(time_filter=time, limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(
sub_cmd_name="random", sub_cmd_description="Get a random post of a subreddit"
)
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_random(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in random")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = await subreddit.random()
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(
sub_cmd_name="rising", sub_cmd_description="Get a rising post of a subreddit"
)
@slash_option(
name="name", description="Subreddit name", opt_type=OptionTypes.STRING, required=True
)
async def _subreddit_rising(self, ctx: InteractionContext, name: str) -> None:
await ctx.defer()
name = name.replace("r/", "")
if len(name) > 20 or len(name) < 3:
await ctx.send("Invalid Subreddit name", ephemeral=True)
return
try:
subreddit = await self.api.subreddit(name)
await subreddit.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Subreddit {name} raised {e.__class__.__name__} in rising")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
try:
post = [x async for x in subreddit.rising(limit=1)][0]
except Exception as e:
self.logger.error(f"Failed to get post from {name}", exc_info=e)
await ctx.send("Well, this is awkward. Something went wrong", ephemeral=True)
return
embeds = await self.post_embeds(subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
@reddit.subcommand(sub_cmd_name="post", sub_cmd_description="Get a specific submission")
@slash_option(
name="sid", description="Submission ID", opt_type=OptionTypes.STRING, required=True
)
async def _reddit_post(self, ctx: InteractionContext, sid: str) -> None:
await ctx.defer()
try:
post = await self.api.submission(sid)
await post.load()
except (NotFound, Forbidden, Redirect) as e:
self.logger.debug(f"Submission {sid} raised {e.__class__.__name__} in post")
await ctx.send("Subreddit may be private, quarantined, or nonexistent.", ephemeral=True)
return
embeds = await self.post_embeds(post.subreddit, post)
if post.over_18 and not ctx.channel.nsfw:
try:
await ctx.author.send(embeds=embeds)
await ctx.send("Hey! Due to content, I had to DM the result to you")
except Exception:
await ctx.send("Hey! Due to content, I cannot share the result")
else:
await ctx.send(embeds=embeds)
def setup(bot: Client) -> None:
"""Add RedditCog to JARVIS"""
if JarvisConfig.from_yaml().reddit:
RedditCog(bot)

View file

@ -1,177 +1,191 @@
"""J.A.R.V.I.S. Remind Me Cog."""
"""JARVIS Remind Me Cog."""
import asyncio
import logging
import re
from datetime import datetime, timedelta
from typing import List, Optional
from datetime import datetime, timezone
from typing import List
from bson import ObjectId
from discord import Embed
from discord.ext.commands import Bot
from discord.ext.tasks import loop
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from discord_slash.utils.manage_components import (
create_actionrow,
create_select,
create_select_option,
wait_for_component,
from dateparser import parse
from dateparser_data.settings import default_parsers
from jarvis_core.db import q
from jarvis_core.db.models import Reminder
from naff import Client, Cog, InteractionContext
from naff.client.utils.misc_utils import get
from naff.models.discord.channel import GuildChannel
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.modal import InputText, Modal, TextStyles
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_command,
slash_option,
)
from jarvis.db.models import Reminder
from jarvis.utils import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>,\u0080-\U000E0FFF]*")
valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>:,\u0080-\U000E0FFF]*")
time_pattern = re.compile(r"(\d+\.?\d?[s|m|h|d|w]{1})\s?", flags=re.IGNORECASE)
invites = re.compile(
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)",
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)", # noqa: E501
flags=re.IGNORECASE,
)
class RemindmeCog(CacheCog):
"""J.A.R.V.I.S. Remind Me Cog."""
class RemindmeCog(Cog):
"""JARVIS Remind Me Cog."""
def __init__(self, bot: Bot):
super().__init__(bot)
self._remind.start()
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash(
name="remindme",
description="Set a reminder",
options=[
create_option(
name="message",
description="What to remind you of",
option_type=3,
required=True,
),
create_option(
name="weeks",
description="Number of weeks?",
option_type=4,
@slash_command(name="remindme", description="Set a reminder")
@slash_option(
name="private",
description="Send as DM?",
opt_type=OptionTypes.BOOLEAN,
required=False,
),
create_option(
name="days",
description="Number of days?",
option_type=4,
required=False,
),
create_option(
name="hours",
description="Number of hours?",
option_type=4,
required=False,
),
create_option(
name="minutes",
description="Number of minutes?",
option_type=4,
required=False,
),
],
)
async def _remindme(
self,
ctx: SlashContext,
message: Optional[str] = None,
weeks: Optional[int] = 0,
days: Optional[int] = 0,
hours: Optional[int] = 0,
minutes: Optional[int] = 0,
ctx: InteractionContext,
private: bool = False,
) -> None:
if len(message) > 100:
await ctx.send("Reminder cannot be > 100 characters.", hidden=True)
return
elif invites.search(message):
await ctx.send(
"Listen, don't use this to try and bypass the rules",
hidden=True,
)
return
elif not valid.fullmatch(message):
await ctx.send("Hey, you should probably make this readable", hidden=True)
return
if not any([weeks, days, hours, minutes]):
await ctx.send("At least one time period is required", hidden=True)
return
weeks = abs(weeks)
days = abs(days)
hours = abs(hours)
minutes = abs(minutes)
if weeks and weeks > 4:
await ctx.send("Cannot be farther than 4 weeks out!", hidden=True)
return
elif days and days > 6:
await ctx.send("Use weeks instead of 7+ days, please.", hidden=True)
return
elif hours and hours > 23:
await ctx.send("Use days instead of 24+ hours, please.", hidden=True)
return
elif minutes and minutes > 59:
await ctx.send("Use hours instead of 59+ minutes, please.", hidden=True)
return
reminders = Reminder.objects(user=ctx.author.id, active=True).count()
reminders = len([x async for x in Reminder.find(q(user=ctx.author.id, active=True))])
if reminders >= 5:
await ctx.send(
"You already have 5 (or more) active reminders. "
"Please either remove an old one, or wait for one to pass",
hidden=True,
ephemeral=True,
)
return
modal = Modal(
title="Set your reminder!",
components=[
InputText(
label="What to remind you?",
placeholder="Reminder",
style=TextStyles.PARAGRAPH,
custom_id="message",
max_length=500,
),
InputText(
label="When to remind you?",
placeholder="1h 30m | in 5 minutes | November 11, 4011",
style=TextStyles.SHORT,
custom_id="delay",
),
],
)
await ctx.send_modal(modal)
try:
response = await self.bot.wait_for_modal(modal, author=ctx.author.id, timeout=60 * 5)
message = response.responses.get("message")
delay = response.responses.get("delay")
except asyncio.TimeoutError:
return
if len(message) > 500:
await response.send("Reminder cannot be > 500 characters.", ephemeral=True)
return
elif invites.search(message):
await response.send(
"Listen, don't use this to try and bypass the rules",
ephemeral=True,
)
return
elif not valid.fullmatch(message):
await response.send("Hey, you should probably make this readable", ephemeral=True)
return
base_settings = {
"PREFER_DATES_FROM": "future",
"TIMEZONE": "UTC",
"RETURN_AS_TIMEZONE_AWARE": True,
}
rt_settings = base_settings.copy()
rt_settings["PARSERS"] = [
x for x in default_parsers if x not in ["absolute-time", "timestamp"]
]
rt_remind_at = parse(delay, settings=rt_settings)
at_settings = base_settings.copy()
at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"]
at_remind_at = parse(delay, settings=at_settings)
if rt_remind_at:
remind_at = rt_remind_at
elif at_remind_at:
remind_at = at_remind_at
else:
self.logger.debug(f"Failed to parse delay: {delay}")
await response.send(
f"`{delay}` is not a parsable date, please try again", ephemeral=True
)
return
remind_at = datetime.utcnow() + timedelta(
weeks=weeks,
days=days,
hours=hours,
minutes=minutes,
if remind_at < datetime.now(tz=timezone.utc):
await response.send(
f"`{delay}` is in the past. Past reminders aren't allowed", ephemeral=True
)
return
_ = Reminder(
user=ctx.author_id,
elif remind_at < datetime.now(tz=timezone.utc):
pass
r = Reminder(
user=ctx.author.id,
channel=ctx.channel.id,
guild=ctx.guild.id,
message=message,
remind_at=remind_at,
private=private,
active=True,
).save()
)
await r.commit()
embed = build_embed(
title="Reminder Set",
description=f"{ctx.author.mention} set a reminder",
fields=[
Field(name="Message", value=message),
Field(
EmbedField(name="Message", value=message),
EmbedField(
name="When",
value=remind_at.strftime("%Y-%m-%d %H:%M UTC"),
value=f"<t:{int(remind_at.timestamp())}:F>",
inline=False,
),
],
)
embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url,
name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.display_avatar.url,
)
embed.set_thumbnail(url=ctx.author.avatar_url)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
await ctx.send(embed=embed)
await response.send(embed=embed, ephemeral=private)
async def get_reminders_embed(self, ctx: SlashContext, reminders: List[Reminder]) -> Embed:
async def get_reminders_embed(
self, ctx: InteractionContext, reminders: List[Reminder]
) -> Embed:
"""Build embed for paginator."""
fields = []
for reminder in reminders:
if reminder.private and isinstance(ctx.channel, GuildChannel):
fields.embed(
EmbedField(
name=f"<t:{int(reminder.remind_at.timestamp())}:F>",
value="Please DM me this command to view the content of this reminder",
inline=False,
)
)
else:
fields.append(
Field(
name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"),
EmbedField(
name=f"<t:{int(reminder.remind_at.timestamp())}:F>",
value=f"{reminder.message}\n\u200b",
inline=False,
)
@ -184,57 +198,43 @@ class RemindmeCog(CacheCog):
)
embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url,
name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.display_avatar.url,
)
embed.set_thumbnail(url=ctx.author.avatar_url)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
return embed
@cog_ext.cog_subcommand(
base="reminders",
name="list",
description="List reminders for a user",
)
async def _list(self, ctx: SlashContext) -> None:
exists = self.check_cache(ctx)
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
reminders = Reminder.objects(user=ctx.author.id, active=True)
reminders = SlashCommand(name="reminders", description="Manage reminders")
@reminders.subcommand(sub_cmd_name="list", sub_cmd_description="List reminders")
async def _list(self, ctx: InteractionContext) -> None:
reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None)
if not reminders:
await ctx.send("You have no reminders set.", hidden=True)
await ctx.send("You have no reminders set.", ephemeral=True)
return
embed = await self.get_reminders_embed(ctx, reminders)
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="reminders",
name="delete",
description="Delete a reminder",
)
async def _delete(self, ctx: SlashContext) -> None:
reminders = Reminder.objects(user=ctx.author.id, active=True)
@reminders.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a reminder")
async def _delete(self, ctx: InteractionContext) -> None:
reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None)
if not reminders:
await ctx.send("You have no reminders set", hidden=True)
await ctx.send("You have no reminders set", ephemeral=True)
return
options = []
for reminder in reminders:
option = create_select_option(
label=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"),
option = SelectOption(
label=f"{reminder.remind_at}",
value=str(reminder.id),
emoji="",
)
options.append(option)
select = create_select(
select = Select(
options=options,
custom_id="to_delete",
placeholder="Select reminders to delete",
@ -242,7 +242,7 @@ class RemindmeCog(CacheCog):
max_values=len(reminders),
)
components = [create_actionrow(select)]
components = [ActionRow(select)]
embed = await self.get_reminders_embed(ctx, reminders)
message = await ctx.send(
content=f"You have {len(reminders)} reminder(s) set:",
@ -251,28 +251,40 @@ class RemindmeCog(CacheCog):
)
try:
context = await wait_for_component(
self.bot,
check=lambda x: ctx.author.id == x.author_id,
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
for to_delete in context.selected_options:
_ = Reminder.objects(user=ctx.author.id, id=ObjectId(to_delete)).delete()
for row in components:
for component in row["components"]:
component["disabled"] = True
fields = []
for reminder in filter(lambda x: str(x.id) in context.selected_options, reminders):
for to_delete in context.context.values:
reminder = get(reminders, user=ctx.author.id, id=ObjectId(to_delete))
if reminder.private and isinstance(ctx.channel, GuildChannel):
fields.append(
Field(
name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"),
EmbedField(
name=f"<t:{int(reminder.remind_at.timestamp())}:F>",
value="Private reminder",
inline=False,
)
)
else:
fields.append(
EmbedField(
name=f"<t:{int(reminder.remind_at.timestamp())}:F>",
value=reminder.message,
inline=False,
)
)
try:
await reminder.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
for row in components:
for component in row.components:
component.disabled = True
embed = build_embed(
title="Deleted Reminder(s)",
description="",
@ -280,52 +292,50 @@ class RemindmeCog(CacheCog):
)
embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url,
name=ctx.author.display_name + "#" + ctx.author.discriminator,
icon_url=ctx.author.display_avatar.url,
)
embed.set_thumbnail(url=ctx.author.avatar_url)
embed.set_thumbnail(url=ctx.author.display_avatar.url)
await context.edit_origin(
content=f"Deleted {len(context.selected_options)} reminder(s)",
await context.context.edit_origin(
content=f"Deleted {len(context.context.values)} reminder(s)",
components=components,
embed=embed,
)
except asyncio.TimeoutError:
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
await message.edit(components=components)
@loop(seconds=15)
async def _remind(self) -> None:
reminders = Reminder.objects(remind_at__lte=datetime.utcnow() + timedelta(seconds=30))
for reminder in reminders:
if reminder.remind_at <= datetime.utcnow():
user = await self.bot.fetch_user(reminder.user)
if not user:
reminder.delete()
continue
embed = build_embed(
title="You have a reminder",
description=reminder.message,
fields=[],
@reminders.subcommand(
sub_cmd_name="fetch",
sub_cmd_description="Fetch a reminder that failed to send",
)
@slash_option(
name="id", description="ID of the reminder", opt_type=OptionTypes.STRING, required=True
)
async def _fetch(self, ctx: InteractionContext, id: str) -> None:
reminder = await Reminder.find_one(q(id=id))
if not reminder:
await ctx.send(f"Reminder `{id}` does not exist", ephemeral=True)
return
embed = build_embed(title="You have a reminder!", description=reminder.message, fields=[])
embed.set_author(
name=user.name + "#" + user.discriminator,
icon_url=user.avatar_url,
name=ctx.author.display_name + "#" + ctx.author.discriminator,
icon_url=ctx.author.display_avatar,
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_thumbnail(url=ctx.author.display_avatar)
await ctx.send(embed=embed, ephemeral=reminder.private)
if reminder.remind_at <= datetime.now(tz=timezone.utc) and not reminder.active:
try:
await user.send(embed=embed)
await reminder.delete()
except Exception:
guild = self.bot.fetch_guild(reminder.guild)
channel = guild.get_channel(reminder.channel) if guild else None
if channel:
await channel.send(f"{user.mention}", embed=embed)
finally:
reminder.delete()
self.logger.debug("Ignoring deletion error")
def setup(bot: Bot) -> None:
"""Add RemindmeCog to J.A.R.V.I.S."""
bot.add_cog(RemindmeCog(bot))
def setup(bot: Client) -> None:
"""Add RemindmeCog to JARVIS"""
RemindmeCog(bot)

View file

@ -1,64 +1,75 @@
"""J.A.R.V.I.S. Role Giver Cog."""
"""JARVIS Role Giver Cog."""
import asyncio
import logging
from discord import Role
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from discord_slash.utils.manage_components import (
create_actionrow,
create_select,
create_select_option,
wait_for_component,
from jarvis_core.db import q
from jarvis_core.db.models import Rolegiver
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import get
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.embed import EmbedField
from naff.models.discord.role import Role
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check, cooldown
from naff.models.naff.cooldowns import Buckets
from jarvis.db.models import Rolegiver
from jarvis.utils import build_embed
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions
class RolegiverCog(commands.Cog):
"""J.A.R.V.I.S. Role Giver Cog."""
class RolegiverCog(Cog):
"""JARVIS Role Giver Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand(
base="rolegiver",
name="add",
description="Add a role to rolegiver",
options=[
create_option(
name="role",
description="Role to add",
option_type=8,
required=True,
rolegiver = SlashCommand(name="rolegiver", description="Allow users to choose their own roles")
@rolegiver.subcommand(
sub_cmd_name="add",
sub_cmd_description="Add a role to rolegiver",
)
],
@slash_option(name="role", description="Role to add", opt_type=OptionTypes.ROLE, required=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _rolegiver_add(self, ctx: InteractionContext, role: Role) -> None:
if role.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` to rolegiver", ephemeral=True)
return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
@admin_or_permissions(manage_guild=True)
async def _rolegiver_add(self, ctx: SlashContext, role: Role) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
if setting and role.id in setting.roles:
await ctx.send("Role already in rolegiver", hidden=True)
return
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if setting and setting.roles and role.id in setting.roles:
await ctx.send("Role already in rolegiver", ephemeral=True)
return
if not setting:
setting = Rolegiver(guild=ctx.guild.id, roles=[])
setting.roles = setting.roles or []
if len(setting.roles) >= 20:
await ctx.send("You can only have 20 roles in the rolegiver", hidden=True)
await ctx.send("You can only have 20 roles in the rolegiver", ephemeral=True)
return
setting.roles.append(role.id)
setting.save()
await setting.commit()
roles = []
for role_id in setting.roles:
if role_id == role.id:
continue
e_role = ctx.guild.get_role(role_id)
e_role = await ctx.guild.fetch_role(role_id)
if not e_role:
continue
roles.append(e_role)
@ -67,8 +78,8 @@ class RolegiverCog(commands.Cog):
value = "\n".join([r.mention for r in roles]) if roles else "None"
fields = [
Field(name="New Role", value=f"{role.mention}"),
Field(name="Existing Role(s)", value=value),
EmbedField(name="New Role", value=f"{role.mention}"),
EmbedField(name="Existing Role(s)", value=value),
]
embed = build_embed(
@ -77,61 +88,58 @@ class RolegiverCog(commands.Cog):
fields=fields,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.avatar_url,
)
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
embed.set_footer(text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}")
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="rolegiver",
name="remove",
description="Remove a role from rolegiver",
)
@admin_or_permissions(manage_guild=True)
async def _rolegiver_remove(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
@rolegiver.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role from rolegiver")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _rolegiver_remove(self, ctx: InteractionContext) -> None:
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or (setting and not setting.roles):
await ctx.send("Rolegiver has no roles", hidden=True)
await ctx.send("Rolegiver has no roles", ephemeral=True)
return
options = []
for role in setting.roles:
role: Role = ctx.guild.get_role(role)
option = create_select_option(label=role.name, value=str(role.id))
role: Role = await ctx.guild.fetch_role(role)
option = SelectOption(label=role.name, value=str(role.id))
options.append(option)
select = create_select(
select = Select(
options=options,
custom_id="to_delete",
placeholder="Select roles to remove",
min_values=1,
max_values=len(options),
)
components = [create_actionrow(select)]
components = [ActionRow(select)]
message = await ctx.send(content="\u200b", components=components)
try:
context = await wait_for_component(
self.bot,
check=lambda x: ctx.author.id == x.author.id,
message=message,
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 1,
)
for to_delete in context.selected_options:
removed_roles = []
for to_delete in context.context.values:
role = await ctx.guild.fetch_role(to_delete)
if role:
removed_roles.append(role)
setting.roles.remove(int(to_delete))
setting.save()
await setting.commit()
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
roles = []
for role_id in setting.roles:
e_role = ctx.guild.get_role(role_id)
e_role = await ctx.guild.fetch_role(role_id)
if not e_role:
continue
roles.append(e_role)
@ -140,9 +148,10 @@ class RolegiverCog(commands.Cog):
roles.sort(key=lambda x: -x.position)
value = "\n".join([r.mention for r in roles]) if roles else "None"
rvalue = "\n".join([r.mention for r in removed_roles]) if removed_roles else "None"
fields = [
Field(name="Removed Role", value=f"{role.mention}"),
Field(name="Remaining Role(s)", value=value),
EmbedField(name="Removed Role(s)", value=rvalue),
EmbedField(name="Remaining Role(s)", value=value),
]
embed = build_embed(
@ -151,39 +160,34 @@ class RolegiverCog(commands.Cog):
fields=fields,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.avatar_url,
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
embed.set_footer(
text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
await context.edit_origin(
content=f"Removed {len(context.selected_options)} role(s)",
await context.context.edit_origin(
content=f"Removed {len(context.context.values)} role(s)",
embed=embed,
components=components,
)
except asyncio.TimeoutError:
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
await message.edit(components=components)
@cog_ext.cog_subcommand(
base="rolegiver",
name="list",
description="List roles rolegiver",
)
async def _rolegiver_list(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
@rolegiver.subcommand(sub_cmd_name="list", sub_cmd_description="List rolegiver roles")
async def _rolegiver_list(self, ctx: InteractionContext) -> None:
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or (setting and not setting.roles):
await ctx.send("Rolegiver has no roles", hidden=True)
await ctx.send("Rolegiver has no roles", ephemeral=True)
return
roles = []
for role_id in setting.roles:
e_role = ctx.guild.get_role(role_id)
e_role = await ctx.guild.fetch_role(role_id)
if not e_role:
continue
roles.append(e_role)
@ -199,59 +203,54 @@ class RolegiverCog(commands.Cog):
fields=[],
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.avatar_url,
name=ctx.author.display_name,
icon_url=ctx.author.display_avatar.url,
)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
embed.set_footer(text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}")
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="role",
name="get",
description="Get a role from rolegiver",
)
@commands.cooldown(1, 10, commands.BucketType.user)
async def _role_get(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
role = SlashCommand(name="role", description="Get/Remove Rolegiver roles")
@role.subcommand(sub_cmd_name="get", sub_cmd_description="Get a role")
@cooldown(bucket=Buckets.USER, rate=1, interval=10)
async def _role_get(self, ctx: InteractionContext) -> None:
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or (setting and not setting.roles):
await ctx.send("Rolegiver has no roles", hidden=True)
await ctx.send("Rolegiver has no roles", ephemeral=True)
return
options = []
for role in setting.roles:
role: Role = ctx.guild.get_role(role)
option = create_select_option(label=role.name, value=str(role.id))
role: Role = await ctx.guild.fetch_role(role)
option = SelectOption(label=role.name, value=str(role.id))
options.append(option)
select = create_select(
select = Select(
options=options,
custom_id="to_delete",
placeholder="Select roles to add",
min_values=1,
max_values=len(options),
)
components = [create_actionrow(select)]
components = [ActionRow(select)]
message = await ctx.send(content="\u200b", components=components)
try:
context = await wait_for_component(
self.bot,
check=lambda x: ctx.author.id == x.author.id,
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
added_roles = []
for role in context.selected_options:
role = ctx.guild.get_role(int(role))
for role in context.context.values:
role = await ctx.guild.fetch_role(int(role))
added_roles.append(role)
await ctx.author.add_roles(role, reason="Rolegiver")
await ctx.author.add_role(role, reason="Rolegiver")
roles = ctx.author.roles
if roles:
@ -261,109 +260,133 @@ class RolegiverCog(commands.Cog):
avalue = "\n".join([r.mention for r in added_roles]) if added_roles else "None"
value = "\n".join([r.mention for r in roles]) if roles else "None"
fields = [
Field(name="Added Role(s)", value=avalue),
Field(name="Prior Role(s)", value=value),
EmbedField(name="Added Role(s)", value=avalue),
EmbedField(name="Prior Role(s)", value=value),
]
embed = build_embed(
title="User Given Role",
description=f"{role.mention} given to {ctx.author.mention}",
description=f"{len(added_roles)} role(s) given to {ctx.author.mention}",
fields=fields,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.avatar_url,
name=ctx.author.display_name,
icon_url=ctx.author.display_avatar.url,
)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
for row in components:
for component in row["components"]:
component["disabled"] = True
embed.set_footer(
text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
await message.edit_origin(embed=embed, content="\u200b", components=components)
for row in components:
for component in row.components:
component.disabled = True
await context.context.edit_origin(embed=embed, content="\u200b", components=components)
except asyncio.TimeoutError:
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
await message.edit(components=components)
@cog_ext.cog_subcommand(
base="role",
name="forfeit",
description="Have rolegiver take away role",
options=[
create_option(
name="role",
description="Role to remove",
option_type=8,
required=True,
)
],
)
@commands.cooldown(1, 10, commands.BucketType.user)
async def _role_forfeit(self, ctx: SlashContext, role: Role) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
@role.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role")
@cooldown(bucket=Buckets.USER, rate=1, interval=10)
async def _role_remove(self, ctx: InteractionContext) -> None:
user_roles = ctx.author.roles
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or (setting and not setting.roles):
await ctx.send("Rolegiver has no roles", hidden=True)
await ctx.send("Rolegiver has no roles", ephemeral=True)
return
elif role.id not in setting.roles:
await ctx.send("Role not in rolegiver", hidden=True)
return
elif role not in ctx.author.roles:
await ctx.send("You do not have that role", hidden=True)
elif not any(x.id in setting.roles for x in user_roles):
await ctx.send("You have no rolegiver roles", ephemeral=True)
return
await ctx.author.remove_roles(role, reason="Rolegiver")
valid = list(filter(lambda x: x.id in setting.roles, user_roles))
options = []
for role in valid:
option = SelectOption(label=role.name, value=str(role.id))
options.append(option)
roles = ctx.author.roles
if roles:
roles.sort(key=lambda x: -x.position)
_ = roles.pop(-1)
select = Select(
options=options,
custom_id="to_remove",
placeholder="Select roles to remove",
min_values=1,
max_values=len(options),
)
components = [ActionRow(select)]
value = "\n".join([r.mention for r in roles]) if roles else "None"
message = await ctx.send(content="\u200b", components=components)
try:
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
removed_roles = []
for to_remove in context.context.values:
role = get(user_roles, id=int(to_remove))
await ctx.author.remove_role(role, reason="Rolegiver")
user_roles.remove(role)
removed_roles.append(role)
user_roles.sort(key=lambda x: -x.position)
_ = user_roles.pop(-1)
value = "\n".join([r.mention for r in user_roles]) if user_roles else "None"
rvalue = "\n".join([r.mention for r in removed_roles]) if removed_roles else "None"
fields = [
Field(name="Taken Role", value=f"{role.mention}"),
Field(name="Remaining Role(s)", value=value),
EmbedField(name="Removed Role(s)", value=rvalue),
EmbedField(name="Remaining Role(s)", value=value),
]
embed = build_embed(
title="User Forfeited Role",
description=f"{role.mention} taken from {ctx.author.mention}",
description=f"{len(removed_roles)} role(s) removed from {ctx.author.mention}",
fields=fields,
)
embed.set_thumbnail(url=ctx.guild.icon_url)
embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.avatar_url,
embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
embed.set_footer(
text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}"
)
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}")
for row in components:
for component in row.components:
component.disabled = True
await ctx.send(embed=embed)
await context.context.edit_origin(embed=embed, components=components, content="\u200b")
@cog_ext.cog_subcommand(
base="rolegiver",
name="cleanup",
description="Cleanup rolegiver roles",
except asyncio.TimeoutError:
for row in components:
for component in row.components:
component.disabled = True
await message.edit(components=components)
@rolegiver.subcommand(
sub_cmd_name="cleanup", sub_cmd_description="Removed deleted roles from rolegiver"
)
@admin_or_permissions(manage_guild=True)
async def _rolegiver_cleanup(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _rolegiver_cleanup(self, ctx: InteractionContext) -> None:
setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or not setting.roles:
await ctx.send("Rolegiver has no roles", hidden=True)
guild_roles = await ctx.guild.fetch_roles()
guild_role_ids = [x.id for x in guild_roles]
await ctx.send("Rolegiver has no roles", ephemeral=True)
guild_role_ids = [r.id for r in ctx.guild.roles]
for role_id in setting.roles:
if role_id not in guild_role_ids:
setting.roles.remove(role_id)
setting.save()
await setting.commit()
await ctx.send("Rolegiver cleanup finished")
def setup(bot: commands.Bot) -> None:
"""Add RolegiverCog to J.A.R.V.I.S."""
bot.add_cog(RolegiverCog(bot))
def setup(bot: Client) -> None:
"""Add RolegiverCog to JARVIS"""
RolegiverCog(bot)

View file

@ -1,296 +1,287 @@
"""J.A.R.V.I.S. Settings Management Cog."""
"""JARVIS Settings Management Cog."""
import asyncio
import logging
from typing import Any
from discord import Role, TextChannel
from discord.ext import commands
from discord.utils import find
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_option
from jarvis_core.db import q
from jarvis_core.db.models import Setting
from naff import Client, Cog, InteractionContext
from naff.models.discord.channel import GuildText
from naff.models.discord.components import ActionRow, Button, ButtonStyles
from naff.models.discord.embed import EmbedField
from naff.models.discord.enums import Permissions
from naff.models.discord.role import Role
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Setting
from jarvis.utils import build_embed
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions
class SettingsCog(commands.Cog):
"""J.A.R.V.I.S. Settings Management Cog."""
class SettingsCog(Cog):
"""JARVIS Settings Management Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
def update_settings(self, setting: str, value: Any, guild: int) -> bool:
async def update_settings(self, setting: str, value: Any, guild: int) -> bool:
"""Update a guild setting."""
existing = Setting.objects(setting=setting, guild=guild).first()
existing = await Setting.find_one(q(setting=setting, guild=guild))
if not existing:
existing = Setting(setting=setting, guild=guild, value=value)
existing.value = value
updated = existing.save()
updated = await existing.commit()
return updated is not None
def delete_settings(self, setting: str, guild: int) -> bool:
async def delete_settings(self, setting: str, guild: int) -> bool:
"""Delete a guild setting."""
return Setting.objects(setting=setting, guild=guild).delete()
existing = await Setting.find_one(q(setting=setting, guild=guild))
if existing:
return await existing.delete()
return False
@cog_ext.cog_subcommand(
base="settings",
base_desc="Settings management",
subcommand_group="set",
subcommand_group_description="Set a setting",
name="mute",
description="Set mute role",
options=[
create_option(
name="role",
description="Mute role",
option_type=8,
required=True,
)
],
)
@admin_or_permissions(manage_guild=True)
async def _set_mute(self, ctx: SlashContext, role: Role) -> None:
await ctx.defer()
self.update_settings("mute", role.id, ctx.guild.id)
await ctx.send(f"Settings applied. New mute role is `{role.name}`")
settings = SlashCommand(name="settings", description="Control guild settings")
set_ = settings.group(name="set", description="Set a setting")
unset = settings.group(name="unset", description="Unset a setting")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="modlog",
description="Set modlog channel",
options=[
create_option(
name="channel",
description="Modlog channel",
option_type=7,
required=True,
@set_.subcommand(
sub_cmd_name="modlog",
sub_cmd_description="Set Moglod channel",
)
],
@slash_option(
name="channel", description="ModLog Channel", opt_type=OptionTypes.CHANNEL, required=True
)
@admin_or_permissions(manage_guild=True)
async def _set_modlog(self, ctx: SlashContext, channel: TextChannel) -> None:
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_modlog(self, ctx: InteractionContext, channel: GuildText) -> None:
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a GuildText", ephemeral=True)
return
self.update_settings("modlog", channel.id, ctx.guild.id)
await self.update_settings("modlog", channel.id, ctx.guild.id)
await ctx.send(f"Settings applied. New modlog channel is {channel.mention}")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="userlog",
description="Set userlog channel",
options=[
create_option(
@set_.subcommand(
sub_cmd_name="activitylog",
sub_cmd_description="Set Activitylog channel",
)
@slash_option(
name="channel",
description="Userlog channel",
option_type=7,
description="Activitylog Channel",
opt_type=OptionTypes.CHANNEL,
required=True,
)
],
)
@admin_or_permissions(manage_guild=True)
async def _set_userlog(self, ctx: SlashContext, channel: TextChannel) -> None:
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_activitylog(self, ctx: InteractionContext, channel: GuildText) -> None:
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a GuildText", ephemeral=True)
return
self.update_settings("userlog", channel.id, ctx.guild.id)
await ctx.send(f"Settings applied. New userlog channel is {channel.mention}")
await self.update_settings("activitylog", channel.id, ctx.guild.id)
await ctx.send(f"Settings applied. New activitylog channel is {channel.mention}")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="massmention",
description="Set massmention amount",
options=[
create_option(
@set_.subcommand(sub_cmd_name="massmention", sub_cmd_description="Set massmention output")
@slash_option(
name="amount",
description="Amount of mentions (0 to disable)",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=True,
)
],
)
@admin_or_permissions(manage_guild=True)
async def _set_massmention(self, ctx: SlashContext, amount: int) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_massmention(self, ctx: InteractionContext, amount: int) -> None:
await ctx.defer()
self.update_settings("massmention", amount, ctx.guild.id)
await self.update_settings("massmention", amount, ctx.guild.id)
await ctx.send(f"Settings applied. New massmention limit is {amount}")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="verified",
description="Set verified role",
options=[
create_option(
name="role",
description="verified role",
option_type=8,
required=True,
@set_.subcommand(sub_cmd_name="verified", sub_cmd_description="Set verified role")
@slash_option(
name="role", description="Verified role", opt_type=OptionTypes.ROLE, required=True
)
],
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_verified(self, ctx: InteractionContext, role: Role) -> None:
if role.id == ctx.guild.id:
await ctx.send("Cannot set verified to `@everyone`", ephemeral=True)
return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
@admin_or_permissions(manage_guild=True)
async def _set_verified(self, ctx: SlashContext, role: Role) -> None:
return
await ctx.defer()
self.update_settings("verified", role.id, ctx.guild.id)
await self.update_settings("verified", role.id, ctx.guild.id)
await ctx.send(f"Settings applied. New verified role is `{role.name}`")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="unverified",
description="Set unverified role",
options=[
create_option(
name="role",
description="Unverified role",
option_type=8,
required=True,
@set_.subcommand(sub_cmd_name="unverified", sub_cmd_description="Set unverified role")
@slash_option(
name="role", description="Unverified role", opt_type=OptionTypes.ROLE, required=True
)
],
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_unverified(self, ctx: InteractionContext, role: Role) -> None:
if role.id == ctx.guild.id:
await ctx.send("Cannot set unverified to `@everyone`", ephemeral=True)
return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
@admin_or_permissions(manage_guild=True)
async def _set_unverified(self, ctx: SlashContext, role: Role) -> None:
return
await ctx.defer()
self.update_settings("unverified", role.id, ctx.guild.id)
await self.update_settings("unverified", role.id, ctx.guild.id)
await ctx.send(f"Settings applied. New unverified role is `{role.name}`")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="set",
name="noinvite",
description="Set if invite deletion should happen",
options=[
create_option(
name="active",
description="Active?",
option_type=4,
required=True,
@set_.subcommand(
sub_cmd_name="noinvite", sub_cmd_description="Set if invite deletion should happen"
)
],
)
@admin_or_permissions(manage_guild=True)
async def _set_invitedel(self, ctx: SlashContext, active: int) -> None:
@slash_option(name="active", description="Active?", opt_type=OptionTypes.BOOLEAN, required=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_invitedel(self, ctx: InteractionContext, active: bool) -> None:
await ctx.defer()
self.update_settings("noinvite", bool(active), ctx.guild.id)
await ctx.send(f"Settings applied. Automatic invite active: {bool(active)}")
await self.update_settings("noinvite", active, ctx.guild.id)
await ctx.send(f"Settings applied. Automatic invite active: {active}")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
subcommand_group_description="Unset a setting",
name="mute",
description="Unset mute role",
)
@admin_or_permissions(manage_guild=True)
async def _unset_mute(self, ctx: SlashContext) -> None:
@set_.subcommand(sub_cmd_name="notify", sub_cmd_description="Notify users of admin action?")
@slash_option(name="active", description="Notify?", opt_type=OptionTypes.BOOLEAN, required=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_notify(self, ctx: InteractionContext, active: bool) -> None:
await ctx.defer()
self.delete_settings("mute", ctx.guild.id)
await ctx.send("Setting removed.")
await self.update_settings("notify", active, ctx.guild.id)
await ctx.send(f"Settings applied. Notifications active: {active}")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
name="modlog",
description="Unset modlog channel",
# Unset
@unset.subcommand(
sub_cmd_name="modlog",
sub_cmd_description="Unset Modlog channel",
)
@admin_or_permissions(manage_guild=True)
async def _unset_modlog(self, ctx: SlashContext) -> None:
self.delete_settings("modlog", ctx.guild.id)
await ctx.send("Setting removed.")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
name="userlog",
description="Unset userlog channel",
)
@admin_or_permissions(manage_guild=True)
async def _unset_userlog(self, ctx: SlashContext) -> None:
self.delete_settings("userlog", ctx.guild.id)
await ctx.send("Setting removed.")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
name="massmention",
description="Unet massmention amount",
)
@admin_or_permissions(manage_guild=True)
async def _massmention(self, ctx: SlashContext) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_modlog(self, ctx: InteractionContext) -> None:
await ctx.defer()
self.delete_settings("massmention", ctx.guild.id)
await ctx.send("Setting removed.")
await self.delete_settings("modlog", ctx.guild.id)
await ctx.send("Setting `modlog` unset")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
name="verified",
description="Unset verified role",
@unset.subcommand(
sub_cmd_name="activitylog",
sub_cmd_description="Unset Activitylog channel",
)
@admin_or_permissions(manage_guild=True)
async def _verified(self, ctx: SlashContext) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_activitylog(self, ctx: InteractionContext) -> None:
await ctx.defer()
self.delete_settings("verified", ctx.guild.id)
await ctx.send("Setting removed.")
await self.delete_settings("activitylog", ctx.guild.id)
await ctx.send("Setting `activitylog` unset")
@cog_ext.cog_subcommand(
base="settings",
subcommand_group="unset",
name="unverified",
description="Unset unverified role",
@unset.subcommand(sub_cmd_name="massmention", sub_cmd_description="Unset massmention output")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_massmention(self, ctx: InteractionContext) -> None:
await ctx.defer()
await self.delete_settings("massmention", ctx.guild.id)
await ctx.send("Setting `massmention` unset")
@unset.subcommand(sub_cmd_name="verified", sub_cmd_description="Unset verified role")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_verified(self, ctx: InteractionContext) -> None:
await ctx.defer()
await self.delete_settings("verified", ctx.guild.id)
await ctx.send("Setting `verified` unset")
@unset.subcommand(sub_cmd_name="unverified", sub_cmd_description="Unset unverified role")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_unverified(self, ctx: InteractionContext) -> None:
await ctx.defer()
await self.delete_settings("unverified", ctx.guild.id)
await ctx.send("Setting `unverified` unset")
@unset.subcommand(
sub_cmd_name="noinvite", sub_cmd_description="Unset if invite deletion should happen"
)
@admin_or_permissions(manage_guild=True)
async def _unverified(self, ctx: SlashContext) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_invitedel(self, ctx: InteractionContext, active: bool) -> None:
await ctx.defer()
self.delete_settings("unverified", ctx.guild.id)
await ctx.send("Setting removed.")
await self.delete_settings("noinvite", ctx.guild.id)
await ctx.send(f"Setting `{active}` unset")
@cog_ext.cog_subcommand(base="settings", name="view", description="View settings")
@admin_or_permissions(manage_guild=True)
async def _view(self, ctx: SlashContext) -> None:
settings = Setting.objects(guild=ctx.guild.id)
@unset.subcommand(sub_cmd_name="notify", sub_cmd_description="Unset admin action notifications")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_notify(self, ctx: InteractionContext) -> None:
await ctx.defer()
await self.delete_settings("notify", ctx.guild.id)
await ctx.send("Setting `notify` unset")
@settings.subcommand(sub_cmd_name="view", sub_cmd_description="View settings")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _view(self, ctx: InteractionContext) -> None:
settings = Setting.find(q(guild=ctx.guild.id))
fields = []
for setting in settings:
async for setting in settings:
value = setting.value
if setting.setting in ["unverified", "verified", "mute"]:
value = find(lambda x: x.id == value, ctx.guild.roles)
try:
value = await ctx.guild.fetch_role(value)
except KeyError:
await setting.delete()
continue
if value:
value = value.mention
else:
value = "||`[redacted]`||"
elif setting.setting in ["userlog", "modlog"]:
value = find(lambda x: x.id == value, ctx.guild.text_channels)
elif setting.setting in ["activitylog", "modlog"]:
value = await ctx.guild.fetch_channel(value)
if value:
value = value.mention
else:
value = "||`[redacted]`||"
elif setting.setting == "rolegiver":
value = ""
for role in setting.value:
nvalue = find(lambda x: x.id == value, ctx.guild.roles)
if value:
for _role in setting.value:
nvalue = await ctx.guild.fetch_role(_role)
if nvalue:
value += "\n" + nvalue.mention
else:
value += "\n||`[redacted]`||"
fields.append(Field(name=setting.setting, value=value or "N/A"))
fields.append(EmbedField(name=setting.setting, value=str(value) or "N/A", inline=False))
embed = build_embed(title="Current Settings", description="", fields=fields)
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(base="settings", name="clear", description="Clear all settings")
@admin_or_permissions(manage_guild=True)
async def _clear(self, ctx: SlashContext) -> None:
deleted = Setting.objects(guild=ctx.guild.id).delete()
await ctx.send(f"Guild settings cleared: `{deleted is not None}`")
@settings.subcommand(sub_cmd_name="clear", sub_cmd_description="Clear all settings")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _clear(self, ctx: InteractionContext) -> None:
components = [
ActionRow(
Button(style=ButtonStyles.RED, emoji="✖️", custom_id="no"),
Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="yes"),
)
]
message = await ctx.send("***Are you sure?***", components=components)
try:
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
content = "***Are you sure?***"
if context.context.custom_id == "yes":
async for setting in Setting.find(q(guild=ctx.guild.id)):
await setting.delete()
content = "Guild settings cleared"
else:
content = "Guild settings not cleared"
for row in components:
for component in row.components:
component.disabled = True
await context.context.edit_origin(content=content, components=components)
except asyncio.TimeoutError:
for row in components:
for component in row.components:
component.disabled = True
await message.edit(content="Guild settings not cleared", components=components)
def setup(bot: commands.Bot) -> None:
"""Add SettingsCog to J.A.R.V.I.S."""
bot.add_cog(SettingsCog(bot))
def setup(bot: Client) -> None:
"""Add SettingsCog to JARVIS"""
SettingsCog(bot)

View file

@ -1,19 +1,21 @@
"""J.A.R.V.I.S. Starboard Cog."""
from discord import TextChannel
from discord.ext import commands
from discord.utils import find
from discord_slash import SlashContext, cog_ext
from discord_slash.context import MenuContext
from discord_slash.model import ContextMenuType, SlashMessage
from discord_slash.utils.manage_commands import create_option
from discord_slash.utils.manage_components import (
create_actionrow,
create_select,
create_select_option,
wait_for_component,
)
"""JARVIS Starboard Cog."""
import logging
from jarvis_core.db import q
from jarvis_core.db.models import Star, Starboard
from naff import Client, Cog, InteractionContext, Permissions
from naff.models.discord.channel import GuildText
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.discord.message import Message
from naff.models.naff.application_commands import (
CommandTypes,
OptionTypes,
SlashCommand,
context_menu,
slash_option,
)
from naff.models.naff.command import check
from jarvis.db.models import Star, Starboard
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
@ -26,20 +28,22 @@ supported_images = [
]
class StarboardCog(commands.Cog):
"""J.A.R.V.I.S. Starboard Cog."""
class StarboardCog(Cog):
"""JARVIS Starboard Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand(
base="starboard",
name="list",
description="Lists all Starboards",
starboard = SlashCommand(name="starboard", description="Extra pins! Manage starboards")
@starboard.subcommand(
sub_cmd_name="list",
sub_cmd_description="List all starboards",
)
@admin_or_permissions(manage_guild=True)
async def _list(self, ctx: SlashContext) -> None:
starboards = Starboard.objects(guild=ctx.guild.id)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _list(self, ctx: InteractionContext) -> None:
starboards = await Starboard.find(q(guild=ctx.guild.id)).to_list(None)
if starboards != []:
message = "Available Starboards:\n"
for s in starboards:
@ -48,153 +52,144 @@ class StarboardCog(commands.Cog):
else:
await ctx.send("No Starboards available.")
@cog_ext.cog_subcommand(
base="starboard",
name="create",
description="Create a starboard",
options=[
create_option(
@starboard.subcommand(sub_cmd_name="create", sub_cmd_description="Create a starboard")
@slash_option(
name="channel",
description="Starboard channel",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _create(self, ctx: SlashContext, channel: TextChannel) -> None:
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _create(self, ctx: InteractionContext, channel: GuildText) -> None:
if channel not in ctx.guild.channels:
await ctx.send(
"Channel not in guild. Choose an existing channel.",
hidden=True,
ephemeral=True,
)
return
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a GuildText", ephemeral=True)
return
exists = Starboard.objects(channel=channel.id, guild=ctx.guild.id).first()
exists = await Starboard.find_one(q(channel=channel.id, guild=ctx.guild.id))
if exists:
await ctx.send(f"Starboard already exists at {channel.mention}.", hidden=True)
await ctx.send(f"Starboard already exists at {channel.mention}.", ephemeral=True)
return
count = Starboard.objects(guild=ctx.guild.id).count()
count = await Starboard.count_documents(q(guild=ctx.guild.id))
if count >= 25:
await ctx.send("25 starboard limit reached", hidden=True)
await ctx.send("25 starboard limit reached", ephemeral=True)
return
_ = Starboard(
await Starboard(
guild=ctx.guild.id,
channel=channel.id,
admin=ctx.author.id,
).save()
).commit()
await ctx.send(f"Starboard created. Check it out at {channel.mention}.")
@cog_ext.cog_subcommand(
base="starboard",
name="delete",
description="Delete a starboard",
options=[
create_option(
@starboard.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a starboard")
@slash_option(
name="channel",
description="Starboard channel",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _delete(self, ctx: SlashContext, channel: TextChannel) -> None:
deleted = Starboard.objects(channel=channel.id, guild=ctx.guild.id).delete()
if deleted:
_ = Star.objects(starboard=channel.id).delete()
await ctx.send(f"Starboard deleted from {channel.mention}.", hidden=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _delete(self, ctx: InteractionContext, channel: GuildText) -> None:
found = await Starboard.find_one(q(channel=channel.id, guild=ctx.guild.id))
if found:
await found.delete()
await ctx.send(f"Starboard deleted from {channel.mention}.")
else:
await ctx.send(f"Starboard not found in {channel.mention}.", hidden=True)
await ctx.send(f"Starboard not found in {channel.mention}.", ephemeral=True)
@cog_ext.cog_context_menu(name="Star Message", target=ContextMenuType.MESSAGE)
async def _star_message(self, ctx: MenuContext) -> None:
await self._star_add.invoke(ctx, ctx.target_message)
@cog_ext.cog_subcommand(
base="star",
name="add",
description="Star a message",
options=[
create_option(
name="message",
description="Message to star",
option_type=3,
required=True,
),
create_option(
name="channel",
description="Channel that has the message, required if different than command message",
option_type=7,
required=False,
),
],
)
@admin_or_permissions(manage_guild=True)
async def _star_add(
self,
ctx: SlashContext,
ctx: InteractionContext,
message: str,
channel: TextChannel = None,
channel: GuildText = None,
) -> None:
if not channel:
channel = ctx.channel
starboards = Starboard.objects(guild=ctx.guild.id)
starboards = await Starboard.find(q(guild=ctx.guild.id)).to_list(None)
if not starboards:
await ctx.send("No starboards exist.", hidden=True)
await ctx.send("No starboards exist.", ephemeral=True)
return
await ctx.defer()
if not isinstance(message, Message):
if message.startswith("https://"):
message = message.split("/")[-1]
message = await channel.fetch_message(int(message))
if not message:
await ctx.send("Message not found", ephemeral=True)
return
channel_list = []
to_delete = []
for starboard in starboards:
channel_list.append(find(lambda x: x.id == starboard.channel, ctx.guild.channels))
c = await ctx.guild.fetch_channel(starboard.channel)
if c and isinstance(c, GuildText):
channel_list.append(c)
else:
self.logger.warning(
f"Starboard {starboard.channel} no longer valid in {ctx.guild.name}"
)
to_delete.append(starboard)
select_channels = [create_select_option(label=x.name, value=str(idx)) for idx, x in enumerate(channel_list)]
for starboard in to_delete:
try:
await starboard.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
select = create_select(
select_channels = []
for idx, x in enumerate(channel_list):
if x:
select_channels.append(SelectOption(label=x.name, value=str(idx)))
select_channels = [
SelectOption(label=x.name, value=str(idx)) for idx, x in enumerate(channel_list)
]
select = Select(
options=select_channels,
min_values=1,
max_values=1,
)
components = [create_actionrow(select)]
components = [ActionRow(select)]
msg = await ctx.send(content="Choose a starboard", components=components)
com_ctx = await wait_for_component(
self.bot,
com_ctx = await self.bot.wait_for_component(
messages=msg,
components=components,
check=lambda x: x.author.id == ctx.author.id,
check=lambda x: ctx.author.id == x.context.author.id,
)
starboard = channel_list[int(com_ctx.selected_options[0])]
starboard = channel_list[int(com_ctx.context.values[0])]
if not isinstance(message, SlashMessage):
if message.startswith("https://"):
message = message.split("/")[-1]
message = await channel.fetch_message(message)
exists = Star.objects(
exists = await Star.find_one(
q(
message=message.id,
channel=message.channel.id,
guild=message.guild.id,
channel=channel.id,
guild=ctx.guild.id,
starboard=starboard.id,
).first()
)
)
if exists:
await ctx.send(
f"Message already sent to Starboard {starboard.mention}",
hidden=True,
ephemeral=True,
)
return
count = Star.objects(guild=message.guild.id, starboard=starboard.id).count()
count = await Star.count_documents(q(guild=ctx.guild.id, starboard=starboard.id))
content = message.content
attachments = message.attachments
@ -215,91 +210,114 @@ class StarboardCog(commands.Cog):
timestamp=message.created_at,
)
embed.set_author(
name=message.author.name,
name=message.author.display_name,
url=message.jump_url,
icon_url=message.author.avatar_url,
icon_url=message.author.avatar.url,
)
embed.set_footer(text=message.guild.name + " | " + message.channel.name)
embed.set_footer(text=ctx.guild.name + " | " + channel.name)
if image_url:
embed.set_image(url=image_url)
star = await starboard.send(embed=embed)
_ = Star(
await Star(
index=count,
message=message.id,
channel=message.channel.id,
guild=message.guild.id,
channel=channel.id,
guild=ctx.guild.id,
starboard=starboard.id,
admin=ctx.author.id,
star=star.id,
active=True,
).save()
).commit()
components[0]["components"][0]["disabled"] = True
components[0].components[0].disabled = True
await com_ctx.edit_origin(
await com_ctx.context.edit_origin(
content=f"Message saved to Starboard.\nSee it in {starboard.mention}",
components=components,
)
@cog_ext.cog_subcommand(
base="star",
name="delete",
description="Delete a starred message",
options=[
create_option(
name="id",
description="Star to delete",
option_type=4,
required=True,
),
create_option(
@context_menu(name="Star Message", context_type=CommandTypes.MESSAGE)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _star_message(self, ctx: InteractionContext) -> None:
await self._star_add(ctx, message=str(ctx.target_id))
star = SlashCommand(
name="star",
description="Manage stars",
)
@star.subcommand(
sub_cmd_name="add",
sub_cmd_description="Star a message",
)
@slash_option(
name="message", description="Message to star", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="channel",
description="Channel that has the message, not required if used in same channel",
opt_type=OptionTypes.CHANNEL,
required=False,
)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _star_message_slash(
self, ctx: InteractionContext, message: str, channel: GuildText
) -> None:
await self._star_add(ctx, message, channel)
@star.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a starred message")
@slash_option(
name="id", description="Star ID to delete", opt_type=OptionTypes.INTEGER, required=True
)
@slash_option(
name="starboard",
description="Starboard to delete star from",
option_type=7,
opt_type=OptionTypes.CHANNEL,
required=True,
),
],
)
@admin_or_permissions(manage_guild=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _star_delete(
self,
ctx: SlashContext,
ctx: InteractionContext,
id: int,
starboard: TextChannel,
starboard: GuildText,
) -> None:
if not isinstance(starboard, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
if not isinstance(starboard, GuildText):
await ctx.send("Channel must be a GuildText channel", ephemeral=True)
return
exists = Starboard.objects(channel=starboard.id, guild=ctx.guild.id).first()
exists = await Starboard.find_one(q(channel=starboard.id, guild=ctx.guild.id))
if not exists:
# TODO: automagically create starboard
await ctx.send(
f"Starboard does not exist in {starboard.mention}. Please create it first",
hidden=True,
ephemeral=True,
)
return
star = Star.objects(
star = await Star.find_one(
q(
starboard=starboard.id,
index=id,
guild=ctx.guild.id,
active=True,
).first()
)
)
if not star:
await ctx.send(f"No star exists with id {id}", hidden=True)
await ctx.send(f"No star exists with id {id}", ephemeral=True)
return
message = await starboard.fetch_message(star.star)
if message:
await message.delete()
star.active = False
star.save()
await star.delete()
await ctx.send(f"Star {id} deleted")
await ctx.send(f"Star {id} deleted from {starboard.mention}")
def setup(bot: commands.Bot) -> None:
"""Add StarboardCog to J.A.R.V.I.S."""
bot.add_cog(StarboardCog(bot))
def setup(bot: Client) -> None:
"""Add StarboardCog to JARVIS"""
StarboardCog(bot)

126
jarvis/cogs/temprole.py Normal file
View file

@ -0,0 +1,126 @@
"""JARVIS temporary role handler."""
import logging
from datetime import datetime, timezone
from dateparser import parse
from dateparser_data.settings import default_parsers
from jarvis_core.db.models import Temprole
from naff import Client, Cog, InteractionContext, Permissions
from naff.models.discord.embed import EmbedField
from naff.models.discord.role import Role
from naff.models.discord.user import Member
from naff.models.naff.application_commands import (
OptionTypes,
slash_command,
slash_option,
)
from naff.models.naff.command import check
from jarvis.utils import build_embed
from jarvis.utils.permissions import admin_or_permissions
class TemproleCog(Cog):
"""JARVIS Temporary Role Cog."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@slash_command(name="temprole", description="Give a user a temporary role")
@slash_option(
name="user", description="User to grant role", opt_type=OptionTypes.USER, required=True
)
@slash_option(
name="role", description="Role to grant", opt_type=OptionTypes.ROLE, required=True
)
@slash_option(
name="duration",
description="Duration of temp role (i.e. 2 hours)",
opt_type=OptionTypes.STRING,
required=True,
)
@slash_option(
name="reason",
description="Reason for temporary role",
opt_type=OptionTypes.STRING,
required=False,
)
@check(admin_or_permissions(Permissions.MANAGE_ROLES))
async def _temprole(
self, ctx: InteractionContext, user: Member, role: Role, duration: str, reason: str = None
) -> None:
await ctx.defer()
if not isinstance(user, Member):
await ctx.send("User not in guild", ephemeral=True)
return
if role.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` to users", ephemeral=True)
return
if role.bot_managed or not role.is_assignable:
await ctx.send(
"Cannot assign this role, try lowering it below my role or using a different role",
ephemeral=True,
)
return
base_settings = {
"PREFER_DATES_FROM": "future",
"TIMEZONE": "UTC",
"RETURN_AS_TIMEZONE_AWARE": True,
}
rt_settings = base_settings.copy()
rt_settings["PARSERS"] = [
x for x in default_parsers if x not in ["absolute-time", "timestamp"]
]
rt_duration = parse(duration, settings=rt_settings)
at_settings = base_settings.copy()
at_settings["PARSERS"] = [x for x in default_parsers if x != "relative-time"]
at_duration = parse(duration, settings=at_settings)
if rt_duration:
duration = rt_duration
elif at_duration:
duration = at_duration
else:
self.logger.debug(f"Failed to parse duration: {duration}")
await ctx.send(f"`{duration}` is not a parsable date, please try again", ephemeral=True)
return
if duration < datetime.now(tz=timezone.utc):
await ctx.send(
f"`{duration}` is in the past. Past durations aren't allowed", ephemeral=True
)
return
await user.add_role(role, reason=reason)
await Temprole(
guild=ctx.guild.id, user=user.id, role=role.id, admin=ctx.author.id, expires_at=duration
).commit()
ts = int(duration.timestamp())
fields = (
EmbedField(name="Role", value=role.mention),
EmbedField(name="Valid Until", value=f"<t:{ts}:F> (<t:{ts}:R>)"),
)
embed = build_embed(
title="Role granted",
description=f"Role temporarily granted to {user.mention}",
fields=fields,
)
embed.set_author(
name=f"{user.username}#{user.discriminator}", icon_url=user.display_avatar.url
)
await ctx.send(embed=embed)
def setup(bot: Client) -> None:
"""Add TemproleCog to JARVIS"""
TemproleCog(bot)

View file

@ -1,164 +1,141 @@
"""J.A.R.V.I.S. Twitter Cog."""
"""JARVIS Twitter Cog."""
import asyncio
import logging
import tweepy
from bson import ObjectId
from discord import TextChannel
from discord.ext import commands
from discord.ext.tasks import loop
from discord.utils import find
from discord_slash import SlashContext, cog_ext
from discord_slash.model import SlashCommandOptionType as COptionType
from discord_slash.utils.manage_commands import create_choice, create_option
from discord_slash.utils.manage_components import (
create_actionrow,
create_select,
create_select_option,
wait_for_component,
from jarvis_core.db import q
from jarvis_core.db.models import TwitterAccount, TwitterFollow
from naff import Client, Cog, InteractionContext, Permissions
from naff.client.utils.misc_utils import get
from naff.models.discord.channel import GuildText
from naff.models.discord.components import ActionRow, Select, SelectOption
from naff.models.naff.application_commands import (
OptionTypes,
SlashCommand,
slash_option,
)
from naff.models.naff.command import check
from jarvis.config import get_config
from jarvis.db.models import Twitter
from jarvis.config import JarvisConfig
from jarvis.utils.permissions import admin_or_permissions
logger = logging.getLogger("discord")
class TwitterCog(Cog):
"""JARVIS Twitter Cog."""
class TwitterCog(commands.Cog):
"""J.A.R.V.I.S. Twitter Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
config = get_config()
auth = tweepy.AppAuthHandler(config.twitter["consumer_key"], config.twitter["consumer_secret"])
self.logger = logging.getLogger(__name__)
config = JarvisConfig.from_yaml()
auth = tweepy.AppAuthHandler(
config.twitter["consumer_key"], config.twitter["consumer_secret"]
)
self.api = tweepy.API(auth)
self._tweets.start()
self._guild_cache = {}
self._channel_cache = {}
@loop(seconds=30)
async def _tweets(self) -> None:
twitters = Twitter.objects(active=True)
handles = Twitter.objects.distinct("handle")
twitter_data = {}
for handle in handles:
try:
twitter_data[handle] = self.api.user_timeline(screen_name=handle)
except Exception as e:
logger.error(f"Error with fetching: {e}")
for twitter in twitters:
try:
tweets = list(filter(lambda x: x.id > twitter.last_tweet, twitter_data[twitter.handle]))
if tweets:
tweets = sorted(tweets, key=lambda x: x.id)
if twitter.guild not in self._guild_cache:
self._guild_cache[twitter.guild] = await self.bot.fetch_guild(twitter.guild)
guild = self._guild_cache[twitter.guild]
if twitter.channel not in self._channel_cache:
channels = await guild.fetch_channels()
self._channel_cache[twitter.channel] = find(lambda x: x.id == twitter.channel, channels)
channel = self._channel_cache[twitter.channel]
for tweet in tweets:
retweet = "retweeted_status" in tweet.__dict__
if retweet and not twitter.retweets:
continue
timestamp = int(tweet.created_at.timestamp())
url = f"https://twitter.com/{twitter.handle}/status/{tweet.id}"
verb = "re" if retweet else ""
await channel.send(f"`@{twitter.handle}` {verb}tweeted this at <t:{timestamp}:f>: {url}")
newest = max(tweets, key=lambda x: x.id)
twitter.last_tweet = newest.id
twitter.save()
except Exception as e:
logger.error(f"Error with tweets: {e}")
twitter = SlashCommand(
name="twitter",
description="Manage Twitter follows",
)
@cog_ext.cog_subcommand(
base="twitter",
base_description="Twitter commands",
name="follow",
description="Follow a Twitter account",
options=[
create_option(name="handle", description="Twitter account", option_type=COptionType.STRING, required=True),
create_option(
@twitter.subcommand(
sub_cmd_name="follow",
sub_cmd_description="Follow a Twitter acount",
)
@slash_option(
name="handle", description="Twitter account", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="channel",
description="Channel to post tweets into",
option_type=COptionType.CHANNEL,
description="Channel to post tweets to",
opt_type=OptionTypes.CHANNEL,
required=True,
),
create_option(
)
@slash_option(
name="retweets",
description="Mirror re-tweets?",
option_type=COptionType.STRING,
opt_type=OptionTypes.BOOLEAN,
required=False,
choices=[create_choice(name="Yes", value="Yes"), create_choice(name="No", value="No")],
),
],
)
@admin_or_permissions(manage_guild=True)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _twitter_follow(
self, ctx: SlashContext, handle: str, channel: TextChannel, retweets: str = "Yes"
self, ctx: InteractionContext, handle: str, channel: GuildText, retweets: bool = True
) -> None:
retweets = retweets == "Yes"
if len(handle) > 15:
await ctx.send("Invalid Twitter handle", hidden=True)
handle = handle.lower()
if len(handle) > 15 or len(handle) < 4:
await ctx.send("Invalid Twitter handle", ephemeral=True)
return
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a text channel", hidden=True)
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a text channel", ephemeral=True)
return
try:
latest_tweet = self.api.user_timeline(screen_name=handle, count=1)[0]
account = await asyncio.to_thread(self.api.get_user, screen_name=handle)
latest_tweet = (await asyncio.to_thread(self.api.user_timeline, screen_name=handle))[0]
except Exception:
await ctx.send("Unable to get user timeline. Are you sure the handle is correct?", hidden=True)
await ctx.send(
"Unable to get user timeline. Are you sure the handle is correct?", ephemeral=True
)
return
count = Twitter.objects(guild=ctx.guild.id).count()
if count >= 12:
await ctx.send("Cannot follow more than 12 Twitter accounts", hidden=True)
return
exists = Twitter.objects(handle=handle, guild=ctx.guild.id)
exists = await TwitterFollow.find_one(q(twitter_id=account.id, guild=ctx.guild.id))
if exists:
await ctx.send("Twitter handle already being followed in this guild", hidden=True)
await ctx.send("Twitter account already being followed in this guild", ephemeral=True)
return
t = Twitter(
handle=handle,
count = len([i async for i in TwitterFollow.find(q(guild=ctx.guild.id))])
if count >= 12:
await ctx.send("Cannot follow more than 12 Twitter accounts", ephemeral=True)
return
ta = await TwitterAccount.find_one(q(twitter_id=account.id))
if not ta:
ta = TwitterAccount(
handle=account.screen_name,
twitter_id=account.id,
last_tweet=latest_tweet.id,
)
await ta.commit()
tf = TwitterFollow(
twitter_id=account.id,
guild=ctx.guild.id,
channel=channel.id,
admin=ctx.author.id,
last_tweet=latest_tweet.id,
retweets=retweets,
)
t.save()
await tf.commit()
await ctx.send(f"Now following `@{handle}` in {channel.mention}")
@cog_ext.cog_subcommand(
base="twitter",
name="unfollow",
description="Unfollow Twitter accounts",
)
@admin_or_permissions(manage_guild=True)
async def _twitter_unfollow(self, ctx: SlashContext) -> None:
twitters = Twitter.objects(guild=ctx.guild.id)
@twitter.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Twitter accounts")
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _twitter_unfollow(self, ctx: InteractionContext) -> None:
t = TwitterFollow.find(q(guild=ctx.guild.id))
twitters = []
async for twitter in t:
twitters.append(twitter)
if not twitters:
await ctx.send("You need to follow a Twitter account first", hidden=True)
await ctx.send("You need to follow a Twitter account first", ephemeral=True)
return
options = []
handlemap = {str(x.id): x.handle for x in twitters}
handlemap = {}
for twitter in twitters:
option = create_select_option(label=twitter.handle, value=str(twitter.id))
account = await TwitterAccount.find_one(q(twitter_id=twitter.twitter_id))
handlemap[str(twitter.twitter_id)] = account.handle
option = SelectOption(label=account.handle, value=str(twitter.twitter_id))
options.append(option)
select = create_select(options=options, custom_id="to_delete", min_values=1, max_values=len(twitters))
select = Select(
options=options, custom_id="to_delete", min_values=1, max_values=len(twitters)
)
components = [create_actionrow(select)]
block = "\n".join(x.handle for x in twitters)
components = [ActionRow(select)]
block = "\n".join(x for x in handlemap.values())
message = await ctx.send(
content=f"You are following the following accounts:\n```\n{block}\n```\n\n"
"Please choose accounts to unfollow",
@ -166,53 +143,65 @@ class TwitterCog(commands.Cog):
)
try:
context = await wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
)
for to_delete in context.selected_options:
_ = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_delete)).delete()
for to_delete in context.context.values:
follow = get(twitters, guild=ctx.guild.id, twitter_id=int(to_delete))
try:
await follow.delete()
except Exception:
self.logger.debug("Ignoring deletion error")
for row in components:
for component in row["components"]:
component["disabled"] = True
block = "\n".join(handlemap[x] for x in context.selected_options)
await context.edit_origin(content=f"Unfollowed the following:\n```\n{block}\n```", components=components)
for component in row.components:
component.disabled = True
block = "\n".join(handlemap[x] for x in context.context.values)
await context.context.edit_origin(
content=f"Unfollowed the following:\n```\n{block}\n```", components=components
)
except asyncio.TimeoutError:
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
await message.edit(components=components)
@cog_ext.cog_subcommand(
base="twitter",
name="retweets",
description="Modify followed Twitter accounts",
options=[
create_option(
@twitter.subcommand(
sub_cmd_name="retweets",
sub_cmd_description="Modify followed Twitter accounts",
)
@slash_option(
name="retweets",
description="Mirror re-tweets?",
option_type=COptionType.STRING,
required=True,
choices=[create_choice(name="Yes", value="Yes"), create_choice(name="No", value="No")],
),
],
opt_type=OptionTypes.BOOLEAN,
required=False,
)
@admin_or_permissions(manage_guild=True)
async def _twitter_modify(self, ctx: SlashContext, retweets: str) -> None:
retweets = retweets == "Yes"
twitters = Twitter.objects(guild=ctx.guild.id)
@check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _twitter_modify(self, ctx: InteractionContext, retweets: bool = True) -> None:
t = TwitterFollow.find(q(guild=ctx.guild.id))
twitters = []
async for twitter in t:
twitters.append(twitter)
if not twitters:
await ctx.send("You need to follow a Twitter account first", hidden=True)
await ctx.send("You need to follow a Twitter account first", ephemeral=True)
return
options = []
handlemap = {}
for twitter in twitters:
option = create_select_option(label=twitter.handle, value=str(twitter.id))
account = await TwitterAccount.find_one(q(twitter_id=twitter.id))
handlemap[str(twitter.twitter_id)] = account.handle
option = SelectOption(label=account.handle, value=str(twitter.twitter_id))
options.append(option)
select = create_select(options=options, custom_id="to_update", min_values=1, max_values=len(twitters))
select = Select(
options=options, custom_id="to_update", min_values=1, max_values=len(twitters)
)
components = [create_actionrow(select)]
block = "\n".join(x.handle for x in twitters)
components = [ActionRow(select)]
block = "\n".join(x for x in handlemap.values())
message = await ctx.send(
content=f"You are following the following accounts:\n```\n{block}\n```\n\n"
f"Please choose which accounts to {'un' if not retweets else ''}follow retweets from",
@ -220,30 +209,41 @@ class TwitterCog(commands.Cog):
)
try:
context = await wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5
context = await self.bot.wait_for_component(
check=lambda x: ctx.author.id == x.author.id,
messages=message,
timeout=60 * 5,
)
handlemap = {str(x.id): x.handle for x in twitters}
for to_update in context.selected_options:
t = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_update)).first()
t.retweets = retweets
t.save()
handlemap = {}
for to_update in context.context.values:
account = await TwitterAccount.find_one(q(twitter_id=int(to_update)))
handlemap[str(twitter.twitter_id)] = account.handle
t = get(twitters, guild=ctx.guild.id, twitter_id=int(to_update))
t.update(q(retweets=True))
await t.commit()
for row in components:
for component in row["components"]:
component["disabled"] = True
block = "\n".join(handlemap[x] for x in context.selected_options)
await context.edit_origin(
content=f"{'Unfollowed' if not retweets else 'Followed'} retweets from the following:"
f"\n```\n{block}\n```",
for component in row.components:
component.disabled = True
block = "\n".join(handlemap[x] for x in context.context.values)
await context.context.edit_origin(
content=(
f"{'Unfollowed' if not retweets else 'Followed'} "
"retweets from the following:"
f"\n```\n{block}\n```"
),
components=components,
)
except asyncio.TimeoutError:
for row in components:
for component in row["components"]:
component["disabled"] = True
for component in row.components:
component.disabled = True
await message.edit(components=components)
def setup(bot: commands.Bot) -> None:
"""Add TwitterCog to J.A.R.V.I.S."""
bot.add_cog(TwitterCog(bot))
def setup(bot: Client) -> None:
"""Add TwitterCog to JARVIS"""
if JarvisConfig.from_yaml().twitter:
TwitterCog(bot)

View file

@ -1,165 +1,166 @@
"""J.A.R.V.I.S. Utility Cog."""
"""JARVIS Utility Cog."""
import logging
import re
import secrets
import string
from datetime import timezone
from io import BytesIO
import discord
import discord_slash
import numpy as np
from discord import File, Guild, Role, User
from discord.ext import commands
from discord_slash import SlashContext, cog_ext
from discord_slash.utils.manage_commands import create_choice, create_option
from dateparser import parse
from naff import Client, Cog, InteractionContext, const
from naff.models.discord.channel import GuildCategory, GuildText, GuildVoice
from naff.models.discord.embed import EmbedField
from naff.models.discord.file import File
from naff.models.discord.guild import Guild
from naff.models.discord.role import Role
from naff.models.discord.user import Member, User
from naff.models.naff.application_commands import (
CommandTypes,
OptionTypes,
SlashCommandChoice,
context_menu,
slash_command,
slash_option,
)
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
from PIL import Image
from tzlocal import get_localzone
import jarvis
from jarvis import jarvis_self
from jarvis.config import get_config
from jarvis import const as jconst
from jarvis.data import pigpen
from jarvis.data.robotcamo import emotes, hk, names
from jarvis.utils import build_embed, convert_bytesize, get_repo_hash
from jarvis.utils.field import Field
from jarvis.utils import build_embed, get_repo_hash
JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA")
class UtilCog(commands.Cog):
class UtilCog(Cog):
"""
Utility functions for J.A.R.V.I.S.
Utility functions for JARVIS
Mostly system utility functions, but may change over time
"""
def __init__(self, bot: commands.Cog):
def __init__(self, bot: Client):
self.bot = bot
self.config = get_config()
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash(
name="status",
description="Retrieve J.A.R.V.I.S. status",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _status(self, ctx: SlashContext) -> None:
title = "J.A.R.V.I.S. Status"
desc = "All systems online"
color = "#98CCDA"
@slash_command(name="status", description="Retrieve JARVIS status")
@cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
async def _status(self, ctx: InteractionContext) -> None:
title = "JARVIS Status"
desc = f"All systems online\nConnected to **{len(self.bot.guilds)}** guilds"
color = "#3498db"
fields = []
with jarvis_self.oneshot():
fields.append(Field("CPU Usage", jarvis_self.cpu_percent()))
uptime = int(self.bot.start_time.timestamp())
fields.append(EmbedField(name="Version", value=jconst.__version__, inline=True))
fields.append(EmbedField(name="naff", value=const.__version__, inline=True))
fields.append(EmbedField(name="Git Hash", value=get_repo_hash()[:7], inline=True))
fields.append(EmbedField(name="Online Since", value=f"<t:{uptime}:F>", inline=False))
num_domains = len(self.bot.phishing_domains)
fields.append(
Field(
"RAM Usage",
convert_bytesize(jarvis_self.memory_info().rss),
EmbedField(
name="Phishing Protection", value=f"Detecting {num_domains} phishing domains"
)
)
fields.append(Field("PID", jarvis_self.pid))
fields.append(Field("discord_slash", discord_slash.__version__))
fields.append(Field("discord.py", discord.__version__))
fields.append(Field("Version", jarvis.__version__, False))
fields.append(Field("Git Hash", get_repo_hash()[:7], False))
embed = build_embed(title=title, description=desc, fields=fields, color=color)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
@slash_command(
name="logo",
description="Get the current logo",
)
@commands.cooldown(1, 30, commands.BucketType.channel)
async def _logo(self, ctx: SlashContext) -> None:
@cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
async def _logo(self, ctx: InteractionContext) -> None:
with BytesIO() as image_bytes:
JARVIS_LOGO.save(image_bytes, "PNG")
image_bytes.seek(0)
logo = File(image_bytes, filename="logo.png")
logo = File(image_bytes, file_name="logo.png")
await ctx.send(file=logo)
@cog_ext.cog_slash(name="rchk", description="Robot Camo HK416")
async def _rchk(self, ctx: SlashContext) -> None:
@slash_command(name="rchk", description="Robot Camo HK416")
async def _rchk(self, ctx: InteractionContext) -> None:
await ctx.send(content=hk)
@cog_ext.cog_slash(
@slash_command(
name="rcauto",
description="Automates robot camo letters",
options=[
create_option(
)
@slash_option(
name="text",
description="Text to camo-ify",
option_type=3,
opt_type=OptionTypes.STRING,
required=True,
)
],
)
async def _rcauto(self, ctx: SlashContext, text: str) -> None:
async def _rcauto(self, ctx: InteractionContext, text: str) -> None:
to_send = ""
if len(text) == 1 and not re.match(r"^[A-Z0-9-()$@!?^'#. ]$", text.upper()):
await ctx.send("Please use ASCII characters.", hidden=True)
await ctx.send("Please use ASCII characters.", ephemeral=True)
return
for letter in text.upper():
if letter == " ":
to_send += " "
elif re.match(r"^[A-Z0-9-()$@!?^'#.]$", letter):
id = emotes[letter]
if ctx.author.is_on_mobile():
to_send += f":{names[id]}:"
else:
to_send += f"<:{names[id]}:{id}>"
if len(to_send) > 2000:
await ctx.send("Too long.", hidden=True)
await ctx.send("Too long.", ephemeral=True)
elif len(to_send) == 0:
await ctx.send("No valid text found", ephemeral=True)
else:
await ctx.send(to_send)
@cog_ext.cog_slash(
name="avatar",
description="Get a user avatar",
options=[
create_option(
@slash_command(name="avatar", description="Get a user avatar")
@slash_option(
name="user",
description="User to view avatar of",
option_type=6,
opt_type=OptionTypes.USER,
required=False,
)
],
)
@commands.cooldown(1, 5, commands.BucketType.user)
async def _avatar(self, ctx: SlashContext, user: User = None) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=5)
async def _avatar(self, ctx: InteractionContext, user: User = None) -> None:
if not user:
user = ctx.author
avatar = user.avatar_url
avatar = user.avatar.url
if isinstance(user, Member):
avatar = user.display_avatar.url
embed = build_embed(title="Avatar", description="", fields=[], color="#00FFEE")
embed.set_image(url=avatar)
embed.set_author(name=f"{user.name}#{user.discriminator}", icon_url=avatar)
embed.set_author(name=f"{user.username}#{user.discriminator}", icon_url=avatar)
await ctx.send(embed=embed)
@cog_ext.cog_slash(
@slash_command(
name="roleinfo",
description="Get role info",
options=[
create_option(
)
@slash_option(
name="role",
description="Role to get info of",
option_type=8,
opt_type=OptionTypes.ROLE,
required=True,
)
],
)
async def _roleinfo(self, ctx: SlashContext, role: Role) -> None:
async def _roleinfo(self, ctx: InteractionContext, role: Role) -> None:
fields = [
Field(name="ID", value=role.id),
Field(name="Name", value=role.name),
Field(name="Color", value=str(role.color)),
Field(name="Mention", value=f"`{role.mention}`"),
Field(name="Hoisted", value="Yes" if role.hoist else "No"),
Field(name="Position", value=str(role.position)),
Field(name="Mentionable", value="Yes" if role.mentionable else "No"),
EmbedField(name="ID", value=str(role.id), inline=True),
EmbedField(name="Name", value=role.mention, inline=True),
EmbedField(name="Color", value=str(role.color.hex), inline=True),
EmbedField(name="Mention", value=f"`{role.mention}`", inline=True),
EmbedField(name="Hoisted", value="Yes" if role.hoist else "No", inline=True),
EmbedField(name="Position", value=str(role.position), inline=True),
EmbedField(name="Mentionable", value="Yes" if role.mentionable else "No", inline=True),
EmbedField(name="Member Count", value=str(len(role.members)), inline=True),
EmbedField(name="Created At", value=f"<t:{int(role.created_at.timestamp())}:F>"),
]
embed = build_embed(
title="",
description="",
fields=fields,
color=str(role.color),
color=role.color,
timestamp=role.created_at,
)
embed.set_footer(text="Role Created")
@ -170,46 +171,38 @@ class UtilCog(commands.Cog):
fill = a > 0
data[..., :-1][fill.T] = list(role.color.to_rgb())
data[..., :-1][fill.T] = list(role.color.rgb)
im = Image.fromarray(data)
with BytesIO() as image_bytes:
im.save(image_bytes, "PNG")
image_bytes.seek(0)
color_show = File(image_bytes, filename="color_show.png")
color_show = File(image_bytes, file_name="color_show.png")
await ctx.send(embed=embed, file=color_show)
@cog_ext.cog_slash(
name="userinfo",
description="Get user info",
options=[
create_option(
name="user",
description="User to get info of",
option_type=6,
required=False,
)
],
)
async def _userinfo(self, ctx: SlashContext, user: User = None) -> None:
async def _userinfo(self, ctx: InteractionContext, user: User = None) -> None:
await ctx.defer()
if not user:
user = ctx.author
if not await ctx.guild.fetch_member(user.id):
await ctx.send("That user isn't in this guild.", ephemeral=True)
return
user_roles = user.roles
if user_roles:
user_roles = sorted(user.roles, key=lambda x: -x.position)
_ = user_roles.pop(-1)
fields = [
Field(
EmbedField(
name="Joined",
value=user.joined_at.strftime("%a, %b %-d, %Y %-I:%M %p"),
value=f"<t:{int(user.joined_at.timestamp())}:F>",
),
Field(
EmbedField(
name="Registered",
value=user.created_at.strftime("%a, %b %-d, %Y %-I:%M %p"),
value=f"<t:{int(user.created_at.timestamp())}:F>",
),
Field(
EmbedField(
name=f"Roles [{len(user_roles)}]",
value=" ".join([x.mention for x in user_roles]) if user_roles else "None",
inline=False,
@ -220,81 +213,102 @@ class UtilCog(commands.Cog):
title="",
description=user.mention,
fields=fields,
color=str(user_roles[0].color) if user_roles else "#FF0000",
color=str(user_roles[0].color) if user_roles else "#3498db",
)
embed.set_author(name=f"{user.name}#{user.discriminator}", icon_url=user.avatar_url)
embed.set_thumbnail(url=user.avatar_url)
embed.set_author(
name=f"{user.display_name}#{user.discriminator}", icon_url=user.display_avatar.url
)
embed.set_thumbnail(url=user.display_avatar.url)
embed.set_footer(text=f"ID: {user.id}")
await ctx.send(embed=embed)
@cog_ext.cog_slash(name="serverinfo", description="Get server info")
async def _server_info(self, ctx: SlashContext) -> None:
@slash_command(
name="userinfo",
description="Get user info",
)
@slash_option(
name="user",
description="User to get info of",
opt_type=OptionTypes.USER,
required=False,
)
async def _userinfo_slsh(self, ctx: InteractionContext, user: User = None) -> None:
await self._userinfo(ctx, user)
@context_menu(name="User Info", context_type=CommandTypes.USER)
async def _userinfo_menu(self, ctx: InteractionContext) -> None:
await self._userinfo(ctx, ctx.target)
@slash_command(name="serverinfo", description="Get server info")
async def _server_info(self, ctx: InteractionContext) -> None:
guild: Guild = ctx.guild
owner = f"{guild.owner.name}#{guild.owner.discriminator}" if guild.owner else "||`[redacted]`||"
owner = await guild.fetch_owner()
region = guild.region
categories = len(guild.categories)
text_channels = len(guild.text_channels)
voice_channels = len(guild.voice_channels)
owner = f"{owner.username}#{owner.discriminator}" if owner else "||`[redacted]`||"
categories = len([x for x in guild.channels if isinstance(x, GuildCategory)])
text_channels = len([x for x in guild.channels if isinstance(x, GuildText)])
voice_channels = len([x for x in guild.channels if isinstance(x, GuildVoice)])
threads = len(guild.threads)
members = guild.member_count
roles = len(guild.roles)
role_list = ", ".join(role.name for role in guild.roles)
role_list = sorted(guild.roles, key=lambda x: x.position, reverse=True)
role_list = ", ".join(role.mention for role in role_list)
fields = [
Field(name="Owner", value=owner),
Field(name="Region", value=region),
Field(name="Channel Categories", value=categories),
Field(name="Text Channels", value=text_channels),
Field(name="Voice Channels", value=voice_channels),
Field(name="Members", value=members),
Field(name="Roles", value=roles),
EmbedField(name="Owner", value=owner, inline=True),
EmbedField(name="Channel Categories", value=str(categories), inline=True),
EmbedField(name="Text Channels", value=str(text_channels), inline=True),
EmbedField(name="Voice Channels", value=str(voice_channels), inline=True),
EmbedField(name="Threads", value=str(threads), inline=True),
EmbedField(name="Members", value=str(members), inline=True),
EmbedField(name="Roles", value=str(roles), inline=True),
EmbedField(name="Created At", value=f"<t:{int(guild.created_at.timestamp())}:F>"),
]
if len(role_list) < 1024:
fields.append(Field(name="Role List", value=role_list, inline=False))
fields.append(EmbedField(name="Role List", value=role_list, inline=False))
embed = build_embed(title="", description="", fields=fields, timestamp=guild.created_at)
embed.set_author(name=guild.name, icon_url=guild.icon_url)
embed.set_thumbnail(url=guild.icon_url)
embed.set_author(name=guild.name, icon_url=guild.icon.url)
embed.set_thumbnail(url=guild.icon.url)
embed.set_footer(text=f"ID: {guild.id} | Server Created")
await ctx.send(embed=embed)
@cog_ext.cog_subcommand(
base="pw",
name="gen",
base_desc="Password utilites",
@slash_command(
name="pw",
sub_cmd_name="gen",
description="Generate a secure password",
guild_ids=[862402786116763668],
options=[
create_option(
scopes=[862402786116763668],
)
@slash_option(
name="length",
description="Password length (default 32)",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=False,
),
create_option(
)
@slash_option(
name="chars",
description="Characters to include (default last option)",
option_type=4,
opt_type=OptionTypes.INTEGER,
required=False,
choices=[
create_choice(name="A-Za-z", value=0),
create_choice(name="A-Fa-f0-9", value=1),
create_choice(name="A-Za-z0-9", value=2),
create_choice(name="A-Za-z0-9!@#$%^&*", value=3),
],
),
SlashCommandChoice(name="A-Za-z", value=0),
SlashCommandChoice(name="A-Fa-f0-9", value=1),
SlashCommandChoice(name="A-Za-z0-9", value=2),
SlashCommandChoice(name="A-Za-z0-9!@#$%^&*", value=3),
],
)
@commands.cooldown(1, 15, type=commands.BucketType.user)
async def _pw_gen(self, ctx: SlashContext, length: int = 32, chars: int = 3) -> None:
@cooldown(bucket=Buckets.USER, rate=1, interval=15)
async def _pw_gen(self, ctx: InteractionContext, length: int = 32, chars: int = 3) -> None:
if length > 256:
await ctx.send("Please limit password to 256 characters", hidden=True)
await ctx.send("Please limit password to 256 characters", ephemeral=True)
return
choices = [
string.ascii_letters,
string.hexdigits,
@ -307,15 +321,14 @@ class UtilCog(commands.Cog):
f"Generated password:\n`{pw}`\n\n"
'**WARNING: Once you press "Dismiss Message", '
"*the password is lost forever***",
hidden=True,
ephemeral=True,
)
@cog_ext.cog_slash(
name="pigpen",
description="Encode a string into pigpen",
options=[create_option(name="text", description="Text to encode", option_type=3, required=True)],
@slash_command(name="pigpen", description="Encode a string into pigpen")
@slash_option(
name="text", description="Text to encode", opt_type=OptionTypes.STRING, required=True
)
async def _pigpen(self, ctx: SlashContext, text: str) -> None:
async def _pigpen(self, ctx: InteractionContext, text: str) -> None:
outp = "`"
for c in text:
c = c.lower()
@ -329,7 +342,39 @@ class UtilCog(commands.Cog):
outp += "`"
await ctx.send(outp[:2000])
@slash_command(
name="timestamp", description="Convert a datetime or timestamp into it's counterpart"
)
@slash_option(
name="string", description="String to convert", opt_type=OptionTypes.STRING, required=True
)
@slash_option(
name="private", description="Respond quietly?", opt_type=OptionTypes.BOOLEAN, required=False
)
async def _timestamp(self, ctx: InteractionContext, string: str, private: bool = False) -> None:
timestamp = parse(string)
if not timestamp:
await ctx.send("Valid time not found, try again", ephemeral=True)
return
def setup(bot: commands.Bot) -> None:
"""Add UtilCog to J.A.R.V.I.S."""
bot.add_cog(UtilCog(bot))
if not timestamp.tzinfo:
timestamp = timestamp.replace(tzinfo=get_localzone()).astimezone(tz=timezone.utc)
timestamp_utc = timestamp.astimezone(tz=timezone.utc)
ts = int(timestamp.timestamp())
ts_utc = int(timestamp_utc.timestamp())
fields = [
EmbedField(name="Unix Epoch", value=f"`{ts}`"),
EmbedField(name="Unix Epoch (UTC)", value=f"`{ts_utc}`"),
EmbedField(name="Absolute Time", value=f"<t:{ts_utc}:F>\n`<t:{ts_utc}:F>`"),
EmbedField(name="Relative Time", value=f"<t:{ts_utc}:R>\n`<t:{ts_utc}:R>`"),
EmbedField(name="ISO8601", value=timestamp.isoformat()),
]
embed = build_embed(title="Converted Time", description=f"`{string}`", fields=fields)
await ctx.send(embed=embed, ephemeral=private)
def setup(bot: Client) -> None:
"""Add UtilCog to JARVIS"""
UtilCog(bot)

View file

@ -1,12 +1,15 @@
"""J.A.R.V.I.S. Verify Cog."""
"""JARVIS Verify Cog."""
import asyncio
import logging
from random import randint
from discord.ext import commands
from discord_slash import ComponentContext, SlashContext, cog_ext
from discord_slash.model import ButtonStyle
from discord_slash.utils import manage_components
from jarvis.db.models import Setting
from jarvis_core.db import q
from jarvis_core.db.models import Setting
from naff import Client, Cog, InteractionContext
from naff.models.discord.components import Button, ButtonStyles, spread_to_rows
from naff.models.naff.application_commands import slash_command
from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
def create_layout() -> list:
@ -16,77 +19,95 @@ def create_layout() -> list:
for i in range(3):
label = "YES" if i == yes else "NO"
id = f"no_{i}" if not i == yes else "yes"
color = ButtonStyle.green if i == yes else ButtonStyle.red
color = ButtonStyles.GREEN if i == yes else ButtonStyles.RED
buttons.append(
manage_components.create_button(
Button(
style=color,
label=label,
custom_id=f"verify_button||{id}",
)
)
action_row = manage_components.spread_to_rows(*buttons, max_in_row=3)
return action_row
return spread_to_rows(*buttons, max_in_row=3)
class VerifyCog(commands.Cog):
"""J.A.R.V.I.S. Verify Cog."""
class VerifyCog(Cog):
"""JARVIS Verify Cog."""
def __init__(self, bot: commands.Bot):
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash(
name="verify",
description="Verify that you've read the rules",
)
@commands.cooldown(1, 15, commands.BucketType.user)
async def _verify(self, ctx: SlashContext) -> None:
@slash_command(name="verify", description="Verify that you've read the rules")
@cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _verify(self, ctx: InteractionContext) -> None:
await ctx.defer()
role = Setting.objects(guild=ctx.guild.id, setting="verified").first()
role = await Setting.find_one(q(guild=ctx.guild.id, setting="verified"))
if not role:
await ctx.send("This guild has not enabled verification", delete_after=5)
message = await ctx.send("This guild has not enabled verification", ephemeral=True)
return
if ctx.guild.get_role(role.value) in ctx.author.roles:
await ctx.send("You are already verified.", delete_after=5)
verified_role = await ctx.guild.fetch_role(role.value)
if not verified_role:
await ctx.send("This guild has not enabled verification", ephemeral=True)
await role.delete()
return
if verified_role in ctx.author.roles:
await ctx.send("You are already verified.", ephemeral=True)
return
components = create_layout()
message = await ctx.send(
content=f"{ctx.author.mention}, please press the button that says `YES`.",
components=components,
)
await message.delete(delay=15)
@cog_ext.cog_component(components=create_layout())
async def _process(self, ctx: ComponentContext) -> None:
await ctx.defer(edit_origin=True)
try:
if ctx.author.id != ctx.origin_message.mentions[0].id:
return
except Exception:
return
correct = ctx.custom_id.split("||")[-1] == "yes"
verified = False
while not verified:
response = await self.bot.wait_for_component(
messages=message,
check=lambda x: ctx.author.id == x.context.author.id,
timeout=30,
)
correct = response.context.custom_id.split("||")[-1] == "yes"
if correct:
components = ctx.origin_message.components
for c in components:
for c2 in c["components"]:
c2["disabled"] = True
setting = Setting.objects(guild=ctx.guild.id, setting="verified").first()
role = ctx.guild.get_role(setting.value)
await ctx.author.add_roles(role, reason="Verification passed")
setting = Setting.objects(guild=ctx.guild.id, setting="unverified").first()
for row in components:
for component in row.components:
component.disabled = True
setting = await Setting.find_one(q(guild=ctx.guild.id, setting="verified"))
try:
role = await ctx.guild.fetch_role(setting.value)
await ctx.author.add_role(role, reason="Verification passed")
except AttributeError:
self.logger.warning("Verified role deleted before verification finished")
setting = await Setting.find_one(q(guild=ctx.guild.id, setting="unverified"))
if setting:
role = ctx.guild.get_role(setting.value)
await ctx.author.remove_roles(role, reason="Verification passed")
await ctx.edit_origin(
try:
role = await ctx.guild.fetch_role(setting.value)
await ctx.author.remove_role(role, reason="Verification passed")
except AttributeError:
self.logger.warning(
"Unverified role deleted before verification finished"
)
await response.context.edit_origin(
content=f"Welcome, {ctx.author.mention}. Please enjoy your stay.",
components=manage_components.spread_to_rows(*components, max_in_row=5),
components=components,
)
await ctx.origin_message.delete(delay=5)
await response.context.message.delete(delay=5)
self.logger.debug(f"User {ctx.author.id} verified successfully")
else:
await ctx.edit_origin(
content=f"{ctx.author.mention}, incorrect. Please press the button that says `YES`",
await response.context.edit_origin(
content=(
f"{ctx.author.mention}, incorrect. "
"Please press the button that says `YES`"
)
)
except asyncio.TimeoutError:
await message.delete(delay=2)
self.logger.debug(f"User {ctx.author.id} failed to verify before timeout")
def setup(bot: commands.Bot) -> None:
"""Add VerifyCog to J.A.R.V.I.S."""
bot.add_cog(VerifyCog(bot))
def setup(bot: Client) -> None:
"""Add VerifyCog to JARVIS"""
VerifyCog(bot)

View file

@ -1,83 +1,17 @@
"""Load the config for J.A.R.V.I.S."""
from pymongo import MongoClient
from yaml import load
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
"""Load the config for JARVIS"""
from jarvis_core.config import Config as CConfig
class Config(object):
"""Config singleton object for J.A.R.V.I.S."""
def __new__(cls, *args: list, **kwargs: dict):
"""Get the singleton config, or creates a new one."""
it = cls.__dict__.get("it")
if it is not None:
return it
cls.__it__ = it = object.__new__(cls)
it.init(*args, **kwargs)
return it
def init(
self,
token: str,
client_id: str,
logo: str,
mongo: dict,
urls: dict,
log_level: str = "WARNING",
cogs: list = None,
events: bool = True,
gitlab_token: str = None,
max_messages: int = 1000,
twitter: dict = None,
) -> None:
"""Initialize the config object."""
self.token = token
self.client_id = client_id
self.logo = logo
self.mongo = mongo
self.urls = urls
self.log_level = log_level
self.cogs = cogs
self.events = events
self.max_messages = max_messages
self.gitlab_token = gitlab_token
self.twitter = twitter
self.__db_loaded = False
self.__mongo = MongoClient(**self.mongo["connect"])
def get_db_config(self) -> None:
"""Load the database config objects."""
if not self.__db_loaded:
db = self.__mongo[self.mongo["database"]]
items = db.config.find()
for item in items:
setattr(self, item["key"], item["value"])
self.__db_loaded = True
@classmethod
def from_yaml(cls, y: dict) -> "Config":
"""Load the yaml config file."""
instance = cls(**y)
return instance
def get_config(path: str = "config.yaml") -> Config:
"""Get the config from the specified yaml file."""
if Config.__dict__.get("it"):
return Config()
with open(path) as f:
raw = f.read()
y = load(raw, Loader=Loader)
config = Config.from_yaml(y)
config.get_db_config()
return config
def reload_config() -> None:
"""Force reload of the config singleton on next call."""
if "it" in Config.__dict__:
Config.__dict__.pop("it")
class JarvisConfig(CConfig):
REQUIRED = ("token", "mongo", "urls", "redis")
OPTIONAL = {
"sync": False,
"log_level": "WARNING",
"cogs": None,
"events": True,
"gitlab_token": None,
"max_messages": 1000,
"twitter": None,
"reddit": None,
"rook_token": None,
}

7
jarvis/const.py Normal file
View file

@ -0,0 +1,7 @@
"""JARVIS constants."""
from importlib.metadata import version as _v
try:
__version__ = _v("jarvis")
except Exception:
__version__ = "0.0.0"

View file

@ -50,32 +50,32 @@ emotes = {
}
names = {
852317928572715038: "rcA",
852317954975727679: "rcB",
852317972424818688: "rcC",
852317990238421003: "rcD",
852318044503539732: "rcE",
852318058353786880: "rcF",
852318073994477579: "rcG",
852318105832259614: "rcH",
852318122278125580: "rcI",
852318145074167818: "rcJ",
852318159952412732: "rcK",
852318179358408704: "rcL",
852318241555873832: "rcM",
852318311115128882: "rcN",
852318329951223848: "rcO",
852318344643477535: "rcP",
852318358920757248: "rcQ",
852318385638211594: "rcR",
852318401166311504: "rcS",
852318421524938773: "rcT",
852318435181854742: "rcU",
852318453204647956: "rcV",
852318470267731978: "rcW",
852318484749877278: "rcX",
852318504564555796: "rcY",
852318519449092176: "rcZ",
852317928572715038: "rcLetterA",
852317954975727679: "rcLetterB",
852317972424818688: "rcLetterC",
852317990238421003: "rcLetterD",
852318044503539732: "rcLetterE",
852318058353786880: "rcLetterF",
852318073994477579: "rcLetterG",
852318105832259614: "rcLetterH",
852318122278125580: "rcLetterI",
852318145074167818: "rcLetterJ",
852318159952412732: "rcLetterK",
852318179358408704: "rcLetterL",
852318241555873832: "rcLetterM",
852318311115128882: "rcLetterN",
852318329951223848: "rcLetterO",
852318344643477535: "rcLetterP",
852318358920757248: "rcLetterQ",
852318385638211594: "rcLetterR",
852318401166311504: "rcLetterS",
852318421524938773: "rcLetterT",
852318435181854742: "rcLetterU",
852318453204647956: "rcLetterV",
852318470267731978: "rcLetterW",
852318484749877278: "rcLetterX",
852318504564555796: "rcLetterY",
852318519449092176: "rcLetterZ",
860663352740151316: "rc1",
860662785243348992: "rc2",
860662950011469854: "rc3",

View file

@ -1,261 +0,0 @@
"""J.A.R.V.I.S. database object for mongoengine."""
from datetime import datetime
from mongoengine import Document
from mongoengine.fields import (
BooleanField,
DateTimeField,
DictField,
DynamicField,
IntField,
ListField,
LongField,
StringField,
)
class SnowflakeField(LongField):
"""Snowflake LongField Override."""
pass
class Autopurge(Document):
"""Autopurge database object."""
guild = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
delay = IntField(min_value=1, max_value=300, default=30)
admin = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Autoreact(Document):
"""Autoreact database object."""
guild = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
reactions = ListField(field=StringField())
admin = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Ban(Document):
"""Ban database object."""
active = BooleanField(default=True)
admin = SnowflakeField(required=True)
user = SnowflakeField(required=True)
username = StringField(required=True)
discrim = IntField(min_value=1, max_value=9999, required=True)
duration = IntField(min_value=1, max_value=744, required=False)
guild = SnowflakeField(required=True)
type = StringField(default="perm", max_length=4, required=True)
reason = StringField(max_length=100, required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Config(Document):
"""Config database object."""
key = StringField(required=True)
value = DynamicField(required=True)
meta = {"db_alias": "main"}
class Guess(Document):
"""Guess database object."""
correct = BooleanField(default=False)
guess = StringField(max_length=800, required=True)
user = SnowflakeField(required=True)
meta = {"db_alias": "ctc2"}
class Joke(Document):
"""Joke database object."""
rid = StringField()
body = StringField()
title = StringField()
created_utc = DateTimeField()
over_18 = BooleanField()
score = IntField()
meta = {"db_alias": "main"}
class Kick(Document):
"""Kick database object."""
admin = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
reason = StringField(max_length=100, required=True)
user = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Lock(Document):
"""Lock database object."""
active = BooleanField(default=True)
admin = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
duration = IntField(min_value=1, max_value=300, default=10)
guild = SnowflakeField(required=True)
reason = StringField(max_length=100, required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Mute(Document):
"""Mute database object."""
active = BooleanField(default=True)
user = SnowflakeField(required=True)
admin = SnowflakeField(required=True)
duration = IntField(min_value=-1, max_value=300, default=10)
guild = SnowflakeField(required=True)
reason = StringField(max_length=100, required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Purge(Document):
"""Purge database object."""
admin = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
count = IntField(min_value=1, default=10)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Reminder(Document):
"""Reminder database object."""
active = BooleanField(default=True)
user = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
message = StringField(max_length=100, required=True)
remind_at = DateTimeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Rolegiver(Document):
"""Rolegiver database object."""
guild = SnowflakeField(required=True)
roles = ListField(field=SnowflakeField())
meta = {"db_alias": "main"}
class Roleping(Document):
"""Roleping database object."""
active = BooleanField(default=True)
role = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
admin = SnowflakeField(required=True)
bypass = DictField()
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Setting(Document):
"""Setting database object."""
guild = SnowflakeField(required=True)
setting = StringField(required=True)
value = DynamicField()
meta = {"db_alias": "main"}
class Star(Document):
"""Star database object."""
active = BooleanField(default=True)
index = IntField(required=True)
message = SnowflakeField(required=True)
channel = SnowflakeField(required=True)
starboard = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
admin = SnowflakeField(required=True)
star = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Starboard(Document):
"""Starboard database object."""
channel = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
admin = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Twitter(Document):
"""Twitter Follow object."""
active = BooleanField(default=True)
handle = StringField(required=True)
channel = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
last_tweet = SnowflakeField(required=True)
retweets = BooleanField(default=True)
admin = SnowflakeField(required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Unban(Document):
"""Unban database object."""
user = SnowflakeField(required=True)
username = StringField(required=True)
discrim = IntField(min_value=1, max_value=9999, required=True)
guild = SnowflakeField(required=True)
admin = SnowflakeField(required=True)
reason = StringField(max_length=100, required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}
class Warning(Document):
"""Warning database object."""
active = BooleanField(default=True)
admin = SnowflakeField(required=True)
user = SnowflakeField(required=True)
guild = SnowflakeField(required=True)
duration = IntField(min_value=1, max_value=120, default=24)
reason = StringField(max_length=100, required=True)
created_at = DateTimeField(default=datetime.utcnow)
meta = {"db_alias": "main"}

View file

@ -1,36 +0,0 @@
"""J.A.R.V.I.S. guild event handler."""
import asyncio
from discord import Guild
from discord.ext.commands import Bot
from discord.utils import find
from jarvis.db.models import Setting
class GuildEventHandler(object):
"""J.A.R.V.I.S. guild event handler."""
def __init__(self, bot: Bot):
self.bot = bot
self.bot.add_listener(self.on_guild_join)
async def on_guild_join(self, guild: Guild) -> None:
"""Handle on_guild_join event."""
general = find(lambda x: x.name == "general", guild.channels)
if general and general.permissions_for(guild.me).send_messages:
user = self.bot.user
await general.send(
f"Allow me to introduce myself. I am {user.mention}, a virtual "
"artificial intelligence, and I'm here to assist you with a "
"variety of tasks as best I can, "
"24 hours a day, seven days a week."
)
await asyncio.sleep(1)
await general.send("Importing all preferences from home interface...")
# Set some default settings
_ = Setting(guild=guild.id, setting="massmention", value=5).save()
_ = Setting(guild=guild.id, setting="noinvite", value=True).save()
await general.send("Systems are now fully operational")

View file

@ -1,27 +0,0 @@
"""J.A.R.V.I.S. Member event handler."""
from discord import Member
from discord.ext.commands import Bot
from jarvis.db.models import Mute, Setting
class MemberEventHandler(object):
"""J.A.R.V.I.S. Member event handler."""
def __init__(self, bot: Bot):
self.bot = bot
self.bot.add_listener(self.on_member_join)
async def on_member_join(self, user: Member) -> None:
"""Handle on_member_join event."""
guild = user.guild
mute = Mute.objects(guild=guild.id, user=user.id, active=True).first()
if mute:
mute_role = Setting.objects(guild=guild.id, setting="mute").first()
role = guild.get_role(mute_role.value)
await user.add_roles(role, reason="User is still muted from prior mute")
unverified = Setting.objects(guild=guild.id, setting="unverified").first()
if unverified:
role = guild.get_role(unverified.value)
if role not in user.roles:
await user.add_roles(role, reason="User just joined and is unverified")

View file

@ -1,210 +0,0 @@
"""J.A.R.V.I.S. Message event handler."""
import re
from discord import DMChannel, Message
from discord.ext.commands import Bot
from discord.utils import find
from jarvis.config import get_config
from jarvis.db.models import Autopurge, Autoreact, Roleping, Setting, Warning
from jarvis.utils import build_embed
from jarvis.utils.field import Field
invites = re.compile(
r"(?:https?://)?(?:www.)?(?:discord.(?:gg|io|me|li)|discord(?:app)?.com/invite)/([^\s/]+?)(?=\b)",
flags=re.IGNORECASE,
)
class MessageEventHandler(object):
"""J.A.R.V.I.S. Message event handler."""
def __init__(self, bot: Bot):
self.bot = bot
self.bot.add_listener(self.on_message)
self.bot.add_listener(self.on_message_edit)
async def autopurge(self, message: Message) -> None:
"""Handle autopurge events."""
autopurge = Autopurge.objects(guild=message.guild.id, channel=message.channel.id).first()
if autopurge:
await message.delete(delay=autopurge.delay)
async def autoreact(self, message: Message) -> None:
"""Handle autoreact events."""
autoreact = Autoreact.objects(
guild=message.guild.id,
channel=message.channel.id,
).first()
if autoreact:
for reaction in autoreact.reactions:
await message.add_reaction(reaction)
async def checks(self, message: Message) -> None:
"""Other message checks."""
# #tech
channel = find(lambda x: x.id == 599068193339736096, message.channel_mentions)
if channel and message.author.id == 293795462752894976:
await channel.send(
content="https://cdn.discordapp.com/attachments/664621130044407838/805218508866453554/tech.gif"
)
content = re.sub(r"\s+", "", message.content)
match = invites.search(content)
setting = Setting.objects(guild=message.guild.id, setting="noinvite").first()
if not setting:
setting = Setting(guild=message.guild.id, setting="noinvite", value=True)
setting.save()
if match:
guild_invites = await message.guild.invites()
allowed = [x.code for x in guild_invites] + [
"dbrand",
"VtgZntXcnZ",
"gPfYGbvTCE",
]
if match.group(1) not in allowed and setting.value:
await message.delete()
_ = Warning(
active=True,
admin=get_config().client_id,
duration=24,
guild=message.guild.id,
reason="Sent an invite link",
user=message.author.id,
).save()
fields = [
Field(
"Reason",
"Sent an invite link",
False,
)
]
embed = build_embed(
title="Warning",
description=f"{message.author.mention} has been warned",
fields=fields,
)
embed.set_author(
name=message.author.nick if message.author.nick else message.author.name,
icon_url=message.author.avatar_url,
)
embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}")
await message.channel.send(embed=embed)
async def massmention(self, message: Message) -> None:
"""Handle massmention events."""
massmention = Setting.objects(
guild=message.guild.id,
setting="massmention",
).first()
if (
massmention
and massmention.value > 0 # noqa: W503
and len(message.mentions) - (1 if message.author in message.mentions else 0) # noqa: W503
> massmention.value # noqa: W503
):
_ = Warning(
active=True,
admin=get_config().client_id,
duration=24,
guild=message.guild.id,
reason="Mass Mention",
user=message.author.id,
).save()
fields = [Field("Reason", "Mass Mention", False)]
embed = build_embed(
title="Warning",
description=f"{message.author.mention} has been warned",
fields=fields,
)
embed.set_author(
name=message.author.nick if message.author.nick else message.author.name,
icon_url=message.author.avatar_url,
)
embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}")
await message.channel.send(embed=embed)
async def roleping(self, message: Message) -> None:
"""Handle roleping events."""
rolepings = Roleping.objects(guild=message.guild.id, active=True)
if not rolepings:
return
# Get all role IDs involved with message
roles = []
for mention in message.role_mentions:
roles.append(mention.id)
for mention in message.mentions:
for role in mention.roles:
roles.append(role.id)
if not roles:
return
# Get all roles that are rolepinged
roleping_ids = [r.role for r in rolepings]
# Get roles in rolepings
role_in_rolepings = list(filter(lambda x: x in roleping_ids, roles))
# Check if the user has the role, so they are allowed to ping it
user_missing_role = any(x.id not in roleping_ids for x in message.author.roles)
# Admins can ping whoever
user_is_admin = message.author.guild_permissions.administrator
# Check if user in a bypass list
user_has_bypass = False
for roleping in rolepings:
if message.author.id in roleping.bypass["users"]:
user_has_bypass = True
break
if any(role.id in roleping.bypass["roles"] for role in message.author.roles):
user_has_bypass = True
break
if role_in_rolepings and user_missing_role and not user_is_admin and not user_has_bypass:
_ = Warning(
active=True,
admin=get_config().client_id,
duration=24,
guild=message.guild.id,
reason="Pinged a blocked role/user with a blocked role",
user=message.author.id,
).save()
fields = [
Field(
"Reason",
"Pinged a blocked role/user with a blocked role",
False,
)
]
embed = build_embed(
title="Warning",
description=f"{message.author.mention} has been warned",
fields=fields,
)
embed.set_author(
name=message.author.nick if message.author.nick else message.author.name,
icon_url=message.author.avatar_url,
)
embed.set_footer(text=f"{message.author.name}#{message.author.discriminator} | {message.author.id}")
await message.channel.send(embed=embed)
async def on_message(self, message: Message) -> None:
"""Handle on_message event. Calls other event handlers."""
if not isinstance(message.channel, DMChannel) and not message.author.bot:
await self.autoreact(message)
await self.massmention(message)
await self.roleping(message)
await self.autopurge(message)
await self.checks(message)
async def on_message_edit(self, before: Message, after: Message) -> None:
"""Handle on_message_edit event. Calls other event handlers."""
if not isinstance(after.channel, DMChannel) and not after.author.bot:
await self.massmention(after)
await self.roleping(after)
await self.checks(after)
await self.roleping(after)
await self.checks(after)

View file

@ -1,106 +0,0 @@
"""Logos for J.A.R.V.I.S."""
logo_doom = r"""
___ ___ ______ _ _ _____ _____
|_ | / _ \ | ___ \ | | | | |_ _| / ___|
| | / /_\ \ | |_/ / | | | | | | \ `--.
| | | _ | | / | | | | | | `--. \
/\__/ / _ | | | | _ | |\ \ _ \ \_/ / _ _| |_ _ /\__/ / _
\____/ (_)\_| |_/(_)\_| \_|(_) \___/ (_) \___/ (_)\____/ (_)
"""
logo_epic = r"""
_________ _______ _______ _________ _______
\__ _/ ( ___ ) ( ____ ) |\ /| \__ __/ ( ____ \
) ( | ( ) | | ( )| | ) ( | ) ( | ( \/
| | | (___) | | (____)| | | | | | | | (_____
| | | ___ | | __) ( ( ) ) | | (_____ )
| | | ( ) | | (\ ( \ \_/ / | | ) |
|\_) ) _ | ) ( | _ | ) \ \__ _ \ / _ ___) (___ _ /\____) | _
(____/ (_)|/ \|(_)|/ \__/(_) \_/ (_)\_______/(_)\_______)(_)
"""
logo_ivrit = r"""
_ _ ____ __ __ ___ ____
| | / \ | _ \ \ \ / / |_ _| / ___|
_ | | / _ \ | |_) | \ \ / / | | \___ \
| |_| | _ / ___ \ _ | _ < _ \ V / _ | | _ ___) | _
\___/ (_) /_/ \_\ (_) |_| \_\ (_) \_/ (_) |___| (_) |____/ (_)
"""
logo_kban = r"""
'||' . | . '||''|. . '||' '|' . '||' . .|'''.| .
|| ||| || || '|. .' || ||.. '
|| | || ||''|' || | || ''|||.
|| .''''|. || |. ||| || . '||
|| .|' .|. .||. .||. '|' | .||. |'....|'
'''
"""
logo_larry3d = r"""
_____ ______ ____ __ __ ______ ____
/\___ \ /\ _ \ /\ _`\ /\ \/\ \ /\__ _\ /\ _`\
\/__/\ \ \ \ \L\ \ \ \ \L\ \ \ \ \ \ \ \/_/\ \/ \ \,\L\_\
_\ \ \ \ \ __ \ \ \ , / \ \ \ \ \ \ \ \ \/_\__ \
/\ \_\ \ __ \ \ \/\ \ __ \ \ \\ \ __ \ \ \_/ \ __ \_\ \__ __ /\ \L\ \ __
\ \____//\_\ \ \_\ \_\/\_\ \ \_\ \_\/\_\ \ `\___//\_\ /\_____\/\_\ \ `\____\/\_\
\/___/ \/_/ \/_/\/_/\/_/ \/_/\/ /\/_/ `\/__/ \/_/ \/_____/\/_/ \/_____/\/_/
"""
logo_slane = r"""
__ ___ ____ _ __ ____ _____
/ / / | / __ \ | | / / / _/ / ___/
__ / / / /| | / /_/ / | | / / / / \__ \
/ /_/ / _ / ___ | _ / _, _/ _ | |/ / _ _/ / _ ___/ / _
\____/ (_)/_/ |_|(_)/_/ |_| (_)|___/ (_)/___/ (_)/____/ (_)
"""
logo_standard = r"""
_ _ ____ __ __ ___ ____
| | / \ | _ \ \ \ / / |_ _| / ___|
_ | | / _ \ | |_) | \ \ / / | | \___ \
| |_| | _ / ___ \ _ | _ < _ \ V / _ | | _ ___) | _
\___/ (_) /_/ \_\ (_) |_| \_\ (_) \_/ (_) |___| (_) |____/ (_)
"""
logo_alligator = r"""
::::::::::: ::: ::::::::: ::: ::: ::::::::::: ::::::::
:+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
+#+ +#++:++#++: +#++:++#: +#+ +:+ +#+ +#++:++#++
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+# #+# #+# #+#+#+# #+# #+# #+# #+# #+# #+#
##### ### ### ### ### ### ### ### ### ### ########### ### ######## ###
""" # noqa: E501
logo_alligator2 = r"""
::::::::::: ::: ::::::::: ::: ::: ::::::::::: ::::::::
:+: :+: :+: :+: :+: :+: :+: :+: :+: :+:
+:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+ +:+
+#+ +#++:++#++: +#++:++#: +#+ +:+ +#+ +#++:++#++
+#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+ +#+
#+# #+# #+# #+# #+# #+# #+# #+# #+# #+#+#+# #+# #+# #+# #+# #+# #+#
##### ### ### ### ### ### ### ### ### ### ########### ### ######## ###
"""
def get_logo(lo: str) -> str:
"""Get a logo."""
if "logo_" not in lo:
lo = "logo_" + lo
return globals()[lo] if lo in globals() else logo_alligator2

View file

@ -1,10 +0,0 @@
"""J.A.R.V.I.S. background task handlers."""
from jarvis.tasks import unban, unlock, unmute, unwarn
def init() -> None:
"""Start the background task handlers."""
unban.unban.start()
unlock.unlock.start()
unmute.unmute.start()
unwarn.unwarn.start()

View file

@ -1,37 +0,0 @@
"""J.A.R.V.I.S. unban background task handler."""
from datetime import datetime, timedelta
from discord.ext.tasks import loop
import jarvis
from jarvis.config import get_config
from jarvis.db.models import Ban, Unban
jarvis_id = get_config().client_id
@loop(minutes=10)
async def unban() -> None:
"""J.A.R.V.I.S. unban background task."""
bans = Ban.objects(type="temp", active=True)
unbans = []
for ban in bans:
if ban.created_at + timedelta(hours=ban.duration) < datetime.utcnow() + timedelta(minutes=10):
guild = await jarvis.jarvis.fetch_guild(ban.guild)
user = await jarvis.jarvis.fetch_user(ban.user)
if user:
guild.unban(user)
ban.active = False
ban.save()
unbans.append(
Unban(
user=user.id,
guild=guild.id,
username=user.name,
discrim=user.discriminator,
admin=jarvis_id,
reason="Ban expired",
)
)
if unbans:
Ban.objects().insert(unbans)

View file

@ -1,25 +0,0 @@
"""J.A.R.V.I.S. unlock background task handler."""
from datetime import datetime, timedelta
from discord.ext.tasks import loop
import jarvis
from jarvis.db.models import Lock
@loop(minutes=1)
async def unlock() -> None:
"""J.A.R.V.I.S. unlock background task."""
locks = Lock.objects(active=True)
for lock in locks:
if lock.created_at + timedelta(minutes=lock.duration) < datetime.utcnow():
guild = await jarvis.jarvis.fetch_guild(lock.guild)
channel = await jarvis.jarvis.fetch_channel(lock.channel)
if channel:
roles = await guild.fetch_roles()
for role in roles:
overrides = channel.overwrites_for(role)
overrides.send_messages = None
await channel.set_permissions(role, overwrite=overrides, reason="Lock expired")
lock.active = False
lock.save()

View file

@ -1,27 +0,0 @@
"""J.A.R.V.I.S. unmute background task handler."""
from datetime import datetime, timedelta
from discord.ext.tasks import loop
import jarvis
from jarvis.db.models import Mute, Setting
@loop(minutes=1)
async def unmute() -> None:
"""J.A.R.V.I.S. unmute background task."""
mutes = Mute.objects(duration__gt=0, active=True)
mute_roles = Setting.objects(setting="mute")
for mute in mutes:
if mute.created_at + timedelta(minutes=mute.duration) < datetime.utcnow():
mute_role = [x.value for x in mute_roles if x.guild == mute.guild][0]
guild = await jarvis.jarvis.fetch_guild(mute.guild)
role = guild.get_role(mute_role)
user = await guild.fetch_member(mute.user)
if user:
if role in user.roles:
await user.remove_roles(role, reason="Mute expired")
# Objects can't handle bulk_write, so handle it via raw methods
mute.active = False
mute.save

View file

@ -1,16 +0,0 @@
"""J.A.R.V.I.S. unwarn background task handler."""
from datetime import datetime, timedelta
from discord.ext.tasks import loop
from jarvis.db.models import Warning
@loop(hours=1)
async def unwarn() -> None:
"""J.A.R.V.I.S. unwarn background task."""
warns = Warning.objects(active=True)
for warn in warns:
if warn.created_at + timedelta(hours=warn.duration) < datetime.utcnow():
warn.active = False
warn.save()

View file

@ -1,60 +1,13 @@
"""J.A.R.V.I.S. Utility Functions."""
from datetime import datetime
"""JARVIS Utility Functions."""
from datetime import datetime, timezone
from pkgutil import iter_modules
import git
from discord import Color, Embed, Message
from discord.ext import commands
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.guild import AuditLogEntry
from naff.models.discord.user import Member
import jarvis.cogs
import jarvis.db
from jarvis.config import get_config
__all__ = ["field", "db", "cachecog", "permissions"]
def convert_bytesize(b: int) -> str:
"""Convert bytes amount to human readable."""
b = float(b)
sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
size = 0
while b >= 1024 and size < len(sizes) - 1:
b = b / 1024
size += 1
return "{:0.3f} {}".format(b, sizes[size])
def unconvert_bytesize(size: int, ending: str) -> int:
"""Convert human readable to bytes."""
ending = ending.upper()
sizes = ["B", "KB", "MB", "GB", "TB", "PB"]
if ending == "B":
return size
# Rounding is only because bytes cannot be partial
return round(size * (1024 ** sizes.index(ending)))
def get_prefix(bot: commands.Bot, message: Message) -> list:
"""Get bot prefixes."""
prefixes = ["!", "-", "%"]
# if not message.guild:
# return "?"
return commands.when_mentioned_or(*prefixes)(bot, message)
def get_extensions(path: str = jarvis.cogs.__path__) -> list:
"""Get J.A.R.V.I.S. cogs."""
config = get_config()
vals = config.cogs or [x.name for x in iter_modules(path)]
return ["jarvis.cogs.{}".format(x) for x in vals]
def parse_color_hex(hex: str) -> Color:
"""Convert a hex color to a d.py Color."""
hex = hex.lstrip("#")
rgb = tuple(int(hex[i : i + 2], 16) for i in (0, 2, 4)) # noqa: E203
return Color.from_rgb(*rgb)
from jarvis.config import JarvisConfig
def build_embed(
@ -67,11 +20,11 @@ def build_embed(
) -> Embed:
"""Embed builder utility function."""
if not timestamp:
timestamp = datetime.utcnow()
timestamp = datetime.now(tz=timezone.utc)
embed = Embed(
title=title,
description=description,
color=parse_color_hex(color),
color=color,
timestamp=timestamp,
**kwargs,
)
@ -80,8 +33,43 @@ def build_embed(
return embed
def modlog_embed(
member: Member,
admin: Member,
log: AuditLogEntry,
title: str,
desc: str,
) -> Embed:
"""Get modlog embed."""
fields = [
EmbedField(
name="Moderator",
value=f"{admin.mention} ({admin.username}#{admin.discriminator})",
),
]
if log.reason:
fields.append(EmbedField(name="Reason", value=log.reason, inline=False))
embed = build_embed(
title=title,
description=desc,
color="#fc9e3f",
fields=fields,
timestamp=log.created_at,
)
embed.set_author(name=f"{member.username}", icon_url=member.display_avatar.url)
embed.set_footer(text=f"{member.username}#{member.discriminator} | {member.id}")
return embed
def get_extensions(path: str) -> list:
"""Get JARVIS cogs."""
config = JarvisConfig.from_yaml()
vals = config.cogs or [x.name for x in iter_modules(path)]
return [f"jarvis.cogs.{x}" for x in vals]
def update() -> int:
"""J.A.R.V.I.S. update utility."""
"""JARVIS update utility."""
repo = git.Repo(".")
dirty = repo.is_dirty()
current_hash = repo.head.object.hexsha
@ -96,6 +84,6 @@ def update() -> int:
def get_repo_hash() -> str:
"""J.A.R.V.I.S. current branch hash."""
"""JARVIS current branch hash."""
repo = git.Repo(".")
return repo.head.object.hexsha

View file

@ -1,35 +0,0 @@
"""Cog wrapper for command caching."""
from datetime import datetime, timedelta
from discord.ext import commands
from discord.ext.tasks import loop
from discord.utils import find
from discord_slash import SlashContext
class CacheCog(commands.Cog):
"""Cog wrapper for command caching."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.cache = {}
self._expire_interaction.start()
def check_cache(self, ctx: SlashContext, **kwargs: dict) -> dict:
"""Check the cache."""
if not kwargs:
kwargs = {}
return find(
lambda x: x["command"] == ctx.subcommand_name # noqa: W503
and x["user"] == ctx.author.id # noqa: W503
and x["guild"] == ctx.guild.id # noqa: W503
and all(x[k] == v for k, v in kwargs.items()), # noqa: W503
self.cache.values(),
)
@loop(minutes=1)
async def _expire_interaction(self) -> None:
keys = list(self.cache.keys())
for key in keys:
if self.cache[key]["timeout"] <= datetime.utcnow() + timedelta(minutes=1):
del self.cache[key]

119
jarvis/utils/cogs.py Normal file
View file

@ -0,0 +1,119 @@
"""Cog wrapper for command caching."""
import logging
from datetime import timedelta
from jarvis_core.db import q
from jarvis_core.db.models import Action, Ban, Kick, Modlog, Mute, Setting, Warning
from naff import Client, Cog, InteractionContext
from naff.models.discord.components import ActionRow, Button, ButtonStyles
from naff.models.discord.embed import EmbedField
from jarvis.utils import build_embed
MODLOG_LOOKUP = {"Ban": Ban, "Kick": Kick, "Mute": Mute, "Warning": Warning}
IGNORE_COMMANDS = {"Ban": ["bans"], "Kick": [], "Mute": ["unmute"], "Warning": ["warnings"]}
class ModcaseCog(Cog):
"""Cog wrapper for moderation case logging."""
def __init__(self, bot: Client):
self.bot = bot
self.logger = logging.getLogger(__name__)
self.add_cog_postrun(self.log)
async def log(self, ctx: InteractionContext, *_args: list, **kwargs: dict) -> None:
"""
Log a moderation activity in a moderation case.
Args:
ctx: Command context
"""
name = self.__name__.replace("Cog", "")
if name in MODLOG_LOOKUP and ctx.invoke_target not in IGNORE_COMMANDS[name]:
user = kwargs.pop("user", None)
if not user and not ctx.target_id:
self.logger.warning("Admin action %s missing user, exiting", name)
return
if ctx.target_id:
user = ctx.target
coll = MODLOG_LOOKUP.get(name, None)
if not coll:
self.logger.warning("Unsupported action %s, exiting", name)
return
action = await coll.find_one(
q(user=user.id, guild=ctx.guild_id, active=True), sort=[("_id", -1)]
)
if not action:
self.logger.warning("Missing action %s, exiting", name)
return
notify = await Setting.find_one(q(guild=ctx.guild.id, setting="notify", value=True))
if notify and name not in ("Kick", "Ban"): # Ignore Kick and Ban, as these are unique
fields = (
EmbedField(name="Action Type", value=name, inline=False),
EmbedField(
name="Reason", value=kwargs.get("reason", None) or "N/A", inline=False
),
)
embed = build_embed(
title="Admin action taken",
description=f"Admin action has been taken against you in {ctx.guild.name}",
fields=fields,
)
guild_url = f"https://discord.com/channels/{ctx.guild.id}"
embed.set_author(name=ctx.guild.name, icon_url=ctx.guild.icon.url, url=guild_url)
embed.set_thumbnail(url=ctx.guild.icon.url)
try:
await user.send(embed=embed)
except Exception:
self.logger.debug("User not warned of action due to closed DMs")
modlog = await Modlog.find_one(q(user=user.id, guild=ctx.guild.id, open=True))
if modlog:
m_action = Action(action_type=name.lower(), parent=action.id)
modlog.actions.append(m_action)
await modlog.commit()
return
lookup_key = f"{user.id}|{ctx.guild.id}"
async with self.bot.redis.lock("lock|" + lookup_key):
if await self.bot.redis.get(lookup_key):
self.logger.debug(f"User {user.id} in {ctx.guild.id} already has pending case")
return
modlog = await Setting.find_one(q(guild=ctx.guild.id, setting="modlog"))
if not modlog:
return
channel = await ctx.guild.fetch_channel(modlog.value)
if not channel:
self.logger.warn(
f"Guild {ctx.guild.id} modlog channel no longer exists, deleting"
)
await modlog.delete()
return
embed = build_embed(
title="Recent Action Taken",
description=f"Would you like to open a moderation case for {user.mention}?",
fields=[],
)
embed.set_author(
name=user.username + "#" + user.discriminator, icon_url=user.display_avatar.url
)
components = [
ActionRow(
Button(style=ButtonStyles.RED, emoji="✖️", custom_id="modcase|no"),
Button(style=ButtonStyles.GREEN, emoji="✔️", custom_id="modcase|yes"),
)
]
message = await channel.send(embed=embed, components=components)
await self.bot.redis.set(
lookup_key, f"{name.lower()}|{action.id}", ex=timedelta(days=7)
)
await self.bot.redis.set(f"msg|{message.id}", user.id, ex=timedelta(days=7))

22
jarvis/utils/embeds.py Normal file
View file

@ -0,0 +1,22 @@
"""JARVIS bot-specific embeds."""
from naff.models.discord.embed import Embed, EmbedField
from naff.models.discord.user import Member
from jarvis.utils import build_embed
def warning_embed(user: Member, reason: str) -> Embed:
"""
Generate a warning embed.
Args:
user: User to warn
reason: Warning reason
"""
fields = (EmbedField(name="Reason", value=reason, inline=False),)
embed = build_embed(
title="Warning", description=f"{user.mention} has been warned", fields=fields
)
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
return embed

View file

@ -1,16 +0,0 @@
"""Embed field helper."""
from dataclasses import dataclass
from typing import Any
@dataclass
class Field:
"""Embed Field."""
name: Any
value: Any
inline: bool = True
def to_dict(self) -> dict:
"""Convert Field to d.py field dict."""
return {"name": self.name, "value": self.value, "inline": self.inline}

View file

@ -1,28 +1,30 @@
"""Permissions wrappers."""
from discord.ext import commands
from naff import InteractionContext, Permissions
from jarvis.config import get_config
from jarvis.config import JarvisConfig
def user_is_bot_admin() -> bool:
"""Check if a user is a J.A.R.V.I.S. admin."""
"""Check if a user is a JARVIS admin."""
def predicate(ctx: commands.Context) -> bool:
async def predicate(ctx: InteractionContext) -> bool:
"""Command check predicate."""
if getattr(get_config(), "admins", None):
return ctx.author.id in get_config().admins
cfg = JarvisConfig.from_yaml()
if getattr(cfg, "admins", None):
return ctx.author.id in cfg.admins
else:
return False
return commands.check(predicate)
return predicate
def admin_or_permissions(**perms: dict) -> bool:
def admin_or_permissions(*perms: list) -> bool:
"""Check if a user is an admin or has other perms."""
original = commands.has_permissions(**perms).predicate
async def extended_check(ctx: commands.Context) -> bool:
async def predicate(ctx: InteractionContext) -> bool:
"""Extended check predicate.""" # noqa: D401
return await commands.has_permissions(administrator=True).predicate(ctx) or await original(ctx)
is_admin = ctx.author.has_permission(Permissions.ADMINISTRATOR)
has_other = any(ctx.author.has_permission(perm) for perm in perms)
return is_admin or has_other
return commands.check(extended_check)
return predicate

217
jarvis/utils/updates.py Normal file
View file

@ -0,0 +1,217 @@
"""JARVIS update handler."""
import asyncio
import logging
from dataclasses import dataclass
from importlib import import_module
from inspect import getmembers, isclass
from pkgutil import iter_modules
from types import FunctionType, ModuleType
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
import git
from naff.client.errors import ExtensionNotFound
from naff.client.utils.misc_utils import find, find_all
from naff.models.naff.application_commands import SlashCommand
from naff.models.naff.cog import Cog
from rich.table import Table
import jarvis.cogs
if TYPE_CHECKING:
from naff.client.client import Client
_logger = logging.getLogger(__name__)
@dataclass
class UpdateResult:
"""JARVIS update result."""
old_hash: str
new_hash: str
table: Table
added: List[str]
removed: List[str]
changed: List[str]
lines: Dict[str, int]
def get_all_commands(module: ModuleType = jarvis.cogs) -> Dict[str, Callable]:
"""Get all SlashCommands from a specified module."""
commands = {}
def validate_ires(entry: Any) -> bool:
return isclass(entry) and issubclass(entry, Cog) and entry is not Cog
def validate_cog(cog: FunctionType) -> bool:
return isinstance(cog, SlashCommand)
for item in iter_modules(module.__path__):
new_module = import_module(f"{module.__name__}.{item.name}")
if item.ispkg:
if cmds := get_all_commands(new_module):
commands.update(cmds)
else:
inspect_result = getmembers(new_module)
cogs = []
for _, val in inspect_result:
if validate_ires(val):
cogs.append(val)
for cog in cogs:
values = cog.__dict__.values()
commands[cog.__module__] = find_all(lambda x: isinstance(x, SlashCommand), values)
return {k: v for k, v in commands.items() if v}
def get_git_changes(repo: git.Repo) -> dict:
"""Get all Git changes"""
logger = _logger
logger.debug("Getting all git changes")
current_hash = repo.head.ref.object.hexsha
tracking = repo.head.ref.tracking_branch()
file_changes = {}
for commit in tracking.commit.iter_items(repo, f"{repo.head.ref.path}..{tracking.path}"):
if commit.hexsha == current_hash:
break
files = commit.stats.files
file_changes.update(
{key: {"insertions": 0, "deletions": 0, "lines": 0} for key in files.keys()}
)
for file, stats in files.items():
if file not in file_changes:
file_changes[file] = {"insertions": 0, "deletions": 0, "lines": 0}
for key, val in stats.items():
file_changes[file][key] += val
logger.debug("Found %i changed files", len(file_changes))
table = Table(title="File Changes")
table.add_column("File", justify="left", style="white", no_wrap=True)
table.add_column("Insertions", justify="center", style="green")
table.add_column("Deletions", justify="center", style="red")
table.add_column("Lines", justify="center", style="magenta")
i_total = 0
d_total = 0
l_total = 0
for file, stats in file_changes.items():
i_total += stats["insertions"]
d_total += stats["deletions"]
l_total += stats["lines"]
table.add_row(
file,
str(stats["insertions"]),
str(stats["deletions"]),
str(stats["lines"]),
)
logger.debug("%i insertions, %i deletions, %i total", i_total, d_total, l_total)
table.add_row("Total", str(i_total), str(d_total), str(l_total))
return {
"table": table,
"lines": {"inserted_lines": i_total, "deleted_lines": d_total, "total_lines": l_total},
}
async def update(bot: "Client") -> Optional[UpdateResult]:
"""
Update JARVIS and return an UpdateResult.
Args:
bot: Bot instance
Returns:
UpdateResult object
"""
logger = _logger
repo = git.Repo(".")
current_hash = repo.head.object.hexsha
origin = repo.remotes.origin
origin.fetch()
remote_hash = origin.refs[repo.active_branch.name].object.hexsha
if current_hash != remote_hash:
logger.info("Updating from %s to %s", current_hash, remote_hash)
current_commands = get_all_commands()
changes = get_git_changes(repo)
origin.pull()
await asyncio.sleep(3)
new_commands = get_all_commands()
logger.info("Checking if any modules need reloaded...")
reloaded = []
loaded = []
unloaded = []
logger.debug("Checking for removed cogs")
for module in current_commands.keys():
if module not in new_commands:
logger.debug("Module %s removed after update", module)
bot.drop_cog(module)
unloaded.append(module)
logger.debug("Checking for new/modified commands")
for module, commands in new_commands.items():
logger.debug("Processing %s", module)
if module not in current_commands:
bot.load_cog(module)
loaded.append(module)
elif len(current_commands[module]) != len(commands):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
else:
for command in commands:
old_command = find(
lambda x: x.resolved_name == command.resolved_name, current_commands[module]
)
# Extract useful info
old_args = old_command.options
if old_args:
old_arg_names = [x.name for x in old_args]
new_args = command.options
if new_args:
new_arg_names = [x.name for x in new_args]
# No changes
if not old_args and not new_args:
continue
# Check if number arguments have changed
if len(old_args) != len(new_args):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
elif any(x not in old_arg_names for x in new_arg_names) or any(
x not in new_arg_names for x in old_arg_names
):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
elif any(new_args[idx].type != x.type for idx, x in enumerate(old_args)):
try:
bot.reload_cog(module)
except ExtensionNotFound:
bot.load_cog(module)
reloaded.append(module)
return UpdateResult(
old_hash=current_hash,
new_hash=remote_hash,
added=loaded,
removed=unloaded,
changed=reloaded,
**changes,
)
return None

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

1859
poetry.lock generated Normal file

File diff suppressed because it is too large Load diff

32
pyproject.toml Normal file
View file

@ -0,0 +1,32 @@
[tool.poetry]
name = "jarvis"
version = "2.0.0b2"
description = "J.A.R.V.I.S. admin bot"
authors = ["Zevaryx <zevaryx@gmail.com>"]
[tool.poetry.dependencies]
python = "^3.10"
PyYAML = "^6.0"
GitPython = "^3.1.26"
mongoengine = "^0.23.1"
opencv-python = "^4.5.5"
Pillow = "^9.0.0"
psutil = "^5.9.0"
python-gitlab = "^3.1.1"
ulid-py = "^1.1.0"
tweepy = "^4.5.0"
orjson = "^3.6.6"
jarvis-core = {git = "https://git.zevaryx.com/stark-industries/jarvis/jarvis-core.git", rev = "main"}
aiohttp = "^3.8.1"
pastypy = "^1.0.1"
dateparser = "^1.1.1"
aiofile = "^3.7.4"
asyncpraw = "^7.5.0"
rook = "^0.1.170"
rich = "^12.3.0"
jurigged = "^0.5.0"
aioredis = "^2.0.1"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

118
run.py
View file

@ -1,117 +1,7 @@
#!/bin/python3
# flake8: noqa
from importlib import reload as ireload
from multiprocessing import Process, Value, freeze_support
from pathlib import Path
from time import sleep
import git
import jarvis
from jarvis.config import get_config
def run():
ctx = None
while True:
ireload(jarvis)
ctx = jarvis.run(ctx)
def restart():
global jarvis_process
Path(get_pid_file()).unlink()
jarvis_process.kill()
jarvis_process = Process(target=run, name="jarvis")
jarvis_process.start()
def update():
repo = git.Repo(".")
dirty = repo.is_dirty()
if dirty:
print(" Local system has uncommitted changes.")
current_hash = repo.head.object.hexsha
origin = repo.remotes.origin
origin.fetch()
if current_hash != origin.refs["main"].object.hexsha:
if dirty:
return 2
origin.pull()
return 0
return 1
def get_pid_file():
return f"jarvis.{get_pid()}.pid"
def get_pid():
global jarvis_process
return jarvis_process.pid
def cli():
pfile = Path(get_pid_file())
while not pfile.exists():
sleep(0.2)
print(
"""
All systems online.
Command List:
(R)eload
(U)pdate
(Q)uit
"""
)
while True:
cmd = input("> ")
if cmd.lower() in ["q", "quit", "e", "exit"]:
print(" Shutting down core systems...")
pfile.unlink()
break
if cmd.lower() in ["u", "update"]:
print(" Updating core systems...")
status = update()
if status == 0:
restart()
pfile = Path(get_pid_file())
while not pfile.exists():
sleep(0.2)
print(" Core systems successfully updated.")
elif status == 1:
print(" No core updates available.")
elif status == 2:
print(" Core system update available, but core is dirty.")
if cmd.lower() in ["r", "reload"]:
print(" Reloading core systems...")
restart()
pfile = Path(get_pid_file())
while not pfile.exists():
sleep(0.2)
print(" All systems reloaded.")
"""Main run file for J.A.R.V.I.S."""
import asyncio
from jarvis import run
if __name__ == "__main__":
freeze_support()
config = get_config()
pid_file = Value("i", 0)
jarvis_process = Process(target=run, name="jarvis")
logo = jarvis.logo.get_logo(config.logo)
print(logo)
print("Initializing....")
print(" Updating core systems...")
status = update()
if status == 0:
print(" Core systems successfully updated")
elif status == 1:
print(" No core updates available.")
elif status == 2:
print(" Core updates available, but not applied.")
print(" Starting core systems...")
jarvis_process.start()
cli()
if jarvis_process.is_alive():
jarvis_process.kill()
print("All systems shut down.")
asyncio.run(run())