Compare commits

..

21 Commits

Author SHA1 Message Date
palmtree5
1cb5394e96 [V3] bump version to 3.0.0b13 (#1583) 2018-05-04 08:48:33 +02:00
palmtree5
2b2dbd25f7 [V3 Help] fix issue with non-existent subcommands (#1565) 2018-05-03 22:19:24 -08:00
Michael H
dd4cd0eeb1 provide an extra method for helping wor with embed_requested (#1558) 2018-05-04 08:16:24 +02:00
palmtree5
ee7b0cf730 [V3 Utils] fix files not being chmodded (#1578) 2018-05-04 08:10:56 +02:00
retke
95ef5d6348 [V3 Launcher] Reinstall Red option (#1536)
* [V3 Launcher] Reinstall Red option

* [V3 Setup] Divided remove_instance function

* Removing changes from another PR

* Indent fails fix

* use remove_instance_interaction for --delete

* Fix some issues with remove_instance

removed `index: int` because what's being passed there is a string
data -> instance_data

* bug fixes, working version
2018-05-04 08:01:37 +02:00
bobloy
23192b9ef6 simple_embed doesn't take author (#1555)
Simple embed doesn't use ctx.author as author
2018-05-04 07:27:44 +02:00
Michael H
7cd98c8a63 Report fixes + improvements (#1541)
* WIP

* fix perms issue

* better

* more work

* working

* working, tessted

* docs

* mutable default fix
2018-05-04 06:38:58 +02:00
palmtree5
fca7686701 [V3 Core] fix 3.5-specific issue with [p]backup (#1586) (#1588) 2018-05-04 06:18:44 +02:00
Michael H
be767478f4 allow deletion based on user ID (actually this time) (#1561) 2018-05-04 06:15:27 +02:00
palmtree5
b3ad5d90ed [V3 Core] fix a couple issues with [p]servers (#1580) 2018-05-04 06:05:09 +02:00
palmtree5
fb093b7411 [V3 Utils] Menu system (#1566)
* [V3 Utils] start on a menu system

* Fix conflicting names

* [V3 Menus] change order of default controls

* [V3 Menus] add a message check to the react check

* Add a note about original source and who ported

* Compare message ids, not the objects themselves
2018-05-04 05:54:30 +02:00
palmtree5
e4ea3110e3 [V3 Warnings] fix several bugs found (#1577) 2018-05-04 05:46:59 +02:00
aikaterna
79676c4f72 Playlist additions and cleanup (#1579)
Add playlist append, create, remove, and upload.
2018-05-04 05:43:00 +02:00
Wyn
d61827b92c [V3 Docs] Fixed broken link (#1567)
Guide migration went into a maze, this should fix it.
2018-05-04 05:33:01 +02:00
palmtree5
1f1f46c70f [V3] move to multiple issue/pr templates (#1585) 2018-05-04 03:58:30 +02:00
Wyn
9188e4a7ec [V3 Info] Don't rely on redirect (#1581)
* [V3 Info] Don't rely on redirect

Http -> Https

* Update core_commands.py

Use existing variable instead of new string

* Update events.py

Remove redirect, url only reference
2018-05-02 10:35:48 +02:00
Redjumpman
e5a780eb0c Update mod.py (#1582)
Update doc-strings to properly format in the help text.
2018-05-01 09:13:25 +02:00
palmtree5
d8c85a2b15 [V3 Audio] fix zombie process on unload (#1575) 2018-04-29 08:19:49 +02:00
palmtree5
83080bc5a2 [V3 Mod] fix issue with unmuting (#1568) 2018-04-28 13:39:06 +10:00
Wyn
233bfc59ac [V3 Docs Arch] Upgrade dependencies (#1553)
-u parameter added to pacman for upgrading dependencies so we don't get partial upgrades.
2018-04-19 13:29:44 -08:00
Bakersbakebread
c606caf3a3 Grammar Change With -> Will (#1539)
Any message successfully forward WILL be marked...
2018-04-18 12:28:12 +02:00
30 changed files with 1005 additions and 296 deletions

25
.github/ISSUE_TEMPLATE/command_bug.md vendored Normal file
View File

@@ -0,0 +1,25 @@
# Command bugs
<!--
Did you find a bug with a command? Fill out the following:
-->
#### Command name
<!-- Replace this line with the name of the command -->
#### What cog is this command from?
<!-- Replace this line with the name of the cog -->
#### What were you expecting to happen?
<!-- Replace this line with a description of what you were expecting to happen -->
#### What actually happened?
<!-- Replace this line with a description of what actually happened. Include any error messages -->
#### How can we reproduce this issue?
<!-- Replace with numbered steps to reproduce the issue -->

35
.github/ISSUE_TEMPLATE/feature_req.md vendored Normal file
View File

@@ -0,0 +1,35 @@
# Feature request
<!-- This template is for feature requests. Please fill out the following: -->
#### Select the type of feature you are requesting:
<!-- To check a box, replace the space between the [] with a x -->
- [ ] Cog
- [ ] Command
- [ ] API functionality
#### Describe your requested feature
<!--
Feel free to describe in as much detail as you wish.
If you are requesting a cog to be included in core:
- Describe the functionality in as much detail as possible
- Include the command structure, if possible
- Please note that unless it's something that should be core functionality,
we reserve the right to reject your suggestion and point you to our cog
board to request it for a third-party cog
If you are requesting a command:
- Include what cog it should be in and a name for the command
- Describe the intended functionality for the command
- Note any restrictions on who can use the command or where it can be used
If you are requesting API functionality:
- Describe what it should do
- Note whether it is to extend existing functionality or introduce new functionality
-->

21
.github/ISSUE_TEMPLATE/other_bug.md vendored Normal file
View File

@@ -0,0 +1,21 @@
# Other bugs
<!--
Did you find a bug with something other than a command? Fill out the following:
-->
#### What were you trying to do?
<!-- Replace this line with a description of what you were trying to do -->
#### What were you expecting to happen?
<!-- Replace this line with a description of what you were expecting to happen -->
#### What actually happened?
<!-- Replace this line with a description of what actually happened. Include any error messages -->
#### How can we reproduce this issue?
<!-- Replace with numbered steps to reproduce the issue -->

14
.github/PULL_REQUEST_TEMPLATE/bugfix.md vendored Normal file
View File

@@ -0,0 +1,14 @@
# Bugfix request
<!--
To be used for pull requests that fix a bug
-->
#### Describe the bug being fixed
<!--
If an issue exists for the bug, mention
that this PR fixes that issue
-->
#### Anything we need to know about this fix?

View File

@@ -0,0 +1,20 @@
# Enhancement request
<!--
To be used for PRs which enhance existing features
-->
#### Describe the enhancement
<!--
Describe what your changes do.
If adding commands, describe any restrictions on their usage.
- For example, who can use the command? Where can it be used?
-->
#### Does this enhancement break existing functionality?
<!-- To check a box, replace the space between the [] with a x -->
- [ ] Yes
- [ ] No

View File

@@ -0,0 +1,21 @@
# New feature addition
<!--
To be used for PRs which add a new feature
Examples of this include new APIs, new core cogs, etc.
-->
#### What type of feature is this?
<!-- To check a box, replace the space between the [] with a x -->
- [ ] New core cog
- [ ] New API
- [ ] Other
#### Describe the feature
<!--
If you are adding a cog, describe its commands in detail (functionality, usage restrictions, etc).
If the new feature introduces new requirements, please try to explain why they are necessary.
-->

View File

@@ -0,0 +1,16 @@
# New release
<!--
To be used by collaborators for doing releases.
Most contributors will not need to use this.
-->
#### Version
#### Has a draft release been created for this?
- [ ] Yes
- [ ] No

View File

@@ -0,0 +1,5 @@
# Translations update
<!--
Used for PRs updating translations from Crowdin
-->

View File

@@ -16,6 +16,12 @@ Embed Helpers
.. automodule:: redbot.core.utils.embed
:members:
Menu Helpers
============
.. automodule:: redbot.core.utils.menus
:members:
Mod Helpers
===========

View File

@@ -90,6 +90,6 @@ have successfully created a cog!
Additional resources
--------------------
Be sure to check out the `migration guide </guide_migration>`_ for some resources
Be sure to check out the :doc:`/guide_migration` for some resources
on developing cogs for V3. This will also cover differences between V2 and V3 for
those who developed cogs for V2.

View File

@@ -14,7 +14,7 @@ Installing the pre-requirements
.. code-block:: none
sudo pacman -Sy python-pip git base-devel jre8-openjdk
sudo pacman -Syu python-pip git base-devel jre8-openjdk
------------------
Installing the bot

View File

@@ -1,16 +1,18 @@
import aiohttp
import asyncio
import datetime
import discord
import heapq
import lavalink
import math
import re
import redbot.core
from discord.ext import commands
from redbot.core import Config, checks, bank
from .manager import shutdown_lavalink_server
__version__ = "0.0.5"
__version__ = "0.0.5a"
__author__ = ["aikaterna", "billy/bollo/ati"]
@@ -46,6 +48,7 @@ class Audio:
self.config.register_guild(**default_guild)
self.config.register_global(**default_global)
self.skip_votes = {}
self.session = aiohttp.ClientSession()
async def init_config(self):
host = await self.config.host()
@@ -96,10 +99,11 @@ class Audio:
await self.bot.change_presence(activity=None)
if playing_servers == 1:
await self.bot.change_presence(activity=discord.Activity(name=get_single_title,
type=discord.ActivityType.listening))
type=discord.ActivityType.listening))
if playing_servers > 1:
await self.bot.change_presence(activity=discord.Activity(name='music in {} servers'.format(playing_servers),
type=discord.ActivityType.playing))
await self.bot.change_presence(
activity=discord.Activity(name='music in {} servers'.format(playing_servers),
type=discord.ActivityType.playing))
if event_type == lavalink.LavalinkEvents.QUEUE_END and notify:
notify_channel = player.fetch('channel')
@@ -113,10 +117,11 @@ class Audio:
await self.bot.change_presence(activity=None)
if playing_servers == 1:
await self.bot.change_presence(activity=discord.Activity(name=get_single_title,
type=discord.ActivityType.listening))
type=discord.ActivityType.listening))
if playing_servers > 1:
await self.bot.change_presence(activity=discord.Activity(name='music in {} servers'.format(playing_servers),
type=discord.ActivityType.playing))
await self.bot.change_presence(
activity=discord.Activity(name='music in {} servers'.format(playing_servers),
type=discord.ActivityType.playing))
if event_type == lavalink.LavalinkEvents.TRACK_EXCEPTION:
message_channel = player.fetch('channel')
@@ -124,7 +129,7 @@ class Audio:
message_channel = self.bot.get_channel(message_channel)
embed = discord.Embed(colour=message_channel.guild.me.top_role.colour, title='Track Error',
description='{}\n**[{}]({})**'.format(extra, player.current.title,
player.current.uri))
player.current.uri))
embed.set_footer(text='Skipping...')
await message_channel.send(embed=embed)
await player.skip()
@@ -143,7 +148,6 @@ class Audio:
dj_role_id = await self.config.guild(ctx.guild).dj_role()
if dj_role_id is None:
await self._embed_msg(ctx, 'Please set a role to use with DJ mode. Enter the role name now.')
def check(m):
return m.author == ctx.author
try:
@@ -172,8 +176,6 @@ class Audio:
@checks.mod_or_permissions(administrator=True)
async def jukebox(self, ctx, price: int):
"""Set a price for queueing songs for non-mods. 0 to disable."""
jukebox = await self.config.guild(ctx.guild).jukebox()
jukebox_price = await self.config.guild(ctx.guild).jukebox_price()
if price < 0:
return await self._embed_msg(ctx, 'Can\'t be less than zero.')
if price == 0:
@@ -182,7 +184,7 @@ class Audio:
else:
jukebox = True
await self._embed_msg(ctx, 'Track queueing command price set to {} {}.'.format(
price, await bank.get_currency_name(ctx.guild)))
price, await bank.get_currency_name(ctx.guild)))
await self.config.guild(ctx.guild).jukebox_price.set(price)
await self.config.guild(ctx.guild).jukebox.set(jukebox)
@@ -266,10 +268,10 @@ class Audio:
connect_dur = self._dynamic_time(int((datetime.datetime.utcnow() - connect_start).total_seconds()))
try:
server_list.append('{} [`{}`]: **[{}]({})**'.format(p.channel.guild.name, connect_dur,
p.current.title, p.current.uri))
p.current.title, p.current.uri))
except AttributeError:
server_list.append('{} [`{}`]: **{}**'.format(p.channel.guild.name, connect_dur,
'Nothing playing.'))
'Nothing playing.'))
if server_num == 0:
servers = 'Not connected anywhere.'
else:
@@ -286,7 +288,7 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to bump a song.')
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author):
@@ -363,6 +365,7 @@ class Audio:
def check(r, u):
return r.message.id == message.id and u == ctx.message.author
try:
(r, u) = await self.bot.wait_for('reaction_add', check=check, timeout=10.0)
except asyncio.TimeoutError:
@@ -390,7 +393,7 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to pause the music.')
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author):
@@ -487,11 +490,13 @@ class Audio:
player.store('guild', ctx.guild.id)
await self._data_check(ctx)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to use the play command.')
if not await self._currency_check(ctx, jukebox_price):
return
if not query:
return await self._embed_msg(ctx, 'No songs to play.')
query = query.strip('<>')
if not query.startswith('http'):
query = 'ytsearch:{}'.format(query)
@@ -510,7 +515,8 @@ class Audio:
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist Enqueued',
description='Added {} tracks to the queue.'.format(len(tracks)))
if not shuffle and queue_duration > 0:
embed.set_footer(text='{} until start of playlist playback: starts at #{} in queue'.format(queue_total_duration, before_queue_length))
embed.set_footer(text='{} until start of playlist playback: starts at #{} in queue'.format(
queue_total_duration, before_queue_length))
if not player.current:
await player.play()
else:
@@ -519,7 +525,8 @@ class Audio:
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Track Enqueued',
description='**[{}]({})**'.format(single_track.title, single_track.uri))
if not shuffle and queue_duration > 0:
embed.set_footer(text='{} until track playback: #{} in queue'.format(queue_total_duration, before_queue_length))
embed.set_footer(text='{} until track playback: #{} in queue'.format(
queue_total_duration, before_queue_length))
if not player.current:
await player.play()
await ctx.send(embed=embed)
@@ -531,17 +538,60 @@ class Audio:
if ctx.invoked_subcommand is None:
await ctx.send_help()
@playlist.command(name='append')
async def _playlist_append(self, ctx, playlist_name, *url):
"""Add a song URL, playlist link, or quick search to the end of a saved playlist."""
if not await self._playlist_check(ctx):
return
async with self.config.guild(ctx.guild).playlists() as playlists:
try:
if (playlists[playlist_name]['author'] != ctx.author.id and not
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You are not the author of that playlist.')
player = lavalink.get_player(ctx.guild.id)
to_append = await self._playlist_tracks(ctx, player, url)
if not to_append:
return
track_list = playlists[playlist_name]['tracks']
if track_list:
playlists[playlist_name]['tracks'] = track_list + to_append
else:
playlists[playlist_name]['tracks'] = to_append
except KeyError:
return await self._embed_msg(ctx, 'No playlist with that name.')
if playlists[playlist_name]['playlist_url'] is not None:
playlists[playlist_name]['playlist_url'] = None
if len(to_append) == 1:
track_title = to_append[0]['info']['title']
return await self._embed_msg(ctx, '{} appended to {}.'.format(track_title, playlist_name))
await self._embed_msg(ctx, '{} tracks appended to {}.'.format(len(to_append), playlist_name))
@playlist.command(name='create')
async def _playlist_create(self, ctx, playlist_name):
"""Create an empty playlist."""
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author):
return await self._embed_msg(ctx, 'You need the DJ role to save playlists.')
async with self.config.guild(ctx.guild).playlists() as playlists:
if playlist_name in playlists:
return await self._embed_msg(ctx, 'Playlist name already exists, try again with a different name.')
playlist_list = self._to_json(ctx, None, None)
playlists[playlist_name] = playlist_list
await self._embed_msg(ctx, 'Empty playlist {} created.'.format(playlist_name))
@playlist.command(name='delete')
async def _playlist_delete(self, ctx, playlist_name):
"""Delete a saved playlist."""
async with self.config.guild(ctx.guild).playlists() as playlists:
try:
if playlists[playlist_name]['author'] != ctx.author.id and not await self._can_instaskip(ctx, ctx.author):
if (playlists[playlist_name]['author'] != ctx.author.id and not
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You are not the author of that playlist.')
del playlists[playlist_name]
except KeyError:
return await self._embed_msg(ctx, 'No playlist with that name.')
await self._embed_msg(ctx, '{} playlist removed.'.format(playlist_name))
await self._embed_msg(ctx, '{} playlist deleted.'.format(playlist_name))
@playlist.command(name='info')
async def _playlist_info(self, ctx, playlist_name):
@@ -556,18 +606,15 @@ class Audio:
try:
track_len = len(playlists[playlist_name]['tracks'])
except TypeError:
track_len = 1
track_len = 0
if playlist_url is None:
playlist_url = '**Not generated from a URL.**'
playlist_url = '**Custom playlist.**'
else:
playlist_url = 'URL: <{}>'.format(playlist_url)
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist info for {}:'.format(playlist_name),
description='Author: **{}**\n{}'.format(author_obj,
playlist_url))
if track_len > 1:
embed.set_footer(text='{} tracks'.format(track_len))
if track_len == 1:
embed.set_footer(text='{} track'.format(track_len))
playlist_url))
embed.set_footer(text='{} track(s)'.format(track_len))
await ctx.send(embed=embed)
@playlist.command(name='list')
@@ -597,11 +644,11 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
tracklist = []
np_song = self._track_creator(ctx, player, 'np', None)
np_song = self._track_creator(player, 'np')
tracklist.append(np_song)
for track in player.queue:
queue_idx = player.queue.index(track)
track_obj = self._track_creator(ctx, player, queue_idx, None)
track_obj = self._track_creator(player, queue_idx)
tracklist.append(track_obj)
if not playlist_name:
await self._embed_msg(ctx, 'Please enter a name for this playlist.')
@@ -616,11 +663,38 @@ class Audio:
return await self._embed_msg(ctx, 'Playlist name already exists, try again with a different name.')
except asyncio.TimeoutError:
return await self._embed_msg(ctx, 'No playlist name entered, try again later.')
playlist_list = self._to_json(ctx, None, tracklist, playlist_name)
playlist_list = self._to_json(ctx, None, tracklist)
async with self.config.guild(ctx.guild).playlists() as playlists:
playlists[playlist_name] = playlist_list
await self._embed_msg(ctx, 'Playlist {} saved from current queue: {} tracks added.'.format(playlist_name, len(tracklist)))
await self._embed_msg(ctx, 'Playlist {} saved from current queue: {} tracks added.'.format(
playlist_name, len(tracklist)))
@playlist.command(name='remove')
async def _playlist_remove(self, ctx, playlist_name, url):
"""Remove a song from a playlist by url."""
async with self.config.guild(ctx.guild).playlists() as playlists:
try:
if (playlists[playlist_name]['author'] != ctx.author.id and not
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You are not the author of that playlist.')
except KeyError:
return await self._embed_msg(ctx, 'No playlist with that name.')
track_list = playlists[playlist_name]['tracks']
clean_list = [track for track in track_list if not url == track['info']['uri']]
if len(playlists[playlist_name]['tracks']) == len(clean_list):
return await self._embed_msg(ctx, 'URL not in playlist.')
del_count = len(playlists[playlist_name]['tracks']) - len(clean_list)
if not clean_list:
del playlists[playlist_name]
return await self._embed_msg(ctx, 'No songs left, removing playlist.')
playlists[playlist_name]['tracks'] = clean_list
if playlists[playlist_name]['playlist_url'] is not None:
playlists[playlist_name]['playlist_url'] = None
if del_count > 1:
await self._embed_msg(ctx, '{} entries have been removed from the {} playlist.'.format(
del_count, playlist_name))
else:
await self._embed_msg(ctx, 'The track has been removed from the {} playlist.'.format(playlist_name))
@playlist.command(name='save')
async def _playlist_save(self, ctx, playlist_name, playlist_url):
@@ -628,18 +702,13 @@ class Audio:
if not await self._playlist_check(ctx):
return
player = lavalink.get_player(ctx.guild.id)
tracks = await player.get_tracks(playlist_url)
if not tracks:
return await self._embed_msg(ctx, 'Nothing found.')
tracklist = []
for track in tracks:
track_obj = self._track_creator(ctx, player, None, track)
tracklist.append(track_obj)
playlist_list = self._to_json(ctx, playlist_url, tracklist, playlist_name)
async with self.config.guild(ctx.guild).playlists() as playlists:
playlists[playlist_name] = playlist_list
return await self._embed_msg(ctx, 'Playlist {} saved: {} tracks added.'.format(playlist_name, len(tracks)))
tracklist = await self._playlist_tracks(ctx, player, playlist_url)
playlist_list = self._to_json(ctx, playlist_url, tracklist)
if tracklist is not None:
async with self.config.guild(ctx.guild).playlists() as playlists:
playlists[playlist_name] = playlist_list
return await self._embed_msg(ctx, 'Playlist {} saved: {} tracks added.'.format(
playlist_name, len(tracklist)))
@playlist.command(name='start')
async def _playlist_start(self, ctx, playlist_name=None):
@@ -647,25 +716,89 @@ class Audio:
if not await self._playlist_check(ctx):
return
playlists = await self.config.guild(ctx.guild).playlists.get_raw()
try:
author_id = playlists[playlist_name]["author"]
except KeyError:
return await self._embed_msg(ctx, 'That playlist doesn\'t exist.')
author_obj = self.bot.get_user(author_id)
author_obj = self.bot.get_user(ctx.author.id)
track_count = 0
try:
playlist_len = len(playlists[playlist_name]["tracks"])
player = lavalink.get_player(ctx.guild.id)
for track in playlists[playlist_name]["tracks"]:
player.add(author_obj, lavalink.rest_api.Track(data=track))
track_count = track_count + 1
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist Enqueued',
description='Added {} tracks to the queue.'.format(track_count))
description='Added {} tracks to the queue.'.format(track_count))
await ctx.send(embed=embed)
if not player.current:
await player.play()
except TypeError:
await ctx.invoke(self.play, query=playlists[playlist_name]["playlist_url"])
except KeyError:
await self._embed_msg(ctx, 'That playlist doesn\'t exist.')
@checks.is_owner()
@playlist.command(name='upload')
async def _playlist_upload(self, ctx):
"""Convert a Red v2 playlist file to a playlist."""
if not await self._playlist_check(ctx):
return
player = lavalink.get_player(ctx.guild.id)
await self._embed_msg(ctx, 'Please upload the playlist file. Any other message will cancel this operation.')
def check(m):
return m.author == ctx.author
try:
file_message = await ctx.bot.wait_for('message', timeout=30.0, check=check)
except asyncio.TimeoutError:
return await self._embed_msg(ctx, 'No file detected, try again later.')
try:
file_url = file_message.attachments[0].url
except IndexError:
return await self._embed_msg(ctx, 'Upload canceled.')
v2_playlist_name = (file_url.split('/')[6]).split('.')[0]
file_suffix = file_url.rsplit('.', 1)[1]
if file_suffix != "txt":
return await self._embed_msg(ctx, 'Only playlist files can be uploaded.')
async with self.session.request('GET', file_url) as r:
v2_playlist = await r.json(content_type='text/plain')
try:
v2_playlist_url = v2_playlist["link"]
except KeyError:
v2_playlist_url = None
if (not v2_playlist_url or not self._match_yt_playlist(v2_playlist_url) or not
await player.get_tracks(v2_playlist_url)):
track_list = []
track_count = 0
async with self.config.guild(ctx.guild).playlists() as v3_playlists:
try:
if v3_playlists[v2_playlist_name]:
return await self._embed_msg(ctx, 'A playlist already exists with this name.')
except KeyError:
pass
embed1 = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Please wait, adding tracks...')
playlist_msg = await ctx.send(embed=embed1)
for song_url in v2_playlist["playlist"]:
track = await player.get_tracks(song_url)
try:
track_obj = self._track_creator(player, other_track=track[0])
track_list.append(track_obj)
track_count = track_count + 1
except IndexError:
pass
if track_count % 5 == 0:
embed2 = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Loading track {}/{}...'.format(
track_count, len(v2_playlist["playlist"])))
await playlist_msg.edit(embed=embed2)
if not track_list:
return await self._embed_msg(ctx, 'No tracks found.')
playlist_list = self._to_json(ctx, v2_playlist_url, track_list)
v3_playlists[v2_playlist_name] = playlist_list
if len(v2_playlist["playlist"]) != track_count:
bad_tracks = len(v2_playlist["playlist"]) - track_count
msg = ('Added {} tracks from the {} playlist. {} track(s) could not '
'be loaded.'.format(track_count, v2_playlist_name, bad_tracks))
else:
msg = 'Added {} tracks from the {} playlist.'.format(track_count, v2_playlist_name)
embed3 = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Playlist Saved', description=msg)
await playlist_msg.edit(embed=embed3)
else:
await ctx.invoke(self._playlist_save, v2_playlist_name, v2_playlist_url)
async def _playlist_check(self, ctx):
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
@@ -686,7 +819,7 @@ class Audio:
player.store('channel', ctx.channel.id)
player.store('guild', ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
await self._embed_msg(ctx, 'You must be in the voice channel to use the playlist command.')
return False
if not await self._currency_check(ctx, jukebox_price):
@@ -694,6 +827,27 @@ class Audio:
await self._data_check(ctx)
return True
async def _playlist_tracks(self, ctx, player, query):
search = False
if type(query) is tuple:
query = " ".join(query)
if not query.startswith('http'):
query = " ".join(query)
query = 'ytsearch:{}'.format(query)
search = True
tracks = await player.get_tracks(query)
if not tracks:
return await self._embed_msg(ctx, 'Nothing found.')
tracklist = []
if not search:
for track in tracks:
track_obj = self._track_creator(player, other_track=track)
tracklist.append(track_obj)
else:
track_obj = self._track_creator(player, other_track=tracks[0])
tracklist.append(track_obj)
return tracklist
@commands.command()
async def prev(self, ctx):
"""Skips to the start of the previously played track."""
@@ -706,7 +860,7 @@ class Audio:
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author):
return await self._embed_msg(ctx, 'You need the DJ role to skip songs.')
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to skip the music.')
if shuffle:
return await self._embed_msg(ctx, 'Turn shuffle off to use this command.')
@@ -773,8 +927,8 @@ class Audio:
for i, track in enumerate(player.queue[start:end], start=start):
req_user = track.requester
next = i + 1
queue_list += '`{}.` **[{}]({})**, requested by **{}**\n'.format(next, track.title, track.uri, req_user)
_next = i + 1
queue_list += '`{}.` **[{}]({})**, requested by **{}**\n'.format(_next, track.title, track.uri, req_user)
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Queue for ' + ctx.guild.name,
description=queue_list)
@@ -802,7 +956,7 @@ class Audio:
await self._data_check(ctx)
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to toggle repeat.')
await self._embed_msg(ctx, 'Repeat songs: {}.'.format(repeat))
@@ -819,7 +973,7 @@ class Audio:
if not await self._can_instaskip(ctx, ctx.author):
return await self._embed_msg(ctx, 'You need the DJ role to remove songs.')
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to manage the queue.')
if index > len(player.queue) or index < 1:
return await self._embed_msg(ctx, 'Song number must be greater than 1 and within the queue limit.')
@@ -852,7 +1006,7 @@ class Audio:
player.store('channel', ctx.channel.id)
player.store('guild', ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to enqueue songs.')
query = query.strip('<>')
@@ -872,8 +1026,8 @@ class Audio:
end = start + items_per_page
search_list = ''
for i, track in enumerate(tracks[start:end], start=start):
next = i + 1
search_list += '`{0}.` [**{1}**]({2})\n'.format(next, track.title,
_next = i + 1
search_list += '`{0}.` [**{1}**]({2})\n'.format(_next, track.title,
track.uri)
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Tracks Found:', description=search_list)
@@ -906,7 +1060,8 @@ class Audio:
queue_duration = await self._queue_duration(ctx)
queue_total_duration = lavalink.utils.format_time(queue_duration)
if not shuffle and queue_duration > 0:
songembed.set_footer(text='{} until start of search playback: starts at #{} in queue'.format(queue_total_duration, (len(player.queue) + 1)))
songembed.set_footer(text='{} until start of search playback: starts at #{} in queue'.format(
queue_total_duration, (len(player.queue) + 1)))
for track in tracks:
player.add(ctx.author, track)
if not player.current:
@@ -926,7 +1081,8 @@ class Audio:
queue_duration = await self._queue_duration(ctx)
queue_total_duration = lavalink.utils.format_time(queue_duration)
if not shuffle and queue_duration > 0:
embed.set_footer(text='{} until track playback: #{} in queue'.format(queue_total_duration, (len(player.queue) + 1)))
embed.set_footer(text='{} until track playback: #{} in queue'.format(queue_total_duration, (
len(player.queue) + 1)))
player.add(ctx.author, search_choice)
if not player.current:
await player.play()
@@ -940,7 +1096,7 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to use seek.')
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author):
@@ -973,7 +1129,7 @@ class Audio:
await self._data_check(ctx)
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to toggle shuffle.')
await self._embed_msg(ctx, 'Shuffle songs: {}.'.format(shuffle))
@@ -984,7 +1140,7 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to skip the music.')
dj_enabled = await self.config.guild(ctx.guild).dj_enabled()
vote_enabled = await self.config.guild(ctx.guild).vote_enabled()
@@ -1052,11 +1208,11 @@ class Audio:
nonbots = sum(not m.bot for m in ctx.guild.get_member(self.bot.user.id).voice.channel.members)
if nonbots == 1:
nonbots = 2
elif ctx.guild.get_member(member.id).voice.channel.members == 1:
nonbots = 1
else:
if ctx.guild.get_member(member.id).voice.channel.members == 1:
nonbots = 1
alone = nonbots <= 1
return alone
nonbots = 0
return nonbots <= 1
async def _has_dj_role(self, ctx, member):
dj_role_id = await self.config.guild(ctx.guild).dj_role()
@@ -1098,7 +1254,7 @@ class Audio:
return await self._embed_msg(ctx, 'Nothing playing.')
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to stop the music.')
if vote_enabled or vote_enabled and dj_enabled:
if not await self._can_instaskip(ctx, ctx.author) and not await self._is_alone(ctx, ctx.author):
@@ -1128,7 +1284,7 @@ class Audio:
if self._player_check(ctx):
player = lavalink.get_player(ctx.guild.id)
if ((not ctx.author.voice or ctx.author.voice.channel != player.channel) and not
await self._can_instaskip(ctx, ctx.author)):
await self._can_instaskip(ctx, ctx.author)):
return await self._embed_msg(ctx, 'You must be in the voice channel to change the volume.')
if dj_enabled:
if not await self._can_instaskip(ctx, ctx.author) and not await self._has_dj_role(ctx, ctx.author):
@@ -1165,7 +1321,8 @@ class Audio:
await self.config.password.set('youshallnotpass')
await self.config.rest_port.set(2333)
await self.config.ws_port.set(2332)
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='External lavalink server: {}.'.format(not external))
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='External lavalink server: {}.'.format(
not external))
embed.set_footer(text='Defaults reset.')
return await ctx.send(embed=embed)
else:
@@ -1187,7 +1344,8 @@ class Audio:
"""Set the lavalink server password."""
await self.config.password.set(str(password))
if await self._check_external():
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Server password set to {}.'.format(password))
embed = discord.Embed(colour=ctx.guild.me.top_role.colour,
title='Server password set to {}.'.format(password))
embed.set_footer(text='External lavalink server set to True.')
await ctx.send(embed=embed)
else:
@@ -1209,7 +1367,8 @@ class Audio:
"""Set the lavalink websocket server port."""
await self.config.rest_port.set(ws_port)
if await self._check_external():
embed = discord.Embed(colour=ctx.guild.me.top_role.colour, title='Websocket port set to {}.'.format(ws_port))
embed = discord.Embed(colour=ctx.guild.me.top_role.colour,
title='Websocket port set to {}.'.format(ws_port))
embed.set_footer(text='External lavalink server set to True.')
await ctx.send(embed=embed)
else:
@@ -1255,7 +1414,8 @@ class Audio:
if player.volume != volume:
await player.set_volume(volume)
async def _draw_time(self, ctx):
@staticmethod
async def _draw_time(ctx):
player = lavalink.get_player(ctx.guild.id)
paused = player.paused
pos = player.position
@@ -1305,6 +1465,15 @@ class Audio:
else:
return 0
@staticmethod
def _match_yt_playlist(url):
yt_list_playlist = re.compile(
r'^(https?\:\/\/)?(www\.)?(youtube\.com|youtu\.?be)'
r'(\/playlist\?).*(list=)(.*)(&|$)')
if yt_list_playlist.match(url):
return True
return False
@staticmethod
async def _queue_duration(ctx):
player = lavalink.get_player(ctx.guild.id)
@@ -1312,7 +1481,7 @@ class Audio:
for i in range(len(player.queue)):
if not player.queue[i].is_stream:
duration.append(player.queue[i].length)
queue_duration = sum(duration)
queue_duration = sum(duration)
if not player.queue:
queue_duration = 0
try:
@@ -1333,14 +1502,16 @@ class Audio:
except KeyError:
return False
def _to_json(self, ctx, playlist_url, tracklist, playlist_name):
@staticmethod
def _to_json(ctx, playlist_url, tracklist):
playlist = {"author": ctx.author.id, "playlist_url": playlist_url, "tracks": tracklist}
return playlist
def _track_creator(self, ctx, player, position, other_track=None):
@staticmethod
def _track_creator(player, position=None, other_track=None):
if position == 'np':
queued_track = player.current
elif position == None:
elif position is None:
queued_track = other_track
else:
queued_track = player.queue[position]
@@ -1365,6 +1536,7 @@ class Audio:
pass
def __unload(self):
self.session.close()
lavalink.unregister_event_listener(self.event_handler)
self.bot.loop.create_task(lavalink.close())
shutdown_lavalink_server()

