├── voicelogs ├── __init__.py ├── info.json └── voicelogs.py ├── quiz ├── __init__.py └── info.json ├── latex ├── __init__.py ├── info.json └── latex.py ├── pressf ├── __init__.py ├── info.json └── pressf.py ├── invites ├── __init__.py ├── info.json └── invites.py ├── tools ├── __init__.py ├── info.json └── converter.py ├── wolfram ├── __init__.py ├── info.json └── wolfram.py ├── youtube ├── __init__.py ├── info.json └── youtube.py ├── chatchart ├── __init__.py └── info.json ├── dadjokes ├── __init__.py ├── info.json └── dadjokes.py ├── embedpeek ├── __init__.py ├── info.json └── embedpeek.py ├── icyparser ├── __init__.py ├── info.json └── icyparser.py ├── pingtime ├── __init__.py ├── info.json └── pingtime.py ├── rndstatus ├── __init__.py ├── info.json └── rndstatus.py ├── snacktime ├── __init__.py ├── info.json └── phrases.py ├── urlfetch ├── __init__.py ├── info.json └── urlfetch.py ├── inspirobot ├── __init__.py ├── info.json └── inspirobot.py ├── luigipoker ├── __init__.py └── info.json ├── partycrash ├── __init__.py ├── info.json └── partycrash.py ├── ttt ├── __init__.py ├── info.json ├── ttt.py └── LICENSE ├── dictionary ├── __init__.py ├── info.json └── dictionary.py ├── trackdecoder ├── __init__.py ├── info.json └── trackdecoder.py ├── rss ├── __init__.py ├── tag_type.py ├── info.json ├── quiet_template.py ├── rss_feed.py └── color.py ├── otherbot ├── __init__.py └── info.json ├── seen ├── __init__.py ├── info.json └── seen.py ├── info.json ├── reminder ├── __init__.py ├── info.json └── reminder.py ├── away ├── __init__.py └── info.json ├── timezone ├── __init__.py ├── info.json └── timezone.py ├── trickortreat ├── __init__.py └── info.json ├── .gitignore ├── README.md ├── rss_guide.md └── LICENSE /voicelogs/__init__.py: -------------------------------------------------------------------------------- 1 | from .voicelogs import VoiceLogs 2 | 3 | 4 | async def setup(bot): 5 | n = VoiceLogs(bot) 6 | await bot.add_cog(n) 7 | -------------------------------------------------------------------------------- /quiz/__init__.py: -------------------------------------------------------------------------------- 1 | from .quiz import Quiz 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Quiz(bot)) 8 | -------------------------------------------------------------------------------- /latex/__init__.py: -------------------------------------------------------------------------------- 1 | from .latex import Latex 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Latex(bot)) 8 | -------------------------------------------------------------------------------- /pressf/__init__.py: -------------------------------------------------------------------------------- 1 | from .pressf import PressF 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(PressF(bot)) 8 | -------------------------------------------------------------------------------- /invites/__init__.py: -------------------------------------------------------------------------------- 1 | from .invites import Invites 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Invites(bot)) 8 | -------------------------------------------------------------------------------- /tools/__init__.py: -------------------------------------------------------------------------------- 1 | from .tools import Tools 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Tools(bot)) 8 | -------------------------------------------------------------------------------- /wolfram/__init__.py: -------------------------------------------------------------------------------- 1 | from .wolfram import Wolfram 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Wolfram(bot)) 8 | -------------------------------------------------------------------------------- /youtube/__init__.py: -------------------------------------------------------------------------------- 1 | from .youtube import YouTube 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(YouTube(bot)) 8 | -------------------------------------------------------------------------------- /chatchart/__init__.py: -------------------------------------------------------------------------------- 1 | from .chatchart import Chatchart 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Chatchart(bot)) 8 | -------------------------------------------------------------------------------- /dadjokes/__init__.py: -------------------------------------------------------------------------------- 1 | from .dadjokes import DadJokes 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(DadJokes(bot)) 8 | -------------------------------------------------------------------------------- /embedpeek/__init__.py: -------------------------------------------------------------------------------- 1 | from .embedpeek import EmbedPeek 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(EmbedPeek(bot)) 8 | -------------------------------------------------------------------------------- /icyparser/__init__.py: -------------------------------------------------------------------------------- 1 | from .icyparser import IcyParser 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(IcyParser(bot)) 8 | -------------------------------------------------------------------------------- /pingtime/__init__.py: -------------------------------------------------------------------------------- 1 | from .pingtime import Pingtime 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Pingtime(bot)) 8 | -------------------------------------------------------------------------------- /rndstatus/__init__.py: -------------------------------------------------------------------------------- 1 | from .rndstatus import RndStatus 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(RndStatus(bot)) 8 | -------------------------------------------------------------------------------- /snacktime/__init__.py: -------------------------------------------------------------------------------- 1 | from .snacktime import Snacktime 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Snacktime(bot)) 8 | -------------------------------------------------------------------------------- /urlfetch/__init__.py: -------------------------------------------------------------------------------- 1 | from .urlfetch import UrlFetch 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(UrlFetch(bot)) 8 | -------------------------------------------------------------------------------- /inspirobot/__init__.py: -------------------------------------------------------------------------------- 1 | from .inspirobot import Inspirobot 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Inspirobot(bot)) 8 | -------------------------------------------------------------------------------- /luigipoker/__init__.py: -------------------------------------------------------------------------------- 1 | from .luigipoker import LuigiPoker 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(LuigiPoker(bot)) 8 | -------------------------------------------------------------------------------- /partycrash/__init__.py: -------------------------------------------------------------------------------- 1 | from .partycrash import PartyCrash 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(PartyCrash(bot)) 8 | -------------------------------------------------------------------------------- /ttt/__init__.py: -------------------------------------------------------------------------------- 1 | from .ttt import TTT 2 | 3 | __red_end_user_data_statement__ = "This cog does store temporarily (in memory) data about users, which is cleared after the game is done." 4 | 5 | async def setup(bot): 6 | await bot.add_cog(TTT(bot)) 7 | -------------------------------------------------------------------------------- /dictionary/__init__.py: -------------------------------------------------------------------------------- 1 | from .dictionary import Dictionary 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(Dictionary(bot)) 8 | -------------------------------------------------------------------------------- /trackdecoder/__init__.py: -------------------------------------------------------------------------------- 1 | from .trackdecoder import TrackDecoder 2 | 3 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 4 | 5 | 6 | async def setup(bot): 7 | await bot.add_cog(TrackDecoder(bot)) 8 | -------------------------------------------------------------------------------- /rss/__init__.py: -------------------------------------------------------------------------------- 1 | from redbot.core import commands 2 | 3 | from .rss import RSS 4 | 5 | __red_end_user_data_statement__ = "This cog does not persistently store data or metadata about users." 6 | 7 | 8 | async def setup(bot: commands.Bot): 9 | n = RSS(bot) 10 | await bot.add_cog(n) 11 | n.initialize() 12 | -------------------------------------------------------------------------------- /rss/tag_type.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | INTERNAL_TAGS = ["is_special", "template_tags", "embed", "embed_color", "embed_image", "embed_thumbnail"] 5 | 6 | VALID_IMAGES = ["png", "webp", "gif", "jpeg", "jpg"] 7 | 8 | 9 | class TagType(Enum): 10 | PLAINTEXT = 1 11 | HTML = 2 12 | DICT = 3 13 | LIST = 4 14 | -------------------------------------------------------------------------------- /otherbot/__init__.py: -------------------------------------------------------------------------------- 1 | from .otherbot import Otherbot 2 | 3 | __red_end_user_data_statement__ = ( 4 | "This cog does not persistently store end user data. This cog does store discord IDs as needed for operation." 5 | ) 6 | 7 | 8 | async def setup(bot): 9 | n = Otherbot(bot) 10 | await n.generate_cache() 11 | await bot.add_cog(n) 12 | -------------------------------------------------------------------------------- /urlfetch/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 4 | "install_msg": "Thanks for installing.", 5 | "min_bot_version": "3.5.0", 6 | "short": "Fetch text from a URL.", 7 | "description": "Fetch text from a URL.", 8 | "tags": ["api"] 9 | } 10 | -------------------------------------------------------------------------------- /seen/__init__.py: -------------------------------------------------------------------------------- 1 | from .seen import Seen 2 | 3 | __red_end_user_data_statement__ = ( 4 | "This cog does not persistently store end user data. " 5 | "This cog does store discord IDs and last seen timestamp as needed for operation. " 6 | ) 7 | 8 | 9 | async def setup(bot): 10 | cog = Seen(bot) 11 | await cog.initialize() 12 | await bot.add_cog(cog) 13 | -------------------------------------------------------------------------------- /info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : ["aikaterna (aikaterna#1393)"], 3 | "install_msg" : "Thanks for installing. Please submit issue reports on my repo if something's broken. You can find me in the Red servers or at the invite on my repo.", 4 | "name" : "aikaterna-cogs", 5 | "short" : "Utility and fun cogs", 6 | "description" : "Cogs requested by others, personal cogs, or orphaned cogs." 7 | } 8 | -------------------------------------------------------------------------------- /quiz/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["Keane", "aikaterna"], 3 | "description": "Play a kahoot-like trivia game.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Play a kahoot-like trivia game.", 8 | "tags": ["trivia", "quiz"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /pingtime/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "It's ping... with latency. Shows all shards.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Ping pong.", 8 | "tags": ["ping", "pingtime", "latency"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /embedpeek/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Dev tool to display the content of an embed.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Dev tool to display the content of an embed.", 8 | "tags": ["embed"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /pressf/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Pay respects to a thing or user by pressing f.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Press f to pay respects.", 8 | "tags": ["f", "pressf", "respects"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /luigipoker/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "themario30"], 3 | "description": "Play the Luigi Poker minigame from New Super Mario Brothers.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "short": "A Luigi poker minigame.", 8 | "tags": ["poker", "game"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /rndstatus/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Twentysix"], 3 | "description": "Random statuses with an optional bot stats mode. Ported from Twentysix's v2 cog.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Random bot statuses", 8 | "tags": ["status"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /rss/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Read RSS feeds.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "permissions": ["embed_links"], 8 | "requirements": ["bs4", "feedparser>=6.0.0", "webcolors==1.3", "filetype"], 9 | "short": "Read RSS feeds.", 10 | "tags": ["rss"] 11 | } 12 | -------------------------------------------------------------------------------- /dadjokes/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["UltimatePancake"], 3 | "description": "Gets a random dad joke from icanhazdadjoke.com", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Gets a random dad joke from icanhazdadjoke.com. Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Random dad jokes", 8 | "tags": ["jokes", "dad", "dadjokes"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /latex/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Stevy"], 3 | "description": "Generates an image for a LaTeX expression.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Generates an image for a LaTeX expression.", 8 | "tags": ["latex"], 9 | "requirements": ["pillow"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /ttt/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "HizkiFW"], 3 | "description": "Tic Tac Toe", 4 | "end_user_data_statement": "This cog does store temporarily (in memory) data about users, which is cleared after the game is done.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "permissions": ["add_reactions"], 8 | "short": "Tic Tac Toe", 9 | "tags": ["game", "games", "tic tac toe", "ttt"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /icyparser/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Audio addon cog for dislaying icecast/shoutcast info.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Audio addon cog for dislaying icecast/shoutcast info", 8 | "tags": ["audio", "icecast", "shoutcast"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /seen/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Check when the user was last active on a server. Originally made by Paddo.", 4 | "end_user_data_statement": "This cog does not persistently store end user data. This cog does store discord IDs and last seen timestamp as needed for operation.", 5 | "min_bot_version": "3.5.0", 6 | "short": "Check when the user was last active on a server.", 7 | "tags": ["seen", "activity"], 8 | "type": "COG" 9 | } 10 | -------------------------------------------------------------------------------- /trackdecoder/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "devoxin#0001"], 3 | "description": "Utility cog for decoding b64 encoded Lavalink Track strings.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Utility cog for decoding b64 encoded Lavalink Track strings.", 8 | "tags": ["lavalink"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /inspirobot/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Fetch a random 'inspiring' message from http://inspirobot.me", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "permissions" : ["embed_links"], 8 | "short": "Fetch 'inspiring' messages.", 9 | "tags": ["inspire", "inspirobot"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /wolfram/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Query Wolfram|Alpha for answers. Requires a free API key. Originally by Paddo.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Query Wolfram|Alpha for answers.", 8 | "tags": ["wolfram"], 9 | "requirements": ["pillow"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /invites/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Invite count display and leaderboard.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing. Use `[p]invites` to get started.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Invite count display and leaderboard.", 8 | "tags": ["invites"], 9 | "permissions": ["administrator", "embed_links"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /youtube/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Paddo"], 3 | "description": "Search youtube for videos, originally by Paddo. This version also includes a ytsearch command to look through multiple results.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Search youtube for videos.", 8 | "tags": ["youtube"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /reminder/__init__.py: -------------------------------------------------------------------------------- 1 | from .reminder import Reminder 2 | 3 | __red_end_user_data_statement__ = ( 4 | "This cog stores data provided by users for the express purpose of redisplaying. " 5 | "It does not store user data which was not provided through a command. " 6 | "Users may remove their own content without making a data removal request. " 7 | "This cog does not support data requests, but will respect deletion requests." 8 | ) 9 | 10 | 11 | async def setup(bot): 12 | await bot.add_cog(Reminder(bot)) 13 | -------------------------------------------------------------------------------- /otherbot/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Predä"], 3 | "description": "Alerts a role when bot(s) go offline.", 4 | "end_user_data_statement": "This cog does not persistently store end user data. This cog does store Discord IDs as needed for operation.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "permissions" : ["manage_roles"], 8 | "short": "Alerts a role when bot(s) go offline.", 9 | "tags": ["bot", "offline", "uptime"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /tools/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "sitryk"], 3 | "description": "Mod and admin tools.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing. Use `[p]help` Tools to get started.", 6 | "min_bot_version": "3.5.0", 7 | "permissions" : ["ban_members", "manage_channels"], 8 | "requirements": ["tabulate", "unidecode"], 9 | "short": "Mod and admin tools.", 10 | "tags": ["tools"], 11 | "type": "COG" 12 | } 13 | -------------------------------------------------------------------------------- /away/__init__.py: -------------------------------------------------------------------------------- 1 | from .away import Away 2 | 3 | __red_end_user_data_statement__ = ( 4 | "This cog stores data provided by users " 5 | "for the express purpose of redisplaying. " 6 | "It does not store user data which was not " 7 | "provided through a command. " 8 | "Users may remove their own content " 9 | "without making a data removal request. " 10 | "This cog does not support data requests, " 11 | "but will respect deletion requests." 12 | ) 13 | 14 | 15 | async def setup(bot): 16 | await bot.add_cog(Away(bot)) 17 | -------------------------------------------------------------------------------- /timezone/__init__.py: -------------------------------------------------------------------------------- 1 | from .timezone import Timezone 2 | 3 | __red_end_user_data_statement__ = ( 4 | "This cog stores data provided by users " 5 | "for the express purpose of redisplaying. " 6 | "It does not store user data which was not " 7 | "provided through a command. " 8 | "Users may remove their own content " 9 | "without making a data removal request. " 10 | "This cog does not support data requests, " 11 | "but will respect deletion requests." 12 | ) 13 | 14 | 15 | async def setup(bot): 16 | await bot.add_cog(Timezone(bot)) 17 | -------------------------------------------------------------------------------- /snacktime/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : ["irdumb", "aikaterna"], 3 | "description" : "snackburr will come around every-so-often if you've asked him to.\nI hear snackburr likes to come around more often when people are partyin.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg" : "A snack delivery bear has arrived ʕ•ᴥ• ʔ", 6 | "min_bot_version": "3.5.0", 7 | "short" : "ʕ •ᴥ•ʔ < It's snacktime, who wants snacks?", 8 | "tags" : ["snack", "snacktime", "snackburr", "party", "party time"] 9 | } 10 | -------------------------------------------------------------------------------- /chatchart/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Redjumpman"], 3 | "description": "Generate a pie chart from the last 5000 messages in a channel to see who's been talking the most.", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "Thanks for installing, have fun.", 6 | "min_bot_version": "3.5.0", 7 | "requirements": ["matplotlib"], 8 | "short": "Generate a pie chart from the last 5000 messages", 9 | "tags": ["messages", "chart", "count", "activity"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /partycrash/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Will (tekulvw)"], 3 | "description": "Posts invites to servers, if the bot is allowed to view them. Does not generate new invites.", 4 | "install_msg": "Note that having an invite available for a server does not automatically grant you permissions to join said server. Thanks for installing, have fun.", 5 | "permissions" : ["manage_guild"], 6 | "short": "Post server invites.", 7 | "tags": ["invite"], 8 | "type": "COG", 9 | "end_user_data_statement": "This cog does not persistently store data or metadata about users." 10 | } 11 | -------------------------------------------------------------------------------- /dictionary/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["UltimatePancake", "aikaterna"], 3 | "description": "Gets definitions, antonyms, or synonyms for given words", 4 | "end_user_data_statement": "This cog does not persistently store data or metadata about users.", 5 | "install_msg": "After loading the cog with `[p]load dictionary`, use [p]help Dictionary to view commands.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Gets definitions, antonyms, or synonyms for given words", 8 | "tags": ["dictionary", "synonym", "antonym"], 9 | "requirements": ["beautifulsoup4"], 10 | "type": "COG" 11 | } 12 | -------------------------------------------------------------------------------- /away/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "Axas", "TrustyJAID"], 3 | "description": "Set and unset a user as being away. Originally by Paddo.", 4 | "min_bot_version": "3.5.0", 5 | "short": "Away message toggle for users", 6 | "tags": ["away", "afk"], 7 | "type": "COG", 8 | "end_user_data_statement": "This cog stores data provided by users for the express purpose of redisplaying. It does not store user data which was not provided through a command. Users may remove their own content without making a data removal request. This cog does not support data requests, but will respect deletion requests." 9 | } 10 | -------------------------------------------------------------------------------- /trickortreat/__init__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from .trickortreat import TrickOrTreat 4 | 5 | __red_end_user_data_statement__ = ( 6 | "This cog does not persistently store end user data. " 7 | "This cog does store discord IDs as needed for operation. " 8 | "This cog does store user stats for the cog such as their score. " 9 | "Users may remove their own content without making a data removal request." 10 | "This cog does not support data requests, " 11 | "but will respect deletion requests." 12 | ) 13 | 14 | 15 | async def setup(bot): 16 | cog = TrickOrTreat(bot) 17 | await bot.add_cog(cog) 18 | asyncio.create_task(cog.cleanup()) 19 | -------------------------------------------------------------------------------- /voicelogs/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["ZeLarpMaster#0818", "aikaterna"], 3 | "description": "Allows moderators to access users' and channels' voice activity with timestamps and duration.", 4 | "end_user_data_statement": "This cog stores discord IDs as needed for operation. This cog stores voice channel connection time per-user when toggled on in a server. This cog does not support data requests, but will respect deletion requests.", 5 | "install_msg": "Thanks for installing. Use `[p]voicelog` to get started once the cog is loaded.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Voice activity logs", 8 | "tags": ["voice", "activity", "logs"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /pingtime/pingtime.py: -------------------------------------------------------------------------------- 1 | from redbot.core import commands 2 | 3 | 4 | BaseCog = getattr(commands, "Cog", object) 5 | 6 | 7 | class Pingtime(BaseCog): 8 | """🏓""" 9 | 10 | async def red_delete_data_for_user(self, **kwargs): 11 | """ Nothing to delete """ 12 | return 13 | 14 | def __init__(self, bot): 15 | self.bot = bot 16 | 17 | @commands.command() 18 | async def pingtime(self, ctx): 19 | """Ping pong.""" 20 | latencies = self.bot.latencies 21 | msg = "Pong!\n" 22 | for shard, pingt in latencies: 23 | msg += "Shard {}/{}: {}ms\n".format(shard + 1, len(latencies), round(pingt * 1000)) 24 | await ctx.send(msg) 25 | -------------------------------------------------------------------------------- /timezone/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna", "fishyfing"], 3 | "description": "Check timezones, user times, or times in specific places. Originally made by Fishyfing.", 4 | "end_user_data_statement": "This cog stores data provided by users for the express purpose of redisplaying. It does not store user data which was not provided through a command. Users may remove their own content without making a data removal request. This cog does not support data requests, but will respect deletion requests.", 5 | "min_bot_version": "3.5.0", 6 | "requirements": ["fuzzywuzzy", "pytz"], 7 | "short": "Check times for users and places.", 8 | "tags": ["time", "timezone"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /reminder/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author" : ["ZeLarpMaster#0818", "aikaterna"], 3 | "description" : "Lets users tell the bot to remind them about anything they want. Originally by ZeLarpMaster.", 4 | "install_msg" : "Enjoy reminding yourself of whatever you wanna remind yourself of! Use `[p]help Reminder` to get started.\nIf you have had ZeLarpMaster's version of Reminder installed in the past, this version of it uses the same data location for ease of switching to a supported version of the cog, as his repo has been archived.", 5 | "min_bot_version": "3.5.0", 6 | "permissions" : ["embed_links"], 7 | "short" : "Allows users to remind themselves about anything they want.", 8 | "tags" : ["remind", "reminder", "remindme"] 9 | } 10 | -------------------------------------------------------------------------------- /trickortreat/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": ["aikaterna"], 3 | "description": "Trick or treating for your server.", 4 | "end_user_data_statement": "This cog does not persistently store end user data. This cog does store discord IDs as needed for operation. This cog does store user stats for the cog such as their score. Users may remove their own content without making a data removal request. This cog does not support data requests, but will respect deletion requests.", 5 | "install_msg": "Thanks for installing. Use `[p]help TrickOrTreat` to get started, specifically by toggling it on in your server and then setting active trick or treating channels.", 6 | "min_bot_version": "3.5.0", 7 | "short": "Trick or treat.", 8 | "tags": ["trick or treat", "candy", "pick", "halloween"], 9 | "type": "COG" 10 | } 11 | -------------------------------------------------------------------------------- /dadjokes/dadjokes.py: -------------------------------------------------------------------------------- 1 | from redbot.core import commands 2 | import aiohttp 3 | 4 | 5 | class DadJokes(commands.Cog): 6 | """Random dad jokes from icanhazdadjoke.com""" 7 | 8 | async def red_delete_data_for_user(self, **kwargs): 9 | """ Nothing to delete """ 10 | return 11 | 12 | def __init__(self, bot): 13 | self.bot = bot 14 | 15 | @commands.command() 16 | async def dadjoke(self, ctx): 17 | """Gets a random dad joke.""" 18 | try: 19 | async with aiohttp.request("GET", "https://icanhazdadjoke.com/", headers={"Accept": "text/plain"}) as r: 20 | if r.status != 200: 21 | return await ctx.send("Oops! Cannot get a dad joke...") 22 | result = await r.text(encoding="UTF-8") 23 | except aiohttp.ClientConnectionError: 24 | return await ctx.send("Oops! Cannot get a dad joke...") 25 | 26 | await ctx.send(f"`{result}`") 27 | -------------------------------------------------------------------------------- /inspirobot/inspirobot.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | from redbot.core import commands 4 | 5 | 6 | class Inspirobot(commands.Cog): 7 | """Posts images generated by https://inspirobot.me""" 8 | 9 | async def red_delete_data_for_user(self, **kwargs): 10 | """ Nothing to delete """ 11 | return 12 | 13 | def __init__(self, bot): 14 | self.bot = bot 15 | self.session = aiohttp.ClientSession() 16 | 17 | @commands.command() 18 | async def inspireme(self, ctx): 19 | """Fetch a random "inspirational message" from the bot.""" 20 | try: 21 | async with self.session.request("GET", "http://inspirobot.me/api?generate=true") as page: 22 | pic = await page.text(encoding="utf-8") 23 | em = discord.Embed() 24 | em.set_image(url=pic) 25 | await ctx.send(embed=em) 26 | except Exception as e: 27 | await ctx.send(f"Oops, there was a problem: {e}") 28 | 29 | def cog_unload(self): 30 | self.bot.loop.create_task(self.session.close()) 31 | -------------------------------------------------------------------------------- /rss/quiet_template.py: -------------------------------------------------------------------------------- 1 | from collections import ChainMap 2 | from string import Template 3 | 4 | 5 | class QuietTemplate(Template): 6 | """ 7 | A subclass of string.Template that is less verbose on a missing key 8 | https://github.com/python/cpython/blob/919f0bc8c904d3aa13eedb2dd1fe9c6b0555a591/Lib/string.py#L123 9 | """ 10 | 11 | def quiet_safe_substitute(self, mapping={}, /, **kws): 12 | if mapping is {}: 13 | mapping = kws 14 | elif kws: 15 | mapping = ChainMap(kws, mapping) 16 | # Helper function for .sub() 17 | def convert(mo): 18 | named = mo.group('named') or mo.group('braced') 19 | if named is not None: 20 | try: 21 | return str(mapping[named]) 22 | except KeyError: 23 | # return None instead of the tag name so that 24 | # invalid tags are not present in the feed output 25 | return None 26 | if mo.group('escaped') is not None: 27 | return self.delimiter 28 | if mo.group('invalid') is not None: 29 | return mo.group() 30 | raise ValueError('Unrecognized named group in pattern', self.pattern) 31 | return self.pattern.sub(convert, self.template) 32 | -------------------------------------------------------------------------------- /tools/converter.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from fuzzywuzzy import fuzz, process 3 | from typing import List 4 | from unidecode import unidecode 5 | 6 | from discord.ext.commands.converter import IDConverter, _get_from_guilds 7 | from discord.ext.commands.errors import BadArgument 8 | 9 | from redbot.core import commands 10 | 11 | 12 | class FuzzyMember(IDConverter): 13 | """ 14 | Original class written by TrustyJaid#0001 15 | https://github.com/TrustyJAID/Trusty-cogs/blob/c739903aa2c8111c58b3d5e695a1221cbe1f57d9/serverstats/converters.py 16 | 17 | This will accept partial names and perform a fuzzy search for 18 | members within the guild and return a list of member objects. 19 | 20 | Guidance code on how to do this from: 21 | https://github.com/Rapptz/discord.py/blob/rewrite/discord/ext/commands/converter.py#L85 22 | https://github.com/Cog-Creators/Red-DiscordBot/blob/V3/develop/redbot/cogs/mod/mod.py#L24 23 | """ 24 | 25 | async def convert(self, ctx: commands.Context, argument: str) -> List[discord.Member]: 26 | bot = ctx.bot 27 | guild = ctx.guild 28 | result = [] 29 | 30 | members = {m: unidecode(m.name) for m in guild.members} 31 | fuzzy_results = process.extract(argument, members, limit=1000, scorer=fuzz.partial_ratio) 32 | matching_names = [m[2] for m in fuzzy_results if m[1] > 90] 33 | for x in matching_names: 34 | result.append(x) 35 | 36 | nick_members = {m: unidecode(m.nick) for m in guild.members if m.nick and m not in matching_names} 37 | fuzzy_results2 = process.extract(argument, nick_members, limit=50, scorer=fuzz.partial_ratio) 38 | matching_nicks = [m[2] for m in fuzzy_results2 if m[1] > 90] 39 | for x in matching_nicks: 40 | result.append(x) 41 | 42 | if not result or result == [None]: 43 | raise BadArgument('Member "{}" not found'.format(argument)) 44 | 45 | return result 46 | -------------------------------------------------------------------------------- /youtube/youtube.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import re 3 | from redbot.core import commands 4 | from redbot.core.utils.menus import menu, DEFAULT_CONTROLS 5 | 6 | 7 | class YouTube(commands.Cog): 8 | """Search YouTube for videos.""" 9 | 10 | async def red_delete_data_for_user(self, **kwargs): 11 | """ Nothing to delete """ 12 | return 13 | 14 | def __init__(self, bot): 15 | self.bot = bot 16 | self.session = aiohttp.ClientSession() 17 | 18 | async def _youtube_results(self, query: str): 19 | try: 20 | headers = {"user-agent": "Red-cog/3.0"} 21 | async with self.session.get( 22 | "https://www.youtube.com/results", params={"search_query": query}, headers=headers 23 | ) as r: 24 | result = await r.text() 25 | yt_find = re.findall(r"{\"videoId\":\"(.{11})", result) 26 | url_list = [] 27 | for track in yt_find: 28 | url = f"https://www.youtube.com/watch?v={track}" 29 | if url not in url_list: 30 | url_list.append(url) 31 | 32 | except Exception as e: 33 | url_list = [f"Something went terribly wrong! [{e}]"] 34 | 35 | return url_list 36 | 37 | @commands.command() 38 | async def youtube(self, ctx, *, query: str): 39 | """Search on Youtube.""" 40 | result = await self._youtube_results(query) 41 | if result: 42 | await ctx.send(result[0]) 43 | else: 44 | await ctx.send("Nothing found. Try again later.") 45 | 46 | @commands.command() 47 | async def ytsearch(self, ctx, *, query: str): 48 | """Search on Youtube, multiple results.""" 49 | result = await self._youtube_results(query) 50 | if result: 51 | await menu(ctx, result, DEFAULT_CONTROLS) 52 | else: 53 | await ctx.send("Nothing found. Try again later.") 54 | 55 | def cog_unload(self): 56 | self.bot.loop.create_task(self.session.close()) 57 | -------------------------------------------------------------------------------- /latex/latex.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | import io 4 | import logging 5 | import re 6 | from PIL import Image, ImageOps 7 | import urllib.parse as parse 8 | from redbot.core import commands 9 | 10 | 11 | log = logging.getLogger("red.aikaterna.latex") 12 | 13 | START_CODE_BLOCK_RE = re.compile(r"^((```(la)?tex)(?=\s)|(```))") 14 | 15 | 16 | class Latex(commands.Cog): 17 | """LaTeX expressions via an image.""" 18 | 19 | async def red_delete_data_for_user(self, **kwargs): 20 | """Nothing to delete.""" 21 | return 22 | 23 | def __init__(self, bot): 24 | self.bot = bot 25 | self.session = aiohttp.ClientSession() 26 | 27 | @staticmethod 28 | def cleanup_code_block(content): 29 | # remove ```latex\n```/```tex\n```/`````` 30 | if content.startswith("```") and content.endswith("```"): 31 | return START_CODE_BLOCK_RE.sub("", content)[:-3] 32 | 33 | # remove `foo` 34 | return content.strip("` \n") 35 | 36 | @commands.guild_only() 37 | @commands.command() 38 | async def latex(self, ctx, *, equation): 39 | """Takes a LaTeX expression and makes it pretty.""" 40 | base_url = "https://latex.codecogs.com/gif.latex?%5Cbg_white%20%5CLARGE%20" 41 | equation = self.cleanup_code_block(equation) 42 | equation = parse.quote(equation) 43 | url = f"{base_url}{equation}" 44 | 45 | try: 46 | async with self.session.get(url) as r: 47 | image = await r.read() 48 | image = Image.open(io.BytesIO(image)).convert("RGBA") 49 | except Exception as exc: 50 | log.exception("Something went wrong while trying to read the image:\n ", exc_info=exc) 51 | image = None 52 | 53 | if not image: 54 | return await ctx.send("I can't get the image from the website, check your console for more information.") 55 | 56 | image = ImageOps.expand(image, border=10, fill="white") 57 | image_file_object = io.BytesIO() 58 | image.save(image_file_object, format="png") 59 | image_file_object.seek(0) 60 | image_final = discord.File(fp=image_file_object, filename="latex.png") 61 | await ctx.send(file=image_final) 62 | 63 | def cog_unload(self): 64 | self.bot.loop.create_task(self.session.close()) 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | .hypothesis/ 50 | .pytest_cache/ 51 | 52 | # Translations 53 | *.mo 54 | *.pot 55 | 56 | # Django stuff: 57 | *.log 58 | local_settings.py 59 | db.sqlite3 60 | 61 | # Flask stuff: 62 | instance/ 63 | .webassets-cache 64 | 65 | # Scrapy stuff: 66 | .scrapy 67 | 68 | # Sphinx documentation 69 | docs/_build/ 70 | 71 | # PyBuilder 72 | target/ 73 | 74 | # Jupyter Notebook 75 | .ipynb_checkpoints 76 | 77 | # IPython 78 | profile_default/ 79 | ipython_config.py 80 | 81 | # pipenv 82 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 83 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 84 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 85 | # install all needed dependencies. 86 | #Pipfile.lock 87 | 88 | # celery beat schedule file 89 | celerybeat-schedule 90 | 91 | # SageMath parsed files 92 | *.sage.py 93 | 94 | # Environments 95 | .env 96 | .venv 97 | env/ 98 | venv/ 99 | ENV/ 100 | env.bak/ 101 | venv.bak/ 102 | 103 | # Spyder project settings 104 | .spyderproject 105 | .spyproject 106 | 107 | # Rope project settings 108 | .ropeproject 109 | 110 | # mkdocs documentation 111 | /site 112 | 113 | # mypy 114 | .mypy_cache/ 115 | .dmypy.json 116 | dmypy.json 117 | 118 | # Pyre type checker 119 | .pyre/ 120 | 121 | # Sublime project files 122 | *.sublime-project 123 | *.sublime-workspace 124 | 125 | # PyCharm project files 126 | .idea/ 127 | 128 | # VS Code project files 129 | .vscode/ 130 | -------------------------------------------------------------------------------- /rss/rss_feed.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | 3 | 4 | class RssFeed(): 5 | """RSS feed object""" 6 | 7 | def __init__(self, **kwargs): 8 | super().__init__() 9 | self.name: str = kwargs.get("name", None) 10 | self.last_title: str = kwargs.get("last_title", None) 11 | self.last_link: str = kwargs.get("last_link", None) 12 | self.last_time: str = kwargs.get("last_time", None) 13 | self.template: str = kwargs.get("template", None) 14 | self.url: str = kwargs.get("url", None) 15 | self.template_tags: List[str] = kwargs.get("template_tags", []) 16 | self.is_special: List[str] = kwargs.get("is_special", []) 17 | self.embed: bool = kwargs.get("embed", True) 18 | self.embed_color: str = kwargs.get("embed_color", None) 19 | self.embed_image: str = kwargs.get("embed_image", None) 20 | self.embed_thumbnail: str = kwargs.get("embed_thumbnail", None) 21 | 22 | def to_json(self) -> dict: 23 | return { 24 | "name": self.name, 25 | "last_title": self.last_title, 26 | "last_link": self.last_link, 27 | "last_time": self.last_time, 28 | "template": self.template, 29 | "url": self.url, 30 | "template_tags": self.template_tags, 31 | "is_special": self.is_special, 32 | "embed": self.embed, 33 | "embed_color": self.embed_color, 34 | "embed_image": self.embed_image, 35 | "embed_thumbnail": self.embed_thumbnail, 36 | } 37 | 38 | @classmethod 39 | def from_json(cls, data: dict): 40 | return cls( 41 | name=data["name"] if data["name"] else None, 42 | last_title=data["last_title"] if data["last_title"] else None, 43 | last_link=data["last_link"] if data["last_link"] else None, 44 | last_time=data["last_time"] if data["last_time"] else None, 45 | template=data["template"] if data["template"] else None, 46 | url=data["url"] if data["url"] else None, 47 | template_tags=data["template_tags"] if data["template_tags"] else [], 48 | is_special=data["is_special"] if data["is_special"] else [], 49 | embed=data["embed"] if data["embed"] else True, 50 | embed_color=data["embed_color"] if data["embed_color"] else None, 51 | embed_image=data["embed_image"] if data["embed_image"] else None, 52 | embed_thumbnail=data["embed_thumbnail"] if data["embed_thumbnail"] else None, 53 | ) 54 | -------------------------------------------------------------------------------- /pressf/pressf.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import discord 3 | from redbot.core import commands 4 | from redbot.core.utils.common_filters import filter_mass_mentions 5 | 6 | 7 | class PressF(commands.Cog): 8 | """Pay some respects.""" 9 | 10 | async def red_delete_data_for_user(self, **kwargs): 11 | """ Nothing to delete """ 12 | return 13 | 14 | def __init__(self, bot): 15 | self.bot = bot 16 | self.channels = {} 17 | 18 | @commands.command() 19 | @commands.bot_has_permissions(add_reactions=True) 20 | async def pressf(self, ctx, *, user: discord.User = None): 21 | """Pay respects by pressing F""" 22 | if str(ctx.channel.id) in self.channels: 23 | return await ctx.send( 24 | "Oops! I'm still paying respects in this channel, you'll have to wait until I'm done." 25 | ) 26 | 27 | if user: 28 | answer = user.display_name 29 | else: 30 | await ctx.send("What do you want to pay respects to?") 31 | 32 | def check(m): 33 | return m.author == ctx.author and m.channel == ctx.channel 34 | 35 | try: 36 | pressf = await ctx.bot.wait_for("message", timeout=120.0, check=check) 37 | except asyncio.TimeoutError: 38 | return await ctx.send("You took too long to reply.") 39 | 40 | answer = pressf.content[:1900] 41 | 42 | message = await ctx.send( 43 | f"Everyone, let's pay respects to **{filter_mass_mentions(answer)}**! Press the f reaction on this message to pay respects." 44 | ) 45 | await message.add_reaction("\U0001f1eb") 46 | self.channels[str(ctx.channel.id)] = {"msg_id": message.id, "reacted": []} 47 | await asyncio.sleep(120) 48 | try: 49 | await message.delete() 50 | except (discord.errors.NotFound, discord.errors.Forbidden): 51 | pass 52 | amount = len(self.channels[str(ctx.channel.id)]["reacted"]) 53 | word = "person has" if amount == 1 else "people have" 54 | await ctx.send(f"**{amount}** {word} paid respects to **{filter_mass_mentions(answer)}**.") 55 | del self.channels[str(ctx.channel.id)] 56 | 57 | @commands.Cog.listener() 58 | async def on_reaction_add(self, reaction, user): 59 | if str(reaction.message.channel.id) not in self.channels: 60 | return 61 | if self.channels[str(reaction.message.channel.id)]["msg_id"] != reaction.message.id: 62 | return 63 | if user.id == self.bot.user.id: 64 | return 65 | if user.id not in self.channels[str(reaction.message.channel.id)]["reacted"]: 66 | if str(reaction.emoji) == "\U0001f1eb": 67 | await reaction.message.channel.send(f"**{user.name}** has paid their respects.") 68 | self.channels[str(reaction.message.channel.id)]["reacted"].append(user.id) 69 | -------------------------------------------------------------------------------- /urlfetch/urlfetch.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | import logging 4 | from urllib.parse import urlparse 5 | 6 | from redbot.core import checks, commands 7 | from redbot.core.utils.chat_formatting import box, pagify 8 | from redbot.core.utils.menus import menu, DEFAULT_CONTROLS 9 | 10 | log = logging.getLogger("red.aikaterna.urlfetch") 11 | 12 | 13 | __version__ = "1.1.0" 14 | 15 | 16 | class UrlFetch(commands.Cog): 17 | """Grab stuff from a text API.""" 18 | 19 | def __init__(self, bot): 20 | self.bot = bot 21 | 22 | self._headers = {'User-Agent': 'Python/3.8'} 23 | 24 | async def red_delete_data_for_user(self, **kwargs): 25 | """Nothing to delete""" 26 | return 27 | 28 | @commands.command() 29 | async def urlfetch(self, ctx, url: str): 30 | """ 31 | Input a URL to read. 32 | """ 33 | async with ctx.typing(): 34 | valid_url = await self._valid_url(ctx, url) 35 | if valid_url: 36 | text = await self._get_url_content(url) 37 | if text: 38 | page_list = [] 39 | for page in pagify(text, delims=["\n"], page_length=1800): 40 | page_list.append(box(page)) 41 | if len(page_list) == 1: 42 | await ctx.send(box(page)) 43 | else: 44 | await menu(ctx, page_list, DEFAULT_CONTROLS) 45 | else: 46 | return 47 | 48 | async def _get_url_content(self, url: str): 49 | try: 50 | timeout = aiohttp.ClientTimeout(total=20) 51 | async with aiohttp.ClientSession(headers=self._headers, timeout=timeout) as session: 52 | async with session.get(url) as resp: 53 | text = await resp.text() 54 | return text 55 | except aiohttp.client_exceptions.ClientConnectorError: 56 | log.error(f"aiohttp failure accessing site at url:\n\t{url}", exc_info=True) 57 | return None 58 | except asyncio.exceptions.TimeoutError: 59 | log.error(f"asyncio timeout while accessing feed at url:\n\t{url}") 60 | return None 61 | except Exception: 62 | log.error(f"General failure accessing site at url:\n\t{url}", exc_info=True) 63 | return None 64 | 65 | async def _valid_url(self, ctx, url: str): 66 | try: 67 | result = urlparse(url) 68 | except Exception as e: 69 | log.exception(e, exc_info=e) 70 | await ctx.send("There was an issue trying to fetch that site. Please check your console for the error.") 71 | return None 72 | 73 | if all([result.scheme, result.netloc]): 74 | text = await self._get_url_content(url) 75 | if not text: 76 | await ctx.send("No text present at the given url.") 77 | return None 78 | else: 79 | return text 80 | else: 81 | await ctx.send(f"That url seems to be incomplete.") 82 | return None 83 | -------------------------------------------------------------------------------- /embedpeek/embedpeek.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from redbot.core import commands 3 | from redbot.core.utils.chat_formatting import box, pagify 4 | 5 | 6 | class EmbedPeek(commands.Cog): 7 | """Take a closer look at an embed.""" 8 | 9 | async def red_delete_data_for_user(self, **kwargs): 10 | """Nothing to delete""" 11 | return 12 | 13 | def __init__(self, bot): 14 | self.bot = bot 15 | self._grave = "\N{GRAVE ACCENT}" 16 | 17 | @commands.command() 18 | async def embedpeek(self, ctx, message_link: str): 19 | """ 20 | Take a closer look at an embed. 21 | 22 | On a webhook message or other multi-embed messages, this will only display the first embed. 23 | """ 24 | bad_link_msg = "That doesn't look like a message link, I can't reach that message, or that link does not have an embed." 25 | no_guild_msg = "You aren't in that guild." 26 | no_channel_msg = "You can't view that channel." 27 | no_message_msg = "That message wasn't found." 28 | 29 | if not "discord.com/channels/" in message_link: 30 | return await ctx.send(bad_link_msg) 31 | ids = message_link.split("/") 32 | if len(ids) != 7: 33 | return await ctx.send(bad_link_msg) 34 | 35 | guild = self.bot.get_guild(int(ids[4])) 36 | channel = self.bot.get_channel(int(ids[5])) 37 | try: 38 | message = await channel.fetch_message(int(ids[6])) 39 | except discord.errors.NotFound: 40 | return await ctx.send(no_message_msg) 41 | 42 | if ctx.author not in guild.members: 43 | return await ctx.send(no_guild_msg) 44 | if not channel.permissions_for(ctx.author).read_messages: 45 | return await ctx.send(no_channel_msg) 46 | 47 | components = [guild, channel, message] 48 | valid_components = [x for x in components if x != None] 49 | if len(valid_components) < 3: 50 | return await ctx.send(bad_link_msg) 51 | 52 | try: 53 | embed = message.embeds[0] 54 | except IndexError: 55 | return await ctx.send(bad_link_msg) 56 | 57 | info = embed.to_dict() 58 | sorted_info = dict(sorted(info.items())) 59 | msg = "" 60 | 61 | for k, v in sorted_info.items(): 62 | if k == "type": 63 | continue 64 | msg += f"+ {k}\n" 65 | if isinstance(v, str): 66 | msg += f"{v.replace(self._grave, '~')}\n\n" 67 | elif isinstance(v, list): 68 | for i, field in enumerate(v): 69 | msg += f"--- field {i+1} ---\n" 70 | for m, n in field.items(): 71 | msg += f"- {str(m).replace(self._grave, '~')}\n" 72 | msg += f"{str(n).replace(self._grave, '~')}\n" 73 | msg += "\n" 74 | elif isinstance(v, dict): 75 | msg += self._dict_cleaner(v) 76 | msg += "\n" 77 | else: 78 | msg += f"{str(v)}\n\n" 79 | 80 | for page in pagify(msg, delims=f"{'-' * 20}", page_length=1500): 81 | await ctx.send(box(page, lang="diff")) 82 | 83 | def _dict_cleaner(self, d: dict): 84 | msg = "" 85 | for k, v in d.items(): 86 | k = str(k).replace(self._grave, "~") 87 | v = str(v).replace(self._grave, "~") 88 | msg += f"- {k}\n{v}\n" 89 | return msg 90 | -------------------------------------------------------------------------------- /partycrash/partycrash.py: -------------------------------------------------------------------------------- 1 | import discord 2 | from redbot.core import commands, checks 3 | from redbot.core.utils.chat_formatting import box, pagify 4 | import asyncio 5 | 6 | 7 | class PartyCrash(commands.Cog): 8 | """Partycrash inspired by v2 Admin by Will 9 | Does not generate invites, only lists existing invites.""" 10 | 11 | async def red_delete_data_for_user(self, **kwargs): 12 | """ Nothing to delete """ 13 | return 14 | 15 | def __init__(self, bot): 16 | self.bot = bot 17 | 18 | async def _get_invites(self, guild, ctx): 19 | answers = ("yes", "y") 20 | if not guild: 21 | return await ctx.send("I'm not in that server.") 22 | try: 23 | invites = await guild.invites() 24 | except discord.errors.Forbidden: 25 | return await ctx.send(f"I don't have permission to view invites for {guild.name}.") 26 | if not invites: 27 | return await ctx.send("I couldn't access any invites.") 28 | await ctx.send(f"Are you sure you want to post the invite(s) to {guild.name} here?") 29 | 30 | def check(m): 31 | return m.author == ctx.author 32 | 33 | try: 34 | msg = await ctx.bot.wait_for("message", timeout=15.0, check=check) 35 | if msg.content.lower().strip() in answers: 36 | msg = f"Invite(s) for **{guild.name}**:" 37 | for url in invites: 38 | msg += f"\n{url}" 39 | await ctx.send(msg) 40 | else: 41 | await ctx.send("Alright then.") 42 | except asyncio.TimeoutError: 43 | await ctx.send("I guess not.") 44 | 45 | @commands.command() 46 | @checks.is_owner() 47 | async def partycrash(self, ctx, idnum=None): 48 | """Lists servers and existing invites for them.""" 49 | if idnum: 50 | guild = self.bot.get_guild(int(idnum)) 51 | await self._get_invites(guild, ctx) 52 | else: 53 | msg = "" 54 | guilds = sorted(self.bot.guilds, key=lambda s: s.name) 55 | for i, guild in enumerate(guilds, 1): 56 | if len(guild.name) > 32: 57 | guild_name = f"{guild.name[:32]}..." 58 | else: 59 | guild_name = guild.name 60 | if i < 10: 61 | i = f"0{i}" 62 | msg += f"{i}: {guild_name:35} ({guild.id})\n" 63 | msg += "\nTo post the existing invite(s) for a server just type its number." 64 | for page in pagify(msg, delims=["\n"]): 65 | await ctx.send(box(page)) 66 | 67 | def check(m): 68 | return m.author == ctx.author 69 | 70 | try: 71 | msg = await ctx.bot.wait_for("message", timeout=20.0, check=check) 72 | try: 73 | guild_no = int(msg.content.strip()) 74 | guild = guilds[guild_no - 1] 75 | except ValueError: 76 | return await ctx.send("You must enter a number.") 77 | except IndexError: 78 | return await ctx.send("Index out of range.") 79 | try: 80 | await self._get_invites(guild, ctx) 81 | except discord.errors.Forbidden: 82 | return await ctx.send(f"I don't have permission to get invites for {guild.name}.") 83 | except asyncio.TimeoutError: 84 | return await ctx.send("No server number entered, try again later.") 85 | -------------------------------------------------------------------------------- /trackdecoder/trackdecoder.py: -------------------------------------------------------------------------------- 1 | from base64 import b64decode 2 | import json 3 | from io import BytesIO 4 | import struct 5 | from types import SimpleNamespace 6 | 7 | from redbot.core import checks, commands 8 | from redbot.core.utils.chat_formatting import box 9 | 10 | 11 | class TrackDecoder(commands.Cog): 12 | """Decodes a b64 encoded audio track string.""" 13 | 14 | def __init__(self, bot): 15 | self.bot = bot 16 | 17 | async def red_delete_data_for_user(self, **kwargs): 18 | """Nothing to delete""" 19 | return 20 | 21 | @checks.is_owner() 22 | @commands.command() 23 | @commands.guild_only() 24 | async def trackdecode(self, ctx: commands.Context, *, track: str): 25 | """ 26 | Decodes a b64 encoded audio track string. 27 | 28 | This command is possible thanks to devoxin#0001's work. 29 | `https://github.com/Devoxin/Lavalink.py` 30 | """ 31 | decoded = self.decode_track(track) 32 | if not decoded: 33 | return await ctx.send(f"Not a valid track.") 34 | 35 | msg = ( 36 | f"[Title]: {decoded.title}\n" 37 | f"[Author]: {decoded.author}\n" 38 | f"[URL]: {decoded.uri}\n" 39 | f"[Identifier]: {decoded.identifier}\n" 40 | f"[Source]: {decoded.source}\n" 41 | f"[Length]: {decoded.length}\n" 42 | f"[Stream]: {decoded.is_stream}\n" 43 | f"[Position]: {decoded.position}\n" 44 | ) 45 | 46 | await ctx.send(box(msg, lang="ini")) 47 | 48 | @staticmethod 49 | def decode_track(track, decode_errors="ignore"): 50 | """ 51 | Source is derived from: 52 | https://github.com/Devoxin/Lavalink.py/blob/3688fe6aff265ff53928ec811279177a88aa9664/lavalink/utils.py 53 | """ 54 | reader = DataReader(track) 55 | 56 | try: 57 | flags = (reader.read_int() & 0xC0000000) >> 30 58 | except struct.error: 59 | return None 60 | 61 | (version,) = struct.unpack("B", reader.read_byte()) if flags & 1 != 0 else 1 62 | 63 | track = SimpleNamespace( 64 | title=reader.read_utf().decode(errors=decode_errors), 65 | author=reader.read_utf().decode(), 66 | length=reader.read_long(), 67 | identifier=reader.read_utf().decode(), 68 | is_stream=reader.read_boolean(), 69 | uri=reader.read_utf().decode() if reader.read_boolean() else None, 70 | source=reader.read_utf().decode(), 71 | position=reader.read_long(), 72 | ) 73 | 74 | return track 75 | 76 | 77 | class DataReader: 78 | """ 79 | Source is from: 80 | https://github.com/Devoxin/Lavalink.py/blob/3688fe6aff265ff53928ec811279177a88aa9664/lavalink/datarw.py 81 | """ 82 | 83 | def __init__(self, ts): 84 | self._buf = BytesIO(b64decode(ts)) 85 | 86 | def _read(self, n): 87 | return self._buf.read(n) 88 | 89 | def read_byte(self): 90 | return self._read(1) 91 | 92 | def read_boolean(self): 93 | (result,) = struct.unpack("B", self.read_byte()) 94 | return result != 0 95 | 96 | def read_unsigned_short(self): 97 | (result,) = struct.unpack(">H", self._read(2)) 98 | return result 99 | 100 | def read_int(self): 101 | (result,) = struct.unpack(">i", self._read(4)) 102 | return result 103 | 104 | def read_long(self): 105 | (result,) = struct.unpack(">Q", self._read(8)) 106 | return result 107 | 108 | def read_utf(self): 109 | text_length = self.read_unsigned_short() 110 | return self._read(text_length) 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # aikaterna-cogs 2 | v3 Cogs for Red-DiscordBot by Twentysix26. 3 | 4 | ### >>> These cogs are for Red 3.5 <<< 5 | If you need a Red 3.4 version of these cogs, see [here](https://github.com/aikaterna/aikaterna-cogs/tree/v3.4). 6 | 7 | adventure - Original concept & cog by locastan. My version is a collaboration between TrustyJAID, Draper, and myself and is now markedly different than locastan's version. The repo can be found on my repo page here on github. 8 | 9 | away - Originally by Paddo, written for v3 by Axas, final tests by aikaterna, and large improvements by TrustyJAID. Set and unset a user as being "away", or other statuses. 10 | 11 | chatchart - Generates a pie chart to display chat activity over the last 5000 messages. Requested by violetnyte. 12 | 13 | dadjokes - Another UltimatePancake cog. Get some dad jokes on command. 14 | 15 | dictionary - Define words and look up antonyms and synonyms. Originally by UltimatePancake. 16 | 17 | embedpeek - Take a closer look at or unpack embed content. This cog is mostly a developer tool. 18 | 19 | hunting - Hunting has moved to Vertyco's repo at https://github.com/vertyco/vrt-cogs 20 | 21 | icyparser - Show icecast/shoutcast stream information. An audio addon cog to show the current stream info, or provide a url yourself. 22 | 23 | imgwelcome - Welcome users to your server(s) with an image. The repo can be found on my repo page here on github. 24 | 25 | inspirobot - Fetch "inspirational" messages from inspirobot.me with [p]inspireme. 26 | 27 | invites - Display invites that are available on the server and the information those invites contain. The bot must have the administrator permission granted on the guild to be able to use this cog. 28 | 29 | latex - A simple cog originally by Stevy for v2 that displayes LaTeX expressions in an image. 30 | 31 | luigipoker - Play the Luigi Poker minigame from New Super Mario Brothers. Ported from the v2 version written by themario30. 32 | 33 | otherbot - Alert a role when bot(s) go offline. 34 | 35 | partycrash - A port of Will's partycrash command from the v2 Admin cog. This cog will not generate invites, but will show already-existing invites that the bot has access to view. 36 | 37 | pingtime - Show all shards' pingtimes. 38 | 39 | pressf - A port/rewrite of NekoTony's v2 pressf cog. Pay your respects by pressing F. 40 | 41 | quiz - A kahoot-like trivia game. Originally by Keane for Red v2. 42 | 43 | reminder - A continued fork of ZeLarpMaster's reminder cog. This cog is licensed under the GPL-3.0 License. 44 | 45 | rndstatus - A v3 port of Twentysix's rndstatus cog with a couple extra settings. 46 | 47 | rss - Will's RSS cog ported for v3 with a lot of extra bells and whistles. 48 | 49 | snacktime - A v3 port of irdumb's snacktime cog. Now with friends! 50 | 51 | timezone - A v3 port of Fishyfing's timezone cog with a few improvements. 52 | 53 | trackdecoder - A dev utility cog to resolve Lavalink Track information from a b64 string. 54 | 55 | trickortreat - A trick or treat-based competitive candy eating game with a leaderboard and other fun commands like stealing candy from guildmates. 56 | 57 | tools - A collection of mod and admin tools, ported from my v2 version. Sitryk is responsible for a lot of the code in tools... thanks for the help with this cog. 58 | 59 | ttt - A Tic Tac Toe cog originally for Red V2 by HizikiFW. This cog is licensed under the Apache-2.0 license. 60 | 61 | urlfetch - Fetch text from a URL. Mainly used for simple text API queries (not JSON). 62 | 63 | voicelogs - A record of people's time spent in voice channels by ZeLarpMaster for v2, and ported here for v3 use. This cog is licensed under the GPL-3.0 License. 64 | 65 | wolfram - A v3 port of Paddo's abandoned Wolfram Alpha cog. 66 | 67 | youtube - A v3 port of Paddo's youtube search cog for v2. 68 | 69 | 70 | # Recently moved to my aikaterna-cogs-unsupported repo: 71 | 72 | blurplefy - Make an avatar or an image upload blurple for Discord's anniversaries. 73 | 74 | cah - Cards Against Humanity, played in DM's. This can rate limit large bots via the sheer number of messages sent. Install and use with caution on larger bots. 75 | 76 | discordexperiments - Create voice channel invites for various built-in apps. This is only for developers or for people that can read the code and assess the risk of using it. 77 | 78 | massunban - Bot Admins or guild Administrators can use this tool to mass unban users via ban reason keywords, or mass unban everyone on the ban list. 79 | 80 | noflippedtables - A v3 port of irdumb's v2 cog with a little extra surprise included. Unflip all the tables. 81 | 82 | 83 | Support for these cogs is via opened issues on the appropriate repo or in the Red - Cog Support server at https://discord.gg/GET4DVk, in the support_aikaterna-cogs channel. 84 | -------------------------------------------------------------------------------- /rss/color.py: -------------------------------------------------------------------------------- 1 | from math import sqrt 2 | import discord 3 | import re 4 | import webcolors 5 | 6 | 7 | _DISCORD_COLOURS = { 8 | discord.Color.teal().to_rgb(): 'teal', 9 | discord.Color.dark_teal().to_rgb(): 'dark_teal', 10 | discord.Color.green().to_rgb(): 'green', 11 | discord.Color.dark_green().to_rgb(): 'dark_green', 12 | discord.Color.blue().to_rgb(): 'blue', 13 | discord.Color.dark_blue().to_rgb(): 'dark_blue', 14 | discord.Color.purple().to_rgb(): 'purple', 15 | discord.Color.dark_purple().to_rgb(): 'dark_purple', 16 | discord.Color.magenta().to_rgb(): 'magenta', 17 | discord.Color.dark_magenta().to_rgb(): 'dark_magenta', 18 | discord.Color.gold().to_rgb(): 'gold', 19 | discord.Color.dark_gold().to_rgb(): 'dark_gold', 20 | discord.Color.orange().to_rgb(): 'orange', 21 | discord.Color.dark_orange().to_rgb(): 'dark_orange', 22 | discord.Color.red().to_rgb(): 'red', 23 | discord.Color.dark_red().to_rgb(): 'dark_red', 24 | discord.Color.lighter_grey().to_rgb(): 'lighter_grey', 25 | discord.Color.light_grey().to_rgb(): 'light_grey', 26 | discord.Color.dark_grey().to_rgb(): 'dark_grey', 27 | discord.Color.darker_grey().to_rgb(): 'darker_grey', 28 | discord.Color.blurple().to_rgb(): 'old_blurple', 29 | discord.Color(0x4a90e2).to_rgb(): 'new_blurple', 30 | discord.Color.greyple().to_rgb(): 'greyple', 31 | discord.Color.dark_theme().to_rgb(): 'discord_dark_theme' 32 | } 33 | 34 | _RGB_NAME_MAP = {webcolors.hex_to_rgb(hexcode): name for hexcode, name in webcolors.css3_hex_to_names.items()} 35 | _RGB_NAME_MAP.update(_DISCORD_COLOURS) 36 | 37 | 38 | def _distance(point_a: tuple, point_b: tuple): 39 | """ 40 | Euclidean distance between two points using rgb values as the metric space. 41 | """ 42 | # rgb values 43 | x1, y1, z1 = point_a 44 | x2, y2, z2 = point_b 45 | 46 | # distances 47 | dx = x1 - x2 48 | dy = y1 - y2 49 | dz = z1 - z2 50 | 51 | # final distance 52 | return sqrt(dx**2 + dy**2 + dz**2) 53 | 54 | def _linear_nearest_neighbour(all_points: list, pivot: tuple): 55 | """ 56 | Check distance against all points from the pivot and return the distance and nearest point. 57 | """ 58 | best_dist = None 59 | nearest = None 60 | for point in all_points: 61 | dist = _distance(point, pivot) 62 | if best_dist is None or dist < best_dist: 63 | best_dist = dist 64 | nearest = point 65 | return best_dist, nearest 66 | 67 | 68 | class Color: 69 | """Helper for color handling.""" 70 | 71 | async def _color_converter(self, hex_code_or_color_word: str): 72 | """ 73 | Used for user input on rss embed color 74 | Input: discord.Color name, CSS3 color name, 0xFFFFFF, #FFFFFF, FFFFFF 75 | Output: 0xFFFFFF 76 | """ 77 | # #FFFFFF and FFFFFF to 0xFFFFFF 78 | hex_match = re.match(r"#?[a-f0-9]{6}", hex_code_or_color_word.lower()) 79 | if hex_match: 80 | hex_code = f"0x{hex_code_or_color_word.lstrip('#')}" 81 | return hex_code 82 | 83 | # discord.Color checking 84 | if hasattr(discord.Color, hex_code_or_color_word): 85 | hex_code = str(getattr(discord.Color, hex_code_or_color_word)()) 86 | hex_code = hex_code.replace("#", "0x") 87 | return hex_code 88 | 89 | # CSS3 color name checking 90 | try: 91 | hex_code = webcolors.name_to_hex(hex_code_or_color_word, spec="css3") 92 | hex_code = hex_code.replace("#", "0x") 93 | return hex_code 94 | except ValueError: 95 | pass 96 | 97 | return None 98 | 99 | async def _hex_to_css3_name(self, hex_code: str): 100 | """ 101 | Input: 0xFFFFFF 102 | Output: CSS3 color name string closest match 103 | """ 104 | hex_code = await self._hex_validator(hex_code) 105 | rgb_tuple = await self._hex_to_rgb(hex_code) 106 | 107 | positions = list(_RGB_NAME_MAP.keys()) 108 | dist, nearest = _linear_nearest_neighbour(positions, rgb_tuple) 109 | 110 | return _RGB_NAME_MAP[nearest] 111 | 112 | async def _hex_to_rgb(self, hex_code: str): 113 | """ 114 | Input: 0xFFFFFF 115 | Output: (255, 255, 255) 116 | """ 117 | return webcolors.hex_to_rgb(hex_code) 118 | 119 | async def _hex_validator(self, hex_code: str): 120 | """ 121 | Input: 0xFFFFFF 122 | Output: #FFFFFF or None 123 | """ 124 | if hex_code[:2] == "0x": 125 | hex_code = hex_code.replace("0x", "#") 126 | try: 127 | # just a check to make sure it's a real color hex code 128 | hex_code = webcolors.normalize_hex(hex_code) 129 | except ValueError: 130 | hex_code = None 131 | return hex_code 132 | -------------------------------------------------------------------------------- /wolfram/wolfram.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | from io import BytesIO 4 | import xml.etree.ElementTree as ET 5 | import urllib.parse 6 | 7 | from redbot.core import Config, commands, checks 8 | from redbot.core.utils.chat_formatting import box, pagify 9 | from redbot.core.utils.menus import menu, DEFAULT_CONTROLS 10 | 11 | 12 | class Wolfram(commands.Cog): 13 | """Ask Wolfram Alpha any question.""" 14 | 15 | async def red_delete_data_for_user(self, **kwargs): 16 | """Nothing to delete.""" 17 | return 18 | 19 | def __init__(self, bot): 20 | self.bot = bot 21 | self.session = aiohttp.ClientSession() 22 | 23 | default_global = {"WOLFRAM_API_KEY": None} 24 | 25 | self.config = Config.get_conf(self, 2788801004) 26 | self.config.register_guild(**default_global) 27 | 28 | @commands.command(name="wolfram") 29 | async def _wolfram(self, ctx, *question: str): 30 | """Ask Wolfram Alpha any question.""" 31 | api_key = await self.config.WOLFRAM_API_KEY() 32 | if not api_key: 33 | return await ctx.send("No API key set for Wolfram Alpha. Get one at http://products.wolframalpha.com/api/") 34 | 35 | url = "http://api.wolframalpha.com/v2/query?" 36 | query = " ".join(question) 37 | payload = {"input": query, "appid": api_key} 38 | headers = {"user-agent": "Red-cog/2.0.0"} 39 | async with ctx.typing(): 40 | async with self.session.get(url, params=payload, headers=headers) as r: 41 | result = await r.text() 42 | root = ET.fromstring(result) 43 | a = [] 44 | for pt in root.findall(".//plaintext"): 45 | if pt.text: 46 | a.append(pt.text.capitalize()) 47 | if len(a) < 1: 48 | message = "There is as yet insufficient data for a meaningful answer." 49 | else: 50 | message = "\n".join(a[0:3]) 51 | if "Current geoip location" in message: 52 | message = "There is as yet insufficient data for a meaningful answer." 53 | 54 | if len(message) > 1990: 55 | menu_pages = [] 56 | for page in pagify(message, delims=[" | ", "\n"], page_length=1990): 57 | menu_pages.append(box(page)) 58 | await menu(ctx, menu_pages, DEFAULT_CONTROLS) 59 | else: 60 | await ctx.send(box(message)) 61 | 62 | @commands.command(name="wolframimage") 63 | async def _image(self, ctx, *arguments: str): 64 | """Ask Wolfram Alpha any question. Returns an image.""" 65 | if not arguments: 66 | return await ctx.send_help() 67 | api_key = await self.config.WOLFRAM_API_KEY() 68 | if not api_key: 69 | return await ctx.send("No API key set for Wolfram Alpha. Get one at http://products.wolframalpha.com/api/") 70 | 71 | width = 800 72 | font_size = 30 73 | layout = "labelbar" 74 | background = "193555" 75 | foreground = "white" 76 | units = "metric" 77 | query = " ".join(arguments) 78 | query = urllib.parse.quote(query) 79 | url = f"http://api.wolframalpha.com/v1/simple?appid={api_key}&i={query}%3F&width={width}&fontsize={font_size}&layout={layout}&background={background}&foreground={foreground}&units={units}&ip=127.0.0.1" 80 | 81 | async with ctx.typing(): 82 | async with self.session.request("GET", url) as r: 83 | img = await r.content.read() 84 | if len(img) == 43: 85 | # img = b'Wolfram|Alpha did not understand your input' 86 | return await ctx.send("There is as yet insufficient data for a meaningful answer.") 87 | wolfram_img = BytesIO(img) 88 | try: 89 | await ctx.channel.send(file=discord.File(wolfram_img, f"wolfram{ctx.author.id}.png")) 90 | except Exception as e: 91 | await ctx.send(f"Oops, there was a problem: {e}") 92 | 93 | @commands.command(name="wolframsolve") 94 | async def _solve(self, ctx, *, query: str): 95 | """Ask Wolfram Alpha any math question. Returns step by step answers.""" 96 | api_key = await self.config.WOLFRAM_API_KEY() 97 | if not api_key: 98 | return await ctx.send("No API key set for Wolfram Alpha. Get one at http://products.wolframalpha.com/api/") 99 | 100 | url = f"http://api.wolframalpha.com/v2/query" 101 | params = { 102 | "appid": api_key, 103 | "input": query, 104 | "podstate": "Step-by-step solution", 105 | "format": "plaintext", 106 | } 107 | msg = "" 108 | 109 | async with ctx.typing(): 110 | async with self.session.request("GET", url, params=params) as r: 111 | text = await r.content.read() 112 | root = ET.fromstring(text) 113 | for pod in root.findall(".//pod"): 114 | if pod.attrib["title"] == "Number line": 115 | continue 116 | msg += f"{pod.attrib['title']}\n" 117 | for pt in pod.findall(".//plaintext"): 118 | if pt.text: 119 | strip = pt.text.replace(" | ", " ").replace("| ", " ") 120 | msg += f"- {strip}\n\n" 121 | if len(msg) < 1: 122 | msg = "There is as yet insufficient data for a meaningful answer." 123 | for text in pagify(msg): 124 | await ctx.send(box(text)) 125 | 126 | @checks.is_owner() 127 | @commands.command(name="setwolframapi", aliases=["setwolfram"]) 128 | async def _setwolframapi(self, ctx, key: str): 129 | """Set the api-key. The key is the AppID of your application on the Wolfram|Alpha Developer Portal.""" 130 | 131 | if key: 132 | await self.config.WOLFRAM_API_KEY.set(key) 133 | await ctx.send("Key set.") 134 | 135 | def cog_unload(self): 136 | self.bot.loop.create_task(self.session.close()) 137 | -------------------------------------------------------------------------------- /snacktime/phrases.py: -------------------------------------------------------------------------------- 1 | FRIENDS = { 2 | "Snackburr": "ʕ •ᴥ•ʔ <", 3 | "Pancakes": "₍⸍⸌̣ʷ̣̫⸍̣⸌₎ <", 4 | "Mr Pickles": "(=`ェ´=) <", 5 | "Satin": "▼・ᴥ・▼ <", 6 | "Thunky": "ᘛ⁐̤ᕐᐷ <", 7 | "Jingle": "꒰∗'꒳'꒱ <", 8 | "FluffButt": r"/ᐠ。ꞈ。ᐟ\ <", 9 | "Staplefoot": "( ̄(エ) ̄) <", 10 | } 11 | 12 | 13 | SNACKBURR_PHRASES = { 14 | "SNACKTIME": [ 15 | "It's snack time!", 16 | "I'm back with s'more snacks! Who wants!?", 17 | "I'm back errbody! Who wants some snacks!?", 18 | "Woo man those errands are crazy! Anyways, anybody want some snacks?", 19 | "I got snacks! If nobody wants em, I'm gonna eat em all!!", 20 | "Hey, I'm back! Anybody in the mood for some snacks?!", 21 | "Heyyaaayayyyaya! I say Hey, I got snacks!", 22 | "Heyyaaayayyyaya! I say Hey, What's goin on?... I uh.. I got snacks.", 23 | "If anybody has reason why these snacks and my belly should not be wed, speak now or forever hold your peace!", 24 | "Got another snack delivery guys!", 25 | "Did somebody say snacks?!?! o/", 26 | "Choo Choo! it's the pb train! Come on over guys!", 27 | "Snacks are here! Dig in! Who wants a plate?", 28 | "Pstt.. I got the snacks you were lookin for. <.<", 29 | "I hope you guys are hungry! Cause i'm loaded to the brim with snacks!!!", 30 | "I was hungry on the way over so I kinda started without you guys :3 Who wants snacks!?!", 31 | "Beep beep! I got a snack delivery comin in! Who wants snacks!", 32 | "Guess what time it is?! It's snacktime!! Who wants?!", 33 | "Hey check out this sweet stach o' snacks I found! Who wants a cut?", 34 | "Who's ready to gobble down some snacks!?", 35 | "So who's gonna help me eat all these snacks? :3", 36 | "Eyoooooooo I haz snacks! Yall wanna munch???", 37 | ], 38 | "OUT": [ 39 | "I'm out of snacks! I'll be back with more soon.", 40 | "I'm out of snacks :( I'll be back soon with more!", 41 | "Aight, I gotta head out! I'll be back with more, don worry :3", 42 | "Alright, I gotta get back to my errands. I'll see you guys soon!", 43 | "Uh okays, imma get back to my errands cuz outta snacks!", 44 | "Yall are cool but i gotta get back to my errands. I'll visit again soon!", 45 | "Errands call! Thanks for the help with eating all these snacks, I'll come again :3", 46 | "I have to go back do my stuffs, but as soon as I get more snacks imma come and share!!!", 47 | ], 48 | "LONELY": ["I guess you guys don't like snacktimes.. I'll stop comin around."], 49 | "NO_TAKERS": [ 50 | "I guess nobody wants snacks... more for me!", 51 | "Guess nobody's here.. I'll just head out then", 52 | "I don't see anybody.. <.< ... >.> ... All the snacks for me!!", 53 | "I guess nobody wants snacks huh.. Well, I'll come back later", 54 | "I guess i'll just come back later..", 55 | "Well. Since yall dun want snacks imma eat them all!!!", 56 | "Where is everyone, I brought so many snacks...", 57 | "I did offer to share, but I guess you dun want. Oh well, I'll come back later :3", 58 | "Whoa... you dun wanna have any snacks?!", 59 | "I'll just eat all those snacks by myself then!", 60 | ":3 okay, catch yall lata", 61 | ], 62 | "GIVE": [ 63 | "Here ya go, {0}, here's {1} pb!", 64 | "Alright here ya go, {0}, {1} pb for you!", 65 | "Yeah! Here you go, {0}! {1} pb!", 66 | "Of course {0}! Here's {1} pb!", 67 | "Ok {0}, here's {1} pb for you. Anyone else want some?", 68 | "Alllright, {1} pb for {0}!", 69 | "Hold your horses {0}! Alright, {1} pb for you :)", 70 | "Woooooooooo hey {0}, here have {1} pb!", 71 | "Here's {1} pb for {0} :3", 72 | "Heyheyehy hiya {0}! Here's {1} pb for you!", 73 | "Oh hiya {0} :) imma give you {1} pb now!", 74 | "Heyyyyyyyyyyy {0}, I brought you {1} pb :)", 75 | "Oh you wanna munchies? I have munchies for you, {0}! Have {1} pb :)", 76 | "{1} pb for {0}!!!", 77 | "{0} {0} {0}! Grab {1} pb!", 78 | ], 79 | "LAST_SECOND": [ 80 | "Fine fine, {0}, I'll give you {1} of my on-the-road pb.. Cya!", 81 | "Oh! {0}, you caught me right before I left! Alright, i'll give you {1} of my own pb", 82 | ], 83 | "GREEDY": [ 84 | "Don't be greedy now! you already got some pb {0}!", 85 | "You already got your snacks {0}!", 86 | "Come on {0}, you already got your snacks! We gotta make sure there's some for errbody!", 87 | "But! {0} you already had a bunch of snacks!", 88 | "Hey {0}, there must be snacks for others too!!", 89 | "Donot be like that, {0}, I wanna give snacks to errybody!", 90 | "Aw but {0}, we gonna run outta snacks if I give you again :(", 91 | "Let's make a deal, remind me when I come back from my errands next time, {0} :3", 92 | "Next time I'm comin 'round imma give you again, {0}", 93 | "I alredy handed you snacks {0}...", 94 | "But... but... you... you had your snacks {0} ._.", 95 | "I wanna give snacks to errybody! You can't have them all, {0}!", 96 | "You already munched on some snacks, {0}!", 97 | "Did you already gobble down yours, {0}? Gotta leave some for the others!", 98 | ], 99 | "EAT_BEFORE": [ 100 | "monstrously", 101 | "shyly", 102 | "earnestly", 103 | "eagerly", 104 | "enthusiastically", 105 | "ravenously", 106 | "delicately", 107 | "daintily", 108 | ], 109 | "EAT_AFTER": [ 110 | "gobbles up", 111 | "pigs out on", 112 | "wolfs down", 113 | "chows down", 114 | "munches on", 115 | "chugs", 116 | "puts away", 117 | "ravages", 118 | "siphons into their mouth", 119 | "disposes of", 120 | "swallows", 121 | "chomps", 122 | "consumes", 123 | "demolishes", 124 | "partakes of", 125 | "ingests", 126 | ], 127 | "ENABLE": ["Oh you guys want snacks?! Aight, I'll come around every so often to hand some out!"], 128 | "DISABLE": ["You guys don't want snacks anymore? Alright, I'll stop comin around."], 129 | } 130 | -------------------------------------------------------------------------------- /dictionary/dictionary.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import discord 3 | import contextlib 4 | from bs4 import BeautifulSoup 5 | import json 6 | import logging 7 | import re 8 | from redbot.core import commands 9 | from redbot.core.utils.chat_formatting import pagify 10 | 11 | 12 | log = logging.getLogger("red.aikaterna.dictionary") 13 | 14 | 15 | class Dictionary(commands.Cog): 16 | """ 17 | Word, yo 18 | Parts of this cog are adapted from the PyDictionary library. 19 | """ 20 | 21 | async def red_delete_data_for_user(self, **kwargs): 22 | """Nothing to delete""" 23 | return 24 | 25 | def __init__(self, bot): 26 | self.bot = bot 27 | self.session = aiohttp.ClientSession() 28 | 29 | def cog_unload(self): 30 | self.bot.loop.create_task(self.session.close()) 31 | 32 | @commands.command() 33 | async def define(self, ctx, *, word: str): 34 | """Displays definitions of a given word.""" 35 | search_msg = await ctx.send("Searching...") 36 | search_term = word.split(" ", 1)[0] 37 | result = await self._definition(ctx, search_term) 38 | str_buffer = "" 39 | if not result: 40 | with contextlib.suppress(discord.NotFound): 41 | await search_msg.delete() 42 | await ctx.send("This word is not in the dictionary.") 43 | return 44 | for key in result: 45 | str_buffer += f"\n**{key}**: \n" 46 | counter = 1 47 | j = False 48 | for val in result[key]: 49 | if val.startswith("("): 50 | str_buffer += f"{str(counter)}. *{val})* " 51 | counter += 1 52 | j = True 53 | else: 54 | if j: 55 | str_buffer += f"{val}\n" 56 | j = False 57 | else: 58 | str_buffer += f"{str(counter)}. {val}\n" 59 | counter += 1 60 | with contextlib.suppress(discord.NotFound): 61 | await search_msg.delete() 62 | for page in pagify(str_buffer, delims=["\n"]): 63 | await ctx.send(page) 64 | 65 | async def _definition(self, ctx, word): 66 | data = await self._get_soup_object(f"http://wordnetweb.princeton.edu/perl/webwn?s={word}") 67 | if not data: 68 | return await ctx.send("Error fetching data.") 69 | types = data.findAll("h3") 70 | length = len(types) 71 | lists = data.findAll("ul") 72 | out = {} 73 | if not lists: 74 | return 75 | for a in types: 76 | reg = str(lists[types.index(a)]) 77 | meanings = [] 78 | for x in re.findall(r">\s\((.*?)\)\s<", reg): 79 | if "often followed by" in x: 80 | pass 81 | elif len(x) > 5 or " " in str(x): 82 | meanings.append(x) 83 | name = a.text 84 | out[name] = meanings 85 | return out 86 | 87 | @commands.command() 88 | async def antonym(self, ctx, *, word: str): 89 | """Displays antonyms for a given word.""" 90 | search_term = word.split(" ", 1)[0] 91 | result = await self._antonym_or_synonym(ctx, "antonyms", search_term) 92 | if not result: 93 | await ctx.send("This word is not in the dictionary or nothing was found.") 94 | return 95 | 96 | result_text = "*, *".join(result) 97 | msg = f"Antonyms for **{search_term}**: *{result_text}*" 98 | for page in pagify(msg, delims=["\n"]): 99 | await ctx.send(page) 100 | 101 | @commands.command() 102 | async def synonym(self, ctx, *, word: str): 103 | """Displays synonyms for a given word.""" 104 | search_term = word.split(" ", 1)[0] 105 | result = await self._antonym_or_synonym(ctx, "synonyms", search_term) 106 | if not result: 107 | await ctx.send("This word is not in the dictionary or nothing was found.") 108 | return 109 | 110 | result_text = "*, *".join(result) 111 | msg = f"Synonyms for **{search_term}**: *{result_text}*" 112 | for page in pagify(msg, delims=["\n"]): 113 | await ctx.send(page) 114 | 115 | async def _antonym_or_synonym(self, ctx, lookup_type, word): 116 | if lookup_type not in ["antonyms", "synonyms"]: 117 | return None 118 | data = await self._get_soup_object(f"http://www.thesaurus.com/browse/{word}") 119 | if not data: 120 | await ctx.send("Error getting information from the website.") 121 | return 122 | 123 | script = data.find("script", id="preloaded-state") 124 | if script: 125 | script_text = script.string 126 | script_text = script_text.strip() 127 | script_text = script_text.replace("window.__PRELOADED_STATE__ = ", "") 128 | else: 129 | await ctx.send("Error fetching script from the website.") 130 | return 131 | 132 | try: 133 | data = json.loads(script_text) 134 | except json.decoder.JSONDecodeError: 135 | await ctx.send("Error decoding script from the website.") 136 | return 137 | except Exception as e: 138 | log.exception(e, exc_info=e) 139 | await ctx.send("Something broke. Check your console for more information.") 140 | return 141 | 142 | try: 143 | data_prefix = data["thesaurus"]["thesaurusData"]["data"]["slugs"][0]["entries"][0]["partOfSpeechGroups"][0]["shortDefinitions"][0] 144 | except KeyError: 145 | return None 146 | 147 | if lookup_type == "antonyms": 148 | try: 149 | antonym_subsection = data_prefix["antonyms"] 150 | except KeyError: 151 | return None 152 | antonyms = [] 153 | for item in antonym_subsection: 154 | try: 155 | antonyms.append(item["targetWord"]) 156 | except KeyError: 157 | pass 158 | if antonyms: 159 | return antonyms 160 | else: 161 | return None 162 | 163 | if lookup_type == "synonyms": 164 | try: 165 | synonyms_subsection = data_prefix["synonyms"] 166 | except KeyError: 167 | return None 168 | synonyms = [] 169 | for item in synonyms_subsection: 170 | try: 171 | synonyms.append(item["targetWord"]) 172 | except KeyError: 173 | pass 174 | if synonyms: 175 | return synonyms 176 | else: 177 | return None 178 | 179 | async def _get_soup_object(self, url): 180 | try: 181 | async with self.session.request("GET", url) as response: 182 | return BeautifulSoup(await response.text(), "html.parser") 183 | except Exception: 184 | log.error("Error fetching dictionary.py related webpage", exc_info=True) 185 | return None 186 | -------------------------------------------------------------------------------- /ttt/ttt.py: -------------------------------------------------------------------------------- 1 | # Ported from https://github.com/hizkifw/discord-tictactoe 2 | # This cog is licensed under Apache-2.0, which is bundled with the cog file under LICENSE. 3 | 4 | import discord 5 | import logging 6 | from redbot.core import commands 7 | 8 | 9 | log = logging.getLogger("red.aikaterna.ttt") 10 | 11 | 12 | class TTT(commands.Cog): 13 | """ 14 | Tic Tac Toe 15 | """ 16 | 17 | def __init__(self, bot): 18 | self.bot = bot 19 | self.ttt_games = {} 20 | 21 | async def red_delete_data_for_user(self, **kwargs): 22 | """Nothing to delete""" 23 | return 24 | 25 | @commands.guild_only() 26 | @commands.bot_has_permissions(add_reactions=True) 27 | @commands.max_concurrency(1, commands.BucketType.user) 28 | @commands.command() 29 | async def ttt(self, ctx, move=""): 30 | """ Tic Tac Toe """ 31 | await self.ttt_new(ctx.author, ctx.channel) 32 | 33 | async def ttt_new(self, user, channel): 34 | self.ttt_games[user.id] = [" "] * 9 35 | response = self._make_board(user) 36 | response += "Your move:" 37 | msg = await channel.send(response) 38 | await self._make_buttons(msg) 39 | 40 | async def ttt_move(self, user, message, move): 41 | log.debug(f"ttt_move:{user.id}") 42 | # Check user currently playing 43 | if user.id not in self.ttt_games: 44 | log.debug("New ttt game") 45 | return await self.ttt_new(user, message.channel) 46 | 47 | # Check spot is empty 48 | if self.ttt_games[user.id][move] == " ": 49 | self.ttt_games[user.id][move] = "x" 50 | log.debug(f"Moved to {move}") 51 | else: 52 | log.debug(f"Invalid move: {move}") 53 | return None 54 | 55 | # Check winner 56 | check = self._do_checks(self.ttt_games[user.id]) 57 | if check is not None: 58 | msg = "It's a draw!" if check == "draw" else f"{check[-1]} wins!" 59 | log.debug(msg) 60 | await message.edit(content=f"{self._make_board(user)}{msg}") 61 | return None 62 | log.debug("Check passed") 63 | 64 | # AI move 65 | mv = self._ai_think(self._matrix(self.ttt_games[user.id])) 66 | self.ttt_games[user.id][self._coords_to_index(mv)] = "o" 67 | log.debug("AI moved") 68 | 69 | # Update board 70 | await message.edit(content=self._make_board(user)) 71 | log.debug("Board updated") 72 | 73 | # Check winner again 74 | check = self._do_checks(self.ttt_games[user.id]) 75 | if check is not None: 76 | msg = "It's a draw!" if check == "draw" else f"{check[-1]} wins!" 77 | log.debug(msg) 78 | await message.edit(content=f"{self._make_board(user)}{msg}") 79 | log.debug("Check passed") 80 | 81 | def _make_board(self, author): 82 | return f"{author.mention}\n{self._table(self.ttt_games[author.id])}\n" 83 | 84 | async def _make_buttons(self, msg): 85 | await msg.add_reaction("\u2196") # 0 tl 86 | await msg.add_reaction("\u2B06") # 1 t 87 | await msg.add_reaction("\u2197") # 2 tr 88 | await msg.add_reaction("\u2B05") # 3 l 89 | await msg.add_reaction("\u23FA") # 4 mid 90 | await msg.add_reaction("\u27A1") # 5 r 91 | await msg.add_reaction("\u2199") # 6 bl 92 | await msg.add_reaction("\u2B07") # 7 b 93 | await msg.add_reaction("\u2198") # 8 br 94 | 95 | @commands.Cog.listener() 96 | async def on_reaction_add(self, reaction, user): 97 | if reaction.message.guild is None: 98 | return 99 | if reaction.message.author != self.bot.user: 100 | return 101 | game_session = self.ttt_games.get(user.id, None) 102 | if game_session is None: 103 | return 104 | move = self._decode_move(str(reaction.emoji)) 105 | if move is None: 106 | return 107 | await self.ttt_move(user, reaction.message, move) 108 | 109 | @staticmethod 110 | def _decode_move(emoji): 111 | dict = { 112 | "\u2196": 0, 113 | "\u2B06": 1, 114 | "\u2197": 2, 115 | "\u2B05": 3, 116 | "\u23FA": 4, 117 | "\u27A1": 5, 118 | "\u2199": 6, 119 | "\u2B07": 7, 120 | "\u2198": 8, 121 | } 122 | return dict[emoji] if emoji in dict else None 123 | 124 | @staticmethod 125 | def _table(xo): 126 | return ( 127 | (("%s%s%s\n" * 3) % tuple(xo)) 128 | .replace("o", ":o2:") 129 | .replace("x", ":regional_indicator_x:") 130 | .replace(" ", ":white_large_square:") 131 | ) 132 | 133 | @staticmethod 134 | def _matrix(b): 135 | return [[b[0], b[1], b[2]], [b[3], b[4], b[5]], [b[6], b[7], b[8]]] 136 | 137 | @staticmethod 138 | def _coords_to_index(coords): 139 | map = {(0, 0): 0, (0, 1): 1, (0, 2): 2, (1, 0): 3, (1, 1): 4, (1, 2): 5, (2, 0): 6, (2, 1): 7, (2, 2): 8} 140 | return map[coords] 141 | 142 | def _do_checks(self, b): 143 | m = self._matrix(b) 144 | if self._check_win(m, "x"): 145 | return "win X" 146 | if self._check_win(m, "o"): 147 | return "win O" 148 | if self._check_draw(b): 149 | return "draw" 150 | return None 151 | 152 | # The following comes from an old project 153 | # https://gist.github.com/HizkiFW/0aadefb73e71794fb4a2802708db5bcf 154 | @staticmethod 155 | def _find_streaks(m, xo): 156 | row = [0, 0, 0] 157 | col = [0, 0, 0] 158 | dia = [0, 0] 159 | 160 | # Check rows and columns for X streaks 161 | for y in range(3): 162 | for x in range(3): 163 | if m[y][x] == xo: 164 | row[y] += 1 165 | col[x] += 1 166 | 167 | # Check diagonals 168 | if m[0][0] == xo: 169 | dia[0] += 1 170 | if m[1][1] == xo: 171 | dia[0] += 1 172 | dia[1] += 1 173 | if m[2][2] == xo: 174 | dia[0] += 1 175 | if m[2][0] == xo: 176 | dia[1] += 1 177 | if m[0][2] == xo: 178 | dia[1] += 1 179 | 180 | return (row, col, dia) 181 | 182 | @staticmethod 183 | def _find_empty(matrix, rcd, n): 184 | # Rows 185 | if rcd == "r": 186 | for x in range(3): 187 | if matrix[n][x] == " ": 188 | return x 189 | # Columns 190 | if rcd == "c": 191 | for x in range(3): 192 | if matrix[x][n] == " ": 193 | return x 194 | # Diagonals 195 | if rcd == "d": 196 | if n == 0: 197 | for x in range(3): 198 | if matrix[x][x] == " ": 199 | return x 200 | else: 201 | for x in range(3): 202 | if matrix[x][2 - x] == " ": 203 | return x 204 | 205 | return False 206 | 207 | def _check_win(self, m, xo): 208 | row, col, dia = self._find_streaks(m, xo) 209 | dia.append(0) 210 | 211 | for i in range(3): 212 | if row[i] == 3 or col[i] == 3 or dia[i] == 3: 213 | return True 214 | 215 | return False 216 | 217 | @staticmethod 218 | def _check_draw(board): 219 | return not " " in board 220 | 221 | def _ai_think(self, m): 222 | rx, cx, dx = self._find_streaks(m, "x") 223 | ro, co, do = self._find_streaks(m, "o") 224 | 225 | mv = self._ai_move(2, m, ro, co, do) 226 | if mv is not False: 227 | return mv 228 | mv = self._ai_move(2, m, rx, cx, dx) 229 | if mv is not False: 230 | return mv 231 | mv = self._ai_move(1, m, ro, co, do) 232 | if mv is not False: 233 | return mv 234 | return self._ai_move(1, m, rx, cx, dx) 235 | 236 | def _ai_move(self, n, m, row, col, dia): 237 | for r in range(3): 238 | if row[r] == n: 239 | x = self._find_empty(m, "r", r) 240 | if x is not False: 241 | return (r, x) 242 | if col[r] == n: 243 | y = self._find_empty(m, "c", r) 244 | if y is not False: 245 | return (y, r) 246 | 247 | if dia[0] == n: 248 | y = self._find_empty(m, "d", 0) 249 | if y is not False: 250 | return (y, y) 251 | if dia[1] == n: 252 | y = self._find_empty(m, "d", 1) 253 | if y is not False: 254 | return (y, 2 - y) 255 | 256 | return False 257 | -------------------------------------------------------------------------------- /rndstatus/rndstatus.py: -------------------------------------------------------------------------------- 1 | import re 2 | import discord 3 | from redbot.core import Config, commands, checks 4 | from redbot.core.utils import AsyncIter 5 | from random import choice as rndchoice 6 | from collections import defaultdict 7 | import contextlib 8 | import asyncio 9 | import logging 10 | 11 | 12 | log = logging.getLogger("red.aikaterna.rndstatus") 13 | 14 | 15 | class RndStatus(commands.Cog): 16 | """Cycles random statuses or displays bot stats. 17 | If a custom status is already set, it won't change it until 18 | it's back to none. [p]set game""" 19 | 20 | async def red_delete_data_for_user(self, **kwargs): 21 | """ Nothing to delete """ 22 | return 23 | 24 | def __init__(self, bot): 25 | self.bot = bot 26 | self.last_change = None 27 | self.config = Config.get_conf(self, 2752521001, force_registration=True) 28 | 29 | self.presence_task = asyncio.create_task(self.maybe_update_presence()) 30 | 31 | default_global = { 32 | "botstats": False, 33 | "delay": 300, 34 | "statuses": ["her Turn()", "Tomb Raider II", "Transistor", "NEO Scavenger", "Python", "with your heart.",], 35 | "streamer": "rndstatusstreamer", 36 | "type": 0, 37 | "status": 0, 38 | } 39 | self.config.register_global(**default_global) 40 | 41 | def cog_unload(self): 42 | self.presence_task.cancel() 43 | 44 | @commands.group(autohelp=True) 45 | @commands.guild_only() 46 | @checks.is_owner() 47 | async def rndstatus(self, ctx): 48 | """Rndstatus group commands.""" 49 | pass 50 | 51 | @rndstatus.command(name="set") 52 | async def _set(self, ctx, *statuses: str): 53 | """Sets Red's random statuses. 54 | Accepts multiple statuses. 55 | Must be enclosed in double quotes in case of multiple words. 56 | Example: 57 | [p]rndstatus set \"Tomb Raider II\" \"Transistor\" \"with your heart.\" 58 | Shows current list if empty.""" 59 | saved_status = await self.config.statuses() 60 | if statuses == () or "" in statuses: 61 | msg = ( 62 | f"Current statuses: {(' | ').join(saved_status)}\n" 63 | f"To set new statuses, use the instructions in `{ctx.prefix}help rndstatus set`." 64 | ) 65 | return await ctx.send(msg) 66 | await self.config.statuses.set(list(statuses)) 67 | await self.presence_updater() 68 | await ctx.send("Done. Redo this command with no parameters to see the current list of statuses.") 69 | 70 | @rndstatus.command(name="streamer") 71 | async def _streamer(self, ctx: commands.Context, *, streamer=None): 72 | """Set the streamer name needed for streaming statuses.""" 73 | saved_streamer = await self.config.streamer() 74 | if streamer is None: 75 | return await ctx.send(f"Current Streamer: {saved_streamer}") 76 | await self.config.streamer.set(streamer) 77 | await ctx.send("Done. Redo this command with no parameters to see the current streamer.") 78 | 79 | @rndstatus.command() 80 | async def botstats(self, ctx, *statuses: str): 81 | """Toggle for a bot stats status instead of random messages.""" 82 | botstats = await self.config.botstats() 83 | await self.config.botstats.set(not botstats) 84 | await ctx.send(f"Botstats toggle: {not botstats}.") 85 | await self.presence_updater() 86 | 87 | @rndstatus.command() 88 | async def delay(self, ctx, seconds: int): 89 | """Sets interval of random status switch. 90 | Must be 20 or superior.""" 91 | if seconds < 20: 92 | seconds = 20 93 | await self.config.delay.set(seconds) 94 | await ctx.send(f"Interval set to {seconds} seconds.") 95 | 96 | @rndstatus.command(name="type") 97 | async def _rndstatus_type(self, ctx, status_type: int): 98 | """Define the rndstatus game type. 99 | 100 | Type list: 101 | 0 = Playing 102 | 1 = Streaming 103 | 2 = Listening 104 | 3 = Watching 105 | 5 = Competing""" 106 | if 0 <= status_type <= 3 or 0 != 5: 107 | rnd_type = {0: "playing", 1: "streaming", 2: "listening", 3: "watching", 5: "competing"} 108 | await self.config.type.set(status_type) 109 | await self.presence_updater() 110 | await ctx.send(f"Rndstatus activity type set to {rnd_type[status_type]}.") 111 | else: 112 | await ctx.send( 113 | f"Status activity type must be between 0 and 3 or 5. " 114 | f"See `{ctx.prefix}help rndstatus type` for more information." 115 | ) 116 | 117 | @rndstatus.command() 118 | async def status(self, ctx, status: int): 119 | """Define the rndstatus presence status. 120 | 121 | Status list: 122 | 0 = Online 123 | 1 = Idle 124 | 2 = DND 125 | 3 = Invisible""" 126 | if 0 <= status <= 3: 127 | rnd_status = {0: "online", 1: "idle", 2: "DND", 3: "invisible"} 128 | await self.config.status.set(status) 129 | await self.presence_updater() 130 | await ctx.send(f"Rndstatus presence status set to {rnd_status[status]}.") 131 | else: 132 | await ctx.send( 133 | f"Status presence type must be between 0 and 3. " 134 | f"See `{ctx.prefix}help rndstatus status` for more information." 135 | ) 136 | 137 | async def maybe_update_presence(self): 138 | await self.bot.wait_until_red_ready() 139 | delay = await self.config.delay() 140 | while True: 141 | try: 142 | await self.presence_updater() 143 | except Exception: 144 | log.exception("Something went wrong in maybe_update_presence task:") 145 | 146 | await asyncio.sleep(int(delay)) 147 | 148 | async def presence_updater(self): 149 | pattern = re.compile(rf"<@!?{self.bot.user.id}>") 150 | cog_settings = await self.config.all() 151 | guilds = self.bot.guilds 152 | try: 153 | guild = next(g for g in guilds if not g.unavailable) 154 | except StopIteration: 155 | return 156 | try: 157 | current_game = str(guild.me.activity.name) 158 | except AttributeError: 159 | current_game = None 160 | statuses = cog_settings["statuses"] 161 | botstats = cog_settings["botstats"] 162 | streamer = cog_settings["streamer"] 163 | _type = cog_settings["type"] 164 | _status = cog_settings["status"] 165 | 166 | url = f"https://www.twitch.tv/{streamer}" 167 | prefix = await self.bot.get_valid_prefixes() 168 | 169 | if _status == 0: 170 | status = discord.Status.online 171 | elif _status == 1: 172 | status = discord.Status.idle 173 | elif _status == 2: 174 | status = discord.Status.dnd 175 | elif _status == 3: 176 | status = discord.Status.offline 177 | 178 | if botstats: 179 | me = self.bot.user 180 | clean_prefix = pattern.sub(f"@{me.name}", prefix[0]) 181 | total_users = len(self.bot.users) 182 | servers = str(len(self.bot.guilds)) 183 | botstatus = f"{clean_prefix}help | {total_users} users | {servers} servers" 184 | if (current_game != str(botstatus)) or current_game is None: 185 | if _type == 1: 186 | await self.bot.change_presence(activity=discord.Streaming(name=botstatus, url=url)) 187 | else: 188 | await self.bot.change_presence(activity=discord.Activity(name=botstatus, type=_type), status=status) 189 | else: 190 | if len(statuses) > 0: 191 | new_status = self.random_status(guild, statuses) 192 | if (current_game != new_status) or (current_game is None) or (len(statuses) == 1): 193 | if _type == 1: 194 | await self.bot.change_presence(activity=discord.Streaming(name=new_status, url=url)) 195 | else: 196 | await self.bot.change_presence( 197 | activity=discord.Activity(name=new_status, type=_type), status=status 198 | ) 199 | 200 | def random_status(self, guild, statuses): 201 | try: 202 | current = str(guild.me.activity.name) 203 | except AttributeError: 204 | current = None 205 | new_statuses = [s for s in statuses if s != current] 206 | if len(new_statuses) > 1: 207 | return rndchoice(new_statuses) 208 | elif len(new_statuses) == 1: 209 | return new_statuses[0] 210 | return current 211 | -------------------------------------------------------------------------------- /seen/seen.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import contextlib 3 | import datetime 4 | from typing import Union, Literal 5 | 6 | import discord 7 | import time 8 | 9 | from redbot.core import Config, commands 10 | 11 | _SCHEMA_VERSION = 2 12 | 13 | 14 | class Seen(commands.Cog): 15 | """Shows last time a user was seen in chat.""" 16 | 17 | async def red_delete_data_for_user( 18 | self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int, 19 | ): 20 | if requester in ["discord", "owner"]: 21 | data = await self.config.all_members() 22 | for guild_id, members in data.items(): 23 | if user_id in members: 24 | await self.config.member_from_ids(guild_id, user_id).clear() 25 | 26 | def __init__(self, bot): 27 | self.bot = bot 28 | self.config = Config.get_conf(self, 2784481001, force_registration=True) 29 | 30 | default_global = dict(schema_version=1) 31 | default_member = dict(seen=None) 32 | 33 | self.config.register_global(**default_global) 34 | self.config.register_member(**default_member) 35 | 36 | self._cache = {} 37 | self._task = self.bot.loop.create_task(self._save_to_config()) 38 | 39 | async def initialize(self): 40 | asyncio.ensure_future( 41 | self._migrate_config(from_version=await self.config.schema_version(), to_version=_SCHEMA_VERSION) 42 | ) 43 | 44 | async def _migrate_config(self, from_version: int, to_version: int): 45 | if from_version == to_version: 46 | return 47 | elif from_version < to_version: 48 | all_guild_data = await self.config.all_members() 49 | users_data = {} 50 | for guild_id, guild_data in all_guild_data.items(): 51 | for user_id, user_data in guild_data.items(): 52 | for _, v in user_data.items(): 53 | if not v: 54 | v = None 55 | if user_id not in users_data: 56 | users_data[guild_id][user_id] = {"seen": v} 57 | else: 58 | if (v and not users_data[guild_id][user_id]["seen"]) or ( 59 | v 60 | and users_data[guild_id][user_id]["seen"] 61 | and v > users_data[guild_id][user_id]["seen"] 62 | ): 63 | users_data[guild_id][user_id] = {"seen": v} 64 | 65 | group = self.config._get_base_group(self.config.MEMBER) # Bulk update to new scope 66 | async with group.all() as new_data: 67 | for guild_id, member_data in users_data.items(): 68 | new_data[guild_id] = member_data 69 | 70 | # new schema is now in place 71 | await self.config.schema_version.set(_SCHEMA_VERSION) 72 | 73 | # migration done, now let's delete all the old stuff 74 | await self.config.clear_all_members() 75 | 76 | @commands.guild_only() 77 | @commands.command(name="seen") 78 | @commands.bot_has_permissions(embed_links=True) 79 | async def _seen(self, ctx, *, author: discord.Member): 80 | """Shows last time a user was seen in chat.""" 81 | member_seen_config = await self.config.member(author).seen() 82 | member_seen_cache = self._cache.get(author.guild.id, {}).get(author.id, None) 83 | 84 | if not member_seen_cache and not member_seen_config: 85 | embed = discord.Embed(colour=discord.Color.red(), title="I haven't seen that user yet.") 86 | return await ctx.send(embed=embed) 87 | 88 | if not member_seen_cache: 89 | member_seen = member_seen_config 90 | elif not member_seen_config: 91 | member_seen = member_seen_cache 92 | elif member_seen_cache > member_seen_config: 93 | member_seen = member_seen_cache 94 | elif member_seen_config > member_seen_cache: 95 | member_seen = member_seen_config 96 | else: 97 | member_seen = member_seen_cache or member_seen_config 98 | 99 | now = int(time.time()) 100 | time_elapsed = int(now - member_seen) 101 | output = self._dynamic_time(time_elapsed) 102 | 103 | if output[2] < 1: 104 | ts = "just now" 105 | else: 106 | ts = "" 107 | if output[0] == 1: 108 | ts += "{} day, ".format(output[0]) 109 | elif output[0] > 1: 110 | ts += "{} days, ".format(output[0]) 111 | if output[1] == 1: 112 | ts += "{} hour, ".format(output[1]) 113 | elif output[1] > 1: 114 | ts += "{} hours, ".format(output[1]) 115 | if output[2] == 1: 116 | ts += "{} minute ago".format(output[2]) 117 | elif output[2] > 1: 118 | ts += "{} minutes ago".format(output[2]) 119 | em = discord.Embed(colour=discord.Color.green()) 120 | avatar = author.display_avatar 121 | em.set_author(name="{} was seen {}".format(author.display_name, ts), icon_url=avatar) 122 | await ctx.send(embed=em) 123 | 124 | @staticmethod 125 | def _dynamic_time(time_elapsed): 126 | m, s = divmod(time_elapsed, 60) 127 | h, m = divmod(m, 60) 128 | d, h = divmod(h, 24) 129 | return d, h, m 130 | 131 | @commands.Cog.listener() 132 | async def on_message(self, message): 133 | if getattr(message, "guild", None): 134 | if message.guild.id not in self._cache: 135 | self._cache[message.guild.id] = {} 136 | self._cache[message.guild.id][message.author.id] = int(time.time()) 137 | 138 | @commands.Cog.listener() 139 | async def on_typing( 140 | self, channel: discord.abc.Messageable, user: Union[discord.User, discord.Member], when: datetime.datetime, 141 | ): 142 | if getattr(user, "guild", None): 143 | if user.guild.id not in self._cache: 144 | self._cache[user.guild.id] = {} 145 | self._cache[user.guild.id][user.id] = int(time.time()) 146 | 147 | @commands.Cog.listener() 148 | async def on_message_edit(self, before: discord.Message, after: discord.Message): 149 | if getattr(after, "guild", None): 150 | if after.guild.id not in self._cache: 151 | self._cache[after.guild.id] = {} 152 | self._cache[after.guild.id][after.author.id] = int(time.time()) 153 | 154 | @commands.Cog.listener() 155 | async def on_reaction_remove(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]): 156 | if getattr(user, "guild", None): 157 | if user.guild.id not in self._cache: 158 | self._cache[user.guild.id] = {} 159 | self._cache[user.guild.id][user.id] = int(time.time()) 160 | 161 | @commands.Cog.listener() 162 | async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]): 163 | if getattr(user, "guild", None): 164 | if user.guild.id not in self._cache: 165 | self._cache[user.guild.id] = {} 166 | self._cache[user.guild.id][user.id] = int(time.time()) 167 | 168 | def cog_unload(self): 169 | self.bot.loop.create_task(self._clean_up()) 170 | 171 | async def _clean_up(self): 172 | if self._task: 173 | self._task.cancel() 174 | if self._cache: 175 | group = self.config._get_base_group(self.config.MEMBER) # Bulk update to config 176 | async with group.all() as new_data: 177 | for guild_id, member_data in self._cache.items(): 178 | if str(guild_id) not in new_data: 179 | new_data[str(guild_id)] = {} 180 | for member_id, seen in member_data.items(): 181 | new_data[str(guild_id)][str(member_id)] = {"seen": seen} 182 | 183 | async def _save_to_config(self): 184 | await self.bot.wait_until_ready() 185 | with contextlib.suppress(asyncio.CancelledError): 186 | while True: 187 | users_data = self._cache.copy() 188 | self._cache = {} 189 | group = self.config._get_base_group(self.config.MEMBER) # Bulk update to config 190 | async with group.all() as new_data: 191 | for guild_id, member_data in users_data.items(): 192 | if str(guild_id) not in new_data: 193 | new_data[str(guild_id)] = {} 194 | for member_id, seen in member_data.items(): 195 | new_data[str(guild_id)][str(member_id)] = {"seen": seen} 196 | 197 | await asyncio.sleep(60) 198 | -------------------------------------------------------------------------------- /rss_guide.md: -------------------------------------------------------------------------------- 1 | # RSS Guide 2 | 3 | The placeholder for your bot's prefix is `[p]` in this guide. 4 | 5 | All commands are under the main command group of `[p]rss`. 6 | 7 | ### Add a new feed 8 | 9 | If you already have a rss feed url to use, great! If you need to find an RSS feed from a website url, use `[p]rss find` with the url. 10 | 11 | ![rss find](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/9f035b36-72fc-4dc2-a191-02be57b61690) 12 | 13 | **Q:** I added my feed but I got something that says "Bozo feed" in response. 14 | **A:** Make sure the link is a real RSS feed, in an Atom or XML standard RSS feed format. The `feedparser` library that RSS uses can only read and use RSS feeds that conform to standards. Sometimes when adding a new feed, your bot may be blocked by Cloudflare from navigating to the RSS feed and receive a bozo response - there is no handling in the cog to be able to follow or solve Cloudflare captchas or gates. 15 | 16 | **Q:** I used `[p]rss find` on a website link but it told me "No RSS feeds found in the link provided". I think they do have an RSS feed, what's wrong? 17 | **A:** The rss find command searches the page for any links in the website's HTML where the RSS feed is properly called out, to Atom 1.0 or RSS 2.0 specifications. If the command can't find the feed, maybe approach the site owners to ask for their feeds to identify the feed as application/rss+xml, application/atom+xml, text/xml, or application/rdf+xml for the `link` tag for the feed. 18 | 19 | Add the RSS feed, giving it a name you can refer to later. I'm using `test` in this example. 20 | 21 | ![rss feed name](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/622dccdc-0d69-4e51-99f2-61f269bd1218) 22 | 23 | ### Set a feed post template 24 | 25 | Use `[p]rss viewtags` with the feed name to view a small content preview of all template tags on the most recent post. 26 | 27 | ![viewtags](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/c234d354-a060-4119-8bcc-3e838c23faff) 28 | 29 | The default template of every feed is a simple embed with the feed entry title, the url if present, and the date of the entry. 30 | 31 | ![default template](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/5983a227-f4af-4468-8aed-cccc1816d026) 32 | 33 | If you wish to have a simple link post with a title where Discord can auto-unfurl the url, if possible, you only need to toggle off the embed display with `[p]rss embed toggle` and the feed name. 34 | 35 | ![simple link post](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/0112ebe4-700a-46a6-9c06-add79d7db01e) 36 | 37 | Preview your changes at any time with `[p]rss force`. 38 | 39 | ![rss force](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/b7af1f68-d177-4aa5-865f-45d5a4f48aaa) 40 | 41 | For the rest of these examples and explaination, I have toggled the test feed back to using an embed, with `[p]rss embed toggle`. 42 | Now let's explore the feed tags present on the feed, so we can modify the way the feed posts look. 43 | 44 | Use `[p]rss listtags` to display the feed tags. 45 | 46 | ![rss listtags](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/88793962-7efc-4c0d-947b-996b3d539d44) 47 | 48 | Templates can include any of these placeholder values, which are prefixed with `$`. Not all feeds will have all of these placeholder values shown - they can vary from feed to feed and even from post to post on the same feed. Templates can also use the same Markdown formatting that you can use natively in Discord messages, like `\n` for a line return, `**` for bold, etc. 49 | 50 | Any tag with `plaintext` in its name is usually a cleaned or unpacked version of the parent tag it was generated from. For example, in our `test` feed here, there is a `$summary_detail` html-containing tag that also has a `$summary_detail_plaintext` version. If we use the summary detail in our template, we will want to choose the plaintext version. 51 | 52 | But what's in that `$summary_detail_plaintext` tag? Let's see... 53 | 54 | ![template preview](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/c102bc76-cd1e-4ae5-9365-ab41dfe310e6) 55 | 56 | Well, this looks good to me on the information I want on these feed posts - let's add the title and the link back. 57 | 58 | I used `[p]rss template test **$title**\n$link\n\n$summary_detail_plaintext` here, giving a bold title with line returns for the link and the summary. 59 | 60 | ![template preview 2](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/6d763c71-dc26-4366-abea-f168e330767c) 61 | 62 | Unfortunately, there are no images to use in these template tags on this test as there are no `$content_image` tags listed in `[p]rss listtags` nor does `$media_content` or the plaintext version of that tag have any image urls. If the post display needed an image preview, we would need to set this feed to display with the embed off, and with a link included, so that Discord can display it if the site is supported. 63 | 64 | ##### Media template tags 65 | The `$media_content` tag, `$media_url` tag, or any other `$media_`-prefixed tag usually holds non-image content. This can include and is not limited to video links and audio links. Rarely, there will be an image url in `$media_content` or `$media_url` tags, but it should not be the case if the feed or site owner is tagging the feed elements or html elements properly. 66 | 67 | ##### Image template tags 68 | Image tags are usually named `$content_image01` or similar, where every image url found in the feed post adds 1 to the name value. For example the second image url gathered from the feed post would be named `$content_image02` if it was present in `[p]rss listtags`. Rarely, an image url might be found under the `$links_plaintext` tag, if present. 69 | 70 | Let's make another test feed that has an image to use. 71 | 72 | ![test 2 listtags](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/0d2f7e34-58de-4e5f-b6d6-d10b1202ef7b) 73 | 74 | There is a `$media_thumbnail_plaintext` and a `$links_plaintext01` here. Let's see what they contain. 75 | 76 | ![test 2 exploring tag content](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/42dd4e99-c7c8-4b17-a361-6589d420cad2) 77 | 78 | Looks like an image url for the first and a link to the commit on the feed. Let's give it a real template and set the `$media_thumbnail_plaintext` tag as the embed image. 79 | 80 | I used `[p]rss template aikaterna-cogs aikaterna-cogs\nAuthor: $author\n[$content_plaintext01]($link)`. Note the combination of a template tag with a link - you can use `[text](url)` formatting in an embed with text-containing template tags. 81 | 82 | Let's set the embed thumbnail with the `[p]rss embed thumbnail` command. You can also use `[p]rss embed image` to set the large embed image to a tag instead of the embed thumbnail. 83 | 84 | ![set embed thumbnail](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/0fd537a5-fcd7-44d6-8432-c065bcf03b48) 85 | 86 | You can set different image template tags to be the main embed image and the embed thumbnail. 87 | 88 | ![set embed images](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/17e3e836-e1cd-4ba4-a28b-1db65a3340c8) 89 | 90 | And that's it! You only need to add your feed and customize it with your template and RSS will check the feed every 5 minutes or so. The hardest part is creating a template you like. 91 | 92 | ### Double feed posts 93 | 94 | RSS qualifies a new post based on the published time and updated time of the feed. Some sites update the updated time of the feed without updating content. This makes the RSS cog "double post" or even continuously post the same post. Use `[p]rss parse` to be able to configure the RSS cog to use the published_parsed feed tag, and stop multi-posting. 95 | 96 | ![rss parse](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/e0b7b123-428b-4c38-8c3b-997bd40dbebc) 97 | 98 | ### Post filtering by tag 99 | 100 | Sometimes, when a feed is set up to RSS standards and specifications, tags are included per feed post that provide content filtering. You can check if your feed includes these tags by using `[p]rss listtags` and seeing if there are `$tags` or similar. 101 | 102 | ![tags list](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/c4a07812-5801-4970-a9dd-170de96ceb01) 103 | 104 | This feed has tags! Let's see what some examples of their tags may be. Alternatively, maybe the site lists their RSS post tags elsewhere as a post tag preview may not include all tags in use on the site. 105 | 106 | ![tag list](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/ffe335cf-09e5-4f5d-8585-167ebe0845e5) 107 | 108 | Tag filtering is only an allow list. These tag names are case insensitive, you cannot have differing tags like `True Crime` vs. `true crime` - they will be processed the same way. 109 | 110 | ![tag filter added](https://github.com/aikaterna/aikaterna-cogs/assets/20862007/efde1fa9-21c4-4228-a643-0a8d966bb23d) 111 | 112 | ### Non-essential noteable features 113 | 114 | There are other features of RSS to explore like: 115 | 116 | `[p]rss embed color` - changing the embed color bar if you have an embedded post template 117 | `[p]rss limit` - limit the amount of characters used per post, but note that this is for the whole template, all template tags combined 118 | `[p]rss list` or `[p]rss listall` - list rss feeds in the current channel or all feeds on the Discord server 119 | `[p]rss showtemplate` - in case you forgot what you used for a feed's template and you would like to use it elsewhere 120 | -------------------------------------------------------------------------------- /timezone/timezone.py: -------------------------------------------------------------------------------- 1 | import discord 2 | import pytz 3 | from datetime import datetime 4 | from fuzzywuzzy import fuzz, process 5 | from typing import Optional, Literal 6 | from redbot.core import Config, commands 7 | from redbot.core.utils.chat_formatting import pagify 8 | from redbot.core.utils.menus import close_menu, menu, DEFAULT_CONTROLS 9 | 10 | 11 | __version__ = "2.1.1" 12 | 13 | 14 | class Timezone(commands.Cog): 15 | """Gets times across the world...""" 16 | def __init__(self, bot): 17 | self.bot = bot 18 | self.config = Config.get_conf(self, 278049241001, force_registration=True) 19 | default_user = {"usertime": None} 20 | self.config.register_user(**default_user) 21 | 22 | async def red_delete_data_for_user( 23 | self, *, requester: Literal["discord", "owner", "user", "user_strict"], user_id: int, 24 | ): 25 | await self.config.user_from_id(user_id).clear() 26 | 27 | async def get_usertime(self, user: discord.User): 28 | tz = None 29 | usertime = await self.config.user(user).usertime() 30 | if usertime: 31 | tz = pytz.timezone(usertime) 32 | 33 | return usertime, tz 34 | 35 | def fuzzy_timezone_search(self, tz: str): 36 | fuzzy_results = process.extract(tz.replace(" ", "_"), pytz.common_timezones, limit=500, scorer=fuzz.partial_ratio) 37 | matches = [x for x in fuzzy_results if x[1] > 98] 38 | return matches 39 | 40 | async def format_results(self, ctx, tz): 41 | if not tz: 42 | await ctx.send( 43 | "Error: Incorrect format or no matching timezones found.\n" 44 | "Use: **Continent/City** with correct capitals or a partial timezone name to search. " 45 | "e.g. `America/New_York` or `New York`\nSee the full list of supported timezones here:\n" 46 | "" 47 | ) 48 | return None 49 | elif len(tz) == 1: 50 | # command specific response, so don't do anything here 51 | return tz 52 | else: 53 | msg = "" 54 | for timezone in tz: 55 | msg += f"{timezone[0]}\n" 56 | 57 | embed_list = [] 58 | for page in pagify(msg, delims=["\n"], page_length=500): 59 | e = discord.Embed(title=f"{len(tz)} results, please be more specific.", description=page) 60 | e.set_footer(text="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones") 61 | embed_list.append(e) 62 | if len(embed_list) == 1: 63 | close_control = {"\N{CROSS MARK}": close_menu} 64 | await menu(ctx, embed_list, close_control) 65 | else: 66 | await menu(ctx, embed_list, DEFAULT_CONTROLS) 67 | return None 68 | 69 | @commands.guild_only() 70 | @commands.group() 71 | async def time(self, ctx): 72 | """ 73 | Checks the time. 74 | 75 | For the list of supported timezones, see here: 76 | https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 77 | """ 78 | pass 79 | 80 | @time.command() 81 | async def tz(self, ctx, *, timezone_name: Optional[str] = None): 82 | """Gets the time in any timezone.""" 83 | if timezone_name is None: 84 | time = datetime.now() 85 | fmt = "**%H:%M** %d-%B-%Y" 86 | await ctx.send(f"Current system time: {time.strftime(fmt)}") 87 | else: 88 | tz_results = self.fuzzy_timezone_search(timezone_name) 89 | tz_resp = await self.format_results(ctx, tz_results) 90 | if tz_resp: 91 | time = datetime.now(pytz.timezone(tz_resp[0][0])) 92 | fmt = "**%H:%M** %d-%B-%Y **%Z (UTC %z)**" 93 | await ctx.send(time.strftime(fmt)) 94 | 95 | @time.command() 96 | async def iso(self, ctx, *, iso_code=None): 97 | """Looks up ISO3166 country codes and gives you a supported timezone.""" 98 | if iso_code is None: 99 | await ctx.send("That doesn't look like a country code!") 100 | else: 101 | exist = True if iso_code.upper() in pytz.country_timezones else False 102 | if exist is True: 103 | tz = str(pytz.country_timezones(iso_code.upper())) 104 | msg = ( 105 | f"Supported timezones for **{iso_code.upper()}:**\n{tz[:-1][1:]}" 106 | f"\n**Use** `{ctx.prefix}time tz Continent/City` **to display the current time in that timezone.**" 107 | ) 108 | await ctx.send(msg) 109 | else: 110 | await ctx.send( 111 | "That code isn't supported.\nFor a full list, see here: " 112 | "\n" 113 | "Use the two-character code under the `Alpha-2 code` column." 114 | ) 115 | 116 | @time.command() 117 | async def me(self, ctx, *, timezone_name=None): 118 | """ 119 | Sets your timezone. 120 | Usage: [p]time me Continent/City 121 | Using the command with no timezone will show your current timezone, if any. 122 | """ 123 | if timezone_name is None: 124 | usertime, timezone_name = await self.get_usertime(ctx.author) 125 | if not usertime: 126 | await ctx.send( 127 | f"You haven't set your timezone. Do `{ctx.prefix}time me Continent/City`: " 128 | "see " 129 | ) 130 | else: 131 | time = datetime.now(timezone_name) 132 | time = time.strftime("**%H:%M** %d-%B-%Y **%Z (UTC %z)**") 133 | msg = f"Your current timezone is **{usertime}.**\n" f"The current time is: {time}" 134 | await ctx.send(msg) 135 | else: 136 | tz_results = self.fuzzy_timezone_search(timezone_name) 137 | tz_resp = await self.format_results(ctx, tz_results) 138 | if tz_resp: 139 | await self.config.user(ctx.author).usertime.set(tz_resp[0][0]) 140 | await ctx.send(f"Successfully set your timezone to **{tz_resp[0][0]}**.") 141 | 142 | @time.command() 143 | @commands.is_owner() 144 | async def set(self, ctx, user: discord.User, *, timezone_name=None): 145 | """ 146 | Allows the bot owner to edit users' timezones. 147 | Use a user id for the user if they are not present in your server. 148 | """ 149 | if not user: 150 | user = ctx.author 151 | if len(self.bot.users) == 1: 152 | return await ctx.send("This cog requires Discord's Privileged Gateway Intents to function properly.") 153 | if user not in self.bot.users: 154 | return await ctx.send("I can't see that person anywhere.") 155 | if timezone_name is None: 156 | return await ctx.send_help() 157 | else: 158 | tz_results = self.fuzzy_timezone_search(timezone_name) 159 | tz_resp = await self.format_results(ctx, tz_results) 160 | if tz_resp: 161 | await self.config.user(user).usertime.set(tz_resp[0][0]) 162 | await ctx.send(f"Successfully set {user.name}'s timezone to **{tz_resp[0][0]}**.") 163 | 164 | @time.command() 165 | async def user(self, ctx, user: discord.Member = None): 166 | """Shows the current time for the specified user.""" 167 | if not user: 168 | await ctx.send("That isn't a user!") 169 | else: 170 | usertime, tz = await self.get_usertime(user) 171 | if usertime: 172 | time = datetime.now(tz) 173 | fmt = "**%H:%M** %d-%B-%Y **%Z (UTC %z)**" 174 | time = time.strftime(fmt) 175 | await ctx.send( 176 | f"{user.name}'s current timezone is: **{usertime}**\n" f"The current time is: {str(time)}" 177 | ) 178 | else: 179 | await ctx.send("That user hasn't set their timezone.") 180 | 181 | @time.command() 182 | async def compare(self, ctx, user: discord.Member = None): 183 | """Compare your saved timezone with another user's timezone.""" 184 | if not user: 185 | return await ctx.send_help() 186 | 187 | usertime, user_tz = await self.get_usertime(ctx.author) 188 | othertime, other_tz = await self.get_usertime(user) 189 | 190 | if not usertime: 191 | return await ctx.send( 192 | f"You haven't set your timezone. Do `{ctx.prefix}time me Continent/City`: " 193 | "see " 194 | ) 195 | if not othertime: 196 | return await ctx.send(f"That user's timezone isn't set yet.") 197 | 198 | user_now = datetime.now(user_tz) 199 | user_diff = user_now.utcoffset().total_seconds() / 60 / 60 200 | other_now = datetime.now(other_tz) 201 | other_diff = other_now.utcoffset().total_seconds() / 60 / 60 202 | time_diff = abs(user_diff - other_diff) 203 | time_diff_text = f"{time_diff:g}" 204 | fmt = "**%H:%M %Z (UTC %z)**" 205 | other_time = other_now.strftime(fmt) 206 | plural = "" if time_diff_text == "1" else "s" 207 | time_amt = "the same time zone as you" if time_diff_text == "0" else f"{time_diff_text} hour{plural}" 208 | position = "ahead of" if user_diff < other_diff else "behind" 209 | position_text = "" if time_diff_text == "0" else f" {position} you" 210 | 211 | await ctx.send(f"{user.display_name}'s time is {other_time} which is {time_amt}{position_text}.") 212 | 213 | @time.command() 214 | async def version(self, ctx): 215 | """Show the cog version.""" 216 | await ctx.send(f"Timezone version: {__version__}.") 217 | -------------------------------------------------------------------------------- /voicelogs/voicelogs.py: -------------------------------------------------------------------------------- 1 | # This cog was originally by ZeLarpMaster for Red v2, and can be found at: 2 | # https://github.com/ZeLarpMaster/ZeCogs/blob/master/voice_logs/voice_logs.py 3 | 4 | import asyncio 5 | import contextlib 6 | import discord 7 | import logging 8 | 9 | from datetime import date, datetime, timedelta, timezone 10 | from types import SimpleNamespace 11 | from typing import Literal, Union 12 | 13 | from redbot.core import checks, commands, Config 14 | from redbot.core.utils.chat_formatting import bold 15 | 16 | 17 | log = logging.getLogger("red.aikaterna.voicelogs") 18 | 19 | 20 | class VoiceLogs(commands.Cog): 21 | """Logs information about voice channel connection times.""" 22 | 23 | __author__ = ["ZeLarpMaster#0818", "aikaterna"] 24 | __version__ = "0.1.1" 25 | 26 | TIME_FORMATS = ["{} seconds", "{} minutes", "{} hours", "{} days", "{} weeks"] 27 | TIME_FRACTIONS = [60, 60, 24, 7] 28 | ENTRY_TIME_LIMIT = timedelta(weeks=1) 29 | CLEANUP_DELAY = timedelta(days=1).total_seconds() 30 | 31 | async def red_delete_data_for_user( 32 | self, 33 | *, 34 | requester: Literal["discord", "owner", "user", "user_strict"], 35 | user_id: int, 36 | ): 37 | await self.config.user_from_id(user_id).clear() 38 | 39 | def __init__(self, bot): 40 | self.bot = bot 41 | self.config = Config.get_conf(self, 2708181003, force_registration=True) 42 | 43 | default_guild = {"toggle": False} 44 | default_user = {"history": []} 45 | 46 | # history is a list of dict entries 47 | # {"channel_id": int, 48 | # "channel_name": str, 49 | # "joined_at": datetime, 50 | # "left_at": datetime} 51 | 52 | self.config.register_guild(**default_guild) 53 | self.config.register_user(**default_user) 54 | 55 | asyncio.ensure_future(self.cleanup_loop()) 56 | 57 | # Commands 58 | @commands.group(name="voicelog", aliases=["voicelogs"]) 59 | async def _command_voicelog(self, ctx): 60 | """ 61 | Access voice activity data. 62 | 63 | You must have the bot Mod or Admin role or View Audit Log permissions to view and use the commands. 64 | """ 65 | pass 66 | 67 | @_command_voicelog.command(name="user", aliases=["u"]) 68 | @checks.mod_or_permissions(view_audit_log=True) 69 | async def _command_voicelog_user(self, ctx: commands.Context, *, user: discord.Member): 70 | """ 71 | Look up the voice activity of a user. 72 | 73 | Timestamps are in UTC. 74 | """ 75 | entries = await self.config.user(user).history() 76 | embed = discord.Embed(description=f"**Voice Activity for** {user.mention}") 77 | for entry in self.process_entries(entries, limit=25): 78 | joined_at = self.format_time(entry["joined_at"]) 79 | left_at = entry.get("left_at") 80 | left_at = self.format_time(left_at) if left_at is not None else "now" 81 | embed.add_field( 82 | name=f"#{entry['channel_name']} ({entry['channel_id']})", 83 | value=f"**{joined_at}** until **{left_at}**", 84 | inline=False, 85 | ) 86 | if len(embed.fields) == 0: 87 | embed.description = f"No voice activity for {user.mention}" 88 | await ctx.channel.send(embed=embed) 89 | 90 | @_command_voicelog.command(name="channel", aliases=["c"]) 91 | @checks.mod_or_permissions(view_audit_log=True) 92 | async def _command_voicelog_channel(self, ctx: commands.Context, *, voice_channel_name_or_id: discord.VoiceChannel): 93 | """ 94 | Look up the voice activity on a voice channel. 95 | 96 | `voice_channel_name_or_id` is either the exact name of the target voice channel (proper case), or its ID. 97 | """ 98 | entries = [] 99 | all_entries = await self.config.all_users() 100 | 101 | for user_id, user_entries in all_entries.items(): 102 | for history_key, entry_list in user_entries.items(): 103 | for entry in entry_list: 104 | if entry["channel_id"] == voice_channel_name_or_id.id: 105 | entry["user_id"] = user_id 106 | entries.append(entry) 107 | 108 | embed = discord.Embed(title=f"Voice Activity in {voice_channel_name_or_id.name}", description="") 109 | for entry in self.process_entries(entries, limit=25): 110 | time_spent = "" 111 | left_at = entry.get("left_at", None) 112 | if left_at is None: 113 | time_spent = "+" 114 | left_at = datetime.now(timezone.utc) 115 | time_diff = left_at - entry["joined_at"] 116 | time_spent = self.humanize_time(round(time_diff.total_seconds())) + time_spent 117 | user_obj = ctx.guild.get_member(entry["user_id"]) 118 | if not user_obj: 119 | user_obj = SimpleNamespace(name="Unknown User", id=entry["user_id"]) 120 | embed.description += f"**{user_obj.name}** ({user_obj.id}) for **{time_spent}**\n" 121 | if len(embed.description) == 0: 122 | embed.description = f"No voice activity in {voice_channel_name_or_id.mention}" 123 | await ctx.send(embed=embed) 124 | 125 | @_command_voicelog.command(name="toggle") 126 | @checks.mod_or_permissions(view_audit_log=True) 127 | async def _command_voicelog_toggle(self, ctx: commands.Context): 128 | """Toggle voice activity recording on and off.""" 129 | toggle = await self.config.guild(ctx.guild).toggle() 130 | await self.config.guild(ctx.guild).toggle.set(not toggle) 131 | await ctx.send(f"Voice channel watching is now toggled {bold('ON') if toggle == False else bold('OFF')}") 132 | 133 | # Events 134 | @commands.Cog.listener() 135 | async def on_voice_state_update( 136 | self, member: discord.Member, before: discord.VoiceState, after: discord.VoiceState 137 | ): 138 | if before.channel == after.channel: 139 | return 140 | 141 | toggle = await self.config.guild(member.guild).toggle() 142 | if not toggle: 143 | return 144 | 145 | try: 146 | # Left that channel 147 | if before.channel is not None: 148 | async with self.config.user(member).history() as user_data: 149 | entry = discord.utils.find( 150 | lambda e: e["channel_id"] == before.channel.id and "left_at" not in e, user_data 151 | ) 152 | if entry is not None: 153 | entry["left_at"] = datetime.now(timezone.utc).timestamp() 154 | 155 | # Joined that channel 156 | if after.channel is not None: 157 | async with self.config.user(member).history() as user_info: 158 | entry = { 159 | "channel_id": after.channel.id, 160 | "channel_name": after.channel.name, 161 | "joined_at": datetime.now(timezone.utc).timestamp(), 162 | } 163 | user_info.append(entry) 164 | 165 | except Exception as e: 166 | log.error(f"Error in on_voice_state_update:\n{e}", exc_info=True) 167 | 168 | async def cleanup_loop(self): 169 | await self.bot.wait_until_red_ready() 170 | 171 | # Suppress the "Event loop is closed" error 172 | with contextlib.suppress(RuntimeError, asyncio.CancelledError): 173 | while True: 174 | try: 175 | await self.cleanup_entries() 176 | await asyncio.sleep(self.CLEANUP_DELAY) 177 | except Exception as e: 178 | log.error(f"Error in cleanup_loop:\n{e}", exc_info=True) 179 | 180 | async def cleanup_entries(self): 181 | try: 182 | delete_threshold = datetime.now(timezone.utc) - self.ENTRY_TIME_LIMIT 183 | to_delete = {"history": []} 184 | user_data = await self.config.all_users() 185 | for user_id, history in user_data.items(): 186 | for dict_title, entry_list in history.items(): 187 | for entry in entry_list: 188 | left_at = entry.get("left_at", None) 189 | if left_at is not None and datetime.fromtimestamp(left_at, timezone.utc) < delete_threshold: 190 | entry_list_index = [i for i, d in enumerate(entry_list) if left_at in d.values()] 191 | entry_list.pop(entry_list_index[0]) 192 | await self.config.user_from_id(user_id).history.set(entry_list) 193 | except Exception as e: 194 | log.error(f"Error in cleanup_entries:\n{e}", exc_info=True) 195 | 196 | def process_entries(self, entries, *, limit=None): 197 | return sorted(self.map_entries(entries), key=lambda o: o["joined_at"], reverse=True)[:limit] 198 | 199 | def map_entries(self, entries): 200 | for entry in entries: 201 | new_entry = entry.copy() 202 | joined_at = datetime.fromtimestamp(entry["joined_at"], timezone.utc) 203 | new_entry["joined_at"] = joined_at 204 | left_at = entry.get("left_at") 205 | if left_at is not None: 206 | new_entry["left_at"] = datetime.fromtimestamp(left_at, timezone.utc) 207 | yield new_entry 208 | 209 | def format_time(self, moment: datetime): 210 | if date.today() == moment.date(): 211 | return "today " + moment.strftime("%X") 212 | else: 213 | return moment.strftime("%c") 214 | 215 | def humanize_time(self, time: int) -> str: 216 | """ 217 | Returns a string of the humanized given time keeping only the 2 biggest formats. 218 | 219 | Examples: 220 | 1661410 --> 2 weeks 5 days (hours, mins, seconds are ignored) 221 | 30 --> 30 seconds 222 | """ 223 | times = [] 224 | # 90 --> divmod(90, 60) --> (1, 30) --> (1m + 30s) 225 | for time_f in zip(self.TIME_FRACTIONS, self.TIME_FORMATS): 226 | time, units = divmod(time, time_f[0]) 227 | if units > 0: 228 | times.append(self.plural_format(units, time_f[1])) 229 | if time > 0: 230 | times.append(self.plural_format(time, self.TIME_FORMATS[-1])) 231 | return " ".join(reversed(times[-2:])) 232 | 233 | def plural_format(self, raw_amount: Union[int, float], format_string: str, *, singular_format: str = None) -> str: 234 | """ 235 | Formats a string for plural and singular forms of an amount. 236 | 237 | The amount given is rounded. 238 | `raw_amount` is an integer (rounded if something else is given) 239 | `format_string` is the string to use when formatting in plural 240 | `singular_format` is the string to use for singular 241 | 242 | By default uses the plural and removes the last character. 243 | """ 244 | amount = round(raw_amount) 245 | result = format_string.format(raw_amount) 246 | if singular_format is None: 247 | result = format_string.format(raw_amount)[: -1 if amount == 1 else None] 248 | elif amount == 1: 249 | result = singular_format.format(raw_amount) 250 | return result 251 | -------------------------------------------------------------------------------- /ttt/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /reminder/reminder.py: -------------------------------------------------------------------------------- 1 | # Reminder was originally written by ZeLarpMaster#0818 2 | # https://github.com/ZeLarpMaster/ZeCogsV3/blob/master/reminder/reminder.py 3 | 4 | import asyncio 5 | import collections 6 | import datetime 7 | import discord 8 | import hashlib 9 | from itertools import islice 10 | from math import ceil 11 | import re 12 | from typing import List, Literal, Optional 13 | 14 | from redbot.core import commands, Config 15 | from redbot.core.bot import Red 16 | from redbot.core.commands import Context 17 | from redbot.core.utils.menus import menu, DEFAULT_CONTROLS 18 | 19 | 20 | class Reminder(commands.Cog): 21 | """Utilities to remind yourself of whatever you want""" 22 | 23 | __author__ = ["ZeLarpMaster#0818", "aikaterna#1393"] 24 | 25 | TIME_AMNT_REGEX = re.compile("([1-9][0-9]*)([a-z]+)", re.IGNORECASE) 26 | TIME_QUANTITIES = collections.OrderedDict( 27 | [ 28 | ("seconds", 1), 29 | ("minutes", 60), 30 | ("hours", 3600), 31 | ("days", 86400), 32 | ("weeks", 604800), 33 | ("months", 2628000), 34 | ("years", 31540000), 35 | ] 36 | ) 37 | MAX_SECONDS = TIME_QUANTITIES["years"] * 2 38 | 39 | async def red_delete_data_for_user( 40 | self, 41 | *, 42 | requester: Literal["discord", "owner", "user", "user_strict"], 43 | user_id: int, 44 | ): 45 | await self.config.user_from_id(user_id).clear() 46 | 47 | def __init__(self, bot: Red): 48 | super().__init__() 49 | self.bot = bot 50 | unique_id = int(hashlib.sha512((self.__author__[0] + "@" + self.__class__.__name__).encode()).hexdigest(), 16) 51 | self.config = Config.get_conf(self, identifier=unique_id, force_registration=True) 52 | self.config.register_user(reminders=[], offset=0) 53 | self.futures = {} 54 | asyncio.ensure_future(self.start_saved_reminders()) 55 | 56 | def cog_unload(self): 57 | for user_futures in self.futures.values(): 58 | for future in user_futures: 59 | future.cancel() 60 | 61 | @commands.group(invoke_without_command=True, aliases=["remindme"], name="remind") 62 | async def command_remind(self, ctx: Context, time: str, *, reminder_text: str): 63 | """ 64 | Remind yourself of something in a specific amount of time 65 | 66 | Examples for time: `5d`, `10m`, `10m30s`, `1h`, `1y1mo2w5d10h30m15s` 67 | Abbreviations: `s` for seconds, `m` for minutes, `h` for hours, `d` for days, `w` for weeks, `mo` for months, `y` for years 68 | Any longer abbreviation is accepted. `m` assumes minutes instead of months. 69 | One month is counted as exact 365/12 days. 70 | Ignores all invalid abbreviations. 71 | """ 72 | seconds = self.get_seconds(time) 73 | if seconds is None: 74 | response = ":x: Invalid time format." 75 | elif seconds >= self.MAX_SECONDS: 76 | response = f":x: Too long amount of time. Maximum: 2 years" 77 | else: 78 | user = ctx.message.author 79 | time_now = datetime.datetime.utcnow() 80 | days, secs = divmod(seconds, 3600 * 24) 81 | end_time = time_now + datetime.timedelta(days=days, seconds=secs) 82 | reminder = {"content": reminder_text, "start_time": time_now.timestamp(), "end_time": end_time.timestamp()} 83 | async with self.config.user(user).reminders() as user_reminders: 84 | user_reminders.append(reminder) 85 | self.futures.setdefault(user.id, []).append( 86 | asyncio.ensure_future(self.remind_later(user, seconds, reminder_text, reminder)) 87 | ) 88 | user_offset = await self.config.user(ctx.author).offset() 89 | offset = user_offset * 3600 90 | formatted_time = round(end_time.timestamp()) + offset 91 | if seconds > 86400: 92 | response = f":white_check_mark: I will remind you of that on ." 93 | else: 94 | response = f":white_check_mark: I will remind you of that in {self.time_from_seconds(seconds)}." 95 | await ctx.send(response) 96 | 97 | @command_remind.group(name="forget") 98 | async def command_remind_forget(self, ctx: Context): 99 | """Forget your reminders""" 100 | pass 101 | 102 | @command_remind_forget.command(name="all") 103 | async def command_remind_forget_all(self, ctx: Context): 104 | """Forget **all** of your reminders""" 105 | for future in self.futures.get(ctx.message.author.id, []): 106 | future.cancel() 107 | async with self.config.user(ctx.message.author).reminders() as user_reminders: 108 | user_reminders.clear() 109 | await ctx.send(":put_litter_in_its_place: Forgot **all** of your reminders!") 110 | 111 | @command_remind_forget.command(name="one") 112 | async def command_remind_forget_one(self, ctx: Context, index_number_of_reminder: int): 113 | """ 114 | Forget one of your reminders 115 | 116 | Use `[p]remind list` to find the index number of the reminder you wish to forget. 117 | """ 118 | async with self.config.user(ctx.message.author).all() as user_data: 119 | if not user_data["reminders"]: 120 | await ctx.send("You don't have any reminders saved.") 121 | return 122 | time_sorted_reminders = sorted(user_data["reminders"], key=lambda x: (x["end_time"])) 123 | try: 124 | removed = time_sorted_reminders.pop(index_number_of_reminder - 1) 125 | except IndexError: 126 | await ctx.send(f"There is no reminder at index {index_number_of_reminder}.") 127 | return 128 | user_data["reminders"] = time_sorted_reminders 129 | offset = user_data["offset"] * 3600 130 | end_time = round((removed["end_time"]) + offset) 131 | msg = f":put_litter_in_its_place: Forgot reminder **#{index_number_of_reminder}**\n" 132 | msg += f"Date: \nContent: `{removed['content']}`" 133 | await ctx.send(msg) 134 | 135 | @command_remind.command(name="list") 136 | async def command_remind_list(self, ctx: Context): 137 | """List your reminders""" 138 | user_data = await self.config.user(ctx.message.author).all() 139 | if not user_data["reminders"]: 140 | await ctx.send("There are no reminders to show.") 141 | return 142 | 143 | if not ctx.channel.permissions_for(ctx.me).embed_links: 144 | return await ctx.send("I need the `Embed Messages` permission here to be able to display this information.") 145 | 146 | embed_pages = await self.create_remind_list_embeds(ctx, user_data) 147 | await ctx.send(embed=embed_pages[0]) if len(embed_pages) == 1 else await menu( 148 | ctx, embed_pages, DEFAULT_CONTROLS 149 | ) 150 | 151 | @command_remind.command(name="offset") 152 | async def command_remind_offset(self, ctx: Context, offset_time_in_hours: str): 153 | """ 154 | Set a basic timezone offset 155 | from the default of UTC for use in [p]remindme list. 156 | 157 | This command accepts number values from `-23.75` to `+23.75`. 158 | You can look up your timezone offset on https://en.wikipedia.org/wiki/List_of_UTC_offsets 159 | """ 160 | offset = self.remind_offset_check(offset_time_in_hours) 161 | if offset is not None: 162 | await self.config.user(ctx.author).offset.set(offset) 163 | await ctx.send(f"Your timezone offset was set to {str(offset).replace('.0', '')} hours from UTC.") 164 | else: 165 | await ctx.send(f"That doesn't seem like a valid hour offset. Check `{ctx.prefix}help remind offset`.") 166 | 167 | @staticmethod 168 | async def chunker(input: List[dict], chunk_size: int) -> List[List[str]]: 169 | chunk_list = [] 170 | iterator = iter(input) 171 | while chunk := list(islice(iterator, chunk_size)): 172 | chunk_list.append(chunk) 173 | return chunk_list 174 | 175 | async def create_remind_list_embeds(self, ctx: Context, user_data: dict) -> List[discord.Embed]: 176 | """Embed creator for command_remind_list.""" 177 | offset = user_data["offset"] * 3600 178 | reminder_list = [] 179 | time_sorted_reminders = sorted(user_data["reminders"], key=lambda x: (x["end_time"])) 180 | entry_size = len(str(len(time_sorted_reminders))) 181 | 182 | for i, reminder_dict in enumerate(time_sorted_reminders, 1): 183 | entry_number = f"{str(i).zfill(entry_size)}" 184 | end_time = round((reminder_dict["end_time"]) + offset) 185 | exact_time_timestamp = f"" 186 | relative_timestamp = f"" 187 | content = reminder_dict["content"] 188 | display_content = content if len(content) < 200 else f"{content[:200]} [...]" 189 | reminder = f"`{entry_number}`. {exact_time_timestamp}, {relative_timestamp}:\n{display_content}\n\n" 190 | reminder_list.append(reminder) 191 | 192 | reminder_text_chunks = await self.chunker(reminder_list, 7) 193 | max_pages = ceil(len(reminder_list) / 7) 194 | offset_hours = str(user_data["offset"]).replace(".0", "") 195 | offset_text = f" • UTC offset of {offset_hours}h applied" if offset != 0 else "" 196 | menu_pages = [] 197 | for chunk in reminder_text_chunks: 198 | embed = discord.Embed(title="", description="".join(chunk)) 199 | embed.set_author(name=f"Reminders for {ctx.author}", icon_url=ctx.author.avatar.url) 200 | embed.set_footer(text=f"Page {len(menu_pages) + 1} of {max_pages}{offset_text}") 201 | menu_pages.append(embed) 202 | return menu_pages 203 | 204 | def get_seconds(self, time: str): 205 | """Returns the amount of converted time or None if invalid""" 206 | seconds = 0 207 | for time_match in self.TIME_AMNT_REGEX.finditer(time): 208 | time_amnt = int(time_match.group(1)) 209 | time_abbrev = time_match.group(2) 210 | time_quantity = discord.utils.find(lambda t: t[0].startswith(time_abbrev), self.TIME_QUANTITIES.items()) 211 | if time_quantity is not None: 212 | seconds += time_amnt * time_quantity[1] 213 | return None if seconds == 0 else seconds 214 | 215 | async def remind_later(self, user: discord.User, time: float, content: str, reminder): 216 | """Reminds the `user` in `time` seconds with a message containing `content`""" 217 | await asyncio.sleep(time) 218 | embed = discord.Embed(title="Reminder", description=content, color=discord.Colour.blue()) 219 | await user.send(embed=embed) 220 | async with self.config.user(user).reminders() as user_reminders: 221 | user_reminders.remove(reminder) 222 | 223 | @staticmethod 224 | def remind_offset_check(offset: str) -> Optional[float]: 225 | """Float validator for command_remind_offset.""" 226 | try: 227 | offset = float(offset.replace("+", "")) 228 | except ValueError: 229 | return None 230 | offset = round(offset * 4) / 4.0 231 | if not -23.75 < offset < 23.75 or 23.75 < offset < -23.75: 232 | return None 233 | return offset 234 | 235 | async def start_saved_reminders(self): 236 | await self.bot.wait_until_red_ready() 237 | user_configs = await self.config.all_users() 238 | for user_id, user_config in list(user_configs.items()): # Making a copy 239 | for reminder in user_config["reminders"]: 240 | user = self.bot.get_user(user_id) 241 | if user is None: 242 | # Delete the reminder if the user doesn't have a mutual server anymore 243 | await self.config.user_from_id(user_id).clear() 244 | else: 245 | time_diff = datetime.datetime.fromtimestamp(reminder["end_time"]) - datetime.datetime.utcnow() 246 | time = max(0.0, time_diff.total_seconds()) 247 | fut = asyncio.ensure_future(self.remind_later(user, time, reminder["content"], reminder)) 248 | self.futures.setdefault(user.id, []).append(fut) 249 | 250 | @staticmethod 251 | def time_from_seconds(seconds: int) -> str: 252 | hours, remainder = divmod(seconds, 3600) 253 | minutes, seconds = divmod(remainder, 60) 254 | if hours: 255 | msg = f"{hours} hour" if hours == 1 else f"{hours} hours" 256 | if minutes != 0: 257 | msg += f" and {minutes} minute" if minutes == 1 else f" and {minutes} minutes" 258 | elif minutes: 259 | msg = f"{minutes} minute" if minutes == 1 else f"{minutes} minutes" 260 | if seconds != 0: 261 | msg += f" and {seconds} second" if seconds == 1 else f" and {seconds} seconds" 262 | else: 263 | msg = f"{seconds} seconds" if seconds == 1 else f"and {seconds} seconds" 264 | return msg 265 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-present aikaterna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | This product bundles methods from https://github.com/hizkifw/discord-tictactoe 25 | which are available under an Apache-2.0 license. This license only applies 26 | to the ttt.py file within the ttt directory on this repository. 27 | 28 | Apache License 29 | Version 2.0, January 2004 30 | http://www.apache.org/licenses/ 31 | 32 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 33 | 34 | 1. Definitions. 35 | 36 | "License" shall mean the terms and conditions for use, reproduction, 37 | and distribution as defined by Sections 1 through 9 of this document. 38 | 39 | "Licensor" shall mean the copyright owner or entity authorized by 40 | the copyright owner that is granting the License. 41 | 42 | "Legal Entity" shall mean the union of the acting entity and all 43 | other entities that control, are controlled by, or are under common 44 | control with that entity. For the purposes of this definition, 45 | "control" means (i) the power, direct or indirect, to cause the 46 | direction or management of such entity, whether by contract or 47 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 48 | outstanding shares, or (iii) beneficial ownership of such entity. 49 | 50 | "You" (or "Your") shall mean an individual or Legal Entity 51 | exercising permissions granted by this License. 52 | 53 | "Source" form shall mean the preferred form for making modifications, 54 | including but not limited to software source code, documentation 55 | source, and configuration files. 56 | 57 | "Object" form shall mean any form resulting from mechanical 58 | transformation or translation of a Source form, including but 59 | not limited to compiled object code, generated documentation, 60 | and conversions to other media types. 61 | 62 | "Work" shall mean the work of authorship, whether in Source or 63 | Object form, made available under the License, as indicated by a 64 | copyright notice that is included in or attached to the work 65 | (an example is provided in the Appendix below). 66 | 67 | "Derivative Works" shall mean any work, whether in Source or Object 68 | form, that is based on (or derived from) the Work and for which the 69 | editorial revisions, annotations, elaborations, or other modifications 70 | represent, as a whole, an original work of authorship. For the purposes 71 | of this License, Derivative Works shall not include works that remain 72 | separable from, or merely link (or bind by name) to the interfaces of, 73 | the Work and Derivative Works thereof. 74 | 75 | "Contribution" shall mean any work of authorship, including 76 | the original version of the Work and any modifications or additions 77 | to that Work or Derivative Works thereof, that is intentionally 78 | submitted to Licensor for inclusion in the Work by the copyright owner 79 | or by an individual or Legal Entity authorized to submit on behalf of 80 | the copyright owner. For the purposes of this definition, "submitted" 81 | means any form of electronic, verbal, or written communication sent 82 | to the Licensor or its representatives, including but not limited to 83 | communication on electronic mailing lists, source code control systems, 84 | and issue tracking systems that are managed by, or on behalf of, the 85 | Licensor for the purpose of discussing and improving the Work, but 86 | excluding communication that is conspicuously marked or otherwise 87 | designated in writing by the copyright owner as "Not a Contribution." 88 | 89 | "Contributor" shall mean Licensor and any individual or Legal Entity 90 | on behalf of whom a Contribution has been received by Licensor and 91 | subsequently incorporated within the Work. 92 | 93 | 2. Grant of Copyright License. Subject to the terms and conditions of 94 | this License, each Contributor hereby grants to You a perpetual, 95 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 96 | copyright license to reproduce, prepare Derivative Works of, 97 | publicly display, publicly perform, sublicense, and distribute the 98 | Work and such Derivative Works in Source or Object form. 99 | 100 | 3. Grant of Patent License. Subject to the terms and conditions of 101 | this License, each Contributor hereby grants to You a perpetual, 102 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 103 | (except as stated in this section) patent license to make, have made, 104 | use, offer to sell, sell, import, and otherwise transfer the Work, 105 | where such license applies only to those patent claims licensable 106 | by such Contributor that are necessarily infringed by their 107 | Contribution(s) alone or by combination of their Contribution(s) 108 | with the Work to which such Contribution(s) was submitted. If You 109 | institute patent litigation against any entity (including a 110 | cross-claim or counterclaim in a lawsuit) alleging that the Work 111 | or a Contribution incorporated within the Work constitutes direct 112 | or contributory patent infringement, then any patent licenses 113 | granted to You under this License for that Work shall terminate 114 | as of the date such litigation is filed. 115 | 116 | 4. Redistribution. You may reproduce and distribute copies of the 117 | Work or Derivative Works thereof in any medium, with or without 118 | modifications, and in Source or Object form, provided that You 119 | meet the following conditions: 120 | 121 | (a) You must give any other recipients of the Work or 122 | Derivative Works a copy of this License; and 123 | 124 | (b) You must cause any modified files to carry prominent notices 125 | stating that You changed the files; and 126 | 127 | (c) You must retain, in the Source form of any Derivative Works 128 | that You distribute, all copyright, patent, trademark, and 129 | attribution notices from the Source form of the Work, 130 | excluding those notices that do not pertain to any part of 131 | the Derivative Works; and 132 | 133 | (d) If the Work includes a "NOTICE" text file as part of its 134 | distribution, then any Derivative Works that You distribute must 135 | include a readable copy of the attribution notices contained 136 | within such NOTICE file, excluding those notices that do not 137 | pertain to any part of the Derivative Works, in at least one 138 | of the following places: within a NOTICE text file distributed 139 | as part of the Derivative Works; within the Source form or 140 | documentation, if provided along with the Derivative Works; or, 141 | within a display generated by the Derivative Works, if and 142 | wherever such third-party notices normally appear. The contents 143 | of the NOTICE file are for informational purposes only and 144 | do not modify the License. You may add Your own attribution 145 | notices within Derivative Works that You distribute, alongside 146 | or as an addendum to the NOTICE text from the Work, provided 147 | that such additional attribution notices cannot be construed 148 | as modifying the License. 149 | 150 | You may add Your own copyright statement to Your modifications and 151 | may provide additional or different license terms and conditions 152 | for use, reproduction, or distribution of Your modifications, or 153 | for any such Derivative Works as a whole, provided Your use, 154 | reproduction, and distribution of the Work otherwise complies with 155 | the conditions stated in this License. 156 | 157 | 5. Submission of Contributions. Unless You explicitly state otherwise, 158 | any Contribution intentionally submitted for inclusion in the Work 159 | by You to the Licensor shall be under the terms and conditions of 160 | this License, without any additional terms or conditions. 161 | Notwithstanding the above, nothing herein shall supersede or modify 162 | the terms of any separate license agreement you may have executed 163 | with Licensor regarding such Contributions. 164 | 165 | 6. Trademarks. This License does not grant permission to use the trade 166 | names, trademarks, service marks, or product names of the Licensor, 167 | except as required for reasonable and customary use in describing the 168 | origin of the Work and reproducing the content of the NOTICE file. 169 | 170 | 7. Disclaimer of Warranty. Unless required by applicable law or 171 | agreed to in writing, Licensor provides the Work (and each 172 | Contributor provides its Contributions) on an "AS IS" BASIS, 173 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 174 | implied, including, without limitation, any warranties or conditions 175 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 176 | PARTICULAR PURPOSE. You are solely responsible for determining the 177 | appropriateness of using or redistributing the Work and assume any 178 | risks associated with Your exercise of permissions under this License. 179 | 180 | 8. Limitation of Liability. In no event and under no legal theory, 181 | whether in tort (including negligence), contract, or otherwise, 182 | unless required by applicable law (such as deliberate and grossly 183 | negligent acts) or agreed to in writing, shall any Contributor be 184 | liable to You for damages, including any direct, indirect, special, 185 | incidental, or consequential damages of any character arising as a 186 | result of this License or out of the use or inability to use the 187 | Work (including but not limited to damages for loss of goodwill, 188 | work stoppage, computer failure or malfunction, or any and all 189 | other commercial damages or losses), even if such Contributor 190 | has been advised of the possibility of such damages. 191 | 192 | 9. Accepting Warranty or Additional Liability. While redistributing 193 | the Work or Derivative Works thereof, You may choose to offer, 194 | and charge a fee for, acceptance of support, warranty, indemnity, 195 | or other liability obligations and/or rights consistent with this 196 | License. However, in accepting such obligations, You may act only 197 | on Your own behalf and on Your sole responsibility, not on behalf 198 | of any other Contributor, and only if You agree to indemnify, 199 | defend, and hold each Contributor harmless for any liability 200 | incurred by, or claims asserted against, such Contributor by reason 201 | of your accepting any such warranty or additional liability. 202 | 203 | END OF TERMS AND CONDITIONS 204 | 205 | APPENDIX: How to apply the Apache License to your work. 206 | 207 | To apply the Apache License to your work, attach the following 208 | boilerplate notice, with the fields enclosed by brackets "{}" 209 | replaced with your own identifying information. (Don't include 210 | the brackets!) The text should be enclosed in the appropriate 211 | comment syntax for the file format. We also recommend that a 212 | file or class name and description of purpose be included on the 213 | same "printed page" as the copyright notice for easier 214 | identification within third-party archives. 215 | 216 | Copyright 2017-present hizkifw 217 | 218 | Licensed under the Apache License, Version 2.0 (the "License"); 219 | you may not use this file except in compliance with the License. 220 | You may obtain a copy of the License at 221 | 222 | http://www.apache.org/licenses/LICENSE-2.0 223 | 224 | Unless required by applicable law or agreed to in writing, software 225 | distributed under the License is distributed on an "AS IS" BASIS, 226 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 227 | See the License for the specific language governing permissions and 228 | limitations under the License. 229 | -------------------------------------------------------------------------------- /invites/invites.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import discord 3 | from datetime import datetime 4 | import re 5 | from typing import List, Callable 6 | from redbot.core import commands, checks, Config 7 | from redbot.core.utils import chat_formatting as cf 8 | from redbot.vendored.discord.ext import menus 9 | 10 | 11 | OLD_CODE_RE = re.compile("^[0-9a-zA-Z]{16}$") 12 | CODE_RE = re.compile("^[0-9a-zA-Z]{6,7}$") 13 | NEW10_CODE_RE = re.compile("^[0-9a-zA-Z]{10}$") 14 | NEW8_CODE_RE = re.compile("^[0-9a-zA-Z]{8}$") 15 | 16 | FAILURE_MSG = "That invite doesn't seem to be valid." 17 | PERM_MSG = "I need the Administrator permission on this guild to view invite information." 18 | 19 | __version__ = "0.0.7" 20 | 21 | 22 | class Invites(commands.Cog): 23 | def __init__(self, bot): 24 | self.bot = bot 25 | self.config = Config.get_conf(self, 2713371001, force_registration=True) 26 | 27 | default_guild = {"pinned_invites": []} 28 | 29 | self.config.register_guild(**default_guild) 30 | 31 | async def red_delete_data_for_user(self, **kwargs): 32 | """Nothing to delete""" 33 | return 34 | 35 | @commands.guild_only() 36 | @commands.group() 37 | async def invites(self, ctx: commands.Context): 38 | """Invite information.""" 39 | pass 40 | 41 | @commands.max_concurrency(1, commands.BucketType.user) 42 | @invites.command() 43 | async def show(self, ctx: commands.Context, invite_code_or_url: str = None): 44 | """Show the stats for an invite, or show all invites.""" 45 | if not ctx.channel.permissions_for(ctx.guild.me).administrator: 46 | return await self._send_embed(ctx, PERM_MSG) 47 | 48 | if not invite_code_or_url: 49 | pages = MenuInvitePages(await ctx.guild.invites()) 50 | else: 51 | invite_code = await self._find_invite_code(invite_code_or_url) 52 | if not invite_code: 53 | return await self._send_embed(ctx, FAILURE_MSG) 54 | pages = MenuInvitePages([x for x in await ctx.guild.invites() if x.code == invite_code]) 55 | await self._menu(ctx, pages) 56 | 57 | @invites.command() 58 | async def leaderboard(self, ctx: commands.Context, list_all_invites: bool = False): 59 | """List pinned invites or all invites in a leaderboard style.""" 60 | if not ctx.channel.permissions_for(ctx.guild.me).administrator: 61 | return await self._send_embed(ctx, PERM_MSG) 62 | 63 | if not list_all_invites: 64 | pinned_invites = await self.config.guild(ctx.guild).pinned_invites() 65 | if not pinned_invites: 66 | return await self._send_embed(ctx, "No invites are pinned, or there are no invites to display.") 67 | else: 68 | pinned_invites = await ctx.guild.invites() 69 | invite_info = "" 70 | for i, invite_code_or_object in enumerate(pinned_invites): 71 | if not list_all_invites: 72 | inv_object = await self._get_invite_from_code(ctx, invite_code_or_object) 73 | else: 74 | inv_object = invite_code_or_object 75 | if not inv_object: 76 | # Someone deleted a pinned invite or it expired 77 | pinned_invites = await self.config.guild(ctx.guild).pinned_invites() 78 | pinned_invites.remove(invite_code_or_object) 79 | await self.config.guild(ctx.guild).pinned_invites.set(pinned_invites) 80 | continue 81 | max_uses = await self.get_invite_max_uses(ctx, inv_object) 82 | inv_details = f"{i+1}. {inv_object.url} [ {inv_object.uses} uses / {max_uses} max ]\n" 83 | invite_info += inv_details 84 | 85 | pagified_stings = [x for x in cf.pagify(invite_info, delims=["\n"], shorten_by=16)] 86 | pages = MenuLeaderboardPages(ctx, pagified_stings, show_all=list_all_invites) 87 | await self._menu(ctx, pages) 88 | 89 | @invites.command(aliases=["listpinned"]) 90 | async def listpin(self, ctx: commands.Context): 91 | """List pinned invites.""" 92 | pinned_invites = await self.config.guild(ctx.guild).pinned_invites() 93 | invite_list = "None." if len(pinned_invites) == 0 else "\n".join(pinned_invites) 94 | await self._send_embed(ctx, "Pinned Invites", invite_list) 95 | 96 | @invites.command() 97 | async def pin(self, ctx: commands.Context, invite_code_or_url: str): 98 | """Pin an invite to the leaderboard.""" 99 | if not ctx.channel.permissions_for(ctx.guild.me).administrator: 100 | return await self._send_embed(ctx, PERM_MSG) 101 | 102 | invite_code = await self._find_invite_code(invite_code_or_url) 103 | invite_code = await self._check_invite_code(ctx, invite_code) 104 | if not invite_code: 105 | return await self._send_embed(ctx, FAILURE_MSG) 106 | 107 | existing_pins = await self.config.guild(ctx.guild).pinned_invites() 108 | if invite_code not in existing_pins: 109 | existing_pins.append(invite_code) 110 | await self.config.guild(ctx.guild).pinned_invites.set(existing_pins) 111 | await self._send_embed(ctx, f"{invite_code} was added to the pinned list.") 112 | else: 113 | await self._send_embed(ctx, f"{invite_code} is already in the pinned list.") 114 | 115 | @invites.command() 116 | async def unpin(self, ctx: commands.Context, invite_code_or_url: str): 117 | """Unpin an invite from the leaderboard.""" 118 | invite_code = await self._find_invite_code(invite_code_or_url) 119 | if not invite_code: 120 | return await self._send_embed(ctx, FAILURE_MSG) 121 | 122 | pinned_invites = await self.config.guild(ctx.guild).pinned_invites() 123 | if invite_code in pinned_invites: 124 | pinned_invites.remove(invite_code) 125 | else: 126 | return await self._send_embed(ctx, f"{invite_code} isn't in the pinned list.") 127 | await self.config.guild(ctx.guild).pinned_invites.set(pinned_invites) 128 | await self._send_embed(ctx, f"{invite_code} was removed from the pinned list.") 129 | 130 | @invites.command(hidden=True) 131 | async def version(self, ctx): 132 | """Invites version.""" 133 | await self._send_embed(ctx, __version__) 134 | 135 | @staticmethod 136 | async def _check_invite_code(ctx: commands.Context, invite_code: str): 137 | for invite in await ctx.guild.invites(): 138 | if invite.code == invite_code: 139 | return invite_code 140 | else: 141 | continue 142 | 143 | return None 144 | 145 | @staticmethod 146 | async def _find_invite_code(invite_code_or_url: str): 147 | invite_match = ( 148 | re.fullmatch(OLD_CODE_RE, invite_code_or_url) 149 | or re.fullmatch(CODE_RE, invite_code_or_url) 150 | or re.fullmatch(NEW10_CODE_RE, invite_code_or_url) 151 | or re.fullmatch(NEW8_CODE_RE, invite_code_or_url) 152 | ) 153 | if invite_match: 154 | return invite_code_or_url 155 | else: 156 | sep = invite_code_or_url.rfind("/") 157 | if sep: 158 | try: 159 | invite_code = invite_code_or_url.rsplit("/", 1)[1] 160 | return invite_code 161 | except IndexError: 162 | return None 163 | 164 | return None 165 | 166 | @staticmethod 167 | async def _get_invite_from_code(ctx: commands.Context, invite_code: str): 168 | for invite in await ctx.guild.invites(): 169 | if invite.code == invite_code: 170 | return invite 171 | else: 172 | continue 173 | 174 | return None 175 | 176 | @classmethod 177 | async def get_invite_max_uses(self, ctx: commands.Context, invite_object: discord.Invite): 178 | if invite_object.max_uses == 0: 179 | return "\N{INFINITY}" 180 | else: 181 | return invite_object.max_uses 182 | 183 | async def _menu(self, ctx: commands.Context, pages: List[discord.Embed]): 184 | # `wait` in this function is whether the menus wait for completion. 185 | # An example of this is with concurrency: 186 | # If max_concurrency's wait arg is False (the default): 187 | # This function's wait being False will not follow the expected max_concurrency behaviour 188 | # This function's wait being True will follow the expected max_concurrency behaviour 189 | await MenuActions(source=pages, delete_message_after=False, clear_reactions_after=True, timeout=180).start( 190 | ctx, wait=True 191 | ) 192 | 193 | @staticmethod 194 | async def _send_embed(ctx: commands.Context, title: str = None, description: str = None): 195 | title = "\N{ZERO WIDTH SPACE}" if title is None else title 196 | embed = discord.Embed() 197 | embed.title = title 198 | if description: 199 | embed.description = description 200 | embed.colour = await ctx.embed_colour() 201 | await ctx.send(embed=embed) 202 | 203 | 204 | class MenuInvitePages(menus.ListPageSource): 205 | def __init__(self, methods: List[discord.Invite]): 206 | super().__init__(methods, per_page=1) 207 | 208 | async def format_page(self, menu: MenuActions, invite: discord.Invite) -> discord.Embed: 209 | # Use the menu to generate pages as they are needed instead of giving it a list of 210 | # already-generated embeds. 211 | embed = discord.Embed(title=f"Invites for {menu.ctx.guild.name}") 212 | max_uses = await Invites.get_invite_max_uses(menu.ctx, invite) 213 | msg = f"{cf.bold(invite.url)}\n\n" 214 | msg += f"Uses: {invite.uses}/{max_uses}\n" 215 | msg += f"Target Channel: {invite.channel.mention}\n" 216 | msg += f"Created by: {invite.inviter.mention}\n" 217 | msg += f"Created at: {invite.created_at.strftime('%m-%d-%Y @ %H:%M:%S UTC')}\n" 218 | if invite.temporary: 219 | msg += "Temporary invite\n" 220 | if invite.max_age == 0: 221 | max_age = f"" 222 | else: 223 | max_age = f"Max age: {self._dynamic_time(int(invite.max_age))}" 224 | msg += f"{max_age}" 225 | embed.description = msg 226 | 227 | return embed 228 | 229 | @staticmethod 230 | def _dynamic_time(time: int): 231 | m, s = divmod(time, 60) 232 | h, m = divmod(m, 60) 233 | d, h = divmod(h, 24) 234 | 235 | if d > 0: 236 | msg = "{0}d {1}h" 237 | elif d == 0 and h > 0: 238 | msg = "{1}h {2}m" 239 | elif d == 0 and h == 0 and m > 0: 240 | msg = "{2}m {3}s" 241 | elif d == 0 and h == 0 and m == 0 and s > 0: 242 | msg = "{3}s" 243 | else: 244 | msg = "" 245 | return msg.format(d, h, m, s) 246 | 247 | 248 | class MenuLeaderboardPages(menus.ListPageSource): 249 | def __init__(self, ctx: commands.Context, entries: List[str], show_all: bool): 250 | super().__init__(entries, per_page=1) 251 | self.show_all = show_all 252 | self.ctx = ctx 253 | 254 | async def format_page(self, menu: MenuActions, page: str) -> discord.Embed: 255 | embed = discord.Embed(title=f"Invite Usage for {self.ctx.guild.name}", description=page) 256 | if self.show_all is False: 257 | embed.set_footer(text="Only displaying pinned invites.") 258 | else: 259 | embed.set_footer(text="Displaying all invites.") 260 | return embed 261 | 262 | 263 | class MenuActions(menus.MenuPages, inherit_buttons=False): 264 | def reaction_check(self, payload): 265 | """The function that is used to check whether the payload should be processed. 266 | This is passed to :meth:`discord.ext.commands.Bot.wait_for `. 267 | 268 | There should be no reason to override this function for most users. 269 | This is done this way in this cog to let a bot owner operate the menu 270 | along with the original command invoker. 271 | 272 | Parameters 273 | ------------ 274 | payload: :class:`discord.RawReactionActionEvent` 275 | The payload to check. 276 | 277 | Returns 278 | --------- 279 | :class:`bool` 280 | Whether the payload should be processed. 281 | """ 282 | if payload.message_id != self.message.id: 283 | return False 284 | if payload.user_id not in (*self.bot.owner_ids, self._author_id): 285 | return False 286 | 287 | return payload.emoji in self.buttons 288 | 289 | async def show_checked_page(self, page_number: int) -> None: 290 | # This is a custom impl of show_checked_page that allows looping around back to the 291 | # beginning of the page stack when at the end and using next, or looping to the end 292 | # when at the beginning page and using prev. 293 | max_pages = self._source.get_max_pages() 294 | try: 295 | if max_pages is None: 296 | await self.show_page(page_number) 297 | elif page_number >= max_pages: 298 | await self.show_page(0) 299 | elif page_number < 0: 300 | await self.show_page(max_pages - 1) 301 | elif max_pages > page_number >= 0: 302 | await self.show_page(page_number) 303 | except IndexError: 304 | pass 305 | 306 | @menus.button("\N{UP-POINTING RED TRIANGLE}", position=menus.First(1)) 307 | async def prev(self, payload: discord.RawReactionActionEvent): 308 | await self.show_checked_page(self.current_page - 1) 309 | 310 | @menus.button("\N{DOWN-POINTING RED TRIANGLE}", position=menus.First(2)) 311 | async def next(self, payload: discord.RawReactionActionEvent): 312 | await self.show_checked_page(self.current_page + 1) 313 | 314 | @menus.button("\N{CROSS MARK}", position=menus.Last(0)) 315 | async def close_menu(self, payload: discord.RawReactionActionEvent) -> None: 316 | self.stop() 317 | -------------------------------------------------------------------------------- /icyparser/icyparser.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import aiohttp 3 | from aiohttp.client_proto import ResponseHandler 4 | from aiohttp.http_parser import HttpResponseParserPy 5 | import discord 6 | import functools 7 | import io 8 | import lavalink 9 | import logging 10 | from pkg_resources import parse_version 11 | import struct 12 | import re 13 | from types import SimpleNamespace 14 | from typing import List, Pattern, Optional 15 | 16 | from redbot.core import commands 17 | from redbot.core.utils.chat_formatting import pagify 18 | from redbot.core.utils.menus import menu, DEFAULT_CONTROLS 19 | 20 | 21 | log = logging.getLogger("red.aikaterna.icyparser") 22 | 23 | 24 | HTML_CLEANUP: Pattern = re.compile("<.*?>|&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-f]{1,6});") 25 | 26 | 27 | # Now utilizing Jack1142's answer for ICY 200 OK -> 200 OK at 28 | # https://stackoverflow.com/questions/4247248/record-streaming-and-saving-internet-radio-in-python/71890980 29 | 30 | 31 | class ICYHttpResponseParser(HttpResponseParserPy): 32 | def parse_message(self, lines): 33 | if lines[0].startswith(b"ICY "): 34 | lines[0] = b"HTTP/1.0 " + lines[0][4:] 35 | return super().parse_message(lines) 36 | 37 | 38 | class ICYResponseHandler(ResponseHandler): 39 | def set_response_params( 40 | self, 41 | *, 42 | timer=None, 43 | skip_payload=False, 44 | read_until_eof=False, 45 | auto_decompress=True, 46 | read_timeout=None, 47 | read_bufsize=2 ** 16, 48 | timeout_ceil_threshold=5, 49 | max_line_size=8190, 50 | max_field_size=8190, 51 | ) -> None: 52 | # this is a copy of the implementation from here: 53 | # https://github.com/aio-libs/aiohttp/blob/v3.8.1/aiohttp/client_proto.py#L137-L165 54 | self._skip_payload = skip_payload 55 | 56 | self._read_timeout = read_timeout 57 | self._reschedule_timeout() 58 | 59 | self._timeout_ceil_threshold = timeout_ceil_threshold 60 | 61 | self._parser = ICYHttpResponseParser( 62 | self, 63 | self._loop, 64 | read_bufsize, 65 | timer=timer, 66 | payload_exception=aiohttp.ClientPayloadError, 67 | response_with_body=not skip_payload, 68 | read_until_eof=read_until_eof, 69 | auto_decompress=auto_decompress, 70 | ) 71 | 72 | if self._tail: 73 | data, self._tail = self._tail, b"" 74 | self.data_received(data) 75 | 76 | 77 | class ICYConnector(aiohttp.TCPConnector): 78 | def __init__(self, *args, **kwargs): 79 | super().__init__(*args, **kwargs) 80 | self._factory = functools.partial(ICYResponseHandler, loop=self._loop) 81 | 82 | 83 | class IcyParser(commands.Cog): 84 | """Icyparser/Shoutcast stream reader.""" 85 | 86 | async def red_delete_data_for_user(self, **kwargs): 87 | """Nothing to delete.""" 88 | return 89 | 90 | def __init__(self, bot): 91 | self.bot = bot 92 | self.timeout = aiohttp.ClientTimeout(total=20) 93 | self.session = session = aiohttp.ClientSession( 94 | connector=ICYConnector(), headers={"Icy-MetaData": "1"}, timeout=self.timeout 95 | ) 96 | 97 | def cog_unload(self): 98 | self.bot.loop.create_task(self.session.close()) 99 | 100 | @commands.guild_only() 101 | @commands.command(aliases=["icynp"]) 102 | async def icyparser(self, ctx, url=None): 103 | """Show Icecast or Shoutcast stream information, if any. 104 | 105 | Supported link formats: 106 | \tDirect links to MP3, AAC, or OGG/Opus encoded Icecast or Shoutcast streams 107 | \tLinks to PLS, M3U, or M3U8 files that contain said stream types 108 | """ 109 | if not url: 110 | audiocog = self.bot.get_cog("Audio") 111 | if not audiocog: 112 | return await ctx.send( 113 | "The Audio cog is not loaded. Provide a url with this command instead, to read from an online Icecast or Shoutcast stream." 114 | ) 115 | 116 | if parse_version(lavalink.__version__) <= parse_version("0.9.0"): 117 | try: 118 | player = lavalink.get_player(ctx.guild.id) 119 | except KeyError: 120 | return await ctx.send("The bot is not playing any music.") 121 | else: 122 | try: 123 | player = lavalink.get_player(ctx.guild.id) 124 | except lavalink.PlayerNotFound: 125 | return await ctx.send("The bot is not playing any music.") 126 | if not player.current: 127 | return await ctx.send("The bot is not playing any music.") 128 | if not player.current.is_stream: 129 | return await ctx.send("The bot is not playing a stream.") 130 | async with ctx.typing(): 131 | radio_obj = await self._icyreader(ctx, player.current.uri) 132 | else: 133 | async with ctx.typing(): 134 | radio_obj = await self._icyreader(ctx, url) 135 | 136 | if not radio_obj: 137 | return 138 | 139 | embed_menu_list = [] 140 | 141 | # Now Playing embed 142 | title = radio_obj.title if radio_obj.title is not None else "No stream title available" 143 | song = f"**[{title}]({player.current.uri if not url else url})**\n" 144 | embed = discord.Embed(colour=await ctx.embed_colour(), title="Now Playing", description=song) 145 | 146 | # Set radio image if scraped or provided by the Icy headers 147 | if radio_obj.image: 148 | embed.set_thumbnail(url=radio_obj.image) 149 | else: 150 | icylogo = dict(radio_obj.resp_headers).get("icy-logo", None) 151 | if icylogo: 152 | embed.set_thumbnail(url=icylogo) 153 | else: 154 | icyfavicon = dict(radio_obj.resp_headers).get("icy-favicon", None) 155 | if icyfavicon: 156 | embed.set_thumbnail(url=icyfavicon) 157 | 158 | # Set radio description if present 159 | radio_station_description = dict(radio_obj.resp_headers).get("icy-description", None) 160 | if radio_station_description == "Unspecified description": 161 | radio_station_description = None 162 | if radio_station_description: 163 | embed.set_footer(text=radio_station_description) 164 | 165 | embed_menu_list.append(embed) 166 | 167 | # Metadata info embed(s) 168 | stream_info_text = "" 169 | sorted_radio_obj_dict = dict(sorted(radio_obj.resp_headers)) 170 | for k, v in sorted_radio_obj_dict.items(): 171 | v = self._clean_html(v) 172 | stream_info_text += f"**{k}**: {v}\n" 173 | 174 | if len(stream_info_text) > 1950: 175 | for page in pagify(stream_info_text, delims=["\n"], page_length=1950): 176 | info_embed = discord.Embed( 177 | colour=await ctx.embed_colour(), title="Radio Station Metadata", description=page 178 | ) 179 | embed_menu_list.append(info_embed) 180 | else: 181 | info_embed = discord.Embed( 182 | colour=await ctx.embed_colour(), title="Radio Station Metadata", description=stream_info_text 183 | ) 184 | embed_menu_list.append(info_embed) 185 | 186 | await menu(ctx, embed_menu_list, DEFAULT_CONTROLS) 187 | 188 | async def _icyreader(self, ctx: commands.Context, url: Optional[str]) -> Optional[SimpleNamespace]: 189 | """ 190 | Icecast/Shoutcast stream reader. 191 | """ 192 | try: 193 | extensions = [".pls", ".m3u", ".m3u8"] 194 | if any(url.endswith(x) for x in extensions): 195 | async with self.session.get(url) as resp: 196 | lines = [] 197 | async for line in resp.content: 198 | lines.append(line) 199 | 200 | if url.endswith(".pls"): 201 | url = await self._pls_reader(lines) 202 | else: 203 | url = await self._m3u_reader(lines) 204 | 205 | if url: 206 | await self._icyreader(ctx, url) 207 | else: 208 | await ctx.send("That url didn't seem to contain any valid Icecast or Shoutcast links.") 209 | return 210 | 211 | async with self.session.get(url) as resp: 212 | metaint = await self._metaint_read(ctx, resp) 213 | if metaint: 214 | radio_obj = await self._metadata_read(int(metaint), resp) 215 | return radio_obj 216 | 217 | except aiohttp.client_exceptions.InvalidURL: 218 | await ctx.send(f"{url} is not a valid url.") 219 | return None 220 | except aiohttp.client_exceptions.ClientConnectorError: 221 | await ctx.send("The connection failed.") 222 | return None 223 | except aiohttp.client_exceptions.ClientPayloadError as e: 224 | friendly_msg = "The website closed the connection prematurely or the response was malformed.\n" 225 | friendly_msg += f"The error returned was: `{str(e)}`\n" 226 | await ctx.send(friendly_msg) 227 | return None 228 | except asyncio.exceptions.TimeoutError: 229 | await ctx.send("The bot timed out while trying to access that url.") 230 | return None 231 | except aiohttp.client_exceptions.ServerDisconnectedError: 232 | await ctx.send("The target server disconnected early without a response.") 233 | return None 234 | except Exception: 235 | log.error( 236 | f"Icyparser's _icyreader encountered an error while trying to read a stream at {url}", exc_info=True 237 | ) 238 | return None 239 | 240 | @staticmethod 241 | async def _metaint_read(ctx: commands.Context, resp: aiohttp.client_reqrep.ClientResponse) -> Optional[int]: 242 | """Fetch the metaint value to know how much of the stream header to read, for metadata.""" 243 | metaint = resp.headers.get("icy-metaint", None) 244 | if not metaint: 245 | error_msg = ( 246 | "The url provided doesn't seem like an Icecast or Shoutcast direct stream link, " 247 | "or doesn't contain a supported format stream link: couldn't read the metadata length." 248 | ) 249 | await ctx.send(error_msg) 250 | return None 251 | 252 | try: 253 | metaint = int(metaint) 254 | return metaint 255 | except ValueError: 256 | return None 257 | 258 | @staticmethod 259 | async def _metadata_read(metaint: int, resp: aiohttp.client_reqrep.ClientResponse) -> Optional[SimpleNamespace]: 260 | """Read the metadata at the beginning of the stream chunk.""" 261 | try: 262 | for _ in range(5): 263 | await resp.content.readexactly(metaint) 264 | metadata_length = struct.unpack("B", await resp.content.readexactly(1))[0] * 16 265 | metadata = await resp.content.readexactly(metadata_length) 266 | m = re.search(br"StreamTitle='([^']*)';", metadata.rstrip(b"\0")) 267 | if m: 268 | title = m.group(1) 269 | if len(title) > 0: 270 | title = title.decode("utf-8", errors="replace") 271 | else: 272 | title = None 273 | else: 274 | title = None 275 | 276 | image = False 277 | t = re.search(br"StreamUrl='([^']*)';", metadata.rstrip(b"\0")) 278 | if t: 279 | streamurl = t.group(1) 280 | if streamurl: 281 | streamurl = streamurl.decode("utf-8", errors="replace") 282 | image_ext = ["webp", "png", "jpg", "gif"] 283 | if streamurl.split(".")[-1] in image_ext: 284 | image = True 285 | else: 286 | streamurl = None 287 | 288 | radio_obj = SimpleNamespace(title=title, image=streamurl, resp_headers=resp.headers.items()) 289 | return radio_obj 290 | 291 | except Exception: 292 | log.error( 293 | f"Icyparser's _metadata_read encountered an error while trying to read a stream at {resp.url}", exc_info=True 294 | ) 295 | return None 296 | 297 | @staticmethod 298 | def _clean_html(html: str) -> str: 299 | """ 300 | Strip out any html, as subtle as a hammer. 301 | """ 302 | plain_text = re.sub(HTML_CLEANUP, "", html) 303 | return plain_text 304 | 305 | @staticmethod 306 | async def _m3u_reader(readlines: List[bytes]) -> Optional[str]: 307 | """ 308 | Helper function for a quick and dirty M3U or M3U8 file read. 309 | M3U8's will most likely contain .ts files, which are not readable by this cog. 310 | 311 | Some M3Us seem to follow the standard M3U format, some only have a bare url in 312 | the file, so let's just return the very first url with an http or https prefix 313 | found, if it's formatted like a real url and not a relative url, and is not a .ts chunk. 314 | """ 315 | for text_line in readlines: 316 | text_line_str = text_line.decode() 317 | if text_line_str.startswith("http"): 318 | if not text_line_str.endswith(".ts"): 319 | return text_line_str 320 | 321 | return None 322 | 323 | @staticmethod 324 | async def _pls_reader(readlines: List[bytes]) -> Optional[str]: 325 | """ 326 | Helper function for a quick and dirty PLS file read. 327 | """ 328 | for text_line in readlines: 329 | text_line_str = text_line.decode() 330 | if text_line_str.startswith("File1="): 331 | return text_line_str[6:] 332 | 333 | return None 334 | --------------------------------------------------------------------------------