mirror of
https://github.com/artiemis/artemis.git
synced 2026-02-14 00:21:56 +00:00
initial commit
This commit is contained in:
commit
7a66139810
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
__pycache__/
|
||||
temp/
|
||||
env/
|
||||
*.log
|
||||
config.prod.toml
|
||||
config.dev.toml
|
||||
status.json
|
||||
19
LICENSE
Normal file
19
LICENSE
Normal 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
11
Makefile
Normal 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
|
||||
268
bot.py
Normal file
268
bot.py
Normal 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
543
cogs/anime.py
Normal 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
118
cogs/events.py
Normal 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
640
cogs/funhouse.py
Normal 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
753
cogs/language.py
Normal 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 są 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
674
cogs/media.py
Normal 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
183
cogs/meta.py
Normal 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
193
cogs/mod.py
Normal 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
275
cogs/music.py
Normal 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
188
cogs/ocr.py
Normal 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
363
cogs/owner.py
Normal 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
797
cogs/useful.py
Normal 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
15
config.example.toml
Normal 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
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
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
1
data/nimi.json
Normal file
File diff suppressed because one or more lines are too long
1
data/pokedex.json
Normal file
1
data/pokedex.json
Normal file
File diff suppressed because one or more lines are too long
7417
data/wiktionary-languages.json
Normal file
7417
data/wiktionary-languages.json
Normal file
File diff suppressed because it is too large
Load Diff
17
ecosystem.config.js
Normal file
17
ecosystem.config.js
Normal 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
6
pyproject.toml
Normal file
@ -0,0 +1,6 @@
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py311']
|
||||
|
||||
[tool.ruff]
|
||||
ignore = ["E501"]
|
||||
26
requirements.txt
Normal file
26
requirements.txt
Normal 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
3
utils/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .common import *
|
||||
from .constants import *
|
||||
from .config import config # noqa: F401
|
||||
138
utils/anilist.py
Normal file
138
utils/anilist.py
Normal 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
84
utils/api.py
Normal 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
106
utils/catbox.py
Normal 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
557
utils/common.py
Normal 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
40
utils/config.py
Normal 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
275
utils/constants.py
Normal 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
62
utils/enigma2.py
Normal 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
87
utils/flags.py
Normal 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
138
utils/iso_639.py
Normal 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
75
utils/keyv.py
Normal 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
142
utils/notifiers.py
Normal 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
270
utils/reddit.py
Normal 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
91
utils/unogs.py
Normal 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
182
utils/views.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user