Merge branch 'master' into conquest_develop

# Conflicts:
#	conquest/conquest.py
conquest_develop
bobloy 4 years ago
commit 7626bb6a76

@ -0,0 +1,26 @@
---
name: Bug report
about: Create an issue to report a bug
title: ''
labels: bug
assignees: bobloy
---
**Describe the bug**
<!--A clear and concise description of what the bug is.-->
**To Reproduce**
<!--Steps to reproduce the behavior:-->
1. Load cog '...'
2. Run command '....'
3. See error
**Expected behavior**
<!--A clear and concise description of what you expected to happen.-->
**Screenshots or Error Messages**
<!--If applicable, add screenshots to help explain your problem.-->
**Additional context**
<!--Add any other context about the problem here.-->

@ -0,0 +1,14 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature Request]"
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
<!--A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]-->
**Describe the solution you'd like**
<!--A clear and concise description of what you want to happen. Include which cog or cogs this would interact with-->

@ -0,0 +1,26 @@
---
name: New AudioTrivia List
about: Submit a new AudioTrivia list to be added
title: "[AudioTrivia Submission]"
labels: 'cog: audiotrivia'
assignees: bobloy
---
**What is this trivia list?**
<!--What's in the list? What kind of category is?-->
**Number of Questions**
<!--Rough estimate at the number of question in this list-->
**Original Content?**
<!--Did you come up with this list yourself or did you get it from some else's work?-->
<!--If no, be sure to include the source-->
- [ ] Yes
- [ ] No
**Did I test the list?**
<!--Did you already try out the list and find no bugs?-->
- [ ] Yes
- [ ] No

