initial commit

This commit is contained in:
artie 2024-03-01 20:45:11 +01:00
commit 7a66139810
40 changed files with 14768 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
__pycache__/
temp/
env/
*.log
config.prod.toml
config.dev.toml
status.json

19
LICENSE Normal file
View File

@ -0,0 +1,19 @@
Copyright (c) 2024 artie
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

11
Makefile Normal file
View File

@ -0,0 +1,11 @@
ENV=env
BIN=$(ENV)/bin
install:
$(BIN)/pip install -Ur requirements.txt
dev:
source $(BIN)/activate; pnpx nodemon bot.py
clean:
rm -rf venv

1
README.md Normal file
View File

@ -0,0 +1 @@
### beep boop

268
bot.py Normal file
View File

@ -0,0 +1,268 @@
import asyncio
import contextlib
import logging
import os
from pathlib import Path
import sys
import time
import traceback
from functools import cached_property
from pkgutil import iter_modules
from typing import Literal, Optional, TypedDict
import aiohttp
import discord
import httpx
from discord import Webhook
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
import utils
from utils import reddit
from utils.api import API
from utils.catbox import Catbox, Litterbox
from utils.common import read_json, ArtemisError
from utils.unogs import uNoGS
from utils import config
class Status(TypedDict):
name: str
emoji: str
logging.basicConfig(
level=logging.INFO,
format="{levelname} - {name}: {message}",
style="{",
stream=sys.stdout,
)
log = logging.getLogger("artemis")
logging.getLogger("discord").setLevel(logging.WARNING)
logging.getLogger("aiocache").setLevel(logging.ERROR)
intents = discord.Intents(
messages=True,
message_content=True,
guilds=True,
members=True,
emojis=True,
reactions=True,
voice_states=True,
)
allowed_mentions = discord.AllowedMentions(everyone=False, replied_user=False)
status: Status = read_json("data/status.json")
class Artemis(commands.Bot):
session: aiohttp.ClientSession
httpx_session: httpx.AsyncClient
def __init__(self):
super().__init__(
command_prefix=commands.when_mentioned_or(config.prefix),
help_command=HelpEmbedded(command_attrs={"hidden": True}, verify_checks=False),
intents=intents,
allowed_mentions=allowed_mentions,
owner_id=134306884617371648,
activity=discord.CustomActivity(name=status["name"], emoji=status["emoji"]),
)
self.start_time = time.perf_counter()
self.invite = discord.utils.oauth_url(
client_id=555412947883524098, permissions=discord.Permissions(8)
)
self.user_agent: str = config.user_agent
self.real_user_agent: str = config.real_user_agent
self.keys = config.keys
self.pink = discord.Colour(0xFFCFF1)
self.invisible = discord.Colour(0x2F3136)
async def maybe_send_restarted(self):
restart = Path("data/temp/restart")
if restart.exists():
chid, _, mid = restart.read_text().partition("-")
restart.unlink()
with contextlib.suppress(Exception):
ch = await self.fetch_channel(int(chid))
msg = await ch.fetch_message(int(mid))
await msg.add_reaction("☑️")
async def setup_hook(self):
# importing aiocache here so that its logger runs after our logging config
from aiocache import Cache
self.cache = Cache(Cache.MEMORY)
self.session = aiohttp.ClientSession()
self.httpx_session = httpx.AsyncClient(
http2=True, follow_redirects=True, timeout=httpx.Timeout(60 * 3)
)
await self.load_extensions()
self.api = API(self, self.keys.api)
self.catbox = Catbox(self.keys.catbox, session=self.session)
self.litterbox = Litterbox(session=self.session)
self.unogs = uNoGS(session=self.session)
self.reddit = reddit.Reddit(self.session)
await self.maybe_send_restarted()
async def load_extensions(self):
os.environ["JISHAKU_HIDE"] = "True"
os.environ["JISHAKU_NO_UNDERSCORE"] = "True"
os.environ["JISHAKU_NO_DM_TRACEBACK"] = "True"
await self.load_extension("jishaku")
extensions = [e.name for e in iter_modules(["cogs"], prefix="cogs.")]
for extension in extensions:
await self.load_extension(extension)
async def close(self):
await self.session.close()
await self.httpx_session.aclose()
if hasattr(self, "db"):
await self.db.close()
await super().close()
@cached_property
def owner(self) -> Optional[discord.User]:
return self.get_user(self.owner_id)
def codeblock(self, text: str, lang: str = "py") -> str:
return f"```{lang}\n{text}\n```"
def get_message(self, msg_id: int):
return (
discord.utils.get(reversed(self.cached_messages), id=msg_id)
if self.cached_messages
else None
)
async def send_webhook(self, url: str, **kwargs):
wh = Webhook.from_url(url=url, session=self.session)
await wh.send(**kwargs)
async def on_ready(self):
log.info(f"Bot ready as {str(self.user)}.")
async def on_disconnect(self):
log.info("Disconnected.")
async def on_resumed(self):
log.info("Connection resumed.")
async def on_command_error(self, ctx: commands.Context, error):
if isinstance(error, commands.CommandInvokeError):
error = error.original
if isinstance(error, commands.CommandNotFound):
msg = f"Command `{ctx.invoked_with}` not found."
cmds = [command.name for command in self.commands if not command.hidden]
aliases = [
alias
for command in self.commands
for alias in command.aliases
if not command.hidden
]
found = utils.fuzzy_search_one(ctx.invoked_with, cmds + aliases, cutoff=70)
if found:
msg += f" Did you mean `{found}`?"
return await ctx.reply(msg)
elif isinstance(error, commands.MissingRequiredArgument):
return await ctx.reply(f"Looks like you're missing the '{error.param.name}' parameter.")
elif isinstance(error, commands.CommandOnCooldown):
prefix = "This command is" if error.type == BucketType.default else "You're"
return await ctx.reply(f"{prefix} on cooldown. Try again in {error.retry_after:.2f}s.")
elif isinstance(error, discord.Forbidden):
return await ctx.reply("This action is not possible due to a permission issue.")
elif isinstance(error, (commands.CommandError, ArtemisError)):
return await ctx.reply(str(error))
log.error(f"Error in command {ctx.command} invoked by {str(ctx.author)} ({ctx.author.id})")
traceback.print_exception(type(error), error, error.__traceback__)
error_line = f"{error.__class__.__qualname__}: {utils.trim(str(error), 100)}"
await ctx.reply(
f"Oops! An unknown error occured.\nCode: `{error_line}`",
)
class HelpEmbedded(commands.MinimalHelpCommand):
context: commands.Context[Artemis]
async def send_pages(self):
destination = self.get_destination()
for page in self.paginator.pages:
embed = discord.Embed(title="Help", description=page, colour=self.context.bot.pink)
await destination.send(embed=embed)
async def send_cog_help(self, cog: commands.Cog):
commands = sorted(cog.get_commands(), key=lambda c: c.name)
for command in commands:
self.add_subcommand_formatting(command)
channel = self.get_destination()
for page in self.paginator.pages:
embed = discord.Embed(
title=f"{cog.qualified_name} {self.commands_heading}",
description=page,
colour=self.context.bot.pink,
)
await channel.send(embed=embed)
async def send_group_help(self, group: commands.Group):
if group.help:
help = group.help.format(prefix=self.context.clean_prefix)
self.paginator.add_line(help + "\n")
commands = sorted(group.commands, key=lambda c: c.name)
self.paginator.add_line("**Subcommands**")
for command in commands:
self.add_subcommand_formatting(command)
channel = self.get_destination()
for page in self.paginator.pages:
embed = discord.Embed(
title=self.get_command_signature(group),
description=page,
color=self.context.bot.pink,
)
await channel.send(embed=embed)
async def send_command_help(self, command: commands.Command):
help = ""
if command.help:
help = command.help.format(prefix=self.context.clean_prefix)
embed = discord.Embed(
title=self.get_command_signature(command),
description=help,
colour=self.context.bot.pink,
)
alias = command.aliases
if alias:
embed.add_field(name="Aliases", value=", ".join(alias), inline=False)
channel = self.get_destination()
await channel.send(embed=embed)
async def main():
async with Artemis() as bot:
await bot.start(config.token)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
print("SIGINT received, closing.")

543
cogs/anime.py Normal file
View File