View File

@@ -92,4 +92,5 @@ def shutdown_lavalink_server():
global proc
if proc is not None:
proc.terminate()
proc.wait()
proc = None

View File

@@ -124,26 +124,34 @@ class Cleanup:
@cleanup.command()
@commands.guild_only()
@commands.bot_has_permissions(manage_messages=True)
async def user(self, ctx: RedContext, user: discord.Member or int, number: int):
async def user(self, ctx: RedContext, user: str, number: int):
"""Deletes last X messages from specified user.
Examples:
cleanup user @\u200bTwentysix 2
cleanup user Red 6"""
try:
member = await commands.converter.MemberConverter().convert(ctx, user)
except commands.BadArgument:
try:
_id = int(user)
except ValueError:
raise commands.BadArgument()
else:
_id = member.id
channel = ctx.channel
author = ctx.author
is_bot = self.bot.user.bot
if number > 100:
cont = await self.check_100_plus(ctx, number)
if not cont:
return
def check(m):
if isinstance(user, discord.Member) and m.author == user:
return True
elif m.author.id == user: # Allow finding messages based on an ID
if m.author.id == _id:
return True
elif m == ctx.message:
return True
@@ -156,7 +164,7 @@ class Cleanup:
reason = "{}({}) deleted {} messages "\
" made by {}({}) in channel {}."\
"".format(author.name, author.id, len(to_delete),
user.name, user.id, channel.name)
member or '???', _id, channel.name)
log.info(reason)
if is_bot:

