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: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1 rev: v4.1.0
hooks: hooks:
- id: check-toml - id: check-toml
- id: check-yaml - id: check-yaml
args: [--unsafe]
- id: check-merge-conflict - id: check-merge-conflict
- id: requirements-txt-fixer - id: requirements-txt-fixer
- id: end-of-file-fixer - id: end-of-file-fixer
- id: debug-statements
language_version: python3.10
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] args: [--markdown-linebreak-ext=md]
@ -16,23 +19,31 @@ repos:
- id: python-check-blanket-noqa - id: python-check-blanket-noqa
- repo: https://github.com/psf/black - repo: https://github.com/psf/black
rev: 21.7b0 rev: 22.3.0
hooks: hooks:
- id: black - 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 - repo: https://github.com/pre-commit/mirrors-isort
rev: V5.9.3 rev: v5.10.1
hooks: hooks:
- id: isort - id: isort
args: ["--profile", "black"] args: ["--profile", "black"]
- repo: https://github.com/pycqa/flake8 - repo: https://github.com/pycqa/flake8
rev: 3.9.2 rev: 4.0.1
hooks: hooks:
- id: flake8 - id: flake8
additional_dependencies: additional_dependencies:
- flake8-annotations~=2.0 - flake8-annotations~=2.0
- flake8-bandit~=2.1 #- flake8-bandit~=2.1
- flake8-docstrings~=1.5 - 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)]() [![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.) [![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) [![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. 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. **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. Join the [Stark R&D Department Discord server](https://discord.gg/VtgZntXcnZ) to be kept up-to-date on code updates and issues.
## Requirements ## Requirements
- MongoDB 4.4 or higher - MongoDB 5.0 or higher
- Python 3.8 or higher - Python 3.10 or higher
- [tokei](https://github.com/XAMPPRocky/tokei) 12.1 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: 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` - `psutil>=5.8, <6`
- `GitPython>=3.1, <4` - `GitPython>=3.1, <4`
- `PyYaml>=5.4, <6` - `PyYaml>=5.4, <6`
- `discord-py-slash-command>=2.3.2, <3`
- `pymongo>=3.12.0, <4` - `pymongo>=3.12.0, <4`
- `opencv-python>=4.5, <5` - `opencv-python>=4.5, <5`
- `ButtonPaginator>=0.0.3`
- `Pillow>=8.2.0, <9` - `Pillow>=8.2.0, <9`
- `python-gitlab>=2.9.0, <3` - `python-gitlab>=2.9.0, <3`
- `ulid-py>=1.1.0, <2` - `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 token: bot token
client_id: 123456789012345678 twitter:
logo: alligator2 consumer_key: key
consumer_secret: secret
access_token: access token
access_secret: access secret
mongo: mongo:
connect: connect:
username: user username: username
password: pass password: password
host: localhost host: hostname
port: 27017 port: 27017
database: database database: database
urls: urls:
url_name: url extra: urls
url_name2: url2 max_messages: 10000
max_messages: 1000 gitlab_token: token
gitlab_token: null
cogs: cogs:
- list - admin
- of - autoreact
- enabled - dev
- cogs - image
- all - gl
- if - remindme
- empty - 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> <defs>
<style> <style>
.a { .a {
fill: #3498DB; fill: #3498db;
} }
</style> </style>
</defs> </defs>
<title>logotests</title> <title>jarvis</title>
<g> <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="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="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="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="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="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="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="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> </g>
</svg> </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.""" """Main JARVIS package."""
import asyncio
import logging import logging
from pathlib import Path
from typing import Optional
from discord import Intents import aioredis
from discord.ext import commands import jurigged
from discord.utils import find import rook
from discord_slash import SlashCommand from jarvis_core.db import connect
from mongoengine import connect from jarvis_core.log import get_logger
from psutil import Process from naff import Intents
from jarvis import logo # noqa: F401 from jarvis import const
from jarvis import tasks, utils from jarvis.client import Jarvis
from jarvis.config import get_config from jarvis.cogs import __path__ as cogs_path
from jarvis.events import guild, member, message from jarvis.config import JarvisConfig
from jarvis.utils import get_extensions
jconfig = get_config() __version__ = const.__version__
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
jarvis = commands.Bot( async def run() -> None:
command_prefix=utils.get_prefix, """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, intents=intents,
help_command=None, sync_interactions=jconfig.sync,
max_messages=jconfig.max_messages, delete_unused_application_cmds=True,
) send_command_tracebacks=False,
redis=redis,
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"],
) )
connect(
db=jconfig.mongo["database"], if jconfig.log_level == "DEBUG":
alias="main", jurigged.watch()
authentication_source="admin", if jconfig.rook_token:
**jconfig.mongo["connect"], rook.start(token=jconfig.rook_token, labels={"env": "dev"})
) logger.info("Starting JARVIS")
jconfig.get_db_config() logger.debug("Connecting to database")
for extension in utils.get_extensions(): 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) jarvis.load_extension(extension)
print( logger.debug("Loaded %s", extension)
" https://discord.com/api/oauth2/authorize?client_id="
+ "{}&permissions=8&scope=bot%20applications.commands".format(jconfig.client_id) # noqa: W503
)
jarvis.max_messages = jconfig.max_messages jarvis.max_messages = jconfig.max_messages
tasks.init() logger.debug("Running JARVIS")
await jarvis.astart(jconfig.token)
# 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

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.""" """JARVIS Admin Cogs."""
from discord.ext.commands import Bot 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: def setup(bot: Client) -> None:
"""Add admin cogs to J.A.R.V.I.S.""" """Add admin cogs to JARVIS"""
bot.add_cog(ban.BanCog(bot)) logger = logging.getLogger(__name__)
bot.add_cog(kick.KickCog(bot)) msg = "Loaded jarvis.cogs.admin.{}"
bot.add_cog(lock.LockCog(bot)) ban.BanCog(bot)
bot.add_cog(lockdown.LockdownCog(bot)) logger.debug(msg.format("ban"))
bot.add_cog(mute.MuteCog(bot)) kick.KickCog(bot)
bot.add_cog(purge.PurgeCog(bot)) logger.debug(msg.format("kick"))
bot.add_cog(roleping.RolepingCog(bot)) lock.LockCog(bot)
bot.add_cog(warning.WarningCog(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 import re
from datetime import datetime, timedelta
from ButtonPaginator import Paginator from jarvis_core.db import q
from discord import User from jarvis_core.db.models import Ban, Unban
from discord.ext import commands from naff import InteractionContext, Permissions
from discord.utils import find from naff.client.utils.misc_utils import find, find_all
from discord_slash import SlashContext, cog_ext from naff.ext.paginators import Paginator
from discord_slash.model import ButtonStyle from naff.models.discord.embed import EmbedField
from discord_slash.utils.manage_commands import create_choice, create_option 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 import build_embed
from jarvis.utils.cachecog import CacheCog from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class BanCog(CacheCog): class BanCog(ModcaseCog):
"""J.A.R.V.I.S. BanCog.""" """JARVIS BanCog."""
def __init__(self, bot: commands.Bot):
super().__init__(bot)
async def discord_apply_ban( async def discord_apply_ban(
self, self,
ctx: SlashContext, ctx: InteractionContext,
reason: str, reason: str,
user: User, user: User,
duration: int, duration: int,
@ -35,9 +37,9 @@ class BanCog(CacheCog):
) -> None: ) -> None:
"""Apply a Discord ban.""" """Apply a Discord ban."""
await ctx.guild.ban(user, reason=reason) await ctx.guild.ban(user, reason=reason)
_ = Ban( b = Ban(
user=user.id, user=user.id,
username=user.name, username=user.username,
discrim=user.discriminator, discrim=user.discriminator,
reason=reason, reason=reason,
admin=ctx.author.id, admin=ctx.author.id,
@ -45,7 +47,8 @@ class BanCog(CacheCog):
type=mtype, type=mtype,
duration=duration, duration=duration,
active=active, active=active,
).save() )
await b.commit()
embed = build_embed( embed = build_embed(
title="User Banned", title="User Banned",
@ -54,100 +57,86 @@ class BanCog(CacheCog):
) )
embed.set_author( embed.set_author(
name=user.nick if user.nick else user.name, name=user.display_name,
icon_url=user.avatar_url, icon_url=user.avatar.url,
) )
embed.set_thumbnail(url=user.avatar_url) embed.set_thumbnail(url=user.avatar.url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed) 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.""" """Apply a Discord unban."""
await ctx.guild.unban(user, reason=reason) await ctx.guild.unban(user, reason=reason)
_ = Unban( u = Unban(
user=user.id, user=user.id,
username=user.name, username=user.username,
discrim=user.discriminator, discrim=user.discriminator,
guild=ctx.guild.id, guild=ctx.guild.id,
admin=ctx.author.id, admin=ctx.author.id,
reason=reason, reason=reason,
).save() )
await u.commit()
embed = build_embed( embed = build_embed(
title="User Unbanned", title="User Unbanned",
description=f"<@{user.id}> was unbanned", description=f"<@{user.id}> was unbanned",
fields=[Field(name="Reason", value=reason)], fields=[EmbedField(name="Reason", value=reason)],
) )
embed.set_author( embed.set_author(
name=user.name, name=user.username,
icon_url=user.avatar_url, icon_url=user.avatar.url,
) )
embed.set_thumbnail(url=user.avatar_url) embed.set_thumbnail(url=user.avatar.url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}") embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(name="ban", description="Ban a user")
name="ban", @slash_option(name="user", description="User to ban", opt_type=OptionTypes.USER, required=True)
description="Ban a user", @slash_option(
options=[ name="reason", description="Ban reason", opt_type=OptionTypes.STRING, required=True
create_option( )
name="user", @slash_option(
description="User to ban",
option_type=6,
required=True,
),
create_option(
name="reason",
description="Ban reason",
required=True,
option_type=3,
),
create_option(
name="btype", name="btype",
description="Ban type", description="Ban type",
option_type=3, opt_type=OptionTypes.STRING,
required=False, required=True,
choices=[ choices=[
create_choice(value="perm", name="Permanent"), SlashCommandChoice(name="Permanent", value="perm"),
create_choice(value="temp", name="Temporary"), SlashCommandChoice(name="Temporary", value="temp"),
create_choice(value="soft", name="Soft"), SlashCommandChoice(name="Soft", value="soft"),
],
),
create_option(
name="duration",
description="Ban duration in hours if temporary",
required=False,
option_type=4,
),
], ],
) )
@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( async def _ban(
self, self,
ctx: SlashContext, ctx: InteractionContext,
user: User = None, user: User,
reason: str = None, reason: str,
btype: str = "perm", btype: str = "perm",
duration: int = 4, duration: int = 4,
) -> None: ) -> None:
if not user or user == ctx.author: if user.id == ctx.author.id:
await ctx.send("You cannot ban yourself.", hidden=True) await ctx.send("You cannot ban yourself.", ephemeral=True)
return return
if user == self.bot.user: if user.id == self.bot.user.id:
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 return
if btype == "temp" and duration < 0: 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 return
elif btype == "temp" and duration > 744: 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 return
if len(reason) > 100: 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 return
if not reason:
reason = "Mr. Stark is displeased with your presence. Please leave."
await ctx.defer() await ctx.defer()
@ -160,10 +149,10 @@ class BanCog(CacheCog):
if mtype == "temp": if mtype == "temp":
user_message += f"\nDuration: {duration} hours" user_message += f"\nDuration: {duration} hours"
fields = [Field(name="Type", value=mtype)] fields = [EmbedField(name="Type", value=mtype)]
if mtype == "temp": 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( user_embed = build_embed(
title=f"You have been banned from {ctx.guild.name}", title=f"You have been banned from {ctx.guild.name}",
@ -172,10 +161,10 @@ class BanCog(CacheCog):
) )
user_embed.set_author( user_embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator, name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url, icon_url=ctx.author.avatar,
) )
user_embed.set_thumbnail(url=ctx.guild.icon_url) user_embed.set_thumbnail(url=ctx.guild.icon.url)
try: try:
await user.send(embed=user_embed) await user.send(embed=user_embed)
@ -184,13 +173,13 @@ class BanCog(CacheCog):
try: try:
await ctx.guild.ban(user, reason=reason) await ctx.guild.ban(user, reason=reason)
except Exception as e: 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 return
send_failed = False send_failed = False
if mtype == "soft": if mtype == "soft":
await ctx.guild.unban(user, reason="Ban was softban") 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": if btype != "temp":
duration = None duration = None
active = True active = True
@ -199,33 +188,22 @@ class BanCog(CacheCog):
await self.discord_apply_ban(ctx, reason, user, duration, active, fields, mtype) await self.discord_apply_ban(ctx, reason, user, duration, active, fields, mtype)
@cog_ext.cog_slash( @slash_command(name="unban", description="Unban a user")
name="unban", @slash_option(
description="Unban a user", name="user", description="User to unban", opt_type=OptionTypes.STRING, required=True
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,
),
],
) )
@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( async def _unban(
self, self,
ctx: SlashContext, ctx: InteractionContext,
user: str, user: str,
reason: str, reason: str,
) -> None: ) -> None:
if len(reason) > 100: 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 return
orig_user = user orig_user = user
@ -233,29 +211,35 @@ class BanCog(CacheCog):
discord_ban_info = None discord_ban_info = None
database_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 # 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) user = int(user)
discord_ban_info = find(lambda x: x.user.id == user, bans) discord_ban_info = find(lambda x: x.user.id == user, bans)
else: # User name 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("#") user, discrim = user.split("#")
if discrim: if discrim:
discord_ban_info = find( 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, bans,
) )
else: 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 results:
if len(results) > 1: if len(results) > 1:
active_bans = [] active_bans = []
for ban in 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) 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) await ctx.send(message)
return return
else: else:
@ -265,8 +249,10 @@ class BanCog(CacheCog):
# try to find the relevant information in the database. # try to find the relevant information in the database.
# We take advantage of the previous checks to save CPU cycles # We take advantage of the previous checks to save CPU cycles
if not discord_ban_info: if not discord_ban_info:
if isinstance(user, int): if isinstance(user, User):
database_ban_info = Ban.objects(guild=ctx.guild.id, user=user, active=True).first() database_ban_info = await Ban.find_one(
q(guild=ctx.guild.id, user=user.id, active=True)
)
else: else:
search = { search = {
"guild": ctx.guild.id, "guild": ctx.guild.id,
@ -275,15 +261,16 @@ class BanCog(CacheCog):
} }
if discrim: if discrim:
search["discrim"] = 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: 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) await self.discord_apply_unban(ctx, discord_ban_info.user, reason)
else: 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: if discord_ban_info:
await self.discord_apply_unban(ctx, discord_ban_info.user, reason) await self.discord_apply_unban(ctx, discord_ban_info.user, reason)
else: else:
@ -297,63 +284,48 @@ class BanCog(CacheCog):
admin=ctx.author.id, admin=ctx.author.id,
reason=reason, reason=reason,
).save() ).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( bans = SlashCommand(name="bans", description="User bans")
base="bans",
name="list", @bans.subcommand(sub_cmd_name="list", sub_cmd_description="List bans")
description="List bans", @slash_option(
options=[ name="btype",
create_option(
name="type",
description="Ban type", description="Ban type",
option_type=4, opt_type=OptionTypes.INTEGER,
required=False, required=False,
choices=[ choices=[
create_choice(value=0, name="All"), SlashCommandChoice(name="All", value=0),
create_choice(value=1, name="Permanent"), SlashCommandChoice(name="Permanent", value=1),
create_choice(value=2, name="Temporary"), SlashCommandChoice(name="Temporary", value=2),
create_choice(value=3, name="Soft"), SlashCommandChoice(name="Soft", value=3),
], ],
), )
create_option( @slash_option(
name="active", name="active",
description="Active bans", description="Active bans",
option_type=4, opt_type=OptionTypes.BOOLEAN,
required=False, required=False,
choices=[
create_choice(value=1, name="Yes"),
create_choice(value=0, name="No"),
],
),
],
) )
@admin_or_permissions(ban_members=True) @check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _bans_list(self, ctx: SlashContext, type: int = 0, active: int = 1) -> None: async def _bans_list(
active = bool(active) self, ctx: InteractionContext, btype: int = 0, active: bool = True
exists = self.check_cache(ctx, type=type, active=active) ) -> None:
if exists:
await ctx.defer(hidden=True)
await ctx.send(
f"Please use existing interaction: {exists['paginator']._message.jump_url}",
hidden=True,
)
return
types = [0, "perm", "temp", "soft"] types = [0, "perm", "temp", "soft"]
search = {"guild": ctx.guild.id} search = {"guild": ctx.guild.id}
if active: if active:
search["active"] = True search["active"] = True
if type > 0: if btype > 0:
search["type"] = types[type] search["type"] = types[btype]
bans = Ban.objects(**search).order_by("-created_at") bans = await Ban.find(search).sort([("created_at", -1)]).to_list(None)
db_bans = [] db_bans = []
fields = [] fields = []
for ban in bans: for ban in bans:
if not ban.username: if not ban.username:
user = await self.bot.fetch_user(ban.user) 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( fields.append(
Field( EmbedField(
name=f"Username: {ban.username}#{ban.discrim}", name=f"Username: {ban.username}#{ban.discrim}",
value=( value=(
f"Date: {ban.created_at.strftime('%d-%m-%Y')}\n" f"Date: {ban.created_at.strftime('%d-%m-%Y')}\n"
@ -370,8 +342,8 @@ class BanCog(CacheCog):
for ban in bans: for ban in bans:
if ban.user.id not in db_bans: if ban.user.id not in db_bans:
fields.append( fields.append(
Field( EmbedField(
name=f"Username: {ban.user.name}#" + f"{ban.user.discriminator}", name=f"Username: {ban.user.username}#" + f"{ban.user.discriminator}",
value=( value=(
f"Date: [unknown]\n" f"Date: [unknown]\n"
f"User ID: {ban.user.id}\n" f"User ID: {ban.user.id}\n"
@ -384,9 +356,9 @@ class BanCog(CacheCog):
pages = [] pages = []
title = "Active " if active else "Inactive " title = "Active " if active else "Inactive "
if type > 0: if btype > 0:
title += types[type] title += types[btype]
if type == 1: if btype == 1:
title += "a" title += "a"
title += "bans" title += "bans"
if len(fields) == 0: if len(fields) == 0:
@ -395,35 +367,14 @@ class BanCog(CacheCog):
description=f"No {'in' if not active else ''}active bans", description=f"No {'in' if not active else ''}active bans",
fields=[], fields=[],
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed) pages.append(embed)
else: else:
for i in range(0, len(bans), 5): for i in range(0, len(bans), 5):
embed = build_embed(title=title, description="", fields=fields[i : i + 5]) # noqa: E203 embed = build_embed(title=title, description="", fields=fields[i : i + 5])
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
pages.append(embed) pages.append(embed)
paginator = Paginator( paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
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)] = { await paginator.send(ctx)
"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()

View file

@ -1,53 +1,40 @@
"""J.A.R.V.I.S. KickCog.""" """JARVIS KickCog."""
from discord import User from jarvis_core.db.models import Kick
from discord.ext.commands import Bot from naff import InteractionContext, Permissions
from discord_slash import SlashContext, cog_ext from naff.models.discord.embed import EmbedField
from discord_slash.utils.manage_commands import create_option 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 import build_embed
from jarvis.utils.cachecog import CacheCog from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class KickCog(CacheCog): class KickCog(ModcaseCog):
"""J.A.R.V.I.S. KickCog.""" """JARVIS KickCog."""
def __init__(self, bot: Bot): @slash_command(name="kick", description="Kick a user")
super().__init__(bot) @slash_option(name="user", description="User to kick", opt_type=OptionTypes.USER, required=True)
@slash_option(
@cog_ext.cog_slash( name="reason", description="Kick reason", opt_type=OptionTypes.STRING, required=True
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,
),
],
) )
@admin_or_permissions(kick_members=True) @check(admin_or_permissions(Permissions.BAN_MEMBERS))
async def _kick(self, ctx: SlashContext, user: User, reason: str = None) -> None: async def _kick(self, ctx: InteractionContext, user: User, reason: str) -> None:
if not user or user == ctx.author: 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 return
if user == self.bot.user: 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 return
if len(reason) > 100: 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 return
if not reason:
reason = "Mr. Stark is displeased with your presence. Please leave."
guild_name = ctx.guild.name guild_name = ctx.guild.name
embed = build_embed( embed = build_embed(
title=f"You have been kicked from {guild_name}", title=f"You have been kicked from {guild_name}",
@ -56,36 +43,38 @@ class KickCog(CacheCog):
) )
embed.set_author( embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator, name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url, 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 send_failed = False
try: try:
await user.send(embed=embed) await user.send(embed=embed)
except Exception: except Exception:
send_failed = True send_failed = True
try:
await ctx.guild.kick(user, reason=reason) 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( embed = build_embed(
title="User Kicked", title="User Kicked",
description=f"Reason: {reason}", description=f"Reason: {reason}",
fields=fields, fields=fields,
) )
embed.set_author( embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
name=user.nick if user.nick else user.name, embed.set_thumbnail(url=user.display_avatar.url)
icon_url=user.avatar_url, embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed) k = Kick(
_ = Kick(
user=user.id, user=user.id,
reason=reason, reason=reason,
admin=ctx.author.id, admin=ctx.author.id,
guild=ctx.guild.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.""" """JARVIS LockCog."""
from contextlib import suppress import logging
from typing import Union from typing import Union
from discord import Role, TextChannel, User, VoiceChannel from jarvis_core.db import q
from discord.ext.commands import Bot from jarvis_core.db.models import Lock, Permission
from discord_slash import SlashContext, cog_ext from naff import Client, Cog, InteractionContext
from discord_slash.utils.manage_commands import create_option 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 from jarvis.utils.permissions import admin_or_permissions
class LockCog(CacheCog): class LockCog(Cog):
"""J.A.R.V.I.S. LockCog.""" """JARVIS LockCog."""
def __init__(self, bot: Bot): def __init__(self, bot: Client):
super().__init__(bot) self.bot = bot
self.logger = logging.getLogger(__name__)
async def _lock_channel( @slash_command(name="lock", description="Lock a channel")
self, @slash_option(
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(
name="reason", name="reason",
description="Lock Reason", description="Lock Reason",
option_type=3, opt_type=3,
required=True, required=True,
), )
create_option( @slash_option(
name="duration", name="duration",
description="Lock duration in minutes (default 10)", description="Lock duration in minutes (default 10)",
option_type=4, opt_type=4,
required=False, required=False,
), )
create_option( @slash_option(
name="channel", name="channel",
description="Channel to lock", description="Channel to lock",
option_type=7, opt_type=7,
required=False, required=False,
),
],
) )
@admin_or_permissions(manage_channels=True) @check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _lock( async def _lock(
self, self,
ctx: SlashContext, ctx: InteractionContext,
reason: str, reason: str,
duration: int = 10, duration: int = 10,
channel: Union[TextChannel, VoiceChannel] = None, channel: Union[GuildText, GuildVoice] = None,
) -> None: ) -> None:
await ctx.defer(hidden=True) await ctx.defer(ephemeral=True)
if duration <= 0: if duration <= 0:
await ctx.send("Duration must be > 0", hidden=True) await ctx.send("Duration must be > 0", ephemeral=True)
return 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 return
if len(reason) > 100: 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 return
if not channel: if not channel:
channel = ctx.channel channel = ctx.channel
for role in ctx.guild.roles:
with suppress(Exception): to_deny = Permissions.CONNECT | Permissions.SPEAK | Permissions.SEND_MESSAGES
await self._lock_channel(channel, role, ctx.author, reason)
_ = Lock( 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, channel=channel.id,
guild=ctx.guild.id, guild=ctx.guild.id,
admin=ctx.author.id, admin=ctx.author.id,
reason=reason, reason=reason,
duration=duration, duration=duration,
).save() original_perms=current,
).commit()
await ctx.send(f"{channel.mention} locked for {duration} minute(s)") await ctx.send(f"{channel.mention} locked for {duration} minute(s)")
@cog_ext.cog_slash( @slash_command(name="unlock", description="Unlock a channel")
name="unlock", @slash_option(
description="Unlocks a channel",
options=[
create_option(
name="channel", name="channel",
description="Channel to lock", description="Channel to unlock",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=False, required=False,
),
],
) )
@admin_or_permissions(manage_channels=True) @check(admin_or_permissions(Permissions.MANAGE_CHANNELS))
async def _unlock( async def _unlock(
self, self,
ctx: SlashContext, ctx: InteractionContext,
channel: Union[TextChannel, VoiceChannel] = None, channel: Union[GuildText, GuildVoice] = None,
) -> None: ) -> None:
if not channel: if not channel:
channel = ctx.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: if not lock:
await ctx.send(f"{channel.mention} not locked.", hidden=True) await ctx.send(f"{channel.mention} not locked.", ephemeral=True)
return return
for role in ctx.guild.roles:
with suppress(Exception): overwrite = get(channel.permission_overwrites, id=ctx.guild.id)
await self._unlock_channel(channel, role, ctx.author) 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.active = False
lock.save() await lock.commit()
await ctx.send(f"{channel.mention} unlocked") await ctx.send(f"{channel.mention} unlocked")

View file

@ -1,100 +1,170 @@
"""J.A.R.V.I.S. LockdownCog.""" """JARVIS LockdownCog."""
from contextlib import suppress import logging
from datetime import datetime
from discord.ext import commands from jarvis_core.db import q
from discord_slash import SlashContext, cog_ext from jarvis_core.db.models import Lock, Lockdown, Permission
from discord_slash.utils.manage_commands import create_option 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 from jarvis.utils.permissions import admin_or_permissions
class LockdownCog(CacheCog): async def lock(
"""J.A.R.V.I.S. LockdownCog.""" bot: Client, target: GuildChannel, admin: Member, reason: str, duration: int
) -> None:
"""
Lock an existing channel
def __init__(self, bot: commands.Bot): Args:
super().__init__(bot) 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", async def lock_all(bot: Client, guild: Guild, admin: Member, reason: str, duration: int) -> None:
name="start", """
description="Locks a server", Lock all channels
options=[
create_option( Args:
name="reason", bot: Bot instance
description="Lockdown Reason", guild: Target guild
option_type=3, admin: Admin who initiated lockdown
required=True, """
), role = await guild.fetch_role(guild.id)
create_option( categories = find_all(lambda x: isinstance(x, GuildCategory), guild.channels)
name="duration", for category in categories:
description="Lockdown duration in minutes (default 10)", await lock(bot, category, admin, reason, duration)
option_type=4, perms = category.permissions_for(role)
required=False,
), 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( async def _lockdown_start(
self, self,
ctx: SlashContext, ctx: InteractionContext,
reason: str, reason: str,
duration: int = 10, duration: int = 10,
) -> None: ) -> None:
await ctx.defer(hidden=True) await ctx.defer()
if duration <= 0: if duration <= 0:
await ctx.send("Duration must be > 0", hidden=True) await ctx.send("Duration must be > 0", ephemeral=True)
return return
elif duration >= 300: elif duration >= 300:
await ctx.send("Duration must be < 5 hours", hidden=True) await ctx.send("Duration must be < 5 hours", ephemeral=True)
return 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( exists = await Lockdown.find_one(q(guild=ctx.guild.id, active=True))
base="lockdown", if exists:
name="end", await ctx.send("Server already in lockdown", ephemeral=True)
description="Unlocks a server", return
)
@commands.has_permissions(administrator=True) 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( async def _lockdown_end(
self, self,
ctx: SlashContext, ctx: InteractionContext,
) -> None: ) -> 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() await ctx.defer()
for channel in channels:
for role in roles: lockdown = await Lockdown.find_one(q(guild=ctx.guild.id, active=True))
with suppress(Exception): if not lockdown:
await self._unlock_channel(channel, role, ctx.author) await ctx.send("Server not in lockdown", ephemeral=True)
update = True return
if update:
Lock.objects(guild=ctx.guild.id, active=True).update(set__active=False) await unlock_all(self.bot, ctx.guild, ctx.author)
await ctx.send("Server unlocked") 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.""" """JARVIS MuteCog."""
from discord import Member import asyncio
from discord.ext import commands from datetime import datetime, timedelta, timezone
from discord.utils import get
from discord_slash import SlashContext, cog_ext from dateparser import parse
from discord_slash.utils.manage_commands import create_option 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 import build_embed
from jarvis.utils.field import Field from jarvis.utils.cogs import ModcaseCog
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class MuteCog(commands.Cog): class MuteCog(ModcaseCog):
"""J.A.R.V.I.S. MuteCog.""" """JARVIS MuteCog."""
def __init__(self, bot: commands.Bot): async def _apply_timeout(
self.bot = bot self, ctx: InteractionContext, user: Member, reason: str, until: datetime
) -> None:
@cog_ext.cog_slash( await user.timeout(communication_disabled_until=until, reason=reason)
name="mute", duration = int((until - datetime.now(tz=timezone.utc)).seconds / 60)
description="Mute a user", await Mute(
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(
user=user.id, user=user.id,
reason=reason, reason=reason,
admin=ctx.author.id, admin=ctx.author.id,
guild=ctx.guild.id, guild=ctx.guild.id,
duration=duration, duration=duration,
active=True if duration >= 0 else False, active=True,
).save() ).commit()
ts = int(until.timestamp())
embed = build_embed( embed = build_embed(
title="User Muted", title="User Muted",
description=f"{user.mention} has been muted", description=f"{user.mention} has been muted",
fields=[Field(name="Reason", value=reason)], fields=[
) EmbedField(name="Reason", value=reason),
embed.set_author( EmbedField(name="Until", value=f"<t:{ts}:F> <t:{ts}:R>"),
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,
)
], ],
) )
@admin_or_permissions(mute_members=True) embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
async def _unmute(self, ctx: SlashContext, user: Member) -> None: embed.set_thumbnail(url=user.display_avatar.url)
mute_setting = Setting.objects(guild=ctx.guild.id, setting="mute").first() embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
if not mute_setting: return embed
await ctx.send(
"Please configure a mute role with /settings mute <role> first.", @context_menu(name="Mute User", context_type=CommandTypes.USER)
hidden=True, @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 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) @slash_command(name="mute", description="Mute a user")
if role in user.roles: @slash_option(name="user", description="User to mute", opt_type=OptionTypes.USER, required=True)
await user.remove_roles(role, reason="Unmute") @slash_option(
else: name="reason",
await ctx.send("User is not muted.", hidden=True) 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 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( embed = build_embed(
title="User Unmuted", title="User Unmuted",
description=f"{user.mention} has been unmuted", description=f"{user.mention} has been unmuted",
fields=[], fields=[],
) )
embed.set_author( embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
name=user.nick if user.nick else user.name, embed.set_thumbnail(url=user.display_avatar.url)
icon_url=user.avatar_url, embed.set_footer(text=f"{user.username}#{user.discriminator} | {user.id}")
)
embed.set_thumbnail(url=user.avatar_url)
embed.set_footer(text=f"{user.name}#{user.discriminator} | {user.id}")
await ctx.send(embed=embed) await ctx.send(embed=embed)

View file

@ -1,138 +1,140 @@
"""J.A.R.V.I.S. PurgeCog.""" """JARVIS PurgeCog."""
from discord import TextChannel import logging
from discord.ext import commands
from discord_slash import SlashContext, cog_ext from jarvis_core.db import q
from discord_slash.utils.manage_commands import create_option 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 from jarvis.utils.permissions import admin_or_permissions
class PurgeCog(commands.Cog): class PurgeCog(Cog):
"""J.A.R.V.I.S. PurgeCog.""" """JARVIS PurgeCog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash( @slash_command(name="purge", description="Purge messages from channel")
name="purge", @slash_option(
description="Purge messages from channel",
options=[
create_option(
name="amount", name="amount",
description="Amount of messages to purge", description="Amount of messages to purge, default 10",
opt_type=OptionTypes.INTEGER,
required=False, required=False,
option_type=4,
) )
], @check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
) async def _purge(self, ctx: InteractionContext, amount: int = 10) -> None:
@admin_or_permissions(manage_messages=True)
async def _purge(self, ctx: SlashContext, amount: int = 10) -> None:
if amount < 1: if amount < 1:
await ctx.send("Amount must be >= 1", hidden=True) await ctx.send("Amount must be >= 1", ephemeral=True)
return return
await ctx.defer() await ctx.defer()
channel = ctx.channel
messages = [] messages = []
async for message in channel.history(limit=amount + 1): async for message in ctx.channel.history(limit=amount + 1):
messages.append(message) messages.append(message)
await channel.delete_messages(messages) await ctx.channel.delete_messages(messages, reason=f"Purge by {ctx.author.username}")
_ = Purge( await Purge(
channel=ctx.channel.id, channel=ctx.channel.id,
guild=ctx.guild.id, guild=ctx.guild.id,
admin=ctx.author.id, admin=ctx.author.id,
count=amount, count=amount,
).save() ).commit()
@cog_ext.cog_subcommand( @slash_command(
base="autopurge", name="autopurge", sub_cmd_name="add", sub_cmd_description="Automatically purge messages"
name="add", )
description="Automatically purge messages after x seconds", @slash_option(
options=[
create_option(
name="channel", name="channel",
description="Channel to autopurge", description="Channel to autopurge",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
), )
create_option( @slash_option(
name="delay", name="delay",
description="Seconds to keep message before purge, default 30", description="Seconds to keep message before purge, default 30",
option_type=4, opt_type=OptionTypes.INTEGER,
required=False, required=False,
),
],
) )
@admin_or_permissions(manage_messages=True) @check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_add(self, ctx: SlashContext, channel: TextChannel, delay: int = 30) -> None: async def _autopurge_add(
if not isinstance(channel, TextChannel): self, ctx: InteractionContext, channel: GuildText, delay: int = 30
await ctx.send("Channel must be a TextChannel", hidden=True) ) -> None:
if not isinstance(channel, GuildText):
await ctx.send("Channel must be a GuildText channel", ephemeral=True)
return return
if delay <= 0: if delay <= 0:
await ctx.send("Delay must be > 0", hidden=True) await ctx.send("Delay must be > 0", ephemeral=True)
return return
elif delay > 300: elif delay > 300:
await ctx.send("Delay must be < 5 minutes", hidden=True) await ctx.send("Delay must be < 5 minutes", ephemeral=True)
return 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: if autopurge:
await ctx.send("Autopurge already exists.", hidden=True) await ctx.send("Autopurge already exists.", ephemeral=True)
return return
_ = Autopurge(
await Autopurge(
guild=ctx.guild.id, guild=ctx.guild.id,
channel=channel.id, channel=channel.id,
admin=ctx.author.id, admin=ctx.author.id,
delay=delay, delay=delay,
).save() ).commit()
await ctx.send(f"Autopurge set up on {channel.mention}, delay is {delay} seconds") await ctx.send(f"Autopurge set up on {channel.mention}, delay is {delay} seconds")
@cog_ext.cog_subcommand( @slash_command(
base="autopurge", name="autopurge", sub_cmd_name="remove", sub_cmd_description="Remove an autopurge"
name="remove", )
description="Remove an autopurge", @slash_option(
options=[
create_option(
name="channel", name="channel",
description="Channel to remove from autopurge", description="Channel to remove from autopurge",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_messages=True) @check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_remove(self, ctx: SlashContext, channel: TextChannel) -> None: async def _autopurge_remove(self, ctx: InteractionContext, channel: GuildText) -> None:
autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id) autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autopurge: if not autopurge:
await ctx.send("Autopurge does not exist.", hidden=True) await ctx.send("Autopurge does not exist.", ephemeral=True)
return return
autopurge.delete() await autopurge.delete()
await ctx.send(f"Autopurge removed from {channel.mention}.") await ctx.send(f"Autopurge removed from {channel.mention}.")
@cog_ext.cog_subcommand( @slash_command(
base="autopurge", name="autopurge",
name="update", sub_cmd_name="update",
description="Update autopurge on a channel", sub_cmd_description="Update autopurge on a channel",
options=[ )
create_option( @slash_option(
name="channel", name="channel",
description="Channel to update", description="Channel to update",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
), )
create_option( @slash_option(
name="delay", name="delay",
description="New time to save", description="New time to save",
option_type=4, opt_type=OptionTypes.INTEGER,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_messages=True) @check(admin_or_permissions(Permissions.MANAGE_MESSAGES))
async def _autopurge_update(self, ctx: SlashContext, channel: TextChannel, delay: int) -> None: async def _autopurge_update(
autopurge = Autopurge.objects(guild=ctx.guild.id, channel=channel.id) self, ctx: InteractionContext, channel: GuildText, delay: int
) -> None:
autopurge = await Autopurge.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not autopurge: if not autopurge:
await ctx.send("Autopurge does not exist.", hidden=True) await ctx.send("Autopurge does not exist.", ephemeral=True)
return return
autopurge.delay = delay autopurge.delay = delay
autopurge.save() await autopurge.commit()
await ctx.send(f"Autopurge delay updated to {delay} seconds on {channel.mention}.") 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.""" """JARVIS RolepingCog."""
from datetime import datetime, timedelta import logging
from ButtonPaginator import Paginator from jarvis_core.db import q
from discord import Member, Role from jarvis_core.db.models import Roleping
from discord.ext.commands import Bot from naff import Client, Cog, InteractionContext, Permissions
from discord_slash import SlashContext, cog_ext from naff.client.utils.misc_utils import find_all
from discord_slash.model import ButtonStyle from naff.ext.paginators import Paginator
from discord_slash.utils.manage_commands import create_option 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 import build_embed
from jarvis.utils.cachecog import CacheCog
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class RolepingCog(CacheCog): class RolepingCog(Cog):
"""J.A.R.V.I.S. RolepingCog.""" """JARVIS RolepingCog."""
def __init__(self, bot: Bot): def __init__(self, bot: Client):
super().__init__(bot) self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand( roleping = SlashCommand(
base="roleping", name="roleping", description="Set up warnings for pinging specific roles"
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.subcommand(
sub_cmd_name="add",
sub_cmd_description="Add a role to roleping",
) )
@admin_or_permissions(manage_guild=True) @slash_option(name="role", description="Role to add", opt_type=OptionTypes.ROLE, required=True)
async def _roleping_add(self, ctx: SlashContext, role: Role) -> None: @check(admin_or_permissions(Permissions.MANAGE_GUILD))
roleping = Roleping.objects(guild=ctx.guild.id, role=role.id).first() 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: 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 return
_ = Roleping(
if role.id == ctx.guild.id:
await ctx.send("Cannot add `@everyone` to roleping", ephemeral=True)
return
_ = await Roleping(
role=role.id, role=role.id,
guild=ctx.guild.id, guild=ctx.guild.id,
admin=ctx.author.id, admin=ctx.author.id,
active=True, active=True,
bypass={"roles": [], "users": []}, bypass={"roles": [], "users": []},
).save() ).commit()
await ctx.send(f"Role `{role.name}` added to roleping.") await ctx.send(f"Role `{role.name}` added to roleping.")
@cog_ext.cog_subcommand( @roleping.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role")
base="roleping", @slash_option(
name="remove", name="role", description="Role to remove", opt_type=OptionTypes.ROLE, required=True
description="Remove a role from the roleping",
options=[
create_option(
name="role",
description="Role to remove from roleping",
option_type=8,
required=True,
) )
], @check(admin_or_permissions(Permissions.MANAGE_GUILD))
) async def _roleping_remove(self, ctx: InteractionContext, role: Role) -> None:
@admin_or_permissions(manage_guild=True) roleping = await Roleping.find_one(q(guild=ctx.guild.id, role=role.id))
async def _roleping_remove(self, ctx: SlashContext, role: Role) -> None:
roleping = Roleping.objects(guild=ctx.guild.id, role=role.id)
if not roleping: if not roleping:
await ctx.send("Roleping does not exist", hidden=True) await ctx.send("Roleping does not exist", ephemeral=True)
return 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.") await ctx.send(f"Role `{role.name}` removed from roleping.")
@cog_ext.cog_subcommand( @roleping.subcommand(sub_cmd_name="list", sub_cmd_description="Lick all blocklisted roles")
base="roleping", async def _roleping_list(self, ctx: InteractionContext) -> None:
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
rolepings = Roleping.objects(guild=ctx.guild.id) rolepings = await Roleping.find(q(guild=ctx.guild.id)).to_list(None)
if not rolepings: if not rolepings:
await ctx.send("No rolepings configured", hidden=True) await ctx.send("No rolepings configured", ephemeral=True)
return return
embeds = [] embeds = []
for roleping in rolepings: for roleping in rolepings:
role = ctx.guild.get_role(roleping.role) role = await ctx.guild.fetch_role(roleping.role)
bypass_roles = list(filter(lambda x: x.id in roleping.bypass["roles"], ctx.guild.roles)) if not role:
bypass_roles = [r.mention or "||`[redacted]`||" for r in bypass_roles] await roleping.delete()
bypass_users = [ctx.guild.get_member(u).mention or "||`[redacted]`||" for u in roleping.bypass["users"]] 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_roles = bypass_roles or ["None"]
bypass_users = bypass_users or ["None"] bypass_users = bypass_users or ["None"]
embed = build_embed( embed = build_embed(
@ -105,229 +100,175 @@ class RolepingCog(CacheCog):
description=role.mention, description=role.mention,
color=str(role.color), color=str(role.color),
fields=[ fields=[
Field( EmbedField(
name="Created At", name="Created At",
value=roleping.created_at.strftime("%a, %b %d, %Y %I:%M %p"), value=roleping.created_at.strftime("%a, %b %d, %Y %I:%M %p"),
inline=False, inline=False,
), ),
Field(name="Active", value=str(roleping.active)), # EmbedField(name="Active", value=str(roleping.active), inline=True),
Field( EmbedField(
name="Bypass Users", name="Bypass Users",
value="\n".join(bypass_users), value="\n".join(bypass_users),
inline=True,
), ),
Field( EmbedField(
name="Bypass Roles", name="Bypass Roles",
value="\n".join(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: if not admin:
admin = self.bot.user admin = self.bot.user
embed.set_author(name=admin.nick or admin.name, icon_url=admin.avatar_url) embed.set_author(name=admin.display_name, icon_url=admin.display_avatar.url)
embed.set_footer(text=f"{admin.name}#{admin.discriminator} | {admin.id}") embed.set_footer(text=f"{admin.username}#{admin.discriminator} | {admin.id}")
embeds.append(embed) embeds.append(embed)
paginator = Paginator( paginator = Paginator.create_from_embeds(self.bot, *embeds, timeout=300)
bot=self.bot,
ctx=ctx, await paginator.send(ctx)
embeds=embeds,
only=ctx.author, bypass = roleping.group(
timeout=60 * 5, # 5 minute timeout name="bypass", description="Allow specific users/roles to ping rolepings"
disable_after_timeout=True,
use_extend=len(embeds) > 2,
left_button_style=ButtonStyle.grey,
right_button_style=ButtonStyle.grey,
basic_buttons=["", ""],
) )
self.cache[hash(paginator)] = { @bypass.subcommand(
"user": ctx.author.id, sub_cmd_name="user",
"guild": ctx.guild.id, sub_cmd_description="Add a user as a bypass to a roleping",
"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,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _roleping_bypass_user(self, ctx: SlashContext, user: Member, rping: Role) -> None: name="bypass", description="User to add", opt_type=OptionTypes.USER, required=True
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() )
@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: 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 return
if user.id in roleping.bypass["users"]: if bypass.id in roleping.bypass["users"]:
await ctx.send(f"{user.mention} already in bypass", hidden=True) await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True)
return return
if len(roleping.bypass["users"]) == 10: if len(roleping.bypass["users"]) == 10:
await ctx.send( await ctx.send(
"Already have 10 users in bypass. Please consider using roles for roleping bypass", "Already have 10 users in bypass. Please consider using roles for roleping bypass",
hidden=True, ephemeral=True,
) )
return 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: if matching_role:
await ctx.send( await ctx.send(
f"{user.mention} already has bypass via {matching_role[0].mention}", f"{bypass.mention} already has bypass via {matching_role[0].mention}",
hidden=True, ephemeral=True,
) )
return return
roleping.bypass["users"].append(user.id) roleping.bypass["users"].append(bypass.id)
roleping.save() await roleping.commit()
await ctx.send(f"{user.nick or user.name} user bypass added for `{rping.name}`") await ctx.send(f"{bypass.display_name} user bypass added for `{role.name}`")
@cog_ext.cog_subcommand( @bypass.subcommand(
base="roleping", sub_cmd_name="role",
subcommand_group="bypass", sub_cmd_description="Add a role as a bypass to roleping",
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,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _roleping_bypass_role(self, ctx: SlashContext, role: Role, rping: Role) -> None: name="bypass", description="Role to add", opt_type=OptionTypes.ROLE, required=True
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() )
@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: 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 return
if role.id in roleping.bypass["roles"]: if bypass.id in roleping.bypass["roles"]:
await ctx.send(f"{role.mention} already in bypass", hidden=True) await ctx.send(f"{bypass.mention} already in bypass", ephemeral=True)
return return
if len(roleping.bypass["roles"]) == 10: if len(roleping.bypass["roles"]) == 10:
await ctx.send( await ctx.send(
"Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass", "Already have 10 roles in bypass. "
hidden=True, "Please consider consolidating roles for roleping bypass",
ephemeral=True,
) )
return return
roleping.bypass["roles"].append(role.id) roleping.bypass["roles"].append(bypass.id)
roleping.save() await roleping.commit()
await ctx.send(f"{role.name} role bypass added for `{rping.name}`") await ctx.send(f"{bypass.name} role bypass added for `{role.name}`")
@cog_ext.cog_subcommand( restore = roleping.group(name="restore", description="Remove a roleping bypass")
base="roleping",
subcommand_group="restore", @restore.subcommand(
name="user", sub_cmd_name="user",
description="Remove a role bypass", sub_cmd_description="Remove a bypass from a roleping (restoring it)",
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,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _roleping_restore_user(self, ctx: SlashContext, user: Member, rping: Role) -> None: name="bypass", description="User to remove", opt_type=OptionTypes.USER, required=True
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() )
@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: 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 return
if user.id not in roleping.bypass["users"]: if bypass.id not in roleping.bypass.users:
await ctx.send(f"{user.mention} not in bypass", hidden=True) await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True)
return return
roleping.bypass["users"].delete(user.id) roleping.bypass.users.remove(bypass.id)
roleping.save() await roleping.commit()
await ctx.send(f"{user.nick or user.name} user bypass removed for `{rping.name}`") await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`")
@cog_ext.cog_subcommand( @restore.subcommand(
base="roleping", sub_cmd_name="role",
subcommand_group="restore", sub_cmd_description="Remove a bypass from a roleping (restoring it)",
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,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _roleping_restore_role(self, ctx: SlashContext, role: Role, rping: Role) -> None: name="bypass", description="Role to remove", opt_type=OptionTypes.ROLE, required=True
roleping = Roleping.objects(guild=ctx.guild.id, role=rping.id).first() )
@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: 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 return
if role.id in roleping.bypass["roles"]: if bypass.id not in roleping.bypass.roles:
await ctx.send(f"{role.mention} already in bypass", hidden=True) await ctx.send(f"{bypass.mention} not in bypass", ephemeral=True)
return return
if len(roleping.bypass["roles"]) == 10: roleping.bypass.roles.remove(bypass.id)
await ctx.send( await roleping.commit()
"Already have 10 roles in bypass. Please consider consolidating roles for roleping bypass", await ctx.send(f"{bypass.display_name} user bypass removed for `{role.name}`")
hidden=True,
)
return
roleping.bypass["roles"].append(role.id)
roleping.save()
await ctx.send(f"{role.name} role bypass added for `{rping.name}`")

View file

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

View file

@ -1,199 +1,211 @@
"""J.A.R.V.I.S. Autoreact Cog.""" """JARVIS Autoreact Cog."""
import logging
import re import re
from typing import Optional, Tuple
from discord import TextChannel from jarvis_core.db import q
from discord.ext import commands from jarvis_core.db.models import Autoreact
from discord.utils import find from naff import Client, Cog, InteractionContext, Permissions
from discord_slash import SlashContext, cog_ext from naff.client.utils.misc_utils import find
from discord_slash.utils.manage_commands import create_option 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.data.unicode import emoji_list
from jarvis.db.models import Autoreact
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class AutoReactCog(commands.Cog): class AutoReactCog(Cog):
"""J.A.R.V.I.S. Autoreact Cog.""" """JARVIS Autoreact Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
self.custom_emote = re.compile(r"^<:\w+:(\d+)>$") self.custom_emote = re.compile(r"^<:\w+:(\d+)>$")
@cog_ext.cog_subcommand( async def create_autoreact(
base="autoreact", self, ctx: InteractionContext, channel: GuildText, thread: bool
name="create", ) -> Tuple[bool, Optional[str]]:
description="Add an autoreact to a channel", """
options=[ Create an autoreact monitor on a channel.
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
_ = 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, guild=ctx.guild.id,
channel=channel.id, channel=channel.id,
reactions=[], reactions=[],
thread=thread,
admin=ctx.author.id, admin=ctx.author.id,
).save() ).commit()
await ctx.send(f"Autoreact created for {channel.mention}!")
@cog_ext.cog_subcommand( return True, None
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)
@cog_ext.cog_subcommand( async def delete_autoreact(self, ctx: InteractionContext, channel: GuildText) -> bool:
base="autoreact", """
name="add", Remove an autoreact monitor on a channel.
description="Add an autoreact emote to an existing autoreact",
options=[ Args:
create_option( 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", name="channel",
description="Autoreact channel to add emote to", description="Autoreact channel to add emote to",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
create_option(
name="emote",
description="Emote to add",
option_type=3,
required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _autoreact_add(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None: 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() await ctx.defer()
if emote:
custom_emoji = self.custom_emote.match(emote) custom_emoji = self.custom_emote.match(emote)
standard_emoji = emote in emoji_list standard_emoji = emote in emoji_list
if not custom_emoji and not standard_emoji: if not custom_emoji and not standard_emoji:
await ctx.send( await ctx.send(
"Please use either an emote from this server or a unicode emoji.", "Please use either an emote from this server or a unicode emoji.",
hidden=True, ephemeral=True,
) )
return return
if custom_emoji: if custom_emoji:
emoji_id = int(custom_emoji.group(1)) emoji_id = int(custom_emoji.group(1))
if not find(lambda x: x.id == emoji_id, ctx.guild.emojis): 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.", hidden=True) await ctx.send("Please use a custom emote from this server.", ephemeral=True)
return return
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if not exists: if not autoreact:
await ctx.send(f"Please create autoreact first with /autoreact create {channel.mention}") await self.create_autoreact(ctx, channel, thread)
return autoreact = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
if emote in exists.reactions: if emote and emote in autoreact.reactions:
await ctx.send( await ctx.send(
f"Emote already added to {channel.mention} autoreactions.", f"Emote already added to {channel.mention} autoreactions.",
hidden=True, ephemeral=True,
) )
return return
if len(exists.reactions) >= 5: if emote and len(autoreact.reactions) >= 5:
await ctx.send( await ctx.send(
"Max number of reactions hit. Remove a different one to add this one", "Max number of reactions hit. Remove a different one to add this one",
hidden=True, ephemeral=True,
) )
return return
exists.reactions.append(emote) if emote:
exists.save() autoreact.reactions.append(emote)
await ctx.send(f"Added {emote} to {channel.mention} autoreact.") 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( @autoreact.subcommand(
base="autoreact", sub_cmd_name="remove",
name="remove", sub_cmd_description="Remove an autoreact emote to a channel",
description="Remove an autoreact emote from an existing autoreact", )
options=[ @slash_option(
create_option(
name="channel", name="channel",
description="Autoreact channel to remove emote from", description="Autoreact channel to remove emote from",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
create_option(
name="emote",
description="Emote to remove",
option_type=3,
required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) @slash_option(
async def _autoreact_remove(self, ctx: SlashContext, channel: TextChannel, emote: str) -> None: name="emote",
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first() description="Emote to remove (use all to delete)",
if not exists: 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( await ctx.send(
f"Please create autoreact first with /autoreact create {channel.mention}", f"Please create autoreact first with /autoreact add {channel.mention} {emote}",
hidden=True, ephemeral=True,
) )
return 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( await ctx.send(
f"{emote} not used in {channel.mention} autoreactions.", f"{emote} not used in {channel.mention} autoreactions.",
hidden=True, ephemeral=True,
) )
return return
exists.reactions.remove(emote) else:
exists.save() 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.") await ctx.send(f"Removed {emote} from {channel.mention} autoreact.")
@cog_ext.cog_subcommand( @autoreact.subcommand(
base="autoreact", sub_cmd_name="list",
name="list", sub_cmd_description="List all autoreacts on a channel",
description="List all autoreacts on a channel", )
options=[ @slash_option(
create_option(
name="channel", name="channel",
description="Autoreact channel to list", description="Autoreact channel to list",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) async def _autoreact_list(self, ctx: InteractionContext, channel: GuildText) -> None:
async def _autoreact_list(self, ctx: SlashContext, channel: TextChannel) -> None: exists = await Autoreact.find_one(q(guild=ctx.guild.id, channel=channel.id))
exists = Autoreact.objects(guild=ctx.guild.id, channel=channel.id).first()
if not exists: if not exists:
await ctx.send( await ctx.send(
f"Please create autoreact first with /autoreact create {channel.mention}", f"Please create autoreact first with /autoreact add {channel.mention} <emote>",
hidden=True, ephemeral=True,
) )
return return
message = "" message = ""
if len(exists.reactions) > 0: 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: else:
message = f"No reactions set on {channel.mention}" message = f"No reactions set on {channel.mention}"
await ctx.send(message) await ctx.send(message)
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add AutoReactCog to J.A.R.V.I.S.""" """Add AutoReactCog to JARVIS"""
bot.add_cog(AutoReactCog(bot)) 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 import re
from datetime import datetime, timedelta
import aiohttp import aiohttp
from ButtonPaginator import Paginator from jarvis_core.db import q
from discord import Member, User from jarvis_core.db.models import Guess
from discord.ext import commands from naff import Client, Cog, InteractionContext
from discord_slash import SlashContext, cog_ext from naff.ext.paginators import Paginator
from discord_slash.model import ButtonStyle 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 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]*") valid = re.compile(r"[\w\s\-\\/.!@#$%^*()+=<>,\u0080-\U000E0FFF]*")
invites = re.compile( 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, flags=re.IGNORECASE,
) )
class CTCCog(CacheCog): class CTCCog(Cog):
"""J.A.R.V.I.S. Complete the Code 2 Cog.""" """JARVIS Complete the Code 2 Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
super().__init__(bot) self.bot = bot
self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
self.url = "https://completethecodetwo.cards/pw" self.url = "https://completethecodetwo.cards/pw"
def __del__(self): def __del__(self):
self._session.close() self._session.close()
@cog_ext.cog_subcommand( ctc2 = SlashCommand(name="ctc2", description="CTC2 related commands", scopes=guild_ids)
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")
@cog_ext.cog_subcommand( @ctc2.subcommand(sub_cmd_name="about")
base="ctc2", @cooldown(bucket=Buckets.USER, rate=1, interval=30)
name="pw", async def _about(self, ctx: InteractionContext) -> None:
description="Guess a password for https://completethecodetwo.cards", components = [
guild_ids=guild_ids, 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: if len(guess) > 800:
await ctx.send( 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 return
elif not valid.fullmatch(guess): elif not valid.fullmatch(guess):
await ctx.send( 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 return
elif invites.search(guess): elif invites.search(guess):
await ctx.send( await ctx.send(
"Listen here, dipshit. No using this to bypass sending invite links.", "Listen here, dipshit. No using this to bypass sending invite links.",
hidden=True, ephemeral=True,
) )
return return
guessed = Guess.objects(guess=guess).first() guessed = await Guess.find_one(q(guess=guess))
if guessed: if guessed:
await ctx.send("Already guessed, dipshit.", hidden=True) await ctx.send("Already guessed, dipshit.", ephemeral=True)
return return
result = await self._session.post(self.url, data=guess) result = await self._session.post(self.url, data=guess)
correct = False correct = False
if 200 <= result.status < 400: if 200 <= result.status < 400:
await ctx.send(f"{ctx.author.mention} got it! Password is {guess}!") await ctx.send(f"{ctx.author.mention} got it! Password is {guess}!")
correct = True correct = True
else: else:
await ctx.send("Nope.", hidden=True) await ctx.send("Nope.", ephemeral=True)
_ = Guess(guess=guess, user=ctx.author.id, correct=correct).save() await Guess(guess=guess, user=ctx.author.id, correct=correct).commit()
@cog_ext.cog_subcommand( @ctc2.subcommand(
base="ctc2", sub_cmd_name="guesses",
name="guesses", sub_cmd_description="Show guesses made for https://completethecodetwo.cards",
description="Show guesses made for https://completethecodetwo.cards",
guild_ids=guild_ids,
) )
@commands.cooldown(1, 2, commands.BucketType.user) @cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _guesses(self, ctx: SlashContext) -> None: async def _guesses(self, ctx: InteractionContext) -> None:
exists = self.check_cache(ctx) await ctx.defer()
if exists: guesses = Guess.find().sort("correct", -1).sort("id", -1)
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")
fields = [] fields = []
for guess in guesses: async for guess in guesses:
user = ctx.guild.get_member(guess["user"])
if not user:
user = await self.bot.fetch_user(guess["user"]) user = await self.bot.fetch_user(guess["user"])
if not user: if not user:
user = "[redacted]" user = "[redacted]"
if isinstance(user, (Member, User)): if isinstance(user, (Member, User)):
user = user.name + "#" + user.discriminator user = user.username + "#" + user.discriminator
name = "Correctly" if guess["correct"] else "Incorrectly" name = "Correctly" if guess["correct"] else "Incorrectly"
name += " guessed by: " + user name += " guessed by: " + user
fields.append( fields.append(
Field( EmbedField(
name=name, name=name,
value=guess["guess"] + "\n\u200b", value=guess["guess"] + "\n\u200b",
inline=False, inline=False,
@ -123,9 +130,9 @@ class CTCCog(CacheCog):
for i in range(0, len(fields), 5): for i in range(0, len(fields), 5):
embed = build_embed( embed = build_embed(
title="completethecodetwo.cards guesses", title="completethecodetwo.cards guesses",
description=f"{len(fields)} guesses so far", description=f"**{len(fields)} guesses so far**",
fields=fields[i : i + 5], # noqa: E203 fields=fields[i : i + 5],
url="https://completethecodetwo.cards", url="https://ctc2.zevaryx.com/gueses",
) )
embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png") embed.set_thumbnail(url="https://dev.zevaryx.com/db_logo.png")
embed.set_footer( embed.set_footer(
@ -134,30 +141,11 @@ class CTCCog(CacheCog):
) )
pages.append(embed) pages.append(embed)
paginator = Paginator( paginator = Paginator.create_from_embeds(self.bot, *pages, timeout=300)
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=["", ""],
)
self.cache[hash(paginator)] = { await paginator.send(ctx)
"guild": ctx.guild.id,
"user": ctx.author.id,
"timeout": datetime.utcnow() + timedelta(minutes=5),
"command": ctx.subcommand_name,
"paginator": paginator,
}
await paginator.start()
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add CTCCog to J.A.R.V.I.S.""" """Add CTCCog to JARVIS"""
bot.add_cog(CTCCog(bot)) CTCCog(bot)

View file

@ -1,165 +1,100 @@
"""J.A.R.V.I.S. dbrand cog.""" """JARVIS dbrand cog."""
import logging
import re import re
import aiohttp import aiohttp
from discord.ext import commands from naff import Client, Cog, InteractionContext
from discord_slash import SlashContext, cog_ext from naff.models.discord.embed import EmbedField
from discord_slash.utils.manage_commands import create_option 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.data.dbrand import shipping_lookup
from jarvis.utils import build_embed 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 Mostly support functions. Credit @cpixl for the shipping API
""" """
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
self.base_url = "https://dbrand.com/" self.base_url = "https://dbrand.com/"
self._session = aiohttp.ClientSession() self._session = aiohttp.ClientSession()
self._session.headers.update({"Content-Type": "application/json"}) 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 = {} self.cache = {}
def __del__(self): def __del__(self):
self._session.close() self._session.close()
@cog_ext.cog_subcommand( db = SlashCommand(name="db", description="dbrand commands", scopes=guild_ids)
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")
@cog_ext.cog_subcommand( @db.subcommand(sub_cmd_name="info", sub_cmd_description="Get useful links")
base="db", @cooldown(bucket=Buckets.USER, rate=1, interval=30)
name="robotcamo", async def _info(self, ctx: InteractionContext) -> None:
guild_ids=guild_ids, urls = [
description="Get some robot camo. Make Tony Stark proud", 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) embed.set_footer(
async def _camo(self, ctx: SlashContext) -> None: text="dbrand.com",
await ctx.send(self.base_url + "robot-camo") 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( @db.subcommand(
base="db", sub_cmd_name="contact",
name="grip", sub_cmd_description="Contact support",
guild_ids=guild_ids,
description="See devices with Grip support",
) )
@commands.cooldown(1, 30, commands.BucketType.channel) @cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _grip(self, ctx: SlashContext) -> None: async def _contact(self, ctx: InteractionContext) -> 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:
await ctx.send("Contact dbrand support here: " + self.base_url + "contact") await ctx.send("Contact dbrand support here: " + self.base_url + "contact")
@cog_ext.cog_subcommand( @db.subcommand(
base="db", sub_cmd_name="support",
name="support", sub_cmd_description="Contact support",
guild_ids=guild_ids,
description="Contact support",
) )
@commands.cooldown(1, 30, commands.BucketType.channel) @cooldown(bucket=Buckets.USER, rate=1, interval=30)
async def _support(self, ctx: SlashContext) -> None: async def _support(self, ctx: InteractionContext) -> None:
await ctx.send("Contact dbrand support here: " + self.base_url + "contact") await ctx.send("Contact dbrand support here: " + self.base_url + "contact")
@cog_ext.cog_subcommand( @db.subcommand(
base="db", sub_cmd_name="ship",
name="orderstat", sub_cmd_description="Get shipping information for your country",
guild_ids=guild_ids,
description="Get your order status",
) )
@commands.cooldown(1, 30, commands.BucketType.channel) @slash_option(
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(
name="search", name="search",
description="Country search query (2 character code, country name, emoji)", description="Country search query (2 character code, country name, flag emoji)",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
) )
) @cooldown(bucket=Buckets.USER, rate=1, interval=2)
], async def _shipping(self, ctx: InteractionContext, search: str) -> None:
)
@commands.cooldown(1, 2, commands.BucketType.user)
async def _shipping(self, ctx: SlashContext, search: str) -> None:
await ctx.defer() await ctx.defer()
if not re.match(r"^[A-Z- ]+$", search, re.IGNORECASE): if not re.match(r"^[A-Z- ]+$", search, re.IGNORECASE):
if re.match( if re.match(
@ -173,7 +108,6 @@ class DbrandCog(commands.Cog):
elif search == "🏳️": elif search == "🏳️":
search = "fr" search = "fr"
else: else:
print(search)
await ctx.send("Please use text to search for shipping.") await ctx.send("Please use text to search for shipping.")
return return
if len(search) > 2: if len(search) > 2:
@ -193,14 +127,14 @@ class DbrandCog(commands.Cog):
fields = None fields = None
if data is not None and data["is_valid"] and data["shipping_available"]: if data is not None and data["is_valid"] and data["shipping_available"]:
fields = [] 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:]: for service in data["shipping_services_available"][1:]:
service_data = await self._session.get(self.api_url + dest + "/" + service["url"]) service_data = await self._session.get(self.api_url + dest + "/" + service["url"])
if service_data.status > 400: if service_data.status > 400:
continue continue
service_data = await service_data.json() service_data = await service_data.json()
fields.append( fields.append(
Field( EmbedField(
service_data["short-name"], service_data["short-name"],
service_data["time-title"], service_data["time-title"],
) )
@ -230,7 +164,8 @@ class DbrandCog(commands.Cog):
embed = build_embed( embed = build_embed(
title="Check Shipping Times", title="Check Shipping Times",
description=( 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=[], fields=[],
url="https://dbrand.com/shipping", url="https://dbrand.com/shipping",
@ -262,6 +197,6 @@ class DbrandCog(commands.Cog):
await ctx.send(embed=embed) await ctx.send(embed=embed)
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add dbrandcog to J.A.R.V.I.S.""" """Add dbrandcog to JARVIS"""
bot.add_cog(DbrandCog(bot)) DbrandCog(bot)

View file

@ -1,26 +1,38 @@
"""J.A.R.V.I.S. Developer Cog.""" """JARVIS Developer Cog."""
import base64 import base64
import hashlib import hashlib
import logging
import re import re
import subprocess # noqa: S404 import subprocess # noqa: S404
import uuid as uuidpy import uuid as uuidpy
from typing import Any, Union
import ulid as ulidpy import ulid as ulidpy
from bson import ObjectId from bson import ObjectId
from discord.ext import commands from jarvis_core.filters import invites, url
from discord_slash import SlashContext, cog_ext from jarvis_core.util import convert_bytesize, hash
from discord_slash.utils.manage_commands import create_choice, create_option 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 import build_embed
from jarvis.utils.field import Field
supported_hashes = {x for x in hashlib.algorithms_guaranteed if "shake" not in x} 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}$") 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( 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) ULID_VERIFY = re.compile(r"^[0-9a-z]{26}$", re.IGNORECASE)
UUID_VERIFY = re.compile( UUID_VERIFY = re.compile(
@ -28,102 +40,99 @@ UUID_VERIFY = re.compile(
re.IGNORECASE, 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} UUID_GET = {3: uuidpy.uuid3, 5: uuidpy.uuid5}
MAX_FILESIZE = 5 * (1024**3) # 5GB
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()
class DevCog(commands.Cog): class DevCog(Cog):
"""J.A.R.V.I.S. Developer Cog.""" """JARVIS Developer Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash( @slash_command(name="hash", description="Hash some data")
name="hash", @slash_option(
description="Hash some data",
options=[
create_option(
name="method", name="method",
description="Hash method", description="Hash method",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
choices=[create_choice(name=x, value=x) for x in supported_hashes], choices=[SlashCommandChoice(name=x, value=x) for x in supported_hashes],
), )
create_option( @slash_option(
name="data", name="data",
description="Data to hash", description="Data to hash",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=False,
),
],
) )
@commands.cooldown(1, 2, commands.BucketType.user) @slash_option(
async def _hash(self, ctx: SlashContext, method: str, data: str) -> None: name="attach", description="File to hash", opt_type=OptionTypes.ATTACHMENT, required=False
if not data: )
@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( await ctx.send(
"No data to hash", "No data to hash",
hidden=True, ephemeral=True,
) )
return return
text = True if data and invites.match(data):
# Default to sha256, just in case await ctx.send("No hashing invites", ephemeral=True)
hash = getattr(hashlib, method, hashlib.sha256)() return
hex = hash_obj(hash, data, text) title = data
data_size = convert_bytesize(len(data)) if attach:
title = data if text else ctx.message.attachments[0].filename 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 description = "Hashed using " + method
fields = [ fields = [
Field("Data Size", data_size, False), EmbedField("Content Type", c_type, False),
Field("Hash", f"`{hex}`", False), EmbedField("Data Size", data_size, False),
EmbedField("Hash", f"`{hexstr}`", False),
] ]
embed = build_embed(title=title, description=description, fields=fields) embed = build_embed(title=title, description=description, fields=fields)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(name="uuid", description="Generate a UUID")
name="uuid", @slash_option(
description="Generate a UUID",
options=[
create_option(
name="version", name="version",
description="UUID version", description="UUID version",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
choices=[create_choice(name=x, value=x) for x in ["3", "4", "5"]], choices=[SlashCommandChoice(name=x, value=x) for x in ["3", "4", "5"]],
), )
create_option( @slash_option(
name="data", name="data",
description="Data for UUID version 3,5", description="Data for UUID version 3,5",
option_type=3, opt_type=OptionTypes.STRING,
required=False, 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) version = int(version)
if version in [3, 5] and not data: 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 return
if version == 4: if version == 4:
await ctx.send(f"UUID4: `{uuidpy.uuid4()}`") await ctx.send(f"UUID4: `{uuidpy.uuid4()}`")
@ -139,40 +148,46 @@ class DevCog(commands.Cog):
to_send = UUID_GET[version](uuidpy.NAMESPACE_DNS, data) to_send = UUID_GET[version](uuidpy.NAMESPACE_DNS, data)
await ctx.send(f"UUID{version}: `{to_send}`") await ctx.send(f"UUID{version}: `{to_send}`")
@cog_ext.cog_slash( @slash_command(
name="objectid", name="objectid",
description="Generate an ObjectID", description="Generate an ObjectID",
) )
@commands.cooldown(1, 2, commands.BucketType.user) @cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _objectid(self, ctx: SlashContext) -> None: async def _objectid(self, ctx: InteractionContext) -> None:
await ctx.send(f"ObjectId: `{str(ObjectId())}`") await ctx.send(f"ObjectId: `{str(ObjectId())}`")
@cog_ext.cog_slash( @slash_command(
name="ulid", name="ulid",
description="Generate a ULID", description="Generate a ULID",
) )
@commands.cooldown(1, 2, commands.BucketType.user) @cooldown(bucket=Buckets.USER, rate=1, interval=2)
async def _ulid(self, ctx: SlashContext) -> None: async def _ulid(self, ctx: InteractionContext) -> None:
await ctx.send(f"ULID: `{ulidpy.new().str}`") await ctx.send(f"ULID: `{ulidpy.new().str}`")
@cog_ext.cog_slash( @slash_command(
name="uuid2ulid", name="uuid2ulid",
description="Convert a UUID to a ULID", description="Convert a UUID to a ULID",
) )
@commands.cooldown(1, 2, commands.BucketType.user) @slash_option(
async def _uuid2ulid(self, ctx: SlashContext, uuid: str) -> None: 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): if UUID_VERIFY.match(uuid):
u = ulidpy.parse(uuid) u = ulidpy.parse(uuid)
await ctx.send(f"ULID: `{u.str}`") await ctx.send(f"ULID: `{u.str}`")
else: else:
await ctx.send("Invalid UUID") await ctx.send("Invalid UUID")
@cog_ext.cog_slash( @slash_command(
name="ulid2uuid", name="ulid2uuid",
description="Convert a ULID to a UUID", description="Convert a ULID to a UUID",
) )
@commands.cooldown(1, 2, commands.BucketType.user) @slash_option(
async def _ulid2uuid(self, ctx: SlashContext, ulid: str) -> None: 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): if ULID_VERIFY.match(ulid):
ulid = ulidpy.parse(ulid) ulid = ulidpy.parse(ulid)
await ctx.send(f"UUID: `{ulid.uuid}`") await ctx.send(f"UUID: `{ulid.uuid}`")
@ -181,82 +196,85 @@ class DevCog(commands.Cog):
base64_methods = ["b64", "b16", "b32", "a85", "b85"] base64_methods = ["b64", "b16", "b32", "a85", "b85"]
@cog_ext.cog_slash( @slash_command(name="encode", description="Encode some data")
name="encode", @slash_option(
description="Encode some data",
options=[
create_option(
name="method", name="method",
description="Encode method", description="Encode method",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
choices=[create_choice(name=x, value=x) for x in base64_methods], choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods],
), )
create_option( @slash_option(
name="data", name="data",
description="Data to encode", description="Data to encode",
option_type=3, opt_type=OptionTypes.STRING,
required=True, 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 mstr = method
method = getattr(base64, method + "encode") method = getattr(base64, method + "encode")
try:
encoded = method(data.encode("UTF-8")).decode("UTF-8") encoded = method(data.encode("UTF-8")).decode("UTF-8")
except Exception as e:
await ctx.send(f"Failed to encode data: {e}")
return
fields = [ fields = [
Field(name="Plaintext", value=f"`{data}`", inline=False), EmbedField(name="Plaintext", value=f"`{data}`", inline=False),
Field(name=mstr, value=f"`{encoded}`", inline=False), EmbedField(name=mstr, value=f"`{encoded}`", inline=False),
] ]
embed = build_embed(title="Decoded Data", description="", fields=fields) embed = build_embed(title="Decoded Data", description="", fields=fields)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(name="decode", description="Decode some data")
name="decode", @slash_option(
description="Decode some data",
options=[
create_option(
name="method", name="method",
description="Decode method", description="Decode method",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
choices=[create_choice(name=x, value=x) for x in base64_methods], choices=[SlashCommandChoice(name=x, value=x) for x in base64_methods],
), )
create_option( @slash_option(
name="data", name="data",
description="Data to encode", description="Data to encode",
option_type=3, opt_type=OptionTypes.STRING,
required=True, 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 mstr = method
method = getattr(base64, method + "decode") method = getattr(base64, method + "decode")
try:
decoded = method(data.encode("UTF-8")).decode("UTF-8") 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): if invites.search(decoded):
await ctx.send( await ctx.send(
"Please don't use this to bypass invite restrictions", "Please don't use this to bypass invite restrictions",
hidden=True, ephemeral=True,
) )
return return
fields = [ fields = [
Field(name="Plaintext", value=f"`{data}`", inline=False), EmbedField(name="Plaintext", value=f"`{data}`", inline=False),
Field(name=mstr, value=f"`{decoded}`", inline=False), EmbedField(name=mstr, value=f"`{decoded}`", inline=False),
] ]
embed = build_embed(title="Decoded Data", description="", fields=fields) embed = build_embed(title="Decoded Data", description="", fields=fields)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(name="cloc", description="Get JARVIS lines of code")
name="cloc", @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
description="Get J.A.R.V.I.S. lines of code", async def _cloc(self, ctx: InteractionContext) -> None:
) output = subprocess.check_output( # noqa: S603, S607
@commands.cooldown(1, 30, commands.BucketType.channel) ["tokei", "-C", "--sort", "code"]
async def _cloc(self, ctx: SlashContext) -> None: ).decode("UTF-8")
output = subprocess.check_output(["tokei", "-C", "--sort", "code"]).decode("UTF-8") # noqa: S603, S607 await ctx.send(f"```haskell\n{output}\n```")
await ctx.send(f"```\n{output}\n```")
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add DevCog to J.A.R.V.I.S.""" """Add DevCog to JARVIS"""
bot.add_cog(DevCog(bot)) 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 import re
from io import BytesIO from io import BytesIO
import aiohttp import aiohttp
import cv2 import cv2
import numpy as np import numpy as np
from discord import File from jarvis_core.util import convert_bytesize, unconvert_bytesize
from discord.ext import commands 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 import build_embed
from jarvis.utils.field import Field
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 May be categorized under util later
""" """
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
self._session = aiohttp.ClientSession() 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): def __del__(self):
self._session.close() self._session.close()
async def _resize(self, ctx: commands.Context, target: str, url: str = None) -> None: @slash_command(name="resize", description="Resize an image")
if not target: @slash_option(
await ctx.send("Missing target size, i.e. 200KB.") 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 return
tgt = self.tgt_match.match(target) tgt = self.tgt_match.match(target)
if not tgt: 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 return
try:
tgt_size = unconvert_bytesize(float(tgt.groups()[0]), tgt.groups()[1]) tgt_size = unconvert_bytesize(float(tgt.groups()[0]), tgt.groups()[1])
if tgt_size > unconvert_bytesize(8, "MB"): except ValueError:
await ctx.send("Target too large to send. Please make target < 8MB") await ctx.send("Failed to read your target size. Try a more sane one", ephemeral=True)
return 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: if size <= tgt_size:
await ctx.send("Image already meets target.") await ctx.send("Image already meets target.", ephemeral=True)
return return
ratio = max(tgt_size / size - 0.02, 0.50) ratio = max(tgt_size / size - 0.02, 0.50)
accuracy = 0.0 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) img = cv2.imdecode(buffer, flags=-1)
width = int(img.shape[1] * ratio) width = int(img.shape[1] * ratio)
height = int(img.shape[0] * ratio) height = int(img.shape[0] * ratio)
new_img = cv2.resize(img, (width, height)) new_img = cv2.resize(img, (width, height))
file = cv2.imencode(".png", new_img)[1].tobytes() data = cv2.imencode(".png", new_img)[1].tobytes()
accuracy = (len(file) / tgt_size) * 100 accuracy = (len(data) / tgt_size) * 100
if accuracy <= 0.50: if accuracy <= 0.50:
file = old_file data = old_file
ratio += 0.1 ratio += 0.1
else: else:
ratio = max(tgt_size / len(file) - 0.02, 0.65) ratio = max(tgt_size / len(data) - 0.02, 0.65)
bufio = BytesIO(file) bufio = BytesIO(data)
accuracy = (len(file) / tgt_size) * 100 accuracy = (len(data) / tgt_size) * 100
fields = [ fields = [
Field("Original Size", convert_bytesize(size), False), EmbedField("Original Size", convert_bytesize(size), False),
Field("New Size", convert_bytesize(len(file)), False), EmbedField("New Size", convert_bytesize(len(data)), False),
Field("Accuracy", f"{accuracy:.02f}%", False), EmbedField("Accuracy", f"{accuracy:.02f}%", False),
] ]
embed = build_embed(title=filename, description="", fields=fields) embed = build_embed(title=filename, description="", fields=fields)
embed.set_image(url="attachment://resized.png") embed.set_image(url="attachment://resized.png")
await ctx.send( await ctx.send(
embed=embed, 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: Client) -> None:
def setup(bot: commands.Bot) -> None: """Add ImageCog to JARVIS"""
"""Add ImageCog to J.A.R.V.I.S.""" ImageCog(bot)
bot.add_cog(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 asyncio
import logging
import re import re
from datetime import datetime, timedelta from datetime import datetime, timezone
from typing import List, Optional from typing import List
from bson import ObjectId from bson import ObjectId
from discord import Embed from dateparser import parse
from discord.ext.commands import Bot from dateparser_data.settings import default_parsers
from discord.ext.tasks import loop from jarvis_core.db import q
from discord_slash import SlashContext, cog_ext from jarvis_core.db.models import Reminder
from discord_slash.utils.manage_commands import create_option from naff import Client, Cog, InteractionContext
from discord_slash.utils.manage_components import ( from naff.client.utils.misc_utils import get
create_actionrow, from naff.models.discord.channel import GuildChannel
create_select, from naff.models.discord.components import ActionRow, Select, SelectOption
create_select_option, from naff.models.discord.embed import Embed, EmbedField
wait_for_component, 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 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( 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, flags=re.IGNORECASE,
) )
class RemindmeCog(CacheCog): class RemindmeCog(Cog):
"""J.A.R.V.I.S. Remind Me Cog.""" """JARVIS Remind Me Cog."""
def __init__(self, bot: Bot): def __init__(self, bot: Client):
super().__init__(bot) self.bot = bot
self._remind.start() self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash( @slash_command(name="remindme", description="Set a reminder")
name="remindme", @slash_option(
description="Set a reminder", name="private",
options=[ description="Send as DM?",
create_option( opt_type=OptionTypes.BOOLEAN,
name="message",
description="What to remind you of",
option_type=3,
required=True,
),
create_option(
name="weeks",
description="Number of weeks?",
option_type=4,
required=False, 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( async def _remindme(
self, self,
ctx: SlashContext, ctx: InteractionContext,
message: Optional[str] = None, private: bool = False,
weeks: Optional[int] = 0,
days: Optional[int] = 0,
hours: Optional[int] = 0,
minutes: Optional[int] = 0,
) -> None: ) -> None:
if len(message) > 100: reminders = len([x async for x in Reminder.find(q(user=ctx.author.id, active=True))])
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()
if reminders >= 5: if reminders >= 5:
await ctx.send( await ctx.send(
"You already have 5 (or more) active reminders. " "You already have 5 (or more) active reminders. "
"Please either remove an old one, or wait for one to pass", "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 return
remind_at = datetime.utcnow() + timedelta( if remind_at < datetime.now(tz=timezone.utc):
weeks=weeks, await response.send(
days=days, f"`{delay}` is in the past. Past reminders aren't allowed", ephemeral=True
hours=hours,
minutes=minutes,
) )
return
_ = Reminder( elif remind_at < datetime.now(tz=timezone.utc):
user=ctx.author_id, pass
r = Reminder(
user=ctx.author.id,
channel=ctx.channel.id, channel=ctx.channel.id,
guild=ctx.guild.id, guild=ctx.guild.id,
message=message, message=message,
remind_at=remind_at, remind_at=remind_at,
private=private,
active=True, active=True,
).save() )
await r.commit()
embed = build_embed( embed = build_embed(
title="Reminder Set", title="Reminder Set",
description=f"{ctx.author.mention} set a reminder", description=f"{ctx.author.mention} set a reminder",
fields=[ fields=[
Field(name="Message", value=message), EmbedField(name="Message", value=message),
Field( EmbedField(
name="When", name="When",
value=remind_at.strftime("%Y-%m-%d %H:%M UTC"), value=f"<t:{int(remind_at.timestamp())}:F>",
inline=False, inline=False,
), ),
], ],
) )
embed.set_author( embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator, name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url, 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.""" """Build embed for paginator."""
fields = [] fields = []
for reminder in reminders: 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( fields.append(
Field( EmbedField(
name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), name=f"<t:{int(reminder.remind_at.timestamp())}:F>",
value=f"{reminder.message}\n\u200b", value=f"{reminder.message}\n\u200b",
inline=False, inline=False,
) )
@ -184,57 +198,43 @@ class RemindmeCog(CacheCog):
) )
embed.set_author( embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator, name=ctx.author.username + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url, 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 return embed
@cog_ext.cog_subcommand( reminders = SlashCommand(name="reminders", description="Manage reminders")
base="reminders",
name="list", @reminders.subcommand(sub_cmd_name="list", sub_cmd_description="List reminders")
description="List reminders for a user", async def _list(self, ctx: InteractionContext) -> None:
) reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None)
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)
if not reminders: if not reminders:
await ctx.send("You have no reminders set.", hidden=True) await ctx.send("You have no reminders set.", ephemeral=True)
return return
embed = await self.get_reminders_embed(ctx, reminders) embed = await self.get_reminders_embed(ctx, reminders)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_subcommand( @reminders.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a reminder")
base="reminders", async def _delete(self, ctx: InteractionContext) -> None:
name="delete", reminders = await Reminder.find(q(user=ctx.author.id, active=True)).to_list(None)
description="Delete a reminder",
)
async def _delete(self, ctx: SlashContext) -> None:
reminders = Reminder.objects(user=ctx.author.id, active=True)
if not reminders: if not reminders:
await ctx.send("You have no reminders set", hidden=True) await ctx.send("You have no reminders set", ephemeral=True)
return return
options = [] options = []
for reminder in reminders: for reminder in reminders:
option = create_select_option( option = SelectOption(
label=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), label=f"{reminder.remind_at}",
value=str(reminder.id), value=str(reminder.id),
emoji="", emoji="",
) )
options.append(option) options.append(option)
select = create_select( select = Select(
options=options, options=options,
custom_id="to_delete", custom_id="to_delete",
placeholder="Select reminders to delete", placeholder="Select reminders to delete",
@ -242,7 +242,7 @@ class RemindmeCog(CacheCog):
max_values=len(reminders), max_values=len(reminders),
) )
components = [create_actionrow(select)] components = [ActionRow(select)]
embed = await self.get_reminders_embed(ctx, reminders) embed = await self.get_reminders_embed(ctx, reminders)
message = await ctx.send( message = await ctx.send(
content=f"You have {len(reminders)} reminder(s) set:", content=f"You have {len(reminders)} reminder(s) set:",
@ -251,28 +251,40 @@ class RemindmeCog(CacheCog):
) )
try: try:
context = await wait_for_component( context = await self.bot.wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.context.author.id,
check=lambda x: ctx.author.id == x.author_id,
messages=message, messages=message,
timeout=60 * 5, 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 = [] 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( fields.append(
Field( EmbedField(
name=reminder.remind_at.strftime("%Y-%m-%d %H:%M UTC"), 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, value=reminder.message,
inline=False, 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( embed = build_embed(
title="Deleted Reminder(s)", title="Deleted Reminder(s)",
description="", description="",
@ -280,52 +292,50 @@ class RemindmeCog(CacheCog):
) )
embed.set_author( embed.set_author(
name=ctx.author.name + "#" + ctx.author.discriminator, name=ctx.author.display_name + "#" + ctx.author.discriminator,
icon_url=ctx.author.avatar_url, 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( await context.context.edit_origin(
content=f"Deleted {len(context.selected_options)} reminder(s)", content=f"Deleted {len(context.context.values)} reminder(s)",
components=components, components=components,
embed=embed, embed=embed,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
await message.edit(components=components) await message.edit(components=components)
@loop(seconds=15) @reminders.subcommand(
async def _remind(self) -> None: sub_cmd_name="fetch",
reminders = Reminder.objects(remind_at__lte=datetime.utcnow() + timedelta(seconds=30)) sub_cmd_description="Fetch a reminder that failed to send",
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=[],
) )
@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( embed.set_author(
name=user.name + "#" + user.discriminator, name=ctx.author.display_name + "#" + ctx.author.discriminator,
icon_url=user.avatar_url, 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: try:
await user.send(embed=embed) await reminder.delete()
except Exception: except Exception:
guild = self.bot.fetch_guild(reminder.guild) self.logger.debug("Ignoring deletion error")
channel = guild.get_channel(reminder.channel) if guild else None
if channel:
await channel.send(f"{user.mention}", embed=embed)
finally:
reminder.delete()
def setup(bot: Bot) -> None: def setup(bot: Client) -> None:
"""Add RemindmeCog to J.A.R.V.I.S.""" """Add RemindmeCog to JARVIS"""
bot.add_cog(RemindmeCog(bot)) RemindmeCog(bot)

View file

@ -1,64 +1,75 @@
"""J.A.R.V.I.S. Role Giver Cog.""" """JARVIS Role Giver Cog."""
import asyncio import asyncio
import logging
from discord import Role from jarvis_core.db import q
from discord.ext import commands from jarvis_core.db.models import Rolegiver
from discord_slash import SlashContext, cog_ext from naff import Client, Cog, InteractionContext, Permissions
from discord_slash.utils.manage_commands import create_option from naff.client.utils.misc_utils import get
from discord_slash.utils.manage_components import ( from naff.models.discord.components import ActionRow, Select, SelectOption
create_actionrow, from naff.models.discord.embed import EmbedField
create_select, from naff.models.discord.role import Role
create_select_option, from naff.models.naff.application_commands import (
wait_for_component, 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 import build_embed
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class RolegiverCog(commands.Cog): class RolegiverCog(Cog):
"""J.A.R.V.I.S. Role Giver Cog.""" """JARVIS Role Giver Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand( rolegiver = SlashCommand(name="rolegiver", description="Allow users to choose their own roles")
base="rolegiver",
name="add", @rolegiver.subcommand(
description="Add a role to rolegiver", sub_cmd_name="add",
options=[ sub_cmd_description="Add a role to rolegiver",
create_option(
name="role",
description="Role to add",
option_type=8,
required=True,
) )
], @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) return
async def _rolegiver_add(self, ctx: SlashContext, role: Role) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first() setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if setting and role.id in setting.roles: if setting and setting.roles and role.id in setting.roles:
await ctx.send("Role already in rolegiver", hidden=True) await ctx.send("Role already in rolegiver", ephemeral=True)
return return
if not setting: if not setting:
setting = Rolegiver(guild=ctx.guild.id, roles=[]) setting = Rolegiver(guild=ctx.guild.id, roles=[])
setting.roles = setting.roles or []
if len(setting.roles) >= 20: 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 return
setting.roles.append(role.id) setting.roles.append(role.id)
setting.save() await setting.commit()
roles = [] roles = []
for role_id in setting.roles: for role_id in setting.roles:
if role_id == role.id: if role_id == role.id:
continue continue
e_role = ctx.guild.get_role(role_id) e_role = await ctx.guild.fetch_role(role_id)
if not e_role: if not e_role:
continue continue
roles.append(e_role) 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" value = "\n".join([r.mention for r in roles]) if roles else "None"
fields = [ fields = [
Field(name="New Role", value=f"{role.mention}"), EmbedField(name="New Role", value=f"{role.mention}"),
Field(name="Existing Role(s)", value=value), EmbedField(name="Existing Role(s)", value=value),
] ]
embed = build_embed( embed = build_embed(
@ -77,61 +88,58 @@ class RolegiverCog(commands.Cog):
fields=fields, fields=fields,
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author( embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.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) await ctx.send(embed=embed)
@cog_ext.cog_subcommand( @rolegiver.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role from rolegiver")
base="rolegiver", @check(admin_or_permissions(Permissions.MANAGE_GUILD))
name="remove", async def _rolegiver_remove(self, ctx: InteractionContext) -> None:
description="Remove a role from rolegiver", setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
)
@admin_or_permissions(manage_guild=True)
async def _rolegiver_remove(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
if not setting or (setting and not setting.roles): 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 return
options = [] options = []
for role in setting.roles: for role in setting.roles:
role: Role = ctx.guild.get_role(role) role: Role = await ctx.guild.fetch_role(role)
option = create_select_option(label=role.name, value=str(role.id)) option = SelectOption(label=role.name, value=str(role.id))
options.append(option) options.append(option)
select = create_select( select = Select(
options=options, options=options,
custom_id="to_delete", custom_id="to_delete",
placeholder="Select roles to remove", placeholder="Select roles to remove",
min_values=1, min_values=1,
max_values=len(options), max_values=len(options),
) )
components = [create_actionrow(select)] components = [ActionRow(select)]
message = await ctx.send(content="\u200b", components=components) message = await ctx.send(content="\u200b", components=components)
try: try:
context = await wait_for_component( context = await self.bot.wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.context.author.id,
check=lambda x: ctx.author.id == x.author.id, messages=message,
message=message,
timeout=60 * 1, 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.roles.remove(int(to_delete))
setting.save() await setting.commit()
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
roles = [] roles = []
for role_id in setting.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: if not e_role:
continue continue
roles.append(e_role) roles.append(e_role)
@ -140,9 +148,10 @@ class RolegiverCog(commands.Cog):
roles.sort(key=lambda x: -x.position) roles.sort(key=lambda x: -x.position)
value = "\n".join([r.mention for r in roles]) if roles else "None" 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 = [ fields = [
Field(name="Removed Role", value=f"{role.mention}"), EmbedField(name="Removed Role(s)", value=rvalue),
Field(name="Remaining Role(s)", value=value), EmbedField(name="Remaining Role(s)", value=value),
] ]
embed = build_embed( embed = build_embed(
@ -151,39 +160,34 @@ class RolegiverCog(commands.Cog):
fields=fields, fields=fields,
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author( embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.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.context.edit_origin(
content=f"Removed {len(context.context.values)} role(s)",
await context.edit_origin(
content=f"Removed {len(context.selected_options)} role(s)",
embed=embed, embed=embed,
components=components, components=components,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
await message.edit(components=components) await message.edit(components=components)
@cog_ext.cog_subcommand( @rolegiver.subcommand(sub_cmd_name="list", sub_cmd_description="List rolegiver roles")
base="rolegiver", async def _rolegiver_list(self, ctx: InteractionContext) -> None:
name="list", setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
description="List roles rolegiver",
)
async def _rolegiver_list(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
if not setting or (setting and not setting.roles): 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 return
roles = [] roles = []
for role_id in setting.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: if not e_role:
continue continue
roles.append(e_role) roles.append(e_role)
@ -199,59 +203,54 @@ class RolegiverCog(commands.Cog):
fields=[], fields=[],
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author( embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name, name=ctx.author.display_name,
icon_url=ctx.author.avatar_url, 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) await ctx.send(embed=embed)
@cog_ext.cog_subcommand( role = SlashCommand(name="role", description="Get/Remove Rolegiver roles")
base="role",
name="get", @role.subcommand(sub_cmd_name="get", sub_cmd_description="Get a role")
description="Get a role from rolegiver", @cooldown(bucket=Buckets.USER, rate=1, interval=10)
) async def _role_get(self, ctx: InteractionContext) -> None:
@commands.cooldown(1, 10, commands.BucketType.user) setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
async def _role_get(self, ctx: SlashContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first()
if not setting or (setting and not setting.roles): 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 return
options = [] options = []
for role in setting.roles: for role in setting.roles:
role: Role = ctx.guild.get_role(role) role: Role = await ctx.guild.fetch_role(role)
option = create_select_option(label=role.name, value=str(role.id)) option = SelectOption(label=role.name, value=str(role.id))
options.append(option) options.append(option)
select = create_select( select = Select(
options=options, options=options,
custom_id="to_delete",
placeholder="Select roles to add", placeholder="Select roles to add",
min_values=1, min_values=1,
max_values=len(options), max_values=len(options),
) )
components = [create_actionrow(select)] components = [ActionRow(select)]
message = await ctx.send(content="\u200b", components=components) message = await ctx.send(content="\u200b", components=components)
try: try:
context = await self.bot.wait_for_component(
context = await wait_for_component( check=lambda x: ctx.author.id == x.context.author.id,
self.bot,
check=lambda x: ctx.author.id == x.author.id,
messages=message, messages=message,
timeout=60 * 5, timeout=60 * 5,
) )
added_roles = [] added_roles = []
for role in context.selected_options: for role in context.context.values:
role = ctx.guild.get_role(int(role)) role = await ctx.guild.fetch_role(int(role))
added_roles.append(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 roles = ctx.author.roles
if 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" 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" value = "\n".join([r.mention for r in roles]) if roles else "None"
fields = [ fields = [
Field(name="Added Role(s)", value=avalue), EmbedField(name="Added Role(s)", value=avalue),
Field(name="Prior Role(s)", value=value), EmbedField(name="Prior Role(s)", value=value),
] ]
embed = build_embed( embed = build_embed(
title="User Given Role", 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, fields=fields,
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author( embed.set_author(
name=ctx.author.nick if ctx.author.nick else ctx.author.name, name=ctx.author.display_name,
icon_url=ctx.author.avatar_url, icon_url=ctx.author.display_avatar.url,
) )
embed.set_footer(text=f"{ctx.author.name}#{ctx.author.discriminator} | {ctx.author.id}") embed.set_footer(
for row in components: text=f"{ctx.author.username}#{ctx.author.discriminator} | {ctx.author.id}"
for component in row["components"]: )
component["disabled"] = True
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: except asyncio.TimeoutError:
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
await message.edit(components=components) await message.edit(components=components)
@cog_ext.cog_subcommand( @role.subcommand(sub_cmd_name="remove", sub_cmd_description="Remove a role")
base="role", @cooldown(bucket=Buckets.USER, rate=1, interval=10)
name="forfeit", async def _role_remove(self, ctx: InteractionContext) -> None:
description="Have rolegiver take away role", user_roles = ctx.author.roles
options=[
create_option( setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
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()
if not setting or (setting and not setting.roles): 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 return
elif role.id not in setting.roles: elif not any(x.id in setting.roles for x in user_roles):
await ctx.send("Role not in rolegiver", hidden=True) await ctx.send("You have no rolegiver roles", ephemeral=True)
return
elif role not in ctx.author.roles:
await ctx.send("You do not have that role", hidden=True)
return 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 select = Select(
if roles: options=options,
roles.sort(key=lambda x: -x.position) custom_id="to_remove",
_ = roles.pop(-1) 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 = [ fields = [
Field(name="Taken Role", value=f"{role.mention}"), EmbedField(name="Removed Role(s)", value=rvalue),
Field(name="Remaining Role(s)", value=value), EmbedField(name="Remaining Role(s)", value=value),
] ]
embed = build_embed( embed = build_embed(
title="User Forfeited Role", 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, fields=fields,
) )
embed.set_thumbnail(url=ctx.guild.icon_url) embed.set_thumbnail(url=ctx.guild.icon.url)
embed.set_author( embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.display_avatar.url)
name=ctx.author.nick if ctx.author.nick else ctx.author.name,
icon_url=ctx.author.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( except asyncio.TimeoutError:
base="rolegiver", for row in components:
name="cleanup", for component in row.components:
description="Cleanup rolegiver roles", 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) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _rolegiver_cleanup(self, ctx: SlashContext) -> None: async def _rolegiver_cleanup(self, ctx: InteractionContext) -> None:
setting = Rolegiver.objects(guild=ctx.guild.id).first() setting = await Rolegiver.find_one(q(guild=ctx.guild.id))
if not setting or not setting.roles: if not setting or not setting.roles:
await ctx.send("Rolegiver has no roles", hidden=True) await ctx.send("Rolegiver has no roles", ephemeral=True)
guild_roles = await ctx.guild.fetch_roles() guild_role_ids = [r.id for r in ctx.guild.roles]
guild_role_ids = [x.id for x in guild_roles]
for role_id in setting.roles: for role_id in setting.roles:
if role_id not in guild_role_ids: if role_id not in guild_role_ids:
setting.roles.remove(role_id) setting.roles.remove(role_id)
setting.save() await setting.commit()
await ctx.send("Rolegiver cleanup finished") await ctx.send("Rolegiver cleanup finished")
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add RolegiverCog to J.A.R.V.I.S.""" """Add RolegiverCog to JARVIS"""
bot.add_cog(RolegiverCog(bot)) 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 typing import Any
from discord import Role, TextChannel from jarvis_core.db import q
from discord.ext import commands from jarvis_core.db.models import Setting
from discord.utils import find from naff import Client, Cog, InteractionContext
from discord_slash import SlashContext, cog_ext from naff.models.discord.channel import GuildText
from discord_slash.utils.manage_commands import create_option 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 import build_embed
from jarvis.utils.field import Field
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
class SettingsCog(commands.Cog): class SettingsCog(Cog):
"""J.A.R.V.I.S. Settings Management Cog.""" """JARVIS Settings Management Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot 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.""" """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: if not existing:
existing = Setting(setting=setting, guild=guild, value=value) existing = Setting(setting=setting, guild=guild, value=value)
existing.value = value existing.value = value
updated = existing.save() updated = await existing.commit()
return updated is not None 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.""" """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( settings = SlashCommand(name="settings", description="Control guild settings")
base="settings", set_ = settings.group(name="set", description="Set a setting")
base_desc="Settings management", unset = settings.group(name="unset", description="Unset a setting")
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}`")
@cog_ext.cog_subcommand( @set_.subcommand(
base="settings", sub_cmd_name="modlog",
subcommand_group="set", sub_cmd_description="Set Moglod channel",
name="modlog",
description="Set modlog channel",
options=[
create_option(
name="channel",
description="Modlog channel",
option_type=7,
required=True,
) )
], @slash_option(
name="channel", description="ModLog Channel", opt_type=OptionTypes.CHANNEL, required=True
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _set_modlog(self, ctx: SlashContext, channel: TextChannel) -> None: async def _set_modlog(self, ctx: InteractionContext, channel: GuildText) -> None:
if not isinstance(channel, TextChannel): if not isinstance(channel, GuildText):
await ctx.send("Channel must be a TextChannel", hidden=True) await ctx.send("Channel must be a GuildText", ephemeral=True)
return 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}") await ctx.send(f"Settings applied. New modlog channel is {channel.mention}")
@cog_ext.cog_subcommand( @set_.subcommand(
base="settings", sub_cmd_name="activitylog",
subcommand_group="set", sub_cmd_description="Set Activitylog channel",
name="userlog", )
description="Set userlog channel", @slash_option(
options=[
create_option(
name="channel", name="channel",
description="Userlog channel", description="Activitylog Channel",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
) )
], @check(admin_or_permissions(Permissions.MANAGE_GUILD))
) async def _set_activitylog(self, ctx: InteractionContext, channel: GuildText) -> None:
@admin_or_permissions(manage_guild=True) if not isinstance(channel, GuildText):
async def _set_userlog(self, ctx: SlashContext, channel: TextChannel) -> None: await ctx.send("Channel must be a GuildText", ephemeral=True)
if not isinstance(channel, TextChannel):
await ctx.send("Channel must be a TextChannel", hidden=True)
return return
self.update_settings("userlog", channel.id, ctx.guild.id) await self.update_settings("activitylog", channel.id, ctx.guild.id)
await ctx.send(f"Settings applied. New userlog channel is {channel.mention}") await ctx.send(f"Settings applied. New activitylog channel is {channel.mention}")
@cog_ext.cog_subcommand( @set_.subcommand(sub_cmd_name="massmention", sub_cmd_description="Set massmention output")
base="settings", @slash_option(
subcommand_group="set",
name="massmention",
description="Set massmention amount",
options=[
create_option(
name="amount", name="amount",
description="Amount of mentions (0 to disable)", description="Amount of mentions (0 to disable)",
option_type=4, opt_type=OptionTypes.INTEGER,
required=True, required=True,
) )
], @check(admin_or_permissions(Permissions.MANAGE_GUILD))
) async def _set_massmention(self, ctx: InteractionContext, amount: int) -> None:
@admin_or_permissions(manage_guild=True)
async def _set_massmention(self, ctx: SlashContext, amount: int) -> None:
await ctx.defer() 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}") await ctx.send(f"Settings applied. New massmention limit is {amount}")
@cog_ext.cog_subcommand( @set_.subcommand(sub_cmd_name="verified", sub_cmd_description="Set verified role")
base="settings", @slash_option(
subcommand_group="set", name="role", description="Verified role", opt_type=OptionTypes.ROLE, required=True
name="verified",
description="Set verified role",
options=[
create_option(
name="role",
description="verified role",
option_type=8,
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) return
async def _set_verified(self, ctx: SlashContext, role: Role) -> None:
await ctx.defer() 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}`") await ctx.send(f"Settings applied. New verified role is `{role.name}`")
@cog_ext.cog_subcommand( @set_.subcommand(sub_cmd_name="unverified", sub_cmd_description="Set unverified role")
base="settings", @slash_option(
subcommand_group="set", name="role", description="Unverified role", opt_type=OptionTypes.ROLE, required=True
name="unverified",
description="Set unverified role",
options=[
create_option(
name="role",
description="Unverified role",
option_type=8,
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) return
async def _set_unverified(self, ctx: SlashContext, role: Role) -> None:
await ctx.defer() 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}`") await ctx.send(f"Settings applied. New unverified role is `{role.name}`")
@cog_ext.cog_subcommand( @set_.subcommand(
base="settings", sub_cmd_name="noinvite", sub_cmd_description="Set if invite deletion should happen"
subcommand_group="set",
name="noinvite",
description="Set if invite deletion should happen",
options=[
create_option(
name="active",
description="Active?",
option_type=4,
required=True,
) )
], @slash_option(name="active", description="Active?", opt_type=OptionTypes.BOOLEAN, required=True)
) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
@admin_or_permissions(manage_guild=True) async def _set_invitedel(self, ctx: InteractionContext, active: bool) -> None:
async def _set_invitedel(self, ctx: SlashContext, active: int) -> None:
await ctx.defer() await ctx.defer()
self.update_settings("noinvite", bool(active), ctx.guild.id) await self.update_settings("noinvite", active, ctx.guild.id)
await ctx.send(f"Settings applied. Automatic invite active: {bool(active)}") await ctx.send(f"Settings applied. Automatic invite active: {active}")
@cog_ext.cog_subcommand( @set_.subcommand(sub_cmd_name="notify", sub_cmd_description="Notify users of admin action?")
base="settings", @slash_option(name="active", description="Notify?", opt_type=OptionTypes.BOOLEAN, required=True)
subcommand_group="unset", @check(admin_or_permissions(Permissions.MANAGE_GUILD))
subcommand_group_description="Unset a setting", async def _set_notify(self, ctx: InteractionContext, active: bool) -> None:
name="mute",
description="Unset mute role",
)
@admin_or_permissions(manage_guild=True)
async def _unset_mute(self, ctx: SlashContext) -> None:
await ctx.defer() await ctx.defer()
self.delete_settings("mute", ctx.guild.id) await self.update_settings("notify", active, ctx.guild.id)
await ctx.send("Setting removed.") await ctx.send(f"Settings applied. Notifications active: {active}")
@cog_ext.cog_subcommand( # Unset
base="settings", @unset.subcommand(
subcommand_group="unset", sub_cmd_name="modlog",
name="modlog", sub_cmd_description="Unset Modlog channel",
description="Unset modlog channel",
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unset_modlog(self, ctx: SlashContext) -> None: async def _unset_modlog(self, ctx: InteractionContext) -> 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:
await ctx.defer() await ctx.defer()
self.delete_settings("massmention", ctx.guild.id) await self.delete_settings("modlog", ctx.guild.id)
await ctx.send("Setting removed.") await ctx.send("Setting `modlog` unset")
@cog_ext.cog_subcommand( @unset.subcommand(
base="settings", sub_cmd_name="activitylog",
subcommand_group="unset", sub_cmd_description="Unset Activitylog channel",
name="verified",
description="Unset verified role",
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _verified(self, ctx: SlashContext) -> None: async def _unset_activitylog(self, ctx: InteractionContext) -> None:
await ctx.defer() await ctx.defer()
self.delete_settings("verified", ctx.guild.id) await self.delete_settings("activitylog", ctx.guild.id)
await ctx.send("Setting removed.") await ctx.send("Setting `activitylog` unset")
@cog_ext.cog_subcommand( @unset.subcommand(sub_cmd_name="massmention", sub_cmd_description="Unset massmention output")
base="settings", @check(admin_or_permissions(Permissions.MANAGE_GUILD))
subcommand_group="unset", async def _unset_massmention(self, ctx: InteractionContext) -> None:
name="unverified", await ctx.defer()
description="Unset unverified role", 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) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _unverified(self, ctx: SlashContext) -> None: async def _unset_invitedel(self, ctx: InteractionContext, active: bool) -> None:
await ctx.defer() await ctx.defer()
self.delete_settings("unverified", ctx.guild.id) await self.delete_settings("noinvite", ctx.guild.id)
await ctx.send("Setting removed.") await ctx.send(f"Setting `{active}` unset")
@cog_ext.cog_subcommand(base="settings", name="view", description="View settings") @unset.subcommand(sub_cmd_name="notify", sub_cmd_description="Unset admin action notifications")
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _view(self, ctx: SlashContext) -> None: async def _unset_notify(self, ctx: InteractionContext) -> None:
settings = Setting.objects(guild=ctx.guild.id) 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 = [] fields = []
for setting in settings: async for setting in settings:
value = setting.value value = setting.value
if setting.setting in ["unverified", "verified", "mute"]: 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: if value:
value = value.mention value = value.mention
else: else:
value = "||`[redacted]`||" value = "||`[redacted]`||"
elif setting.setting in ["userlog", "modlog"]: elif setting.setting in ["activitylog", "modlog"]:
value = find(lambda x: x.id == value, ctx.guild.text_channels) value = await ctx.guild.fetch_channel(value)
if value: if value:
value = value.mention value = value.mention
else: else:
value = "||`[redacted]`||" value = "||`[redacted]`||"
elif setting.setting == "rolegiver": elif setting.setting == "rolegiver":
value = "" value = ""
for role in setting.value: for _role in setting.value:
nvalue = find(lambda x: x.id == value, ctx.guild.roles) nvalue = await ctx.guild.fetch_role(_role)
if value: if nvalue:
value += "\n" + nvalue.mention value += "\n" + nvalue.mention
else: else:
value += "\n||`[redacted]`||" 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) embed = build_embed(title="Current Settings", description="", fields=fields)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_subcommand(base="settings", name="clear", description="Clear all settings") @settings.subcommand(sub_cmd_name="clear", sub_cmd_description="Clear all settings")
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _clear(self, ctx: SlashContext) -> None: async def _clear(self, ctx: InteractionContext) -> None:
deleted = Setting.objects(guild=ctx.guild.id).delete() components = [
await ctx.send(f"Guild settings cleared: `{deleted is not None}`") 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: def setup(bot: Client) -> None:
"""Add SettingsCog to J.A.R.V.I.S.""" """Add SettingsCog to JARVIS"""
bot.add_cog(SettingsCog(bot)) SettingsCog(bot)

View file

@ -1,19 +1,21 @@
"""J.A.R.V.I.S. Starboard Cog.""" """JARVIS Starboard Cog."""
from discord import TextChannel import logging
from discord.ext import commands
from discord.utils import find from jarvis_core.db import q
from discord_slash import SlashContext, cog_ext from jarvis_core.db.models import Star, Starboard
from discord_slash.context import MenuContext from naff import Client, Cog, InteractionContext, Permissions
from discord_slash.model import ContextMenuType, SlashMessage from naff.models.discord.channel import GuildText
from discord_slash.utils.manage_commands import create_option from naff.models.discord.components import ActionRow, Select, SelectOption
from discord_slash.utils.manage_components import ( from naff.models.discord.message import Message
create_actionrow, from naff.models.naff.application_commands import (
create_select, CommandTypes,
create_select_option, OptionTypes,
wait_for_component, 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 import build_embed
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
@ -26,20 +28,22 @@ supported_images = [
] ]
class StarboardCog(commands.Cog): class StarboardCog(Cog):
"""J.A.R.V.I.S. Starboard Cog.""" """JARVIS Starboard Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_subcommand( starboard = SlashCommand(name="starboard", description="Extra pins! Manage starboards")
base="starboard",
name="list", @starboard.subcommand(
description="Lists all Starboards", sub_cmd_name="list",
sub_cmd_description="List all starboards",
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _list(self, ctx: SlashContext) -> None: async def _list(self, ctx: InteractionContext) -> None:
starboards = Starboard.objects(guild=ctx.guild.id) starboards = await Starboard.find(q(guild=ctx.guild.id)).to_list(None)
if starboards != []: if starboards != []:
message = "Available Starboards:\n" message = "Available Starboards:\n"
for s in starboards: for s in starboards:
@ -48,153 +52,144 @@ class StarboardCog(commands.Cog):
else: else:
await ctx.send("No Starboards available.") await ctx.send("No Starboards available.")
@cog_ext.cog_subcommand( @starboard.subcommand(sub_cmd_name="create", sub_cmd_description="Create a starboard")
base="starboard", @slash_option(
name="create",
description="Create a starboard",
options=[
create_option(
name="channel", name="channel",
description="Starboard channel", description="Starboard channel",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _create(self, ctx: SlashContext, channel: TextChannel) -> None: async def _create(self, ctx: InteractionContext, channel: GuildText) -> None:
if channel not in ctx.guild.channels: if channel not in ctx.guild.channels:
await ctx.send( await ctx.send(
"Channel not in guild. Choose an existing channel.", "Channel not in guild. Choose an existing channel.",
hidden=True, ephemeral=True,
) )
return return
if not isinstance(channel, TextChannel): if not isinstance(channel, GuildText):
await ctx.send("Channel must be a TextChannel", hidden=True) await ctx.send("Channel must be a GuildText", ephemeral=True)
return 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: 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 return
count = Starboard.objects(guild=ctx.guild.id).count() count = await Starboard.count_documents(q(guild=ctx.guild.id))
if count >= 25: if count >= 25:
await ctx.send("25 starboard limit reached", hidden=True) await ctx.send("25 starboard limit reached", ephemeral=True)
return return
_ = Starboard( await Starboard(
guild=ctx.guild.id, guild=ctx.guild.id,
channel=channel.id, channel=channel.id,
admin=ctx.author.id, admin=ctx.author.id,
).save() ).commit()
await ctx.send(f"Starboard created. Check it out at {channel.mention}.") await ctx.send(f"Starboard created. Check it out at {channel.mention}.")
@cog_ext.cog_subcommand( @starboard.subcommand(sub_cmd_name="delete", sub_cmd_description="Delete a starboard")
base="starboard", @slash_option(
name="delete",
description="Delete a starboard",
options=[
create_option(
name="channel", name="channel",
description="Starboard channel", description="Starboard channel",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _delete(self, ctx: SlashContext, channel: TextChannel) -> None: async def _delete(self, ctx: InteractionContext, channel: GuildText) -> None:
deleted = Starboard.objects(channel=channel.id, guild=ctx.guild.id).delete() found = await Starboard.find_one(q(channel=channel.id, guild=ctx.guild.id))
if deleted: if found:
_ = Star.objects(starboard=channel.id).delete() await found.delete()
await ctx.send(f"Starboard deleted from {channel.mention}.", hidden=True) await ctx.send(f"Starboard deleted from {channel.mention}.")
else: 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( async def _star_add(
self, self,
ctx: SlashContext, ctx: InteractionContext,
message: str, message: str,
channel: TextChannel = None, channel: GuildText = None,
) -> None: ) -> None:
if not channel: if not channel:
channel = ctx.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: if not starboards:
await ctx.send("No starboards exist.", hidden=True) await ctx.send("No starboards exist.", ephemeral=True)
return return
await ctx.defer() 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 = [] channel_list = []
to_delete = []
for starboard in starboards: 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, options=select_channels,
min_values=1, min_values=1,
max_values=1, max_values=1,
) )
components = [create_actionrow(select)] components = [ActionRow(select)]
msg = await ctx.send(content="Choose a starboard", components=components) msg = await ctx.send(content="Choose a starboard", components=components)
com_ctx = await wait_for_component( com_ctx = await self.bot.wait_for_component(
self.bot,
messages=msg, messages=msg,
components=components, 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): exists = await Star.find_one(
if message.startswith("https://"): q(
message = message.split("/")[-1]
message = await channel.fetch_message(message)
exists = Star.objects(
message=message.id, message=message.id,
channel=message.channel.id, channel=channel.id,
guild=message.guild.id, guild=ctx.guild.id,
starboard=starboard.id, starboard=starboard.id,
).first() )
)
if exists: if exists:
await ctx.send( await ctx.send(
f"Message already sent to Starboard {starboard.mention}", f"Message already sent to Starboard {starboard.mention}",
hidden=True, ephemeral=True,
) )
return 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 content = message.content
attachments = message.attachments attachments = message.attachments
@ -215,91 +210,114 @@ class StarboardCog(commands.Cog):
timestamp=message.created_at, timestamp=message.created_at,
) )
embed.set_author( embed.set_author(
name=message.author.name, name=message.author.display_name,
url=message.jump_url, 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: if image_url:
embed.set_image(url=image_url) embed.set_image(url=image_url)
star = await starboard.send(embed=embed) star = await starboard.send(embed=embed)
_ = Star( await Star(
index=count, index=count,
message=message.id, message=message.id,
channel=message.channel.id, channel=channel.id,
guild=message.guild.id, guild=ctx.guild.id,
starboard=starboard.id, starboard=starboard.id,
admin=ctx.author.id, admin=ctx.author.id,
star=star.id, star=star.id,
active=True, 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}", content=f"Message saved to Starboard.\nSee it in {starboard.mention}",
components=components, components=components,
) )
@cog_ext.cog_subcommand( @context_menu(name="Star Message", context_type=CommandTypes.MESSAGE)
base="star", @check(admin_or_permissions(Permissions.MANAGE_GUILD))
name="delete", async def _star_message(self, ctx: InteractionContext) -> None:
description="Delete a starred message", await self._star_add(ctx, message=str(ctx.target_id))
options=[
create_option( star = SlashCommand(
name="id", name="star",
description="Star to delete", description="Manage stars",
option_type=4, )
required=True,
), @star.subcommand(
create_option( 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", name="starboard",
description="Starboard to delete star from", description="Starboard to delete star from",
option_type=7, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
),
],
) )
@admin_or_permissions(manage_guild=True) @check(admin_or_permissions(Permissions.MANAGE_GUILD))
async def _star_delete( async def _star_delete(
self, self,
ctx: SlashContext, ctx: InteractionContext,
id: int, id: int,
starboard: TextChannel, starboard: GuildText,
) -> None: ) -> None:
if not isinstance(starboard, TextChannel): if not isinstance(starboard, GuildText):
await ctx.send("Channel must be a TextChannel", hidden=True) await ctx.send("Channel must be a GuildText channel", ephemeral=True)
return 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: if not exists:
# TODO: automagically create starboard
await ctx.send( await ctx.send(
f"Starboard does not exist in {starboard.mention}. Please create it first", f"Starboard does not exist in {starboard.mention}. Please create it first",
hidden=True, ephemeral=True,
) )
return return
star = Star.objects( star = await Star.find_one(
q(
starboard=starboard.id, starboard=starboard.id,
index=id, index=id,
guild=ctx.guild.id, guild=ctx.guild.id,
active=True, active=True,
).first() )
)
if not star: 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 return
message = await starboard.fetch_message(star.star) message = await starboard.fetch_message(star.star)
if message: if message:
await message.delete() await message.delete()
star.active = False await star.delete()
star.save()
await ctx.send(f"Star {id} deleted") await ctx.send(f"Star {id} deleted from {starboard.mention}")
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add StarboardCog to J.A.R.V.I.S.""" """Add StarboardCog to JARVIS"""
bot.add_cog(StarboardCog(bot)) 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 asyncio
import logging import logging
import tweepy import tweepy
from bson import ObjectId from jarvis_core.db import q
from discord import TextChannel from jarvis_core.db.models import TwitterAccount, TwitterFollow
from discord.ext import commands from naff import Client, Cog, InteractionContext, Permissions
from discord.ext.tasks import loop from naff.client.utils.misc_utils import get
from discord.utils import find from naff.models.discord.channel import GuildText
from discord_slash import SlashContext, cog_ext from naff.models.discord.components import ActionRow, Select, SelectOption
from discord_slash.model import SlashCommandOptionType as COptionType from naff.models.naff.application_commands import (
from discord_slash.utils.manage_commands import create_choice, create_option OptionTypes,
from discord_slash.utils.manage_components import ( SlashCommand,
create_actionrow, slash_option,
create_select,
create_select_option,
wait_for_component,
) )
from naff.models.naff.command import check
from jarvis.config import get_config from jarvis.config import JarvisConfig
from jarvis.db.models import Twitter
from jarvis.utils.permissions import admin_or_permissions from jarvis.utils.permissions import admin_or_permissions
logger = logging.getLogger("discord")
class TwitterCog(Cog):
"""JARVIS Twitter Cog."""
class TwitterCog(commands.Cog): def __init__(self, bot: Client):
"""J.A.R.V.I.S. Twitter Cog."""
def __init__(self, bot: commands.Bot):
self.bot = bot self.bot = bot
config = get_config() self.logger = logging.getLogger(__name__)
auth = tweepy.AppAuthHandler(config.twitter["consumer_key"], config.twitter["consumer_secret"]) config = JarvisConfig.from_yaml()
auth = tweepy.AppAuthHandler(
config.twitter["consumer_key"], config.twitter["consumer_secret"]
)
self.api = tweepy.API(auth) self.api = tweepy.API(auth)
self._tweets.start()
self._guild_cache = {} self._guild_cache = {}
self._channel_cache = {} self._channel_cache = {}
@loop(seconds=30) twitter = SlashCommand(
async def _tweets(self) -> None: name="twitter",
twitters = Twitter.objects(active=True) description="Manage Twitter follows",
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}")
@cog_ext.cog_subcommand( @twitter.subcommand(
base="twitter", sub_cmd_name="follow",
base_description="Twitter commands", sub_cmd_description="Follow a Twitter acount",
name="follow", )
description="Follow a Twitter account", @slash_option(
options=[ name="handle", description="Twitter account", opt_type=OptionTypes.STRING, required=True
create_option(name="handle", description="Twitter account", option_type=COptionType.STRING, required=True), )
create_option( @slash_option(
name="channel", name="channel",
description="Channel to post tweets into", description="Channel to post tweets to",
option_type=COptionType.CHANNEL, opt_type=OptionTypes.CHANNEL,
required=True, required=True,
), )
create_option( @slash_option(
name="retweets", name="retweets",
description="Mirror re-tweets?", description="Mirror re-tweets?",
option_type=COptionType.STRING, opt_type=OptionTypes.BOOLEAN,
required=False, 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( 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: ) -> None:
retweets = retweets == "Yes" handle = handle.lower()
if len(handle) > 15: if len(handle) > 15 or len(handle) < 4:
await ctx.send("Invalid Twitter handle", hidden=True) await ctx.send("Invalid Twitter handle", ephemeral=True)
return return
if not isinstance(channel, TextChannel): if not isinstance(channel, GuildText):
await ctx.send("Channel must be a text channel", hidden=True) await ctx.send("Channel must be a text channel", ephemeral=True)
return return
try: 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: 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 return
count = Twitter.objects(guild=ctx.guild.id).count() exists = await TwitterFollow.find_one(q(twitter_id=account.id, guild=ctx.guild.id))
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)
if exists: 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 return
t = Twitter( count = len([i async for i in TwitterFollow.find(q(guild=ctx.guild.id))])
handle=handle, 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, guild=ctx.guild.id,
channel=channel.id, channel=channel.id,
admin=ctx.author.id, admin=ctx.author.id,
last_tweet=latest_tweet.id,
retweets=retweets, retweets=retweets,
) )
t.save() await tf.commit()
await ctx.send(f"Now following `@{handle}` in {channel.mention}") await ctx.send(f"Now following `@{handle}` in {channel.mention}")
@cog_ext.cog_subcommand( @twitter.subcommand(sub_cmd_name="unfollow", sub_cmd_description="Unfollow Twitter accounts")
base="twitter", @check(admin_or_permissions(Permissions.MANAGE_GUILD))
name="unfollow", async def _twitter_unfollow(self, ctx: InteractionContext) -> None:
description="Unfollow Twitter accounts", t = TwitterFollow.find(q(guild=ctx.guild.id))
) twitters = []
@admin_or_permissions(manage_guild=True) async for twitter in t:
async def _twitter_unfollow(self, ctx: SlashContext) -> None: twitters.append(twitter)
twitters = Twitter.objects(guild=ctx.guild.id)
if not twitters: 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 return
options = [] options = []
handlemap = {str(x.id): x.handle for x in twitters} handlemap = {}
for twitter in twitters: 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) 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)] components = [ActionRow(select)]
block = "\n".join(x.handle for x in twitters) block = "\n".join(x for x in handlemap.values())
message = await ctx.send( message = await ctx.send(
content=f"You are following the following accounts:\n```\n{block}\n```\n\n" content=f"You are following the following accounts:\n```\n{block}\n```\n\n"
"Please choose accounts to unfollow", "Please choose accounts to unfollow",
@ -166,53 +143,65 @@ class TwitterCog(commands.Cog):
) )
try: try:
context = await wait_for_component( context = await self.bot.wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5 check=lambda x: ctx.author.id == x.context.author.id,
messages=message,
timeout=60 * 5,
) )
for to_delete in context.selected_options: for to_delete in context.context.values:
_ = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_delete)).delete() 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 row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True 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) 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: except asyncio.TimeoutError:
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
await message.edit(components=components) await message.edit(components=components)
@cog_ext.cog_subcommand( @twitter.subcommand(
base="twitter", sub_cmd_name="retweets",
name="retweets", sub_cmd_description="Modify followed Twitter accounts",
description="Modify followed Twitter accounts", )
options=[ @slash_option(
create_option(
name="retweets", name="retweets",
description="Mirror re-tweets?", description="Mirror re-tweets?",
option_type=COptionType.STRING, opt_type=OptionTypes.BOOLEAN,
required=True, 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_modify(self, ctx: SlashContext, retweets: str) -> None: async def _twitter_modify(self, ctx: InteractionContext, retweets: bool = True) -> None:
retweets = retweets == "Yes" t = TwitterFollow.find(q(guild=ctx.guild.id))
twitters = Twitter.objects(guild=ctx.guild.id) twitters = []
async for twitter in t:
twitters.append(twitter)
if not twitters: 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 return
options = [] options = []
handlemap = {}
for twitter in twitters: 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) 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)] components = [ActionRow(select)]
block = "\n".join(x.handle for x in twitters) block = "\n".join(x for x in handlemap.values())
message = await ctx.send( message = await ctx.send(
content=f"You are following the following accounts:\n```\n{block}\n```\n\n" 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", f"Please choose which accounts to {'un' if not retweets else ''}follow retweets from",
@ -220,30 +209,41 @@ class TwitterCog(commands.Cog):
) )
try: try:
context = await wait_for_component( context = await self.bot.wait_for_component(
self.bot, check=lambda x: ctx.author.id == x.author.id, messages=message, timeout=60 * 5 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: handlemap = {}
t = Twitter.objects(guild=ctx.guild.id, id=ObjectId(to_update)).first() for to_update in context.context.values:
t.retweets = retweets account = await TwitterAccount.find_one(q(twitter_id=int(to_update)))
t.save() 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 row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
block = "\n".join(handlemap[x] for x in context.selected_options)
await context.edit_origin( block = "\n".join(handlemap[x] for x in context.context.values)
content=f"{'Unfollowed' if not retweets else 'Followed'} retweets from the following:" await context.context.edit_origin(
f"\n```\n{block}\n```", content=(
f"{'Unfollowed' if not retweets else 'Followed'} "
"retweets from the following:"
f"\n```\n{block}\n```"
),
components=components, components=components,
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
for row in components: for row in components:
for component in row["components"]: for component in row.components:
component["disabled"] = True component.disabled = True
await message.edit(components=components) await message.edit(components=components)
def setup(bot: commands.Bot) -> None: def setup(bot: Client) -> None:
"""Add TwitterCog to J.A.R.V.I.S.""" """Add TwitterCog to JARVIS"""
bot.add_cog(TwitterCog(bot)) 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 re
import secrets import secrets
import string import string
from datetime import timezone
from io import BytesIO from io import BytesIO
import discord
import discord_slash
import numpy as np import numpy as np
from discord import File, Guild, Role, User from dateparser import parse
from discord.ext import commands from naff import Client, Cog, InteractionContext, const
from discord_slash import SlashContext, cog_ext from naff.models.discord.channel import GuildCategory, GuildText, GuildVoice
from discord_slash.utils.manage_commands import create_choice, create_option 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 PIL import Image
from tzlocal import get_localzone
import jarvis from jarvis import const as jconst
from jarvis import jarvis_self
from jarvis.config import get_config
from jarvis.data import pigpen from jarvis.data import pigpen
from jarvis.data.robotcamo import emotes, hk, names from jarvis.data.robotcamo import emotes, hk, names
from jarvis.utils import build_embed, convert_bytesize, get_repo_hash from jarvis.utils import build_embed, get_repo_hash
from jarvis.utils.field import Field
JARVIS_LOGO = Image.open("jarvis_small.png").convert("RGBA") 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 Mostly system utility functions, but may change over time
""" """
def __init__(self, bot: commands.Cog): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.config = get_config() self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash( @slash_command(name="status", description="Retrieve JARVIS status")
name="status", @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
description="Retrieve J.A.R.V.I.S. status", async def _status(self, ctx: InteractionContext) -> None:
) title = "JARVIS Status"
@commands.cooldown(1, 30, commands.BucketType.channel) desc = f"All systems online\nConnected to **{len(self.bot.guilds)}** guilds"
async def _status(self, ctx: SlashContext) -> None: color = "#3498db"
title = "J.A.R.V.I.S. Status"
desc = "All systems online"
color = "#98CCDA"
fields = [] fields = []
with jarvis_self.oneshot(): uptime = int(self.bot.start_time.timestamp())
fields.append(Field("CPU Usage", jarvis_self.cpu_percent()))
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( fields.append(
Field( EmbedField(
"RAM Usage", name="Phishing Protection", value=f"Detecting {num_domains} phishing domains"
convert_bytesize(jarvis_self.memory_info().rss),
) )
) )
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) embed = build_embed(title=title, description=desc, fields=fields, color=color)
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(
name="logo", name="logo",
description="Get the current logo", description="Get the current logo",
) )
@commands.cooldown(1, 30, commands.BucketType.channel) @cooldown(bucket=Buckets.CHANNEL, rate=1, interval=30)
async def _logo(self, ctx: SlashContext) -> None: async def _logo(self, ctx: InteractionContext) -> None:
with BytesIO() as image_bytes: with BytesIO() as image_bytes:
JARVIS_LOGO.save(image_bytes, "PNG") JARVIS_LOGO.save(image_bytes, "PNG")
image_bytes.seek(0) image_bytes.seek(0)
logo = File(image_bytes, filename="logo.png") logo = File(image_bytes, file_name="logo.png")
await ctx.send(file=logo) await ctx.send(file=logo)
@cog_ext.cog_slash(name="rchk", description="Robot Camo HK416") @slash_command(name="rchk", description="Robot Camo HK416")
async def _rchk(self, ctx: SlashContext) -> None: async def _rchk(self, ctx: InteractionContext) -> None:
await ctx.send(content=hk) await ctx.send(content=hk)
@cog_ext.cog_slash( @slash_command(
name="rcauto", name="rcauto",
description="Automates robot camo letters", description="Automates robot camo letters",
options=[ )
create_option( @slash_option(
name="text", name="text",
description="Text to camo-ify", description="Text to camo-ify",
option_type=3, opt_type=OptionTypes.STRING,
required=True, required=True,
) )
], async def _rcauto(self, ctx: InteractionContext, text: str) -> None:
)
async def _rcauto(self, ctx: SlashContext, text: str) -> None:
to_send = "" to_send = ""
if len(text) == 1 and not re.match(r"^[A-Z0-9-()$@!?^'#. ]$", text.upper()): 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 return
for letter in text.upper(): for letter in text.upper():
if letter == " ": if letter == " ":
to_send += " " to_send += " "
elif re.match(r"^[A-Z0-9-()$@!?^'#.]$", letter): elif re.match(r"^[A-Z0-9-()$@!?^'#.]$", letter):
id = emotes[letter] id = emotes[letter]
if ctx.author.is_on_mobile():
to_send += f":{names[id]}:" to_send += f":{names[id]}:"
else:
to_send += f"<:{names[id]}:{id}>"
if len(to_send) > 2000: 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: else:
await ctx.send(to_send) await ctx.send(to_send)
@cog_ext.cog_slash( @slash_command(name="avatar", description="Get a user avatar")
name="avatar", @slash_option(
description="Get a user avatar",
options=[
create_option(
name="user", name="user",
description="User to view avatar of", description="User to view avatar of",
option_type=6, opt_type=OptionTypes.USER,
required=False, required=False,
) )
], @cooldown(bucket=Buckets.USER, rate=1, interval=5)
) async def _avatar(self, ctx: InteractionContext, user: User = None) -> None:
@commands.cooldown(1, 5, commands.BucketType.user)
async def _avatar(self, ctx: SlashContext, user: User = None) -> None:
if not user: if not user:
user = ctx.author 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 = build_embed(title="Avatar", description="", fields=[], color="#00FFEE")
embed.set_image(url=avatar) 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) await ctx.send(embed=embed)
@cog_ext.cog_slash( @slash_command(
name="roleinfo", name="roleinfo",
description="Get role info", description="Get role info",
options=[ )
create_option( @slash_option(
name="role", name="role",
description="Role to get info of", description="Role to get info of",
option_type=8, opt_type=OptionTypes.ROLE,
required=True, required=True,
) )
], async def _roleinfo(self, ctx: InteractionContext, role: Role) -> None:
)
async def _roleinfo(self, ctx: SlashContext, role: Role) -> None:
fields = [ fields = [
Field(name="ID", value=role.id), EmbedField(name="ID", value=str(role.id), inline=True),
Field(name="Name", value=role.name), EmbedField(name="Name", value=role.mention, inline=True),
Field(name="Color", value=str(role.color)), EmbedField(name="Color", value=str(role.color.hex), inline=True),
Field(name="Mention", value=f"`{role.mention}`"), EmbedField(name="Mention", value=f"`{role.mention}`", inline=True),
Field(name="Hoisted", value="Yes" if role.hoist else "No"), EmbedField(name="Hoisted", value="Yes" if role.hoist else "No", inline=True),
Field(name="Position", value=str(role.position)), EmbedField(name="Position", value=str(role.position), inline=True),
Field(name="Mentionable", value="Yes" if role.mentionable else "No"), 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( embed = build_embed(
title="", title="",
description="", description="",
fields=fields, fields=fields,
color=str(role.color), color=role.color,
timestamp=role.created_at, timestamp=role.created_at,
) )
embed.set_footer(text="Role Created") embed.set_footer(text="Role Created")
@ -170,46 +171,38 @@ class UtilCog(commands.Cog):
fill = a > 0 fill = a > 0
data[..., :-1][fill.T] = list(role.color.to_rgb()) data[..., :-1][fill.T] = list(role.color.rgb)
im = Image.fromarray(data) im = Image.fromarray(data)
with BytesIO() as image_bytes: with BytesIO() as image_bytes:
im.save(image_bytes, "PNG") im.save(image_bytes, "PNG")
image_bytes.seek(0) 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) await ctx.send(embed=embed, file=color_show)
@cog_ext.cog_slash( async def _userinfo(self, ctx: InteractionContext, user: User = None) -> None:
name="userinfo", await ctx.defer()
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:
if not user: if not user:
user = ctx.author 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 user_roles = user.roles
if user_roles: if user_roles:
user_roles = sorted(user.roles, key=lambda x: -x.position) user_roles = sorted(user.roles, key=lambda x: -x.position)
_ = user_roles.pop(-1)
fields = [ fields = [
Field( EmbedField(
name="Joined", 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", 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)}]", name=f"Roles [{len(user_roles)}]",
value=" ".join([x.mention for x in user_roles]) if user_roles else "None", value=" ".join([x.mention for x in user_roles]) if user_roles else "None",
inline=False, inline=False,
@ -220,81 +213,102 @@ class UtilCog(commands.Cog):
title="", title="",
description=user.mention, description=user.mention,
fields=fields, 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_author(
embed.set_thumbnail(url=user.avatar_url) 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}") embed.set_footer(text=f"ID: {user.id}")
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_slash(name="serverinfo", description="Get server info") @slash_command(
async def _server_info(self, ctx: SlashContext) -> None: 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 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 owner = f"{owner.username}#{owner.discriminator}" if owner else "||`[redacted]`||"
categories = len(guild.categories)
text_channels = len(guild.text_channels) categories = len([x for x in guild.channels if isinstance(x, GuildCategory)])
voice_channels = len(guild.voice_channels) 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 members = guild.member_count
roles = len(guild.roles) 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 = [ fields = [
Field(name="Owner", value=owner), EmbedField(name="Owner", value=owner, inline=True),
Field(name="Region", value=region), EmbedField(name="Channel Categories", value=str(categories), inline=True),
Field(name="Channel Categories", value=categories), EmbedField(name="Text Channels", value=str(text_channels), inline=True),
Field(name="Text Channels", value=text_channels), EmbedField(name="Voice Channels", value=str(voice_channels), inline=True),
Field(name="Voice Channels", value=voice_channels), EmbedField(name="Threads", value=str(threads), inline=True),
Field(name="Members", value=members), EmbedField(name="Members", value=str(members), inline=True),
Field(name="Roles", value=roles), 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: 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 = build_embed(title="", description="", fields=fields, timestamp=guild.created_at)
embed.set_author(name=guild.name, icon_url=guild.icon_url) embed.set_author(name=guild.name, icon_url=guild.icon.url)
embed.set_thumbnail(url=guild.icon_url) embed.set_thumbnail(url=guild.icon.url)
embed.set_footer(text=f"ID: {guild.id} | Server Created") embed.set_footer(text=f"ID: {guild.id} | Server Created")
await ctx.send(embed=embed) await ctx.send(embed=embed)
@cog_ext.cog_subcommand( @slash_command(
base="pw", name="pw",
name="gen", sub_cmd_name="gen",
base_desc="Password utilites",
description="Generate a secure password", description="Generate a secure password",
guild_ids=[862402786116763668], scopes=[862402786116763668],
options=[ )
create_option( @slash_option(
name="length", name="length",
description="Password length (default 32)", description="Password length (default 32)",
option_type=4, opt_type=OptionTypes.INTEGER,
required=False, required=False,
), )
create_option( @slash_option(
name="chars", name="chars",
description="Characters to include (default last option)", description="Characters to include (default last option)",
option_type=4, opt_type=OptionTypes.INTEGER,
required=False, required=False,
choices=[ choices=[
create_choice(name="A-Za-z", value=0), SlashCommandChoice(name="A-Za-z", value=0),
create_choice(name="A-Fa-f0-9", value=1), SlashCommandChoice(name="A-Fa-f0-9", value=1),
create_choice(name="A-Za-z0-9", value=2), SlashCommandChoice(name="A-Za-z0-9", value=2),
create_choice(name="A-Za-z0-9!@#$%^&*", value=3), SlashCommandChoice(name="A-Za-z0-9!@#$%^&*", value=3),
],
),
], ],
) )
@commands.cooldown(1, 15, type=commands.BucketType.user) @cooldown(bucket=Buckets.USER, rate=1, interval=15)
async def _pw_gen(self, ctx: SlashContext, length: int = 32, chars: int = 3) -> None: async def _pw_gen(self, ctx: InteractionContext, length: int = 32, chars: int = 3) -> None:
if length > 256: 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 return
choices = [ choices = [
string.ascii_letters, string.ascii_letters,
string.hexdigits, string.hexdigits,
@ -307,15 +321,14 @@ class UtilCog(commands.Cog):
f"Generated password:\n`{pw}`\n\n" f"Generated password:\n`{pw}`\n\n"
'**WARNING: Once you press "Dismiss Message", ' '**WARNING: Once you press "Dismiss Message", '
"*the password is lost forever***", "*the password is lost forever***",
hidden=True, ephemeral=True,
) )
@cog_ext.cog_slash( @slash_command(name="pigpen", description="Encode a string into pigpen")
name="pigpen", @slash_option(
description="Encode a string into pigpen", name="text", description="Text to encode", opt_type=OptionTypes.STRING, required=True
options=[create_option(name="text", description="Text to encode", option_type=3, required=True)],
) )
async def _pigpen(self, ctx: SlashContext, text: str) -> None: async def _pigpen(self, ctx: InteractionContext, text: str) -> None:
outp = "`" outp = "`"
for c in text: for c in text:
c = c.lower() c = c.lower()
@ -329,7 +342,39 @@ class UtilCog(commands.Cog):
outp += "`" outp += "`"
await ctx.send(outp[:2000]) 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: if not timestamp.tzinfo:
"""Add UtilCog to J.A.R.V.I.S.""" timestamp = timestamp.replace(tzinfo=get_localzone()).astimezone(tz=timezone.utc)
bot.add_cog(UtilCog(bot))
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 random import randint
from discord.ext import commands from jarvis_core.db import q
from discord_slash import ComponentContext, SlashContext, cog_ext from jarvis_core.db.models import Setting
from discord_slash.model import ButtonStyle from naff import Client, Cog, InteractionContext
from discord_slash.utils import manage_components from naff.models.discord.components import Button, ButtonStyles, spread_to_rows
from naff.models.naff.application_commands import slash_command
from jarvis.db.models import Setting from naff.models.naff.command import cooldown
from naff.models.naff.cooldowns import Buckets
def create_layout() -> list: def create_layout() -> list:
@ -16,77 +19,95 @@ def create_layout() -> list:
for i in range(3): for i in range(3):
label = "YES" if i == yes else "NO" label = "YES" if i == yes else "NO"
id = f"no_{i}" if not i == yes else "yes" 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( buttons.append(
manage_components.create_button( Button(
style=color, style=color,
label=label, label=label,
custom_id=f"verify_button||{id}", custom_id=f"verify_button||{id}",
) )
) )
action_row = manage_components.spread_to_rows(*buttons, max_in_row=3) return spread_to_rows(*buttons, max_in_row=3)
return action_row
class VerifyCog(commands.Cog): class VerifyCog(Cog):
"""J.A.R.V.I.S. Verify Cog.""" """JARVIS Verify Cog."""
def __init__(self, bot: commands.Bot): def __init__(self, bot: Client):
self.bot = bot self.bot = bot
self.logger = logging.getLogger(__name__)
@cog_ext.cog_slash( @slash_command(name="verify", description="Verify that you've read the rules")
name="verify", @cooldown(bucket=Buckets.USER, rate=1, interval=30)
description="Verify that you've read the rules", async def _verify(self, ctx: InteractionContext) -> None:
)
@commands.cooldown(1, 15, commands.BucketType.user)
async def _verify(self, ctx: SlashContext) -> None:
await ctx.defer() 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: 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 return
if ctx.guild.get_role(role.value) in ctx.author.roles: verified_role = await ctx.guild.fetch_role(role.value)
await ctx.send("You are already verified.", delete_after=5) 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 return
components = create_layout() components = create_layout()
message = await ctx.send( message = await ctx.send(
content=f"{ctx.author.mention}, please press the button that says `YES`.", content=f"{ctx.author.mention}, please press the button that says `YES`.",
components=components, 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: try:
if ctx.author.id != ctx.origin_message.mentions[0].id: verified = False
return while not verified:
except Exception: response = await self.bot.wait_for_component(
return messages=message,
correct = ctx.custom_id.split("||")[-1] == "yes" check=lambda x: ctx.author.id == x.context.author.id,
timeout=30,
)
correct = response.context.custom_id.split("||")[-1] == "yes"
if correct: if correct:
components = ctx.origin_message.components for row in components:
for c in components: for component in row.components:
for c2 in c["components"]: component.disabled = True
c2["disabled"] = True setting = await Setting.find_one(q(guild=ctx.guild.id, setting="verified"))
setting = Setting.objects(guild=ctx.guild.id, setting="verified").first() try:
role = ctx.guild.get_role(setting.value) role = await ctx.guild.fetch_role(setting.value)
await ctx.author.add_roles(role, reason="Verification passed") await ctx.author.add_role(role, reason="Verification passed")
setting = Setting.objects(guild=ctx.guild.id, setting="unverified").first() 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: if setting:
role = ctx.guild.get_role(setting.value) try:
await ctx.author.remove_roles(role, reason="Verification passed") role = await ctx.guild.fetch_role(setting.value)
await ctx.edit_origin( 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.", 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: else:
await ctx.edit_origin( await response.context.edit_origin(
content=f"{ctx.author.mention}, incorrect. Please press the button that says `YES`", 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: def setup(bot: Client) -> None:
"""Add VerifyCog to J.A.R.V.I.S.""" """Add VerifyCog to JARVIS"""
bot.add_cog(VerifyCog(bot)) VerifyCog(bot)

View file

@ -1,83 +1,17 @@
"""Load the config for J.A.R.V.I.S.""" """Load the config for JARVIS"""
from pymongo import MongoClient from jarvis_core.config import Config as CConfig
from yaml import load
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader
class Config(object): class JarvisConfig(CConfig):
"""Config singleton object for J.A.R.V.I.S.""" REQUIRED = ("token", "mongo", "urls", "redis")
OPTIONAL = {
def __new__(cls, *args: list, **kwargs: dict): "sync": False,
"""Get the singleton config, or creates a new one.""" "log_level": "WARNING",
it = cls.__dict__.get("it") "cogs": None,
if it is not None: "events": True,
return it "gitlab_token": None,
cls.__it__ = it = object.__new__(cls) "max_messages": 1000,
it.init(*args, **kwargs) "twitter": None,
return it "reddit": None,
"rook_token": None,
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")

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 = { names = {
852317928572715038: "rcA", 852317928572715038: "rcLetterA",
852317954975727679: "rcB", 852317954975727679: "rcLetterB",
852317972424818688: "rcC", 852317972424818688: "rcLetterC",
852317990238421003: "rcD", 852317990238421003: "rcLetterD",
852318044503539732: "rcE", 852318044503539732: "rcLetterE",
852318058353786880: "rcF", 852318058353786880: "rcLetterF",
852318073994477579: "rcG", 852318073994477579: "rcLetterG",
852318105832259614: "rcH", 852318105832259614: "rcLetterH",
852318122278125580: "rcI", 852318122278125580: "rcLetterI",
852318145074167818: "rcJ", 852318145074167818: "rcLetterJ",
852318159952412732: "rcK", 852318159952412732: "rcLetterK",
852318179358408704: "rcL", 852318179358408704: "rcLetterL",
852318241555873832: "rcM", 852318241555873832: "rcLetterM",
852318311115128882: "rcN", 852318311115128882: "rcLetterN",
852318329951223848: "rcO", 852318329951223848: "rcLetterO",
852318344643477535: "rcP", 852318344643477535: "rcLetterP",
852318358920757248: "rcQ", 852318358920757248: "rcLetterQ",
852318385638211594: "rcR", 852318385638211594: "rcLetterR",
852318401166311504: "rcS", 852318401166311504: "rcLetterS",
852318421524938773: "rcT", 852318421524938773: "rcLetterT",
852318435181854742: "rcU", 852318435181854742: "rcLetterU",
852318453204647956: "rcV", 852318453204647956: "rcLetterV",
852318470267731978: "rcW", 852318470267731978: "rcLetterW",
852318484749877278: "rcX", 852318484749877278: "rcLetterX",
852318504564555796: "rcY", 852318504564555796: "rcLetterY",
852318519449092176: "rcZ", 852318519449092176: "rcLetterZ",
860663352740151316: "rc1", 860663352740151316: "rc1",
860662785243348992: "rc2", 860662785243348992: "rc2",
860662950011469854: "rc3", 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.""" """JARVIS Utility Functions."""
from datetime import datetime from datetime import datetime, timezone
from pkgutil import iter_modules from pkgutil import iter_modules
import git import git
from discord import Color, Embed, Message from naff.models.discord.embed import Embed, EmbedField
from discord.ext import commands from naff.models.discord.guild import AuditLogEntry
from naff.models.discord.user import Member
import jarvis.cogs from jarvis.config import JarvisConfig
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)
def build_embed( def build_embed(
@ -67,11 +20,11 @@ def build_embed(
) -> Embed: ) -> Embed:
"""Embed builder utility function.""" """Embed builder utility function."""
if not timestamp: if not timestamp:
timestamp = datetime.utcnow() timestamp = datetime.now(tz=timezone.utc)
embed = Embed( embed = Embed(
title=title, title=title,
description=description, description=description,
color=parse_color_hex(color), color=color,
timestamp=timestamp, timestamp=timestamp,
**kwargs, **kwargs,
) )
@ -80,8 +33,43 @@ def build_embed(
return 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: def update() -> int:
"""J.A.R.V.I.S. update utility.""" """JARVIS update utility."""
repo = git.Repo(".") repo = git.Repo(".")
dirty = repo.is_dirty() dirty = repo.is_dirty()
current_hash = repo.head.object.hexsha current_hash = repo.head.object.hexsha
@ -96,6 +84,6 @@ def update() -> int:
def get_repo_hash() -> str: def get_repo_hash() -> str:
"""J.A.R.V.I.S. current branch hash.""" """JARVIS current branch hash."""
repo = git.Repo(".") repo = git.Repo(".")
return repo.head.object.hexsha 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.""" """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: 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.""" """Command check predicate."""
if getattr(get_config(), "admins", None): cfg = JarvisConfig.from_yaml()
return ctx.author.id in get_config().admins if getattr(cfg, "admins", None):
return ctx.author.id in cfg.admins
else: else:
return False 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.""" """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 """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 """Main run file for J.A.R.V.I.S."""
# flake8: noqa import asyncio
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.")
from jarvis import run
if __name__ == "__main__": if __name__ == "__main__":
freeze_support() asyncio.run(run())
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.")