@ -0,0 +1,543 @@
from __future__ import annotations
import json
import re
import time
from enum import Enum
from io import BytesIO
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote
import discord
import feedparser
import pendulum
from anilist.async_client import Client as Anilist
from bs4 import BeautifulSoup
from discord.ext import commands
from discord.utils import format_dt
import utils
from utils.common import ArtemisError
from utils.anilist import build_anilist_embed, build_character_embed
from utils.views import DropdownView, ViewPages
if TYPE_CHECKING:
from bot import Artemis
watching_query = """query ($userId: Int, $type: MediaType) {
MediaListCollection(userId: $userId, type: $type, status: CURRENT) {
lists {
entries {
...mediaListEntry
}
}
}
}
fragment mediaListEntry on MediaList {
progress
media {
id
episodes
nextAiringEpisode {
episode
airingAt
}
coverImage {
extraLarge
color
}
title {
userPreferred
romaji
english
native
}
}
}
"""
class Theme(Enum):
Opening = "OP"
Ending = "ED"
class Anime(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
self.anilist: Anilist = Anilist()
@commands.command()
async def aniart(self, ctx: commands.Context):
"""Find out what anime artie is currently watching."""
payload = {"query": watching_query, "variables": {"userId": 5132095, "type": "ANIME"}}
await ctx.typing()
async with self.bot.session.post("https://graphql.anilist.co", json=payload) as r:
data = await r.json()
data = data["data"]["MediaListCollection"]["lists"][0]["entries"]
if not data:
return await ctx.reply("Artie is currently not watching any anime :()")
image_url = data[0]["media"]["coverImage"]["extraLarge"]
color = data[0]["media"]["coverImage"]["color"]
desc = ""
for entry in data:
media = entry["media"]
title = media["title"]["userPreferred"]
mid = media["id"]
progress = entry["progress"]
episodes = media["episodes"] or "?"
next_airing_episode = media["nextAiringEpisode"]
airing_at = None
if next_airing_episode:
next_airing_episode = media["nextAiringEpisode"]["episode"]
airing_at = media["nextAiringEpisode"]["airingAt"]
is_caught_up = False
if next_airing_episode:
is_caught_up = progress == next_airing_episode - 1
desc += f"**[{title}](https://anilist.co/anime/{mid})**\n"
desc += f"Progress: **{progress}/{episodes}**"
if not is_caught_up:
if next_airing_episode:
behind = next_airing_episode - 1 - progress
desc += f" *({behind} episode{'s' if behind > 1 else ''} behind)*"
if next_airing_episode:
dt = pendulum.from_timestamp(airing_at, "UTC")
fmt_dt = format_dt(dt, "R")
desc += f"\nEpisode {next_airing_episode} airing {fmt_dt}"
desc += "\n\n"
embed = discord.Embed(
title="Artie's Anime Watching List", description=desc, color=int(color[1:], 16)
)
embed.set_thumbnail(url=image_url)
await ctx.reply(embed=embed)
@commands.command()
async def anime(self, ctx: commands.Context, *, query: str):
"""Search for anime."""
await ctx.typing()
results, _ = await self.anilist.search_anime(query, 10)
if not results:
return await ctx.reply("No results found.")
if len(results) > 1:
view = DropdownView(
ctx,
results,
lambda x: getattr(x.title, "english", x.title.romaji),
lambda x: getattr(x.title, "native", None),
"Choose anime...",
)
result = await view.prompt()
if not result:
return
await ctx.typing()
else:
result = results[0]
anime = await self.anilist.get_anime(result.id)
if not anime:
return await ctx.reply("Anilist Error: Anime ID not found.")
embed = build_anilist_embed(anime)
await ctx.reply(embed=embed)
@commands.command()
async def manga(self, ctx: commands.Context, *, query: str):
"""Search for manga."""
await ctx.typing()
results, _ = await self.anilist.search_manga(query, 10)
if not results:
return await ctx.reply("No results found.")
if len(results) > 1:
view = DropdownView(
ctx,
results,
lambda x: getattr(x.title, "english", x.title.romaji),
lambda x: getattr(x.title, "native", None),
"Choose manga...",
)
result = await view.prompt()
if not result:
return
await ctx.typing()
else:
result = results[0]
manga = await self.anilist.get_manga(result.id)
if not manga:
return await ctx.reply("Anilist Error: Manga ID not found.")
embed = build_anilist_embed(manga)
await ctx.reply(embed=embed)
@commands.command(aliases=["chara"])
async def character(self, ctx: commands.Context, *, query: str):
"""Search for anime and manga characters."""
await ctx.typing()
results, _ = await self.anilist.search_character(query, 10)
if not results:
return await ctx.reply("No results found.")
if len(results) > 1:
view = DropdownView(
ctx,
results,
lambda x: x.name.full,
lambda x: getattr(x.name, "native", None),
"Choose a character...",
)
result = await view.prompt()
if not result:
return
await ctx.typing()
else:
result = results[0]
await ctx.typing()
character = await self.anilist.get_character(result.id)
if not character:
return await ctx.reply("Anilist Error: Character ID not found.")
embed = build_character_embed(character)
await ctx.reply(embed=embed)
@commands.group(invoke_without_command=True, aliases=["trace"])
@commands.max_concurrency(1)
async def whatanime(self, ctx: commands.Context, url: Optional[utils.URL]):
"""
Reverse search for anime with a screenshot.
The screenshot can be sent as an attachment or a URL.
"""
if not ctx.message.attachments and not url:
return await ctx.reply("Please send me a screenshot first!")
elif ctx.message.attachments:
url = ctx.message.attachments[0].url
await ctx.typing()
async with self.bot.session.get(f"https://api.trace.moe/search?anilistInfo&url={url}") as r:
if r.status == 402:
raise ArtemisError("Error: The bot has reached max API search quota for the month.")
json = await r.json()
if json.get("error"):
raise ArtemisError(json["error"])
result = json["result"][0]
anilist = result["anilist"]
episode = result.get("episode", "N/A")
episode = episode if episode != "" else "N/A"
timestamp = result["from"]
if timestamp >= 3600: # check if time exceeds one hour
seconds_format = "%H:%M:%S"
else:
seconds_format = "%M:%S"
timestamp = time.strftime(seconds_format, time.gmtime(timestamp))
similarity = int(round(result["similarity"], 2) * 100)
filename = result["filename"]
anilist_id = anilist["id"]
titles = anilist["title"]
is_adult = anilist["isAdult"]
main_title = titles.get("romaji") or titles.get("english")
native_title = titles.get("native")
video = result["video"]
image = result["image"]
embed = discord.Embed(
title=main_title,
url=f"https://anilist.co/anime/{anilist_id}",
description=native_title,
color=self.bot.pink,
)
if is_adult and ctx.guild and not ctx.channel.is_nsfw():
embed.title = main_title + " (NSFW)"
else:
embed.set_image(url=image)
embed.add_field(name="Episode", value=episode, inline=True)
embed.add_field(name="Timestamp", value=timestamp, inline=True)
embed.add_field(name="Similarity", value=f"{similarity}%", inline=True)
embed.add_field(name="Video match", value=f"[{filename}]({video})", inline=False)
embed.set_footer(text="Powered by trace.moe")
await ctx.reply(embed=embed)
@whatanime.command()
async def quota(self, ctx: commands.Context):
"""
Returns the search quota left for the month.
"""
await ctx.typing()
async with self.bot.session.get("https://api.trace.moe/me") as r:
data = await r.json()
quota_left = data["quota"] - data["quotaUsed"]
first_of_next_month = (
pendulum.now("UTC").add(months=1).replace(day=1, hour=0, minute=0, second=0)
)
await ctx.reply(
f'API search quota left for the month: **{quota_left}**\nQuota resets {format_dt(first_of_next_month, "R")}.'
)
@commands.command(aliases=["sb", "db", "safebooru", "booru"])
async def danbooru(self, ctx: commands.Context, *, tags: str = None):
"""
Search for art on Danbooru or show a random image.
This uses the common tag search logic found on booru imageboards, fuzzy matching for tags is enabled.
"""
params = None
await ctx.typing()
include_nsfw = not ctx.guild or ctx.channel.nsfw
if not tags:
if include_nsfw:
params = {}
else:
params = {"post[tags]": "rating:g"}
elif tags:
valid_tags = len([tag for tag in tags.split(" ") if not tag.startswith("rating")])
if valid_tags > 1:
return await ctx.reply(
"You cannot search for more than 2 tags at a time (rating:g already included in SFW channels)."
)
if include_nsfw:
params = {"post[tags]": tags}
else:
params = {"post[tags]": f"rating:g {tags}"}
params["limit"] = "25"
params["random"] = "true"
async with self.bot.session.get(
"https://danbooru.donmai.us/posts.json", params=params
) as r:
posts = await r.json()
posts = [post for post in posts if post.get("id") and post.get("large_file_url")]
if not posts:
return await ctx.reply("No posts matching the tags found.")
embeds = []
for post in posts:
pid = post["id"]
character = post.get("tag_string_character")
artist = post.get("tag_string_artist")
tags = post["tag_string"].strip().split(" ")
tags = ", ".join(tags[:3])
title = (character or tags).replace("_", "\\_")
url = f"https://danbooru.donmai.us/posts/{pid}"
img_url = post["large_file_url"]
embed = discord.Embed(title=utils.trim(title, 256), url=url, color=0x0075F8)
embed.set_image(url=img_url)
embed.set_footer(text="Powered by Danbooru API")
embed.set_author(name=artist or "Danbooru")
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command(
help="Search for torrents on Nyaa.\nSorted by seeds, if no query is given, shows recently uploaded torrents."
)
async def nyaa(self, ctx: commands.Context, *, query: Optional[str] = None):
"""
Search for torrents on Nyaa.
Sorted by seeds, if no query is given, shows recently uploaded torrents.
"""
if not query:
params = None
else:
params = {"q": query}
await ctx.typing()
async with self.bot.session.get("https://nyaa.si/?page=rss", params=params) as r:
parsed = feedparser.parse(await r.text())
if not parsed.entries:
return await ctx.reply("No torrents found.")
entries = parsed.entries
if query:
entries = sorted(entries, key=lambda x: int(x.get("nyaa_seeders")), reverse=True)
data = []
for torrent in entries[:50]:
title = torrent.title
guid = torrent.id
link = torrent.link
size = torrent.get("nyaa_size", "N/A")
category = torrent.get("nyaa_category", "N/A")
seeders = torrent.get("nyaa_seeders", "N/A")
leechers = torrent.get("nyaa_leechers", "N/A")
data.append(
f"**[{title}]({guid})**\n**{size}** | {category} | **{seeders}** :green_heart: • **{leechers}** :yellow_heart: | [.torrent]({link})\n"
)
embed = discord.Embed(title="Results", colour=discord.Color.blue())
embed.set_author(name="Nyaa", icon_url="https://nyaa.si/static/favicon.png")
embeds = utils.make_embeds(data, embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def pixiv(self, ctx: commands.Context, url: utils.URL):
"""Returns the original-res pixiv image for a given art URL for easy sharing/embedding."""
PIXIV_RE = r"https:\/\/(?:www\.)?pixiv\.net(?:\/\w+)?\/artworks\/(?P<pid>\d+)\/?"
async with ctx.typing():
match = re.fullmatch(PIXIV_RE, url)
if not match:
return await ctx.reply("Invalid pixiv URL.")
pid = match.group("pid")
headers = {"User-Agent": self.bot.user_agent, "Referer": "https://www.pixiv.net/"}
async with self.bot.session.get(url, headers=headers) as r:
if r.status != 200:
return await ctx.reply(f"Pixiv Error: {r.status} {r.reason}")
html = await r.text()
soup = BeautifulSoup(html, "lxml")
try:
meta = soup.select_one("#meta-preload-data")
if not meta:
return await ctx.reply("Pixiv Error: No preload data found.")
data = meta["content"]
data = json.loads(data)
original_url = data["illust"][pid]["urls"]["original"]
except Exception:
return await ctx.reply("Pixiv Error: No image data found.")
async with self.bot.session.get(original_url, headers=headers) as r:
if r.status != 200:
return await ctx.reply(f"Pixiv Error: {r.status} {r.reason}")
img = await r.read()
img_size = len(img)
img = BytesIO(img)
try:
adult = any([tag["tag"] == "R-18" for tag in data["illust"][pid]["tags"]["tags"]])
except Exception:
adult = False
ext = original_url.split("/")[-1].split(".")[-1].split("?")[0]
filename = f"{pid}.{ext}"
if adult:
filename = f"SPOILER_{filename}"
if img_size <= utils.MAX_DISCORD_SIZE:
dfile = discord.File(img, filename)
return await ctx.reply(file=dfile)
else:
img.name = filename
try:
res = await self.bot.litterbox.upload(img, 24)
return await ctx.reply(res)
except Exception as err:
return await ctx.reply(f"Upload Error: {err}")
async def search_themes(self, ctx: commands.Context, query: str, theme_type: Theme):
data = await self.bot.cache.get(f"anithemes:{query}")
if not data:
request_url = f"https://api.animethemes.moe/search?fields[search]=anime&include[anime]=animethemes.animethemeentries.videos&limit=10&q={quote(query)}"
headers = {"User-Agent": self.bot.user_agent}
await ctx.typing()
async with self.bot.session.get(request_url, headers=headers) as r:
data = await r.json()
await self.bot.cache.set(f"anithemes:{query}", data, ttl=60 * 60)
results = data["search"]["anime"]
if not results:
return await ctx.reply("No results found.")
elif len(results) == 1:
anime = results[0]
else:
view = DropdownView(
ctx,
results,
lambda x: x["name"],
lambda x: x["slug"],
placeholder="Choose anime...",
)
anime = await view.prompt()
if not anime:
return
anime_slug = anime["slug"]
themes = anime["animethemes"]
themes = [theme for theme in themes if theme["type"] == theme_type.value]
if not themes:
return await ctx.reply(f"No {theme_type.value} for this anime found.")
items = []
for theme in themes:
for entry in theme["animethemeentries"]:
try:
video = entry["videos"][0]
version = entry.get("version")
tags = video.get("tags")
slug = theme["slug"]
if version and version != 1:
slug += version
if tags:
slug += f"-{tags}"
link = f"https://animethemes.moe/anime/{anime_slug}/{slug}"
except Exception:
continue
msg = f"**{anime['name']} {theme['type']}{theme['sequence'] or 1}**\n"
episodes = entry["episodes"]
if episodes:
msg += f"`episodes: {episodes}`\n"
msg += f"{link}"
items.append(msg)
view = ViewPages(ctx, items)
await view.start()
@commands.command(aliases=["op"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def opening(self, ctx: commands.Context, *, query: str):
"""Search for anime openings."""
await self.search_themes(ctx, query, Theme.Opening)
@commands.command(aliases=["ed"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def ending(self, ctx: commands.Context, *, query: str):
"""Search for anime endings."""
await self.search_themes(ctx, query, Theme.Ending)
async def setup(bot: Artemis):
await bot.add_cog(Anime(bot))

118
cogs/events.py Normal file
View File

@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
import contextlib
import logging
import random
import re
from logging.handlers import RotatingFileHandler
from typing import TYPE_CHECKING
import discord
from discord.ext import commands
from utils import config
from utils.constants import RIP_EMOJIS, TEEHEE_EMOJIS
if TYPE_CHECKING:
from bot import Artemis
log = logging.getLogger("artemis")
cmd_log = logging.getLogger("commands")
cmd_log.propagate = False
cmd_log.setLevel(logging.DEBUG)
ch = RotatingFileHandler("data/commands.log", "a", 10 * 1024**2, encoding="utf-8")
ch.setLevel(logging.DEBUG)
ch.setFormatter(logging.Formatter("[{asctime}] {message}", "%Y-%m-%d %H:%M:%S", style="{"))
cmd_log.addHandler(ch)
TIKTOK_RE = re.compile(
r"https://vm\.tiktok\.com/(\w+)|https://(?:www\.)?tiktok\.com/(@.+?/video/\d+)"
)
PIXIV_RE = re.compile(r"https:\/\/(?:www\.)?pixiv\.net(?:\/\w+)?\/artworks\/(?P<pid>\d+)\/?")
REDDIT_RE = re.compile(
r"https?:\/\/(?:www\.)?(?:old\.)?reddit\.com\/r\/\w+\/comments\/(?P<id>[a-zA-Z0-9]+)(?:\/)?(?:[^\s]*)?"
)
class Events(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
def suppress_embeds(self, message: discord.Message, delay: float | int = 0):
async def _suppress():
await asyncio.sleep(delay)
with contextlib.suppress(Exception):
await message.edit(suppress=True)
asyncio.create_task(_suppress())
async def handle_triggers(self, message: discord.Message, content: str):
if content == "good bot":
emoji = random.choice(TEEHEE_EMOJIS)
await message.channel.send(emoji)
elif content == "bad bot":
emoji = random.choice(RIP_EMOJIS)
await message.channel.send(emoji)
async def handle_links(self, message: discord.Message, content: str):
if message.guild and message.guild.id not in (
config.main_guild_id,
config.dev_guild_id,
):
return
tiktok_url = TIKTOK_RE.search(content)
if tiktok_url:
vid = tiktok_url.group(1) or tiktok_url.group(2)
self.suppress_embeds(message, 0.1)
return await message.reply(f"https://vm.dstn.to/{vid}")
pixiv_url = PIXIV_RE.search(content)
if pixiv_url:
pid = pixiv_url.group("pid")
self.suppress_embeds(message, 0.1)
return await message.reply(f"{config.api_base_url}/pixiv/{pid}")
reddit_url = REDDIT_RE.search(content)
if reddit_url:
pid = reddit_url.group("id")
reddit_post = await self.bot.reddit.post(pid=pid)
if reddit_post:
self.suppress_embeds(message, 0.1)
embeds = await reddit_post.to_embed(message)
return await message.reply(embeds=embeds)
log.warn(f"Invalid Reddit post URL/ID: {reddit_url.group(0)}")
@commands.Cog.listener()
async def on_message(self, message: discord.Message):
if message.author.bot:
return
content = message.content.lower()
if content.startswith(("$")) and "$jsk" not in content:
cmd_log.debug(f"{message.author.id}: {message.content}")
await self.handle_triggers(message, content)
await self.handle_links(message, content)
@commands.Cog.listener()
async def on_message_edit(self, before: discord.Message, after: discord.Message):
if after.author.id != self.bot.owner_id:
return
if before.content == after.content:
return
await self.bot.process_commands(after)
@commands.Cog.listener()
async def on_thread_create(self, thread: discord.Thread):
try:
await thread.join()
except Exception:
pass
async def setup(bot: Artemis):
await bot.add_cog(Events(bot))

640
cogs/funhouse.py Normal file
View File

@ -0,0 +1,640 @@
from __future__ import annotations
import http
import mimetypes
import random
import re
from io import BytesIO
from typing import TYPE_CHECKING, Optional, TypedDict
from urllib.parse import quote
import discord
import pendulum
from bs4 import BeautifulSoup
from discord.ext import commands
import utils
from utils import config
from utils.common import ArtemisError, read_json, trim
from utils.views import DropdownView, ViewPages
if TYPE_CHECKING:
from bot import Artemis
class Pokemon(TypedDict):
abilities: list[str]
detailPageURL: str
weight: float
weakness: list[str]
number: str
height: int
slug: str
name: str
ThumbnailImage: str
id: int
type: list[str]
Pokedex = list[Pokemon]
pokedex = read_json("data/pokedex.json")
fim_transcripts = read_json("data/fim-dialogues.json")
class Funhouse(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
def fun_embed(self, description: str) -> discord.Embed:
return discord.Embed(
description=description, timestamp=pendulum.now("UTC"), colour=discord.Colour.random()
)
async def invoke_reddit(self, ctx: commands.Context, subreddit: str):
reddit = self.bot.get_command("reddit")
return await reddit(ctx, subreddit)
@commands.command()
async def cat(self, ctx: commands.Context):
"""Random cat picture."""
await ctx.typing()
async with self.bot.session.get("https://cataas.com/cat") as r:
ext = mimetypes.guess_extension(r.content_type)
image = discord.File(BytesIO(await r.read()), f"{utils.time()}.{ext}")
await ctx.send(file=image)
@commands.command()
async def dog(self, ctx: commands.Context):
"""Random dog picture."""
async with self.bot.session.get("https://random.dog/woof.json") as r:
json = await r.json(content_type=None)
await ctx.send(json["url"])
@commands.command()
async def fox(self, ctx: commands.Context):
"""Random fox picture."""
async with self.bot.session.get("https://randomfox.ca/floof/") as r:
json = await r.json(content_type=None)
await ctx.send(json["image"])
@commands.command()
async def waifu(self, ctx: commands.Context):
"""Random waifu (anime girl)."""
await self.invoke_reddit(ctx, "awwnime")
@commands.command()
async def husbando(self, ctx: commands.Context):
"""Random husbando (anime boy)."""
sub = random.choice(("cuteanimeboys", "bishounen"))
await self.invoke_reddit(ctx, sub)
@commands.command()
async def yuri(self, ctx: commands.Context):
"""Random yuri (anime lesbian couple) art."""
await self.invoke_reddit(ctx, "wholesomeyuri")
@commands.command()
async def neko(self, ctx: commands.Context):
"""Random neko (anime cat girl/boy)."""
db = self.bot.get_command("db")
await db(ctx, tags="cat_ears")
@commands.command()
@commands.is_nsfw()
async def ecchi(self, ctx: commands.Context):
"""
Random ecchi image.
NSFW channels only.
"""
db = self.bot.get_command("db")
await db(ctx, tags="rating:q score:>10")
@commands.command()
@commands.is_nsfw()
async def hentai(self, ctx: commands.Context):
"""
Random hentai image.
NSFW channels only.
"""
db = self.bot.get_command("db")
await db(ctx, tags="rating:e score:>10")
@commands.command()
async def hug(self, ctx: commands.Context, member: discord.Member):
"""Hug someone."""
async with self.bot.session.get("https://some-random-api.ml/animu/hug") as r:
json = await r.json()
url = json["link"]
embed = self.fun_embed(f"{ctx.author.mention} hugged {member.mention}!")
embed.set_image(url=url)
await ctx.send(embed=embed)
@commands.command()
async def pat(self, ctx: commands.Context, member: discord.Member):
"""Pat someone."""
async with self.bot.session.get("https://some-random-api.ml/animu/pat") as r:
json = await r.json()
url = json["link"]
embed = self.fun_embed(f"{ctx.author.mention} pats {member.mention}!")
embed.set_image(url=url)
await ctx.send(embed=embed)
@commands.command()
async def bonk(self, ctx: commands.Context, member: discord.Member):
"""Bonk someone."""
async with self.bot.session.get("https://waifu.pics/api/sfw/bonk") as r:
json = await r.json()
url = json["url"]
embed = self.fun_embed(f"{ctx.author.mention} bonked {member.mention}!")
embed.set_image(url=url)
await ctx.send(embed=embed)
@commands.command()
async def httpcat(self, ctx: commands.Context, code: int):
"""Sends a cat for the given HTTP code."""
try:
code = http.HTTPStatus(code).value
await ctx.reply(f"https://http.cat/{code}")
except Exception:
await ctx.reply("https://http.cat/404")
@commands.command()
async def httpdog(self, ctx: commands.Context, code: int):
"""Sends a dog for the given HTTP code."""
try:
code = http.HTTPStatus(code).value
await ctx.reply(f"https://http.dog/{code}.jpg")
except Exception:
await ctx.reply("https://http.dog/404.jpg")
@commands.command(aliases=["av"])
async def avatar(self, ctx: commands.Context, user: Optional[discord.User]):
"""
Returns your or another user's avatar.
Works with names, mentions and IDs.
"""
if not user:
user = ctx.message.author
if user.display_avatar.is_animated():
url = gif = user.display_avatar.replace(size=4096, format="gif").url
static = user.display_avatar.replace(size=4096, format="png").url
description = f"[gif]({gif}) | [static]({static})"
else:
url = png = user.display_avatar.replace(size=4096, format="png").url
jpg = user.display_avatar.replace(size=4096, format="jpg").url
webp = user.display_avatar.replace(size=4096, format="webp").url
description = f"[png]({png}) | [jpg]({jpg}) | [webp]({webp})"
embed = discord.Embed(
description=description,
color=user.colour if user.colour.value != 0 else self.bot.invisible,
)
embed.set_image(url=url)
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
await ctx.reply(embed=embed)
@commands.command()
async def banner(self, ctx: commands.Context, user: discord.User = None):
"""
Returns your or another user's custom banner.
Works with names, mentions and IDs.
"""
if not user:
user = ctx.author
if user.id in [member.id for member in self.bot.users]:
user = await self.bot.fetch_user(user.id)
banner: discord.Asset = user.banner
if not banner:
banner_colour = user.accent_colour
if banner_colour:
colour_cmd = self.bot.get_command("color")
return await colour_cmd(ctx, colour=banner_colour)
else:
raise ArtemisError(f"{user.display_name} does not have a custom banner set.")
if banner.is_animated():
url = gif = banner.replace(size=4096, format="gif").url
static = banner.replace(size=4096, format="png").url
description = f"[gif]({gif}) | [static]({static})"
else:
url = png = banner.replace(size=4096, format="png").url
jpg = banner.replace(size=4096, format="jpg").url
webp = banner.replace(size=4096, format="webp").url
description = f"[png]({png}) | [jpg]({jpg}) | [webp]({webp})"
embed = discord.Embed(description=description, color=self.bot.invisible)
embed.set_image(url=url)
embed.set_author(name=user.display_name, icon_url=user.display_avatar.url)
await ctx.reply(embed=embed)
@commands.group(name="reddit", invoke_without_command=True)
async def reddit_(self, ctx: commands.Context, subreddit: str = "all"):
"""Shows a random post from reddit or a given subreddit."""
async with ctx.typing():
post = await self.bot.reddit.random(subreddit)
embeds = await post.to_embed(ctx.message)
await ctx.reply(embeds=embeds)
@reddit_.command()
async def show(self, ctx: commands.Context, pid: str):
"""Displays a rich reddit post embed for a given post ID."""
await ctx.typing()
post = await self.bot.reddit.post(pid=pid)
if not post:
raise ArtemisError("Invalid post ID.")
embeds = await post.to_embed(ctx.message)
await ctx.reply(embeds=embeds)
@commands.command(aliases=["4chan", "da"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def desuarchive(self, ctx: commands.Context, board: str, *, query: str):
"""
Search through the desuarchive.
<board> - board actively archived by desuarchive or "all"
"""
icon = "https://desuarchive.org/favicon.ico"
headers = {"User-Agent": self.bot.user_agent}
banned_boards = ["aco", "d", "gif"]
board = quote(board)
query = quote(query)
if board == "all":
board = "_"
elif board in banned_boards:
return await ctx.reply("Only SFW boards are allowed.")
await ctx.typing()
async with self.bot.session.get(
f"https://desuarchive.org/{board}/search/text/{query}", headers=headers
) as r:
html = await r.text()
if "Page not found." in html:
return await ctx.reply("Board not found.")
elif "No results found." in html:
return await ctx.reply("No results found.")
soup = BeautifulSoup(html, "lxml")
embeds = []
posts = soup.select(".post_wrapper")
for post in posts:
title = post.select_one(".post_title").text
if not title:
title = f"{post.select_one('.post_author').text} {post.select_one('time').text} UTC"
post_url = post.find(
"a", attrs={"href": re.compile(r"https://desuarchive.org/.*?/thread/")}
)["href"]
board = post_url.split("/")[-4]
if board in banned_boards:
continue
board_url = f"https://desuarchive.org/{board}/"
description = post.select_one(".text")
for br in description.select("br"):
br.replace_with("\n")
description = trim(re.sub(r"(>)(\w.*)", r"\g<1> \g<2>", description.text), 4096)
img = post.select_one(".thread_image_box .thread_image_link")
embed = discord.Embed(
title=title, description=description, url=post_url, color=self.bot.invisible
)
embed.set_author(name=f"desuarchive - /{board}/", url=board_url, icon_url=icon)
if img:
embed.set_image(url=img["href"])
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def codewaifu(self, ctx: commands.Context, *, lang: str = None):
"""[Anime girls holding programming books.](https://github.com/cat-milk/Anime-Girls-Holding-Programming-Books)"""
repo = "https://api.github.com/repos/cat-milk/Anime-Girls-Holding-Programming-Books"
alt = {
"js": "javascript",
"assembly": "asm",
"golang": "go",
"mongo": "mongodb",
"ray tracing": "raytracing",
"quantum": "quantum computing",
"ts": "typescript",
"vb": "visual basic",
"cpp": "c++",
}
langs = await self.bot.cache.get("anime_books:langs")
if not langs:
await ctx.typing()
async with self.bot.session.get(f"{repo}/contents") as r:
data = await r.json()
langs = [entry["name"] for entry in data if entry["type"] == "dir"]
await self.bot.cache.set("anime_books:langs", langs)
if not lang:
lang = random.choice(langs)
else:
if lang in alt:
lang = alt[lang]
lang = utils.fuzzy_search_one(lang, langs, cutoff=70)
if not lang:
return await ctx.reply("No code waifus for that language found.")
lang = quote(lang)
data = await self.bot.cache.get(f"anime_books:{lang}")
if not data:
async with self.bot.session.get(f"{repo}/contents/{lang}") as r:
await ctx.typing()
data = await r.json()
await self.bot.cache.set(f"anime_books:{lang}", data, ttl=3600)
img = random.choice(data)
await ctx.reply(img["download_url"])
@commands.group(aliases=["ffxiv"])
async def xiv(self, ctx: commands.Context):
"""Final Fantasy XIV commands."""
if ctx.invoked_subcommand is None:
await ctx.send("Invalid subcommand passed.")
@xiv.command(aliases=["chara"])
async def character(self, ctx: commands.Context, *, query: str):
"""Search for player characters in all worlds."""
LODESTONE_URL = "https://eu.finalfantasyxiv.com/lodestone/character/"
await ctx.typing()
params = {"name": query, "columns": "ID,Name,Server"}
async with self.bot.session.get("https://xivapi.com/character/search", params=params) as r:
if not r.ok:
return await ctx.reply(f"XIV API Error: {r.status} {r.reason}")
data = await r.json()
characters = data["Results"]
if not characters:
return await ctx.reply("No results found.")
elif len(characters) == 1:
character = characters[0]
else:
view = DropdownView(ctx, characters, lambda x: x["Name"], lambda x: x["Server"])
character = await view.prompt("Which character?")
if not character:
return
await ctx.typing()
chid = character["ID"]
params = {
"columns": "Character.Name,Character.Avatar,Character.Portrait,Character.ActiveClassJob"
}
async with self.bot.session.get(f"https://xivapi.com/character/{chid}", params=params) as r:
if not r.ok:
return await ctx.reply(f"XIV API Error: {r.status} {r.reason}")
data = await r.json()
character = data["Character"]
name = character["Name"]
url = LODESTONE_URL + str(chid)
portrait_url = character["Portrait"]
avatar_url = character["Avatar"]
embed = discord.Embed(title=name, url=url, color=0x293C66)
embed.set_image(url=portrait_url)
embed.set_thumbnail(url=avatar_url)
embed.set_author(
name="The Lodestone",
icon_url="https://img.finalfantasyxiv.com/lds/h/0/U2uGfVX4GdZgU1jASO0m9h_xLg.png",
)
active_job = character["ActiveClassJob"]
job_name = active_job["Name"].title()
job_level = active_job["Level"]
embed.description = f"Level **{job_level}**\n{job_name}"
await ctx.reply(embed=embed)
@xiv.command()
async def item(self, ctx: commands.Context, *, query: str):
"""Search for items."""
await ctx.typing()
params = {"string": query, "columns": "Name,Url", "indexes": "item"}
async with self.bot.session.get("https://xivapi.com/search", params=params) as r:
if not r.ok:
return await ctx.reply(f"XIV API Error: {r.status} {r.reason}")
data = await r.json()
results = data["Results"]
if not results:
return await ctx.reply("No results found.")
elif len(results) == 1:
result = results[0]
else:
view = DropdownView(ctx, results, lambda x: x["Name"])
result = await view.prompt("Which item?")
if not result:
return
await ctx.typing()
url = "https://xivapi.com" + result["Url"]
params = {
"columns": "ClassJobCategory.Name,DamageMag,DamagePhys,DefenseMag,DefensePhys,DelayMs,Description,IconHD,ItemUICategory.Name,LevelEquip,LevelItem,Name,Rarity,Stats"
}
async with self.bot.session.get(url, params=params) as r:
if not r.ok:
return await ctx.reply(f"XIV API Error: {r.status} {r.reason}")
data = await r.json()
name = data["Name"]
category = data["ItemUICategory"]["Name"]
icon_url = "https://xivapi.com" + data["IconHD"]
# rarity = item_rarity[data["Rarity"]]
item_level = data["LevelItem"]
job_category = data["ClassJobCategory"]["Name"]
equip_level = data["LevelEquip"]
description = data["Description"].replace("\n\n\n\n", "\n\n")
mag_dmg = ("Magic Damage", int(data["DamageMag"]))
phys_dmg = ("Damage", int(data["DamagePhys"]))
dmg = max(mag_dmg, phys_dmg, key=lambda x: x[1])
mag_def = ("Magic Defense", int(data["DefenseMag"]))
phys_def = ("Defense", int(data["DefensePhys"]))
main_stats = [dmg, mag_def, phys_def]
main_stats = [stat for stat in main_stats if stat[1] > 0]
main_stats.sort(key=lambda x: x[0])
if int(data["DelayMs"]):
main_stats.append(("Delay", round(int(data["DelayMs"]) / 1000, 2)))
if data["Stats"]:
bonuses = [(re.sub("([A-Z]+)", r" \1", k), v["NQ"]) for k, v in data["Stats"].items()]
else:
bonuses = []
embed = discord.Embed(title=name, color=0x293C66)
embed.set_thumbnail(url=icon_url)
embed.set_author(
name="Eorzea Database",
icon_url="https://img.finalfantasyxiv.com/lds/h/0/U2uGfVX4GdZgU1jASO0m9h_xLg.png",
)
desc = f"{category}\nItem Level **{item_level}**\n\n{job_category or 'All Classes'}\nLv. **{equip_level}**\n\n"
if description:
desc += f"{description}\n\n"
for bonus in bonuses:
desc += f"{bonus[0]}: **+{bonus[1]}**\n"
embed.description = desc
for stat in main_stats:
embed.add_field(name=stat[0], value=stat[1])
await ctx.reply(embed=embed)
@xiv.command(aliases=["fr", "fashion"])
async def fashionreport(self, ctx: commands.Context):
"""Displays the latest Fashion Report requirements."""
headers = {"User-Agent": self.bot.real_user_agent}
await ctx.typing()
async with self.bot.session.get(
f"{config.api_base_url}/xiv/kaiyoko", headers=headers, allow_redirects=False
) as r:
title = r.headers.get("x-title")
embed = discord.Embed(title=title, color=0xE7DFCE)
embed.set_image(url=f"{config.api_base_url}/xiv/kaiyoko?includeMeta=false&t={utils.time()}")
await ctx.reply(embed=embed)
@commands.command(aliases=["fs"])
async def foalsay(self, ctx: commands.Context, *, query: str):
"""
Search for dialogue lines in MLP:FiM transcripts.
[Command name generated with ChatGPT.](https://files.catbox.moe/e66t2g.png)
"""
if len(query) < 3:
return await ctx.reply("Your search term must be at least 3 characters long!")
query = query.strip().lower()
results = []
for entry in fim_transcripts:
lines = entry["text"].splitlines()
for idx, line in enumerate(lines):
line = line.split(":", 1)[-1].strip()
if not line:
continue
if query in line.lower():
results.append({"line": idx, "entry": entry})
if not results:
return await ctx.reply("No results found.")
embeds = []
for result in results:
url = result["entry"]["url"]
url = url[:28] + "Transcripts/" + url[28:]
embed = discord.Embed(title=result["entry"]["title"], url=url, color=0x883E97)
embed.set_author(
name="Pony Transcripts", icon_url="https://files.catbox.moe/h1wxle.png"
)
line_no = result["line"]
lines = result["entry"]["text"].splitlines()
line = lines[line_no]
embed.description = ""
line_before = lines[line_no - 1 : line_no]
if line_before:
embed.description += f"{line_before[0]}\n"
# embed.description += f"**{line}**\n"
line = re.sub(rf"({query})", r"**\g<1>**", line, flags=re.IGNORECASE)
embed.description += f"{line}\n"
line_after = lines[line_no + 1 : line_no + 2]
if line_after:
embed.description += f"{line_after[0]}\n"
embed.description = re.sub(r"(^.+?):", r"**\g<1>**:", embed.description, flags=re.M)
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command(name="pokedex", aliases=["poke", "pokémon", "pokemon", "poké", "pokédex"])
async def _pokedex(self, ctx: commands.Context, *, query: str):
"""Search for a pokémon."""
type_map = {
"normal": 11053176,
"fire": 15761456,
"fighting": 12595240,
"water": 6852848,
"flying": 11047152,
"grass": 7915600,
"poison": 10502304,
"electric": 16306224,
"ground": 14729320,
"psychic": 16275592,
"rock": 12099640,
"ice": 10016984,
"bug": 11057184,
"dragon": 7354616,
"ghost": 7362712,
"dark": 7362632,
"steel": 12105936,
"fairy": 15636908,
"???": 6856848,
}
if query.isdigit():
result = next((entry for entry in pokedex if entry["id"] == int(query)), None)
else:
result = utils.fuzzy_search_one(query, pokedex, "name", cutoff=60)
if not result:
raise ArtemisError("Pokémon not found.")
embed = discord.Embed(
title=result["name"], url="https://www.pokemon.com/us/pokedex/" + result["slug"]
)
embed.color = type_map.get(result["type"][0].lower(), type_map["???"])
embed.set_author(
name="#" + result["number"], icon_url="https://www.pokemon.com/favicon.ico"
)
embed.set_image(url=f"{config.cdn_base_url}/pokedex/{result['id']:>03}.png")
types = ", ".join([t.title() for t in result["type"]])
abilities = ", ".join(result["abilities"])
weaknesses = ", ".join(result["weakness"])
embed.add_field(name="Type", value=types, inline=False)
embed.add_field(name="Abilities", value=abilities, inline=False)
embed.add_field(name="Weaknesses", value=weaknesses, inline=False)
await ctx.reply(embed=embed)
async def setup(bot: Artemis):
await bot.add_cog(Funhouse(bot))

753
cogs/language.py Normal file
View File

@ -0,0 +1,753 @@
from __future__ import annotations
import asyncio
import re
from io import BytesIO
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote, quote_plus, unquote
from aiocache import cached
import discord
import gtts
import pendulum
from aiogoogletrans import LANGUAGES as GT_LANGUAGES
from aiogoogletrans import Translator
from bs4 import BeautifulSoup, Tag
from discord import app_commands
from discord.ext import commands
from wiktionaryparser import WiktionaryParser
import utils
from utils import iso_639
from utils.common import (
ArtemisError,
Stopwatch,
read_json,
)
from utils.constants import (
GT_LANGUAGES_EXTRAS,
WIKT_LANGUAGES,
)
from utils.flags import TranslateFlags, TTSFlags, WiktionaryFlags
from utils.views import ViewPages
if TYPE_CHECKING:
from bot import Artemis
# Mod aiogoogletrans
GT_LANGUAGES.update(GT_LANGUAGES_EXTRAS)
translator = Translator()
translator.lock = asyncio.Lock()
# Load toki pona data
nimi = read_json("data/nimi.json")
nimi_lookup = {entry["word"]: entry for entry in nimi}
nimi_reverse_lookup = {entry["definition"]: entry for entry in nimi}
@cached()
async def get_deepl_languages():
languages = [
"bg",
"cs",
"da",
"de",
"el",
"en",
"es",
"et",
"fi",
"fr",
"hu",
"id",
"it",
"ja",
"ko",
"lt",
"lv",
"nb",
"nl",
"pl",
"pt",
"ro",
"ru",
"sk",
"sl",
"sv",
"tr",
"uk",
"zh",
]
languages = {code: iso_639.get_language_name(code) for code in languages}
if languages.get("el"):
languages["el"] = "Greek"
return languages
# Translation slash commands
@app_commands.context_menu(name="Translate (DeepL)")
async def deepl_slash(interaction: discord.Interaction, message: discord.Message):
await interaction.response.defer(ephemeral=True)
content = message.content
if not content:
return await interaction.followup.send("No text detected.", ephemeral=True)
languages = await get_deepl_languages()
try:
result = await interaction.client.api.deepl(content, "auto", "en")
except Exception as err:
return await interaction.followup.send(f"Error: {err}", ephemeral=True)
src = result.src.lower()
dest = result.dst.lower()
try:
src = languages[src]
dest = languages[dest]
except Exception:
pass
translation = result.translation
embed = discord.Embed(colour=0x0F2B46)
embed.set_author(
name="DeepL",
icon_url="https://www.google.com/s2/favicons?domain=deepl.com&sz=64",
)
embed.add_field(name=f"From {src} to {dest}", value=translation)
await interaction.followup.send(embed=embed, ephemeral=True)
@app_commands.context_menu(name="Translate (Google)")
async def gt_slash(interaction: discord.Interaction, message: discord.Message):
await interaction.response.defer(ephemeral=True)
content = message.content
if not content:
return await interaction.followup.send("No text detected.", ephemeral=True)
async with translator.lock:
try:
result = await translator.translate(content, src="auto", dest="en")
except ValueError as err:
return await interaction.followup.send(f"Error: {err}", ephemeral=True)
src = GT_LANGUAGES[result.src.lower()].title()
translated = result.text
embed = discord.Embed(description=translated, color=0x4B8CF5)
embed.set_footer(
text=f"Translated from {src} by Google",
icon_url="https://upload.wikimedia.org/wikipedia/commons/d/db/Google_Translate_Icon.png",
)
await interaction.followup.send(embed=embed, ephemeral=True)
# Modded wiktionary parser
class ModdedWiktionaryParser(WiktionaryParser):
def __init__(self, bot: Artemis):
super().__init__()
self.include_part_of_speech("romanization")
self.include_part_of_speech("prefix")
self.include_part_of_speech("suffix")
self.bot: Artemis = bot
self.headers = {"User-Agent": self.bot.user_agent}
self.lock = asyncio.Lock()
def extract_first_language(self) -> str:
lang = self.soup.find("span", {"class": "toctext"})
if not lang:
lang = self.soup.find("span", {"class": "mw-headline"})
if not lang:
return None
return lang.text.strip()
async def fetch(self, word: str, language: Optional[str] = None):
async with self.bot.session.get(self.url.format(word), headers=self.headers) as r:
html = await r.text()
html = html.replace(">\n<", "><")
async with self.lock:
self.soup = BeautifulSoup(html, "lxml")
self.current_word = word
self.clean_html()
first_language = self.extract_first_language()
language = first_language if not language else language
if not language:
raise ArtemisError("Cannot extract language from the page, try specifying one?")
ret = await asyncio.to_thread(self.get_word_data, language.lower())
self.soup = None
return ret, first_language
class Language(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
self.wikt_parser = ModdedWiktionaryParser(self.bot)
for menu in (deepl_slash, gt_slash):
self.bot.tree.add_command(menu)
@commands.command()
async def langname(self, ctx: commands.Context, code: str):
"""
Converts language code to language name.
Supports ISO `639-1` to `639-3`.
"""
found = iso_639.get_language_name(code)
if not found:
return await ctx.reply("Language code not found.")
m = f"Code: **{code.lower()}**\nName: **{found}**"
await ctx.reply(m)
@commands.command()
async def langcode(self, ctx: commands.Context, *, name: str):
"""
Converts language name to language codes in ISO `639-1` to `639-3`.
"""
found = iso_639.get_language_code(name)
if not found:
return await ctx.reply("Language name not found.")
codes = []
for code in found:
m = f"Name: **{code['name']}**\n"
m += f"part3: **{code['part3']}**\n"
if code["part2b"]:
m += f"part2b: **{code['part2b']}**\n"
if code["part2t"]:
m += f"part2t: **{code['part2t']}**\n"
if code["part1"]:
m += f"part1: **{code['part1']}**\n"
codes.append(m)
view = ViewPages(ctx, codes)
await view.start()
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def jisho(self, ctx: commands.Context, *, query: str):
"""Look up a word in Jisho (JP-EN / EN-JP dictionary)."""
base = "https://jisho.org/api/v1/search/words?keyword="
payload = base + quote_plus(query)
await ctx.typing()
async with self.bot.session.get(payload) as r:
json = await r.json()
data = json["data"]
if not data:
return await ctx.reply("No results found.")
embeds = []
for meaning in data:
slug = meaning["slug"]
word = meaning["japanese"][0].get("word")
reading = meaning["japanese"][0].get("reading")
furigana = None
if word and reading:
furigana = reading
source = reading
else:
source = word or reading
romaji = utils.romajify(source)
embed = discord.Embed(
title=word or reading,
description=f"{furigana or ''}\n{romaji}",
url=f"https://jisho.org/word/{quote(slug)}",
colour=0x56D926,
)
embed.set_author(name="Jisho", icon_url="https://i.imgur.com/SO4IGvY.png")
for sense in meaning["senses"]:
parts_of_speech = ", ".join(sense["parts_of_speech"])
definition = ", ".join(sense["english_definitions"])
tags = ", ".join(sense["tags"]) or ""
tags = tags if "Usually written using kana alone" not in tags else ""
name = f"{parts_of_speech}\n{tags}".strip()
if "Wikipedia definition" in parts_of_speech:
wiki = sense["links"][0]
definition = f"[{definition}]({wiki['url']})"
embed.add_field(name=name or "Special", value=definition, inline=False)
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command(usage="[source:auto] [s:auto] [dest:en] [d:en] <text>")
@commands.cooldown(1, 2, commands.BucketType.default)
async def gt(self, ctx: commands.Context, *, flags: TranslateFlags):
"""
Translation using Google Translate.
Optional flags:
`source` or `s` - Source language, defaults to `auto`.
`dest` or `d` - Target (destination) language, defaults to `en`.
Example usage:
`{prefix}gt Hej, co tam?`
`{prefix}gt s:pl d:en Hey, what's up?`
"""
text = flags.text
src = flags.source or "auto"
dest = flags.dest or "en"
if not text:
raise ArtemisError("No text provided.")
async with translator.lock:
try:
result = await translator.translate(text, src=src, dest=dest)
except ValueError as err:
return await ctx.reply(f"Error: {err}")
src = result.src.lower()
try:
src = GT_LANGUAGES[src].title()
except Exception:
pass
dest = GT_LANGUAGES[result.dest.lower()].title()
translation = result.text
if len(translation) > 1024:
buff = f"--- From {src} to {dest} ---\n{translation}".encode("utf-8")
buff = BytesIO(buff)
file = discord.File(buff, f"{src}-{dest}.txt")
return await ctx.reply(
"The translation could not fit on the screen, so here's a file:",
file=file,
)
embed = discord.Embed(colour=0x4B8CF5)
embed.set_author(
name="Google Translate",
icon_url="https://upload.wikimedia.org/wikipedia/commons/d/db/Google_Translate_Icon.png",
)
embed.add_field(name=f"From {src} to {dest}", value=translation)
await ctx.reply(embed=embed)
@commands.command(usage="[source:auto] [s:auto] [dest:en] [d:en] <text>")
@commands.max_concurrency(1)
@commands.cooldown(1, 2, commands.BucketType.default)
async def deepl(self, ctx: commands.Context, *, flags: TranslateFlags):
"""
Translation using DeepL.
Optional flags:
`source` or `s` - Source language, defaults to `auto`.
`dest` or `d` - Target (destination) language, defaults to `en`.
Example usage:
`{prefix}deepl Hej, co tam?`
`{prefix}trd s:pl d:en Hey, what's up?`
"""
text = flags.text
src = flags.source or "auto"
dest = flags.dest or "en"
if not text:
raise ArtemisError("No text provided.")
await ctx.typing()
languages = await get_deepl_languages()
if src != "auto" and src not in languages or dest not in languages:
msg = "Unsupported language code, list of supported languages:\n\n"
msg += "\n".join((f"`{k}` - {v}" for k, v in languages.items()))
embed = discord.Embed(description=msg, color=discord.Color.red())
return await ctx.reply(embed=embed)
try:
result = await self.bot.api.deepl(text, src, dest)
except Exception as err:
return await ctx.reply(err)
src = result.src.lower()
dest = result.dst.lower()
try:
src = languages[src]
dest = languages[dest]
except Exception:
pass
translation = result.translation
if len(translation) > 1024:
buff = f"--- From {src} to {dest} ---\n{translation}".encode("utf-8")
buff = BytesIO(buff)
file = discord.File(buff, f"{src}-{dest}.txt")
return await ctx.reply(
"The translation could not fit on the screen, so here's a file:",
file=file,
)
embed = discord.Embed(colour=0x0F2B46)
embed.set_author(
name="DeepL",
icon_url="https://www.google.com/s2/favicons?domain=deepl.com&sz=64",
)
embed.add_field(name=f"From {src} to {dest}", value=translation)
await ctx.reply(embed=embed)
@commands.command(usage="[lang:en] [l:en] <text>")
@commands.max_concurrency(1)
async def tts(self, ctx: commands.Context, *, flags: TTSFlags):
"""
Make Google TTS say some stuff.
Optional flags:
`lang` or `l` - Two-letter language code.
Defaults to English (`en`).
[Supported languages.](https://mystb.in/RemovalNilVariance.json)
Example usage:
`{prefix}tts apple`
`{prefix}tts apple cider`
`{prefix}tts l:pl jabłko`
`{prefix}tts l:de apfel`
"""
text = flags.text
lang = flags.lang or "en"
if lang not in gtts.lang.tts_langs().keys():
return await ctx.reply("Sorry, I couldn't find that language!")
elif not text:
return await ctx.reply("No text provided.")
await ctx.typing()
mp3_fp = BytesIO()
filename = f"{ctx.author.display_name}-TTS-{lang}.mp3"
with Stopwatch() as sw:
tts = await asyncio.to_thread(gtts.gTTS, text, lang=lang)
tts.write_to_fp(mp3_fp)
mp3_fp.seek(0)
discord_file = discord.File(mp3_fp, filename)
diff = round(sw.result, 2)
await ctx.reply(content=f"Finished in {diff}s.", file=discord_file)
@commands.command(aliases=["ud"])
async def urban(self, ctx: commands.Context, *, phrase):
"""Look up a phrase in the Urban Dictionary."""
# thanks, Danny
ref_re = re.compile(r"(\[(.+?)\])")
def repl(m):
word = m.group(2)
return f'[{word}](http://{word.replace(" ", "-")}.urbanup.com)'
await ctx.typing()
params = {"term": phrase}
async with self.bot.session.get(
"https://api.urbandictionary.com/v0/autocomplete-extra", params=params
) as r:
data = await r.json()
results = data["results"]
if not results:
raise ArtemisError("No results found.")
params = {"term": results[0]["term"]}
async with self.bot.session.get(
"http://api.urbandictionary.com/v0/define", params=params
) as r:
data = await r.json()
results = data["list"]
if not results:
raise ArtemisError("No results found.")
embeds = []
for result in results:
title = result["word"]
definition = ref_re.sub(repl, result["definition"])
example = ref_re.sub(repl, result["example"])
permalink = result["permalink"]
written_on = pendulum.parse(result["written_on"][0:10], tz="UTC")
if not example:
example = "No example provided."
embed = discord.Embed(
title=title,
description=utils.trim(definition, 4096),
url=permalink,
color=0x134FE6,
timestamp=written_on,
)
embed.add_field(name="Example:", value=utils.trim(example, 1024), inline=False)
embed.set_footer(
text=f"{result['thumbs_up']} 👍 {result['thumbs_down']} 👎 • Written by {result['author']}"
)
embed.set_author(name="Urban Dictionary", icon_url="https://i.imgur.com/2NDCme4.png")
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command(aliases=["wikt"], usage="[lang:] [l:] <phrase>")
@commands.cooldown(1, 2, commands.BucketType.default)
async def wiktionary(self, ctx: commands.Context, *, flags: WiktionaryFlags):
"""
Look up words in Wiktionary for different languages.
Optional flags:
`lang` or `l` - Language name or two-letter code to look up the phrase in.
Defaults to the first language found on the Wiktionary page.
If you want to use a language name with spaces, replace spaces with underscores (`_`).
[Supported languages (mostly).](https://en.wiktionary.org/wiki/Wiktionary:List_of_languages)
Example usage:
`{prefix}wiktionary apple`
`{prefix}wiktionary apple cider`
`{prefix}wiktionary l:polish jabłko`
`{prefix}wiktionary lang:ja kawaii`
"""
favicon = "https://en.wiktionary.org/static/apple-touch/wiktionary/en.png"
SEARCH_API = "https://en.wiktionary.org/w/api.php"
params = {
"action": "opensearch",
"format": "json",
"formatversion": "2",
"search": "",
"namespace": "0",
"limit": "10",
}
headers = {"User-Agent": self.bot.user_agent}
phrase = flags.phrase
language = flags.lang
if language:
try:
language = WIKT_LANGUAGES[language.lower()]
except KeyError:
language = language.replace("_", " ").title()
if language not in WIKT_LANGUAGES.values():
return await ctx.reply(
f"Language `{language}` not found.\nSee `$help wikt` for supported languages."
)
if not phrase:
return await ctx.reply("No phrase provided.")
params["search"] = phrase
await ctx.typing()
async with self.bot.session.get(SEARCH_API, params=params, headers=headers) as r:
data = await r.json()
links = data[3]
if not links:
return await ctx.reply(f"Phrase `{phrase}` not found.")
link = links[0]
phrase = link.split("/")[-1]
phrase_pretty = unquote(phrase).replace("_", " ")
entries, first_language = await self.wikt_parser.fetch(phrase, language)
if not entries:
return await ctx.reply(
f"Found suggested phrase `{phrase_pretty}` but not in `{language}`."
)
if not language:
language = first_language or "Unknown"
embeds = []
for entry in entries:
embed = discord.Embed(title=phrase_pretty, url=link, colour=0xFEFEFE)
embed.set_author(name=f"Wiktionary - {language}", icon_url=favicon)
etymology = entry.get("etymology")
if etymology:
embed.add_field(name="Etymology", value=utils.trim(etymology, 1024), inline=False)
parts_of_speech = entry.get("definitions")
if parts_of_speech:
max_defs = 3
if len(parts_of_speech) == 1:
max_defs = 10
for part_of_speech in parts_of_speech[:5]:
name = part_of_speech["partOfSpeech"]
definitions = part_of_speech["text"]
if not definitions:
continue
extra_info = definitions.pop(0)
definitions_formatted = []
for def_idx, definiton in enumerate(definitions[:max_defs], start=1):
definitions_formatted.append(f"`{def_idx}.` {definiton}")
if len(definitions) > max_defs:
definitions_formatted.append(
f"[**+ {len(definitions) - max_defs} more**]({link})"
)
definitions_formatted = "\n".join(definitions_formatted)
embed.add_field(
name=utils.trim(f"{name}\n{extra_info}", 256),
value=definitions_formatted,
inline=False,
)
pronunciations = entry.get("pronunciations")
if pronunciations and language != "Chinese":
texts = pronunciations.get("text")
if texts:
texts_formatted = []
for text_idx, text in enumerate(texts[:5], start=1):
texts_formatted.append(f"`{text_idx}.` {text}")
if len(texts) > 5:
texts_formatted.append(f"[**+ {len(texts) - 5} more**]({link})")
texts_formatted = "\n".join(texts_formatted)
embed.add_field(name="Pronunciations", value=texts_formatted, inline=False)
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
@commands.command(usage="<wyraz/word>")
async def sjp(self, ctx: commands.Context, *, word: str):
"""
:flag_pl: Wyszukaj podany wyraz w słowniku języka polskiego ze strony `sjp.pl`.
Wpisy bez definicji zastąpione źródłem, na jakie powołuje się strona.
:flag_gb: Look up a word in the Polish dictionary sourced from `sjp.pl`.
Entries with missing definitions are replaced by a source referenced by the website.
"""
headers = {"User-Agent": self.bot.user_agent}
SJP_ICON = "https://i.imgur.com/b4JLozn.png"
params = {"q": word}
async with self.bot.session.get(
"https://sjp.pl/slownik/s/", params=params, headers=headers
) as r:
res = await r.json(content_type=None)
if not res["d"]:
return await ctx.reply(
f":flag_pl: `{word}` nie występuje w słowniku.\n:flag_gb: `{word}` not found in the dictionary."
)
word = quote(res["d"][0])
url = f"https://sjp.pl/{word}"
async with self.bot.session.get(url, headers=headers) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
for element in soup.find_all("br"):
element.append("\n")
entries = []
meanings = soup.select("h1")
for meaning in meanings:
word = meaning.text
original = None
definitions = []
dictionary = None
for element in meaning.next_siblings:
if element in meanings:
break
if not isinstance(element, Tag):
continue
if "margin: .5em" in element.get("style", ""):
definitions = [
re.sub(r"\d\.\s", "", defi).rstrip(";") for defi in element.text.split("\n")
]
orig = element.select_one(".lc")
if orig:
original = orig.text if orig.text != word else None
td = element.select_one("td")
if td:
dictionary = td.text
entries.append(
{
"word": word,
"root": original,
"definitions": definitions,
"dictionary": dictionary,
}
)
embed = discord.Embed(colour=0x2266CC).set_author(name="SJP.pl", icon_url=SJP_ICON, url=url)
for entry in entries:
word = entry["root"] or entry["word"]
definitions = entry["definitions"]
if not definitions:
if entry["dictionary"]:
definition = f"Występowanie: `{entry['dictionary']}`"
else:
definition = "Brak definicji / No definition"
else:
definition = ""
for idx, defi in enumerate(entry["definitions"], start=1):
definition += f"`{idx}.` {defi}\n"
embed.add_field(name=word, value=definition, inline=False)
await ctx.reply(embed=embed)
@commands.command()
async def nimi(self, ctx: commands.Context, *, query: str):
"""toki pona word list supporting toki pona and English lookup."""
spreadsheet = "https://docs.google.com/spreadsheets/d/1t-pjAgZDyKPXcCRnEdATFQOxGbQFMjZm-8EvXiQd2Po/edit#gid=0"
icon = "https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Toki_Pona_flag.svg/320px-Toki_Pona_flag.svg.png"
query = query.strip().replace(" ", " ").lower()
# try word lookup
entries = [nimi_lookup.get(query)]
# try definition lookup
if not entries[0]:
entries.pop()
for definition, entry in nimi_reverse_lookup.items():
if re.search(rf"\b{query}\b", definition.lower()):
entries.append(entry)
if not entries:
return await ctx.reply("No results found.")
embeds = []
for entry in entries:
embed = discord.Embed(
title=entry["word"],
description=entry["definition"],
url=spreadsheet,
color=0xFEFEFE,
)
embed.set_footer(text="nimi ale pona (2nd ed.)", icon_url=icon)
for k, v in entry.items():
if not v or k in ("word", "definition"):
continue
k = k.title() if k != "creator(s)" else "Creator(s)"
embed.add_field(name=k, value=v, inline=False)
embeds.append(embed)
view = ViewPages(ctx, embeds)
await view.start()
async def setup(bot: Artemis):
await bot.add_cog(Language(bot))

674
cogs/media.py Normal file
View File

@ -0,0 +1,674 @@
from __future__ import annotations, unicode_literals
import asyncio
import html
import re
import shlex
import struct
import zipfile
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote_plus
import discord
import humanize
import pendulum
import yt_dlp
from bs4 import BeautifulSoup
from discord.ext import commands
from PIL import Image
from pycaption import SRTWriter, WebVTTReader
from yt_dlp.utils import parse_duration
import utils
from utils.common import ArtemisError
from utils.constants import MAX_DISCORD_SIZE, MAX_LITTERBOX_SIZE
from utils.catbox import CatboxError
from utils.flags import DLFlags
from utils.iso_639 import get_language_name
from utils.views import DropdownView
if TYPE_CHECKING:
from bot import Artemis
TEMP_DIR = Path("data/temp/")
yt_dlp.utils.bug_reports_message = lambda: ""
DEFAULT_OPTS = {
"quiet": True,
"noprogress": True,
"no_warnings": True,
"socket_timeout": 5,
"noplaylist": True,
"playlistend": 1,
"nopart": True,
}
def format_ytdlp_error(error: str) -> str:
ret = utils.silence_url_embeds(error)
ret = (
ret.removeprefix("[generic] ")
.removeprefix("None: ")
.split("Set --default-search")[0]
.split("(caused by")[0]
.split("You might want to use a VPN")[0]
)
return ret
async def run_ytdlp(query: str, opts: dict, download: bool = True) -> dict:
try:
with yt_dlp.YoutubeDL(opts) as ytdl:
return await asyncio.to_thread(ytdl.extract_info, query, download=download)
except yt_dlp.utils.YoutubeDLError as error:
raise ArtemisError(format_ytdlp_error(error))
class Media(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
@commands.command(aliases=["nf"])
@commands.cooldown(1, 2, commands.BucketType.user)
async def netflix(self, ctx: commands.Context, *, query: str):
"""Check if and where a show is available on Netflix."""
await ctx.typing()
data = await self.bot.unogs.search(query)
if "total" not in data:
return await ctx.reply("The API returned no data, weird!")
elif data["total"] == 0:
return await ctx.reply("No results found.")
elif data["total"] == 1:
data = data["results"][0]
else:
view = DropdownView(
ctx,
data["results"],
lambda x: html.unescape(x["title"]),
placeholder="Choose title...",
)
data = await view.prompt()
if not data:
return
title = html.unescape(data["title"])
synopsis = html.unescape(data["synopsis"])
nfid = data["nfid"]
nfurl = f"https://www.netflix.com/title/{data['nfid']}"
img = data.get("poster") or data.get("img")
countries = await self.bot.unogs.fetch_details(nfid, "countries")
flags = " ".join([f":flag_{country['cc'].strip().lower()}:" for country in countries])
audio = []
subtitles = []
for country in countries:
audio += country["audio"].split(",")
subtitles += country["subtitle"].split(",")
audio, subtitles = sorted(set(audio)), sorted(set(subtitles))
audio, subtitles = [a for a in audio if a], [s for s in subtitles if s]
embed = discord.Embed(title=title, description=synopsis, url=nfurl, color=0xE50914)
if img and "http" in img:
embed.set_image(url=img)
embed.set_author(
name="Netflix",
icon_url="https://assets.nflxext.com/us/ffe/siteui/common/icons/nficon2016.png",
)
embed.add_field(name="Availability", value=flags)
embed.add_field(name="Audio", value=", ".join(audio), inline=False)
embed.add_field(name="Subtitles", value=", ".join(subtitles), inline=False)
await ctx.reply(embed=embed)
@commands.command(aliases=["thumb"])
async def thumbnail(self, ctx: commands.Context, url: str):
"""Gives you a video thumbnail URL for a video from any site supported by YTDL."""
url = url.strip("<>")
utils.check_for_ssrf(url)
await ctx.typing()
youtube = re.search(
r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([\w-]+)", url
)
if youtube:
thumbnail = f"https://i.ytimg.com/vi/{youtube.group(1)}/maxresdefault.jpg"
else:
info_dict = await run_ytdlp(url, DEFAULT_OPTS, download=False)
thumbnail = info_dict.get("thumbnail")
if not thumbnail:
return await ctx.reply("No thumbnail available.")
await ctx.reply(thumbnail)
@commands.command(aliases=["audio"])
@commands.max_concurrency(1)
async def dlaudio(self, ctx: commands.Context, url: str, fmt: Optional[str]):
"""
Downloads audio from a YouTube video in original format or mp3.
To convert the audio to mp3, pass 'mp3' after the URL.
"""
url = url.strip("<>")
utils.check_for_ssrf(url)
ytdl_opts = {
**DEFAULT_OPTS,
"format": "251/140/ba",
"outtmpl": TEMP_DIR.joinpath("%(id)s.%(ext)s").as_posix(),
"match_filter": yt_dlp.match_filter_func("duration < 1500"),
}
if fmt == "mp3":
ytdl_opts["postprocessors"] = [
{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}
]
async with ctx.typing():
info_dict = await run_ytdlp(url, ytdl_opts)
title = utils.romajify(info_dict.get("title"))
vid_id = info_dict.get("id")
ext = info_dict.get("ext") if fmt != "mp3" else "mp3"
filename = f"{vid_id}.{ext}"
pretty_filename = f"{title}.{ext}" if ext != "webm" else f"{title}.ogg"
path = TEMP_DIR / filename
if not path.exists():
return await ctx.reply("ERROR: The file is too big for me to upload!")
await ctx.reply(file=discord.File(path, pretty_filename))
path.unlink()
@commands.command(usage="<url> <lang>", aliases=["subs", "subtitles"])
async def dlsubs(self, ctx: commands.Context, url: str, lang: Optional[str]):
"""
Downloads a subtitle file from any site supported by YTDL.
Makes you choose the language if more than one detected and no `<lang>` given.
`<lang>` is optional if the video only has one subtitle file.
Pass `all` to `<lang>` to get all of the subtitles.
"""
url = url.strip("<>")
utils.check_for_ssrf(url)
ytdl_opts = {
**DEFAULT_OPTS,
"writesubtitles": True,
"subtitleslangs": ["all"],
}
async def process_one(data: dict) -> discord.File:
url = data.get("url")
ext = data["ext"]
if data.get("data") is not None:
sub_data = data["data"]
else:
async with self.bot.session.get(url) as r:
sub_data = await r.text()
if ext == "vtt":
try:
sub_data = str(SRTWriter().write(WebVTTReader().read(sub_data)))
ext = "srt"
except Exception:
pass
filename = f"{yt_dlp.utils.sanitize_filename(title)}-{data['lang']}.{ext}"
return discord.File(BytesIO(sub_data.encode("utf-8")), filename)
async def process(data: list[dict], lang: str = None) -> discord.File:
if lang:
found = discord.utils.find(lambda x: x["lang"] == lang)
if not data:
raise ArtemisError("No subtitles available for that language.")
return await process_one(found)
elif len(data) == 1:
return await process_one(data[0])
zip_buffer = BytesIO()
coros = [process_one(entry) for entry in data]
files: list[discord.File] = await asyncio.gather(*coros)
with zipfile.ZipFile(zip_buffer, "a", zipfile.ZIP_DEFLATED) as zip_file:
for file in files:
zip_file.writestr(file.filename, file.fp.read())
zip_buffer.seek(0)
filename = f"{title}-subs.zip"
return discord.File(zip_buffer, filename)
async with ctx.typing():
info_dict = await run_ytdlp(url, ytdl_opts, download=False)
title = utils.romajify(info_dict.get("title")).replace(" ", "_")
subtitles: dict = info_dict.get("requested_subtitles")
if not subtitles:
return await ctx.reply("No subtitles available.")
file = None
subtitles = [{"lang": k, **v} for k, v in subtitles.items()]
if lang:
if lang == "all":
file = await process(subtitles)
else:
try:
file = await process(subtitles, lang)
except KeyError:
return await ctx.reply("No subtitles available for that language.")
elif len(subtitles) == 1:
file = await process(subtitles)
elif len(subtitles) > 1:
view = DropdownView(
ctx,
subtitles,
lambda item: item["lang"],
lambda item: item.get("name") or get_language_name(item["lang"].lower()) or None,
"Choose one or more...",
25,
True,
)
view.message = await ctx.reply("Which language(s)?", view=view)
if await view.wait():
return await view.message.edit(content="You took too long!", view=None)
result = view.result
async with ctx.typing():
file = await process(result)
await ctx.reply(file=file)
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def mediainfo(self, ctx: commands.Context, url: str, format: Optional[str]):
"""Returns MediaInfo output for a media file."""
url = url.strip("<>")
utils.check_for_ssrf(url)
if not format:
format = (
"bv/best"
if any([domain in url for domain in ("youtube", "youtu.be")])
else "b/mp4/b*"
)
ytdl_opts = {**DEFAULT_OPTS, "format": format}
async with ctx.typing():
info_dict = await run_ytdlp(url, ytdl_opts, download=False)
title = info_dict.get("title")
url = info_dict["url"]
result = await utils.run_cmd(f'mediainfo "{url}"')
if not result.ok:
return await ctx.reply(result.decoded)
lines = result.decoded.split("\n")
lines.pop(1)
output = "\n".join(lines)
data = BytesIO(output.encode())
fp = discord.File(data, f"{utils.romajify(title)}.txt")
await ctx.reply(f"Media information for `{title}`", file=fp)
@commands.command(aliases=["screenshot", "ss"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def screencap(self, ctx: commands.Context, url: str, timestamp: Optional[str] = "1"):
"""
Takes a video screencap at a specified timestamp.
Valid timestamp formats:
- `SS` or `SS.ms`
- `HH:MM:SS` or `HH:MM:SS.ms`
"""
TIMESTAMP_RE = r"\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?"
SECONDS_RE = r"\d{1,5}(?:\.\d{1,3})?"
url = url.strip("<>")
utils.check_for_ssrf(url)
ytdl_opts = {**DEFAULT_OPTS, "format": "bv*/b"}
@utils.in_executor
def to_jpeg(image):
im = Image.open(image)
buff = BytesIO()
im.save(buff, "JPEG", quality=90)
buff.seek(0)
return buff
if not (re.fullmatch(TIMESTAMP_RE, timestamp) or re.fullmatch(SECONDS_RE, timestamp)):
return await ctx.reply("Invalid timestamp format, check out `$help screencap`.")
async with ctx.typing():
info_dict = await run_ytdlp(url, ytdl_opts, download=False)
title = info_dict["title"]
url = info_dict["url"]
if info_dict.get("is_live"):
args = f'ffmpeg -hide_banner -loglevel warning -i "{url}" -vframes 1 -c:v png -f image2 -'
else:
args = f'ffmpeg -hide_banner -loglevel warning -ss {timestamp} -i "{url}" -vframes 1 -c:v png -f image2 -'
result = await utils.run_cmd(args)
stdout, stderr = result.stdout, result.stderr
if not result.ok:
return await ctx.reply(stderr.decode().split("pipe:")[0])
w, h = struct.unpack(">II", stdout[16:20] + stdout[20:24])
msg = f"Resolution: {w}x{h}"
buff = BytesIO(stdout)
if len(stdout) > MAX_DISCORD_SIZE:
buff = await to_jpeg(buff)
msg += "\nThe image was too big for me to upload so I converted it to JPEG Q90."
dfile = discord.File(buff, f"{title}.png")
return await ctx.reply(content=msg, file=dfile)
@commands.command(usage="[format:] [trim:] <url>", aliases=["dl"])
@commands.max_concurrency(1)
@commands.cooldown(1, 30, commands.BucketType.user)
async def download(self, ctx: commands.Context, *, flags: DLFlags):
"""
Downloads videos from websites supported by youtube-dl.
The download fails if the video is more than 1 hour long or its filesize exceeds 1 GB.
Only one command can run at once and every user has a 30 second cooldown.
Optional flags:
`format` or `f` - youtube-dl format choice (only when trim flag is not present)
`trim` or `t` - Trim selection of the form `start-end`.
Valid trim selection formats:
- `SS-SS` or `SS.ms-SS.ms`
- `MM:SS-MM:SS` or `MM:SS.ms-MM:SS.ms`
- `HH:MM:SS-HH:MM:SS` or `HH:MM:SS.ms-HH:MM:SS.ms`
Examples:
`{prefix}download https://youtu.be/dQw4w9WgXcQ`
`{prefix}download f:22 https://youtu.be/o6wtDPVkKqI`
`{prefix}dl trim:41-58 https://youtu.be/uKxyLmbOc0Q`
`{prefix}dl t:01:15-01:27 https://youtu.be/qUk1ZoCGqsA`
`{prefix}dl t:120-160 https://www.reddit.com/r/anime/comments/f86otf/`
"""
path: Path = None
msg: discord.Message = None
finished = False
state = "downloading"
template = TEMP_DIR.joinpath("%(id)s.%(ext)s").as_posix()
url = flags.url
format = flags.format
trim = flags.trim
ss, to = flags.ss, None
async def monitor_download():
nonlocal msg, state
path = Path("./data/temp/")
while not finished:
content = "Processing..."
if state == "downloading":
match = None
files = list(path.iterdir())
if files:
match = max(files, key=lambda f: f.stat().st_size)
if match:
size = match.stat().st_size
size = humanize.naturalsize(size, binary=True)
content = f":arrow_down: `Downloading...` {size}"
else:
content = ":arrow_down: `Downloading...`"
elif state == "uploading":
content = ":arrow_up: `Uploading...`"
if not msg:
msg = await ctx.reply(content)
else:
msg = await msg.edit(content=content)
await asyncio.sleep(1)
if msg:
await msg.delete()
try:
url = url.strip("<>")
utils.check_for_ssrf(url)
if not url:
raise ArtemisError("No URL provided.")
def match_filter(info_dict, incomplete):
nonlocal url
if "#_sudo" in url and ctx.author.id == self.bot.owner_id:
return None
duration = info_dict.get("duration")
filesize = info_dict.get("filesize") or info_dict.get("filesize_approx")
is_live = info_dict.get("is_live")
if is_live:
raise ArtemisError("Streams are not supported.")
elif trim:
return None
elif not duration and not filesize:
raise ArtemisError("Failed to extract duration and filesize.")
elif filesize and (filesize < 1 or filesize > MAX_LITTERBOX_SIZE):
raise ArtemisError("The video is too big (> 1 GB).")
elif duration and (duration < 0 or duration > 3600):
raise ArtemisError("The video is too long (> 1 hour).")
else:
return None
ytdl_opts = {**DEFAULT_OPTS, "outtmpl": template, "match_filter": match_filter}
if "youtube.com" in url or "youtu.be" in url:
ytdl_opts["format"] = "248+251/247+251/137+140/136+140/bv*+ba/b"
else:
ytdl_opts["format_sort"] = ["ext", "+vcodec:avc"]
if trim:
dur = tuple(map(parse_duration, trim.strip().split("-")))
if len(dur) == 2 and all(t is not None for t in dur):
ss, to = dur
else:
raise ArtemisError("Invalid trim selection. Must be of the form `start-end`.")
args = {
"ffmpeg": shlex.split("-hide_banner -loglevel error"),
"ffmpeg_i": shlex.split(f"-ss {ss} -to {to}"),
}
ytdl_opts["format"] = f"({ytdl_opts['format']})[protocol!*=dash][protocol!*=m3u8]"
ytdl_opts["external_downloader"] = {"default": "ffmpeg"}
ytdl_opts["external_downloader_args"] = args
diff = to - ss
if diff > 3600:
raise ArtemisError("The trim selection is too long (> 1 hour).")
elif diff < 1:
raise ArtemisError("The trim selection cannot be negative or zero.")
if format:
if trim:
raise ArtemisError("Format choice is not supported with a trim selection.")
ytdl_opts["format"] = format
info_dict = None
asyncio.create_task(monitor_download())
async with ctx.typing():
info_dict = await run_ytdlp(url, ytdl_opts)
state = "uploading"
title = utils.romajify(info_dict.get("title"))
vid_id = info_dict.get("id")
ext = info_dict.get("ext")
filename = f"{vid_id}.{ext}"
if trim:
discord_filename = f"{title}_{round(ss)}-{round(to)}.{ext}"
else:
discord_filename = f"{title}.{ext}"
path = TEMP_DIR / filename
if not path.exists():
raise ArtemisError(f"Internal Error: File {path} does not exist.")
size = path.stat().st_size
async with ctx.typing():
if size <= utils.MAX_DISCORD_SIZE:
await ctx.reply(file=discord.File(path, discord_filename))
elif size <= MAX_LITTERBOX_SIZE:
try:
res = await self.bot.litterbox.upload(path.as_posix(), 24)
expiration = discord.utils.format_dt(pendulum.now("UTC").add(hours=24))
await ctx.reply(f"This file will expire on {expiration}\n{res}")
except CatboxError as err:
await ctx.reply(err)
else:
raise ArtemisError(
"The file passed the initial filesize guesstimation but is still too big to upload (> 1 GB)."
)
except ArtemisError as err:
ctx.command.reset_cooldown(ctx)
if "requested format not available" in str(err) and ss and to:
raise ArtemisError("Segmented streams are not supported with a trim selection.")
raise err
except Exception as err:
raise err
finally:
finished = True
if path and path.exists():
path.unlink()
@commands.command()
@commands.cooldown(1, 1, commands.BucketType.default)
async def dislikes(self, ctx: commands.Context, url: str):
"""Shows some statistics for a YouTube video including dislikes using Return YouTube Dislikes API."""
YT_RE = r"(?:youtube\.com/watch\?v=|youtu\.be/|youtube\.com/shorts/)([\w-]+)"
if len(url) == 11:
vid = url
else:
m = re.search(YT_RE, url)
if not m:
raise ArtemisError("Invalid YouTube URL or ID.")
vid = m.group(1)
params = {"videoId": vid}
async with ctx.typing():
async with self.bot.session.get(
"https://returnyoutubedislikeapi.com/votes", params=params
) as r:
if not r.ok:
if r.status == 404:
raise ArtemisError("Video not found.")
elif r.status == 400:
raise ArtemisError("Invalid video ID.")
else:
raise ArtemisError(
f"Return YouTube Dislikes API returned {r.status} {r.reason}"
)
data = await r.json()
views = humanize.intcomma(data["viewCount"])
likes = humanize.intcomma(data["likes"])
dislikes = humanize.intcomma(data["dislikes"])
msg = f"**{views}** views\n**{likes}** likes\n**{dislikes}** dislikes"
await ctx.reply(msg)
@commands.command(aliases=["lg"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def libgen(self, ctx: commands.Context, *, query: str):
"""
Search and download content from Library Genesis.
Current mirror: libgen.is
"""
LIBGEN_SEARCH_URL = "https://libgen.is/search.php?req={query}&column=def"
if len(query) < 3:
return await ctx.reply("The search query most contain at least 3 characters.")
await ctx.typing()
query = quote_plus(query)
headers = {"User-Agent": self.bot.user_agent}
async with self.bot.session.get(
LIBGEN_SEARCH_URL.format(query=query), headers=headers
) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
for el in soup.select("i"):
el.decompose()
table = soup.select(".c > tr")
if not table:
return await ctx.reply(
"edge case hit, debug dump:\n",
file=discord.File(BytesIO(html.encode("utf-8")), "search.html"),
)
elif len(table) == 1:
return await ctx.reply("No results found.")
entries = []
for row in table[1:]:
cells = row.select("td")
title = ", ".join([s for s in cells[2].stripped_strings if s])
year = cells[4].text
if year:
title += f" ({year})"
author = cells[1].text
mirrors = [cell.a["href"] for cell in cells[9:11]]
ext = cells[8].text
entries.append((title, author, mirrors, ext))
if len(entries) == 1:
result = entries[0]
else:
view = DropdownView(ctx, entries, lambda x: x[0], lambda x: x[1])
result = await view.prompt("Which entry?")
if not result:
return
async with ctx.typing():
for mirror in result[2]:
try:
async with self.bot.session.get(mirror, headers=headers) as r:
html = await r.text()
except Exception:
continue
soup = BeautifulSoup(html, "lxml")
url = soup.find("a", text="GET")["href"]
if not url:
continue
try:
async with self.bot.session.get(url, headers=headers) as r:
filesize = r.headers.get("content-length")
disposition = r.content_disposition
if disposition:
filename = disposition.filename
else:
filename = f"{result[0]}.{result[3]}"
content = None
if not filesize:
content = await r.read()
filesize = len(content)
if int(filesize) > MAX_DISCORD_SIZE:
msg = "The file is too big to upload, so here's the link:"
desc = f"[{filename}]({url})"
embed = discord.Embed(description=desc, color=0xFEFEFE)
return await ctx.reply(msg, embed=embed)
if not content:
content = await r.read()
file = discord.File(BytesIO(content), filename)
return await ctx.reply(file=file)
except Exception:
continue
return await ctx.reply("Kernel panic: Could not contact any of the download mirrors.")
async def setup(bot: Artemis):
await bot.add_cog(Media(bot))

183
cogs/meta.py Normal file
View File

@ -0,0 +1,183 @@
from __future__ import annotations
import asyncio
import json
import time
from io import StringIO
from typing import TYPE_CHECKING
from urllib.parse import quote
import aiohttp
import discord
import magic
import pendulum
from discord.ext import commands
from discord.utils import format_dt, snowflake_time
from humanize import naturalsize
import utils
from utils.common import ArtemisError
from utils.views import BaseView
if TYPE_CHECKING:
from bot import Artemis
class Meta(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
@commands.hybrid_command()
async def ping(self, ctx: commands.Context):
"""Check Websocket latency."""
curr_ws_lat = round(self.bot.latency * 1000, 1)
await ctx.reply(f":ping_pong: Pong!\nCurrent WS latency: `{curr_ws_lat}` ms.")
@commands.command()
async def uptime(self, ctx: commands.Context):
"""Check the running time of this bot."""
uptime = round(time.perf_counter() - self.bot.start_time)
uptime = pendulum.duration(seconds=uptime).in_words(separator=", ")
embed = discord.Embed(title="I'm up and running!", color=discord.Colour.green())
embed.set_author(name="Artemis", icon_url=self.bot.user.display_avatar.url)
embed.add_field(name="Uptime", value=uptime, inline=True)
embed.set_footer(text="Thanks for checking in on me!")
await ctx.reply(embed=embed)
@commands.command()
async def isdown(self, ctx: commands.Context, url: utils.URL):
"""Check if a site is down."""
headers = {"User-Agent": self.bot.user_agent}
await ctx.typing()
try:
timeout = aiohttp.ClientTimeout(total=5)
async with self.bot.session.get(url, headers=headers, timeout=timeout) as r:
buff = await r.content.read(2048)
if buff:
content_type = magic.from_buffer(buff)
else:
content_type = r.content_type
if r.ok:
size = r.headers.get("Content-Length")
size = f"{naturalsize(size, binary=True)}" if size else ""
await ctx.reply(
f"It's just you! The site is up.\n`HTTP Response: {r.status} {r.reason}{content_type}{size}`"
)
elif r.status == 404:
await ctx.reply(
f"It's not just you! Either the resource is down or you entered the wrong URI path.\n`HTTP Response: {r.status} {r.reason}{content_type}`"
)
else:
await ctx.reply(
f"It's not just you! The site is down.\n`HTTP Response: {r.status} {r.reason}`"
)
except asyncio.exceptions.TimeoutError:
await ctx.reply(
"It's not just you! The site is down.\n`Request timed out, no HTTP response.`"
)
except aiohttp.ClientConnectionError as e:
if "Name or service not known" in str(e):
msg = f"NXDOMAIN: {str(e).split(':')[-3].split()[-1]} does not exist."
else:
msg = "Couldn't establish HTTP connection."
await ctx.reply(f"It's not just you! The site is down.\n`{msg}`")
@commands.command(aliases=["ip"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def whois(self, ctx: commands.Context, query: str):
"""IP or domain geo lookup."""
async with self.bot.session.get(f"http://ip-api.com/json/{quote(query)}") as r:
data = await r.json()
ret = json.dumps(data, indent=4, ensure_ascii=False)
await ctx.reply(self.bot.codeblock(ret, "json"))
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def rawmsg(self, ctx: commands.Context, id: int):
"""
Display raw message data for message ID.
The command needs to be invoked in the same channel the message was sent in.
"""
try:
message = await self.bot.http.get_message(ctx.channel.id, int(id))
ret = json.dumps(message, indent=2, ensure_ascii=False)
dfile = discord.File(StringIO(ret), f"{id}.json")
await ctx.reply(file=dfile)
except Exception:
await ctx.reply("Invalid message ID.")
@commands.command()
async def snowflake(self, ctx: commands.Context, id: discord.Member | int):
"""Convert Discord's Snowflake ID or a member mention to a datetime."""
if isinstance(id, discord.Member):
id = id.id
dt = snowflake_time(id)
dtimestamp = format_dt(dt, "f")
await ctx.send(dtimestamp)
def to_discord_timestamp(self, datetime: str):
try:
if datetime == "now":
parsed = pendulum.now("UTC")
else:
parsed = pendulum.parse(datetime, tz="UTC")
except Exception:
raise ArtemisError("Unable to parse the given string.")
if len(datetime) == 5:
fmt = "t"
elif len(datetime) == 8:
fmt = "T"
elif len(datetime) == 10:
fmt = "D"
else:
fmt = "f"
return format_dt(parsed, fmt)
@commands.group(invoke_without_command=True)
async def timestamp(self, ctx: commands.Context, *, datetime: str):
"""
Converts a UTC datetime or time into a localized Discord timestamp.
Valid format examples:
`2022-01-01 14:00:03`
`2022-01-01 22:00`
`2039-05-01`
`12:30:35`
`15:33`
`now`
As well as `RFC 3339` or `ISO 8601` formats.
"""
msg = self.to_discord_timestamp(datetime)
await ctx.reply(msg)
@timestamp.command(name="raw")
async def timestamp_raw(self, ctx: commands.Context, *, datetime: str):
"""
Same as the main command but sends the raw markdown that you can use yourself.
"""
msg = f"`{self.to_discord_timestamp(datetime)}`"
await ctx.reply(msg)
@commands.command(aliases=["ffmpeg"])
async def getffmpeg(self, ctx: commands.Context):
"""ffmpeg-dl script information."""
view = BaseView(ctx)
view.add_item(
discord.ui.Button(
label="Download", url="https://github.com/artiemis/get-ffmpeg/releases/latest"
)
)
await ctx.reply("https://github.com/artiemis/get-ffmpeg", view=view)
async def setup(bot: Artemis):
await bot.add_cog(Meta(bot))

193
cogs/mod.py Normal file
View File

@ -0,0 +1,193 @@
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
import discord
import pendulum
from discord.ext import commands
from utils.common import ArtemisError, parse_short_time
if TYPE_CHECKING:
from bot import Artemis
class ShortTime(commands.Converter):
async def convert(self, ctx: commands.Context, argument: str) -> pendulum.DateTime:
return parse_short_time(argument)
class Mod(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
def cog_check(self, ctx: commands.Context):
if ctx.guild:
return True
raise commands.CheckFailure("This command cannot be used in private messages.")
@commands.command()
@commands.has_permissions(kick_members=True)
async def kick(self, ctx: commands.Context, member: discord.Member, *, reason: Optional[str]):
"""Kicks a member with an optional reason."""
if not reason:
reason = f"Action done by {ctx.author} ({ctx.author.id})"
else:
reason = f"{ctx.author} ({ctx.author.id}): {reason}"
await ctx.guild.kick(member, reason=reason)
await ctx.reply(f"Successfully kicked {member}.")
@commands.command()
@commands.has_permissions(ban_members=True)
async def ban(self, ctx: commands.Context, member: discord.Member, *, reason: Optional[str]):
"""Bans a member with an optional reason."""
if not reason:
reason = f"Action done by {ctx.author} ({ctx.author.id})"
else:
reason = f"{ctx.author} ({ctx.author.id}): {reason}"
await ctx.guild.ban(member, reason=reason)
await ctx.reply(f"Successfully banned {member}.")
@commands.command()
@commands.has_permissions(moderate_members=True)
async def mute(
self,
ctx: commands.Context,
member: discord.Member,
time: ShortTime,
*,
reason: Optional[str],
):
"""
Mutes a member for a specific time with an optional reason.
Usage examples:
`{prefix}mute 555412947883524098 2h`
`{prefix}mute Artemis 12h`
`{prefix}mute Artemis 2d12h for being nosy`
"""
max_timeout = pendulum.now("UTC").add(days=28)
if time > max_timeout:
raise ArtemisError("Mute time cannot exceed 28 days.")
if not reason:
reason = f"Action done by {ctx.author} ({ctx.author.id})"
else:
reason = f"{ctx.author} ({ctx.author.id}): {reason}"
await member.timeout(time, reason=reason)
return await ctx.reply(
f"Successfully muted {member} until {discord.utils.format_dt(time)}."
)
@commands.command()
@commands.has_permissions(moderate_members=True)
async def unmute(self, ctx: commands.Context, member: discord.Member):
"""
Unmutes a member.
"""
if not member.timed_out_until:
return await ctx.reply("This member is not muted.")
await member.timeout(None, reason=f"Action done by {ctx.author} ({ctx.author.id})")
return await ctx.reply(f"Successfully unmuted {member}.")
async def move_impl(
self,
ctx: commands.Context,
channel: discord.TextChannel,
*,
no_messages: int = None,
from_message_id: int = None,
to_message_id: int = None,
reason: str = None,
):
await ctx.typing()
webhooks = await channel.webhooks()
webhook = discord.utils.get(webhooks, name="Artemis")
if not webhook:
webhook = await channel.create_webhook(
name="Artemis", reason="Creating general-purpose webhook (called from $move)."
)
if no_messages:
messages = [msg async for msg in ctx.history(limit=no_messages + 1)]
messages = messages[::-1][:-1]
else:
messages = [
msg
async for msg in ctx.history(
after=discord.Object(from_message_id - 1),
before=discord.Object(to_message_id + 1),
)
]
for message in messages:
embeds = []
files = [await attachment.to_file() for attachment in message.attachments]
if not message.content and message.author.bot:
embeds = [embed for embed in message.embeds if embed.type == "rich"]
if not message.content and not files and not embeds:
continue
await webhook.send(
content=message.content,
username=message.author.display_name,
avatar_url=message.author.avatar.url,
files=files,
embeds=embeds,
)
await message.delete()
await ctx.message.delete()
if reason:
await channel.send(
f"Moved **{len(messages)}** messages from {ctx.channel.mention} for: `{reason}`",
delete_after=30,
)
else:
await channel.send(
f"Moved **{len(messages)}** messages from {ctx.channel.mention}.", delete_after=30
)
@commands.command()
@commands.has_permissions(manage_messages=True)
async def move(
self,
ctx: commands.Context,
channel: discord.TextChannel,
no_messages: int,
*,
reason: Optional[str],
):
"""Move `no_messages` to `channel` with an optional `reason`."""
await self.move_impl(ctx, channel, no_messages=no_messages, reason=reason)
@commands.command()
@commands.has_permissions(manage_messages=True)
async def move2(
self,
ctx: commands.Context,
channel: discord.TextChannel,
from_message_id: int,
to_message_id: int,
*,
reason: Optional[str],
):
"""Move `from_message_id`-`to_message_id` messages to `channel` with an optional `reason`."""
await self.move_impl(
ctx,
channel,
from_message_id=from_message_id,
to_message_id=to_message_id,
reason=reason,
)
async def setup(bot: Artemis):
await bot.add_cog(Mod(bot))

275
cogs/music.py Normal file
View File

@ -0,0 +1,275 @@
from __future__ import annotations
import asyncio
import json
import re
from collections import deque
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
import discord
from discord.ext import commands
import utils
from cogs.media import DEFAULT_OPTS, run_ytdlp
from utils.views import DropdownView
if TYPE_CHECKING:
from bot import Artemis
async def in_voice_channel(ctx: commands.Context):
client = ctx.voice_client
voice = ctx.author.voice
if client and voice and voice.channel and client.channel and voice.channel == client.channel:
return True
elif not client:
raise commands.CheckFailure("I am not connected to a voice channel.")
else:
raise commands.CheckFailure("You need to be in my voice channel to do that.")
async def audio_playing(ctx: commands.Context):
client = ctx.voice_client
if client and client.channel and client.is_playing():
return True
else:
raise commands.CheckFailure("Not playing any audio.")
@dataclass
class SongInfo:
title: str
url: str
webpage_url: Optional[str]
embed: discord.Embed
requestor: int
ctx: commands.Context
@dataclass
class MusicState:
song: Optional[SongInfo]
connected: bool
@property
def requestor(self):
if not self.song:
return None
return self.song.requestor
class Music(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
self.ffmpeg_options = {
"before_options": "-reconnect 1 -reconnect_streamed 1 -reconnect_delay_max 5",
"options": "-vn",
}
self.state = MusicState(None, False)
self.queue: deque[SongInfo] = deque([], 10)
def cleanup(self):
self.state.connected = False
self.state.song = None
self.queue.clear()
async def cog_check(self, ctx: commands.Context):
if ctx.guild.id not in (338684864008290304, 789168201295724574):
raise commands.CheckFailure("Music features are not supported in this server.")
return True
def is_requestor(self, ctx: commands.Context[Artemis]):
client = ctx.voice_client
requestor = self.state.requestor
if ctx.author.id == ctx.bot.owner_id or not requestor:
return True
return bool(client and client.is_playing() and ctx.author.id == requestor)
def build_embed(self, ctx: commands.Context, info_dict: dict) -> discord.Embed:
title = info_dict["title"]
url = info_dict.get("webpage_url") or None
uploader = info_dict.get("uploader") or ""
thumbnail = info_dict.get("thumbnail")
colour = 0xFF0000 if info_dict["extractor"] == "youtube" else self.bot.pink
embed = discord.Embed(title=title, url=url, colour=colour)
if thumbnail:
embed.set_thumbnail(url=thumbnail)
embed.set_author(name=uploader)
embed.set_footer(text=f"Requested by {ctx.author}", icon_url=ctx.author.display_avatar.url)
return embed
async def search_youtube(self, query: str) -> list[dict]:
headers = {"User-Agent": self.bot.user_agent}
params = {"search_query": query}
async with self.bot.session.get(
"https://youtube.com/results", headers=headers, params=params
) as r:
html = await r.text()
data = re.search(r"var\s?ytInitialData\s?=\s?(\{.*?\});", html).group(1)
data = json.loads(data)
videos = data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"][
"sectionListRenderer"
]["contents"][0]["itemSectionRenderer"]["contents"]
results = []
for video in videos:
if "videoRenderer" in video:
video_data = video["videoRenderer"]
results.append(
{
"title": video_data.get("title", {})
.get("runs", [[{}]])[0]
.get("text", None),
"id": video_data.get("videoId", None),
"uploader": video_data.get("longBylineText", {})
.get("runs", [[{}]])[0]
.get("text", None),
}
)
return results
async def resolve_query(self, ctx: commands.Context, query: str):
url_or_query = query.strip("<>")
if not utils.is_valid_url(url_or_query): # Try scraping YT search
try:
results = await self.search_youtube(url_or_query)
if results:
view = DropdownView(
ctx,
results,
lambda x: x["title"],
lambda x: x["uploader"],
"Choose a video...",
)
result = await view.prompt()
if not result:
return
url_or_query = f"https://www.youtube.com/watch?v={result['id']}"
await ctx.typing()
except Exception:
pass
else:
utils.check_for_ssrf(url_or_query)
ytdl_opts = {**DEFAULT_OPTS, "default_search": "auto", "format": "251/ba*"}
info_dict = await run_ytdlp(url_or_query, ytdl_opts, download=False)
if info_dict.get("entries"):
info_dict = info_dict["entries"][0]
url = info_dict["url"]
webpage_url = info_dict.get("webpage_url")
title = info_dict.get("title") or info_dict.get("id")
embed = self.build_embed(ctx, info_dict)
return SongInfo(title, url, webpage_url, embed, ctx.author.id, ctx)
async def real_play(self):
def my_after(error):
coro = self.real_play()
fut = asyncio.run_coroutine_threadsafe(coro, self.bot.loop)
fut.result()
try:
self.state.song = next_song = self.queue.popleft()
except IndexError:
self.state.song = None
return
source = await discord.FFmpegOpusAudio.from_probe(next_song.url, **self.ffmpeg_options)
next_song.ctx.voice_client.play(source, after=my_after)
await next_song.ctx.send(":musical_note: Now playing:", embed=next_song.embed)
@commands.command()
async def join(self, ctx: commands.Context):
if not ctx.author.voice:
return await ctx.reply("You are not connected to a voice channel.")
if ctx.voice_client:
await ctx.voice_client.move_to(ctx.author.voice.channel)
else:
if self.state.connected:
return await ctx.reply("Sorry, but I can only play in one server at a time.")
await ctx.author.voice.channel.connect(reconnect=True)
self.state.connected = True
@commands.command()
@commands.check(in_voice_channel)
async def play(self, ctx: commands.Context, *, url_or_query: str):
await ctx.typing()
if ctx.voice_client.is_playing():
if len(self.queue) == 10:
return await ctx.reply("The queue is full!")
song = await self.resolve_query(ctx, url_or_query)
if not song:
return
self.queue.append(song)
return await ctx.reply(":ballot_box_with_check: Added to queue.", embed=song.embed)
song = await self.resolve_query(ctx, url_or_query)
self.queue.append(song)
await self.real_play()
@commands.command()
@commands.check(in_voice_channel)
@commands.check(audio_playing)
async def queue(self, ctx: commands.Context):
if not self.queue:
return await ctx.reply("The queue is empty.")
desc = ""
for idx, song in enumerate(self.queue, start=1):
desc += f"`{idx}.` [{song.title}]({song.webpage_url})\n"
embed = discord.Embed(title="🎵 Song Queue", description=desc, color=self.bot.invisible)
await ctx.reply(embed=embed)
@commands.command(name="clearqueue")
@commands.check(in_voice_channel)
@commands.check(audio_playing)
@commands.is_owner()
async def clear_queue(self, ctx: commands.Context):
if not self.queue:
return await ctx.reply("The queue is already empty.")
else:
self.queue.clear()
return await ctx.reply("The queue has been cleared.")
@commands.command()
@commands.check(in_voice_channel)
@commands.check(audio_playing)
async def skip(self, ctx: commands.Context):
if self.is_requestor(ctx):
ctx.voice_client.stop()
else:
return await ctx.reply("You cannot skip a song you did not request.")
@commands.command(aliases=["dc"])
@commands.check(in_voice_channel)
async def disconnect(self, ctx: commands.Context):
if self.queue:
return await ctx.reply("Cannot disconnect with a filled queue.")
if self.is_requestor(ctx):
await ctx.voice_client.disconnect()
self.cleanup()
else:
return await ctx.reply(
"You cannot disconnect me while playing a song you did not request."
)
@commands.Cog.listener()
async def on_voice_state_update(
self, member: discord.Member, before: discord.VoiceState, after
):
voice = member.guild.voice_client
if not voice:
return
if len(voice.channel.members) < 2:
await voice.disconnect()
self.cleanup()
async def setup(bot: Artemis):
await bot.add_cog(Music(bot))

188
cogs/ocr.py Normal file
View File

@ -0,0 +1,188 @@
from __future__ import annotations
import json
import mimetypes
import re
from io import StringIO
from typing import TYPE_CHECKING, Literal, Optional
import discord
import magic
from discord.ext import commands
import utils
from utils.common import ArtemisError
from utils.constants import TESSERACT_LANGUAGES
from utils.flags import Flags, OCRFlags, OCRTranslateFlags
from utils.iso_639 import get_language_name
if TYPE_CHECKING:
from bot import Artemis
class OCR(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
async def ocr_impl(
self,
ctx: commands.Context,
flags: OCRFlags | OCRTranslateFlags,
translate: Literal["gt", "deepl"] = None,
):
if flags:
url = flags.url
lang = flags.lang or "eng"
else:
url = None
lang = "eng"
await ctx.typing()
for lang_code in lang.split("+"):
if lang_code not in TESSERACT_LANGUAGES:
msg = "Unsupported language code, list of supported languages:\n\n"
msg += "\n".join(
(f"`{lang}` - {get_language_name(lang[:3])}" for lang in TESSERACT_LANGUAGES)
)
embed = discord.Embed(description=msg, color=discord.Color.red())
return await ctx.reply(embed=embed)
message = await utils.get_message_or_reference(ctx)
image = await utils.get_attachment_or_url(ctx, message, url, ["image/jpeg", "image/png"])
args = f"tesseract stdin stdout -l {lang}"
result = await utils.run_cmd(args, input=image)
stdout, stderr = result.stdout, result.stderr
if not result.ok:
return await ctx.reply(result.decoded)
elif not stdout:
return await ctx.reply(
f"No recognized text output, stderr:\n{self.bot.codeblock(stderr.decode(), '')}"
)
text = stdout.decode("utf-8")
if translate:
if flags:
flags.text = text
else:
flags = Flags(text=text, source=None, dest=None)
cmd = self.bot.get_command(translate)
await cmd(ctx, flags=flags)
else:
if len(text) > 2000 - 8:
return await ctx.reply(file=discord.File(StringIO(text), "ocr.txt"))
await ctx.reply(self.bot.codeblock(text, ""))
async def lens_impl(self, ctx: commands.Context[Artemis], url: str) -> str:
headers = {"User-Agent": self.bot.user_agent}
cookies = {
"CONSENT": "PENDING+137",
"SOCS": "CAISHAgBEhJnd3NfMjAyMzEwMTItMF9SQzQaAnBsIAEaBgiA48GpBg",
}
final_data_re = r"\"(\w+)\",\[\[(\[\".*?\"\])\]"
cur_time = utils.time("ms")
upload_url = f"https://lens.google.com/v3/upload?hl=en&re=df&st={cur_time}&ep=gsbubb"
await ctx.typing()
message = await utils.get_message_or_reference(ctx)
image = await utils.get_attachment_or_url(ctx, message, url, ["image/jpeg", "image/png"])
content_type = magic.from_buffer(image, mime=True)
ext = mimetypes.guess_extension(content_type)
files = {"encoded_image": (f"image{ext}", image, content_type)}
r = await ctx.bot.httpx_session.post(
upload_url,
files=files,
headers=headers,
cookies=cookies,
follow_redirects=True,
)
if r.is_error:
print(r.text)
raise ArtemisError(f"Google Lens Upload returned {r.status_code} {r.reason_phrase}")
html = r.text
match = re.search(final_data_re, html)
if not match:
if ctx.author.id == self.bot.owner.id:
await ctx.send(file=utils.File(html, "lens.html"))
raise ArtemisError("No text detected.")
_lang, lines = match.groups()
text = "\n".join(json.loads(lines))
return text
@commands.command(usage="[lang:eng] [l:eng] <url>")
@commands.cooldown(1, 2, commands.BucketType.default)
async def ocr(self, ctx: commands.Context, *, flags: Optional[OCRFlags]):
"""
OCR using tesseract 5.
Default language: `eng` (English)
You can specify multiple languages using `+` -> `eng+pol`.
"""
await self.ocr_impl(ctx, flags)
@commands.command(usage="[source:auto] [lang:eng] [l:eng] [s:auto] [dest:en] [d:en] <url>")
@commands.cooldown(1, 2, commands.BucketType.default)
async def ocrgt(self, ctx: commands.Context, *, flags: Optional[OCRTranslateFlags]):
"""
OCR using tesseract and translation using Google.
Takes $translate and $ocr flags combined.
"""
await self.ocr_impl(ctx, flags, translate="gt")
@commands.command(usage="[source:auto] [lang:eng] [l:eng] [s:auto] [dest:en] [d:en] <url>")
@commands.cooldown(1, 2, commands.BucketType.default)
async def ocrdeepl(self, ctx: commands.Context, *, flags: Optional[OCRTranslateFlags]):
"""
OCR using tesseract and translation using DeepL.
Takes $deepl and $ocr flags combined.
"""
await self.ocr_impl(ctx, flags, translate="deepl")
@commands.command()
@commands.max_concurrency(1)
@commands.cooldown(1, 10, commands.BucketType.default)
async def lens(self, ctx: commands.Context, *, url: Optional[str]):
"""
OCR using Google Lens.
"""
text = await self.lens_impl(ctx, url)
if len(text) > 2000 - 8:
return await ctx.reply(file=discord.File(StringIO(text), "lens.txt"))
await ctx.reply(self.bot.codeblock(text, ""))
@commands.command(aliases=["ocrtr"])
@commands.max_concurrency(1)
@commands.cooldown(1, 10, commands.BucketType.default)
async def lensgt(self, ctx: commands.Context, *, url: Optional[str]):
"""
OCR using Google Lens and translation using Google Translate.
"""
text = await self.lens_impl(ctx, url)
flags = Flags(text=text, source=None, dest=None)
cmd = self.bot.get_command("gt")
await cmd(ctx, flags=flags)
@commands.command(aliases=["lenstr"])
@commands.max_concurrency(1)
@commands.cooldown(1, 10, commands.BucketType.default)
async def lensdeepl(self, ctx: commands.Context, *, url: Optional[str]):
"""
OCR using Google Lens and translation using DeepL.
"""
text = await self.lens_impl(ctx, url)
flags = Flags(text=text, source=None, dest=None)
cmd = self.bot.get_command("deepl")
await cmd(ctx, flags=flags)
async def setup(bot: Artemis):
await bot.add_cog(OCR(bot))

363
cogs/owner.py Normal file
View File

@ -0,0 +1,363 @@
from __future__ import annotations, unicode_literals
import json
import os
from turtle import color
import typing
from base64 import b64decode
from io import StringIO
from pathlib import Path
from typing import TYPE_CHECKING, Optional
import discord
import magic
from discord.ext import commands
from jishaku import codeblocks
import pendulum
import utils
from utils.common import ArtemisError
from utils.views import BaseView
if TYPE_CHECKING:
from bot import Artemis
class Owner(commands.Cog, command_attrs={"hidden": True}):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
async def cog_check(self, ctx: commands.Context):
if ctx.author.id == self.bot.owner_id:
return True
raise commands.CheckFailure("You do not have permission to run this command.")
@commands.group()
async def dev(self, ctx: commands.Context):
"""Bot developer commands."""
if ctx.invoked_subcommand is None:
await ctx.send("Invalid subcommand passed.")
@dev.command()
async def load(self, ctx: commands.Context, extension: str):
"""Loads a cog."""
try:
await self.bot.load_extension(f"cogs.{extension}")
await ctx.send(f"Loaded '{extension}.py'")
except Exception as e:
await ctx.send(e)
@dev.command()
async def unload(self, ctx: commands.Context, extension: str):
"""Unloads a cog."""
try:
await self.bot.unload_extension(f"cogs.{extension}")
await ctx.send(f"Unloaded '{extension}.py'")
except Exception as e:
await ctx.send(e)
@dev.command(name="reload")
async def _reload(self, ctx: commands.Context, extension: Optional[str]):
"""Reloads a cog."""
if not extension:
try:
for filename in os.listdir("./cogs"):
if filename.endswith(".py") and filename != "__init__.py":
await self.bot.reload_extension(f"cogs.{filename[:-3]}")
await ctx.send("Reloaded all cogs.")
except Exception as e:
await ctx.send(e)
else:
try:
await self.bot.reload_extension(f"cogs.{extension}")
await ctx.send(f"Reloaded '{extension}.py'")
except Exception as e:
await ctx.send(e)
@commands.command(aliases=["r"])
@commands.is_owner()
async def restart(self, ctx: commands.Context):
await ctx.message.add_reaction("🔄")
with open("data/temp/restart", "w") as f:
f.write(f"{ctx.channel.id}-{ctx.message.id}")
await self.bot.close()
@commands.command(aliases=["u"])
@commands.is_owner()
async def update(self, ctx: commands.Context[Artemis]):
class RestartView(BaseView):
message: discord.Message
def __init__(self, ctx: commands.Context):
super().__init__(ctx, timeout=60)
@discord.ui.button(label="Restart", style=discord.ButtonStyle.danger)
async def on_restart(self, interaction: discord.Interaction, button):
await interaction.response.edit_message(view=None)
await self.message.add_reaction("🔄")
with open("data/temp/restart", "w") as f:
f.write(f"{self.message.channel.id}-{self.message.id}")
await self.ctx.bot.close()
async def on_timeout(self):
await self.message.edit(view=None)
res = await utils.run_cmd("git pull")
embed = discord.Embed(
description=self.bot.codeblock(res.decoded, "cmd"),
timestamp=pendulum.now(),
color=discord.Color.green() if res.ok else discord.Color.red(),
)
if res.ok:
view = RestartView(ctx)
view.message = await ctx.reply(embed=embed, view=view)
return
await ctx.reply(embed=embed)
@dev.command()
@commands.is_owner()
async def status(
self, ctx: commands.Context, emoji: Optional[discord.Emoji], *, name: Optional[str]
):
await self.bot.change_presence(activity=discord.CustomActivity(name=name, emoji=emoji))
with open("data/status.json", "w") as f:
json.dump({"name": name, "emoji": emoji}, f)
await ctx.message.add_reaction("☑️")
@dev.command()
@commands.guild_only()
async def sync(
self,
ctx: commands.Context,
guilds: commands.Greedy[discord.Object],
spec: Optional[typing.Literal["~", "*", "^"]] = None,
) -> None:
if not guilds:
if spec == "~":
synced = await ctx.bot.tree.sync(guild=ctx.guild)
elif spec == "*":
ctx.bot.tree.copy_global_to(guild=ctx.guild)
synced = await ctx.bot.tree.sync(guild=ctx.guild)
elif spec == "^":
ctx.bot.tree.clear_commands(guild=ctx.guild)
await ctx.bot.tree.sync(guild=ctx.guild)
synced = []
else:
synced = await ctx.bot.tree.sync()
await ctx.send(
f"Synced {len(synced)} commands {'globally' if spec is None else 'to the current guild.'}"
)
return
ret = 0
for guild in guilds:
try:
await ctx.bot.tree.sync(guild=guild)
except discord.HTTPException:
pass
else:
ret += 1
await ctx.send(f"Synced the tree to {ret}/{len(guilds)}.")
@dev.command()
async def spy(self, ctx: commands.Context, *, code: str):
silencer = """
try:
asyncio.create_task(ctx.message.delete())
except Exception:
pass
"""
if code.startswith("```py"):
code = code[:5] + silencer + code[5:]
else:
code = silencer + code
jsk_py = self.bot.get_command("jsk py")
await jsk_py(ctx, argument=codeblocks.codeblock_converter(code))
@dev.command()
async def logs(
self, ctx: commands.Context, app: Optional[str] = "bot", lines: Optional[int] = "50"
):
"""
Displays logs for various apps.
Available: bot, cmds, api, nginx
"""
match app:
case "bot":
args = f"tail -n {lines} artemis.log"
case "cmds":
args = f"tail -n {lines} data/commands.log"
case "api":
args = f"tail -n {lines} ../api/api.log"
case "nginx":
args = f"tail -n {lines} /var/log/nginx/access.log"
case _:
return await ctx.send("Unrecognized app.")
ext = "py" if app == "bot" else "txt"
file = await utils.run_cmd_to_file(args, f"{app}-logs_{utils.time()}.{ext}")
return await ctx.reply(file=file)
@dev.command()
async def http(self, ctx: commands.Context, url: str):
"""Debugs HTTP requests."""
url = url.strip("<>")
await ctx.typing()
headers = {"User-Agent": self.bot.user_agent}
async with self.bot.session.get(url, headers=headers, allow_redirects=False) as r:
headers = "\n".join([f"{k}: {v}" for k, v in r.headers.items()])
m = f"HTTP/1.1 {r.status} {r.reason}\n{headers}"
if len(m) <= 2000:
await ctx.send(self.bot.codeblock(m, "http"))
else:
m = StringIO(m)
await ctx.send(file=discord.File(m, "headers.http"))
@dev.command()
async def mime(self, ctx: commands.Context, url: str):
"""Check magic bytes of a file to determine its format."""
url = url.strip("<>")
headers = {"User-Agent": self.bot.user_agent}
await ctx.typing()
async with self.bot.session.get(url, headers=headers) as r:
mime = None
content_type = r.content_type
buff = await r.content.read(4096)
if not buff:
return await ctx.reply("No data in body.")
mime = magic.from_buffer(buff)
content_type = content_type or magic.from_buffer(buff, mime=True)
return await ctx.reply(f"{mime}\n`{content_type}`")
@dev.command()
async def say(self, ctx: commands.Context, channel: Optional[discord.TextChannel], *, msg: str):
"""Says message as the bot."""
if not channel:
try:
await ctx.message.delete()
except Exception:
pass
await ctx.send(msg)
else:
await channel.send(msg)
@dev.command()
async def delete(self, ctx: commands.Context):
"""Deletes the replied to message."""
try:
await ctx.message.reference.cached_message.delete()
await ctx.message.delete()
except Exception:
pass
@dev.command()
async def catdel(self, ctx: commands.Context, *files):
"""Deletes catbox files."""
files = " ".join([f.strip("<>").split("/")[-1] for f in files])
resp = await self.bot.catbox.delete(files)
await ctx.reply(resp)
@dev.command()
async def ping(self, ctx: commands.Context, host: str):
"""Pings a host."""
async with ctx.typing():
result = await utils.run_cmd(f"ping -n -c 3 {host}")
cb_wrapped = self.bot.codeblock(result.decoded, "c")
await ctx.send(cb_wrapped)
@dev.command(name="b64decode")
async def _b64decode(self, ctx: commands.Context, data: str):
"""Decodes base64 data."""
data = b64decode(data + "==")
try:
data = data.decode()
except Exception:
pass
return await ctx.send(data)
@dev.command()
async def mp4ify(self, ctx: commands.Context, *, url: utils.URL):
"""Makes the video playable in Discord and browsers."""
args = f'ffmpeg -hide_banner -loglevel error -headers "User-Agent: {self.bot.user_agent}" -i "{url}" -pix_fmt yuv420p -f mp4 -movflags frag_keyframe+empty_moov -'
filename = url.split("/")[-1].split("?")[0].split("#")[0]
if not filename.endswith(".mp4"):
filename += ".mp4"
async with ctx.typing():
try:
file = await utils.run_cmd_to_file(args, filename)
except Exception as err:
return await ctx.reply(str(err).split("pipe:")[0])
await ctx.reply(file=file)
@dev.command()
async def oggify(self, ctx: commands.Context, *, url: utils.URL):
"""Makes the audio playable in Discord and browsers."""
args = f'ffmpeg -hide_banner -loglevel error -headers "User-Agent: {self.bot.user_agent}" -i "{url}" -c:a libopus -vbr on -b:a 128k -f opus -'
filename = url.split("/")[-1].split("?")[0].split("#")[0]
basename = filename.split(".")[0]
filename = basename + ".ogg"
async with ctx.typing():
try:
file = await utils.run_cmd_to_file(args, filename)
except Exception as err:
return await ctx.reply(str(err).split("pipe:")[0])
await ctx.reply(file=file)
async def handle_tempo_conversion(
self, ctx: commands.Context, target: str, url: str, rubberband: bool
):
factor = None
if target == "pal":
factor = "1.04271"
elif target == "ntsc":
factor = "0.95904"
else:
raise ArtemisError("Invalid target.")
if rubberband:
filters = f"rubberband=tempo={factor}:pitch={factor}"
else:
filters = f"atempo={factor}"
args = f'ffmpeg -hide_banner -loglevel error -headers "User-Agent: {self.bot.user_agent}" -i "{url}" -c:a libopus -vbr on -b:a 128k -af "{filters}" -f opus -'
filename = url.split("/")[-1].split("?")[0].split("#")[0]
basename = filename.split(".")[0]
filename = f"{basename}_{target.upper()}.ogg"
async with ctx.typing():
try:
file = await utils.run_cmd_to_file(args, filename)
except Exception as err:
return await ctx.reply(str(err).split("pipe:")[0])
await ctx.reply(file=file)
@dev.command()
async def ntsctopal(self, ctx: commands.Context, url: utils.URL, rubberband: bool = True):
"""NTSC (23.976) to PAL (25) audio conversion with optional pitch correction."""
await self.handle_tempo_conversion(ctx, "pal", url, rubberband=rubberband)
@dev.command()
async def paltontsc(self, ctx: commands.Context, url: utils.URL, rubberband: bool = True):
"""PAL (25) to NTSC (23.976) audio conversion with optional pitch correction."""
await self.handle_tempo_conversion(ctx, "ntsc", url, rubberband=rubberband)
async def setup(bot: Artemis):
await bot.add_cog(Owner(bot))

797
cogs/useful.py Normal file
View File

@ -0,0 +1,797 @@
from __future__ import annotations
import json
import re
import unicodedata
from io import BytesIO, StringIO
from math import ceil, log2
from typing import TYPE_CHECKING, Optional
from urllib.parse import quote, urlencode
import aiohttp
import discord
import pendulum
from aiocache import cached
from bs4 import BeautifulSoup
from colorama import Fore, Style
from discord.ext import commands
from discord.utils import format_dt
from humanize import intcomma
from PIL import Image
import utils
from utils import enigma2
from utils.common import ArtemisError
from utils.flags import WikipediaFlags
from utils.views import DropdownView
if TYPE_CHECKING:
from bot import Artemis
class Useful(commands.Cog):
def __init__(self, bot: Artemis):
self.bot: Artemis = bot
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def bing(self, ctx: commands.Context, *, query: str):
"""
Bing Search.
Uses the RSS feed, useless for complex searches.
"""
await ctx.typing()
results = await utils.search_bing(ctx, query)
if not results:
return await ctx.reply("No results found.")
embed = discord.Embed(title=f"Search Results for '{query}'", color=0x1E5DD4)
embed.set_author(
name="Bing", icon_url="https://www.google.com/s2/favicons?domain=bing.com&sz=128"
)
for result in results[:5]:
embed.add_field(
name=result.title,
value=f"[{utils.trim(result.url, 50)}]({result.url})\n{utils.trim(result.description, 120)}",
inline=False,
)
await ctx.reply(embed=embed)
@commands.command(aliases=["char"])
async def charinfo(self, ctx: commands.Context, *, characters: str):
"""Shows you information about a number of characters using unicode data lookup."""
length = len(characters)
footer = f"{length} character{'s' if length != 1 else ''}"
def to_string(c):
digit = f"{ord(c):x}".upper()
name = unicodedata.name(c, "Name not found.")
return f"`{c}` - `U+{digit:>04}` - **[{name}](http://www.fileformat.info/info/unicode/char/{digit})**"
desc = "\n".join(map(to_string, characters))
if len(desc) > 4096:
return await ctx.reply("Output too long to display.")
await ctx.reply(
embed=discord.Embed(
title="Character Information",
description=desc,
colour=self.bot.pink,
timestamp=pendulum.now("UTC"),
).set_footer(text=footer)
)
@commands.command(aliases=["redir"])
async def redirect(self, ctx: commands.Context, url: utils.URL):
"""Checks if the given URL is a redirect and shows where it points to."""
headers = {"User-Agent": self.bot.user_agent}
js_redirects = r"location\.(?:replace|assign)\([\"\'](.+)[\"\']\)|location\.href\s?=\s?[\"\'](.+)[\"\']"
redirect_url = None
await ctx.typing()
async def check_for_redirects(url, is_js_redirect=False):
try:
timeout = aiohttp.ClientTimeout(total=5)
async with self.bot.session.get(url, timeout=timeout, headers=headers) as r:
if "text/html" in r.content_type:
html = await r.text()
js_redirect = re.search(js_redirects, html)
if js_redirect:
redirect_url = js_redirect[1] or js_redirect[2]
return await check_for_redirects(redirect_url, is_js_redirect=True)
if r.history or not r.history and is_js_redirect:
return str(r.url), r.status, r.reason, r.content_type
else:
return None, None, None, None
except Exception as err:
print(err)
raise ArtemisError("Oops, I couldn't connect to the given URL.")
redirect_url, status, reason, content_type = await check_for_redirects(url)
embed = discord.Embed(title="Redirect Check", color=self.bot.pink)
embed.add_field(name="Input", value=url)
if redirect_url:
embed.add_field(name="Redirect", value=redirect_url, inline=False)
embed.set_footer(text=f"Redirect HTTP Response: {status} {reason}{content_type}")
else:
embed.add_field(name="Redirect", value="No redirects detected.", inline=False)
await ctx.reply(embed=embed)
@commands.command(aliases=["cur", "money", "cash"])
async def currency(self, ctx: commands.Context, amount: str, cur_from: str, cur_to: str):
"""
Convert currencies.
Example usage: `{prefix}money 10 USD EUR`
"""
cur_from = cur_from.upper()
cur_to = cur_to.upper()
currencies = ", ".join(utils.SUPPORTED_CURRENCIES)
if not re.match(r"\d*(?:\.?|\,?)\d*$", amount):
return await ctx.reply("Invalid amount.")
elif cur_from not in currencies or cur_to not in currencies:
return await ctx.reply(
embed=discord.Embed(
title="Invalid or unsupported currency.",
description=f"Supported currencies:\n{self.bot.codeblock(currencies, '')}",
color=discord.Colour.red(),
)
)
embed = discord.Embed(color=self.bot.pink)
amount = amount.replace(",", ".")
if cur_from == cur_to:
desc = f"{intcomma(amount)} {Fore.BLUE}{cur_from}{Style.RESET_ALL} = {intcomma(amount)} {Fore.BLUE}{cur_to}"
embed.description = self.bot.codeblock(desc, "ansi")
return await ctx.reply(embed=embed)
try:
params = {"amount": amount, "from": cur_from, "to": cur_to}
async with self.bot.session.get(
"https://api.frankfurter.app/latest", params=params
) as r:
json = await r.json()
result = round(json["rates"][cur_to], 2)
desc = f"{intcomma(amount)} {Fore.BLUE}{cur_from}{Style.RESET_ALL} = {intcomma(result)} {Fore.BLUE}{cur_to}"
embed.description = self.bot.codeblock(desc, "ansi")
await ctx.reply(embed=embed)
except Exception:
await ctx.reply("API Error: Failed to fetch conversions.")
@commands.command(aliases=["colour"])
async def color(self, ctx: commands.Context, *, colour: utils.BetterColour):
"""
Look up a colour by its Hex value or Crayola name.
Valid lookup formats:
`#fff`
`#ffffff`
`rgb(0, 0, 0)`
`blue`
`magenta`
"""
@utils.in_executor
def make_solid_colour(colour, as_hex):
buff = BytesIO()
im = Image.new("RGB", (250, 250), colour)
im.save(buff, "png")
buff.seek(0)
ret = discord.File(buff, f"{as_hex}.png")
return ret
rgb = colour.to_rgb()
as_hex_raw = hex(colour.value)
as_hex = "#" + as_hex_raw[2:]
image = await make_solid_colour(rgb, as_hex_raw)
if rgb == (255, 255, 255):
colour = discord.Colour.from_rgb(254, 254, 254)
embed = discord.Embed(color=colour)
embed.set_thumbnail(url=f"attachment://{as_hex_raw}.png")
embed.add_field(name="Hex", value=f"{as_hex.upper()}", inline=False)
embed.add_field(name="RGB", value=rgb, inline=False)
await ctx.reply(embed=embed, file=image)
@commands.command(aliases=["clock"])
async def time(self, ctx: commands.Context, tz: Optional[str]):
"""Check the time in given time zone or UTC."""
if not tz:
time = pendulum.now(tz="UTC")
else:
try:
tz = utils.COMMON_TIMEZONES[tz.lower()]
except Exception:
pass
tz = utils.fuzzy_search_one(tz, pendulum.timezones, cutoff=80)
if tz:
time = pendulum.now(tz=tz)
else:
return await ctx.reply(
embed=discord.Embed(
title="Invalid time zone",
description="[List of valid time zones](https://gist.github.com/heyalexej/8bf688fd67d7199be4a1682b3eec7568)",
colour=discord.Colour.red(),
)
)
await ctx.reply(
embed=discord.Embed(
title=f"Time for {time.timezone.name}",
description=time.format("dddd[, ]HH:mm"),
colour=self.bot.pink,
)
)
@commands.command(aliases=["timezone", "tz"])
async def convtime(self, ctx: commands.Context, time: str, from_tz: str, *to_tz):
"""
Converts a datetime or time in given time zone to a different time zone or time zones.
Accepts multiple time zones to convert to.
One of the output time conversions will always be your local system time in a Discord Timestamp.
Valid examples:
`{prefix}tz 12:00 UTC`
`{prefix}tz 14:00 CET ET`
`{prefix}tz 15:33 tokyo egypt warsaw`
`{prefix}tz "2022-01-01 22:00" UTC MSK PT WIT`
"""
to_tzs = []
try:
from_tz = utils.COMMON_TIMEZONES[from_tz.lower()]
except Exception:
pass
from_tz = utils.fuzzy_search_one(from_tz, pendulum.timezones)
try:
parsed_dt = pendulum.parse(time, tz=from_tz)
except Exception:
raise ArtemisError("Unable to parse the given time string.")
for tz in to_tz:
try:
tz = utils.COMMON_TIMEZONES[tz.lower()]
except Exception:
pass
curr_to_tz = utils.fuzzy_search_one(tz, pendulum.timezones)
to_tzs.append(curr_to_tz)
embed = discord.Embed(title="Time Zone Converter", colour=self.bot.pink)
embed.add_field(name=from_tz, value=parsed_dt.format("dddd[, ]HH:mm"))
for tz in to_tzs:
converted_dt = parsed_dt.in_tz(tz)
embed.add_field(name=tz, value=converted_dt.format("dddd[, ]HH:mm"), inline=False)
if len(embed.fields) > 20:
raise ArtemisError("Woah there! That's too many time zones.")
if not to_tz:
parsed_dt_utc = parsed_dt.in_tz("UTC")
embed.add_field(name="Local time", value=format_dt(parsed_dt_utc, "t"), inline=False)
await ctx.reply(embed=embed)
@commands.command(aliases=["wiki"], usage="[lang:en] [l:en] <query>")
async def wikipedia(self, ctx: commands.Context, *, flags: Optional[WikipediaFlags]):
"""
Search the Wikipedias.
If query is missing, shows a random article.
Optional flags:
`lang` or `l` - Wikipedia language subdomain (two-letter code).
Defaults to English (`en`).
"""
favicon = "https://en.wikipedia.org/static/apple-touch/wikipedia.png"
if flags:
query = flags.query
endpoint = flags.lang or "en"
else:
query = None
endpoint = "en"
if endpoint == "jp":
endpoint = "ja"
API_BASE = f"https://{endpoint}.wikipedia.org/w/api.php"
WEB_BASE = f"https://{endpoint}.wikipedia.org/wiki/"
SEARCH = API_BASE + "?action=opensearch&format=json&redirects=resolve&search={}"
EXTRACT = (
API_BASE
+ "?action=query&format=json&prop=extracts|pageimages&exintro&explaintext&exsentences=5&piprop=original&redirects=1&titles={}"
)
RANDOM = API_BASE + "?action=query&format=json&redirects=1&list=random&rnnamespace=0"
HEADERS = {"User-Agent": self.bot.real_user_agent}
await ctx.typing()
try:
if query:
async with self.bot.session.get(SEARCH.format(quote(query)), headers=HEADERS) as r:
data = await r.json()
titles = data[1]
if not titles:
return await ctx.reply("No results found.")
elif len(titles) == 1:
title = titles[0]
else:
view = DropdownView(ctx, titles, lambda x: x)
result = await view.prompt("Which page?")
if not result:
return
title = result
else:
async with self.bot.session.get(RANDOM, headers=HEADERS) as r:
data = await r.json()
page = data["query"]["random"][0]
title = page["title"]
async with self.bot.session.get(EXTRACT.format(quote(title)), headers=HEADERS) as r:
data = await r.json()
pages = data["query"]["pages"]
if not pages:
return await ctx.reply("Title mismatch, action=query returned no pages.")
page = pages[list(pages)[0]]
extract = page["extract"]
page_url = WEB_BASE + quote(title)
image = page.get("original")
if image:
image_url = image.get("source") or None
else:
image_url = None
embed = discord.Embed(
title=title, description=utils.trim(extract, 4096), url=page_url, colour=0xFEFEFE
)
embed.set_author(name="Wikipedia", icon_url=favicon)
if image_url:
embed.set_image(url=image_url)
await ctx.reply(embed=embed)
except aiohttp.client_exceptions.ClientConnectionError:
await ctx.reply("API Error: Invalid language endpoint.")
@commands.command(aliases=["wttr"])
async def weather(self, ctx: commands.Context, *, location: str):
"""Check the weather for given city/region/country."""
LOC_RE = re.compile(r"Location:\s*(.*?)\s*\[")
await ctx.typing()
url = f"https://wttr.in/{quote(location)}"
async with self.bot.session.get(f"{url}?T") as r:
if r.status == 404:
return await ctx.reply("Location not found.")
data = await r.text()
if "Sorry" in data:
return await ctx.reply(data.split("\n\n")[0])
loc = LOC_RE.search(data).group(1)
text = "\n".join(data.split("\n")[1:7])
wrapped = self.bot.codeblock(text, "py")
embed = discord.Embed(title=loc, description=wrapped, url=url, color=0x7494D7)
await ctx.reply(embed=embed)
@commands.command(aliases=["qrd"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def qrdecode(self, ctx: commands.Context, url: Optional[utils.URL]):
"""
Decode a QR code from an image.
Accepts a URL or an attachment.
"""
if not ctx.message.attachments and not url:
return await ctx.reply("Please send me a valid image with a QR code.")
elif ctx.message.attachments:
url = ctx.message.attachments[0].url
endpoint = "http://api.qrserver.com/v1/read-qr-code"
params = {"fileurl": url}
async with ctx.typing():
async with self.bot.session.get(endpoint, params=params) as r:
json = await r.json()
result = json[0]["symbol"][0]
text = result["data"]
error = result["error"]
if error:
if "could not find" in error:
return await ctx.reply("Could not find/read a QR code.")
else:
return await ctx.reply(f"API ERROR: {error}")
if len(text) > 2000:
await ctx.reply(file=discord.File(StringIO(text), "decoded_QR_code.txt"))
await ctx.reply(text)
@commands.command(name="map", aliases=["maps"])
@commands.cooldown(1, 2, commands.BucketType.default)
async def _map(self, ctx: commands.Context, *, query: str):
"""
Return a static map for a given location.
Examples:
`{prefix}map statue of liberty`
`{prefix}map cieszyn, stawowa`
"""
GEOCODER_API = "https://nominatim.openstreetmap.org/search"
HEADERS = {"User-Agent": self.bot.real_user_agent, "Accept-Language": "en-US"}
STATIC_MAP_URL = (
"https://tyler-demo.herokuapp.com/?lat={lat}&lon={lon}&width=800&height=600&zoom={zoom}"
)
results = await self.bot.cache.get(f"geocoder:{query}")
if not results:
await ctx.typing()
params = {"q": query, "format": "jsonv2"}
async with self.bot.session.get(GEOCODER_API, params=params, headers=HEADERS) as r:
results = await r.json()
await self.bot.cache.set(f"geocoder:{query}", results, ttl=60)
if not results:
raise ArtemisError("No results found.")
elif len(results) == 1:
result = results[0]
else:
view = DropdownView(
ctx,
results,
lambda x: x.get("display_name") or "Unknown display name.",
lambda x: f"{x['osm_type']} {x['osm_id']}",
"Choose place...",
)
result = await view.prompt()
if not result:
return
await ctx.typing()
lat = result["lat"]
lon = result["lon"]
address = result["display_name"]
osm_id = result["osm_id"]
osm_type = result["osm_type"]
bbox: list[float] = result["boundingbox"]
lon_diff = abs(float(bbox[2]) - float(bbox[3]))
lat_diff = abs(float(bbox[0]) - float(bbox[1]))
zoom_lon = ceil(log2(360 * 2 / lon_diff))
zoom_lat = ceil(log2(180 * 2 / lat_diff))
zoom = max(zoom_lon, zoom_lat) - 1
zoom = max(0, min(zoom, 19))
url = f"https://www.openstreetmap.org/{osm_type}/{osm_id}"
async with self.bot.session.get(STATIC_MAP_URL.format(lat=lat, lon=lon, zoom=zoom)) as r:
data = await r.read()
data = BytesIO(data)
file = discord.File(data, f"{osm_id}.png")
embed = discord.Embed(title=utils.trim(address, 256), url=url, color=0xFEFEFE)
embed.set_image(url=f"attachment://{osm_id}.png")
embed.set_footer(
text="Data © OpenStreetMap contributors, ODbL 1.0. https://osm.org/copyright"
)
await ctx.reply(embed=embed, file=file)
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def reverse(self, ctx: commands.Context, *, url: Optional[utils.URL]):
"""
Yandex Reverse Image Search.
"""
headers = {"User-Agent": self.bot.user_agent}
bad_link_msg = "Couldn't upload image. Try uploading a different one."
await ctx.typing()
if not ctx.message.attachments and not url:
return await ctx.reply("Please send me a valid image first!")
elif ctx.message.attachments:
url = ctx.message.attachments[0].url
async with self.bot.session.get(
f"https://yandex.com/images/search?url={url}&rpt=imageview", headers=headers
) as r:
if not r.ok:
return await ctx.reply(f"Yandex API Error: {r.status} {r.reason}")
html = await r.text()
if bad_link_msg in html:
return await ctx.reply(bad_link_msg)
soup = BeautifulSoup(html, "lxml")
preview_img = soup.select_one(".CbirPreview-Image")
preview_img_url = preview_img["src"]
embed = discord.Embed(title="Uploaded image", color=0xFDDE55, url=r.url)
embed.set_thumbnail(url=preview_img_url)
embed.set_author(
name="Yandex",
icon_url="https://yastatic.net/s3/web4static/_/v2/oxjfXL1EO-B5Arm80ZrL00p0al4.png",
)
tags = soup.select(".CbirTags a")
if tags:
tags_fmt = []
for tag in tags:
href = "https://yandex.com" + tag["href"]
tags_fmt.append(f"[{tag.span.text}]({href})")
embed.add_field(
name="Image appears to contain", value=", ".join(tags_fmt), inline=False
)
sizes = soup.select(".CbirOtherSizes a")
if sizes:
sizes_fmt = []
for size in sizes[:4]:
sizes_fmt.append(f"[{size.span.text}]({size['href']})")
embed.add_field(name="Other image sizes", value=", ".join(sizes_fmt), inline=False)
results = soup.select(".CbirSites-ItemInfo")
for result in results[:3]:
a = result.select_one(".CbirSites-ItemTitle a")
title = a.text
url = a["href"]
url = f"[{utils.trim(url.split('//', 1)[-1], 50)}]({url})"
description = result.select_one(".CbirSites-ItemDescription").text
description = description if "http" not in description else None
value = f"{url}\n{description}" if description else url
embed.add_field(
name=utils.trim(title, 256), value=utils.trim(value, 1024), inline=False
)
await ctx.reply(embed=embed)
@cached(ttl=6 * 60 * 60)
async def get_lyngsat_cse_url(self):
headers = {"User-Agent": self.bot.user_agent}
async with self.bot.session.get(
"https://cse.google.com/cse.js?cx=009961667831609082040:rhpc-bbbuim", headers=headers
) as r:
data = await r.text()
cse_token = re.search(r"\"cse_token\":\s*\"(.*?)\"", data)
if not cse_token:
raise ArtemisError("Invalid CSE data, missing `cse_token`.")
cselibv = re.search(r"\"cselibVersion\":\s*\"(.*?)\"", data)
if not cselibv:
raise ArtemisError("Invalid CSE data, missing `cselibVersion`.")
cse_token, cselibv = cse_token.group(1), cselibv.group(1)
params = urlencode(
{
"rsz": "filtered_cse",
"num": "10",
"hl": "en",
"source": "gcsc",
"gss": ".com",
"cselibv": cselibv,
"cx": "009961667831609082040:rhpc-bbbuim",
"safe": "off",
"cse_tok": cse_token,
"exp": "csqr,cc,4861325",
"callback": "google.search.cse.api8659",
}
)
return "https://cse.google.com/cse/element/v1?" + params
@commands.command()
@commands.cooldown(1, 2, commands.BucketType.default)
async def enigma2(self, ctx: commands.Context, *, query: str):
"""Recreates a DVB-S ServiceReference ID present in enigma2 based on LyngSat data."""
headers = {"User-Agent": self.bot.user_agent}
advice_embed = discord.Embed(
description="You can try finding the channel manually (`CTRL+F`) in the following EPG source list:\n[rytec.channels-sat.xml](https://raw.githubusercontent.com/doglover3920/EPGimport-Sources/main/rytec.channels-sat.xml)",
color=discord.Color.blue(),
)
await ctx.typing()
query = quote(query)
cse_url = await self.get_lyngsat_cse_url()
cse_url += f"&q={query}&oq={query}"
async with self.bot.session.get(cse_url, headers=headers) as r:
if not r.ok:
raise ArtemisError(f"LyngSat CSE returned error: {r.status} {r.reason}")
data = await r.text()
data = re.search(r"\"results\":\s*(\[.*?\]),", data, re.S)
if not data:
raise ArtemisError("LyngSat CSE returned invalid data.")
data = json.loads(data.group(1))
items = [
item for item in data if "tvchannels" in item["url"] and r"%26sa%3DU" not in item["url"]
]
if not items:
return await ctx.reply("No results found.", embed=advice_embed)
elif len(items) == 1:
result = items[0]
else:
view = DropdownView(
ctx,
items,
lambda x: x["titleNoFormatting"].removesuffix(" - LyngSat"),
lambda x: x["url"].split("tvchannels/")[1].removesuffix(".html"),
)
result = await view.prompt("Which channel?")
if not result:
return
await ctx.typing()
lyngsat_url = result["url"]
async with self.bot.session.get(lyngsat_url, headers=headers) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
channel = result["titleNoFormatting"].removesuffix(" - LyngSat")
satellites_table = soup.find(string="Satellite")
satellites_table = satellites_table.find_parent("table")
satellites = satellites_table.select("tr")[2:-1]
satellites = [s for s in satellites if len(s.select("td")) >= 7]
if len(satellites) == 1:
result = satellites[0]
else:
def satellite_desc(s):
fields = s.select("td")
ret = []
video = fields[6]
lang = fields[7]
if video.text:
for br in video.select("br"):
br.replace_with(" ")
ret.append(video.text)
if lang.text:
for br in lang.select("br"):
br.replace_with(" ")
ret.append(lang.text.lower())
return ", ".join(ret) if ret else None
view = DropdownView(ctx, satellites, lambda x: x.select("td")[1].text, satellite_desc)
result = await view.prompt("Which satellite?")
if not result:
return
await ctx.typing()
satellite_data = result.select("td")
satellite_pos = satellite_data[0].text.strip()
satellite_url = satellite_data[1].a["href"]
sat_pos = re.search(r"(\d{1,3}(?:\.\d)?).*?((?:E|W))", satellite_pos)
if not sat_pos:
return await ctx.reply("Failed to find satellite position.", embed=advice_embed)
pos, cardinal = sat_pos.groups()
# sref Namespace
ns = enigma2.build_namespace(float(pos), cardinal.upper())
packages = satellite_data[9].select("a")
if not packages:
return await ctx.reply(
"Extraction for channels without linked providers is not supported due to missing data.",
embed=advice_embed,
)
elif len(packages) == 1:
package = packages[0]
else:
view = DropdownView(ctx, packages, lambda x: x.text)
package = await view.prompt("Which provider?")
if not package:
return
package = package
await ctx.typing()
async with self.bot.session.get(satellite_url, headers=headers) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
cell = soup.find(string=package.text.strip())
if not cell:
return await ctx.reply(
"Could not match provider name to the entries in the satellite's table.",
embed=advice_embed,
)
onid_tid = cell.find_parent("td").find_next_siblings("td")[1].text.strip()
if not onid_tid:
return await ctx.reply(
"Not enough data to recreate a ServiceReference (missing ONID-TID).",
embed=advice_embed,
)
onid_tid = re.search(r"(\d+)-(\d+)", onid_tid)
if not onid_tid:
return await ctx.reply(
"Not enough data to recreate a ServiceReference (invalid ONID-TID).",
embed=advice_embed,
)
onid, package_tid = onid_tid.groups()
async with self.bot.session.get(package["href"], headers=headers) as r:
html = await r.text()
soup = BeautifulSoup(html, "lxml")
cell = soup.find(string=channel.strip())
parent = cell.find_parent("td")
siblings = parent.find_previous_siblings("td")
sid = siblings[2].text.strip()
if not sid:
return await ctx.reply(
"Not enough data to recreate a ServiceReference (missing SID).", embed=advice_embed
)
tid = parent.find_all_previous("td", rowspan=True)[1]
for br in tid.select("br"):
br.replace_with("\n")
tid = re.search(r"tp (\d+)", tid.text)
if not tid:
is_guessed_tid = True
tid = package_tid
else:
tid = tid.group(1)
tid = tid + "00"
is_guessed_tid = False
if not sid.isdigit():
return await ctx.reply(
"Not enough data to recreate a ServiceReference (invalid SID).", embed=advice_embed
)
stype = enigma2.ServiceType.HDTV if "HD" in channel else enigma2.ServiceType.TV
sref = enigma2.build_sref(stype, int(sid), int(tid), int(onid), ns)
parsed = enigma2.parse_sref(sref)
parsed = "\n".join([f"{k.replace('_', '').upper()}: **{v}**" for k, v in parsed.items()])
embed = discord.Embed(title=channel, url=lyngsat_url, color=0xFFE4B5)
embed.description = f"{parsed}\n\n`{sref}`\n"
if is_guessed_tid:
embed.description += (
"(TSID assumed from provider's value, channel-specific TSID unavailable)"
)
return await ctx.reply(embed=embed)
async def setup(bot: Artemis):
await bot.add_cog(Useful(bot))

15
config.example.toml Normal file
View File

@ -0,0 +1,15 @@
token = "token"
prefix = "!"
user_agent = "user_agent"
real_user_agent = "real_user_agent"
api_base_url = "api_base_url"
cdn_base_url = "cdn_base_url"
main_guild_id = 1
dev_guild_id = 1
[keys]
api = "api"
catbox = "catbox"
github = "github"
cloudflare = "cloudflare"
openai = "openai"

1
data/fim-dialogues.json Normal file

File diff suppressed because one or more lines are too long

1
data/iso_639_3.json Normal file

File diff suppressed because one or more lines are too long

1
data/nimi.json Normal file

File diff suppressed because one or more lines are too long

1
data/pokedex.json Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

17
ecosystem.config.js Normal file
View File

@ -0,0 +1,17 @@
module.exports = {
apps: [
{
name: "artemis",
interpreter: "./env/bin/python",
script: "./bot.py",
out_file: "/dev/null",
error_file: "/dev/null",
log_file: "./artemis.log",
time: true,
env: {
PYTHONUNBUFFERED: "1",
ENV: "production",
},
},
],
};

6
pyproject.toml Normal file
View File

@ -0,0 +1,6 @@
[tool.black]
line-length = 100
target-version = ['py311']
[tool.ruff]
ignore = ["E501"]

26
requirements.txt Normal file
View File

@ -0,0 +1,26 @@
wheel
aiocache
aiosqlite
discord.py
PyNaCl>=1.3.0,<1.5
beautifulsoup4
black
colorama
feedparser
rapidfuzz
gTTS
humanize
jishaku
lxml
pendulum
Pillow
psutil
pycaption
pykakasi
python-anilist
python-magic
yt-dlp
h2
aiogoogletrans
setuptools
git+https://github.com/Suyash458/WiktionaryParser

3
utils/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .common import *
from .constants import *
from .config import config # noqa: F401

138
utils/anilist.py Normal file
View File

@ -0,0 +1,138 @@
from __future__ import annotations
import re
from typing import Any, Optional
import discord
from anilist.types import Anime, Character, Manga
from utils.common import trim
ANILIST_COLOR = 0x02A9FF
FOOTER = "Powered by AniList APIv2"
media_formats_map = {
"TV": "TV",
"TV_SHORT": "TV Short",
"MOVIE": "Movie",
"SPECIAL": "Special",
"OVA": "OVA",
"ONA": "ONA",
"MUSIC": "Music",
"MANGA": "Manga",
"NOVEL": "Novel",
"ONE_SHOT": "One Shot",
}
class BaseEmbed(discord.Embed):
def __init__(self, *args, **kwargs):
super().__init__(color=ANILIST_COLOR, *args, **kwargs)
self.set_footer(text=FOOTER)
def get(obj: Any, prop: str, default: Optional[str] = None):
return getattr(obj, prop, default)
def get_character_summary(character: Character) -> Optional[str]:
if not get(character, "description"):
return "No description available."
clean, spoilers = [], []
description = character.description
def repl(m):
spoilers.append(f"||{m.group(1)}||")
return ""
description = re.sub(r"~!(.+?)!~", repl, description, re.S)
lines = description.split("\n")
lines = [line for line in lines if line]
for line in lines:
if re.search(r"^__.+(:__|__:|__.+:)|^\*\*.+(:\*\*|\*\*:|\*\*.+:)", line):
continue
else:
clean.append(line.strip())
if clean:
ret = clean[0]
if len(ret) < 100 and len(clean) > 1:
ret += f"\n{clean[1]}"
return trim(ret, 512)
elif spoilers:
return spoilers[0]
else:
return "No description available."
def build_anilist_embed(result: Anime | Manga) -> discord.Embed:
title = get(result.title, "english", result.title.romaji)
description = result.description.split("<br>")[0] if get(result, "description") else ""
description = re.sub(r"<.+?>", "", description)
source = result.source.replace("_", " ").title() if get(result, "source") else "N/A"
status = result.status.replace("_", " ").title() if get(result, "status") else "N/A"
start_date: Any = get(result, "start_date")
url = result.url
mid = result.id
embed = BaseEmbed(title=title, url=url, description=description)
embed.set_image(url=f"https://img.anili.st/media/{mid}")
embed.add_field(name="Source", value=source)
embed.add_field(name="Status", value=status)
if start_date and get(start_date, "year"):
embed.add_field(name="Release Year", value=start_date.year, inline=True)
if result is Anime:
nextairing = get(result, "next_airing", None)
episodes = get(result, "episodes") or get(nextairing, "episode")
duration = get(result, "duration")
media_format = media_formats_map.get(result.format)
embed.add_field(name="Type", value=media_format)
if episodes:
embed.add_field(name="Episodes", value=episodes)
if duration:
duration = (
str(duration) + " mins per ep." if media_format == "TV" else str(duration) + " mins"
)
embed.add_field(name="Duration", value=duration)
elif result is Manga:
volumes = get(result, "volumes")
chapters = get(result, "chapters")
if volumes:
embed.add_field(name="Volumes", value=volumes)
if chapters:
embed.add_field(name="Chapters", value=chapters)
return embed
def build_character_embed(character: Character) -> discord.Embed:
name = character.name.full
if get(character.name, "native"):
name += f" ({character.name.native})"
url = character.url
image = character.image.large if get(character, "image") else None
media = character.media if get(character, "media") else None
description = get_character_summary(character) or ""
if media:
media_joined = "**Featured in:**\n"
for entry in media[:3]:
title = entry["title"].get("english") or entry["title"].get("romaji")
entry_url = f"https://anilist.co/{entry['type'].lower()}/{entry['id']}"
media_joined += f"[{title}]({entry_url})\n"
if len(media) > 3:
media_joined += f"[**+ {len(media) - 3} more**]({url})"
description += f"\n\n{media_joined}"
embed = BaseEmbed(title=name, url=url, description=description)
if image:
embed.set_image(url=image)
return embed

84
utils/api.py Normal file
View File

@ -0,0 +1,84 @@
from __future__ import annotations
import asyncio
import io
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Literal
from utils.common import ArtemisError
import aiohttp
if TYPE_CHECKING:
from bot import Artemis
@dataclass
class DeepLResult:
src: str
dst: str
translation: str
class API:
def __init__(self, bot: Artemis, token: str):
self.base_url = "http://127.0.0.1:3000"
self.token = token
self.session: aiohttp.ClientSession = bot.session
self.HEADERS = {"User-Agent": bot.real_user_agent}
self.AUTHED_HEADERS = {**self.HEADERS, "Authorization": f"Bearer {self.token}"}
async def _aioread(self, fp):
return await asyncio.to_thread(fp.read)
async def _request(
self,
method: str,
path: str,
authed: bool = False,
res_type: Literal["json", "text", "bytes"] = "json",
**kwargs,
) -> Any:
headers = self.AUTHED_HEADERS if authed else self.HEADERS
async with self.session.request(
method, self.base_url + path, headers=headers, **kwargs
) as r:
match res_type:
case "json":
return await r.json()
case "text":
return await r.text()
case "bytes":
return await r.read()
async def screenshot(
self,
url: str,
selector: str | None = None,
waitForSelector: str | None = None,
waitForFunction: str | None = None,
) -> io.BytesIO:
"""Returns a PNG screenshot of the website at url with optional selector."""
params = {"url": url}
if selector:
params["selector"] = selector
if waitForSelector:
params["waitForSelector"] = waitForSelector
if waitForFunction:
params["waitForFunction"] = waitForFunction
res: bytes = await self._request(
"GET", "/webdriver/screenshot", authed=True, res_type="bytes", params=params
)
return io.BytesIO(res)
async def deepl(self, text: str, src: str = "auto", dst: str = "en") -> DeepLResult:
"""Returns DeepL translated text."""
data = {"src": src.lower(), "dst": dst.lower(), "text": text}
async with self.session.post(
self.base_url + "/webdriver/deepl", json=data, headers=self.AUTHED_HEADERS
) as r:
data = await r.json()
if not r.ok:
raise ArtemisError(f"DeepL Error: `{data.get('error', 'Unknown')}`")
return DeepLResult(**data)

106
utils/catbox.py Normal file
View File

@ -0,0 +1,106 @@
import asyncio
import io
import os
from typing import Dict, Literal, Optional
import aiohttp
from utils.common import is_valid_url
Expiration = Literal[1, 12, 24, 72]
class CatboxError(Exception):
pass
class BoxBase:
userhash: Optional[str]
API_URL: str
def __init__(self, session: aiohttp.ClientSession):
self.session = session
async def _request(self, data: Dict, timeout: int | None = None) -> str:
if hasattr(self, "userhash"):
data["userhash"] = self.userhash
client_timeout = aiohttp.ClientTimeout(total=timeout or 120)
try:
async with self.session.post(self.API_URL, data=data, timeout=client_timeout) as r:
resp = await r.text()
if 200 <= r.status < 400:
return resp
else:
raise CatboxError(resp)
except asyncio.TimeoutError:
raise CatboxError("Upload timed out.")
async def _aioread(self, fp) -> bytes:
return await asyncio.to_thread(fp.read)
class Catbox(BoxBase):
API_URL = "https://catbox.moe/user/api.php"
def __init__(self, userhash: str, session: aiohttp.ClientSession):
super().__init__(session=session)
self.userhash = userhash or ""
async def upload(self, resource: str | io.IOBase | os.PathLike, timeout: int | None = None):
fp = None
url = None
data = {}
if isinstance(resource, str):
if is_valid_url(resource):
url = resource
elif os.path.isfile(resource):
with open(resource, "rb") as f:
name = f.name.split("/")[-1]
fp = io.BytesIO(await self._aioread(f))
fp.name = name
else:
raise CatboxError("Invalid file path or URL.")
elif isinstance(resource, io.IOBase):
fp = resource
else:
raise CatboxError("Invalid file buffer, path or URL.")
if url:
data = {"reqtype": "urlupload", "url": url}
elif fp:
data = {"reqtype": "fileupload", "fileToUpload": fp}
return await self._request(data, timeout=timeout)
async def delete(self, files: str, timeout: int | None = None):
data = {"reqtype": "deletefiles", "files": files}
return await self._request(data, timeout=timeout)
class Litterbox(BoxBase):
API_URL = "https://litterbox.catbox.moe/resources/internals/api.php"
async def upload(
self, fp: io.IOBase | os.PathLike, time: Expiration = 1, timeout: int | None = None
):
if time not in (1, 12, 24, 72):
raise CatboxError("Invalid expiration time.")
if isinstance(fp, str):
if os.path.isfile(fp):
with open(fp, "rb") as f:
name = f.name.split("/")[-1]
fp = io.BytesIO(await self._aioread(f))
fp.name = name
else:
raise CatboxError("Invalid file path.")
elif isinstance(fp, io.IOBase):
pass
else:
raise CatboxError("Invalid file buffer.")
data = {"reqtype": "fileupload", "time": f"{time}h", "fileToUpload": fp}
return await self._request(data, timeout=timeout)

557
utils/common.py Normal file
View File

@ -0,0 +1,557 @@
from __future__ import annotations
import asyncio
import functools
import json
import re
import shlex
from dataclasses import dataclass
from io import BytesIO
from ipaddress import ip_address
from subprocess import PIPE
from time import perf_counter, time_ns
from time import time as _time
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Sequence, TypeVar
from urllib.parse import quote_plus, urlparse
import discord
import humanize
import pendulum
import pykakasi
import tomllib
from aiohttp.helpers import is_ip_address
from discord.ext import commands
import feedparser
from rapidfuzz import process
import utils
if TYPE_CHECKING:
from bot import Artemis
# url regex
URL_RE = r"(?i)\b((?:https?://|www\d{0,3}[.]|[a-z0-9.\-]+[.][a-z]{2,4}/)(?:[^\s()<>]+|\(([^\s()<>]+|(\([^\s()<>]+\)))*\))+(?:\(([^\s()<>]+|(\([^\s()<>]+\)))*\)|[^\s`!()\[\]{};:'\".,<>?«»“”‘’]))"
class ArtemisError(commands.CommandError):
pass
class InvalidURL(ArtemisError):
pass
class SSRFError(ArtemisError):
pass
class CommandExecutionError(Exception):
pass
class InvalidColour(commands.BadArgument):
pass
class BetterColour(commands.Converter):
async def convert(self, ctx, argument: str):
colour_name = fuzzy_search_one(argument, list(utils.COMMON_COLOURS), cutoff=60)
if not colour_name:
raise InvalidColour("Invalid colour code/name.")
argument = utils.COMMON_COLOURS[colour_name]
colour = discord.Colour.from_str(argument)
return colour
class URL(commands.Converter):
"""URL converter."""
async def convert(self, ctx, argument: str):
argument = argument.strip("<>")
match = is_valid_url(argument)
if match:
check_for_ssrf(argument)
return argument
else:
raise InvalidURL("That doesn't look like a valid URL.")
class Stopwatch:
"""A context manager to measure execution time."""
def __enter__(self):
self._t = perf_counter()
return self
def __exit__(self, type, value, tb):
self._t = perf_counter() - self._t
@property
def result(self):
return self._t
@property
def duration(self):
return pendulum.duration(seconds=self._t)
@property
def humanized(self):
return humanize.metric(self._t, "s")
class ProgressBarMessage:
"""Auto-updating progress bar using a discord message."""
_total: int
_current: int
_msg: discord.Message | None
_ctx: commands.Context[Artemis]
_format: str
_refresh_rate: int
_prefix: str | None
_bar_width: int
_delete_on_finished = bool
_finished = bool
_finished_kwargs: dict
def __init__(
self,
ctx: commands.Context[Artemis],
total: int,
prefix: str | None = None,
format: str = "bytes",
refresh_rate: int = 1,
bar_width: int = 25,
delete_on_finished: bool = False,
):
self._ctx = ctx
self._total = total
self._prefix = prefix
self._format = format
self._refresh_rate = refresh_rate
self._bar_width = bar_width
self._msg = None
self._current = 0
self._finished_kwargs = {"content": "Done!"}
self._finished = False
self._delete_on_finished = delete_on_finished
def _get_fmt(self):
fmt = None
if self._total == 0 and self._current == 0:
return ""
match self._format:
case "bytes":
fmt = humanize.naturalsize(self._current, binary=True)
case "integer":
fmt = self._current
case "percent":
fmt = ""
return fmt
async def _render(self):
try:
while not self._finished:
print(vars(self))
content = ""
if self._prefix:
content += self._prefix + "\n"
content += self._render_bar()
if not self._msg:
self._msg = await self._ctx.send(content=content)
else:
self._msg = await self._msg.edit(content=content)
await asyncio.sleep(1 / self._refresh_rate)
if self._msg:
if self._delete_on_finished:
return await self._msg.delete()
self._msg = await self._msg.edit(**self._finished_kwargs)
except Exception as err:
print(str(err))
if self._msg:
await self._msg.edit(content="Error while rendering progress bar.")
def _render_bar(self):
if self._total == 0:
return f"??% `Unknown Size` {self._get_fmt()}"
progress = self._current * self._bar_width // self._total
bar = "["
bar += "=" * (progress - 1) + ">"
bar += " " * (self._bar_width - progress)
bar += "]"
percent = round(self._current / self._total * 100)
return f"{percent}% `{bar}` {self._get_fmt()}"
def start(self):
asyncio.create_task(self._render())
def set(self, val: int):
self._current = val
def set_total(self, val: int):
self._total = val
def set_prefix(self, prefix: str):
self._prefix = prefix
def increment(self, val: int):
self._current += val
def finish(self, **kwargs):
self._finished = True
if kwargs:
self._finished_kwargs = kwargs
def set_finished_kwargs(self, **kwargs):
self._finished_kwargs = kwargs
def __enter__(self):
self.start()
return self
def __exit__(self, _exc_type, _exc_value, _traceback):
self.finish()
def File(fp: bytes | str | list | dict, filename: str):
"""Dirty discord.File helper for fast debugging."""
if isinstance(fp, bytes):
buf = BytesIO(fp)
elif isinstance(fp, str):
buf = BytesIO(fp.encode("utf-8"))
elif isinstance(fp, (list, dict)):
buf = BytesIO(json.dumps(fp, indent=2, ensure_ascii=False).encode("utf-8"))
else:
raise TypeError("Invalid file pointer input.")
buf.seek(0)
return discord.File(buf, filename)
def read_text(path: str) -> str:
with open(path, "r") as f:
return f.read()
def read_bytes(path: str) -> bytes:
with open(path, "rb") as f:
return f.read()
def read_json(path: str) -> Any:
with open(path, "r") as f:
return json.load(f)
def read_toml(path: str) -> Any:
with open(path, "rb") as f:
return tomllib.load(f)
def in_executor(func: Callable):
"""A decorator for running a function in a thread."""
@functools.wraps(func)
def decorator(*args: Any, **kwargs: Any):
return asyncio.to_thread(func, *args, **kwargs)
return decorator
def time(resolution: Literal["s", "ms", "ns"] = "s") -> int:
"""
Return the current time in resolution since the unix epoch as an int.
### Parameters
resolution: `Literal["s", "ms", "ns"]`
Resolution of the returned time, seconds, milliseconds or nanoseconds.
"""
match resolution:
case "s":
return int(_time())
case "ms":
return int(_time() * 1000)
case "ns":
return time_ns()
def trim(text: Optional[str], max: int) -> Optional[str]:
"""Trims text to specified max length."""
if text is None:
return None
return f"{text[:max - 3]}..." if len(text) > max else text
def romajify(text: str, strict: bool = True) -> str:
"""
Romajifies all Japanese characters.
If strict, text containing any English characters won't be converted.
"""
if strict and re.search(r"[a-zA-Z]", text):
return text
kana = pykakasi.kakasi().convert(text)
romaji = "".join([group["hepburn"] + " " for group in kana])
return romaji.strip()
def is_valid_url(url: str) -> bool:
parsed = urlparse(url)
if (
not parsed.scheme
or not parsed.netloc
or not (parsed.scheme.startswith("http") and "." in parsed.netloc)
):
return False
return True
def check_for_ssrf(url: str):
"""Checks if the provided url contains private IP addresses."""
hostname = urlparse(url).hostname
if (
hostname
and hostname == "localhost"
or is_ip_address(hostname)
and ip_address(hostname).is_private
):
raise SSRFError("Error: Invalid URL.")
def silence_url_embeds(message: str) -> str:
"""Silences link embeds in a message we send."""
regex = re.compile(URL_RE)
def repl(m):
url = m.group(0)
return f"<{url}>"
ret = regex.sub(repl, str(message))
return ret
def make_pages(data: List[Dict | Any], per_page: int = 5) -> List[List]:
"""Turn a list of items into pages."""
pages = []
while data:
pages.append(data[:per_page])
data = data[per_page:]
return pages
def make_embeds(
data: List[str | Dict],
embed_base: discord.Embed,
per_page: int = 5,
etype: str = "description",
) -> list[discord.Embed]:
"""Turn a list of data into embed pages."""
pages = make_pages(data, per_page=per_page)
embeds = []
if etype == "description":
for page in pages:
embed = embed_base.copy()
embed.description = "\n".join(page)
embeds.append(embed)
elif etype == "fields":
for page in pages:
embed = embed_base.copy()
for item in page:
embed.add_field(name=item["name"], value=item["value"], inline=False)
embeds.append(embed)
return embeds
@dataclass
class CommandResult:
stdout: bytes
stderr: bytes
returncode: Optional[int]
@property
def decoded(self) -> str:
return self.stdout.decode() + self.stderr.decode()
@property
def ok(self) -> bool:
return self.returncode == 0
async def run_cmd(args: str, shell=False, input=None) -> CommandResult:
"""Runs a shell command and returns raw/formatted output."""
stdin = PIPE if input else None
try:
if shell:
process = await asyncio.create_subprocess_shell(
args, stdout=PIPE, stderr=PIPE, stdin=stdin
)
else:
split_args = shlex.split(args)
process = await asyncio.create_subprocess_exec(
*split_args, stdout=PIPE, stderr=PIPE, stdin=stdin
)
stdout, stderr = await process.communicate(input=input)
except Exception as err:
raise CommandExecutionError(err)
return CommandResult(stdout, stderr, process.returncode)
async def run_cmd_to_file(args: str, filename: str, shell=False) -> discord.File | str:
"""Runs a shell command and returns the output as a discord.File."""
result = await run_cmd(args, shell=shell)
stdout, stderr = result.stdout, result.stderr
if not result.ok:
raise CommandExecutionError(stderr.decode())
fp = BytesIO(stdout)
fp.seek(0)
if len(stdout) <= 25 * 1024**2:
return discord.File(fp, filename)
else:
raise CommandExecutionError("The file is too big to upload.")
def parse_short_time(time_string: str, as_duration: bool = False):
compiled = re.compile(
"""(?:(?P<years>[0-9])(?:years?|y))? # e.g. 2y
(?:(?P<months>[0-9]{1,2})(?:months?|mo))? # e.g. 2months
(?:(?P<weeks>[0-9]{1,4})(?:weeks?|w))? # e.g. 10w
(?:(?P<days>[0-9]{1,5})(?:days?|d))? # e.g. 14d
(?:(?P<hours>[0-9]{1,5})(?:hours?|h))? # e.g. 12h
(?:(?P<minutes>[0-9]{1,5})(?:minutes?|m))? # e.g. 10m
(?:(?P<seconds>[0-9]{1,5})(?:seconds?|s))? # e.g. 15s
""",
re.VERBOSE,
)
match = compiled.fullmatch(time_string)
if match is None or not match.group(0):
raise commands.BadArgument("Invalid time provided.")
data = {k: int(v) for k, v in match.groupdict(default=0).items()}
if as_duration:
return pendulum.duration(**data)
return pendulum.now("UTC") + pendulum.duration(**data)
async def get_attachment_or_url(
ctx: commands.Context[Artemis], message: discord.Message, url: Optional[str], types: list = None
) -> bytes:
if not message.attachments and not url:
raise ArtemisError("Please send me an attachment or URL first!")
elif message.attachments:
attachment = message.attachments[0]
if types:
if not attachment.content_type:
raise ArtemisError("Cannot guess file content type.")
elif attachment.content_type not in types:
raise ArtemisError(
f"Unsupported file type, should be one of: `{', '.join(types)}`."
)
return await attachment.read()
elif url:
url = url.strip("<>")
if not is_valid_url(url):
raise ArtemisError("URL is not valid.")
utils.check_for_ssrf(url)
headers = {"User-Agent": ctx.bot.user_agent}
try:
async with ctx.bot.session.get(url, headers=headers) as r:
if not r.ok:
raise ArtemisError(f"URL returned error status {r.status}")
if types:
if not r.content_type:
if "discord" not in url:
raise ArtemisError("Cannot guess file content type.")
elif r.content_type not in types:
raise ArtemisError("Unsupported file type, should be an image.")
return await r.read()
except Exception:
raise ArtemisError("An error occured when trying to connect to the given URL.")
async def get_message_or_reference(ctx: commands.Context[Artemis]) -> discord.Message:
reference = ctx.message.reference
if reference:
try:
return reference.cached_message or await ctx.channel.fetch_message(reference.message_id)
except Exception:
return ctx.message
else:
return ctx.message
T = TypeVar("T")
def fuzzy_search(
query: str,
choices: Sequence[T],
key: str | None = None,
cutoff: float | None = None,
limit: int = 5,
) -> Sequence[T]:
"""Fuzzy search in a list of strings or a list of dictionaries, returns the matching entries."""
if isinstance(choices[0], str):
return [
result[0]
for result in process.extract(query.lower(), choices, score_cutoff=cutoff, limit=limit)
]
if not key:
raise KeyError("'key' is required for dictionary search")
lookup = {entry[key]: entry for entry in choices}
results = process.extract(query.lower(), lookup.keys(), score_cutoff=cutoff, limit=limit)
return [lookup[result[0]] for result in results]
def fuzzy_search_one(
query: str, choices: Sequence[T], key: str = None, cutoff: float = None
) -> Optional[T]:
"""Fuzzy search in a list of strings or a list of dictionaries, returns one matching entry."""
return next(iter(fuzzy_search(query, choices, key=key, cutoff=cutoff, limit=1)), None)
@dataclass
class BingResult:
url: str
title: str
description: str
async def search_bing(
ctx: commands.Context[Artemis],
query: str,
site: str | None = None,
) -> list[BingResult]:
headers = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_11_3) AppleWebKit/601.4.4 (KHTML, like Gecko) Vienna/3.0.0 Safari/1601.4.4"
}
if site:
query = quote_plus(f"{query.strip()} {site}")
url = f"https://www.bing.com/search?q={query}&setLang=en&format=rss"
async with ctx.bot.session.get(url, headers=headers) as r:
if not r.ok:
raise ArtemisError(f"Bing returned {r.status} {r.reason}")
rss = await r.text()
feed = feedparser.parse(rss)
entries = feed.entries
return [BingResult(entry["link"], entry["title"], entry["summary"]) for entry in entries]

40
utils/config.py Normal file
View File

@ -0,0 +1,40 @@
import os
from utils.common import read_toml
from dataclasses import dataclass
@dataclass
class Keys:
api: str
catbox: str
github: str
cloudflare: str
openai: str
@dataclass
class Config:
token: str
prefix: str
user_agent: str
real_user_agent: str
api_base_url: str
cdn_base_url: str
main_guild_id: int
dev_guild_id: int
keys: Keys
def __post_init__(self):
self.keys = Keys(**self.keys) # type: ignore
def load_config() -> Config:
if os.getenv("ENV") == "production":
config = read_toml("config.prod.toml")
else:
config = read_toml("config.dev.toml")
return Config(**config)
config = load_config()

275
utils/constants.py Normal file
View File

@ -0,0 +1,275 @@
from utils.common import read_json
MAX_DISCORD_SIZE = 25 * 1024**2
MAX_API_SIZE = 200 * 1024**2
MAX_CATBOX_SIZE = 200 * 1024**2
MAX_LITTERBOX_SIZE = 1024**3
WIKT_LANGUAGES = read_json("data/wiktionary-languages.json")
TEEHEE_EMOJIS = [
"<:teehee:825098257742299136>",
"<:teehee2:825098258741067787>",
"<:teehee3:825098263820632066>",
"<:teehee4:825098262884778026>",
"<:teehee5:825098263437901825>",
]
RIP_EMOJIS = [
"<:rip:825101664939147285>",
"<:rip2:825101666373206086>",
"<:rip3:825101667434889236>",
"<:rip4:825101668428546058>",
"<:rip5:825101671436255243>",
]
TESSERACT_LANGUAGES = [
"ara",
"ces",
"chi_sim",
"chi_sim_vert",
"chi_tra",
"chi_tra_vert",
"deu",
"eng",
"fra",
"heb",
"hun",
"ind",
"ita",
"jpn",
"jpn_vert",
"kor",
"kor_vert",
"msa",
"pol",
"rus",
"slk",
"slv",
"spa",
"ukr",
]
GT_LANGUAGES_EXTRAS = {
"as": "assamese",
"ay": "aymara",
"bm": "bambara",
"bho": "bhojpuri",
"dv": "dhivehi",
"doi": "dogri",
"ee": "ewe",
"gn": "guarani",
"ilo": "ilocano",
"rw": "kinyarwanda",
"gom": "konkani",
"kri": "krio",
"ckb": "kurdish (sorani)",
"ln": "lingala",
"lg": "luganda",
"mai": "maithili",
"mni-mtei": "meiteilon (manipuri)",
"lus": "mizo",
"om": "oromo",
"qu": "quechua",
"sa": "sanskrit",
"nso": "sepedi",
"tt": "tatar",
"ti": "tigrinya",
"ts": "tsonga",
"tk": "turkmen",
"ak": "twi",
}
COMMON_TIMEZONES = {
"pt": "US/Pacific",
"pst": "US/Pacific",
"mst": "US/Mountain",
"mt": "US/Mountain",
"cst": "US/Central",
"ct": "US/Central",
"est": "US/Eastern",
"et": "US/Eastern",
"wib": "Asia/Jakarta",
"wita": "Asia/Makassar",
"wit": "Asia/Jayapura",
"cet": "Europe/Warsaw",
"cest": "Europe/Warsaw",
"msk": "Europe/Moscow",
"eet": "Europe/Moscow",
"eest": "Europe/Moscow",
}
COMMON_COLOURS = {
"Almond": "#EFDECD",
"Antique Brass": "#CD9575",
"Apricot": "#FDD9B5",
"Aquamarine": "#78DBE2",
"Asparagus": "#87A96B",
"Atomic Tangerine": "#FFA474",
"Banana Mania": "#FAE7B5",
"Beaver": "#9F8170",
"Bittersweet": "#FD7C6E",
"Black": "#000000",
"Blizzard Blue": "#ACE5EE",
"Blue": "#1F75FE",
"Blue Bell": "#A2A2D0",
"Blue Gray": "#6699CC",
"Blue Green": "#0D98BA",
"Blue Violet": "#7366BD",
"Blush": "#DE5D83",
"Brick Red": "#CB4154",
"Brown": "#B4674D",
"Burnt Orange": "#FF7F49",
"Burnt Sienna": "#EA7E5D",
"Cadet Blue": "#B0B7C6",
"Canary": "#FFFF99",
"Caribbean Green": "#1CD3A2",
"Carnation Pink": "#FFAACC",
"Cerise": "#DD4492",
"Cerulean": "#1DACD6",
"Chestnut": "#BC5D58",
"Copper": "#DD9475",
"Cornflower": "#9ACEEB",
"Cotton Candy": "#FFBCD9",
"Dandelion": "#FDDB6D",
"Denim": "#2B6CC4",
"Desert Sand": "#EFCDB8",
"Eggplant": "#6E5160",
"Electric Lime": "#CEFF1D",
"Fern": "#71BC78",
"Forest Green": "#6DAE81",
"Fuchsia": "#C364C5",
"Fuzzy Wuzzy": "#CC6666",
"Gold": "#E7C697",
"Goldenrod": "#FCD975",
"Granny Smith Apple": "#A8E4A0",
"Gray": "#95918C",
"Green": "#1CAC78",
"Green Blue": "#1164B4",
"Green Yellow": "#F0E891",
"Hot Magenta": "#FF1DCE",
"Inchworm": "#B2EC5D",
"Indigo": "#5D76CB",
"Jazzberry Jam": "#CA3767",
"Jungle Green": "#3BB08F",
"Laser Lemon": "#FEFE22",
"Lavender": "#FCB4D5",
"Lemon Yellow": "#FFF44F",
"Macaroni and Cheese": "#FFBD88",
"Magenta": "#F664AF",
"Magic Mint": "#AAF0D1",
"Mahogany": "#CD4A4C",
"Maize": "#EDD19C",
"Manatee": "#979AAA",
"Mango Tango": "#FF8243",
"Maroon": "#C8385A",
"Mauvelous": "#EF98AA",
"Melon": "#FDBCB4",
"Midnight Blue": "#1A4876",
"Mountain Meadow": "#30BA8F",
"Mulberry": "#C54B8C",
"Navy Blue": "#1974D2",
"Neon Carrot": "#FFA343",
"Olive Green": "#BAB86C",
"Orange": "#FF7538",
"Orange Red": "#FF2B2B",
"Orange Yellow": "#F8D568",
"Orchid": "#E6A8D7",
"Outer Space": "#414A4C",
"Outrageous Orange": "#FF6E4A",
"Pacific Blue": "#1CA9C9",
"Peach": "#FFCFAB",
"Periwinkle": "#C5D0E6",
"Piggy Pink": "#FDDDE6",
"Pine Green": "#158078",
"Pink Flamingo": "#FC74FD",
"Pink Sherbet": "#F78FA7",
"Plum": "#8E4585",
"Purple Heart": "#7442C8",
"Purple Mountain's Majesty": "#9D81BA",
"Purple Pizzazz": "#FE4EDA",
"Radical Red": "#FF496C",
"Raw Sienna": "#D68A59",
"Raw Umber": "#714B23",
"Razzle Dazzle Rose": "#FF48D0",
"Razzmatazz": "#E3256B",
"Red": "#EE204D",
"Red Orange": "#FF5349",
"Red Violet": "#C0448F",
"Robin's Egg Blue": "#1FCECB",
"Royal Purple": "#7851A9",
"Salmon": "#FF9BAA",
"Scarlet": "#FC2847",
"Screamin' Green": "#76FF7A",
"Sea Green": "#9FE2BF",
"Sepia": "#A5694F",
"Shadow": "#8A795D",
"Shamrock": "#45CEA2",
"Shocking Pink": "#FB7EFD",
"Silver": "#CDC5C2",
"Sky Blue": "#80DAEB",
"Spring Green": "#ECEABE",
"Sunglow": "#FFCF48",
"Sunset Orange": "#FD5E53",
"Tan": "#FAA76C",
"Teal Blue": "#18A7B5",
"Thistle": "#EBC7DF",
"Tickle Me Pink": "#FC89AC",
"Timberwolf": "#DBD7D2",
"Tropical Rain Forest": "#17806D",
"Tumbleweed": "#DEAA88",
"Turquoise Blue": "#77DDE7",
"Unmellow Yellow": "#FFFF66",
"Violet (Purple)": "#926EAE",
"Violet Blue": "#324AB2",
"Violet Red": "#F75394",
"Vivid Tangerine": "#FFA089",
"Vivid Violet": "#8F509D",
"White": "#FFFFFF",
"Wild Blue Yonder": "#A2ADD0",
"Wild Strawberry": "#FF43A4",
"Wild Watermelon": "#FC6C85",
"Wisteria": "#CDA4DE",
"Yellow": "#FCE883",
"Yellow Green": "#C5E384",
"Yellow Orange": "#FFAE42",
}
SUPPORTED_CURRENCIES = [
"AUD",
"BGN",
"BRL",
"CAD",
"CHF",
"CNY",
"CZK",
"DKK",
"EUR",
"GBP",
"HKD",
"HRK",
"HUF",
"IDR",
"ILS",
"INR",
"ISK",
"JPY",
"KRW",
"MXN",
"MYR",
"NOK",
"NZD",
"PHP",
"PLN",
"RON",
"RUB",
"SEK",
"SGD",
"THB",
"TRY",
"USD",
"ZAR",
]

62
utils/enigma2.py Normal file
View File

@ -0,0 +1,62 @@
from enum import IntEnum
from typing import Literal
# https://github.com/openatv/enigma2/blob/a0979a6091df64d9f1e7283fa9bd40ca3f64d9d8/doc/SERVICEREF#L131
class ServiceType(IntEnum):
TV = 0x01 # digital television
MPEG2HDTV = 0x11 # MPEG-2 HD digital television service
SDTV = 0x16 # H.264/AVC SD digital television service
HDTV = 0x19 # H.264/AVC HD digital television service
HEVC = 0x1F # HEVC digital television service
HEVCUHD = 0x20 # HEVC UHD digital television service
UNKNOWN = -1
class Namespace(IntEnum):
# anything else is a valid satellite position
DVB_C = 0xFFFF0000
DVB_T = 0xEEEE0000
# %d:%d:%x:%x:%x:%x:%x:%x:%x:%x:%d:%s:%s
SREF_FMT = "1:0:{:x}:{:x}:{:x}:{:x}:{:x}:0:0:0:"
# 3600 - west_sat_position
def build_namespace(pos: float, cardinal: Literal["E", "W"]) -> int:
pos = int(pos * 10)
if cardinal == "W":
pos = 3600 - pos
return pos << 16
def pos_from_namespace(ns: int) -> str:
pos = ns >> 16
if pos > 1800:
return f"{(3600 - pos) / 10}W"
return f"{pos / 10}E"
# REFTYPE:FLAGS:STYPE:SID:TSID:ONID:NS:PARENT_SID:PARENT_TSID:UNUSED:PATH:NAME
# we only care about DVB here
def build_sref(service_type, sid, tsid, onid, ns):
return SREF_FMT.format(service_type, sid, tsid, onid, ns).upper()
def parse_sref(sref):
parts = sref.split(":")
service_type = ServiceType._value2member_map_.get((int(parts[2], 16)))
if not service_type:
service_type = ServiceType.UNKNOWN
pos = pos_from_namespace(int(parts[6], 16))
return {
"service_type": service_type.name,
"sid": int(parts[3], 16), # Stream ID
"tsid": int(parts[4], 16), # Transport stream ID (tp xx -> xx00 or TID in ONID-TID)
"onid": int(parts[5], 16), # Originating network ID (ONID in ONIT-TID)
"sat_pos": pos,
}

87
utils/flags.py Normal file
View File

@ -0,0 +1,87 @@
import re
from discord.ext import commands
class _PosArgSentinel:
pass
PosArgument = _PosArgSentinel
class Flags:
def __init__(self, **kwargs):
for name, value in kwargs.items():
setattr(self, name, value)
def __repr__(self) -> str:
args = []
for name, value in self.__dict__.items():
args.append(f"{name}={value!r}")
return f"Flags({', '.join(args)})"
class FlagConverter(commands.Converter):
"""Custom command flags converter and parser."""
async def convert(self, ctx: commands.Context, argument: str):
flags = self.__class__.__annotations__
if not flags:
raise ValueError("No flags provided.")
parsed_flags = {}
for name, type in flags.items():
if type is PosArgument:
value = re.sub(r"[a-zA-Z]+:[^\s\/]+", "", argument)
parsed_flags[name] = value.strip()
else:
m = re.search(rf"(\b{name}|\b{name[0]}):(?P<value>[^\s\/]+)\b", argument)
if m:
value = m.group("value")
parsed_flags[name] = value
else:
parsed_flags[name] = None
return Flags(**parsed_flags)
class TranslateFlags(FlagConverter):
text: PosArgument
source: str
dest: str
class TTSFlags(FlagConverter):
text: PosArgument
lang: str
class WiktionaryFlags(FlagConverter):
phrase: PosArgument
lang: str
class DLFlags(FlagConverter):
url: PosArgument
format: str
trim: str
name: str
ss: str
bypass: str
class WikipediaFlags(FlagConverter):
query: PosArgument
lang: str
class OCRFlags(FlagConverter):
url: PosArgument
lang: str
class OCRTranslateFlags(FlagConverter):
url: PosArgument
lang: str
source: str
dest: str

138
utils/iso_639.py Normal file
View File

@ -0,0 +1,138 @@
from __future__ import annotations
import json
import re
from csv import DictReader
from io import StringIO
from typing import TYPE_CHECKING, Literal, TypedDict
from utils.common import fuzzy_search, read_json
if TYPE_CHECKING:
from bot import Artemis
class Language(TypedDict):
id: str
part1: str
part2b: str
part2t: str
name: str
class NameResult(TypedDict):
name: str
source: str
class CodeResult(TypedDict):
name: str
part1: str
part2b: str
part2t: str
part3: str
SearchMethod = Literal["fuzzy", "strict-start", "strict"]
iso_639_1: list[Language] = []
iso_639_2b: list[Language] = []
iso_639_3: list[Language] = []
try:
iso_639_3 = read_json("data/iso_639_3.json")
iso_639_1 = [entry for entry in iso_639_3 if entry["part1"]]
iso_639_2b = [entry for entry in iso_639_3 if entry["part2b"]]
except FileNotFoundError:
pass
def _find_entry(seq: list[Language], lookup_key: str, query: str) -> Language | None:
return next((entry for entry in seq if entry[lookup_key] == query), None)
def get_language_name(code: str):
code = code.strip().lower()
if not code:
return None
found = None
# iso 639-1 alpha2 codes
if len(code) == 2:
found = _find_entry(iso_639_1, "part1", code)
if found:
found = found["name"]
# alpha3 codes
elif len(code) == 3:
# try iso-639-3 first
found = _find_entry(iso_639_3, "id", code)
if found:
found = found["name"]
else:
# try iso-639-2b
found = _find_entry(iso_639_2b, "part2b", code)
if found:
found = found["name"]
return found
def get_language_code(name: str, method: SearchMethod = "fuzzy") -> list[CodeResult] | None:
name = name.strip().lower()
if not name:
return None
if method == "fuzzy":
found = fuzzy_search(name, iso_639_3, "name", cutoff=80)
elif method == "strict-start":
found = [entry for entry in iso_639_3 if re.search(rf"^{name}\b", entry["name"], re.I)]
elif method == "strict":
found = [entry for entry in iso_639_3 if entry["name"].lower() == name]
if not found:
return None
return [
{
"name": entry["name"],
"part3": entry["id"],
"part2b": entry["part2b"],
"part2t": entry["part2t"],
"part1": entry["part1"],
}
for entry in found
]
async def build(bot: Artemis):
url = "https://iso639-3.sil.org/sites/iso639-3/files/downloads/iso-639-3.tab"
headers = {"User-Agent": bot.user_agent}
async with bot.session.get(url, headers=headers) as r:
data = await r.text()
data = DictReader(StringIO(data), delimiter="\t")
clean_data = []
for entry in data:
entry = {
k.lower(): v for k, v in entry.items() if k not in ("Scope", "Language_Type", "Comment")
}
for k in entry:
if not entry[k]:
entry[k] = None
entry["name"] = entry.pop("ref_name")
clean_data.append(entry)
with open("data/iso_639_3.json", "w") as f:
json.dump(clean_data, f)
global iso_639_3
iso_639_3 = clean_data
return len(clean_data)

75
utils/keyv.py Normal file
View File

@ -0,0 +1,75 @@
import json
from typing import Any, Callable, Optional
import aiosqlite
from typing_extensions import Self
Serializer = Callable[[Any], str]
Deserializer = Callable[[str], Any]
class keyv:
_table: str
_conn: aiosqlite.Connection
_serializer: Serializer
_deserializer: Deserializer
def __init__(self, table, conn, serializer, deserializer):
self._table = table
self._conn = conn
self._serializer = serializer
self._deserializer = deserializer
async def _execute_rowcount(self, *args, **kwargs) -> int:
async with self._conn.execute(*args, **kwargs) as cursor:
return cursor.rowcount
@classmethod
async def connect(
cls,
database: str = ":memory:",
table: str = "keyv",
serializer: Serializer = json.dumps,
deserializer: Deserializer = json.loads,
) -> Self:
conn = await aiosqlite.connect(database)
conn.row_factory = aiosqlite.Row
query = f"CREATE TABLE IF NOT EXISTS {table} (key TEXT PRIMARY KEY, value TEXT)"
await conn.execute(query)
return cls(table, conn, serializer, deserializer)
async def close(self):
return await self._conn.close()
async def get(self, key: str) -> Optional[Any]:
query = f"SELECT value FROM {self._table} WHERE key = ?"
async with self._conn.execute(query, (key,)) as cursor:
result = await cursor.fetchone()
if result:
return self._deserializer(result[0])
return None
async def get_all(self) -> list[Optional[Any]]:
query = f"SELECT value FROM {self._table}"
results = await self._conn.execute_fetchall(query)
return [self._deserializer(result[0]) for result in results]
async def set(self, key: str, value: Any):
query = f"INSERT INTO {self._table} VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value"
return await self._execute_rowcount(query, (key, self._serializer(value)))
async def delete(self, key: str):
query = f"DELETE FROM {self._table} WHERE key = ?"
return await self._execute_rowcount(query, (key,))
async def clear(self):
query = f"DELETE FROM {self._table}"
return await self._execute_rowcount(query)
def __repr__(self) -> str:
return f"<keyv table='{self._table}'>"
# shortcut constructor
async def connect(*args, **kwargs):
return await keyv.connect(*args, **kwargs)

142
utils/notifiers.py Normal file
View File

@ -0,0 +1,142 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from typing import TYPE_CHECKING, TypeVar
from collections import deque
from bs4 import BeautifulSoup
if TYPE_CHECKING:
from bot import Artemis
T = TypeVar("T")
class FeedNotifier:
NAME: str = "Base"
CHECK_INTERVAL: int | float = 60 * 5
FEED_INTERVAL: int | float = 0.1
CACHE_SIZE: int = 100
bot: Artemis
feeds: list[str]
_cache = dict[str, list[str]]
_task = asyncio.Task
def __init__(self, bot: Artemis, feeds: list[str]):
self.bot = bot
self.feeds = feeds
self._log = logging.getLogger(f"{self.NAME}Notifier")
self._cache = {}
for feed in self.feeds:
self._cache[feed] = deque([], maxlen=self.CACHE_SIZE)
def log(self, msg):
self._log.info(msg)
async def _run(self):
try:
await self._init_cache()
await asyncio.sleep(self.CHECK_INTERVAL)
self.log("Starting check loop...")
while True:
self.log("Processing feeds...")
for feed in self.feeds:
entries = await self.fetch_entries(feed)
for entry in entries:
key = self.get_cache_key(entry)
if key in self._cache[feed]:
continue
self.log(f"{feed}: New entry found, handing over to on_new_entry()")
self._cache[feed].append(key)
await self.on_new_entry(entry)
await asyncio.sleep(self.FEED_INTERVAL)
await asyncio.sleep(self.CHECK_INTERVAL)
except Exception as error:
await self.on_error(error)
async def _init_cache(self):
self.log("Bootstrapping cache...")
for feed in self.feeds:
self._cache[feed].extend(
[self.get_cache_key(entry) for entry in await self.fetch_entries(feed)]
)
self.log(f"{feed}: Bootstrapped cache with {len(self._cache[feed])} entries.")
def start(self):
self._task = asyncio.create_task(self._run())
self.log("Worker started.")
return self
def stop(self):
try:
self._task.cancel()
except asyncio.CancelledError:
pass
finally:
self.log("Worker stopped.")
def get_cache_key(self, entry: T) -> str:
raise NotImplementedError()
async def fetch_entries(self, feed: str) -> list[T]:
raise NotImplementedError()
async def on_new_entry(self, entry: T):
raise NotImplementedError()
async def on_error(self, error: Exception):
await self.send_to_user(
self.bot.owner_id, f"[{self.NAME}Notifier] {error.__class__.__name__}: {str(error)}"
)
async def fetch_html(self, url):
self.log(f"Fetching {url}")
headers = {"User-Agent": self.bot.user_agent}
async with self.bot.session.get(url, headers=headers) as r:
html = await r.text()
return BeautifulSoup(html, "lxml")
async def fetch_json(self, url) -> dict:
headers = {"User-Agent": self.bot.user_agent}
async with self.bot.session.get(url, headers=headers) as r:
return await r.json()
async def send_to_channel(self, channel_id: int, *args, **kwargs):
self.log(f"Sending new entry to channel {channel_id}.")
await self.bot.get_channel(channel_id).send(*args, **kwargs)
async def send_to_user(self, user_id: int, *args, **kwargs):
self.log(f"Sending new entry to user {user_id}.")
await self.bot.get_user(user_id).send(*args, **kwargs)
@dataclass
class HNEntry:
title: str
url: str
class HackerNewsNotifier(FeedNotifier):
NAME = "HackerNews"
CHECK_INTERVAL = 60
def get_cache_key(self, entry: HNEntry) -> str:
return entry.url
async def fetch_entries(self, feed: str) -> list[HNEntry]:
url = "https://news.ycombinator.com/" + feed
soup = await self.fetch_html(url)
articles = []
for article in soup.select("tr.athing"):
titleline = article.select_one("span.titleline > a")
url = titleline["href"]
title = titleline.text
articles.append(HNEntry(title, url))
return list(reversed(articles))
async def on_new_entry(self, entry: HNEntry):
await self.send_to_user(self.bot.owner_id, f"{entry.title}\n{entry.url}")

270
utils/reddit.py Normal file
View File

@ -0,0 +1,270 @@
from __future__ import annotations
from functools import cached_property
import html
import random
import re
from typing import Any, Literal, Optional
import discord
import pendulum
from aiohttp import ClientSession
from humanize import intcomma
from yt_dlp.utils import random_user_agent
import utils
from utils.common import ArtemisError
class Route:
method: str
path: str
url: str
BASE = "https://old.reddit.com"
def __init__(self, method, path):
self.method = method
self.path = path
self.url = self.BASE + self.path
class Reddit:
def __init__(self, session: ClientSession):
self.session: ClientSession = session
@staticmethod
def _gen_session_id() -> str:
id_length = 16
rand_max = 1 << (id_length * 4)
return "%0.*x" % (id_length, random.randrange(rand_max))
async def _request(self, route: Route, **kwargs) -> dict[str, Any]:
headers = {"User-Agent": random_user_agent()}
cookies = {
"reddit_session": self._gen_session_id(),
"_options": "%7B%22pref_quarantine_optin%22%3A%20true%7D",
}
async with self.session.request(
route.method, route.url, headers=headers, cookies=cookies, **kwargs
) as r:
data = await r.json()
return data
async def subreddit(
self,
name: str = "all",
sort: Literal["hot", "new"] = "hot",
include_stickied_and_pinned: bool = False,
) -> list[Post]:
route = Route("GET", f"/r/{name}/{sort}.json")
data = await self._request(route)
if data.get("reason"):
raise ArtemisError(f"This subreddit is inaccessible.\nReason: `{data['reason']}`")
if not data.get("data") or not data["data"]["children"]:
raise ArtemisError(f"Subreddit `{name}` not found.")
posts = [Post(post["data"]) for post in data["data"]["children"]]
if not include_stickied_and_pinned:
posts = [post for post in posts if not post.stickied and not post.pinned]
return posts
async def post(self, pid: str):
route = Route("GET", f"/{pid}.json?limit=1")
try:
data = await self._request(route)
post_data = data[0]["data"]["children"][0]["data"]
return Post(post_data)
except Exception:
return None
async def random(self, subreddit: str = "all", *args, **kwargs) -> Post:
posts = await self.subreddit(subreddit, *args, **kwargs)
return random.choice(posts)
async def random_image(self, subreddit: str = "all", *args, **kwargs) -> str:
posts = await self.subreddit(subreddit, *args, **kwargs)
images = [post for post in posts if post.image or post.gallery]
post = random.choice(images)
if post.gallery:
return post.gallery[0]
return post.image
class Post:
ICON = "https://www.redditstatic.com/desktop2x/img/favicon/android-icon-192x192.png"
GOLD_ICON = "https://www.redditstatic.com/gold/awards/icon/gold_64.png"
def __init__(self, data: dict):
self.data = data
self.title = html.unescape(self.data["title"])
self.body = self.data.get("selftext")
self.thumbnail = self.data.get("thumbnail", "")
self.over_18 = self.data.get("over_18")
self.stickied = self.data.get("stickied")
self.pinned = self.data.get("pinned")
self.spoiler = self.data.get("spoiler")
self.score = self.data.get("score", "N/A")
self.num_comments = self.data.get("num_comments", "N/A")
self.gilded = self.data.get("gilded")
self.awards = self.data.get("all_awardings")
self.permalink = "https://reddit.com" + self.data.get("permalink", "")
self.subreddit = self.data.get("subreddit")
self.subreddit_prefixed = f"r/{self.subreddit}"
self.created_at = pendulum.from_timestamp(self.data["created_utc"], "UTC")
@cached_property
def image(self) -> Optional[str]:
image = self.data.get("url_overridden_by_dest", "")
if not self.body:
if self.data.get("secure_media") or self.data.get("media_embed"):
return None
if re.search(r"(i\.redd\.it\/[^\/]+\.gifv)|gifv|webm", image):
return None
elif re.search(r"jpg|png|webp|gif|jfif|jpeg|imgur", image):
return image
return None
@cached_property
def video(self) -> Optional[str]:
media = self.data.get("media") or self.data.get("secure_media")
if not media:
return None
reddit_video = media.get("reddit_video")
if not reddit_video:
return None
playlist = reddit_video.get("dash_url") or reddit_video.get("hls_url")
if not playlist:
return None
return playlist
@cached_property
def preview(self) -> Optional[str]:
try:
preview = self.data["preview"]["images"][0]["source"]["url"]
return html.unescape(preview)
except Exception:
return None
@cached_property
def gallery(self) -> list[str]:
if not self.data.get("gallery_data") or not self.data.get("media_metadata"):
return []
images: list[str] = []
metadata = self.data["media_metadata"]
for image in self.data["gallery_data"]["items"]:
media_id = image["media_id"]
try:
url = html.unescape(metadata[media_id]["s"]["u"])
except Exception:
url = html.unescape(metadata[media_id]["s"]["gif"])
images.append(url)
return images
def get_warnings(self, nsfw: bool) -> str | None:
warnings = []
if self.spoiler:
warnings.append("SPOILER")
if nsfw:
warnings.append("NSFW")
if self.data.get("secure_media") or self.data.get("media_embed"):
warnings.append("UNSUPPORTED MEDIA")
if warnings:
return f"`❗ {', '.join(warnings)}`" # type: ignore
return None
def is_nsfw(self, message: discord.Message):
return self.over_18 and message.guild and not message.channel.is_nsfw()
async def to_embed(self, message: discord.Message) -> list[discord.Embed]:
COLOUR = discord.Colour(0xFF4500)
SPOILER_IMG_URL = "https://derpicdn.net/img/2016/5/22/1160541/medium.png"
NSFW_IMG_URL = "https://upload.wikimedia.org/wikipedia/commons/thumb/7/7a/Znaczek_TV_-_dozwolone_od_lat_18.svg/150px-Znaczek_TV_-_dozwolone_od_lat_18.svg.png"
files = []
icon_url = None
embed = discord.Embed(title=utils.trim(self.title, 256), url=self.permalink, colour=COLOUR)
embeds = [embed]
nsfw = self.is_nsfw(message)
if self.image:
embed.set_image(url=self.image)
if self.body:
body = html.unescape(utils.trim(self.body.strip(), 4096))
images = re.findall(r"https.*(?:png|jpg|jpeg|webp|gif)\S*", body)
for idx, url in enumerate(images[:10]):
if idx == 0:
embed.set_image(url=url)
else:
embeds.append(discord.Embed(color=COLOUR).set_image(url=url))
if self.spoiler or nsfw:
body = f"||{body}||"
embed.description = body
if self.gallery:
for idx, url in enumerate(self.gallery[:10]):
if idx == 0:
embed.set_image(url=url)
else:
embeds.append(discord.Embed(colour=COLOUR).set_image(url=url))
if not embed.image:
if self.preview:
embed.set_image(url=self.preview)
elif self.thumbnail and "http" in self.thumbnail:
embed.set_thumbnail(url=self.thumbnail)
if nsfw:
if embed.thumbnail:
embed.set_thumbnail(url=NSFW_IMG_URL)
elif embed.image:
for idx, embed in enumerate(embeds):
embed.set_image(url=NSFW_IMG_URL)
if self.spoiler:
if embed.image and not files:
for idx, embed in enumerate(embeds):
embed.set_image(url=SPOILER_IMG_URL)
warnings = self.get_warnings(nsfw)
if warnings:
if embed.description:
embed.description = f"{warnings}\n\n{embed.description}"
else:
embed.description = warnings
if self.gilded:
icon_url = self.GOLD_ICON
elif self.awards:
sorted_awards = sorted(self.awards, key=lambda x: int(x.get("count")), reverse=True)
icon_url = sorted_awards[0]["icon_url"]
upvotes = f"{intcomma(self.score)} upvote{'s' if self.score != 1 else ''}"
comments = f"{intcomma(self.num_comments)} comment{'s' if self.num_comments != 1 else ''}"
embed.set_author(
name=self.subreddit_prefixed,
icon_url=self.ICON,
url=f"https://reddit.com/r/{self.subreddit}",
)
embeds[-1].set_footer(text=f"{upvotes} and {comments}", icon_url=icon_url)
embeds[-1].timestamp = self.created_at
return embeds

91
utils/unogs.py Normal file
View File

@ -0,0 +1,91 @@
import json
import time
from base64 import b64decode
from typing import Optional
from urllib.parse import quote
from aiohttp import ClientSession
from yt_dlp.utils import random_user_agent
import utils
class uNoGSError(Exception):
pass
class uNoGS:
token: Optional[str]
token_expiry: Optional[int]
_API_BASE = "https://unogs.com/api"
_EMPTY_PARAMS = [
"country_andorunique",
"start_year",
"end_year",
"start_rating",
"end_rating",
"genrelist",
"type",
"audio",
"subtitle",
"audiosubtitle_andor",
"person",
"filterby",
"orderby",
]
_COUNTRY_LIST = "21,23,26,29,33,36,307,45,39,327,331,334,265,337,336,269,267,357,378,65,67,390,392,268,400,402,408,412,447,348,270,73,34,425,432,436,46,78"
_DEFAULT_HEADERS = {
"User-Agent": random_user_agent(),
"Referer": "https://unogs.com",
"Referrer": "http://unogs.com",
}
_DETAILS = ["detail", "bgimages", "genres", "people", "countries", "episodes"]
def __init__(self, session: ClientSession):
self.session: ClientSession = session
self.token = None
self.token_expiry = None
async def _validate_token(self):
if not self.token or self.token_expiry < utils.time():
await self._fetch_token()
async def _fetch_token(self):
data = {"user_name": round(time.time(), 3)}
async with self.session.post(
self._API_BASE + "/user", headers=self._DEFAULT_HEADERS, data=data
) as r:
data = await r.json()
token = data["token"]["access_token"]
self.token = token
token_data = token.split(".")[1] + "=="
token_data = b64decode(token_data).decode()
self.token_expiry = json.loads(token_data)["exp"]
async def _request(self, path: str, **kwargs):
await self._validate_token()
headers = {**self._DEFAULT_HEADERS, "Authorization": f"Bearer {self.token}"}
cookies = {"authtoken": "token"}
async with self.session.get(
self._API_BASE + path, headers=headers, cookies=cookies, **kwargs
) as r:
return await r.json()
async def search(self, query: str):
params = {
"limit": "20",
"offset": "0",
"query": quote(query),
"countrylist": self._COUNTRY_LIST,
}
for param in self._EMPTY_PARAMS:
params[param] = ""
return await self._request("/search", params=params)
async def fetch_details(self, nfid, kind="detail"):
if kind not in self._DETAILS:
raise uNoGSError("Incorrect detail kind.")
return await self._request(f"/title/{kind}", params={"netflixid": nfid})

182
utils/views.py Normal file
View File

@ -0,0 +1,182 @@
from typing import Any, List
import discord
from discord.ext import commands
from utils.common import trim
class BaseView(discord.ui.View):
def __init__(self, ctx: commands.Context, timeout: int = 180):
super().__init__(timeout=timeout)
self.ctx = ctx
async def interaction_check(self, interaction):
if interaction.user == self.ctx.author:
return True
await interaction.response.send_message(
"This interaction cannot be controlled by you, sorry!", ephemeral=True
)
class ConfirmView(BaseView):
def __init__(self, ctx: commands.Context):
super().__init__(ctx, timeout=60)
self.result = None
@discord.ui.button(label="Yes", style=discord.ButtonStyle.green)
async def yes(self, interaction, button):
self.result = True
self.stop()
await self.message.delete()
@discord.ui.button(label="No", style=discord.ButtonStyle.red)
async def no(self, interaction, button):
self.result = False
self.stop()
await self.message.delete()
async def prompt(self, message="Are you sure?", timeout_msg="You took too long!"):
self.message = await self.ctx.send(message, view=self)
if await self.wait():
await self.message.edit(content=timeout_msg, view=None)
return None
return self.result
class ViewPages(BaseView):
def __init__(self, ctx: commands.Context, items: List[Any], timeout: int = 180):
super().__init__(ctx=ctx, timeout=timeout)
self.items = items
self.current_page = 0
self.pages = len(self.items)
self.use_last_and_first = self.pages > 2
def get_kwargs(self, item: discord.Embed | str):
if isinstance(item, discord.Embed):
return {"embed": item}
elif isinstance(item, str):
return {"content": item}
async def start(self):
start_page = self.items[0]
kwargs = self.get_kwargs(start_page)
if self.pages == 1:
return await self.ctx.send(**kwargs)
elif not self.use_last_and_first:
self.remove_item(self.first_page)
self.remove_item(self.last_page)
self.update_labels()
self.message = await self.ctx.send(**kwargs, view=self)
async def update_view(self, interaction: discord.Interaction):
item = self.items[self.current_page]
kwargs = self.get_kwargs(item)
self.update_labels()
if interaction.response.is_done():
if self.message:
await self.message.edit(**kwargs, view=self)
else:
await interaction.response.edit_message(**kwargs, view=self)
def update_labels(self):
self.current_page_display.label = f"{self.current_page + 1}/{self.pages}"
if self.use_last_and_first:
self.first_page.disabled = self.current_page == 0
self.last_page.disabled = (self.current_page + 1) >= self.pages
self.next_page.disabled = False
self.previous_page.disabled = False
if (self.current_page + 1) >= self.pages:
self.next_page.disabled = True
elif self.current_page == 0:
self.previous_page.disabled = True
@discord.ui.button(label="◀◀", style=discord.ButtonStyle.blurple)
async def first_page(self, interaction, button):
self.current_page = 0
await self.update_view(interaction)
@discord.ui.button(label="", style=discord.ButtonStyle.blurple)
async def previous_page(self, interaction, button):
if self.current_page != 0:
self.current_page -= 1
await self.update_view(interaction)
@discord.ui.button(label="PH", style=discord.ButtonStyle.gray, disabled=True)
async def current_page_display(self, interaction, button):
pass
@discord.ui.button(label="", style=discord.ButtonStyle.blurple)
async def next_page(self, interaction, button):
if not (self.current_page + 1) >= self.pages:
self.current_page += 1
await self.update_view(interaction)
@discord.ui.button(label="▶▶", style=discord.ButtonStyle.blurple)
async def last_page(self, interaction, button):
self.current_page = self.pages - 1
await self.update_view(interaction)
async def on_timeout(self):
if self.message:
await self.message.edit(view=None)
class BaseDropdown(discord.ui.Select):
def __init__(self, items: list, label_key, description_key, placeholder: str, max_values: int):
self.items = items
options = []
for i, item in enumerate(self.items[:25]):
options.append(
discord.SelectOption(
label=trim(label_key(item), 100),
description=trim(description_key(item), 100) if description_key else None,
value=str(i),
)
)
super().__init__(placeholder=placeholder, options=options, max_values=max_values)
async def callback(self, interaction: discord.Interaction):
if self.max_values > 1:
self.view.result = [self.items[int(i)] for i in self.values]
else:
result_idx = int(self.values[0])
self.view.result = self.items[result_idx]
self.view.stop()
await self.view.message.delete()
class DropdownView(BaseView):
def __init__(
self,
ctx,
items,
label_key,
description_key=None,
placeholder="Choose one...",
max_values=1,
chunk: bool = False,
):
super().__init__(ctx, timeout=60)
if chunk:
items = items[: 5 * 25]
for i in range(0, len(items), 25):
chunk = items[i : i + 25]
self.add_item(
BaseDropdown(chunk, label_key, description_key, placeholder, len(chunk))
)
else:
self.add_item(BaseDropdown(items, label_key, description_key, placeholder, max_values))
self.result = None
async def prompt(self, message="Which one?", timeout_msg="You took too long!"):
self.message = await self.ctx.send(message, view=self)
if await self.wait():
await self.message.edit(content=timeout_msg, view=None)
return None
return self.result