View File

@@ -256,7 +256,8 @@ class Mod:
@commands.guild_only()
async def deletedelay(self, ctx: RedContext, time: int=None):
"""Sets the delay until the bot removes the command message.
Must be between -1 and 60.
Must be between -1 and 60.
A delay of -1 means the bot will not remove the message."""
guild = ctx.guild
@@ -281,10 +282,10 @@ class Mod:
@modset.command()
@commands.guild_only()
async def reinvite(self, ctx: RedContext):
"""Toggles whether an invite will be sent when a user
is unbanned via [p]unban. If this is True, the bot will
attempt to create and send a single-use invite to the
newly-unbanned user"""
"""Toggles whether an invite will be sent when a user is unbanned via [p]unban.
If this is True, the bot will attempt to create and send a single-use invite
to the newly-unbanned user"""
guild = ctx.guild
cur_setting = await self.settings.guild(guild).reinvite_on_unban()
if not cur_setting:
@@ -299,8 +300,8 @@ class Mod:
@checks.admin_or_permissions(kick_members=True)
async def kick(self, ctx: RedContext, user: discord.Member, *, reason: str = None):
"""Kicks user.
If a reason is specified, it
will be the reason that shows up
If a reason is specified, it will be the reason that shows up
in the audit log"""
author = ctx.author
guild = ctx.guild