@ -0,0 +1,62 @@
'cog: announcedaily':
- announcedaily/*
'cog: audiotrivia':
- audiotrivia/*
'cog: ccrole':
- ccrole/*
'cog: chatter':
- chatter/*
'cog: conquest':
- conquest/*
'cog: dad':
- dad/*
'cog: exclusiverole':
- exclusiverole/*
'cog: fifo':
- fifo/*
'cog: firstmessage':
- firstmessage/*
'cog: flag':
- flag/*
'cog: forcemention':
- forcemention/*
'cog: hangman':
- hangman
'cog: infochannel':
- infochannel/*
'cog: isitdown':
- isitdown/*
'cog: launchlib':
- launchlib/*
'cog: leaver':
- leaver/*
'cog: lovecalculator':
- lovecalculator/*
'cog: lseen':
- lseen/*
'cog: nudity':
- nudity/*
'cog: planttycoon':
- planttycoon/*
'cog: qrinvite':
- qrinvite/*
'cog: reactrestrict':
- reactrestrict/*
'cog: recyclingplant':
- recyclingplant/*
'cog: rpsls':
- rpsls/*
'cog: sayurl':
- sayurl/*
'cog: scp':
- scp/*
'cog: stealemoji':
- stealemoji/*
'cog: timerole':
- timerole/*
'cog: tts':
- tts/*
'cog: unicode':
- unicode/*
'cog: werewolf':
- werewolf

@ -0,0 +1,20 @@
# GitHub Action that uses Black to reformat the Python code in an incoming pull request.
# If all Python code in the pull request is compliant with Black then this Action does nothing.
# Othewrwise, Black is run and its changes are committed back to the incoming pull request.
# https://github.com/cclauss/autoblack
name: black
on: [pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
uses: actions/setup-python@v2
with:
python-version: '3.8'
- name: Install Black
run: pip install --upgrade --no-cache-dir black
- name: Run black --check .
run: black --check --diff -l 99 .

@ -0,0 +1,19 @@
# This workflow will triage pull requests and apply a label based on the
# paths that are modified in the pull request.
#
# To use this workflow, you will need to set up a .github/labeler.yml
# file with configuration. For more information, see:
# https://github.com/actions/labeler
name: Labeler
on: [pull_request]
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@2.2.0
with:
repo-token: "${{ secrets.GITHUB_TOKEN }}"

@ -12,12 +12,15 @@ Cog Function
| conquest | **Alpha** | <details><summary>Manage maps for war games and RPGs</summary>Lots of additional features are planned, currently function with simple map</details> |
| dad | **Beta** | <details><summary>Tell dad jokes</summary>Works great!</details> |
| exclusiverole | **Alpha** | <details><summary>Prevent certain roles from getting any other roles</summary>Fully functional, but pretty simple</details> |
| fifo | **Alpha** | <details><summary>Schedule commands to be run at certain times or intervals</summary>Just released, please report bugs as you find them. Only works for bot owner for now</details> |
| fight | **Incomplete** | <details><summary>Organize bracket tournaments within discord</summary>Still in-progress, a massive project</details> |
| firstmessage | **Release** | <details><summary>Simple cog to provide a jump link to the first message in a channel/summary>Just released, please report bugs as you find them.</details> |
| flag | **Alpha** | <details><summary>Create temporary marks on users that expire after specified time</summary>Ported, will not import old data. Please report bugs</details> |
| forcemention | **Alpha** | <details><summary>Mentions unmentionable roles</summary>Very simple cog, mention doesn't persist</details> |
| hangman | **Beta** | <details><summary>Play a game of hangman</summary>Some visual glitches and needs more customization</details> |
| howdoi | **Incomplete** | <details><summary>Ask coding questions and get results from StackExchange</summary>Not yet functional</details> |
| infochannel | **Beta** | <details><summary>Create a channel to display server info</summary>Just released, please report bugs</details> |
| infochannel | **Beta** | <details><summary>Create a channel to display server info</summary>Due to rate limits, this does not update as often as it once did</details> |
| isitdown | **Beta** | <details><summary>Check if a website/url is down</summary>Just released, please report bugs</details> |
| launchlib | **Beta** | <details><summary>Access rocket launch data</summary>Just released, please report bugs</details> |
| leaver | **Beta** | <details><summary>Send a message in a channel when a user leaves the server</summary>Seems to be functional, please report any bugs or suggestions</details> |
| lovecalculator | **Alpha** | <details><summary>Calculate the love between two users</summary>[Snap-Ons] Just updated to V3</details> |
@ -37,7 +40,7 @@ Cog Function
| unicode | **Alpha** | <details><summary>Encode and Decode unicode characters</summary>[Snap-Ons] Just updated to V3</details> |
| werewolf | **Pre-Alpha** | <details><summary>Play the classic party game Werewolf within discord</summary>Another massive project currently being developed, will be fully customizable</details> |
Check out my V2 cogs at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs)
Check out *Deprecated* my V2 cogs at [Fox-Cogs v2](https://github.com/bobloy/Fox-Cogs)
# Installation
### Recommended - Built-in Downloader

@ -1,21 +1,25 @@
"""Module to manage audio trivia sessions."""
import asyncio
import logging
import lavalink
from redbot.cogs.trivia import TriviaSession
from redbot.cogs.trivia.session import _parse_answers
from redbot.core.utils.chat_formatting import bold
log = logging.getLogger("red.fox_v3.audiotrivia.audiosession")
class AudioSession(TriviaSession):
"""Class to run a session of audio trivia"""
def __init__(self, ctx, question_list: dict, settings: dict, player: lavalink.Player):
def __init__(self, ctx, question_list: dict, settings: dict, audio=None):
super().__init__(ctx, question_list, settings)
self.player = player
self.audio = audio
@classmethod
def start(cls, ctx, question_list, settings, player: lavalink.Player = None):
session = cls(ctx, question_list, settings, player)
def start(cls, ctx, question_list, settings, audio=None):
session = cls(ctx, question_list, settings, audio)
loop = ctx.bot.loop
session._task = loop.create_task(session.run())
return session
@ -29,46 +33,89 @@ class AudioSession(TriviaSession):
await self._send_startup_msg()
max_score = self.settings["max_score"]
delay = self.settings["delay"]
audio_delay = self.settings["audio_delay"]
timeout = self.settings["timeout"]
for question, answers in self._iter_questions():
if self.audio is not None:
import lavalink
player = lavalink.get_player(self.ctx.guild.id)
player.store("channel", self.ctx.channel.id) # What's this for? I dunno
await self.audio.set_player_settings(self.ctx)
else:
lavalink = None
player = False
for question, answers, audio_url in self._iter_questions():
async with self.ctx.typing():
await asyncio.sleep(3)
self.count += 1
await self.player.stop()
msg = "**Question number {}!**\n\nName this audio!".format(self.count)
await self.ctx.send(msg)
# print("Audio question: {}".format(question))
# await self.ctx.invoke(self.audio.play(ctx=self.ctx, query=question))
# ctx_copy = copy(self.ctx)
msg = bold(f"Question number {self.count}!") + f"\n\n{question}"
if player:
await player.stop()
if audio_url:
if not player:
log.debug("Got an audio question in a non-audio trivia session")
continue
# await self.ctx.invoke(self.player.play, query=question)
query = question.strip("<>")
tracks = await self.player.get_tracks(query)
seconds = tracks[0].length / 1000
if self.settings["repeat"] and seconds < delay:
load_result = await player.load_tracks(audio_url)
if (
load_result.has_error
or load_result.load_type != lavalink.enums.LoadType.TRACK_LOADED
):
await self.ctx.maybe_send_embed(
"Audio Track has an error, skipping. See logs for details"
)
log.info(f"Track has error: {load_result.exception_message}")
continue
tracks = load_result.tracks
track = tracks[0]
seconds = track.length / 1000
track.uri = "" # Hide the info from `now`
if self.settings["repeat"] and seconds < audio_delay:
# Append it until it's longer than the delay
tot_length = seconds + 0
while tot_length < delay:
self.player.add(self.ctx.author, tracks[0])
while tot_length < audio_delay:
player.add(self.ctx.author, track)
tot_length += seconds
else:
self.player.add(self.ctx.author, tracks[0])
player.add(self.ctx.author, track)
if not self.player.current:
await self.player.play()
if not player.current:
await player.play()
await self.ctx.maybe_send_embed(msg)
log.debug(f"Audio question: {question}")
continue_ = await self.wait_for_answer(answers, delay, timeout)
continue_ = await self.wait_for_answer(
answers, audio_delay if audio_url else delay, timeout
)
if continue_ is False:
break
if any(score >= max_score for score in self.scores.values()):
await self.end_game()
break
else:
await self.ctx.send("There are no more questions!")
await self.ctx.maybe_send_embed("There are no more questions!")
await self.end_game()
async def end_game(self):
await super().end_game()
await self.player.disconnect()
if self.audio is not None:
await self.ctx.invoke(self.audio.command_disconnect)
def _iter_questions(self):
"""Iterate over questions and answers for this session.
Yields
------
`tuple`
A tuple containing the question (`str`) and the answers (`tuple` of
`str`).
"""
for question, q_data in self.question_list:
answers = _parse_answers(q_data["answers"])
_audio = q_data["audio"]
if _audio:
yield _audio, answers, question.strip("<>")
else:
yield question, answers, _audio

@ -1,22 +1,22 @@
import datetime
import logging
import pathlib
from typing import List
from typing import List, Optional
import discord
import lavalink
import yaml
from redbot.cogs.audio import Audio
from redbot.cogs.audio.core.utilities import validation
from redbot.cogs.trivia import LOG
from redbot.cogs.trivia.trivia import InvalidListError, Trivia
from redbot.core import commands, Config, checks
from redbot.cogs.trivia.trivia import InvalidListError, Trivia, get_core_lists
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.data_manager import cog_data_path
from redbot.core.utils.chat_formatting import box
from redbot.core.utils.chat_formatting import bold, box
# from redbot.cogs.audio.utils import userlimit
from .audiosession import AudioSession
from .audiosession import AudioSession
log = logging.getLogger("red.fox_v3.audiotrivia")
class AudioTrivia(Trivia):
@ -28,12 +28,11 @@ class AudioTrivia(Trivia):
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.audio = None
self.audioconf = Config.get_conf(
self, identifier=651171001051118411410511810597, force_registration=True
)
self.audioconf.register_guild(delay=30.0, repeat=True)
self.audioconf.register_guild(audio_delay=30.0, repeat=True)
@commands.group()
@commands.guild_only()
@ -44,132 +43,112 @@ class AudioTrivia(Trivia):
settings_dict = await audioset.all()
msg = box(
"**Audio settings**\n"
"Answer time limit: {delay} seconds\n"
"Answer time limit: {audio_delay} seconds\n"
"Repeat Short Audio: {repeat}"
"".format(**settings_dict),
lang="py",
)
await ctx.send(msg)
@atriviaset.command(name="delay")
async def atriviaset_delay(self, ctx: commands.Context, seconds: float):
@atriviaset.command(name="timelimit")
async def atriviaset_timelimit(self, ctx: commands.Context, seconds: float):
"""Set the maximum seconds permitted to answer a question."""
if seconds < 4.0:
await ctx.send("Must be at least 4 seconds.")
return
settings = self.audioconf.guild(ctx.guild)
await settings.delay.set(seconds)
await ctx.send("Done. Maximum seconds to answer set to {}.".format(seconds))
await settings.audo_delay.set(seconds)
await ctx.maybe_send_embed(f"Done. Maximum seconds to answer set to {seconds}.")
@atriviaset.command(name="repeat")
async def atriviaset_repeat(self, ctx: commands.Context, true_or_false: bool):
"""Set whether or not short audio will be repeated"""
settings = self.audioconf.guild(ctx.guild)
await settings.repeat.set(true_or_false)
await ctx.send(
"Done. Repeating short audio is now set to {}.".format(true_or_false)
)
await ctx.maybe_send_embed(f"Done. Repeating short audio is now set to {true_or_false}.")
@commands.group(invoke_without_command=True)
@commands.guild_only()
async def audiotrivia(self, ctx: commands.Context, *categories: str):
"""Start trivia session on the specified category.
"""Start trivia session on the specified category or categories.
Includes Audio categories.
You may list multiple categories, in which case the trivia will involve
questions from all of them.
"""
if not categories and ctx.invoked_subcommand is None:
await ctx.send_help()
return
if self.audio is None:
self.audio: Audio = self.bot.get_cog("Audio")
if self.audio is None:
await ctx.send("Audio is not loaded. Load it and try again")
return
categories = [c.lower() for c in categories]
session = self._get_trivia_session(ctx.channel)
if session is not None:
await ctx.send(
await ctx.maybe_send_embed(
"There is already an ongoing trivia session in this channel."
)
return
status = await self.audio.config.status()
notify = await self.audio.config.guild(ctx.guild).notify()
if status:
await ctx.send(
"It is recommended to disable audio status with `{}audioset status`".format(
ctx.prefix
)
)
if notify:
await ctx.send(
"It is recommended to disable audio notify with `{}audioset notify`".format(
ctx.prefix
)
)
if not self.audio._player_check(ctx):
try:
if not ctx.author.voice.channel.permissions_for(
ctx.me
).connect or self.audio.is_vc_full(ctx.author.voice.channel):
return await ctx.send(
"I don't have permission to connect to your channel."
)
await lavalink.connect(ctx.author.voice.channel)
lavaplayer = lavalink.get_player(ctx.guild.id)
lavaplayer.store("connect", datetime.datetime.utcnow())
except AttributeError:
return await ctx.send("Connect to a voice channel first.")
lavaplayer = lavalink.get_player(ctx.guild.id)
lavaplayer.store("channel", ctx.channel.id) # What's this for? I dunno
await self.audio.set_player_settings(ctx)
if not ctx.author.voice or ctx.author.voice.channel != lavaplayer.channel:
return await ctx.send(
"You must be in the voice channel to use the audiotrivia command."
)
trivia_dict = {}
authors = []
any_audio = False
for category in reversed(categories):
# We reverse the categories so that the first list's config takes
# priority over the others.
try:
dict_ = self.get_audio_list(category)
except FileNotFoundError:
await ctx.send(
"Invalid category `{0}`. See `{1}audiotrivia list`"
await ctx.maybe_send_embed(
f"Invalid category `{category}`. See `{ctx.prefix}audiotrivia list`"
" for a list of trivia categories."
"".format(category, ctx.prefix)
)
except InvalidListError:
await ctx.send(
await ctx.maybe_send_embed(
"There was an error parsing the trivia list for"
" the `{}` category. It may be formatted"
" incorrectly.".format(category)
f" the `{category}` category. It may be formatted"
" incorrectly."
)
else:
trivia_dict.update(dict_)
authors.append(trivia_dict.pop("AUTHOR", None))
is_audio = dict_.pop("AUDIO", False)
authors.append(dict_.pop("AUTHOR", None))
trivia_dict.update(
{_q: {"audio": is_audio, "answers": _a} for _q, _a in dict_.items()}
)
any_audio = any_audio or is_audio
continue
return
if not trivia_dict:
await ctx.send(
await ctx.maybe_send_embed(
"The trivia list was parsed successfully, however it appears to be empty!"
)
return
if not any_audio:
audio = None
else:
audio: Optional["Audio"] = self.bot.get_cog("Audio")
if audio is None:
await ctx.send("Audio lists were parsed but Audio is not loaded!")
return
status = await audio.config.status()
notify = await audio.config.guild(ctx.guild).notify()
if status:
await ctx.maybe_send_embed(
f"It is recommended to disable audio status with `{ctx.prefix}audioset status`"
)
if notify:
await ctx.maybe_send_embed(
f"It is recommended to disable audio notify with `{ctx.prefix}audioset notify`"
)
failed = await ctx.invoke(audio.command_summon)
if failed:
return
lavaplayer = lavalink.get_player(ctx.guild.id)
lavaplayer.store("channel", ctx.channel.id) # What's this for? I dunno
settings = await self.config.guild(ctx.guild).all()
audiosettings = await self.audioconf.guild(ctx.guild).all()
config = trivia_dict.pop("CONFIG", None)
config = trivia_dict.pop("CONFIG", {"answer": None})["answer"]
if config and settings["allow_override"]:
settings.update(config)
settings["lists"] = dict(zip(categories, reversed(authors)))
@ -177,24 +156,32 @@ class AudioTrivia(Trivia):
# Delay in audiosettings overwrites delay in settings
combined_settings = {**settings, **audiosettings}
session = AudioSession.start(
ctx=ctx,
question_list=trivia_dict,
settings=combined_settings,
player=lavaplayer,
ctx,
trivia_dict,
combined_settings,
audio,
)
self.trivia_sessions.append(session)
LOG.debug("New audio trivia session; #%s in %d", ctx.channel, ctx.guild.id)
log.debug("New audio trivia session; #%s in %d", ctx.channel, ctx.guild.id)
@audiotrivia.command(name="list")
@commands.guild_only()
async def audiotrivia_list(self, ctx: commands.Context):
"""List available trivia categories."""
lists = set(p.stem for p in self._audio_lists())
msg = box("**Available trivia lists**\n\n{}".format(", ".join(sorted(lists))))
"""List available trivia including audio categories."""
lists = set(p.stem for p in self._all_audio_lists())
if await ctx.embed_requested():
await ctx.send(
embed=discord.Embed(
title="Available trivia lists",
colour=await ctx.embed_colour(),
description=", ".join(sorted(lists)),
)
)
else:
msg = box(bold("Available trivia lists") + "\n\n" + ", ".join(sorted(lists)))
if len(msg) > 1000:
await ctx.author.send(msg)
return
else:
await ctx.send(msg)
def get_audio_list(self, category: str) -> dict:
@ -212,11 +199,9 @@ class AudioTrivia(Trivia):
"""
try:
path = next(p for p in self._audio_lists() if p.stem == category)
path = next(p for p in self._all_audio_lists() if p.stem == category)
except StopIteration:
raise FileNotFoundError(
"Could not find the `{}` category.".format(category)
)
raise FileNotFoundError("Could not find the `{}` category.".format(category))
with path.open(encoding="utf-8") as file:
try:
@ -226,13 +211,15 @@ class AudioTrivia(Trivia):
else:
return dict_
def _audio_lists(self) -> List[pathlib.Path]:
def _all_audio_lists(self) -> List[pathlib.Path]:
# Custom trivia lists uploaded with audiotrivia. Not necessarily audio lists
personal_lists = [p.resolve() for p in cog_data_path(self).glob("*.yaml")]
return personal_lists + get_core_lists()
# Add to that custom lists uploaded with trivia and core lists
return personal_lists + get_core_audio_lists() + self._all_lists()
def get_core_lists() -> List[pathlib.Path]:
def get_core_audio_lists() -> List[pathlib.Path]:
"""Return a list of paths for all trivia lists packaged with the bot."""
core_lists_path = pathlib.Path(__file__).parent.resolve() / "data/lists"
return list(core_lists_path.glob("*.yaml"))

@ -1,4 +1,5 @@
AUTHOR: Plab
AUDIO: "[Audio] Identify this Anime!"
https://www.youtube.com/watch?v=2uq34TeWEdQ:
- 'Hagane no Renkinjutsushi (2009)'
- '(2009) الخيميائي المعدني الكامل'

@ -1,4 +1,5 @@
AUTHOR: Lazar
AUDIO: "[Audio] Identify this NHL Team by their goal horn"
https://youtu.be/6OejNXrGkK0:
- Anaheim Ducks
- Anaheim

File diff suppressed because it is too large Load Diff

@ -1,13 +1,14 @@
AUTHOR: Plab
https://www.youtube.com/watch?v=--bWm9hhoZo:
NEEDS: New links for all songs.
https://www.youtube.com/watch?v=f9O2Rjn1azc:
- Transistor
https://www.youtube.com/watch?v=-4nCbgayZNE:
https://www.youtube.com/watch?v=PgUhYFkVdSY:
- Dark Cloud 2
- Dark Cloud II
https://www.youtube.com/watch?v=-64NlME4lJU:
https://www.youtube.com/watch?v=1T1RZttyMwU:
- Mega Man 7
- Mega Man VII
https://www.youtube.com/watch?v=-AesqnudNuw:
https://www.youtube.com/watch?v=AdDbbzuq1vY:
- Mega Man 9
- Mega Man IX
https://www.youtube.com/watch?v=-BmGDtP2t7M:

@ -1,4 +1,5 @@
import asyncio
import logging
import re
import discord
@ -6,14 +7,16 @@ from discord.ext.commands.view import StringView
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.utils.chat_formatting import box, pagify
from redbot.core.utils.mod import get_audit_reason
log = logging.getLogger("red.fox_v3.ccrole")
async def _get_roles_from_content(ctx, content):
content_list = content.split(",")
try:
role_list = [
discord.utils.get(ctx.guild.roles, name=role.strip(" ")).id
for role in content_list
discord.utils.get(ctx.guild.roles, name=role.strip(" ")).id for role in content_list
]
except (discord.HTTPException, AttributeError): # None.id is attribute error
return None
@ -55,6 +58,12 @@ class CCRole(commands.Cog):
When adding text, put arguments in `{}` to eval them
Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`"""
# TODO: Clean this up so it's not so repetitive
# The call/answer format has better options as well
# Saying "none" over and over can trigger automod actions as well
# Also, allow `ctx.tick()` instead of sending a message
command = command.lower()
if command in self.bot.all_commands:
await ctx.send("That command is already a standard command.")
@ -76,7 +85,8 @@ class CCRole(commands.Cog):
# Roles to add
await ctx.send(
"What roles should it add? (Must be **comma separated**)\nSay `None` to skip adding roles"
"What roles should it add? (Must be **comma separated**)\n"
"Say `None` to skip adding roles"
)
def check(m):
@ -97,7 +107,8 @@ class CCRole(commands.Cog):
# Roles to remove
await ctx.send(
"What roles should it remove? (Must be comma separated)\nSay `None` to skip removing roles"
"What roles should it remove? (Must be comma separated)\n"
"Say `None` to skip removing roles"
)
try:
answer = await self.bot.wait_for("message", timeout=120, check=check)
@ -114,7 +125,8 @@ class CCRole(commands.Cog):
# Roles to use
await ctx.send(
"What roles are allowed to use this command? (Must be comma separated)\nSay `None` to allow all roles"
"What roles are allowed to use this command? (Must be comma separated)\n"
"Say `None` to allow all roles"
)
try:
@ -131,7 +143,9 @@ class CCRole(commands.Cog):
return
# Selfrole
await ctx.send("Is this a targeted command?(yes/no)\nNo will make this a selfrole command")
await ctx.send(
"Is this a targeted command?(yes/no)\n" "No will make this a selfrole command"
)
try:
answer = await self.bot.wait_for("message", timeout=120, check=check)
@ -149,7 +163,7 @@ class CCRole(commands.Cog):
# Message to send
await ctx.send(
"What message should the bot say when using this command?\n"
"Say `None` to send the default `Success!` message\n"
"Say `None` to send no message and just react with ✅\n"
"Eval Options: `{author}`, `{target}`, `{server}`, `{channel}`, `{message}`\n"
"For example: `Welcome {target.mention} to {server.name}!`"
)
@ -160,7 +174,7 @@ class CCRole(commands.Cog):
await ctx.send("Timed out, canceling")
return
text = "Success!"
text = None
if answer.content.upper() != "NONE":
text = answer.content
@ -193,7 +207,7 @@ class CCRole(commands.Cog):
await self.config.guild(guild).cmdlist.set_raw(command, value=None)
await ctx.send("Custom command successfully deleted.")
@ccrole.command(name="details")
@ccrole.command(name="details", aliases=["detail"])
async def ccrole_details(self, ctx, command: str):
"""Provide details about passed custom command"""
guild = ctx.guild
@ -217,10 +231,10 @@ class CCRole(commands.Cog):
[discord.utils.get(ctx.guild.roles, id=roleid).name for roleid in role_list]
)
embed.add_field(name="Text", value="```{}```".format(cmd["text"]))
embed.add_field(name="Adds Roles", value=process_roles(cmd["aroles"]), inline=True)
embed.add_field(name="Removes Roles", value=process_roles(cmd["rroles"]), inline=True)
embed.add_field(name="Role Restrictions", value=process_roles(cmd["proles"]), inline=True)
embed.add_field(name="Text", value="```{}```".format(cmd["text"]), inline=False)
embed.add_field(name="Adds Roles", value=process_roles(cmd["aroles"]), inline=False)
embed.add_field(name="Removes Roles", value=process_roles(cmd["rroles"]), inline=False)
embed.add_field(name="Role Restrictions", value=process_roles(cmd["proles"]), inline=False)
await ctx.send(embed=embed)
@ -288,6 +302,8 @@ class CCRole(commands.Cog):
if cmd is not None:
await self.eval_cc(cmd, message, ctx)
else:
log.debug(f"No custom command named {ctx.invoked_with} found")
async def get_prefix(self, message: discord.Message) -> str:
"""
@ -312,18 +328,10 @@ class CCRole(commands.Cog):
if cmd["proles"] and not (
set(role.id for role in message.author.roles) & set(cmd["proles"])
):
log.debug(f"{message.author} missing required role to execute {ctx.invoked_with}")
return # Not authorized, do nothing
if cmd["targeted"]:
# try:
# arg1 = message.content.split(maxsplit=1)[1]
# except IndexError: # .split() return list of len<2
# target = None
# else:
# target = discord.utils.get(
# message.guild.members, mention=arg1
# )
view: StringView = ctx.view
view.skip_ws()
@ -342,47 +350,43 @@ class CCRole(commands.Cog):
else:
target = None
# try:
# arg1 = ctx.args[1]
# except IndexError: # args is list of len<2
# target = None
# else:
# target = discord.utils.get(
# message.guild.members, mention=arg1
# )
if not target:
out_message = "This custom command is targeted! @mention a target\n`{} <target>`".format(
ctx.invoked_with
out_message = (
f"This custom command is targeted! @mention a target\n`"
f"{ctx.invoked_with} <target>`"
)
await message.channel.send(out_message)
await ctx.send(out_message)
return
else:
target = message.author
reason = get_audit_reason(message.author)
if cmd["aroles"]:
arole_list = [
discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd["aroles"]
]
# await self.bot.send_message(message.channel, "Adding: "+str([str(arole) for arole in arole_list]))
try:
await target.add_roles(*arole_list)
await target.add_roles(*arole_list, reason=reason)
except discord.Forbidden:
await message.channel.send("Permission error: Unable to add roles")
await asyncio.sleep(1)
log.exception(f"Permission error: Unable to add roles")
await ctx.send("Permission error: Unable to add roles")
if cmd["rroles"]:
rrole_list = [
discord.utils.get(message.guild.roles, id=roleid) for roleid in cmd["rroles"]
]
# await self.bot.send_message(message.channel, "Removing: "+str([str(rrole) for rrole in rrole_list]))
try:
await target.remove_roles(*rrole_list)
await target.remove_roles(*rrole_list, reason=reason)
except discord.Forbidden:
await message.channel.send("Permission error: Unable to remove roles")
log.exception(f"Permission error: Unable to remove roles")
await ctx.send("Permission error: Unable to remove roles")
if cmd["text"] is not None:
out_message = self.format_cc(cmd, message, target)
await message.channel.send(out_message, allowed_mentions=discord.AllowedMentions())
await ctx.send(out_message, allowed_mentions=discord.AllowedMentions())
else:
await ctx.tick()
def format_cc(self, cmd, message, target):
out = cmd["text"]
@ -396,6 +400,7 @@ class CCRole(commands.Cog):
"""
For security reasons only specific objects are allowed
Internals are ignored
Copied from customcom.CustomCommands.transform_parameter and added `target`
"""
raw_result = "{" + result + "}"
objects = {

@ -29,7 +29,7 @@ Chatter by default uses spaCy's `en_core_web_md` training model, which is ~50 MB
Chatter can potential use spaCy's `en_core_web_lg` training model, which is ~800 MB
Chatter uses as sqlite database that can potentially take up a large amount os disk space,
Chatter uses as sqlite database that can potentially take up a large amount of disk space,
depending on how much training Chatter has done.
The sqlite database can be safely deleted at any time. Deletion will only erase training data.
@ -50,7 +50,9 @@ Linux is a bit easier, but only tested on Debian and Ubuntu.
## Windows Prerequisites
Install these on your windows machine before attempting the installation
**Requires 64 Bit Python to continue on Windows.**
Install these on your windows machine before attempting the installation:
[Visual Studio C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
@ -83,6 +85,7 @@ pip install --no-deps "chatterbot>=1.1"
#### Step 3: Load Chatter
```
[p]repo add Fox https://github.com/bobloy/Fox-V3 # If you didn't already do this in step 1
[p]cog install Fox chatter
[p]load chatter
```
@ -92,7 +95,7 @@ pip install --no-deps "chatterbot>=1.1"
#### Step 1: Built-in Downloader
```
[p]cog install Chatter
[p]cog install <Fox> Chatter
```
#### Step 2: Install Requirements

@ -3,6 +3,7 @@ import logging
import os
import pathlib
from datetime import datetime, timedelta
from typing import Optional
import discord
from chatterbot import ChatBot
@ -17,6 +18,13 @@ from redbot.core.utils.predicates import MessagePredicate
log = logging.getLogger("red.fox_v3.chatter")
def my_local_get_prefix(prefixes, content):
for p in prefixes:
if content.startswith(p):
return p
return None
class ENG_LG:
ISO_639_1 = "en_core_web_lg"
ISO_639 = "eng"
@ -45,7 +53,7 @@ class Chatter(Cog):
self.bot = bot
self.config = Config.get_conf(self, identifier=6710497116116101114)
default_global = {}
default_guild = {"whitelist": None, "days": 1, "convo_delta": 15}
default_guild = {"whitelist": None, "days": 1, "convo_delta": 15, "chatchannel": None}
path: pathlib.Path = cog_data_path(self)
self.data_path = path / "database.sqlite3"
@ -183,6 +191,25 @@ class Chatter(Cog):
if ctx.invoked_subcommand is None:
pass
@chatter.command(name="channel")
async def chatter_channel(
self, ctx: commands.Context, channel: Optional[discord.TextChannel] = None
):
"""
Set a channel that the bot will respond in without mentioning it
Pass with no channel object to clear this guild's channel
"""
if channel is None:
await self.config.guild(ctx.guild).chatchannel.set(None)
await ctx.maybe_send_embed("Chat channel for guild is cleared")
else:
if channel.guild != ctx.guild:
await ctx.maybe_send_embed("What are you trying to pull here? :eyes:")
return
await self.config.guild(ctx.guild).chatchannel.set(channel.id)
await ctx.maybe_send_embed(f"Chat channel is now {channel.mention}")
@chatter.command(name="cleardata")
async def chatter_cleardata(self, ctx: commands.Context, confirm: bool = False):
"""
@ -407,7 +434,7 @@ class Chatter(Cog):
else:
await ctx.maybe_send_embed("Error occurred :(")
@commands.Cog.listener()
@Cog.listener()
async def on_message_without_command(self, message: discord.Message):
"""
Credit to https://github.com/Twentysix26/26-Cogs/blob/master/cleverbot/cleverbot.py
@ -434,13 +461,11 @@ class Chatter(Cog):
###########
# Thank you Cog-Creators
channel: discord.TextChannel = message.channel
def my_local_get_prefix(prefixes, content):
for p in prefixes:
if content.startswith(p):
return p
return None
if guild is not None and channel.id == await self.config.guild(guild).chatchannel():
pass # good to go
else:
when_mentionables = commands.when_mentioned(self.bot, message)
prefix = my_local_get_prefix(when_mentionables, message.content)
@ -449,9 +474,8 @@ class Chatter(Cog):
# print("not mentioned")
return
channel: discord.TextChannel = message.channel
message.content = message.content.replace(prefix, "", 1)
text = message.clean_content
async with channel.typing():

@ -5,7 +5,7 @@
"min_bot_version": "3.4.0",
"description": "Create an offline chatbot that talks like your average member using Machine Learning",
"hidden": false,
"install_msg": "Thank you for installing Chatter! Get started ith `[p]load chatter` and `[p]help Chatter`",
"install_msg": "Thank you for installing Chatter! Please make sure you check the install instructions at https://github.com/bobloy/Fox-V3/blob/master/chatter/README.md\nAfter that, get started ith `[p]load chatter` and `[p]help Chatter`",
"requirements": [
"git+git://github.com/gunthercox/chatterbot-corpus@master#egg=chatterbot_corpus",
"mathparse>=0.1,<0.2",

@ -85,6 +85,8 @@ class Dad(Cog):
@commands.Cog.listener()
async def on_message_without_command(self, message: discord.Message):
if message.author.bot:
return
guild: discord.Guild = getattr(message, "guild", None)
if guild is None:
return

@ -0,0 +1,11 @@
from .fifo import FIFO
async def setup(bot):
cog = FIFO(bot)
bot.add_cog(cog)
await cog.initialize()
def teardown(bot):
pass

@ -0,0 +1,42 @@
from datetime import datetime, tzinfo
from typing import TYPE_CHECKING
from apscheduler.triggers.cron import CronTrigger
from dateutil import parser
from discord.ext.commands import BadArgument, Converter
from pytz import timezone
from fifo.timezones import assemble_timezones
if TYPE_CHECKING:
DatetimeConverter = datetime
CronConverter = str
else:
class TimezoneConverter(Converter):
async def convert(self, ctx, argument) -> tzinfo:
tzinfos = assemble_timezones()
if argument.upper() in tzinfos:
return tzinfos[argument.upper()]
timez = timezone(argument)
if timez is not None:
return timez
raise BadArgument()
class DatetimeConverter(Converter):
async def convert(self, ctx, argument) -> datetime:
dt = parser.parse(argument, fuzzy=True, tzinfos=assemble_timezones())
if dt is not None:
return dt
raise BadArgument()
class CronConverter(Converter):
async def convert(self, ctx, argument) -> str:
try:
CronTrigger.from_crontab(argument)
except ValueError:
raise BadArgument()
return argument

@ -0,0 +1,511 @@
import logging
from datetime import datetime, timedelta, tzinfo
from typing import Optional, Union
import discord
from apscheduler.job import Job
from apscheduler.jobstores.base import JobLookupError
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.schedulers.base import STATE_PAUSED, STATE_RUNNING
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.commands import TimedeltaConverter
from redbot.core.utils.chat_formatting import pagify
from .datetime_cron_converters import CronConverter, DatetimeConverter, TimezoneConverter
from .task import Task
schedule_log = logging.getLogger("red.fox_v3.fifo.scheduler")
schedule_log.setLevel(logging.DEBUG)
log = logging.getLogger("red.fox_v3.fifo")
async def _execute_task(task_state):
log.info(f"Executing {task_state=}")
task = Task(**task_state)
if await task.load_from_config():
return await task.execute()
return False
def _assemble_job_id(task_name, guild_id):
return f"{task_name}_{guild_id}"
def _disassemble_job_id(job_id: str):
return job_id.split("_")
class FIFO(commands.Cog):
"""
Simple Scheduling Cog
Named after the simplest scheduling algorithm: First In First Out
"""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, identifier=70737079, force_registration=True)
default_global = {"jobs": []}
default_guild = {"tasks": {}}
self.config.register_global(**default_global)
self.config.register_guild(**default_guild)
self.scheduler = None
self.jobstore = None
self.tz_cog = None
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
def cog_unload(self):
# self.scheduler.remove_all_jobs()
if self.scheduler is not None:
self.scheduler.shutdown()
async def initialize(self):
job_defaults = {"coalesce": False, "max_instances": 1}
# executors = {"default": AsyncIOExecutor()}
# Default executor is already AsyncIOExecutor
self.scheduler = AsyncIOScheduler(job_defaults=job_defaults, logger=schedule_log)
from .redconfigjobstore import RedConfigJobStore
self.jobstore = RedConfigJobStore(self.config, self.bot)
await self.jobstore.load_from_config(self.scheduler, "default")
self.scheduler.add_jobstore(self.jobstore, "default")
self.scheduler.start()
async def _check_parsable_command(self, ctx: commands.Context, command_to_parse: str):
message: discord.Message = ctx.message
message.content = ctx.prefix + command_to_parse
message.author = ctx.author
new_ctx: commands.Context = await self.bot.get_context(message)
return new_ctx.valid
async def _delete_task(self, task: Task):
job: Union[Job, None] = await self._get_job(task)
if job is not None:
job.remove()
await task.delete_self()
async def _process_task(self, task: Task):
job: Union[Job, None] = await self._get_job(task)
if job is not None:
job.reschedule(await task.get_combined_trigger())
return job
return await self._add_job(task)
async def _get_job(self, task: Task) -> Job:
return self.scheduler.get_job(_assemble_job_id(task.name, task.guild_id))
async def _add_job(self, task: Task):
return self.scheduler.add_job(
_execute_task,
args=[task.__getstate__()],
id=_assemble_job_id(task.name, task.guild_id),
trigger=await task.get_combined_trigger(),
)
async def _resume_job(self, task: Task):
try:
job = self.scheduler.resume_job(job_id=_assemble_job_id(task.name, task.guild_id))
except JobLookupError:
job = await self._process_task(task)
return job
async def _pause_job(self, task: Task):
return self.scheduler.pause_job(job_id=_assemble_job_id(task.name, task.guild_id))
async def _remove_job(self, task: Task):
return self.scheduler.remove_job(job_id=_assemble_job_id(task.name, task.guild_id))
async def _get_tz(self, user: Union[discord.User, discord.Member]) -> Union[None, tzinfo]:
if self.tz_cog is None:
self.tz_cog = self.bot.get_cog("Timezone")
if self.tz_cog is None:
self.tz_cog = False # only try once to get the timezone cog
if not self.tz_cog:
return None
try:
usertime = await self.tz_cog.config.user(user).usertime()
except AttributeError:
return None
if usertime:
return await TimezoneConverter().convert(None, usertime)
else:
return None
@checks.is_owner()
@commands.guild_only()
@commands.command()
async def fifoclear(self, ctx: commands.Context):
"""Debug command to clear all current fifo data"""
self.scheduler.remove_all_jobs()
await self.config.guild(ctx.guild).tasks.clear()
await self.config.jobs.clear()
# await self.config.jobs_index.clear()
await ctx.tick()
@checks.is_owner() # Will be reduced when I figure out permissions later
@commands.guild_only()
@commands.group()
async def fifo(self, ctx: commands.Context):
"""
Base command for handling scheduling of tasks
"""
if ctx.invoked_subcommand is None:
pass
@fifo.command(name="set")
async def fifo_set(
self,
ctx: commands.Context,
task_name: str,
author_or_channel: Union[discord.Member, discord.TextChannel],
):
"""
Sets a different author or in a different channel for execution of a task.
"""
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
if isinstance(author_or_channel, discord.Member):
if task.author_id == author_or_channel.id:
await ctx.maybe_send_embed("Already executing as that member")
return
await task.set_author(author_or_channel) # also saves
elif isinstance(author_or_channel, discord.TextChannel):
if task.channel_id == author_or_channel.id:
await ctx.maybe_send_embed("Already executing in that channel")
return
await task.set_channel(author_or_channel)
else:
await ctx.maybe_send_embed("Unsupported result")
return
await ctx.tick()
@fifo.command(name="resume")
async def fifo_resume(self, ctx: commands.Context, task_name: Optional[str] = None):
"""
Provide a task name to resume execution of a task.
Otherwise resumes execution of all tasks on all guilds
If the task isn't currently scheduled, will schedule it
"""
if task_name is None:
if self.scheduler.state == STATE_PAUSED:
self.scheduler.resume()
await ctx.maybe_send_embed("All task execution for all guilds has been resumed")
else:
await ctx.maybe_send_embed("Task execution is not paused, can't resume")
else:
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
if await self._resume_job(task):
await ctx.maybe_send_embed(f"Execution of {task_name=} has been resumed")
else:
await ctx.maybe_send_embed(f"Failed to resume {task_name=}")
@fifo.command(name="pause")
async def fifo_pause(self, ctx: commands.Context, task_name: Optional[str] = None):
"""
Provide a task name to pause execution of a task
Otherwise pauses execution of all tasks on all guilds
"""
if task_name is None:
if self.scheduler.state == STATE_RUNNING:
self.scheduler.pause()
await ctx.maybe_send_embed("All task execution for all guilds has been paused")
else:
await ctx.maybe_send_embed("Task execution is not running, can't pause")
else:
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
if await self._pause_job(task):
await ctx.maybe_send_embed(f"Execution of {task_name=} has been paused")
else:
await ctx.maybe_send_embed(f"Failed to pause {task_name=}")
@fifo.command(name="details")
async def fifo_details(self, ctx: commands.Context, task_name: str):
"""
Provide all the details on the specified task name
"""
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
embed = discord.Embed(title=f"Task: {task_name}")
embed.add_field(
name="Task command", value=f"{ctx.prefix}{task.get_command_str()}", inline=False
)
guild: discord.Guild = self.bot.get_guild(task.guild_id)
if guild is not None:
author: discord.Member = guild.get_member(task.author_id)
channel: discord.TextChannel = guild.get_channel(task.channel_id)
embed.add_field(name="Server", value=guild.name)
if author is not None:
embed.add_field(name="Author", value=author.mention)
if channel is not None:
embed.add_field(name="Channel", value=channel.mention)
else:
embed.add_field(name="Server", value="Server not found", inline=False)
trigger_str = "\n".join(str(t) for t in await task.get_triggers())
if trigger_str:
embed.add_field(name="Triggers", value=trigger_str, inline=False)
job = await self._get_job(task)
if job and job.next_run_time:
embed.timestamp = job.next_run_time
await ctx.send(embed=embed)
@fifo.command(name="list")
async def fifo_list(self, ctx: commands.Context, all_guilds: bool = False):
"""
Lists all current tasks and their triggers.
Do `[p]fifo list True` to see tasks from all guilds
"""
if all_guilds:
pass
else:
out = ""
all_tasks = await self.config.guild(ctx.guild).tasks()
for task_name, task_data in all_tasks.items():
out += f"{task_name}: {task_data}\n"
if out:
if len(out) > 2000:
for page in pagify(out):
await ctx.maybe_send_embed(page)
else:
await ctx.maybe_send_embed(out)
else:
await ctx.maybe_send_embed("No tasks to list")
@fifo.command(name="add")
async def fifo_add(self, ctx: commands.Context, task_name: str, *, command_to_execute: str):
"""
Add a new task to this guild's task list
"""
if (await self.config.guild(ctx.guild).tasks.get_raw(task_name, default=None)) is not None:
await ctx.maybe_send_embed(f"Task already exists with {task_name=}")
return
if "_" in task_name: # See _disassemble_job_id
await ctx.maybe_send_embed("Task name cannot contain underscores")
return
if not await self._check_parsable_command(ctx, command_to_execute):
await ctx.maybe_send_embed(
"Failed to parse command. Make sure not to include the prefix"
)
return
task = Task(task_name, ctx.guild.id, self.config, ctx.author.id, ctx.channel.id, self.bot)
await task.set_commmand_str(command_to_execute)
await task.save_all()
await ctx.tick()
@fifo.command(name="delete")
async def fifo_delete(self, ctx: commands.Context, task_name: str):
"""
Deletes a task from this guild's task list
"""
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
await self._delete_task(task)
await ctx.maybe_send_embed(f"Task[{task_name}] has been deleted from this guild")
@fifo.command(name="cleartriggers", aliases=["cleartrigger"])
async def fifo_cleartriggers(self, ctx: commands.Context, task_name: str):
"""
Removes all triggers from specified task
Useful to start over with new trigger
"""
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
await task.clear_triggers()
await ctx.tick()
@fifo.group(name="addtrigger", aliases=["trigger"])
async def fifo_trigger(self, ctx: commands.Context):
"""
Add a new trigger for a task from the current guild.
"""
if ctx.invoked_subcommand is None:
pass
@fifo_trigger.command(name="interval")
async def fifo_trigger_interval(
self, ctx: commands.Context, task_name: str, *, interval_str: TimedeltaConverter
):
"""
Add an interval trigger to the specified task
"""
task = Task(task_name, ctx.guild.id, self.config, bot=self.bot)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
result = await task.add_trigger("interval", interval_str)
if not result:
await ctx.maybe_send_embed(
"Failed to add an interval trigger to this task, see console for logs"
)
return
await task.save_data()
job: Job = await self._process_task(task)
delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo)
await ctx.maybe_send_embed(
f"Task `{task_name}` added interval of {interval_str} to its scheduled runtimes\n\n"
f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)"
)
@fifo_trigger.command(name="date")
async def fifo_trigger_date(
self, ctx: commands.Context, task_name: str, *, datetime_str: DatetimeConverter
):
"""
Add a "run once" datetime trigger to the specified task
"""
task = Task(task_name, ctx.guild.id, self.config)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
maybe_tz = await self._get_tz(ctx.author)
result = await task.add_trigger("date", datetime_str, maybe_tz)
if not result:
await ctx.maybe_send_embed(
"Failed to add a date trigger to this task, see console for logs"
)
return
await task.save_data()
job: Job = await self._process_task(task)
delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo)
await ctx.maybe_send_embed(
f"Task `{task_name}` added {datetime_str} to its scheduled runtimes\n"
f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)"
)
@fifo_trigger.command(name="cron")
async def fifo_trigger_cron(
self,
ctx: commands.Context,
task_name: str,
optional_tz: Optional[TimezoneConverter] = None,
*,
cron_str: CronConverter,
):
"""
Add a cron "time of day" trigger to the specified task
See https://crontab.guru/ for help generating the cron_str
"""
task = Task(task_name, ctx.guild.id, self.config)
await task.load_from_config()
if task.data is None:
await ctx.maybe_send_embed(
f"Task by the name of {task_name} is not found in this guild"
)
return
if optional_tz is None:
optional_tz = await self._get_tz(ctx.author) # might still be None
result = await task.add_trigger("cron", cron_str, optional_tz)
if not result:
await ctx.maybe_send_embed(
"Failed to add a cron trigger to this task, see console for logs"
)
return
await task.save_data()
job: Job = await self._process_task(task)
delta_from_now: timedelta = job.next_run_time - datetime.now(job.next_run_time.tzinfo)
await ctx.maybe_send_embed(
f"Task `{task_name}` added cron_str to its scheduled runtimes\n"
f"Next run time: {job.next_run_time} ({delta_from_now.total_seconds()} seconds)"
)

@ -0,0 +1,30 @@
{
"author": [
"Bobloy"
],
"min_bot_version": "3.4.0",
"description": "[BETA] Schedule commands to be run at certain times or intervals",
"hidden": false,
"install_msg": "Thank you for installing FIFO.\nGet started with `[p]load fifo`, then `[p]help FIFO`",
"short": "[BETA] Schedule commands to be run at certain times or intervals",
"end_user_data_statement": "This cog does not store any End User Data",
"requirements": [
"apscheduler",
"pytz"
],
"tags": [
"bobloy",
"utilities",
"tool",
"tools",
"roles",
"schedule",
"cron",
"interval",
"date",
"datetime",
"time",
"calendar",
"timezone"
]
}

@ -0,0 +1,183 @@
import asyncio
import base64
import logging
import pickle
from datetime import datetime
from typing import Tuple, Union
from apscheduler.job import Job
from apscheduler.jobstores.base import ConflictingIdError, JobLookupError
from apscheduler.jobstores.memory import MemoryJobStore
from apscheduler.schedulers.asyncio import run_in_event_loop
from apscheduler.util import datetime_to_utc_timestamp
from redbot.core import Config
# TODO: use get_lock on config
from redbot.core.bot import Red
from redbot.core.utils import AsyncIter
log = logging.getLogger("red.fox_v3.fifo.jobstore")
log.setLevel(logging.DEBUG)
save_task_objects = []
class RedConfigJobStore(MemoryJobStore):
def __init__(self, config: Config, bot: Red):
super().__init__()
self.config = config
self.bot = bot
self.pickle_protocol = pickle.HIGHEST_PROTOCOL
self._eventloop = self.bot.loop
# TODO: self.config.jobs_index is never used,
# fine but maybe a sign of inefficient use of config
# task = asyncio.create_task(self.load_from_config())
# while not task.done():
# sleep(0.1)
# future = asyncio.ensure_future(self.load_from_config(), loop=self.bot.loop)
@run_in_event_loop
def start(self, scheduler, alias):
super().start(scheduler, alias)
async def load_from_config(self, scheduler, alias):
super().start(scheduler, alias)
_jobs = await self.config.jobs()
self._jobs = [
(await self._decode_job(job), timestamp) async for (job, timestamp) in AsyncIter(_jobs)
]
# self._jobs_index = await self.config.jobs_index.all() # Overwritten by next
self._jobs_index = {job.id: (job, timestamp) for job, timestamp in self._jobs}
def _encode_job(self, job: Job):
job_state = job.__getstate__()
new_args = list(job_state["args"])
new_args[0]["config"] = None
new_args[0]["bot"] = None
job_state["args"] = tuple(new_args)
encoded = base64.b64encode(pickle.dumps(job_state, self.pickle_protocol))
out = {
"_id": job.id,
"next_run_time": datetime_to_utc_timestamp(job.next_run_time),
"job_state": encoded.decode("ascii"),
}
new_args = list(job_state["args"])
new_args[0]["config"] = self.config
new_args[0]["bot"] = self.bot
job_state["args"] = tuple(new_args)
# log.debug(f"Encoding job id: {job.id}\n"
# f"Encoded as: {out}")
return out
async def _decode_job(self, in_job):
if in_job is None:
return None
job_state = in_job["job_state"]
job_state = pickle.loads(base64.b64decode(job_state))
new_args = list(job_state["args"])
new_args[0]["config"] = self.config
new_args[0]["bot"] = self.bot
job_state["args"] = tuple(new_args)
job = Job.__new__(Job)
job.__setstate__(job_state)
job._scheduler = self._scheduler
job._jobstore_alias = self._alias
# task_name, guild_id = _disassemble_job_id(job.id)
# task = Task(task_name, guild_id, self.config)
# await task.load_from_config()
# save_task_objects.append(task)
#
# job.func = task.execute
# log.debug(f"Decoded job id: {job.id}\n"
# f"Decoded as {job_state}")
return job
@run_in_event_loop
def add_job(self, job: Job):
if job.id in self._jobs_index:
raise ConflictingIdError(job.id)
# log.debug(f"Check job args: {job.args=}")
timestamp = datetime_to_utc_timestamp(job.next_run_time)
index = self._get_job_index(timestamp, job.id) # This is fine
self._jobs.insert(index, (job, timestamp))
self._jobs_index[job.id] = (job, timestamp)
asyncio.create_task(self._async_add_job(job, index, timestamp))
# log.debug(f"Added job: {self._jobs[index][0].args}")
async def _async_add_job(self, job, index, timestamp):
encoded_job = self._encode_job(job)
job_tuple = tuple([encoded_job, timestamp])
async with self.config.jobs() as jobs:
jobs.insert(index, job_tuple)
# await self.config.jobs_index.set_raw(job.id, value=job_tuple)
return True
@run_in_event_loop
def update_job(self, job):
old_tuple: Tuple[Union[Job, None], Union[datetime, None]] = self._jobs_index.get(
job.id, (None, None)
)
old_job = old_tuple[0]
old_timestamp = old_tuple[1]
if old_job is None:
raise JobLookupError(job.id)
# If the next run time has not changed, simply replace the job in its present index.
# Otherwise, reinsert the job to the list to preserve the ordering.
old_index = self._get_job_index(old_timestamp, old_job.id)
new_timestamp = datetime_to_utc_timestamp(job.next_run_time)
asyncio.create_task(
self._async_update_job(job, new_timestamp, old_index, old_job, old_timestamp)
)
async def _async_update_job(self, job, new_timestamp, old_index, old_job, old_timestamp):
encoded_job = self._encode_job(job)
if old_timestamp == new_timestamp:
self._jobs[old_index] = (job, new_timestamp)
async with self.config.jobs() as jobs:
jobs[old_index] = (encoded_job, new_timestamp)
else:
del self._jobs[old_index]
new_index = self._get_job_index(new_timestamp, job.id) # This is fine
self._jobs.insert(new_index, (job, new_timestamp))
async with self.config.jobs() as jobs:
del jobs[old_index]
jobs.insert(new_index, (encoded_job, new_timestamp))
self._jobs_index[old_job.id] = (job, new_timestamp)
# await self.config.jobs_index.set_raw(old_job.id, value=(encoded_job, new_timestamp))
log.debug(f"Async Updated {job.id=}")
log.debug(f"Check job args: {job.args=}")
@run_in_event_loop
def remove_job(self, job_id):
job, timestamp = self._jobs_index.get(job_id, (None, None))
if job is None:
raise JobLookupError(job_id)
index = self._get_job_index(timestamp, job_id)
del self._jobs[index]
del self._jobs_index[job.id]
asyncio.create_task(self._async_remove_job(index, job))
async def _async_remove_job(self, index, job):
async with self.config.jobs() as jobs:
del jobs[index]
# await self.config.jobs_index.clear_raw(job.id)
@run_in_event_loop
def remove_all_jobs(self):
super().remove_all_jobs()
asyncio.create_task(self._async_remove_all_jobs())
async def _async_remove_all_jobs(self):
await self.config.jobs.clear()
# await self.config.jobs_index.clear()
def shutdown(self):
"""Removes all jobs without clearing config"""
super().remove_all_jobs()

@ -0,0 +1,371 @@
import logging
from datetime import datetime, timedelta
from typing import Dict, List, Union
import discord
from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.combining import OrTrigger
from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.date import DateTrigger
from apscheduler.triggers.interval import IntervalTrigger
from discord.utils import time_snowflake
from pytz import timezone
from redbot.core import Config, commands
from redbot.core.bot import Red
log = logging.getLogger("red.fox_v3.fifo.task")
async def _do_nothing(*args, **kwargs):
pass
def get_trigger(data):
if data["type"] == "interval":
parsed_time = data["time_data"]
return IntervalTrigger(days=parsed_time.days, seconds=parsed_time.seconds)
if data["type"] == "date":
return DateTrigger(data["time_data"], timezone=data["tzinfo"])
if data["type"] == "cron":
return CronTrigger.from_crontab(data["time_data"], timezone=data["tzinfo"])
return False
def parse_triggers(data: Union[Dict, None]):
if data is None or not data.get("triggers", False): # No triggers
return None
if len(data["triggers"]) > 1: # Multiple triggers
return OrTrigger(get_trigger(t_data) for t_data in data["triggers"])
return get_trigger(data["triggers"][0])
class FakeMessage:
def __init__(self, message: discord.Message):
d = {k: getattr(message, k, None) for k in dir(message)}
self.__dict__.update(**d)
def neuter_message(message: FakeMessage):
message.delete = _do_nothing
message.edit = _do_nothing
message.publish = _do_nothing
message.pin = _do_nothing
message.unpin = _do_nothing
message.add_reaction = _do_nothing
message.remove_reaction = _do_nothing
message.clear_reaction = _do_nothing
message.clear_reactions = _do_nothing
message.ack = _do_nothing
return message
class Task:
default_task_data = {"triggers": [], "command_str": ""}
default_trigger = {
"type": "",
"time_data": None, # Used for Interval and Date Triggers
"tzinfo": None,
}
def __init__(
self, name: str, guild_id, config: Config, author_id=None, channel_id=None, bot: Red = None
):
self.name = name
self.guild_id = guild_id
self.config = config
self.bot = bot
self.author_id = author_id
self.channel_id = channel_id
self.data = None
async def _encode_time_triggers(self):
if not self.data or not self.data.get("triggers", None):
return []
triggers = []
for t in self.data["triggers"]:
if t["type"] == "interval": # Convert into timedelta
td: timedelta = t["time_data"]
triggers.append(
{"type": t["type"], "time_data": {"days": td.days, "seconds": td.seconds}}
)
continue
if t["type"] == "date": # Convert into datetime
dt: datetime = t["time_data"]
triggers.append(
{
"type": t["type"],
"time_data": dt.isoformat(),
"tzinfo": getattr(t["tzinfo"], "zone", None),
}
)
# triggers.append(
# {
# "type": t["type"],
# "time_data": {
# "year": dt.year,
# "month": dt.month,
# "day": dt.day,
# "hour": dt.hour,
# "minute": dt.minute,
# "second": dt.second,
# "tzinfo": dt.tzinfo,
# },
# }
# )
continue
if t["type"] == "cron":
if t["tzinfo"] is None:
triggers.append(t) # already a string, nothing to do
else:
triggers.append(
{
"type": t["type"],
"time_data": t["time_data"],
"tzinfo": getattr(t["tzinfo"], "zone", None),
}
)
continue
raise NotImplemented
return triggers
async def _decode_time_triggers(self):
if not self.data or not self.data.get("triggers", None):
return
for t in self.data["triggers"]:
# Backwards compatibility
if "tzinfo" not in t:
t["tzinfo"] = None
# First decode timezone if there is one
if t["tzinfo"] is not None:
t["tzinfo"] = timezone(t["tzinfo"])
if t["type"] == "interval": # Convert into timedelta
t["time_data"] = timedelta(**t["time_data"])
continue
if t["type"] == "date": # Convert into datetime
# self.data["triggers"][n]["time_data"] = datetime(**t["time_data"])
t["time_data"] = datetime.fromisoformat(t["time_data"])
continue
if t["type"] == "cron":
continue # already a string
raise NotImplemented
# async def load_from_data(self, data: Dict):
# self.data = data.copy()
async def load_from_config(self):
data = await self.config.guild_from_id(self.guild_id).tasks.get_raw(
self.name, default=None
)
if not data:
return
self.author_id = data["author_id"]
self.guild_id = data["guild_id"]
self.channel_id = data["channel_id"]
self.data = data["data"]
await self._decode_time_triggers()
return self.data
async def get_triggers(self) -> List[Union[IntervalTrigger, DateTrigger]]:
if not self.data:
await self.load_from_config()
if self.data is None or "triggers" not in self.data: # No triggers
return []
return [get_trigger(t) for t in self.data["triggers"]]
async def get_combined_trigger(self) -> Union[BaseTrigger, None]:
if not self.data:
await self.load_from_config()
return parse_triggers(self.data)
# async def set_job_id(self, job_id):
# if self.data is None:
# await self.load_from_config()
#
# self.data["job_id"] = job_id
async def save_all(self):
"""To be used when creating an new task"""
data_to_save = self.default_task_data.copy()
if self.data:
data_to_save["command_str"] = self.get_command_str()
data_to_save["triggers"] = await self._encode_time_triggers()
to_save = {
"guild_id": self.guild_id,
"author_id": self.author_id,
"channel_id": self.channel_id,
"data": data_to_save,
}
await self.config.guild_from_id(self.guild_id).tasks.set_raw(self.name, value=to_save)
async def save_data(self):
"""To be used when updating triggers"""
if not self.data:
return
data_to_save = self.data.copy()
data_to_save["triggers"] = await self._encode_time_triggers()
await self.config.guild_from_id(self.guild_id).tasks.set_raw(
self.name, "data", value=data_to_save
)
async def execute(self):
if not self.data or not self.get_command_str():
log.warning(f"Could not execute task due to data problem: {self.data=}")
return False
guild: discord.Guild = self.bot.get_guild(self.guild_id) # used for get_prefix
if guild is None:
log.warning(f"Could not execute task due to missing guild: {self.guild_id}")
return False
channel: discord.TextChannel = guild.get_channel(self.channel_id)
if channel is None:
log.warning(f"Could not execute task due to missing channel: {self.channel_id}")
return False
author: discord.User = guild.get_member(self.author_id)
if author is None:
log.warning(f"Could not execute task due to missing author: {self.author_id}")
return False
actual_message: discord.Message = channel.last_message
# I'd like to present you my chain of increasingly desperate message fetching attempts
if actual_message is None:
# log.warning("No message found in channel cache yet, skipping execution")
# return
actual_message = await channel.fetch_message(channel.last_message_id)
if actual_message is None: # last_message_id was an invalid message I guess
actual_message = await channel.history(limit=1).flatten()
if not actual_message: # Basically only happens if the channel has no messages
actual_message = await author.history(limit=1).flatten()
if not actual_message: # Okay, the *author* has never sent a message?
log.warning("No message found in channel cache yet, skipping execution")
return
actual_message = actual_message[0]
message = FakeMessage(actual_message)
# message = FakeMessage2
message.author = author
message.guild = guild # Just in case we got desperate
message.channel = channel
message.id = time_snowflake(datetime.now()) # Pretend to be now
message = neuter_message(message)
# absolutely weird that this takes a message object instead of guild
prefixes = await self.bot.get_prefix(message)
if isinstance(prefixes, str):
prefix = prefixes
else:
prefix = prefixes[0]
message.content = f"{prefix}{self.get_command_str()}"
if not message.guild or not message.author or not message.content:
log.warning(f"Could not execute task due to message problem: {message}")
return False
new_ctx: commands.Context = await self.bot.get_context(message)
new_ctx.assume_yes = True
if not new_ctx.valid:
log.warning(
f"Could not execute Task[{self.name}] due invalid context: {new_ctx.invoked_with}"
)
return False
await self.bot.invoke(new_ctx)
return True
async def set_bot(self, bot: Red):
self.bot = bot
async def set_author(self, author: Union[discord.User, discord.Member, str]):
self.author_id = getattr(author, "id", None) or author
await self.config.guild_from_id(self.guild_id).tasks.set_raw(
self.name, "author_id", value=self.author_id
)
async def set_channel(self, channel: Union[discord.TextChannel, str]):
self.channel_id = getattr(channel, "id", None) or channel
await self.config.guild_from_id(self.guild_id).tasks.set_raw(
self.name, "channel_id", value=self.channel_id
)
def get_command_str(self):
return self.data.get("command_str", "")
async def set_commmand_str(self, command_str):
if not self.data:
self.data = self.default_task_data.copy()
self.data["command_str"] = command_str
return True
async def add_trigger(
self, param, parsed_time: Union[timedelta, datetime, str], timezone=None
):
# TODO: Save timezone separately for cron and date triggers
trigger_data = self.default_trigger.copy()
trigger_data["type"] = param
trigger_data["time_data"] = parsed_time
if timezone is not None:
trigger_data["tzinfo"] = timezone
if not get_trigger(trigger_data):
return False
if not self.data:
self.data = self.default_task_data.copy()
self.data["triggers"].append(trigger_data)
return True
def __setstate__(self, task_state):
self.name = task_state["name"]
self.guild_id = task_state["guild_id"]
self.config = task_state["config"]
self.bot = None
self.author_id = None
self.channel_id = None
self.data = None
def __getstate__(self):
return {
"name": self.name,
"guild_id": self.guild_id,
"config": self.config,
"bot": self.bot,
}
async def clear_triggers(self):
self.data["triggers"] = []
await self.save_data()
async def delete_self(self):
"""Hopefully nothing uses the object after running this..."""
await self.config.guild_from_id(self.guild_id).tasks.clear_raw(self.name)

@ -0,0 +1,230 @@
"""
Timezone information for the dateutil parser
All credit to https://github.com/prefrontal/dateutil-parser-timezones
"""
# from dateutil.tz import gettz
from pytz import timezone
def assemble_timezones():
"""
Assembles a dictionary of timezone abbreviations and values
:return: Dictionary of abbreviation keys and timezone values
"""
timezones = {}
timezones["ACDT"] = timezone(
"Australia/Darwin"
) # Australian Central Daylight Savings Time (UTC+10:30)
timezones["ACST"] = timezone(
"Australia/Darwin"
) # Australian Central Standard Time (UTC+09:30)
timezones["ACT"] = timezone("Brazil/Acre") # Acre Time (UTC05)
timezones["ADT"] = timezone("America/Halifax") # Atlantic Daylight Time (UTC03)
timezones["AEDT"] = timezone(
"Australia/Sydney"
) # Australian Eastern Daylight Savings Time (UTC+11)
timezones["AEST"] = timezone("Australia/Sydney") # Australian Eastern Standard Time (UTC+10)
timezones["AFT"] = timezone("Asia/Kabul") # Afghanistan Time (UTC+04:30)
timezones["AKDT"] = timezone("America/Juneau") # Alaska Daylight Time (UTC08)
timezones["AKST"] = timezone("America/Juneau") # Alaska Standard Time (UTC09)
timezones["AMST"] = timezone("America/Manaus") # Amazon Summer Time (Brazil)[1] (UTC03)
timezones["AMT"] = timezone("America/Manaus") # Amazon Time (Brazil)[2] (UTC04)
timezones["ART"] = timezone("America/Cordoba") # Argentina Time (UTC03)
timezones["AST"] = timezone("Asia/Riyadh") # Arabia Standard Time (UTC+03)
timezones["AWST"] = timezone("Australia/Perth") # Australian Western Standard Time (UTC+08)
timezones["AZOST"] = timezone("Atlantic/Azores") # Azores Summer Time (UTC±00)
timezones["AZOT"] = timezone("Atlantic/Azores") # Azores Standard Time (UTC01)
timezones["AZT"] = timezone("Asia/Baku") # Azerbaijan Time (UTC+04)
timezones["BDT"] = timezone("Asia/Brunei") # Brunei Time (UTC+08)
timezones["BIOT"] = timezone("Etc/GMT+6") # British Indian Ocean Time (UTC+06)
timezones["BIT"] = timezone("Pacific/Funafuti") # Baker Island Time (UTC12)
timezones["BOT"] = timezone("America/La_Paz") # Bolivia Time (UTC04)
timezones["BRST"] = timezone("America/Sao_Paulo") # Brasilia Summer Time (UTC02)
timezones["BRT"] = timezone("America/Sao_Paulo") # Brasilia Time (UTC03)
timezones["BST"] = timezone("Asia/Dhaka") # Bangladesh Standard Time (UTC+06)
timezones["BTT"] = timezone("Asia/Thimphu") # Bhutan Time (UTC+06)
timezones["CAT"] = timezone("Africa/Harare") # Central Africa Time (UTC+02)
timezones["CCT"] = timezone("Indian/Cocos") # Cocos Islands Time (UTC+06:30)
timezones["CDT"] = timezone(
"America/Chicago"
) # Central Daylight Time (North America) (UTC05)
timezones["CEST"] = timezone(
"Europe/Berlin"
) # Central European Summer Time (Cf. HAEC) (UTC+02)
timezones["CET"] = timezone("Europe/Berlin") # Central European Time (UTC+01)
timezones["CHADT"] = timezone("Pacific/Chatham") # Chatham Daylight Time (UTC+13:45)
timezones["CHAST"] = timezone("Pacific/Chatham") # Chatham Standard Time (UTC+12:45)
timezones["CHOST"] = timezone("Asia/Choibalsan") # Choibalsan Summer Time (UTC+09)
timezones["CHOT"] = timezone("Asia/Choibalsan") # Choibalsan Standard Time (UTC+08)
timezones["CHST"] = timezone("Pacific/Guam") # Chamorro Standard Time (UTC+10)
timezones["CHUT"] = timezone("Pacific/Chuuk") # Chuuk Time (UTC+10)
timezones["CIST"] = timezone("Etc/GMT-8") # Clipperton Island Standard Time (UTC08)
timezones["CIT"] = timezone("Asia/Makassar") # Central Indonesia Time (UTC+08)
timezones["CKT"] = timezone("Pacific/Rarotonga") # Cook Island Time (UTC10)
timezones["CLST"] = timezone("America/Santiago") # Chile Summer Time (UTC03)
timezones["CLT"] = timezone("America/Santiago") # Chile Standard Time (UTC04)
timezones["COST"] = timezone("America/Bogota") # Colombia Summer Time (UTC04)
timezones["COT"] = timezone("America/Bogota") # Colombia Time (UTC05)
timezones["CST"] = timezone(
"America/Chicago"
) # Central Standard Time (North America) (UTC06)
timezones["CT"] = timezone("Asia/Chongqing") # China time (UTC+08)
timezones["CVT"] = timezone("Atlantic/Cape_Verde") # Cape Verde Time (UTC01)
timezones["CXT"] = timezone("Indian/Christmas") # Christmas Island Time (UTC+07)
timezones["DAVT"] = timezone("Antarctica/Davis") # Davis Time (UTC+07)
timezones["DDUT"] = timezone("Antarctica/DumontDUrville") # Dumont d'Urville Time (UTC+10)
timezones["DFT"] = timezone(
"Europe/Berlin"
) # AIX equivalent of Central European Time (UTC+01)
timezones["EASST"] = timezone("Chile/EasterIsland") # Easter Island Summer Time (UTC05)
timezones["EAST"] = timezone("Chile/EasterIsland") # Easter Island Standard Time (UTC06)
timezones["EAT"] = timezone("Africa/Mogadishu") # East Africa Time (UTC+03)
timezones["ECT"] = timezone("America/Guayaquil") # Ecuador Time (UTC05)
timezones["EDT"] = timezone(
"America/New_York"
) # Eastern Daylight Time (North America) (UTC04)
timezones["EEST"] = timezone("Europe/Bucharest") # Eastern European Summer Time (UTC+03)
timezones["EET"] = timezone("Europe/Bucharest") # Eastern European Time (UTC+02)
timezones["EGST"] = timezone("America/Scoresbysund") # Eastern Greenland Summer Time (UTC±00)
timezones["EGT"] = timezone("America/Scoresbysund") # Eastern Greenland Time (UTC01)
timezones["EIT"] = timezone("Asia/Jayapura") # Eastern Indonesian Time (UTC+09)
timezones["EST"] = timezone(
"America/New_York"
) # Eastern Standard Time (North America) (UTC05)
timezones["FET"] = timezone("Europe/Minsk") # Further-eastern European Time (UTC+03)
timezones["FJT"] = timezone("Pacific/Fiji") # Fiji Time (UTC+12)
timezones["FKST"] = timezone("Atlantic/Stanley") # Falkland Islands Summer Time (UTC03)
timezones["FKT"] = timezone("Atlantic/Stanley") # Falkland Islands Time (UTC04)
timezones["FNT"] = timezone("Brazil/DeNoronha") # Fernando de Noronha Time (UTC02)
timezones["GALT"] = timezone("Pacific/Galapagos") # Galapagos Time (UTC06)
timezones["GAMT"] = timezone("Pacific/Gambier") # Gambier Islands (UTC09)
timezones["GET"] = timezone("Asia/Tbilisi") # Georgia Standard Time (UTC+04)
timezones["GFT"] = timezone("America/Cayenne") # French Guiana Time (UTC03)
timezones["GILT"] = timezone("Pacific/Tarawa") # Gilbert Island Time (UTC+12)
timezones["GIT"] = timezone("Pacific/Gambier") # Gambier Island Time (UTC09)
timezones["GMT"] = timezone("GMT") # Greenwich Mean Time (UTC±00)
timezones["GST"] = timezone("Asia/Muscat") # Gulf Standard Time (UTC+04)
timezones["GYT"] = timezone("America/Guyana") # Guyana Time (UTC04)
timezones["HADT"] = timezone("Pacific/Honolulu") # Hawaii-Aleutian Daylight Time (UTC09)
timezones["HAEC"] = timezone("Europe/Paris") # Heure Avancée d'Europe Centrale (CEST) (UTC+02)
timezones["HAST"] = timezone("Pacific/Honolulu") # Hawaii-Aleutian Standard Time (UTC10)
timezones["HKT"] = timezone("Asia/Hong_Kong") # Hong Kong Time (UTC+08)
timezones["HMT"] = timezone("Indian/Kerguelen") # Heard and McDonald Islands Time (UTC+05)
timezones["HOVST"] = timezone("Asia/Hovd") # Khovd Summer Time (UTC+08)
timezones["HOVT"] = timezone("Asia/Hovd") # Khovd Standard Time (UTC+07)
timezones["ICT"] = timezone("Asia/Ho_Chi_Minh") # Indochina Time (UTC+07)
timezones["IDT"] = timezone("Asia/Jerusalem") # Israel Daylight Time (UTC+03)
timezones["IOT"] = timezone("Etc/GMT+3") # Indian Ocean Time (UTC+03)
timezones["IRDT"] = timezone("Asia/Tehran") # Iran Daylight Time (UTC+04:30)
timezones["IRKT"] = timezone("Asia/Irkutsk") # Irkutsk Time (UTC+08)
timezones["IRST"] = timezone("Asia/Tehran") # Iran Standard Time (UTC+03:30)
timezones["IST"] = timezone("Asia/Kolkata") # Indian Standard Time (UTC+05:30)
timezones["JST"] = timezone("Asia/Tokyo") # Japan Standard Time (UTC+09)
timezones["KGT"] = timezone("Asia/Bishkek") # Kyrgyzstan time (UTC+06)
timezones["KOST"] = timezone("Pacific/Kosrae") # Kosrae Time (UTC+11)
timezones["KRAT"] = timezone("Asia/Krasnoyarsk") # Krasnoyarsk Time (UTC+07)
timezones["KST"] = timezone("Asia/Seoul") # Korea Standard Time (UTC+09)
timezones["LHST"] = timezone("Australia/Lord_Howe") # Lord Howe Standard Time (UTC+10:30)
timezones["LINT"] = timezone("Pacific/Kiritimati") # Line Islands Time (UTC+14)
timezones["MAGT"] = timezone("Asia/Magadan") # Magadan Time (UTC+12)
timezones["MART"] = timezone("Pacific/Marquesas") # Marquesas Islands Time (UTC09:30)
timezones["MAWT"] = timezone("Antarctica/Mawson") # Mawson Station Time (UTC+05)
timezones["MDT"] = timezone(
"America/Denver"
) # Mountain Daylight Time (North America) (UTC06)
timezones["MEST"] = timezone(
"Europe/Paris"
) # Middle European Summer Time Same zone as CEST (UTC+02)
timezones["MET"] = timezone("Europe/Berlin") # Middle European Time Same zone as CET (UTC+01)
timezones["MHT"] = timezone("Pacific/Kwajalein") # Marshall Islands (UTC+12)
timezones["MIST"] = timezone("Antarctica/Macquarie") # Macquarie Island Station Time (UTC+11)
timezones["MIT"] = timezone("Pacific/Marquesas") # Marquesas Islands Time (UTC09:30)
timezones["MMT"] = timezone("Asia/Rangoon") # Myanmar Standard Time (UTC+06:30)
timezones["MSK"] = timezone("Europe/Moscow") # Moscow Time (UTC+03)
timezones["MST"] = timezone(
"America/Denver"
) # Mountain Standard Time (North America) (UTC07)
timezones["MUT"] = timezone("Indian/Mauritius") # Mauritius Time (UTC+04)
timezones["MVT"] = timezone("Indian/Maldives") # Maldives Time (UTC+05)
timezones["MYT"] = timezone("Asia/Kuching") # Malaysia Time (UTC+08)
timezones["NCT"] = timezone("Pacific/Noumea") # New Caledonia Time (UTC+11)
timezones["NDT"] = timezone("Canada/Newfoundland") # Newfoundland Daylight Time (UTC02:30)
timezones["NFT"] = timezone("Pacific/Norfolk") # Norfolk Time (UTC+11)
timezones["NPT"] = timezone("Asia/Kathmandu") # Nepal Time (UTC+05:45)
timezones["NST"] = timezone("Canada/Newfoundland") # Newfoundland Standard Time (UTC03:30)
timezones["NT"] = timezone("Canada/Newfoundland") # Newfoundland Time (UTC03:30)
timezones["NUT"] = timezone("Pacific/Niue") # Niue Time (UTC11)
timezones["NZDT"] = timezone("Pacific/Auckland") # New Zealand Daylight Time (UTC+13)
timezones["NZST"] = timezone("Pacific/Auckland") # New Zealand Standard Time (UTC+12)
timezones["OMST"] = timezone("Asia/Omsk") # Omsk Time (UTC+06)
timezones["ORAT"] = timezone("Asia/Oral") # Oral Time (UTC+05)
timezones["PDT"] = timezone(
"America/Los_Angeles"
) # Pacific Daylight Time (North America) (UTC07)
timezones["PET"] = timezone("America/Lima") # Peru Time (UTC05)
timezones["PETT"] = timezone("Asia/Kamchatka") # Kamchatka Time (UTC+12)
timezones["PGT"] = timezone("Pacific/Port_Moresby") # Papua New Guinea Time (UTC+10)
timezones["PHOT"] = timezone("Pacific/Enderbury") # Phoenix Island Time (UTC+13)
timezones["PKT"] = timezone("Asia/Karachi") # Pakistan Standard Time (UTC+05)
timezones["PMDT"] = timezone(
"America/Miquelon"
) # Saint Pierre and Miquelon Daylight time (UTC02)
timezones["PMST"] = timezone(
"America/Miquelon"
) # Saint Pierre and Miquelon Standard Time (UTC03)
timezones["PONT"] = timezone("Pacific/Pohnpei") # Pohnpei Standard Time (UTC+11)
timezones["PST"] = timezone(
"America/Los_Angeles"
) # Pacific Standard Time (North America) (UTC08)
timezones["PYST"] = timezone(
"America/Asuncion"
) # Paraguay Summer Time (South America)[7] (UTC03)
timezones["PYT"] = timezone("America/Asuncion") # Paraguay Time (South America)[8] (UTC04)
timezones["RET"] = timezone("Indian/Reunion") # Réunion Time (UTC+04)
timezones["ROTT"] = timezone("Antarctica/Rothera") # Rothera Research Station Time (UTC03)
timezones["SAKT"] = timezone("Asia/Vladivostok") # Sakhalin Island time (UTC+11)
timezones["SAMT"] = timezone("Europe/Samara") # Samara Time (UTC+04)
timezones["SAST"] = timezone("Africa/Johannesburg") # South African Standard Time (UTC+02)
timezones["SBT"] = timezone("Pacific/Guadalcanal") # Solomon Islands Time (UTC+11)
timezones["SCT"] = timezone("Indian/Mahe") # Seychelles Time (UTC+04)
timezones["SGT"] = timezone("Asia/Singapore") # Singapore Time (UTC+08)
timezones["SLST"] = timezone("Asia/Colombo") # Sri Lanka Standard Time (UTC+05:30)
timezones["SRET"] = timezone("Asia/Srednekolymsk") # Srednekolymsk Time (UTC+11)
timezones["SRT"] = timezone("America/Paramaribo") # Suriname Time (UTC03)
timezones["SST"] = timezone("Asia/Singapore") # Singapore Standard Time (UTC+08)
timezones["SYOT"] = timezone("Antarctica/Syowa") # Showa Station Time (UTC+03)
timezones["TAHT"] = timezone("Pacific/Tahiti") # Tahiti Time (UTC10)
timezones["TFT"] = timezone("Indian/Kerguelen") # Indian/Kerguelen (UTC+05)
timezones["THA"] = timezone("Asia/Bangkok") # Thailand Standard Time (UTC+07)
timezones["TJT"] = timezone("Asia/Dushanbe") # Tajikistan Time (UTC+05)
timezones["TKT"] = timezone("Pacific/Fakaofo") # Tokelau Time (UTC+13)
timezones["TLT"] = timezone("Asia/Dili") # Timor Leste Time (UTC+09)
timezones["TMT"] = timezone("Asia/Ashgabat") # Turkmenistan Time (UTC+05)
timezones["TOT"] = timezone("Pacific/Tongatapu") # Tonga Time (UTC+13)
timezones["TVT"] = timezone("Pacific/Funafuti") # Tuvalu Time (UTC+12)
timezones["ULAST"] = timezone("Asia/Ulan_Bator") # Ulaanbaatar Summer Time (UTC+09)
timezones["ULAT"] = timezone("Asia/Ulan_Bator") # Ulaanbaatar Standard Time (UTC+08)
timezones["USZ1"] = timezone("Europe/Kaliningrad") # Kaliningrad Time (UTC+02)
timezones["UTC"] = timezone("UTC") # Coordinated Universal Time (UTC±00)
timezones["UYST"] = timezone("America/Montevideo") # Uruguay Summer Time (UTC02)
timezones["UYT"] = timezone("America/Montevideo") # Uruguay Standard Time (UTC03)
timezones["UZT"] = timezone("Asia/Tashkent") # Uzbekistan Time (UTC+05)
timezones["VET"] = timezone("America/Caracas") # Venezuelan Standard Time (UTC04)
timezones["VLAT"] = timezone("Asia/Vladivostok") # Vladivostok Time (UTC+10)
timezones["VOLT"] = timezone("Europe/Volgograd") # Volgograd Time (UTC+04)
timezones["VOST"] = timezone("Antarctica/Vostok") # Vostok Station Time (UTC+06)
timezones["VUT"] = timezone("Pacific/Efate") # Vanuatu Time (UTC+11)
timezones["WAKT"] = timezone("Pacific/Wake") # Wake Island Time (UTC+12)
timezones["WAST"] = timezone("Africa/Lagos") # West Africa Summer Time (UTC+02)
timezones["WAT"] = timezone("Africa/Lagos") # West Africa Time (UTC+01)
timezones["WEST"] = timezone("Europe/London") # Western European Summer Time (UTC+01)
timezones["WET"] = timezone("Europe/London") # Western European Time (UTC±00)
timezones["WIT"] = timezone("Asia/Jakarta") # Western Indonesian Time (UTC+07)
timezones["WST"] = timezone("Australia/Perth") # Western Standard Time (UTC+08)
timezones["YAKT"] = timezone("Asia/Yakutsk") # Yakutsk Time (UTC+09)
timezones["YEKT"] = timezone("Asia/Yekaterinburg") # Yekaterinburg Time (UTC+05)
return timezones

@ -0,0 +1,5 @@
from .firstmessage import FirstMessage
async def setup(bot):
bot.add_cog(FirstMessage(bot))

@ -0,0 +1,49 @@
import logging
import discord
from redbot.core import Config, commands
from redbot.core.bot import Red
log = logging.getLogger("red.fox_v3.firstmessage")
class FirstMessage(commands.Cog):
"""
Provides a link to the first message in the provided channel
"""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(
self, identifier=701051141151167710111511597103101, force_registration=True
)
default_guild = {}
self.config.register_guild(**default_guild)
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
@commands.command()
async def firstmessage(self, ctx: commands.Context, channel: discord.TextChannel = None):
"""
Provide a link to the first message in current or provided channel.
"""
if channel is None:
channel = ctx.channel
try:
message: discord.Message = (
await channel.history(limit=1, oldest_first=True).flatten()
)[0]
except (discord.Forbidden, discord.HTTPException):
log.exception(f"Unable to read message history for {channel.id=}")
await ctx.maybe_send_embed("Unable to read message history for that channel")
return
em = discord.Embed(description=f"[First Message in {channel.mention}]({message.jump_url})")
em.set_author(name=message.author.display_name, icon_url=message.author.avatar_url)
await ctx.send(embed=em)

@ -0,0 +1,17 @@
{
"author": [
"Bobloy"
],
"min_bot_version": "3.4.0",
"description": "Simple cog to jump to the first message of a channel easily",
"hidden": false,
"install_msg": "Thank you for installing FirstMessage.\nGet started with `[p]load firstmessage`, then `[p]help FirstMessage`",
"short": "Simple cog to jump to first message of a channel",
"end_user_data_statement": "This cog does not store any End User Data",
"tags": [
"bobloy",
"utilities",
"tool",
"tools"
]
}

@ -6,4 +6,3 @@ def setup(bot):
n = Hangman(bot)
data_manager.bundled_data_path(n)
bot.add_cog(n)
bot.add_listener(n.on_react, "on_reaction_add")

@ -50,27 +50,27 @@ class Hangman(Cog):
theface = await self.config.guild(guild).theface()
self.hanglist[guild] = (
""">
\_________
\\_________
|/
|
|
|
|
|
|\___
|\\___
""",
""">
\_________
\\_________
|/ |
|
|
|
|
|
|\___
|\\___
H""",
""">
\_________
\\_________
|/ |
| """
+ theface
@ -79,10 +79,10 @@ class Hangman(Cog):
|
|
|
|\___
|\\___
HA""",
""">
\________
\\________
|/ |
| """
+ theface
@ -91,10 +91,10 @@ class Hangman(Cog):
| |
|
|
|\___
|\\___
HAN""",
""">
\_________
\\_________
|/ |
| """
+ theface
@ -103,43 +103,43 @@ class Hangman(Cog):
| |
|
|
|\___
|\\___
HANG""",
""">
\_________
\\_________
|/ |
| """
+ theface
+ """
| /|\
| /|\\
| |
|
|
|\___
|\\___
HANGM""",
""">
\________
\\________
|/ |
| """
+ theface
+ """
| /|\
| /|\\
| |
| /
|
|\___
|\\___
HANGMA""",
""">
\________
\\________
|/ |
| """
+ theface
+ """
| /|\
| /|\\
| |
| / \
| / \\
|
|\___
|\\___
HANGMAN""",
)
@ -255,7 +255,7 @@ class Hangman(Cog):
elif i in self.the_data[guild]["guesses"] or i not in "ABCDEFGHIJKLMNOPQRSTUVWXYZ":
out_str += "__" + i + "__ "
else:
out_str += "**\_** "
out_str += "**\\_** "
self.winbool[guild] = False
return out_str
@ -286,9 +286,9 @@ class Hangman(Cog):
await self._reprintgame(message)
@commands.Cog.listener()
@commands.Cog.listener("on_reaction_add")
async def on_react(self, reaction, user: Union[discord.User, discord.Member]):
""" Thanks to flapjack reactpoll for guidelines
"""Thanks to flapjack reactpoll for guidelines
https://github.com/flapjax/FlapJack-Cogs/blob/master/reactpoll/reactpoll.py"""
guild: discord.Guild = getattr(user, "guild", None)
if guild is None:

@ -1,4 +1,5 @@
import asyncio
from typing import Union
import discord
from redbot.core import Config, checks, commands
@ -61,10 +62,9 @@ class InfoChannel(Cog):
guild: discord.Guild = ctx.guild
channel_id = await self.config.guild(guild).channel_id()
channel = None
if channel_id is not None:
channel: discord.VoiceChannel = guild.get_channel(channel_id)
else:
channel: discord.VoiceChannel = None
channel: Union[discord.VoiceChannel, None] = guild.get_channel(channel_id)
if channel_id is not None and channel is None:
await ctx.send("Info channel has been deleted, recreate it?")

@ -0,0 +1,5 @@
from .isitdown import IsItDown
async def setup(bot):
bot.add_cog(IsItDown(bot))

@ -0,0 +1,17 @@
{
"author": [
"Bobloy"
],
"min_bot_version": "3.4.0",
"description": "Check if a website/url is down using the https://isitdown.site/ api",
"hidden": false,
"install_msg": "Thank you for installing IsItDown.\nGet started with `[p]load isitdown`, then `[p]help IsItDown`",
"short": "Check if a website/url is down",
"end_user_data_statement": "This cog does not store any End User Data",
"tags": [
"bobloy",
"utilities",
"tool",
"tools"
]
}

@ -0,0 +1,58 @@
import logging
import re
import aiohttp
from redbot.core import Config, commands
from redbot.core.bot import Red
log = logging.getLogger("red.fox_v3.isitdown")
class IsItDown(commands.Cog):
"""
Cog Description
Less important information about the cog
"""
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, identifier=0, force_registration=True)
default_guild = {"iids": []} # List of tuple pairs (channel_id, website)
self.config.register_guild(**default_guild)
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
return
@commands.command(alias=["iid"])
async def isitdown(self, ctx: commands.Context, url_to_check):
"""
Check if the provided url is down
Alias: iid
"""
try:
resp = await self._check_if_down(url_to_check)
except AssertionError:
await ctx.maybe_send_embed("Invalid URL provided. Make sure not to include `http://`")
return
if resp["isitdown"]:
await ctx.maybe_send_embed(f"{url_to_check} is DOWN!")
else:
await ctx.maybe_send_embed(f"{url_to_check} is UP!")
async def _check_if_down(self, url_to_check):
url = re.compile(r"https?://(www\.)?")
url.sub("", url_to_check).strip().strip("/")
url = f"https://isitdown.site/api/v3/{url}"
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
assert response.status == 200
resp = await response.json()
return resp

@ -8,7 +8,7 @@
"install_msg": "Thank you for installing LaunchLib. Get started with `[p]load launchlib`, then `[p]help LaunchLib`",
"short": "Access launch data for space flights",
"end_user_data_statement": "This cog does not store any End User Data",
"requirements": ["python-launch-library"],
"requirements": ["python-launch-library>=1.0.6"],
"tags": [
"bobloy",
"utils",

@ -22,7 +22,9 @@ class LaunchLib(commands.Cog):
def __init__(self, bot: Red):
super().__init__()
self.bot = bot
self.config = Config.get_conf(self, identifier=0, force_registration=True)
self.config = Config.get_conf(
self, identifier=7697117110991047610598, force_registration=True
)
default_guild = {}
@ -34,10 +36,10 @@ class LaunchLib(commands.Cog):
"""Nothing to delete"""
return
async def _embed_launch_data(self, launch: ll.Launch):
status: ll.LaunchStatus = launch.get_status()
async def _embed_launch_data(self, launch: ll.AsyncLaunch):
status: ll.AsyncLaunchStatus = await launch.get_status()
rocket: ll.Rocket = launch.rocket
rocket: ll.AsyncRocket = launch.rocket
title = launch.name
description = status.description
@ -105,15 +107,13 @@ class LaunchLib(commands.Cog):
@launchlib.command()
async def next(self, ctx: commands.Context, num_launches: int = 1):
# launches = await api.async_next_launches(num_launches)
loop = asyncio.get_running_loop()
launches = await loop.run_in_executor(
None, functools.partial(self.api.fetch_launch, num=num_launches)
)
# launches = self.api.fetch_launch(num=num_launches)
print(len(launches))
# loop = asyncio.get_running_loop()
#
# launches = await loop.run_in_executor(
# None, functools.partial(self.api.fetch_launch, num=num_launches)
# )
#
launches = await self.api.async_fetch_launch(num=num_launches)
async with ctx.typing():
for x, launch in enumerate(launches):

@ -4,13 +4,13 @@
"SnappyDragon"
],
"min_bot_version": "3.3.0",
"description": "Calculate the love percentage for two users",
"description": "Calculate the love percentage for two users. Shows gif result and description of their love",
"hidden": false,
"install_msg": "Thank you for installing LoveCalculator. Love is in the air.\n Get started with `[p]load lovecalculator`, then `[p]help LoveCalculator`",
"requirements": [
"beautifulsoup4"
],
"short": "Calculate love percentage",
"short": "Calculate love percentage for two users.",
"end_user_data_statement": "This cog uses the core Bank cog. It store no End User Data otherwise.",
"tags": [
"bobloy",

@ -1,9 +1,13 @@
import logging
import aiohttp
import discord
from bs4 import BeautifulSoup
from redbot.core import commands
from redbot.core.commands import Cog
log = logging.getLogger("red.fox_v3.chatter")
class LoveCalculator(Cog):
"""Calculate the love percentage for two users!"""
@ -28,17 +32,25 @@ class LoveCalculator(Cog):
url = "https://www.lovecalculator.com/love.php?name1={}&name2={}".format(
x.replace(" ", "+"), y.replace(" ", "+")
)
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
soup_object = BeautifulSoup(await response.text(), "html.parser")
try:
description = (
soup_object.find("div", attrs={"class": "result__score"})
.get_text()
.strip()
)
except:
async with aiohttp.ClientSession(headers={"Connection": "keep-alive"}) as session:
async with session.get(url, ssl=False) as response:
assert response.status == 200
resp = await response.text()
log.debug(f"{resp=}")
soup_object = BeautifulSoup(resp, "html.parser")
description = soup_object.find("div", class_="result__score").get_text()
if description is None:
description = "Dr. Love is busy right now"
else:
description = description.strip()
result_image = soup_object.find("img", class_="result__image").get("src")
result_text = soup_object.find("div", class_="result-text").get_text()
result_text = " ".join(result_text.split())
try:
z = description[:2]
@ -47,11 +59,15 @@ class LoveCalculator(Cog):
emoji = ""
else:
emoji = "💔"
title = "Dr. Love says that the love percentage for {} and {} is:".format(x, y)
title = f"Dr. Love says that the love percentage for {x} and {y} is: {emoji} {description} {emoji}"
except:
emoji = ""
title = "Dr. Love has left a note for you."
description = emoji + " " + description + " " + emoji
em = discord.Embed(title=title, description=description, color=discord.Color.red())
em = discord.Embed(
title=title,
description=result_text,
color=discord.Color.red(),
url=f"https://www.lovecalculator.com/{result_image}",
)
await ctx.send(embed=em)

@ -75,9 +75,7 @@ class LastSeen(Cog):
else:
last_seen = await self.config.member(member).seen()
if last_seen is None:
await ctx.maybe_send_embed(
embed=discord.Embed(description="I've never seen this user")
)
await ctx.maybe_send_embed("I've never seen this user")
return
last_seen = self.get_date_time(last_seen)

@ -85,7 +85,9 @@ class Nudity(commands.Cog):
if r["unsafe"] > 0.7:
await nsfw_channel.send(
"NSFW Image from {}".format(message.channel.mention),
file=discord.File(image,),
file=discord.File(
image,
),
)
@commands.Cog.listener()

@ -360,7 +360,9 @@ class PlantTycoon(commands.Cog):
``{0}prune``: Prune your plant.\n"""
em = discord.Embed(
title=title, description=description.format(prefix), color=discord.Color.green(),
title=title,
description=description.format(prefix),
color=discord.Color.green(),
)
em.set_thumbnail(url="https://image.prntscr.com/image/AW7GuFIBSeyEgkR2W3SeiQ.png")
em.set_footer(
@ -525,7 +527,8 @@ class PlantTycoon(commands.Cog):
if t:
em = discord.Embed(
title="Plant statistics of {}".format(plant["name"]), color=discord.Color.green(),
title="Plant statistics of {}".format(plant["name"]),
color=discord.Color.green(),
)
em.set_thumbnail(url=plant["image"])
em.add_field(name="**Name**", value=plant["name"])
@ -583,7 +586,8 @@ class PlantTycoon(commands.Cog):
author = ctx.author
if product is None:
em = discord.Embed(
title="All gardening supplies that you can buy:", color=discord.Color.green(),
title="All gardening supplies that you can buy:",
color=discord.Color.green(),
)
for pd in self.products:
em.add_field(
@ -616,8 +620,11 @@ class PlantTycoon(commands.Cog):
await gardener.save_gardener()
message = "You bought {}.".format(product.lower())
else:
message = "You don't have enough Thneeds. You have {}, but need {}.".format(
gardener.points, self.products[product.lower()]["cost"] * amount,
message = (
"You don't have enough Thneeds. You have {}, but need {}.".format(
gardener.points,
self.products[product.lower()]["cost"] * amount,
)
)
else:
message = "I don't have this product."

@ -1,3 +1,4 @@
import logging
from typing import List, Union
import discord
@ -5,6 +6,8 @@ from redbot.core import Config, commands
from redbot.core.bot import Red
from redbot.core.commands import Cog
log = logging.getLogger("red.fox_v3.reactrestrict")
class ReactRestrictCombo:
def __init__(self, message_id, role_id):
@ -131,10 +134,12 @@ class ReactRestrict(Cog):
If no such channel or member can be found.
"""
channel = self.bot.get_channel(channel_id)
if channel is None:
raise LookupError("no channel found.")
try:
member = channel.guild.get_member(user_id)
except AttributeError as e:
raise LookupError("No channel found.") from e
raise LookupError("No member found.") from e
if member is None:
raise LookupError("No member found.")
@ -168,7 +173,7 @@ class ReactRestrict(Cog):
"""
channel = self.bot.get_channel(channel_id)
try:
return await channel.get_message(message_id)
return await channel.fetch_message(message_id)
except discord.NotFound:
pass
except AttributeError: # VoiceChannel object has no attribute 'get_message'
@ -186,9 +191,11 @@ class ReactRestrict(Cog):
:param message_id:
:return:
"""
for channel in ctx.guild.channels:
guild: discord.Guild = ctx.guild
for channel in guild.text_channels:
try:
return await channel.get_message(message_id)
return await channel.fetch_message(message_id)
except discord.NotFound:
pass
except AttributeError: # VoiceChannel object has no attribute 'get_message'
@ -232,7 +239,7 @@ class ReactRestrict(Cog):
# noinspection PyTypeChecker
await self.add_reactrestrict(message_id, role)
await ctx.maybe_send_embed("Message|Role combo added.")
await ctx.maybe_send_embed("Message|Role restriction added.")
@reactrestrict.command()
async def remove(self, ctx: commands.Context, message_id: int, role: discord.Role):
@ -248,37 +255,38 @@ class ReactRestrict(Cog):
# noinspection PyTypeChecker
await self.remove_react(message_id, role)
await ctx.send("Reaction removed.")
await ctx.send("React restriction removed.")
@commands.Cog.listener()
async def on_raw_reaction_add(
self, emoji: discord.PartialEmoji, message_id: int, channel_id: int, user_id: int
):
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
"""
Event handler for long term reaction watching.
:param discord.PartialReactionEmoji emoji:
:param int message_id:
:param int channel_id:
:param int user_id:
:return:
"""
if emoji.is_custom_emoji():
emoji_id = emoji.id
else:
emoji_id = emoji.name
emoji = payload.emoji
message_id = payload.message_id
channel_id = payload.channel_id
user_id = payload.user_id
# if emoji.is_custom_emoji():
# emoji_id = emoji.id
# else:
# emoji_id = emoji.name
has_reactrestrict, combos = await self.has_reactrestrict_combo(message_id)
if not has_reactrestrict:
log.debug("Message not react restricted")
return
try:
member = self._get_member(channel_id, user_id)
except LookupError:
log.exception("Unable to get member from guild")
return
if member.bot:
log.debug("Won't remove reactions added by bots")
return
if await self.bot.cog_disabled_in_guild(self, member.guild):
@ -287,14 +295,19 @@ class ReactRestrict(Cog):
try:
roles = [self._get_role(member.guild, c.role_id) for c in combos]
except LookupError:
log.exception("Couldn't get approved roles from combos")
return
for apprrole in roles:
if apprrole in member.roles:
log.debug("Has approved role")
return
message = await self._get_message_from_channel(channel_id, message_id)
try:
await message.remove_reaction(emoji, member)
except (discord.Forbidden, discord.NotFound, discord.HTTPException):
log.exception("Unable to remove reaction")
# try:
# await member.add_roles(*roles)

@ -1,9 +1,13 @@
import asyncio
import logging
from typing import Union
import discord
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.commands import Cog
log = logging.getLogger("red.fox_v3.stealemoji")
# Replaced with discord.Asset.read()
# async def fetch_img(session: aiohttp.ClientSession, url: StrOrURL):
# async with session.get(url) as response:
@ -43,7 +47,13 @@ class StealEmoji(Cog):
super().__init__()
self.bot = red
self.config = Config.get_conf(self, identifier=11511610197108101109111106105)
default_global = {"stolemoji": {}, "guildbanks": [], "on": False, "notify": 0}
default_global = {
"stolemoji": {},
"guildbanks": [],
"on": False,
"notify": 0,
"autobank": False,
}
self.config.register_global(**default_global)
@ -124,6 +134,17 @@ class StealEmoji(Cog):
await ctx.maybe_send_embed("Collection is now " + str(not curr_setting))
@checks.is_owner()
@stealemoji.command(name="autobank")
async def se_autobank(self, ctx):
"""Toggles automatically creating new guilds as emoji banks"""
curr_setting = await self.config.autobank()
await self.config.autobank.set(not curr_setting)
self.is_on = await self.config.autobank()
await ctx.maybe_send_embed("AutoBanking is now " + str(not curr_setting))
@checks.is_owner()
@commands.guild_only()
@stealemoji.command(name="bank")
@ -193,7 +214,7 @@ class StealEmoji(Cog):
# This is now a custom emoji that the bot doesn't have access to, time to steal it
# First, do I have an available guildbank?
guildbank = None
guildbank: Union[discord.Guild, None] = None
banklist = await self.config.guildbanks()
for guild_id in banklist:
guild: discord.Guild = self.bot.get_guild(guild_id)
@ -203,8 +224,32 @@ class StealEmoji(Cog):
break
if guildbank is None:
# print("No guildbank to store emoji")
# Eventually make a new banklist
if await self.config.autobank():
try:
guildbank: discord.Guild = await self.bot.create_guild(
"StealEmoji Guildbank", code="S93bqTqKQ9rM"
)
except discord.HTTPException:
await self.config.autobank.set(False)
log.exception("Unable to create guilds, disabling autobank")
return
async with self.config.guildbanks() as guildbanks:
guildbanks.append(guildbank.id)
await asyncio.sleep(2)
if guildbank.text_channels:
channel = guildbank.text_channels[0]
else:
# Always hits the else.
# Maybe create_guild doesn't return guild object with
# the template channel?
channel = await guildbank.create_text_channel("invite-channel")
invite = await channel.create_invite()
await self.bot.send_to_owners(invite)
log.info(f"Guild created id {guildbank.id}. Invite: {invite}")
else:
return
# Next, have I saved this emoji before (because uploaded emoji != orignal emoji)

@ -3,10 +3,10 @@
"Bobloy"
],
"min_bot_version": "3.3.0",
"description": "Apply roles based on the # of days on server",
"description": "Apply roles based on the # of hours or days on server",
"hidden": false,
"install_msg": "Thank you for installing timerole.\nGet started with `[p]load timerole`. Configure with `[p]timerole`",
"short": "Apply roles after # of days",
"short": "Apply roles after # of hours or days",
"end_user_data_statement": "This cog does not store any End User Data",
"tags": [
"bobloy",

@ -1,12 +1,23 @@
import asyncio
import logging
from datetime import datetime, timedelta
import discord
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.commands import Cog, parse_timedelta
from redbot.core.utils import AsyncIter
from redbot.core.utils.chat_formatting import pagify
log = logging.getLogger("red.fox_v3.timerole")
async def sleep_till_next_hour():
now = datetime.utcnow()
next_hour = datetime(year=now.year, month=now.month, day=now.day, hour=now.hour + 1)
log.debug("Sleeping for {} seconds".format((next_hour - datetime.utcnow()).seconds))
await asyncio.sleep((next_hour - datetime.utcnow()).seconds)
class Timerole(Cog):
"""Add roles to users based on time on server"""
@ -20,7 +31,7 @@ class Timerole(Cog):
self.config.register_global(**default_global)
self.config.register_guild(**default_guild)
self.updating = asyncio.create_task(self.check_day())
self.updating = asyncio.create_task(self.check_hour())
async def red_delete_data_for_user(self, **kwargs):
"""Nothing to delete"""
@ -34,11 +45,12 @@ class Timerole(Cog):
@commands.guild_only()
async def runtimerole(self, ctx: commands.Context):
"""
Trigger the daily timerole
Trigger the hourly timerole
Useful for troubleshooting the initial setup
"""
async with ctx.typing():
await self.timerole_update()
await ctx.tick()
@ -129,7 +141,7 @@ class Timerole(Cog):
guild = ctx.guild
role_dict = await self.config.guild(guild).roles()
out = ""
out = "Current Timeroles:\n"
for r_id, r_data in role_dict.items():
if r_data is not None:
role = discord.utils.get(guild.roles, id=int(r_id))
@ -141,11 +153,11 @@ class Timerole(Cog):
str(discord.utils.get(guild.roles, id=int(new_id)))
for new_id in r_data["required"]
]
out += "{} || {} days || requires: {}\n".format(str(role), r_data["days"], r_roles)
out += "{} | {} days | requires: {}\n".format(str(role), r_data["days"], r_roles)
await ctx.maybe_send_embed(out)
async def timerole_update(self):
for guild in self.bot.guilds:
async for guild in AsyncIter(self.bot.guilds):
addlist = []
removelist = []
@ -153,7 +165,7 @@ class Timerole(Cog):
if not any(role_data for role_data in role_dict.values()): # No roles
continue
for member in guild.members:
async for member in AsyncIter(guild.members):
has_roles = [r.id for r in member.roles]
add_roles = [
@ -188,7 +200,7 @@ class Timerole(Cog):
async def announce_roles(self, title, role_list, channel, guild, to_add: True):
results = ""
for member, role_id in role_list:
async for member, role_id in AsyncIter(role_list):
role = discord.utils.get(guild.roles, id=role_id)
try:
if to_add:
@ -203,9 +215,11 @@ class Timerole(Cog):
await channel.send(title)
for page in pagify(results, shorten_by=50):
await channel.send(page)
elif results: # Channel is None, log the results
log.info(results)
async def check_required_and_date(self, role_list, check_roles, has_roles, member, role_dict):
for role_id in check_roles:
async for role_id in AsyncIter(check_roles):
# Check for required role
if "required" in role_dict[str(role_id)]:
if not set(role_dict[str(role_id)]["required"]) & set(has_roles):
@ -223,7 +237,8 @@ class Timerole(Cog):
# Qualifies
role_list.append((member, role_id))
async def check_day(self):
async def check_hour(self):
await sleep_till_next_hour()
while self is self.bot.get_cog("Timerole"):
await self.timerole_update()
await asyncio.sleep(3600)
await sleep_till_next_hour()

@ -1,5 +1,7 @@
import bisect
import logging
from collections import defaultdict
from operator import attrgetter
from random import choice
import discord
@ -8,77 +10,55 @@ import discord
# Import all roles here
from redbot.core import commands
from .roles.seer import Seer
from .roles.vanillawerewolf import VanillaWerewolf
from .roles.villager import Villager
from redbot.core.utils.menus import menu, prev_page, next_page, close_menu
# All roles in this list for iterating
ROLE_LIST = sorted([Villager, Seer, VanillaWerewolf], key=lambda x: x.alignment)
ALIGNMENT_COLORS = [0x008000, 0xff0000, 0xc0c0c0]
TOWN_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment == 1]
WW_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment == 2]
OTHER_ROLES = [(idx, role) for idx, role in enumerate(ROLE_LIST) if role.alignment not in [0, 1]]
# from .roles.seer import Seer
# from .roles.vanillawerewolf import VanillaWerewolf
# from .roles.villager import Villager
ROLE_PAGES = []
PAGE_GROUPS = [0]
from werewolf import roles
from redbot.core.utils.menus import menu, prev_page, next_page, close_menu
ROLE_CATEGORIES = {
1: "Random", 2: "Investigative", 3: "Protective", 4: "Government",
5: "Killing", 6: "Power (Special night action)",
11: "Random", 12: "Deception", 15: "Killing", 16: "Support",
21: "Benign", 22: "Evil", 23: "Killing"}
from werewolf.constants import ROLE_CATEGORY_DESCRIPTIONS
from werewolf.role import Role
CATEGORY_COUNT = []
log = logging.getLogger("red.fox_v3.werewolf.builder")
# All roles in this list for iterating
def role_embed(idx, role, color):
embed = discord.Embed(title="**{}** - {}".format(idx, str(role.__name__)), description=role.game_start_message,
color=color)
embed.add_field(name='Alignment', value=['Town', 'Werewolf', 'Neutral'][role.alignment - 1], inline=True)
embed.add_field(name='Multiples Allowed', value=str(not role.unique), inline=True)
embed.add_field(name='Role Type', value=", ".join(ROLE_CATEGORIES[x] for x in role.category), inline=True)
embed.add_field(name='Random Option', value=str(role.rand_choice), inline=True)
ROLE_DICT = {name: cls for name, cls in roles.__dict__.items() if isinstance(cls, type)}
ROLE_LIST = sorted(
[cls for cls in ROLE_DICT.values()],
key=attrgetter("alignment"),
)
return embed
log.debug(f"{ROLE_DICT=}")
# Town, Werewolf, Neutral
ALIGNMENT_COLORS = [0x008000, 0xFF0000, 0xC0C0C0]
def setup():
# Roles
last_alignment = ROLE_LIST[0].alignment
for idx, role in enumerate(ROLE_LIST):
if role.alignment != last_alignment and len(ROLE_PAGES) - 1 not in PAGE_GROUPS:
PAGE_GROUPS.append(len(ROLE_PAGES) - 1)
last_alignment = role.alignment
ROLE_PAGES = []
ROLE_PAGES.append(role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]))
# Random Town Roles
if len(ROLE_PAGES) - 1 not in PAGE_GROUPS:
PAGE_GROUPS.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORIES.items():
if 0 < k <= 6:
ROLE_PAGES.append(discord.Embed(title="RANDOM:Town Role", description="Town {}".format(v), color=0x008000))
CATEGORY_COUNT.append(k)
def role_embed(idx, role: Role, color):
embed = discord.Embed(
title=f"**{idx}** - {role.__name__}",
description=role.game_start_message,
color=color,
)
if role.icon_url is not None:
embed.set_thumbnail(url=role.icon_url)
embed.add_field(
name="Alignment", value=["Town", "Werewolf", "Neutral"][role.alignment - 1], inline=False
)
embed.add_field(name="Multiples Allowed", value=str(not role.unique), inline=False)
embed.add_field(
name="Role Types",
value=", ".join(ROLE_CATEGORY_DESCRIPTIONS[x] for x in role.category),
inline=False,
)
embed.add_field(name="Random Option", value=str(role.rand_choice), inline=False)
# Random WW Roles
if len(ROLE_PAGES) - 1 not in PAGE_GROUPS:
PAGE_GROUPS.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORIES.items():
if 10 < k <= 16:
ROLE_PAGES.append(
discord.Embed(title="RANDOM:Werewolf Role", description="Werewolf {}".format(v), color=0xff0000))
CATEGORY_COUNT.append(k)
# Random Neutral Roles
if len(ROLE_PAGES) - 1 not in PAGE_GROUPS:
PAGE_GROUPS.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORIES.items():
if 20 < k <= 26:
ROLE_PAGES.append(
discord.Embed(title="RANDOM:Neutral Role", description="Neutral {}".format(v), color=0xc0c0c0))
CATEGORY_COUNT.append(k)
return embed
"""
@ -147,15 +127,15 @@ async def parse_code(code, game):
return decode
async def encode(roles, rand_roles):
async def encode(role_list, rand_roles):
"""Convert role list to code"""
out_code = ""
digit_sort = sorted(role for role in roles if role < 10)
digit_sort = sorted(role for role in role_list if role < 10)
for role in digit_sort:
out_code += str(role)
digit_sort = sorted(role for role in roles if 10 <= role < 100)
digit_sort = sorted(role for role in role_list if 10 <= role < 100)
if digit_sort:
out_code += "-"
for role in digit_sort:
@ -187,49 +167,20 @@ async def encode(roles, rand_roles):
return out_code
async def next_group(ctx: commands.Context, 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
page = bisect.bisect_right(PAGE_GROUPS, page)
if page == len(PAGE_GROUPS):
page = PAGE_GROUPS[0]
else:
page = PAGE_GROUPS[page]
return await menu(ctx, pages, controls, message=message,
page=page, timeout=timeout)
async def prev_group(ctx: commands.Context, 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
page = PAGE_GROUPS[bisect.bisect_left(PAGE_GROUPS, page) - 1]
return await menu(ctx, pages, controls, message=message,
page=page, timeout=timeout)
def role_from_alignment(alignment):
return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST) if alignment == role.alignment]
return [
role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST)
if alignment == role.alignment
]
def role_from_category(category):
return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST) if category in role.category]
return [
role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST)
if category in role.category
]
def role_from_id(idx):
@ -242,8 +193,11 @@ def role_from_id(idx):
def role_from_name(name: str):
return [role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST) if name in role.__name__]
return [
role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1])
for idx, role in enumerate(ROLE_LIST)
if name in role.__name__
]
def say_role_list(code_list, rand_roles):
@ -255,34 +209,87 @@ def say_role_list(code_list, rand_roles):
for role in rand_roles:
if 0 < role <= 6:
role_dict["Town {}".format(ROLE_CATEGORIES[role])] += 1
role_dict[f"Town {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1
if 10 < role <= 16:
role_dict["Werewolf {}".format(ROLE_CATEGORIES[role])] += 1
role_dict[f"Werewolf {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1
if 20 < role <= 26:
role_dict["Neutral {}".format(ROLE_CATEGORIES[role])] += 1
role_dict[f"Neutral {ROLE_CATEGORY_DESCRIPTIONS[role]}"] += 1
for k, v in role_dict.items():
embed.add_field(name=k, value="Count: {}".format(v), inline=True)
embed.add_field(name=k, value=f"Count: {v}", inline=True)
return embed
class GameBuilder:
def __init__(self):
self.code = []
self.rand_roles = []
setup()
self.page_groups = [0]
self.category_count = []
self.setup()
def setup(self):
# Roles
last_alignment = ROLE_LIST[0].alignment
for idx, role in enumerate(ROLE_LIST):
if role.alignment != last_alignment and len(ROLE_PAGES) - 1 not in self.page_groups:
self.page_groups.append(len(ROLE_PAGES) - 1)
last_alignment = role.alignment
ROLE_PAGES.append(role_embed(idx, role, ALIGNMENT_COLORS[role.alignment - 1]))
# Random Town Roles
if len(ROLE_PAGES) - 1 not in self.page_groups:
self.page_groups.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORY_DESCRIPTIONS.items():
if 0 < k <= 9:
ROLE_PAGES.append(
discord.Embed(
title="RANDOM:Town Role",
description=f"Town {v}",
color=ALIGNMENT_COLORS[0],
)
)
self.category_count.append(k)
# Random WW Roles
if len(ROLE_PAGES) - 1 not in self.page_groups:
self.page_groups.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORY_DESCRIPTIONS.items():
if 10 < k <= 19:
ROLE_PAGES.append(
discord.Embed(
title="RANDOM:Werewolf Role",
description=f"Werewolf {v}",
color=ALIGNMENT_COLORS[1],
)
)
self.category_count.append(k)
# Random Neutral Roles
if len(ROLE_PAGES) - 1 not in self.page_groups:
self.page_groups.append(len(ROLE_PAGES) - 1)
for k, v in ROLE_CATEGORY_DESCRIPTIONS.items():
if 20 < k <= 29:
ROLE_PAGES.append(
discord.Embed(
title=f"RANDOM:Neutral Role",
description=f"Neutral {v}",
color=ALIGNMENT_COLORS[2],
)
)
self.category_count.append(k)
async def build_game(self, ctx: commands.Context):
new_controls = {
'': prev_group,
"": self.prev_group,
"": prev_page,
'': self.select_page,
"": self.select_page,
"": next_page,
'': next_group,
'📇': self.list_roles,
"": close_menu
"": self.next_group,
"📇": self.list_roles,
"": close_menu,
}
await ctx.send("Browse through roles and add the ones you want using the check mark")
@ -292,10 +299,17 @@ class GameBuilder:
out = await encode(self.code, self.rand_roles)
return out
async def list_roles(self, ctx: commands.Context, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
async def list_roles(
self,
ctx: commands.Context,
pages: list,
controls: dict,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
@ -304,13 +318,19 @@ class GameBuilder:
await ctx.send(embed=say_role_list(self.code, self.rand_roles))
return await menu(ctx, pages, controls, message=message,
page=page, timeout=timeout)
async def select_page(self, ctx: commands.Context, pages: list,
controls: dict, message: discord.Message, page: int,
timeout: float, emoji: str):
perms = message.channel.permissions_for(ctx.guild.me)
return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout)
async def select_page(
self,
ctx: commands.Context,
pages: list,
controls: dict,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
@ -318,9 +338,53 @@ class GameBuilder:
pass
if page >= len(ROLE_LIST):
self.rand_roles.append(CATEGORY_COUNT[page - len(ROLE_LIST)])
self.rand_roles.append(self.category_count[page - len(ROLE_LIST)])
else:
self.code.append(page)
return await menu(ctx, pages, controls, message=message,
page=page, timeout=timeout)
return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout)
async def next_group(
self,
ctx: commands.Context,
pages: list,
controls: dict,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
page = bisect.bisect_right(self.page_groups, page)
if page == len(self.page_groups):
page = self.page_groups[0]
else:
page = self.page_groups[page]
return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout)
async def prev_group(
self,
ctx: commands.Context,
pages: list,
controls: dict,
message: discord.Message,
page: int,
timeout: float,
emoji: str,
):
perms = message.channel.permissions_for(ctx.me)
if perms.manage_messages: # Can manage messages, so remove react
try:
await message.remove_reaction(emoji, ctx.author)
except discord.NotFound:
pass
page = self.page_groups[bisect.bisect_left(self.page_groups, page) - 1]
return await menu(ctx, pages, controls, message=message, page=page, timeout=timeout)

@ -0,0 +1,91 @@
"""
Role Constants
Role Alignment guide as follows:
Town: 1
Werewolf: 2
Neutral: 3
Additional alignments may be added when warring factions are added
(Rival werewolves, cultists, vampires)
Role Category enrollment guide as follows (See Role.category):
Town:
1: Random, 2: Investigative, 3: Protective, 4: Government,
5: Killing, 6: Power (Special night action)
Werewolf:
11: Random, 12: Deception, 15: Killing, 16: Support
Neutral:
21: Benign, 22: Evil, 23: Killing
Example category:
category = [1, 5, 6] Could be Veteran
category = [1, 5] Could be Bodyguard
category = [11, 16] Could be Werewolf Silencer
category = [22] Could be Blob (non-killing)
category = [22, 23] Could be Serial-Killer
"""
ALIGNMENT_TOWN = 1
ALIGNMENT_WEREWOLF = 2
ALIGNMENT_NEUTRAL = 3
ALIGNMENT_MAP = {"Town": 1, "Werewolf": 2, "Neutral": 3}
# 0-9: Town Role Categories
# 10-19: Werewolf Role Categories
# 20-29: Neutral Role Categories
CATEGORY_TOWN_RANDOM = 1
CATEGORY_TOWN_INVESTIGATIVE = 2
CATEGORY_TOWN_PROTECTIVE = 3
CATEGORY_TOWN_GOVERNMENT = 4
CATEGORY_TOWN_KILLING = 5
CATEGORY_TOWN_POWER = 6
CATEGORY_WW_RANDOM = 11
CATEGORY_WW_DECEPTION = 12
CATEGORY_WW_KILLING = 15
CATEGORY_WW_SUPPORT = 16
CATEGORY_NEUTRAL_BENIGN = 21
CATEGORY_NEUTRAL_EVIL = 22
CATEGORY_NEUTRAL_KILLING = 23
ROLE_CATEGORY_DESCRIPTIONS = {
CATEGORY_TOWN_RANDOM: "Random",
CATEGORY_TOWN_INVESTIGATIVE: "Investigative",
CATEGORY_TOWN_PROTECTIVE: "Protective",
CATEGORY_TOWN_GOVERNMENT: "Government",
CATEGORY_TOWN_KILLING: "Killing",
CATEGORY_TOWN_POWER: "Power (Special night action)",
CATEGORY_WW_RANDOM: "Random",
CATEGORY_WW_DECEPTION: "Deception",
CATEGORY_WW_KILLING: "Killing",
CATEGORY_WW_SUPPORT: "Support",
CATEGORY_NEUTRAL_BENIGN: "Benign",
CATEGORY_NEUTRAL_EVIL: "Evil",
CATEGORY_NEUTRAL_KILLING: "Killing",
}
"""
Listener Actions Priority Guide
Action priority guide as follows (see listeners.py for wolflistener):
_at_night_start
0. No Action
1. Detain actions (Jailer/Kidnapper)
2. Group discussions and choose targets
_at_night_end
0. No Action
1. Self actions (Veteran)
2. Target switching and role blocks (bus driver, witch, escort)
3. Protection / Preempt actions (bodyguard/framer)
4. Non-disruptive actions (seer/silencer)
5. Disruptive actions (Killing)
6. Role altering actions (Cult / Mason / Shifter)
"""

@ -0,0 +1,28 @@
from typing import TYPE_CHECKING, Union
import discord
from discord.ext.commands import BadArgument, Converter
from redbot.core import commands
from werewolf.player import Player
if TYPE_CHECKING:
PlayerConverter = Union[int, discord.Member]
CronConverter = str
else:
class PlayerConverter(Converter):
async def convert(self, ctx, argument) -> Player:
try:
target = await commands.MemberConverter().convert(ctx, argument)
except BadArgument:
try:
target = int(argument)
assert target >= 0
except (ValueError, AssertionError):
raise BadArgument
# TODO: Get the game for context without making a new one
# TODO: Get player from game based on either ID or member object
return target

File diff suppressed because it is too large Load Diff

@ -4,10 +4,10 @@
],
"min_bot_version": "3.3.0",
"description": "Customizable Werewolf Game",
"hidden": true,
"hidden": false,
"install_msg": "Thank you for installing Werewolf! Get started with `[p]load werewolf`\n Use `[p]wwset` to run inital setup",
"requirements": [],
"short": "Werewolf Game",
"short": "[ALPHA] Play Werewolf (Mafia) Game in discord",
"end_user_data_statement": "This stores user IDs in memory while they're actively using the cog, and stores no persistent End User Data.",
"tags": [
"mafia",

@ -0,0 +1,106 @@
import inspect
def wolflistener(name=None, priority=0):
"""A decorator that marks a function as a listener.
This is the werewolf.Game equivalent of :meth:`.Cog.listener`.
Parameters
------------
name: :class:`str`
The name of the event being listened to. If not provided, it
defaults to the function's name.
priority: :class:`int`
The priority of the listener.
Priority guide as follows:
_at_night_start
0. No Action
1. Detain actions (Jailer/Kidnapper)
2. Group discussions and choose targets
_at_night_end
0. No Action
1. Self actions (Veteran)
2. Target switching and role blocks (bus driver, witch, escort)
3. Protection / Preempt actions (bodyguard/framer)
4. Non-disruptive actions (seer/silencer)
5. Disruptive actions (Killing)
6. Role altering actions (Cult / Mason / Shifter)
Raises
--------
TypeError
The function is not a coroutine function or a string was not passed as
the name.
"""
if name is not None and not isinstance(name, str):
raise TypeError(
"Game.listener expected str but received {0.__class__.__name__!r} instead.".format(
name
)
)
def decorator(func):
actual = func
if isinstance(actual, staticmethod):
actual = actual.__func__
if not inspect.iscoroutinefunction(actual):
raise TypeError("Listener function must be a coroutine function.")
actual.__wolf_listener__ = priority
to_assign = name or actual.__name__
try:
actual.__wolf_listener_names__.append((priority, to_assign))
except AttributeError:
actual.__wolf_listener_names__ = [(priority, to_assign)]
# we have to return `func` instead of `actual` because
# we need the type to be `staticmethod` for the metaclass
# to pick it up but the metaclass unfurls the function and
# thus the assignments need to be on the actual function
return func
return decorator
class WolfListenerMeta(type):
def __new__(mcs, *args, **kwargs):
name, bases, attrs = args
listeners = {}
need_at_msg = "Listeners must start with at_ (in method {0.__name__}.{1})"
new_cls = super().__new__(mcs, name, bases, attrs, **kwargs)
for base in reversed(new_cls.__mro__):
for elem, value in base.__dict__.items():
if elem in listeners:
del listeners[elem]
is_static_method = isinstance(value, staticmethod)
if is_static_method:
value = value.__func__
if inspect.iscoroutinefunction(value):
try:
is_listener = getattr(value, "__wolf_listener__")
except AttributeError:
continue
else:
# if not elem.startswith("at_"):
# raise TypeError(need_at_msg.format(base, elem))
listeners[elem] = value
listeners_as_list = []
for listener in listeners.values():
for priority, listener_name in listener.__wolf_listener_names__:
# I use __name__ instead of just storing the value so I can inject
# the self attribute when the time comes to add them to the bot
listeners_as_list.append((priority, listener_name, listener.__name__))
new_cls.__wolf_listeners__ = listeners_as_list
return new_cls
class WolfListener(metaclass=WolfListenerMeta):
def __init__(self, game):
for priority, name, method_name in self.__wolf_listeners__:
game.add_ww_listener(getattr(self, method_name), priority, name)

@ -1,4 +1,8 @@
from .role import Role
import logging
from werewolf.role import Role
log = logging.getLogger("red.fox_v3.werewolf.night_powers")
def night_immune(role: Role):

@ -1,5 +1,9 @@
import logging
import discord
log = logging.getLogger("red.fox_v3.werewolf.player")
class Player:
"""
@ -16,6 +20,9 @@ class Player:
self.muted = False
self.protected = False
def __repr__(self):
return f"{self.__class__.__name__}({self.member})"
async def assign_role(self, role):
"""
Give this player a role
@ -28,6 +35,15 @@ class Player:
async def send_dm(self, message):
try:
await self.member.send(message) # Lets do embeds later
await self.member.send(message) # Lets ToDo embeds later
except discord.Forbidden:
await self.role.game.village_channel.send("Couldn't DM {}, uh oh".format(self.mention))
log.info(f"Unable to mention {self.member.__repr__()}")
await self.role.game.village_channel.send(
f"Couldn't DM {self.mention}, uh oh",
allowed_mentions=discord.AllowedMentions(users=[self.member]),
)
except AttributeError:
log.exception("Someone messed up and added a bot to the game (I think)")
await self.role.game.village_channel.send(
"Someone messed up and added a bot to the game :eyes:"
)

@ -1,4 +1,12 @@
class Role:
import inspect
import logging
from werewolf.listener import WolfListener, wolflistener
log = logging.getLogger("red.fox_v3.werewolf.role")
class Role(WolfListener):
"""
Base Role class for werewolf game
@ -18,9 +26,11 @@ class Role:
category = [1, 5, 6] Could be Veteran
category = [1, 5] Could be Bodyguard
category = [11, 16] Could be Werewolf Silencer
category = [22] Could be Blob (non-killing)
category = [22, 23] Could be Serial-Killer
Action guide as follows (on_event function):
Action priority guide as follows (on_event function):
_at_night_start
0. No Action
1. Detain actions (Jailer/Kidnapper)
@ -33,13 +43,15 @@ class Role:
3. Protection / Preempt actions (bodyguard/framer)
4. Non-disruptive actions (seer/silencer)
5. Disruptive actions (Killing)
6. Role altering actions (Cult / Mason)
6. Role altering actions (Cult / Mason / Shifter)
"""
rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles)
# Determines if it can be picked as a random role (False for unusually disruptive roles)
rand_choice = False # TODO: Rework random with categories
town_balance = 0 # Guess at power level and it's balance on the town
category = [0] # List of enrolled categories (listed above)
alignment = 0 # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "" # Empty for no private channel
channel_name = "" # Empty for no private channel
unique = False # Only one of this role per game
game_start_message = (
"Your role is **Default**\n"
@ -54,32 +66,14 @@ class Role:
icon_url = None # Adding a URL here will enable a thumbnail of the role
def __init__(self, game):
super().__init__(game)
self.game = game
self.player = None
self.blocked = False
self.properties = {} # Extra data for other roles (i.e. arsonist)
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 0),
(self._at_hang, 0),
(self._at_day_end, 0),
(self._at_night_start, 0),
(self._at_night_end, 0),
(self._at_visit, 0)
]
def __repr__(self):
return self.__class__.__name__
async def on_event(self, event, data):
"""
See Game class for event guide
"""
await self.action_list[event][0](data)
return f"{self.__class__.__name__}({self.player.__repr__()})"
async def assign_player(self, player):
"""
@ -90,6 +84,8 @@ class Role:
player.role = self
self.player = player
log.debug(f"Assigned {self} to {player}")
async def get_alignment(self, source=None):
"""
Interaction for powerful access of alignment
@ -101,7 +97,7 @@ class Role:
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see alignment (Village, Werewolf Other)
to see alignment (Village, Werewolf, Other)
"""
return "Other"
@ -119,35 +115,16 @@ class Role:
"""
return "Default"
async def _at_game_start(self, data=None):
if self.channel_id:
await self.game.register_channel(self.channel_id, self)
@wolflistener("at_game_start", priority=2)
async def _at_game_start(self):
if self.channel_name:
await self.game.register_channel(self.channel_name, self)
try:
await self.player.send_dm(self.game_start_message) # Maybe embeds eventually
async def _at_day_start(self, data=None):
pass
async def _at_voted(self, data=None):
pass
async def _at_kill(self, data=None):
pass
async def _at_hang(self, data=None):
pass
async def _at_day_end(self, data=None):
pass
async def _at_night_start(self, data=None):
pass
async def _at_night_end(self, data=None):
pass
async def _at_visit(self, data=None):
pass
except AttributeError as e:
log.exception(self.__repr__())
raise e
async def kill(self, source):
"""

@ -0,0 +1,11 @@
from .villager import Villager
from .seer import Seer
from .vanillawerewolf import VanillaWerewolf
from .shifter import Shifter
# Don't sort these imports. They are unstably in order
# TODO: Replace with unique IDs for roles in the future
__all__ = ["Seer", "Shifter", "VanillaWerewolf", "Villager"]

@ -0,0 +1,101 @@
import logging
import random
from werewolf.constants import ALIGNMENT_NEUTRAL, CATEGORY_NEUTRAL_EVIL
from werewolf.listener import wolflistener
from werewolf.player import Player
from werewolf.role import Role
log = logging.getLogger("red.fox_v3.werewolf.role.blob")
class TheBlob(Role):
rand_choice = True
category = [CATEGORY_NEUTRAL_EVIL] # List of enrolled categories
alignment = ALIGNMENT_NEUTRAL # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "" # Empty for no private channel
unique = True # Only one of this role per game
game_start_message = (
"Your role is **The Blob**\n"
"You win by absorbing everyone town\n"
"Lynch players during the day with `[p]ww vote <ID>`\n"
"Each night you will absorb an adjacent player"
)
description = (
"A mysterious green blob of jelly, slowly growing in size.\n"
"The Blob fears no evil, must be dealt with in town"
)
def __init__(self, game):
super().__init__(game)
self.blob_target = None
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see team (Village, Werewolf, Other)
"""
return ALIGNMENT_NEUTRAL
async def get_role(self, source=None):
"""
Interaction for powerful access of role
Unlikely to be able to deceive this
"""
return "The Blob"
async def see_role(self, source=None):
"""
Interaction for investigative roles.
More common to be able to deceive these roles
"""
return "The Blob"
async def kill(self, source):
"""
Called when someone is trying to kill you!
Can you do anything about it?
self.player.alive is now set to False, set to True to stay alive
"""
# Blob cannot simply be killed
self.player.alive = True
@wolflistener("at_night_start", priority=2)
async def _at_night_start(self):
if not self.player.alive:
return
self.blob_target = None
idx = self.player.id
left_or_right = random.choice((-1, 1))
while self.blob_target is None:
idx += left_or_right
if idx >= len(self.game.players):
idx = 0
player = self.game.players[idx]
# you went full circle, everyone is a blob or something else is wrong
if player == self.player:
break
if player.role.properties.get("been_blobbed", False):
self.blob_target = player
if self.blob_target is not None:
await self.player.send_dm(f"**You will attempt to absorb {self.blob_target} tonight**")
else:
await self.player.send_dm(f"**No player will be absorbed tonight**")
@wolflistener("at_night_end", priority=4)
async def _at_night_end(self):
if self.blob_target is None or not self.player.alive:
return
target: "Player" = await self.game.visit(self.blob_target, self.player)
if target is not None:
target.role.properties["been_blobbed"] = True
self.game.night_results.append("The Blob grows...")

@ -1,11 +1,26 @@
from ..night_powers import pick_target
from ..role import Role
import logging
from werewolf.constants import (
ALIGNMENT_TOWN,
ALIGNMENT_WEREWOLF,
CATEGORY_TOWN_INVESTIGATIVE,
CATEGORY_TOWN_RANDOM,
)
from werewolf.listener import wolflistener
from werewolf.night_powers import pick_target
from werewolf.role import Role
log = logging.getLogger("red.fox_v3.werewolf.role.seer")
class Seer(Role):
rand_choice = True # Determines if it can be picked as a random role (False for unusually disruptive roles)
category = [1, 2] # List of enrolled categories (listed above)
alignment = 1 # 1: Town, 2: Werewolf, 3: Neutral
rand_choice = True
town_balance = 4
category = [
CATEGORY_TOWN_RANDOM,
CATEGORY_TOWN_INVESTIGATIVE,
] # List of enrolled categories (listed above)
alignment = ALIGNMENT_TOWN # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "" # Empty for no private channel
unique = False # Only one of this role per game
game_start_message = (
@ -14,8 +29,10 @@ class Seer(Role):
"Lynch players during the day with `[p]ww vote <ID>`\n"
"Check for werewolves at night with `[p]ww choose <ID>`"
)
description = "A mystic in search of answers in a chaotic town.\n" \
description = (
"A mystic in search of answers in a chaotic town.\n"
"Calls upon the cosmos to discern those of Lycan blood"
)
def __init__(self, game):
super().__init__(game)
@ -24,47 +41,49 @@ class Seer(Role):
# self.blocked = False
# self.properties = {} # Extra data for other roles (i.e. arsonist)
self.see_target = None
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 0),
(self._at_hang, 0),
(self._at_day_end, 0),
(self._at_night_start, 2),
(self._at_night_end, 4),
(self._at_visit, 0)
]
# self.action_list = [
# (self._at_game_start, 1), # (Action, Priority)
# (self._at_day_start, 0),
# (self._at_voted, 0),
# (self._at_kill, 0),
# (self._at_hang, 0),
# (self._at_day_end, 0),
# (self._at_night_start, 2),
# (self._at_night_end, 4),
# (self._at_visit, 0),
# ]
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see team (Village, Werewolf Other)
to see team (Village, Werewolf, Other)
"""
return "Village"
return ALIGNMENT_TOWN
async def get_role(self, source=None):
"""
Interaction for powerful access of role
Unlikely to be able to deceive this
"""
return "Villager"
return "Seer"
async def see_role(self, source=None):
"""
Interaction for investigative roles.
More common to be able to deceive these roles
"""
return "Villager"
return "Seer"
async def _at_night_start(self, data=None):
@wolflistener("at_night_start", priority=2)
async def _at_night_start(self):
if not self.player.alive:
return
self.see_target = None
await self.game.generate_targets(self.player.member)
await self.player.send_dm("**Pick a target to see tonight**")
async def _at_night_end(self, data=None):
@wolflistener("at_night_end", priority=4)
async def _at_night_end(self):
if self.see_target is None:
if self.player.alive:
await self.player.send_dm("You will not use your powers tonight...")
@ -75,9 +94,9 @@ class Seer(Role):
if target:
alignment = await target.role.see_alignment(self.player)
if alignment == "Werewolf":
if alignment == ALIGNMENT_WEREWOLF:
out = "Your insight reveals this player to be a **Werewolf!**"
else:
else: # Don't reveal neutrals
out = "You fail to find anything suspicious about this player..."
await self.player.send_dm(out)
@ -87,4 +106,6 @@ class Seer(Role):
await super().choose(ctx, data)
self.see_target, target = await pick_target(self, ctx, data)
await ctx.send("**You will attempt to see the role of {} tonight...**".format(target.member.display_name))
await ctx.send(
f"**You will attempt to see the role of {target.member.display_name} tonight...**"
)

@ -1,5 +1,11 @@
from ..night_powers import pick_target
from ..role import Role
import logging
from werewolf.constants import ALIGNMENT_NEUTRAL, CATEGORY_NEUTRAL_BENIGN
from werewolf.listener import wolflistener
from werewolf.night_powers import pick_target
from werewolf.role import Role
log = logging.getLogger("red.fox_v3.werewolf.role.shifter")
class Shifter(Role):
@ -37,12 +43,13 @@ class Shifter(Role):
3. Protection / Preempt actions (bodyguard/framer)
4. Non-disruptive actions (seer/silencer)
5. Disruptive actions (Killing)
6. Role altering actions (Cult / Mason)
6. Role altering actions (Cult / Mason / Shifter)
"""
rand_choice = False # Determines if it can be picked as a random role (False for unusually disruptive roles)
category = [22] # List of enrolled categories (listed above)
alignment = 3 # 1: Town, 2: Werewolf, 3: Neutral
town_balance = -3
category = [CATEGORY_NEUTRAL_BENIGN] # List of enrolled categories (listed above)
alignment = ALIGNMENT_NEUTRAL # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "" # Empty for no private channel
unique = False # Only one of this role per game
game_start_message = (
@ -61,22 +68,22 @@ class Shifter(Role):
super().__init__(game)
self.shift_target = None
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 0),
(self._at_hang, 0),
(self._at_day_end, 0),
(self._at_night_start, 2), # Chooses targets
(self._at_night_end, 6), # Role Swap
(self._at_visit, 0)
]
# self.action_list = [
# (self._at_game_start, 1), # (Action, Priority)
# (self._at_day_start, 0),
# (self._at_voted, 0),
# (self._at_kill, 0),
# (self._at_hang, 0),
# (self._at_day_end, 0),
# (self._at_night_start, 2), # Chooses targets
# (self._at_night_end, 6), # Role Swap
# (self._at_visit, 0),
# ]
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see alignment (Village, Werewolf, Other)
to see alignment (Village, Werewolf,, Other)
"""
return "Other"
@ -94,14 +101,14 @@ class Shifter(Role):
"""
return "Shifter"
async def _at_night_start(self, data=None):
await super()._at_night_start(data)
@wolflistener("at_night_start", priority=2)
async def _at_night_start(self):
self.shift_target = None
await self.game.generate_targets(self.player.member)
await self.player.send_dm("**Pick a target to shift into**")
async def _at_night_end(self, data=None):
await super()._at_night_end(data)
@wolflistener("at_night_end", priority=6)
async def _at_night_end(self):
if self.shift_target is None:
if self.player.alive:
await self.player.send_dm("You will not use your powers tonight...")
@ -114,16 +121,20 @@ class Shifter(Role):
# Roles have now been swapped
await self.player.send_dm("Your role has been stolen...\n"
"You are now a **Shifter**.")
await self.player.send_dm(
"Your role has been stolen...\n" "You are now a **Shifter**."
)
await self.player.send_dm(self.game_start_message)
await target.send_dm(target.role.game_start_message)
else:
await self.player.send_dm("**Your shift failed...**")
async def choose(self, ctx, data):
"""Handle night actions"""
await super().choose(ctx, data)
self.shift_target, target = await pick_target(self, ctx, data)
await ctx.send("**You will attempt to see the role of {} tonight...**".format(target.member.display_name))
await ctx.send(
f"**You will attempt to see the role of {target.member.display_name} tonight...**"
)

@ -1,13 +1,19 @@
from ..role import Role
import logging
from ..votegroups.wolfvote import WolfVote
from werewolf.constants import ALIGNMENT_WEREWOLF, CATEGORY_WW_KILLING, CATEGORY_WW_RANDOM
from werewolf.listener import wolflistener
from werewolf.role import Role
from werewolf.votegroups.wolfvote import WolfVote
log = logging.getLogger("red.fox_v3.werewolf.role.vanillawerewolf")
class VanillaWerewolf(Role):
rand_choice = True
category = [11, 15]
alignment = 2 # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "werewolves"
town_balance = -6
category = [CATEGORY_WW_RANDOM, CATEGORY_WW_KILLING]
alignment = ALIGNMENT_WEREWOLF # 1: Town, 2: Werewolf, 3: Neutral
channel_name = "werewolves"
unique = False
game_start_message = (
"Your role is **Werewolf**\n"
@ -16,34 +22,19 @@ class VanillaWerewolf(Role):
"Vote to kill players at night with `[p]ww vote <ID>`"
)
def __init__(self, game):
super().__init__(game)
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 0),
(self._at_hang, 0),
(self._at_day_end, 0),
(self._at_night_start, 0),
(self._at_night_end, 0),
(self._at_visit, 0)
]
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see team (Village, Werewolf Other)
"""
return "Werewolf"
return ALIGNMENT_WEREWOLF
async def get_role(self, source=None):
"""
Interaction for powerful access of role
Unlikely to be able to deceive this
"""
return "Werewolf"
return "VanillaWerewolf"
async def see_role(self, source=None):
"""
@ -52,10 +43,13 @@ class VanillaWerewolf(Role):
"""
return "Werewolf"
async def _at_game_start(self, data=None):
if self.channel_id:
print("Wolf has channel_id: " + self.channel_id)
await self.game.register_channel(self.channel_id, self, WolfVote) # Add VoteGroup WolfVote
@wolflistener("at_game_start", priority=2)
async def _at_game_start(self):
if self.channel_name:
log.debug("Wolf has channel_name: " + self.channel_name)
await self.game.register_channel(
self.channel_name, self, WolfVote
) # Add VoteGroup WolfVote
await self.player.send_dm(self.game_start_message)

@ -1,10 +1,17 @@
from ..role import Role
import logging
from werewolf.constants import ALIGNMENT_TOWN, CATEGORY_TOWN_RANDOM
from werewolf.role import Role
log = logging.getLogger("red.fox_v3.werewolf.role.villager")
class Villager(Role):
rand_choice = True # Determines if it can be picked as a random role (False for unusually disruptive roles)
category = [1] # List of enrolled categories (listed above)
alignment = 1 # 1: Town, 2: Werewolf, 3: Neutral
# Determines if it can be picked as a random role (False for unusually disruptive roles)
rand_choice = True
town_balance = 1
category = [CATEGORY_TOWN_RANDOM] # List of enrolled categories (listed above)
alignment = ALIGNMENT_TOWN # 1: Town, 2: Werewolf, 3: Neutral
channel_id = "" # Empty for no private channel
unique = False # Only one of this role per game
game_start_message = (
@ -13,15 +20,12 @@ class Villager(Role):
"Lynch players during the day with `[p]ww vote <ID>`"
)
def __init__(self, game):
super().__init__(game)
async def see_alignment(self, source=None):
"""
Interaction for investigative roles attempting
to see team (Village, Werewolf Other)
to see team (Village, Werewolf, Other)
"""
return "Village"
return ALIGNMENT_TOWN
async def get_role(self, source=None):
"""

@ -1,4 +1,11 @@
class VoteGroup:
import logging
from werewolf.listener import WolfListener, wolflistener
log = logging.getLogger("red.fox_v3.werewolf.votegroup")
class VoteGroup(WolfListener):
"""
Base VoteGroup class for werewolf game
Handles secret channels and group decisions
@ -8,58 +15,41 @@ class VoteGroup:
channel_id = ""
def __init__(self, game, channel):
super().__init__(game)
self.game = game
self.channel = channel
self.players = []
self.vote_results = {}
self.properties = {} # Extra data for other options
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 1),
(self._at_hang, 1),
(self._at_day_end, 0),
(self._at_night_start, 2),
(self._at_night_end, 0),
(self._at_visit, 0)
]
async def on_event(self, event, data):
"""
See Game class for event guide
"""
await self.action_list[event][0](data)
def __repr__(self):
return f"{self.__class__.__name__}({self.channel},{self.players})"
async def _at_game_start(self, data=None):
@wolflistener("at_game_start", priority=1)
async def _at_game_start(self):
await self.channel.send(" ".join(player.mention for player in self.players))
async def _at_day_start(self, data=None):
pass
async def _at_voted(self, data=None):
pass
async def _at_kill(self, data=None):
if data["player"] in self.players:
self.players.remove(data["player"])
async def _at_hang(self, data=None):
if data["player"] in self.players:
self.players.remove(data["player"])
@wolflistener("at_kill", priority=1)
async def _at_kill(self, player):
if player in self.players:
self.players.remove(player)
async def _at_day_end(self, data=None):
pass
@wolflistener("at_hang", priority=1)
async def _at_hang(self, player):
if player in self.players:
self.players.remove(player)
async def _at_night_start(self, data=None):
@wolflistener("at_night_start", priority=2)
async def _at_night_start(self):
if self.channel is None:
return
self.vote_results = {}
await self.game.generate_targets(self.channel)
async def _at_night_end(self, data=None):
@wolflistener("at_night_end", priority=5)
async def _at_night_end(self):
if self.channel is None:
return
@ -70,11 +60,8 @@ class VoteGroup:
target = max(set(vote_list), key=vote_list.count)
if target:
# Do what you voted on
pass
async def _at_visit(self, data=None):
pass
# Do what the votegroup votes on
raise NotImplementedError
async def register_players(self, *players):
"""
@ -90,7 +77,7 @@ class VoteGroup:
self.players.remove(player)
if not self.players:
# ToDo: Trigger deletion of votegroup
# TODO: Confirm deletion
pass
async def vote(self, target, author, target_id):

@ -0,0 +1 @@
from .wolfvote import WolfVote

@ -1,6 +1,12 @@
import logging
import random
from ..votegroup import VoteGroup
import discord
from werewolf.listener import wolflistener
from werewolf.votegroup import VoteGroup
log = logging.getLogger("red.fox_v3.werewolf.votegroup.wolfvote")
class WolfVote(VoteGroup):
@ -13,71 +19,29 @@ class WolfVote(VoteGroup):
kill_messages = [
"**{ID}** - {target} was mauled by wolves",
"**{ID}** - {target} was found torn to shreds"]
"**{ID}** - {target} was found torn to shreds",
]
def __init__(self, game, channel):
super().__init__(game, channel)
# self.game = game
# self.channel = channel
# self.players = []
# self.vote_results = {}
# self.properties = {} # Extra data for other options
self.killer = None # Added killer
self.action_list = [
(self._at_game_start, 1), # (Action, Priority)
(self._at_day_start, 0),
(self._at_voted, 0),
(self._at_kill, 1),
(self._at_hang, 1),
(self._at_day_end, 0),
(self._at_night_start, 2),
(self._at_night_end, 5), # Kill priority
(self._at_visit, 0)
]
# async def on_event(self, event, data):
# """
# See Game class for event guide
# """
#
# await action_list[event][0](data)
#
# async def _at_game_start(self, data=None):
# await self.channel.send(" ".join(player.mention for player in self.players))
#
# async def _at_day_start(self, data=None):
# pass
#
# async def _at_voted(self, data=None):
# pass
#
# async def _at_kill(self, data=None):
# if data["player"] in self.players:
# self.players.pop(data["player"])
#
# async def _at_hang(self, data=None):
# if data["player"] in self.players:
# self.players.pop(data["player"])
#
# async def _at_day_end(self, data=None):
# pass
async def _at_night_start(self, data=None):
if self.channel is None:
return
@wolflistener("at_night_start", priority=2)
async def _at_night_start(self):
await super()._at_night_start()
await self.game.generate_targets(self.channel)
mention_list = " ".join(player.mention for player in self.players)
if mention_list != "":
await self.channel.send(mention_list)
self.killer = random.choice(self.players)
await self.channel.send("{} has been selected as tonight's killer".format(self.killer.member.display_name))
await self.channel.send(
f"{self.killer.member.display_name} has been selected as tonight's killer"
)
async def _at_night_end(self, data=None):
@wolflistener("at_night_end", priority=5)
async def _at_night_end(self):
if self.channel is None:
return
@ -87,34 +51,23 @@ class WolfVote(VoteGroup):
if vote_list:
target_id = max(set(vote_list), key=vote_list.count)
print("Target id: {}\nKiller: {}".format(target_id, self.killer.member.display_name))
log.debug(f"Target id: {target_id}\nKiller: {self.killer.member.display_name}")
if target_id is not None and self.killer:
await self.game.kill(target_id, self.killer, random.choice(self.kill_messages))
await self.channel.send("**{} has left to complete the kill...**".format(self.killer.member.display_name))
await self.channel.send(
"*{} has left to complete the kill...*".format(self.killer.member.display_name)
)
else:
await self.channel.send("**No kill will be attempted tonight...**")
# async def _at_visit(self, data=None):
# pass
#
# async def register_players(self, *players):
# """
# Extend players by passed list
# """
# self.players.extend(players)
#
# async def remove_player(self, player):
# """
# Remove a player from player list
# """
# if player.id in self.players:
# self.players.remove(player)
await self.channel.send("*No kill will be attempted tonight...*")
async def vote(self, target, author, target_id):
"""
Receive vote from game
"""
self.vote_results[author.id] = target_id
await super().vote(target, author, target_id)
await self.channel.send("{} has voted to kill {}".format(author.mention, target.member.display_name))
await self.channel.send(
"{} has voted to kill {}".format(author.mention, target.member.display_name),
allowed_mentions=discord.AllowedMentions(everyone=False, users=[author]),
)

@ -1,17 +1,31 @@
import logging
from typing import List, Union
import discord
from redbot.core import Config, checks, commands
from redbot.core.bot import Red
from redbot.core.commands import Cog
from redbot.core.utils import AsyncIter
from redbot.core.utils.menus import DEFAULT_CONTROLS, menu
from .builder import (
from werewolf.builder import (
GameBuilder,
role_from_alignment,
role_from_category,
role_from_id,
role_from_name,
)
from .game import Game
from werewolf.game import Game
log = logging.getLogger("red.fox_v3.werewolf")
async def anyone_has_role(
member_list: List[discord.Member], role: discord.Role
) -> Union[None, discord.Member]:
return await AsyncIter(member_list).find(
lambda m: AsyncIter(m.roles).find(lambda r: r.id == role.id)
)
class Werewolf(Cog):
@ -43,7 +57,7 @@ class Werewolf(Cog):
return
def __unload(self):
print("Unload called")
log.debug("Unload called")
for game in self.games.values():
del game
@ -58,9 +72,9 @@ class Werewolf(Cog):
code = await gb.build_game(ctx)
if code != "":
await ctx.send("Your game code is **{}**".format(code))
await ctx.maybe_send_embed(f"Your game code is **{code}**")
else:
await ctx.send("No code generated")
await ctx.maybe_send_embed("No code generated")
@checks.guildowner()
@commands.group()
@ -77,31 +91,36 @@ class Werewolf(Cog):
"""
Lists current guild settings
"""
success, role, category, channel, log_channel = await self._get_settings(ctx)
if not success:
await ctx.send("Failed to get settings")
return None
valid, role, category, channel, log_channel = await self._get_settings(ctx)
# if not valid:
# await ctx.send("Failed to get settings")
# return None
embed = discord.Embed(title="Current Guild Settings")
embed = discord.Embed(
title="Current Guild Settings",
description=f"Valid: {valid}",
color=0x008000 if valid else 0xFF0000,
)
embed.add_field(name="Role", value=str(role))
embed.add_field(name="Category", value=str(category))
embed.add_field(name="Channel", value=str(channel))
embed.add_field(name="Log Channel", value=str(log_channel))
await ctx.send(embed=embed)
@commands.guild_only()
@wwset.command(name="role")
async def wwset_role(self, ctx: commands.Context, role: discord.Role = None):
"""
Assign the game role
Set the game role
This role should not be manually assigned
"""
if role is None:
await self.config.guild(ctx.guild).role_id.set(None)
await ctx.send("Cleared Game Role")
await ctx.maybe_send_embed("Cleared Game Role")
else:
await self.config.guild(ctx.guild).role_id.set(role.id)
await ctx.send("Game Role has been set to **{}**".format(role.name))
await ctx.maybe_send_embed("Game Role has been set to **{}**".format(role.name))
@commands.guild_only()
@wwset.command(name="category")
@ -111,14 +130,16 @@ class Werewolf(Cog):
"""
if category_id is None:
await self.config.guild(ctx.guild).category_id.set(None)
await ctx.send("Cleared Game Channel Category")
await ctx.maybe_send_embed("Cleared Game Channel Category")
else:
category = discord.utils.get(ctx.guild.categories, id=int(category_id))
if category is None:
await ctx.send("Category not found")
await ctx.maybe_send_embed("Category not found")
return
await self.config.guild(ctx.guild).category_id.set(category.id)
await ctx.send("Game Channel Category has been set to **{}**".format(category.name))
await ctx.maybe_send_embed(
"Game Channel Category has been set to **{}**".format(category.name)
)
@commands.guild_only()
@wwset.command(name="channel")
@ -128,10 +149,12 @@ class Werewolf(Cog):
"""
if channel is None:
await self.config.guild(ctx.guild).channel_id.set(None)
await ctx.send("Cleared Game Channel")
await ctx.maybe_send_embed("Cleared Game Channel")
else:
await self.config.guild(ctx.guild).channel_id.set(channel.id)
await ctx.send("Game Channel has been set to **{}**".format(channel.mention))
await ctx.maybe_send_embed(
"Game Channel has been set to **{}**".format(channel.mention)
)
@commands.guild_only()
@wwset.command(name="logchannel")
@ -141,10 +164,12 @@ class Werewolf(Cog):
"""
if channel is None:
await self.config.guild(ctx.guild).log_channel_id.set(None)
await ctx.send("Cleared Game Log Channel")
await ctx.maybe_send_embed("Cleared Game Log Channel")
else:
await self.config.guild(ctx.guild).log_channel_id.set(channel.id)
await ctx.send("Game Log Channel has been set to **{}**".format(channel.mention))
await ctx.maybe_send_embed(
"Game Log Channel has been set to **{}**".format(channel.mention)
)
@commands.group()
async def ww(self, ctx: commands.Context):
@ -162,9 +187,9 @@ class Werewolf(Cog):
"""
game = await self._get_game(ctx, game_code)
if not game:
await ctx.send("Failed to start a new game")
await ctx.maybe_send_embed("Failed to start a new game")
else:
await ctx.send("Game is ready to join! Use `[p]ww join`")
await ctx.maybe_send_embed("Game is ready to join! Use `[p]ww join`")
@commands.guild_only()
@ww.command(name="join")
@ -173,28 +198,49 @@ class Werewolf(Cog):
Joins a game of Werewolf
"""
game = await self._get_game(ctx)
game: Game = await self._get_game(ctx)
if not game:
await ctx.maybe_send_embed("Failed to join a game!")
return
await game.join(ctx, ctx.author)
await ctx.tick()
@commands.guild_only()
@commands.admin()
@ww.command(name="forcejoin")
async def ww_forcejoin(self, ctx: commands.Context, target: discord.Member):
"""
Force someone to join a game of Werewolf
"""
game: Game = await self._get_game(ctx)
if not game:
await ctx.send("No game to join!\nCreate a new one with `[p]ww new`")
await ctx.maybe_send_embed("Failed to join a game!")
return
await game.join(ctx.author, ctx.channel)
await game.join(ctx, target)
await ctx.tick()
@commands.guild_only()
@ww.command(name="code")
async def ww_code(self, ctx: commands.Context, code):
"""
Adjust game code
Adjusts the game code.
See `[p]buildgame` to generate a new code
"""
game = await self._get_game(ctx)
if not game:
await ctx.send("No game to join!\nCreate a new one with `[p]ww new`")
await ctx.maybe_send_embed("No game to join!\nCreate a new one with `[p]ww new`")
return
await game.set_code(ctx, code)
await ctx.tick()
@commands.guild_only()
@ww.command(name="quit")
@ -206,6 +252,7 @@ class Werewolf(Cog):
game = await self._get_game(ctx)
await game.quit(ctx.author, ctx.channel)
await ctx.tick()
@commands.guild_only()
@ww.command(name="start")
@ -215,10 +262,12 @@ class Werewolf(Cog):
"""
game = await self._get_game(ctx)
if not game:
await ctx.send("No game running, cannot start")
await ctx.maybe_send_embed("No game running, cannot start")
if not await game.setup(ctx):
pass # Do something?
pass # ToDo something?
await ctx.tick()
@commands.guild_only()
@ww.command(name="stop")
@ -226,17 +275,18 @@ class Werewolf(Cog):
"""
Stops the current game
"""
if ctx.guild is None:
# Private message, can't get guild
await ctx.send("Cannot start game from PM!")
return
# if ctx.guild is None:
# # Private message, can't get guild
# await ctx.send("Cannot stop game from PM!")
# return
if ctx.guild.id not in self.games or self.games[ctx.guild.id].game_over:
await ctx.send("No game to stop")
await ctx.maybe_send_embed("No game to stop")
return
game = await self._get_game(ctx)
game.game_over = True
await ctx.send("Game has been stopped")
game.current_action.cancel()
await ctx.maybe_send_embed("Game has been stopped")
@commands.guild_only()
@ww.command(name="vote")
@ -250,7 +300,7 @@ class Werewolf(Cog):
target_id = None
if target_id is None:
await ctx.send("`id` must be an integer")
await ctx.maybe_send_embed("`id` must be an integer")
return
# if ctx.guild is None:
@ -267,7 +317,7 @@ class Werewolf(Cog):
game = await self._get_game(ctx)
if game is None:
await ctx.send("No game running, cannot vote")
await ctx.maybe_send_embed("No game running, cannot vote")
return
# Game handles response now
@ -277,7 +327,7 @@ class Werewolf(Cog):
elif channel in (c["channel"] for c in game.p_channels.values()):
await game.vote(ctx.author, target_id, channel)
else:
await ctx.send("Nothing to vote for in this channel")
await ctx.maybe_send_embed("Nothing to vote for in this channel")
@ww.command(name="choose")
async def ww_choose(self, ctx: commands.Context, data):
@ -288,7 +338,7 @@ class Werewolf(Cog):
"""
if ctx.guild is not None:
await ctx.send("This action is only available in DM's")
await ctx.maybe_send_embed("This action is only available in DM's")
return
# DM nonsense, find their game
# If multiple games, panic
@ -296,7 +346,7 @@ class Werewolf(Cog):
if await game.get_player_by_member(ctx.author):
break # game = game
else:
await ctx.send("You're not part of any werewolf game")
await ctx.maybe_send_embed("You're not part of any werewolf game")
return
await game.choose(ctx, data)
@ -317,7 +367,7 @@ class Werewolf(Cog):
if from_name:
await menu(ctx, from_name, DEFAULT_CONTROLS)
else:
await ctx.send("No roles containing that name were found")
await ctx.maybe_send_embed("No roles containing that name were found")
@ww_search.command(name="alignment")
async def ww_search_alignment(self, ctx: commands.Context, alignment: int):
@ -327,7 +377,7 @@ class Werewolf(Cog):
if from_alignment:
await menu(ctx, from_alignment, DEFAULT_CONTROLS)
else:
await ctx.send("No roles with that alignment were found")
await ctx.maybe_send_embed("No roles with that alignment were found")
@ww_search.command(name="category")
async def ww_search_category(self, ctx: commands.Context, category: int):
@ -337,7 +387,7 @@ class Werewolf(Cog):
if pages:
await menu(ctx, pages, DEFAULT_CONTROLS)
else:
await ctx.send("No roles in that category were found")
await ctx.maybe_send_embed("No roles in that category were found")
@ww_search.command(name="index")
async def ww_search_index(self, ctx: commands.Context, idx: int):
@ -347,24 +397,32 @@ class Werewolf(Cog):
if idx_embed is not None:
await ctx.send(embed=idx_embed)
else:
await ctx.send("Role ID not found")
await ctx.maybe_send_embed("Role ID not found")
async def _get_game(self, ctx: commands.Context, game_code=None):
guild: discord.Guild = ctx.guild
async def _get_game(self, ctx: commands.Context, game_code=None) -> Union[Game, None]:
guild: discord.Guild = getattr(ctx, "guild", None)
if guild is None:
# Private message, can't get guild
await ctx.send("Cannot start game from PM!")
await ctx.maybe_send_embed("Cannot start game from DM!")
return None
if guild.id not in self.games or self.games[guild.id].game_over:
await ctx.send("Starting a new game...")
success, role, category, channel, log_channel = await self._get_settings(ctx)
await ctx.maybe_send_embed("Starting a new game...")
valid, role, category, channel, log_channel = await self._get_settings(ctx)
if not success:
await ctx.send("Cannot start a new game")
if not valid:
await ctx.maybe_send_embed("Cannot start a new game")
return None
self.games[guild.id] = Game(guild, role, category, channel, log_channel, game_code)
who_has_the_role = await anyone_has_role(guild.members, role)
if who_has_the_role:
await ctx.maybe_send_embed(
f"Cannot continue, {who_has_the_role.display_name} already has the game role."
)
return None
self.games[guild.id] = Game(
self.bot, guild, role, category, channel, log_channel, game_code
)
return self.games[guild.id]
@ -385,23 +443,30 @@ class Werewolf(Cog):
if role_id is not None:
role = discord.utils.get(guild.roles, id=role_id)
if role is None:
await ctx.send("Game Role is invalid")
return False, None, None, None, None
# if role is None:
# # await ctx.send("Game Role is invalid")
# return False, None, None, None, None
if category_id is not None:
category = discord.utils.get(guild.categories, id=category_id)
if category is None:
await ctx.send("Game Category is invalid")
return False, None, None, None, None
# if category is None:
# # await ctx.send("Game Category is invalid")
# return False, role, None, None, None
if channel_id is not None:
channel = discord.utils.get(guild.text_channels, id=channel_id)
if channel is None:
await ctx.send("Village Channel is invalid")
return False, None, None, None, None
# if channel is None:
# # await ctx.send("Village Channel is invalid")
# return False, role, category, None, None
if log_channel_id is not None:
log_channel = discord.utils.get(guild.text_channels, id=log_channel_id)
if log_channel is None:
await ctx.send("Log Channel is invalid")
return False, None, None, None, None
return True, role, category, channel, log_channel
# if log_channel is None:
# # await ctx.send("Log Channel is invalid")
# return False, None, None, None, None
return (
role is not None and category is not None and channel is not None,
role,
category,
channel,
log_channel,
)

Loading…
Cancel
Save