View File

@@ -2,7 +2,8 @@ import logging
import asyncio
from typing import Union
from datetime import timedelta
from copy import copy
import contextlib
import discord
from discord.ext import commands
@@ -109,10 +110,11 @@ class Reports:
ret |= await self.bot.is_owner(m)
return ret
async def discover_guild(self, author: discord.User, *,
mod: bool=False,
permissions: Union[discord.Permissions, dict]={},
prompt: str=""):
async def discover_guild(
self, author: discord.User, *,
mod: bool=False,
permissions: Union[discord.Permissions, dict]=None,
prompt: str=""):
"""
discovers which of shared guilds between the bot
and provided user based on conditions (mod or permissions is an or)
@@ -120,10 +122,12 @@ class Reports:
prompt is for providing a user prompt for selection
"""
shared_guilds = []
if isinstance(permissions, discord.Permissions):
if permissions is None:
perms = discord.Permissions()
elif isinstance(permissions, discord.Permissions):
perms = permissions
else:
permissions = discord.Permissions(**perms)
perms = discord.Permissions(**permissions)
for guild in self.bot.guilds:
x = guild.get_member(author.id)
@@ -170,26 +174,40 @@ class Reports:
author = guild.get_member(msg.author.id)
report = msg.clean_content
avatar = author.avatar_url
em = discord.Embed(description=report)
em.set_author(
name=_('Report from {0.display_name}').format(author),
icon_url=avatar
)
ticket_number = await self.config.guild(guild).next_ticket()
await self.config.guild(guild).next_ticket.set(ticket_number + 1)
em.set_footer(text=_("Report #{}").format(ticket_number))
channel_id = await self.config.guild(guild).output_channel()
channel = guild.get_channel(channel_id)
if channel is not None:
try:
await channel.send(embed=em)
except (discord.Forbidden, discord.HTTPException):
return None
if channel is None:
return None
files = await Tunnel.files_from_attatch(msg)
ticket_number = await self.config.guild(guild).next_ticket()
await self.config.guild(guild).next_ticket.set(ticket_number + 1)
if await self.bot.embed_requested(channel, author):
em = discord.Embed(description=report)
em.set_author(
name=_('Report from {0.display_name}').format(author),
icon_url=author.avatar_url
)
em.set_footer(text=_("Report #{}").format(ticket_number))
send_content = None
else:
em = None
send_content = _(
'Report from {author.mention} (Ticket #{number})'
).format(author=author, number=ticket_number)
send_content += "\n" + report
try:
await Tunnel.message_forwarder(
destination=channel,
content=send_content,
embed=em,
files=files
)
except (discord.Forbidden, discord.HTTPException):
return None
await self.config.custom('REPORT', guild.id, ticket_number).report.set(
@@ -198,8 +216,13 @@ class Reports:
return ticket_number
@commands.group(name="report", invoke_without_command=True)
async def report(self, ctx: RedContext):
"Follow the prompts to make a report"
async def report(self, ctx: RedContext, *, _report: str=""):
"""
Follow the prompts to make a report
optionally use with a report message
to use it non interactively
"""
author = ctx.author
guild = ctx.guild
if guild is None:
@@ -243,31 +266,39 @@ class Reports:
pass
self.user_cache.append(author.id)
try:
dm = await author.send(
_("Please respond to this message with your Report."
"\nYour report should be a single message")
)
except discord.Forbidden:
await ctx.send(
_("This requires DMs enabled.")
)
self.user_cache.remove(author.id)
return
def pred(m):
return m.author == author and m.channel == dm.channel
try:
message = await self.bot.wait_for(
'message', check=pred, timeout=180
)
except asyncio.TimeoutError:
await author.send(
_("You took too long. Try again later.")
)
if _report:
_m = copy(ctx.message)
_m.content = _report
_m.content = _m.clean_content
val = await self.send_report(_m, guild)
else:
val = await self.send_report(message, guild)
try:
dm = await author.send(
_("Please respond to this message with your Report."
"\nYour report should be a single message")
)
except discord.Forbidden:
await ctx.send(
_("This requires DMs enabled.")
)
self.user_cache.remove(author.id)
return
def pred(m):
return m.author == author and m.channel == dm.channel
try:
message = await self.bot.wait_for(
'message', check=pred, timeout=180
)
except asyncio.TimeoutError:
await author.send(
_("You took too long. Try again later.")
)
else:
val = await self.send_report(message, guild)
with contextlib.suppress(discord.Forbidden, discord.HTTPException):
if val is None:
await author.send(
_("There was an error sending your report.")
@@ -276,7 +307,7 @@ class Reports:
await author.send(
_("Your report was submitted. (Ticket #{})").format(val)
)
self.antispam[guild.id][author.id].stamp()
self.antispam[guild.id][author.id].stamp()
self.user_cache.remove(author.id)
@@ -353,7 +384,7 @@ class Reports:
"will be forwarded to them until the communication is closed.\n"
"You can close a communication at any point "
"by reacting with the X to the last message recieved. "
"\nAny message succesfully forwarded with be marked with a check."
"\nAny message succesfully forwarded will be marked with a check."
"\nTunnels are not persistent across bot restarts."
)
topic = big_topic.format(

View File

@@ -17,8 +17,8 @@ async def warning_points_add_check(config: Config, ctx: RedContext, user: discor
act = {}
async with guild_settings.actions() as registered_actions:
for a in registered_actions:
if points >= registered_actions[a]["point_count"]:
act = registered_actions[a]
if points >= a["points"]:
act = a
else:
break
if act: # some action needs to be taken
@@ -31,8 +31,8 @@ async def warning_points_remove_check(config: Config, ctx: RedContext, user: dis
act = {}
async with guild_settings.actions() as registered_actions:
for a in registered_actions:
if points >= registered_actions[a]["point_count"]:
act = registered_actions[a]
if points >= a["points"]:
act = a
else:
break
if act: # some action needs to be taken

View File

@@ -120,7 +120,7 @@ class Warnings:
registered_actions.append(to_add)
# Sort in descending order by point count for ease in
# finding the highest possible action to take
registered_actions.sort(key=lambda a: a["point_count"], reverse=True)
registered_actions.sort(key=lambda a: a["points"], reverse=True)
await ctx.tick()
@warnaction.command(name="del")
@@ -137,6 +137,11 @@ class Warnings:
break
if to_remove:
registered_actions.remove(to_remove)
await ctx.tick()
else:
await ctx.send(
_("No action named {} exists!").format(action_name)
)
@commands.group()
@commands.guild_only()
@@ -178,7 +183,7 @@ class Warnings:
guild_settings = self.config.guild(guild)
async with guild_settings.reasons() as registered_reasons:
if registered_reasons.pop(reason_name.lower(), None):
await ctx.send(_("Removed reason {}").format(reason_name))
await ctx.tick()
else:
await ctx.send(_("That is not a registered reason name"))
@@ -191,13 +196,16 @@ class Warnings:
guild_settings = self.config.guild(guild)
msg_list = []
async with guild_settings.reasons() as registered_reasons:
for r in registered_reasons.keys():
for r, v in registered_reasons.items():
msg_list.append(
"Name: {}\nPoints: {}\nAction: {}".format(
r, r["points"], r["action"]
"Name: {}\nPoints: {}\nDescription: {}".format(
r, v["points"], v["description"]
)
)
await ctx.send_interactive(msg_list)
if msg_list:
await ctx.send_interactive(msg_list)
else:
await ctx.send(_("There are no reasons configured!"))
@commands.command()
@commands.guild_only()
@@ -210,11 +218,16 @@ class Warnings:
async with guild_settings.actions() as registered_actions:
for r in registered_actions:
msg_list.append(
"Name: {}\nPoints: {}\nDescription: {}".format(
r, r["points"], r["description"]
"Name: {}\nPoints: {}\nExceed command: {}\n"
"Drop command: {}".format(
r["action_name"], r["points"], r["exceed_command"],
r["drop_command"]
)
)
await ctx.send_interactive(msg_list)
if msg_list:
await ctx.send_interactive(msg_list)
else:
await ctx.send(_("There are no actions configured!"))
@commands.command()
@commands.guild_only()
@@ -271,7 +284,7 @@ class Warnings:
if userid is None:
user = ctx.author
else:
if not is_admin_or_superior(self.bot, ctx.author):
if not await is_admin_or_superior(self.bot, ctx.author):
await ctx.send(
warning(
_("You are not allowed to check "

View File

@@ -33,5 +33,5 @@ class VersionInfo:
def to_json(self):
return [self.major, self.minor, self.micro, self.releaselevel, self.serial]
__version__ = "3.0.0b12"
version_info = VersionInfo(3, 0, 0, 'beta', 12)
__version__ = "3.0.0b13"
version_info = VersionInfo(3, 0, 0, 'beta', 13)

View File

@@ -137,3 +137,32 @@ class RedContext(commands.Context):
return await self.bot.embed_requested(
self.channel, self.author, command=self.command
)
async def maybe_send_embed(self, message: str) -> discord.Message:
"""
Simple helper to send a simple message to context
without manually checking ctx.embed_requested
This should only be used for simple messages.
Parameters
----------
message: `str`
The string to send
Returns
-------
discord.Message:
the message which was sent
Raises
------
discord.Forbidden
see `discord.abc.Messageable.send`
discord.HTTPException
see `discord.abc.Messageable.send`
"""
if await self.embed_requested():
return await self.send(embed=discord.Embed(description=message))
else:
return await self.send(message)

View File

@@ -72,7 +72,7 @@ class Core:
owner = app_info.owner
async with aiohttp.ClientSession() as session:
async with session.get("http://pypi.python.org/pypi/red-discordbot/json") as r:
async with session.get('{}/json'.format(red_pypi)) as r:
data = await r.json()
outdated = StrictVersion(data["info"]["version"]) > StrictVersion(__version__)
about = (
@@ -280,7 +280,7 @@ class Core:
guilds = sorted(list(self.bot.guilds),
key=lambda s: s.name.lower())
msg = ""
for i, server in enumerate(guilds):
for i, server in enumerate(guilds, 1):
msg += "{}: {}\n".format(i, server.name)
msg += "\nTo leave a server, just type its number."
@@ -313,6 +313,9 @@ class Core:
try:
msg = await self.bot.wait_for("message", check=conf_check, timeout=15)
if msg.content.lower().strip() in ("yes", "y"):
if server.owner == ctx.bot.user:
await ctx.send("I cannot leave a guild I am the owner of.")
return
await server.leave()
if server != ctx.guild:
await ctx.send("Done.")
@@ -875,7 +878,7 @@ class Core:
if data_dir.exists():
home = data_dir.home()
backup_file = home / backup_filename
os.chdir(data_dir.parent)
os.chdir(str(data_dir.parent))
with tarfile.open(str(backup_file), "w:gz") as tar:
tar.add(data_dir.stem)
await ctx.send(_("A backup has been made of this instance. It is at {}.").format(

View File

@@ -110,7 +110,7 @@ def init_events(bot, cli_flags):
INFO.append('{} cogs with {} commands'.format(len(bot.cogs), len(bot.commands)))
async with aiohttp.ClientSession() as session:
async with session.get("http://pypi.python.org/pypi/red-discordbot/json") as r:
async with session.get("https://pypi.python.org/pypi/red-discordbot/json") as r:
data = await r.json()
if StrictVersion(data["info"]["version"]) > StrictVersion(red_version):
INFO.append(

View File

@@ -269,6 +269,13 @@ class Help(formatter.HelpFormatter):
color=color)
return embed
def cmd_has_no_subcommands(self, ctx, cmd, color=None):
embed = self.simple_embed(
ctx,
title=ctx.bot.command_has_no_subcommands.format(cmd),
color=color
)
return embed
@commands.command()
async def help(ctx, *cmds: str):
@@ -341,8 +348,7 @@ async def help(ctx, *cmds: str):
embed=ctx.bot.formatter.simple_embed(
ctx,
title='Command "{0.name}" has no subcommands.'.format(command),
color=ctx.bot.formatter.color,
author=ctx.author.display_name))
color=ctx.bot.formatter.color))
else:
await destination.send(
ctx.bot.command_has_no_subcommands.format(command)

View File

@@ -22,4 +22,6 @@ def safe_delete(pth: Path):
os.chmod(root, 0o755)
for d in dirs:
os.chmod(os.path.join(root, d), 0o755)
for f in files:
os.chmod(os.path.join(root, f), 0o755)
shutil.rmtree(str(pth), ignore_errors=True)

141
redbot/core/utils/menus.py Normal file
View File

@@ -0,0 +1,141 @@
"""
Original source of reaction-based menu idea from
https://github.com/Lunar-Dust/Dusty-Cogs/blob/master/menu/menu.py
Ported to Red V3 by Palm__ (https://github.com/palmtree5)
"""
import asyncio
import discord
from redbot.core import RedContext
async def menu(ctx: RedContext, pages: list,
controls: dict,
message: discord.Message=None, page: int=0,
timeout: float=30.0):
"""
An emoji-based menu
.. note:: All pages should be of the same type
.. note:: All functions for handling what a particular emoji does
should be coroutines (i.e. :code:`async def`). Additionally,
they must take all of the parameters of this function, in
addition to a string representing the emoji reacted with.
This parameter should be the last one, and none of the
parameters in the handling functions are optional
Parameters
----------
ctx: RedContext
The command context
pages: `list` of `str` or `discord.Embed`
The pages of the menu.
controls: dict
A mapping of emoji to the function which handles the action for the
emoji.
message: discord.Message
The message representing the menu. Usually :code:`None` when first opening
the menu
page: int
The current page number of the menu
timeout: float
The time (in seconds) to wait for a reaction
Raises
------
RuntimeError
If either of the notes above are violated
"""
if not all(isinstance(x, discord.Embed) for x in pages) and\
not all(isinstance(x, str) for x in pages):
raise RuntimeError("All pages must be of the same type")
for key, value in controls.items():
if not asyncio.iscoroutinefunction(value):
raise RuntimeError("Function must be a coroutine")
current_page = pages[page]
if not message:
if isinstance(current_page, discord.Embed):
message = await ctx.send(embed=current_page)
else:
message = await ctx.send(current_page)
for key in controls.keys():
await message.add_reaction(key)
else:
if isinstance(current_page, discord.Embed):
await message.edit(embed=current_page)
else:
await message.edit(content=current_page)
def react_check(r, u):
return u == ctx.author and r.message.id == message.id and \
str(r.emoji) in controls.keys()
try:
react, user = await ctx.bot.wait_for(
"reaction_add",
check=react_check,
timeout=timeout
)
except asyncio.TimeoutError:
try:
await message.clear_reactions()
except discord.Forbidden: # cannot remove all reactions
for key in controls.keys():
await message.remove_reaction(key, ctx.bot.user)
return None
return await controls[react.emoji](ctx, pages, controls,
message, page,
timeout, react.emoji)
async def next_page(ctx: RedContext, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
if page == len(pages) - 1:
page = 0 # Loop around to the first item
else:
page = page + 1
return await menu(ctx, pages, controls, message=message,
page=page, timeout=timeout)
async def prev_page(ctx: RedContext, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
if page == 0:
next_page = len(pages) - 1 # Loop around to the last item
else:
next_page = page - 1
return await menu(ctx, pages, controls, message=message,
page=next_page, timeout=timeout)
async def close_menu(ctx: RedContext, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
if message:
await message.delete()
return None
DEFAULT_CONTROLS = {
"": prev_page,
"": close_menu,
"": next_page
}

View File

@@ -4,6 +4,7 @@ from redbot.core.utils.chat_formatting import pagify
import io
import sys
import weakref
from typing import List
_instances = weakref.WeakValueDictionary({})
@@ -94,6 +95,88 @@ class Tunnel(metaclass=TunnelMeta):
def minutes_since(self):
return int((self.last_interaction - datetime.utcnow()).seconds / 60)
@staticmethod
async def message_forwarder(
*, destination: discord.abc.Messageable,
content: str=None, embed=None, files=[]) -> List[discord.Message]:
"""
This does the actual sending, use this instead of a full tunnel
if you are using command initiated reactions instead of persistent
event based ones
Parameters
----------
destination: `discord.abc.Messageable`
Where to send
content: `str`
The message content
embed: `discord.Embed`
The embed to send
files: `List[discord.Files]`
A list of files to send.
Returns
-------
list of `discord.Message`
The `discord.Message`(s) sent as a result
Raises
------
discord.Forbidden
see `discord.abc.Messageable.send`
discord.HTTPException
see `discord.abc.Messageable.send`
"""
rets = []
files = files if files else None
if content:
for page in pagify(content):
rets.append(
await destination.send(
page, files=files, embed=embed)
)
if files:
del files
if embed:
del embed
elif embed or files:
rets.append(
await destination.send(files=files, embed=embed)
)
return rets
@staticmethod
async def files_from_attatch(m: discord.Message) -> List[discord.File]:
"""
makes a list of file objects from a message
returns an empty list if none, or if the sum of file sizes
is too large for the bot to send
Parameters
---------
m: `discord.Message`
A message to get attachments from
Returns
-------
list of `discord.File`
A list of `discord.File` objects
"""
files = []
size = 0
max_size = 8 * 1024 * 1024
for a in m.attachments:
_fp = io.BytesIO()
await a.save(_fp)
size += sys.getsizeof(_fp)
if size > max_size:
return []
files.append(
discord.File(_fp, filename=a.filename)
)
return files
async def communicate(self, *,
message: discord.Message,
topic: str=None,
@@ -140,35 +223,22 @@ class Tunnel(metaclass=TunnelMeta):
else:
content = topic
attach = None
if message.attachments:
files = []
size = 0
max_size = 8 * 1024 * 1024
for a in message.attachments:
_fp = io.BytesIO()
await a.save(_fp)
size += sys.getsizeof(_fp)
if size > max_size:
await send_to.send(
"Could not forward attatchments. "
"Total size of attachments in a single "
"message must be less than 8MB."
)
break
files.append(
discord.File(_fp, filename=a.filename)
attach = await self.files_from_attatch(message)
if not attach:
await message.channel.send(
"Could not forward attatchments. "
"Total size of attachments in a single "
"message must be less than 8MB."
)
else:
attach = files
else:
attach = []
rets = []
for page in pagify(content):
rets.append(
await send_to.send(content, files=attach)
)
if attach:
del attach
rets = await self.message_forwarder(
destination=send_to,
content=content,
files=attach
)
await message.add_reaction("\N{WHITE HEAVY CHECK MARK}")
await message.add_reaction("\N{NEGATIVE SQUARED CROSS MARK}")

View File

@@ -7,7 +7,10 @@ import argparse
import asyncio
import pkg_resources
from redbot.setup import basic_setup, load_existing_config, remove_instance
from pathlib import Path
from redbot.setup import basic_setup, load_existing_config, remove_instance, remove_instance_interaction, create_backup, save_config
from redbot.core.utils import safe_delete
from redbot.core.cli import confirm
if sys.platform == "linux":
import distro
@@ -60,7 +63,7 @@ def parse_cli_args():
return parser.parse_known_args()
def update_red(dev=False, voice=False, mongo=False, docs=False, test=False):
def update_red(dev=False, reinstall=False, voice=False, mongo=False, docs=False, test=False):
interpreter = sys.executable
print("Updating Red...")
# If the user ran redbot-launcher.exe, updating with pip will fail
@@ -93,12 +96,21 @@ def update_red(dev=False, voice=False, mongo=False, docs=False, test=False):
package = "Red-DiscordBot"
if egg_l:
package += "[{}]".format(", ".join(egg_l))
code = subprocess.call([
interpreter, "-m",
"pip", "install", "-U",
"--process-dependency-links",
package
])
if reinstall:
code = subprocess.call([
interpreter, "-m",
"pip", "install", "-U", "-I",
"--force-reinstall", "--no-cache-dir",
"--process-dependency-links",
package
])
else:
code = subprocess.call([
interpreter, "-m",
"pip", "install", "-U",
"--process-dependency-links",
package
])
if code == 0:
print("Red has been updated")
else:
@@ -223,6 +235,37 @@ def instance_menu():
return name_num_map[str(selection)]
async def reset_red():
instances = load_existing_config()
if not instances:
print("No instance to delete.\n")
return
print("WARNING: You are about to remove ALL Red instances on this computer.")
print("If you want to reset data of only one instance, "
"please select option 5 in the launcher.")
await asyncio.sleep(2)
print("\nIf you continue you will remove these instanes.\n")
for instance in list(instances.keys()):
print(" - {}".format(instance))
await asyncio.sleep(3)
print('\nIf you want to reset all instances, type "I agree".')
response = input("> ").strip()
if response != "I agree":
print("Cancelling...")
return
if confirm("\nDo you want to create a backup for an instance? (y/n) "):
for index, instance in instances.items():
print("\nRemoving {}...".format(index))
await create_backup(index, instance)
await remove_instance(index, instance)
else:
for index, instance in instances.items():
await remove_instance(index, instance)
print("All instances have been removed.")
def clear_screen():
if IS_WINDOWS:
os.system("cls")
@@ -247,6 +290,33 @@ def extras_selector():
return selected
def development_choice(reinstall = False):
while True:
print("\n")
print("Do you want to install stable or development version?")
print("1. Stable version")
print("2. Development version")
choice = user_choice()
print("\n")
selected = extras_selector()
if choice == "1":
update_red(
dev=False, reinstall=reinstall, voice=True if "voice" in selected else False,
docs=True if "docs" in selected else False,
test=True if "test" in selected else False,
mongo=True if "mongo" in selected else False
)
break
elif choice == "2":
update_red(
dev=True, reinstall=reinstall, voice=True if "voice" in selected else False,
docs=True if "docs" in selected else False,
test=True if "test" in selected else False,
mongo=True if "mongo" in selected else False
)
break
def debug_info():
pyver = sys.version
redver = pkg_resources.get_distribution("Red-DiscordBot").version
@@ -275,55 +345,64 @@ def debug_info():
def main_menu():
if IS_WINDOWS:
os.system("TITLE Red - Discord Bot V3 Launcher")
clear_screen()
while True:
print(INTRO)
print("1. Run Red w/ autorestart in case of issues")
print("2. Run Red")
print("3. Update Red")
print("4. Update Red (development version)")
print("5. Create Instance")
print("6. Remove Instance")
print("7. Debug information (use this if having issues with the launcher or bot)")
print("4. Create Instance")
print("5. Remove Instance")
print("6. Debug information (use this if having issues with the launcher or bot)")
print("7. Reinstall Red")
print("0. Exit")
choice = user_choice()
if choice == "1":
instance = instance_menu()
cli_flags = cli_flag_getter()
if instance:
cli_flags = cli_flag_getter()
run_red(instance, autorestart=True, cliflags=cli_flags)
wait()
elif choice == "2":
instance = instance_menu()
cli_flags = cli_flag_getter()
if instance:
cli_flags = cli_flag_getter()
run_red(instance, autorestart=False, cliflags=cli_flags)
wait()
elif choice == "3":
selected = extras_selector()
update_red(
dev=False, voice=True if "voice" in selected else False,
docs=True if "docs" in selected else False,
test=True if "test" in selected else False,
mongo=True if "mongo" in selected else False
)
development_choice()
wait()
elif choice == "4":
selected = extras_selector()
update_red(
dev=True, voice=True if "voice" in selected else False,
docs=True if "docs" in selected else False,
test=True if "test" in selected else False,
mongo=True if "mongo" in selected else False
)
wait()
elif choice == "5":
basic_setup()
wait()
elif choice == "6":
asyncio.get_event_loop().run_until_complete(remove_instance())
elif choice == "5":
asyncio.get_event_loop().run_until_complete(remove_instance_interaction())
wait()
elif choice == "7":
elif choice == "6":
debug_info()
elif choice == "7":
while True:
loop = asyncio.get_event_loop()
clear_screen()
print("==== Reinstall Red ====")
print("1. Reinstall Red requirements (discard code changes, keep data and 3rd party cogs)")
print("2. Reset all data")
print("3. Factory reset (discard code changes, reset all data)")
print("\n")
print("0. Back")
choice = user_choice()
if choice == "1":
development_choice(reinstall=True)
wait()
elif choice == "2":
loop.run_until_complete(reset_red())
wait()
elif choice == "3":
loop.run_until_complete(reset_red())
development_choice(reinstall=True)
wait()
elif choice == "0":
break
elif choice == "0":
break
clear_screen()

View File

@@ -302,7 +302,61 @@ async def edit_instance():
)
async def remove_instance():
async def create_backup(selected, instance_data):
if confirm("Would you like to make a backup of the data for this instance? (y/n)"):
if instance_data["STORAGE_TYPE"] == "MongoDB":
print("Backing up the instance's data...")
await mongo_to_json(instance_data["DATA_PATH"], instance_data["STORAGE_DETAILS"])
backup_filename = "redv3-{}-{}.tar.gz".format(
selected, dt.utcnow().strftime("%Y-%m-%d %H-%M-%S")
)
pth = Path(instance_data["DATA_PATH"])
if pth.exists():
home = pth.home()
backup_file = home / backup_filename
os.chdir(str(pth.parent))
with tarfile.open(str(backup_file), "w:gz") as tar:
tar.add(pth.stem)
print("A backup of {} has been made. It is at {}".format(
selected, backup_file
))
else:
print("Backing up the instance's data...")
backup_filename = "redv3-{}-{}.tar.gz".format(
selected, dt.utcnow().strftime("%Y-%m-%d %H-%M-%S")
)
pth = Path(instance_data["DATA_PATH"])
if pth.exists():
home = pth.home()
backup_file = home / backup_filename
os.chdir(str(pth.parent)) # str is used here because 3.5 support
with tarfile.open(str(backup_file), "w:gz") as tar:
tar.add(pth.stem) # add all files in that directory
print(
"A backup of {} has been made. It is at {}".format(
selected, backup_file
)
)
async def remove_instance(selected, instance_data):
instance_list = load_existing_config()
if instance_data["STORAGE_TYPE"] == "MongoDB":
m = Mongo("Core", **instance_data["STORAGE_DETAILS"])
db = m.db
collections = await db.collection_names(include_system_collections=False)
for name in collections:
collection = await db.get_collection(name)
await collection.drop()
else:
pth = Path(instance_data["DATA_PATH"])
safe_delete(pth)
save_config(selected, {}, remove=True)
print("The instance {} has been removed\n".format(selected))
async def remove_instance_interaction():
instance_list = load_existing_config()
if not instance_list:
print("No instances have been set up!")
@@ -321,79 +375,15 @@ async def remove_instance():
print("That isn't a valid instance!")
return
instance_data = instance_list[selected]
if confirm("Would you like to make a backup of the data for this instance? (y/n)"):
if instance_data["STORAGE_TYPE"] == "MongoDB":
print("Backing up the instance's data...")
await mongo_to_json(instance_data["DATA_PATH"], instance_data["STORAGE_DETAILS"])
backup_filename = "redv3-{}-{}.tar.gz".format(
selected, dt.utcnow().strftime("%Y-%m-%d %H-%M-%S")
)
pth = Path(instance_data["DATA_PATH"])
if pth.exists():
home = pth.home()
backup_file = home / backup_filename
os.chdir(str(pth.parent))
with tarfile.open(str(backup_file), "w:gz") as tar:
tar.add(pth.stem)
print("A backup of {} has been made. It is at {}".format(
selected, backup_file
))
print("Removing the instance...")
m = Mongo("Core", **instance_data["STORAGE_DETAILS"])
db = m.db
collections = await db.collection_names(include_system_collections=False)
for name in collections:
collection = await db.get_collection(name)
await collection.drop()
safe_delete(pth)
save_config(selected, {}, remove=True)
print("The instance has been removed.")
return
else:
print("Backing up the instance's data...")
backup_filename = "redv3-{}-{}.tar.gz".format(
selected, dt.utcnow().strftime("%Y-%m-%d %H-%M-%S")
)
pth = Path(instance_data["DATA_PATH"])
if pth.exists():
home = pth.home()
backup_file = home / backup_filename
os.chdir(str(pth.parent)) # str is used here because 3.5 support
with tarfile.open(str(backup_file), "w:gz") as tar:
tar.add(pth.stem) # add all files in that directory
print(
"A backup of {} has been made. It is at {}".format(
selected, backup_file
)
)
print("Removing the instance...")
safe_delete(pth)
save_config(selected, {}, remove=True)
print("The instance has been removed")
return
else:
print("Removing the instance...")
if instance_data["STORAGE_TYPE"] == "MongoDB":
m = Mongo("Core", **instance_data["STORAGE_DETAILS"])
db = m.db
collections = await db.collection_names(include_system_collections=False)
for name in collections:
collection = await db.get_collection(name)
await collection.drop()
else:
pth = Path(instance_data["DATA_PATH"])
safe_delete(pth)
save_config(selected, {}, remove=True)
print("The instance has been removed")
return
await create_backup(selected, instance_data)
await remove_instance(selected, instance_data)
def main():
if args.delete:
loop = asyncio.get_event_loop()
loop.run_until_complete(remove_instance())
loop.run_until_complete(remove_instance_interaction())
elif args.edit:
loop = asyncio.get_event_loop()
loop.run_until_complete(edit_instance())