Repository: TechStruck/TechStruck-Bot
Branch: main
Commit: 514519896d31
Files: 57
Total size: 137.9 KB
Directory structure:
gitextract_f5y6cz_r/
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ ├── dependabot.yml
│ └── workflows/
│ ├── black.yml
│ ├── codeql-analysis.yml
│ └── isort.yml
├── .gitignore
├── .isort.cfg
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── api/
│ ├── dependencies.py
│ ├── exceptions.py
│ ├── main.py
│ └── routers/
│ ├── oauth.py
│ └── webhooks.py
├── bot/
│ ├── __main__.py
│ ├── bot.py
│ ├── cogs/
│ │ ├── admin.py
│ │ ├── brainfeed.py
│ │ ├── coc.py
│ │ ├── code_exec.py
│ │ ├── fun.py
│ │ ├── github.py
│ │ ├── help_command.py
│ │ ├── joke.py
│ │ ├── packages.py
│ │ ├── quiz.py
│ │ ├── rtfm.py
│ │ ├── stackexchange.py
│ │ ├── thank.py
│ │ └── utils.py
│ ├── core.py
│ └── utils/
│ ├── embed_flag_input.py
│ ├── fuzzy.py
│ ├── process_files.py
│ └── rtfm.py
├── bot.Dockerfile
├── config/
│ ├── __init__.py
│ ├── bot.py
│ ├── common.py
│ ├── oauth.py
│ ├── reddit.py
│ └── webhook.py
├── heroku.yml
├── models.py
├── public/
│ └── templates/
│ ├── 404.html
│ ├── oauth_error.html
│ └── oauth_success.html
├── requirements-bot.txt
├── requirements-dev.txt
├── requirements.txt
├── tortoise_config.py
├── utils/
│ ├── db_backup.py
│ ├── embed.py
│ └── webhook.py
└── vercel.json
================================================
FILE CONTENTS
================================================
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: FalseDev
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Use commands '....'
2. Do '....'
3. See error
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Additional context**
Add any other context about the problem here.
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
================================================
FILE: .github/workflows/black.yml
================================================
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: psf/black@stable
================================================
FILE: .github/workflows/codeql-analysis.yml
================================================
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ main ]
schedule:
- cron: '32 12 * * 3'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# ℹ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1
================================================
FILE: .github/workflows/isort.yml
================================================
name: Run isort
on:
- push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
with:
python-version: 3.9
- uses: jamescurtin/isort-action@master
with:
requirementsFiles: "requirements.txt requirements-dev.txt"
================================================
FILE: .gitignore
================================================
config.yaml
.vim
.env
**/__pycache__
tmp
cache
# Tortoise stuff
aerich.ini
migrations
.vercel
================================================
FILE: .isort.cfg
================================================
[settings]
multi_line_output = 3
include_trailing_comma = True
force_grid_wrap = 0
use_parentheses = True
ensure_newline_before_comments = True
line_length = 88
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at thetechnopath1802@gmail.com. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 FalseDev
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.
================================================
FILE: README.md
================================================
Techstruck
• Info
Tech Struck is a discord server where developers, designers and just about everyone struck with curiosity on tech unite together as a community!
This repository has Tech Struck server's custom bot along with webhook based announcement and BrainFeed senders for Tech Struck
• I'd like to contribute
You may help by adding features to Tech Struck or fix bugs in the code. Here's how:
Fork the repository
Clone your fork: git clone https://github.com/your-username/Tech-Struck.git
Create your feature branch: git checkout -b my-new-feature
Commit your changes: git commit -am 'uwu new feature'
Push to the branch: git push origin my-new-feature
Submit a pull request
• I found a bug!
Please open an issue or even send a pull request with the fix to help up keet the bot and other bug free!
================================================
FILE: api/dependencies.py
================================================
import hmac
import ssl
from datetime import datetime
import asyncpg
from aiohttp import ClientSession
from fastapi import Header, HTTPException, Query, Request, status, templating
from jose import jwt
from config.common import config
from config.webhook import webhook_config
from .exceptions import CustomHTTPException
jinja = templating.Jinja2Templates("./public/templates/")
def auth_dep(authorization: str = Header(...)):
if not hmac.compare_digest(authorization, webhook_config.authorization):
raise HTTPException(status.HTTP_401_UNAUTHORIZED)
async def aiohttp_session():
session = ClientSession(headers={"Accept": "application/json"})
try:
yield session
finally:
await session.close()
def state_check(request: Request, state: str = Query(...)) -> int:
try:
payload = jwt.decode(state, config.secret)
except jwt.JWTError:
raise CustomHTTPException(
jinja.TemplateResponse(
"oauth_error.html",
{"request": request, "detail": "Invalid state"},
status_code=status.HTTP_406_NOT_ACCEPTABLE,
)
)
expiry = datetime.fromisoformat(payload["expiry"])
if datetime.now() > expiry:
raise CustomHTTPException(
jinja.TemplateResponse(
"oauth_error.html",
{"request": request, "detail": "Expired link"},
status_code=status.HTTP_406_NOT_ACCEPTABLE,
)
)
return payload["id"]
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
async def db_connection():
connection = await asyncpg.connect(config.database_uri, ssl=ctx)
try:
yield connection
finally:
await connection.close()
================================================
FILE: api/exceptions.py
================================================
class CustomHTTPException(Exception):
def __init__(self, response):
self.response = response
================================================
FILE: api/main.py
================================================
import sys
from fastapi import FastAPI, Request
from .exceptions import CustomHTTPException
from .routers import oauth, webhooks
if sys.version_info[1] < 7:
from backports.datetime_fromisoformat import MonkeyPatch
MonkeyPatch.patch_fromisoformat()
app = FastAPI()
@app.exception_handler(CustomHTTPException)
def custom_http_exception_handler(request: Request, exc: CustomHTTPException):
return exc.response
app.include_router(oauth.router)
app.include_router(webhooks.router)
================================================
FILE: api/routers/oauth.py
================================================
from datetime import datetime
from urllib.parse import parse_qs
import asyncpg
from aiohttp import ClientSession
from fastapi import (
APIRouter,
Depends,
HTTPException,
Query,
Request,
status,
templating,
)
from jose import jwt
from config.common import config
from config.oauth import github_oauth_config, stack_oauth_config
from ..dependencies import aiohttp_session, db_connection, jinja, state_check
router = APIRouter(
prefix="/oauth",
)
# {table} is the table name, {field} the field name
# Hence this query is safe against sql injection type attacks
insert_or_update_template = """
insert into {table} (id, {field}) values ($1, $2) on conflict (id) do update set {field}=$2
""".strip()
stack_sql_query = insert_or_update_template.format(
table="users", field="stackoverflow_oauth_token"
)
github_sql_query = insert_or_update_template.format(
table="users", field="github_oauth_token"
)
# TODO: Cache recently used jwt tokens until expiry and deny their usage
# TODO: Serverless is stateless, hence use db caching
@router.get("/stackexchange")
async def stackexchange_oauth(
request: Request,
code: str = Query(...),
user_id: int = Depends(state_check),
db_conn: asyncpg.pool.Pool = Depends(db_connection),
session: ClientSession = Depends(aiohttp_session),
):
"""Link account with stackexchange through OAuth2"""
res = await session.post(
"https://stackoverflow.com/oauth/access_token/json",
data={**stack_oauth_config.dict(), "code": code},
)
auth = await res.json()
if "access_token" not in auth:
return {k: v for k, v in auth.items() if k.startswith("error_")}
await db_conn.execute(stack_sql_query, user_id, auth["access_token"])
return jinja.TemplateResponse(
"oauth_success.html", {"request": request, "oauth_provider": "Stackexchange"}
)
@router.get("/github")
async def github_oauth(
request: Request,
code: str = Query(...),
user_id: int = Depends(state_check),
db_conn: asyncpg.pool.Pool = Depends(db_connection),
session: ClientSession = Depends(aiohttp_session),
):
"""Link account with github through OAuth2"""
res = await session.post(
"https://github.com/login/oauth/access_token",
data={**github_oauth_config.dict(), "code": code},
)
auth = await res.json()
await db_conn.execute(github_sql_query, user_id, auth["access_token"])
return jinja.TemplateResponse(
"oauth_success.html", {"request": request, "oauth_provider": "Github"}
)
================================================
FILE: api/routers/webhooks.py
================================================
import datetime
import json
import random
from concurrent import futures
from typing import Iterable, List
from aiohttp import ClientSession
from discord import AsyncWebhookAdapter, Color, Embed, RequestsWebhookAdapter, Webhook
from fastapi import APIRouter, Depends
from praw import Reddit
from config.reddit import reddit_config
from config.webhook import webhook_config
from ..dependencies import aiohttp_session, auth_dep
router = APIRouter(prefix="/webhooks", dependencies=[Depends(auth_dep)])
reddit = Reddit(
**reddit_config.dict(),
user_agent="TechStruck",
)
REDDIT_ALLOWED_FORMATS = (".jpg", ".gif", ".png", ".jpeg")
SUBREDDITS = (
"memes",
"meme",
"dankmeme",
"me_irl",
"dankmemes",
"showerthoughts",
"jokes",
"funny",
)
def send_meme(webhook: Webhook, subreddits: List[str]) -> bool:
meme_subreddit = reddit.subreddit(random.choice(subreddits))
meme = meme_subreddit.random()
if not any((meme.url.endswith(i) for i in REDDIT_ALLOWED_FORMATS)):
return False
embed = Embed(title=meme.title, color=Color.magenta())
embed.set_image(url=meme.url)
embed.set_footer(text=f"\U0001f44d {meme.ups} \u2502 \U0001f44e {meme.downs}")
webhook.send(embed=embed)
return True
# The subreddits arg exists although theres a
# global so that in the future it can be
# modified for multiple channels/servers
def send_memes(webhook: Webhook, subreddits: Iterable[str], quantity: int):
sent = 0
skipped = 0
with futures.ThreadPoolExecutor() as tp:
while sent < quantity:
results = [
tp.submit(send_meme, webhook, subreddits)
for _ in range(quantity - sent)
]
new_sent = sum([r.result() for r in results])
skipped += (quantity - sent) - new_sent
sent += new_sent
return sent, skipped
@router.get("/meme")
def send_memes_route():
sent, skipped = send_memes(
Webhook.from_url(webhook_config.meme, adapter=RequestsWebhookAdapter()),
SUBREDDITS,
5,
)
return {"sent": sent, "skipped": skipped}
@router.get("/git-tip")
async def git_tip(session: ClientSession = Depends(aiohttp_session)):
tips_json_url = "https://raw.githubusercontent.com/git-tips/tips/master/tips.json"
async with session.get(tips_json_url) as res:
tips = json.loads(await res.text())
tip_no = (datetime.date.today() - datetime.date(2021, 1, 31)).days
tip = tips[tip_no]
await Webhook.from_url(
webhook_config.git_tips, adapter=AsyncWebhookAdapter(session)
).send(
"<@&804403893760688179>",
embed=Embed(
title=tip["title"],
description="```sh\n" + tip["tip"] + "```",
color=Color.green(),
).set_footer(text="Tip {}".format(tip_no)),
avatar_url="https://upload.wikimedia.org/wikipedia/commons/thumb/3/3f/Git_icon.svg/2000px-Git_icon.svg.png",
)
return {"status": "success"}
================================================
FILE: bot/__main__.py
================================================
import os
from .bot import TechStruckBot
os.environ.setdefault("JISHAKU_HIDE", "1")
os.environ.setdefault("JISHAKU_RETAIN", "1")
os.environ.setdefault("JISHAKU_NO_UNDERSCORE", "1")
if __name__ == "__main__":
from config.bot import bot_config
from tortoise_config import tortoise_config
bot = TechStruckBot(tortoise_config=tortoise_config)
bot.run(bot_config.bot_token)
================================================
FILE: bot/bot.py
================================================
import asyncio
import contextlib
import math
import re
import traceback
from typing import Iterable
from aiohttp import ClientSession
from discord import (
AllowedMentions,
AsyncWebhookAdapter,
Color,
Embed,
Forbidden,
Intents,
Message,
NotFound,
TextChannel,
Webhook,
utils,
)
from discord.ext import commands, tasks
from discord.http import HTTPClient
from tortoise import Tortoise
from config.bot import bot_config
from models import GuildModel
class TechStruckBot(commands.Bot):
http: HTTPClient
def __init__(self, *, tortoise_config, load_extensions=True, loadjsk=True):
allowed_mentions = AllowedMentions(
users=True, replied_user=True, roles=False, everyone=False
)
super().__init__(
command_prefix=self.get_custom_prefix,
intents=Intents.all(),
allowed_mentions=allowed_mentions,
description="A bot by and for developers to integrate several tools into one place.",
strip_after_prefix=True,
)
self.tortoise_config = tortoise_config
self.db_connected = False
self.prefix_cache = {}
self.connect_db.start()
if load_extensions:
self.load_extensions(
(
"bot.core",
"bot.cogs.admin",
"bot.cogs.thank",
"bot.cogs.stackexchange",
"bot.cogs.github",
"bot.cogs.help_command",
"bot.cogs.code_exec",
"bot.cogs.fun",
"bot.cogs.rtfm",
"bot.cogs.joke",
"bot.cogs.utils",
"bot.cogs.brainfeed",
"bot.cogs.packages",
"bot.cogs.coc",
)
)
if loadjsk:
self.load_extension("jishaku")
@property
def session(self) -> ClientSession:
return self.http._HTTPClient__session # type: ignore
@tasks.loop(seconds=0, count=1)
async def connect_db(self):
print("Connecting to db")
await Tortoise.init(self.tortoise_config)
self.db_connected = True
print("Database connected")
def load_extensions(self, extentions: Iterable[str]):
for ext in extentions:
try:
self.load_extension(ext)
except Exception as e:
traceback.print_exception(type(e), e, e.__traceback__)
async def on_message(self, msg: Message):
if msg.author.bot:
return
while not self.db_connected:
await asyncio.sleep(0.2)
user_id = self.user.id
if msg.content in (f"<@{user_id}>", f"<@!{user_id}>"):
return await msg.reply(
"My prefix here is `{}`".format(await self.fetch_prefix(msg))
)
await self.process_commands(msg)
async def on_command_error(
self, ctx: commands.Context, error: commands.CommandError
):
if isinstance(error, commands.CommandNotFound):
return
if not isinstance(error, commands.CommandInvokeError):
title = " ".join(
re.compile(r"[A-Z][a-z]*").findall(error.__class__.__name__)
)
return await ctx.send(
embed=Embed(title=title, description=str(error), color=Color.red())
)
# If we've reached here, the error wasn't expected
# Report to logs
embed = Embed(
title="Error",
description="An unknown error has occurred and my developer has been notified of it.",
color=Color.red(),
)
with contextlib.suppress(NotFound, Forbidden):
await ctx.send(embed=embed)
traceback_text = "".join(
traceback.format_exception(type(error), error, error.__traceback__)
)
length = len(traceback_text)
chunks = math.ceil(length / 1990)
traceback_texts = [
traceback_text[l * 1990 : (l + 1) * 1990] for l in range(chunks)
]
traceback_embeds = [
Embed(
title="Traceback",
description=("```py\n" + text + "\n```"),
color=Color.red(),
)
for text in traceback_texts
]
# Add message content
info_embed = Embed(
title="Message content",
description="```\n" + utils.escape_markdown(ctx.message.content) + "\n```",
color=Color.red(),
)
# Guild information
value = (
(
"**Name**: {0.name}\n"
"**ID**: {0.id}\n"
"**Created**: {0.created_at}\n"
"**Joined**: {0.me.joined_at}\n"
"**Member count**: {0.member_count}\n"
"**Permission integer**: {0.me.guild_permissions.value}"
).format(ctx.guild)
if ctx.guild
else "None"
)
info_embed.add_field(name="Guild", value=value)
# Channel information
if isinstance(ctx.channel, TextChannel):
value = (
"**Type**: TextChannel\n"
"**Name**: {0.name}\n"
"**ID**: {0.id}\n"
"**Created**: {0.created_at}\n"
"**Permission integer**: {1}\n"
).format(ctx.channel, ctx.channel.permissions_for(ctx.guild.me).value)
else:
value = (
"**Type**: DM\n" "**ID**: {0.id}\n" "**Created**: {0.created_at}\n"
).format(ctx.channel)
info_embed.add_field(name="Channel", value=value)
# User info
value = (
"**Name**: {0}\n" "**ID**: {0.id}\n" "**Created**: {0.created_at}\n"
).format(ctx.author)
info_embed.add_field(name="User", value=value)
wh = Webhook.from_url(
bot_config.log_webhook, adapter=AsyncWebhookAdapter(self.session)
)
return await wh.send(embeds=[*traceback_embeds, info_embed])
async def get_custom_prefix(self, _, message: Message) -> str:
prefix = await self.fetch_prefix(message)
bot_id = self.user.id
prefixes = [prefix, f"<@{bot_id}> ", f"<@!{bot_id}> "]
comp = re.compile(
"^(" + "|".join(re.escape(p) for p in prefixes) + ").*", flags=re.I
)
match = comp.match(message.content)
if match is not None:
return match.group(1)
return prefix
async def fetch_prefix(self, message: Message) -> str:
# DMs/Group
if not message.guild:
return "."
guild_id = message.guild.id
# Get from cache
if guild_id in self.prefix_cache:
return self.prefix_cache[guild_id]
# Fetch from db
guild, _ = await GuildModel.get_or_create(id=guild_id)
self.prefix_cache[guild_id] = guild.prefix
return guild.prefix
async def on_ready(self):
print("Ready!")
================================================
FILE: bot/cogs/admin.py
================================================
from discord.ext import commands
from discord.utils import get
from utils.embed import yaml_file_to_message
class Admin(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
async def _refresh(self, ctx: commands.Context, filename: str, channel_name: str):
target_channel = get(ctx.guild.text_channels, name=channel_name)
async for msg in target_channel.history():
if msg.author.id == self.bot.user.id:
target = msg
m, e, _ = yaml_file_to_message(filename)
await target.edit(message=m, embed=e)
@commands.group(name="refresh", invoke_without_subcommand=True)
@commands.is_owner()
async def refresh(self, ctx: commands.Context):
await ctx.send_help()
@refresh.command(name="roles")
async def refresh_roles(self, ctx: commands.Context):
await self._refresh(ctx, "./yaml_embeds/roles.yaml", "\U0001f3c5\u2502roles")
@refresh.command(name="rules")
async def refresh_rules(self, ctx: commands.Context):
await self._refresh(ctx, "./yaml_embeds/rules.yaml", "\u2502rules")
def setup(bot: commands.Bot):
bot.add_cog(Admin(bot))
================================================
FILE: bot/cogs/brainfeed.py
================================================
import asyncio
from datetime import datetime
from functools import cached_property
from discord import Embed, Member, NotFound, Reaction, TextChannel
from discord.ext import commands, flags # type: ignore
from discord.utils import get
from bot.bot import TechStruckBot
from bot.utils.embed_flag_input import dict_to_embed, embed_input
class UnknownBrainfeed(commands.CommandError):
def __str__(self) -> str:
return "The BrainFeed with the requested ID was not found"
class BrainFeed(commands.Cog):
"""BrainFeed related commands"""
def __init__(self, bot: TechStruckBot):
self.bot = bot
self.submission_channel_id = 824887130853474304
@flags.group(aliases=["bf", "brain", "feed"], invoke_without_command=True)
async def brainfeed(self, ctx: commands.Context):
"""BrainFeed - the daily dose of knowledge"""
await ctx.send_help(self.brainfeed) # type: ignore
@cached_property
def submission_channel(self) -> TextChannel:
return self.bot.get_channel(self.submission_channel_id) # type: ignore
@embed_input(basic=True, image=True)
@brainfeed.command(aliases=["new", "submit"], cls=flags.FlagCommand)
@commands.guild_only()
@commands.max_concurrency(1, per=commands.BucketType.user)
async def add(self, ctx: commands.Context, **kwargs):
"""Submit your brainfeed for approval and publishing"""
embed = dict_to_embed(kwargs)
embed.set_author(name=ctx.author.name, icon_url=str(ctx.author.avatar_url))
embed.timestamp = datetime.now()
msg = await ctx.send(embed=embed)
await msg.add_reaction("\u2705")
await msg.add_reaction("\u274c")
def check(r: Reaction, u: Member):
return (
u == ctx.author and r.emoji in ("\u2705", "\u274c") and r.message == msg
)
try:
r, _ = await self.bot.wait_for("reaction_add", check=check, timeout=120)
except asyncio.TimeoutError:
return await msg.reply("Timeout!")
if r.emoji == "\u274c":
return await ctx.send("Cancelled!")
await ctx.trigger_typing()
submission = await self.submission_channel.send(embed=embed)
metaembed = Embed(
title="Submission details",
description=(
"```"
f"User ID: {ctx.author.id}\n"
f"User name: {ctx.author}\n"
f"Channel ID: {ctx.channel.id}\n"
f"Channel name: {ctx.channel}\n"
f"Guild ID: {ctx.guild.id}\n"
f"Guild name: {ctx.guild}\n"
"```"
),
)
await submission.reply(embed=metaembed)
await ctx.send(f"Submitted\nSubmission ID: {submission.id}")
async def get_submission(self, bf_id) -> Embed:
try:
msg = await self.submission_channel.fetch_message(bf_id)
except NotFound:
raise UnknownBrainfeed()
if not msg.embeds:
raise UnknownBrainfeed()
return msg.embeds[0]
@brainfeed.command(aliases=["show"])
@commands.cooldown(1, 15, commands.BucketType.user)
async def view(self, ctx: commands.Context, id: int):
"""View a BrainFeed"""
embed = await self.get_submission(id)
await ctx.send(embed=embed)
@flags.add_flag("--in", "-i", type=TextChannel, default=None)
@flags.add_flag("--webhook", "-wh", action="store_true", default=False)
@flags.add_flag("--webhook-name", "-wn", default="BrainFeed")
@flags.add_flag("--webhook-dispose", "-wd", action="store_true", default=False)
@brainfeed.command(aliases=["post"], cls=flags.FlagCommand)
@commands.has_guild_permissions(administrator=True)
@commands.bot_has_guild_permissions(manage_webhooks=True, embed_links=True)
async def send(self, ctx: commands.Context, bf_id: int, **kwargs):
"""Publish a BrainFeed in your server"""
channel: TextChannel = ctx.channel # type: ignore
if in_ := kwargs.pop("in"):
channel = await in_
embed = await self.get_submission(bf_id)
if not kwargs.pop("webhook"):
return await channel.send(embed=embed)
wh_name: str = kwargs.pop("webhook_name")
webhook = get(
await channel.webhooks(), name=wh_name
) or await channel.create_webhook(name=wh_name)
await webhook.send(embed=embed)
if kwargs.pop("webhook_dispose"):
await webhook.delete()
@brainfeed.command(hidden=True)
@commands.is_owner()
async def approve(self, ctx: commands.Context, *, id: int):
try:
msg = await self.submission_channel.fetch_message(id)
except NotFound:
await ctx.send("Submission not found")
else:
await msg.remove_reaction("\u274c", ctx.guild.me)
await msg.add_reaction("\u2705")
await ctx.send("Approved")
@brainfeed.command(hidden=True)
@commands.is_owner()
async def deny(self, ctx: commands.Context, *, id: int):
try:
msg = await self.submission_channel.fetch_message(id)
except NotFound:
await ctx.send("Submission not found")
else:
await msg.remove_reaction("\u2705", ctx.guild.me)
await msg.add_reaction("\u274c")
await ctx.send("Denied")
def setup(bot: TechStruckBot):
bot.add_cog(BrainFeed(bot))
================================================
FILE: bot/cogs/coc.py
================================================
import asyncio
import re
import time
import aiohttp
import discord
from discord.ext import commands
from ..bot import TechStruckBot
coc_role = 862200819376717865 # Coc role in TCA
coc_channel = 862195507229360168 # Coc channel in TCA
coc_message = 862200700410527744
URL_REGEX = re.compile(r"https://www.codingame.com/clashofcode/clash/([0-9a-f]{39})")
API_URL = "https://www.codingame.com/services/ClashOfCode/findClashByHandle"
class ClashOfCode(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.session = False
self.session_message_id: int = 0
self.session_users = []
self.previous_clash: int = 0
@commands.Cog.listener()
async def on_ready(self):
self.guild = self.bot.get_guild(681882711945641997)
@property
def role(self):
return self.guild.get_role(coc_role)
def em(self, mode, players):
embed = discord.Embed(title="**Clash started**", color=discord.Color.random())
embed.add_field(name="Mode", value=mode, inline=False)
embed.add_field(name="Players", value=players)
return embed
@commands.Cog.listener()
async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent):
if payload.user_id == self.bot.user.id:
return
if self.session_message_id != 0:
if payload.message_id == self.session_message_id:
if payload.emoji.id == 859056281788743690:
if payload.user_id not in self.session_users:
self.session_users.append(payload.user_id)
if payload.message_id != coc_message:
return
if self.role in payload.member.roles:
return
await payload.member.add_roles(self.role)
try:
await payload.member.send(f"Gave you the **{self.role.name}** role!")
except discord.HTTPException:
pass
@commands.Cog.listener()
async def on_raw_reaction_remove(self, payload: discord.RawReactionActionEvent):
if payload.user_id == self.bot.user.id:
return
if self.session_message_id != 0:
if payload.message_id == self.session_message_id:
if payload.emoji.id == 859056281788743690:
if payload.user_id in self.session_users:
self.session_users.remove(payload.user_id)
if payload.message_id != coc_message:
return
member = self.guild.get_member(payload.user_id)
if self.role not in member.roles:
return
await member.remove_roles(self.role)
try:
await member.send(f"Removed your **{self.role.name}** role!")
except discord.HTTPException:
pass
@commands.group(name="clashofcode", aliases=["coc"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def clash_of_code(self, ctx: commands.Context):
"""Clash of Code"""
if ctx.invoked_subcommand is None:
return await ctx.send_help(self.bot.get_command("coc"))
@clash_of_code.group(aliases=["s"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def session(self, ctx: commands.Context):
"""Start or End a clash of code session"""
if ctx.invoked_subcommand is None:
if self.session_message_id == 0:
return await ctx.send_help(self.bot.get_command("coc session start"))
return await ctx.send_help(self.bot.get_command("coc session end"))
@session.command(name="start", aliases=["s"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def session_start(self, ctx: commands.context):
"""Start a new coc session"""
if self.session_message_id != 0:
return await ctx.send(
f"There is an active session right now.\n"
f"Join by reacting to the pinned message or using `{ctx.prefix}coc session join`. Have fun!"
)
pager = commands.Paginator(
prefix=f"**Hey, {ctx.author.mention} is starting a coc session.\n"
f"Use `{ctx.prefix}coc session join` or react to this message to join**",
suffix="",
)
for member in self.role.members:
if member != ctx.author:
if member.status != discord.Status.offline:
pager.add_line(member.mention + ", ")
if not len(pager.pages):
return await ctx.send(
f"{ctx.author.mention}, Nobody is online to play with <:pepe_sad:756087659281121312>"
)
self.session = True
self.previous_clash = int(time.time())
self.session_users.append(ctx.author.id)
msg = await ctx.send(pager.pages[0])
self.session_message_id = msg.id
await msg.add_reaction("<:poggythumbsup:859056281788743690>")
try:
await msg.pin()
except:
await ctx.send("Failed to pin message")
while self.session_message_id != 0:
await asyncio.sleep(10)
if (
self.previous_clash + 1800 < int(time.time())
and self.session_message_id != 0
):
await ctx.send("Clash session has been closed due to inactivity")
try:
await msg.unpin()
except:
await ctx.send("Failed to unpin message")
self.previous_clash = 0
self.session_users = []
self.session_message_id = 0
self.session = False
break
@session.command(name="join", aliases=["j"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def session_join(self, ctx: commands.Context):
"""Join the current active coc session"""
if self.session_message_id == 0:
return await ctx.send(
f"There is no active coc session at the moment.\n"
f"Use `{ctx.prefix}coc session start` to start a coc session."
)
if ctx.author.id in self.session_users:
return await ctx.send(
"You are already in the session. Have fun playing.\n"
f"If you want to leave remove your reaction or use `{ctx.prefix}coc session leave`"
)
self.session_users.append(ctx.author.id)
return await ctx.send("You have joined the session. Have fun playing")
@session.command(name="leave", aliases=["l"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def session_leave(self, ctx: commands.Context):
"""Leave the current active coc session"""
if self.session_message_id == 0:
return await ctx.send(
f"There is no active coc session right now"
f"use `{ctx.prefix}coc session start` to start a coc session"
)
if ctx.author.id not in self.session_users:
return await ctx.send(
"You aren't in a clash of code session right now.\n"
f"If you want to join react to session message or use `{ctx.prefix}coc session join`"
)
self.session_users.remove(ctx.author.id)
return await ctx.send("You have left the session. No more pings for now.")
@session.command(name="end", aliases=["e"])
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
async def session_end(self, ctx: commands.context):
"""Ends the current coc session"""
if self.session_message_id == 0:
return await ctx.send("There is no active clash of code session.")
try:
msg = await ctx.channel.fetch_message(self.session_message_id)
try:
await msg.unpin()
except:
await ctx.send("Failed to unpin message")
except:
await ctx.send("Error while fetching message to unpin")
self.previous_clash = 0
self.session_users = []
self.session_message_id = 0
self.session = False
return await ctx.send(
f"Clash session has been closed by {ctx.author.mention}. See you later :wave:"
)
@clash_of_code.command(name="invite", aliases=["i"])
@commands.has_any_role(
681895373454835749, # Owner
580911082290282506, # Admin perms
795145820210462771, # Staff
726650418444107869, # Official Helper
coc_role,
)
@commands.check(lambda ctx: ctx.channel.id == coc_channel)
@commands.cooldown(1, 60, commands.BucketType.channel)
async def coc_invite(self, ctx: commands.Context, *, url: str = None):
"""Mentions all the users with the `Clash Of Code` role that are in the current session."""
await ctx.message.delete()
if self.session_message_id == 0:
ctx.command.reset_cooldown(ctx)
return await ctx.send(
"No active Clash of Code session please create one to start playing\n"
f"Use `{ctx.prefix}coc session start` to start a coc session <:smugcat:737943749929467975>"
)
if ctx.author.id not in self.session_users:
ctx.command.reset_cooldown(ctx)
return await ctx.send(
"You can't create a clash unless you participate in the session\n"
f"Use `{ctx.prefix}coc session join` or react to the pinned message to join the coc session "
"<:smugcat:737943749929467975>"
)
if url is None:
ctx.command.reset_cooldown(ctx)
return await ctx.send("You should provide a valid clash of code url")
link = URL_REGEX.fullmatch(url)
if not link:
ctx.command.reset_cooldown(ctx)
return await ctx.send('Could not find any valid "clashofcode" url')
self.previous_clash = time.time()
id = link[1]
async with aiohttp.ClientSession() as session:
async with session.post(API_URL, json=[id]) as resp:
json = await resp.json()
pager = commands.Paginator(
prefix="\n".join(
[
f"**Hey, {ctx.author.mention} is hosting a Clash Of Code game!**",
f"Mode{'s' if len(json['modes']) > 1 else ''}: {', '.join(json['modes'])}",
f"Programming languages: {', '.join(json['programmingLanguages']) if json['programmingLanguages'] else 'All'}",
f"Join here: {link[0]}",
]
),
suffix="",
)
for member_id in self.session_users:
if member_id != ctx.author.id:
member = self.bot.get_user(member_id)
pager.add_line(member.mention + ", ")
if not len(pager.pages):
return await ctx.send(
f"{ctx.author.mention}, Nobody is online to play with <:pepe_sad:756087659281121312>"
)
for page in pager.pages:
await ctx.send(page)
async with aiohttp.ClientSession() as session:
while not json["started"]:
await asyncio.sleep(10) # wait 10s to avoid flooding the API
async with session.post(API_URL, json=[id]) as resp:
json = await resp.json()
players = len(json["players"])
players_text = ", ".join(
[
p["codingamerNickname"]
for p in sorted(json["players"], key=lambda p: p["position"])
]
)
start_message = await ctx.send(embed=self.em(json["mode"], players_text))
async with aiohttp.ClientSession() as session:
while not json["finished"]:
await asyncio.sleep(10) # wait 10s to avoid flooding the API
async with session.post(API_URL, json=[id]) as resp:
json = await resp.json()
if len(json["players"]) != players:
players_text = ", ".join(
[
p["codingamerNickname"]
for p in sorted(
json["players"], key=lambda p: p["position"]
)
]
)
await start_message.edit(embed=self.em(json["mode"], players_text))
embed = discord.Embed(
title="**Clash finished, here are the results**",
color=discord.Color.random(),
)
for p in sorted(json["players"], key=lambda p: p["rank"]):
embed.add_field(
name=f"{p['rank']}. {p['codingamerNickname']}",
value=(
f"Code length: {p['criterion']}, "
if json["mode"] == "SHORTEST"
else ""
)
+ f"Score: {p['score']}%, Time: {p['duration'] // 60_000}:{p['duration'] // 1000 % 60:02}",
inline=False,
)
await ctx.send(embed=embed)
def setup(bot: TechStruckBot):
bot.add_cog(ClashOfCode(bot=bot))
================================================
FILE: bot/cogs/code_exec.py
================================================
import re
from discord import Color, Embed
from discord.ext import commands
from config.bot import bot_config
# TODO: Move this into utils
async def create_guest_paste_bin(session, code):
res = await session.post(
"https://pastebin.com/api/api_post.php",
data={
"api_dev_key": bot_config.pastebin_api_key,
"api_paste_code": code,
"api_paste_private": 0,
"api_paste_name": "output.txt",
"api_paste_expire_date": "1D",
"api_option": "paste",
},
)
return await res.text()
class CodeExec(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
# TODO: Improve this further
self.regex = re.compile(r"(\w*)\s*(?:```)(\w*)?([\s\S]*)(?:```$)")
@property
def session(self):
return self.bot.http._HTTPClient__session # type: ignore
async def _run_code(self, *, lang: str, code: str):
res = await self.session.post(
"https://emkc.org/api/v1/piston/execute",
json={"language": lang, "source": code},
)
return await res.json()
@commands.command()
async def run(self, ctx: commands.Context, *, codeblock: str):
"""
Run code and get results instantly
**Note**: You must use codeblocks around the code
"""
matches = self.regex.findall(codeblock)
if not matches:
return await ctx.reply(
embed=Embed(
title="Uh-oh", description="Couldn't quite see your codeblock"
)
)
lang = matches[0][0] or matches[0][1]
if not lang:
return await ctx.reply(
embed=Embed(
title="Uh-oh",
description="Couldn't find the language hinted in the codeblock or before it",
)
)
code = matches[0][2]
result = await self._run_code(lang=lang, code=code)
await self._send_result(ctx, result)
@commands.command()
async def runl(self, ctx: commands.Context, lang: str, *, code: str):
"""
Run a single line of code, **must** specify language as first argument
"""
result = await self._run_code(lang=lang, code=code)
await self._send_result(ctx, result)
async def _send_result(self, ctx: commands.Context, result: dict):
if "message" in result:
return await ctx.reply(
embed=Embed(
title="Uh-oh", description=result["message"], color=Color.red()
)
)
output = result["output"]
# if len(output) > 2000:
# url = await create_guest_paste_bin(self.session, output)
# return await ctx.reply("Your output was too long, so here's the pastebin link " + url)
embed = Embed(title=f"Ran your {result['language']} code", color=Color.green())
output = output[:500].strip()
shortened = len(output) > 500
lines = output.splitlines()
shortened = shortened or (len(lines) > 15)
output = "\n".join(lines[:15])
output += shortened * "\n\n**Output shortened**"
embed.add_field(name="Output", value=output or "****")
await ctx.reply(embed=embed)
def setup(bot: commands.Bot):
bot.add_cog(CodeExec(bot))
================================================
FILE: bot/cogs/fun.py
================================================
import asyncio
from discord import Color, Embed, Forbidden, Member, utils
from discord.ext import commands
from bot.bot import TechStruckBot
class Fun(commands.Cog):
"""Commands for fun and entertainment"""
def __init__(self, bot: TechStruckBot):
self.bot = bot
@commands.command()
async def beer(
self, ctx, user: Member = None, *, reason: commands.clean_content = None
):
"""Have virtual beer with your friends/fellow members"""
if not user or user.id == ctx.author.id:
return await ctx.send(f"{ctx.author.name}: paaaarty!:tada::beer:")
if user.id == self.bot.user.id:
return await ctx.send("drinks beer with you* :beers:")
if user.bot:
return await ctx.send(f"lol {ctx.author.name}lol")
beer_offer = f"{user.name}, you got a :beer: offer from {ctx.author.name}"
beer_offer = beer_offer + f"\n\nReason: {reason}" if reason else beer_offer
msg = await ctx.send(beer_offer)
def reaction_check(reaction, m):
return m.id == user.id and str(reaction.emoji) == "🍻"
try:
await msg.add_reaction("🍻")
await self.bot.wait_for("reaction_add", timeout=30.0, check=reaction_check)
await msg.edit(
content=f"{user.name} and {ctx.author.name} are enjoying a lovely beer together :beers:"
)
except asyncio.TimeoutError:
await msg.delete()
await ctx.send(
f"well, doesn't seem like {user.name} wanted a beer with you {ctx.author.name} ;-;"
)
except Forbidden:
beer_offer = f"{user.name}, you got a :beer: from {ctx.author.name}"
beer_offer = beer_offer + f"\n\nReason: {reason}" if reason else beer_offer
await msg.edit(content=beer_offer)
@commands.command()
async def beers(
self,
ctx: commands.Context,
members: commands.Greedy[Member],
*,
reason: commands.clean_content = None,
):
"""Invite a bunch of people to have beer"""
if not members:
return await ctx.send("You can't have beer with no other person!")
for member in members:
if member.bot:
return await ctx.send("Beer with bots isn't exactly a thing...")
message = (
", ".join(m.display_name for m in members)
+ "\nYou have been invited for beer \U0001f37b by "
+ ctx.author.display_name
+ ((" Reason: " + reason) if reason else "")
)
msg = await ctx.send(message)
await msg.add_reaction("\U0001f37b")
def check(r, m):
return m in members and r.message == msg and str(r.emoji) == "\U0001f37b"
while True:
try:
r, _ = await self.bot.wait_for("reaction_add", check=check, timeout=60)
except asyncio.TimeoutError:
return await msg.edit(
content="Ouch, looks like not everyone wants beer now..."
)
else:
if set(
m.id for m in await r.message.reactions[0].users().flatten()
).issuperset(m.id for m in members):
content = (
", ".join(
utils.escape_mentions(m.display_name) for m in members
)
+ ", "
+ utils.escape_mentions(ctx.author.display_name)
+ " enjoy a lovely beer together \U0001f37b"
)
return await msg.edit(content=content)
@commands.command()
async def beerparty(
self, ctx: commands.Context, *, reason: commands.clean_content = None
):
"""Openly allow anyone to join and enjoy in a beer party"""
reason = ("\nReason: " + reason) if reason else ""
msg = await ctx.send(f"Open invite to a beer party! {reason}")
await msg.add_reaction("\U0001f37b")
await asyncio.sleep(20)
users = (
await (await ctx.channel.fetch_message(msg.id))
.reactions[0]
.users()
.flatten()
)
await ctx.send(
", ".join(
[
utils.escape_mentions(u.display_name)
for u in users + ([] if ctx.author in users else [ctx.author])
if not u.bot
]
)
+ " enjoy a lovely beer paaarty \U0001f37b"
)
def setup(bot: TechStruckBot):
bot.add_cog(Fun(bot))
================================================
FILE: bot/cogs/github.py
================================================
import datetime
import re
from io import BytesIO
from typing import Optional
from urllib.parse import urlencode
from cachetools import TTLCache
from discord import Color, Embed, File, Forbidden, Member
from discord.ext import commands
from jose import jwt
from reportlab.graphics import renderPM
from svglib.svglib import svg2rlg
from bot.utils.process_files import process_files
from config.common import config
from config.oauth import github_oauth_config
from models import UserModel
class GithubNotLinkedError(commands.CommandError):
def __str__(self):
return "Your github account hasn't been linked yet, please use the `linkgithub` command to do it"
class InvalidTheme(commands.CommandError):
def __str__(self):
return "Not a valid theme. List of all valid themes:- default, dark, radical, merko, gruvbox, tokyonight, onedark, cobalt, synthwave, highcontrast, dracula"
class Github(commands.Cog):
"""Commands related to Github"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.themes = "default dark radical merko gruvbox tokyonight onedark cobalt synthwave highcontrast dracula".split()
self.files_regex = re.compile(r"\s{0,}```\w{0,}\s{0,}")
self.token_cache = TTLCache(maxsize=1000, ttl=600)
@property
def session(self):
return self.bot.http._HTTPClient__session # type: ignore
async def cog_before_invoke(self, ctx: commands.Context):
if ctx.command == self.link_github:
return
token = self.token_cache.get(ctx.author.id)
if not token:
user = await UserModel.get_or_none(id=ctx.author.id)
if user is None or user.github_oauth_token is None:
raise GithubNotLinkedError()
token = user.github_oauth_token
self.token_cache[ctx.author.id] = token
ctx.gh_token = token # type: ignore
@commands.command(name="linkgithub", aliases=["lngithub"])
async def link_github(self, ctx: commands.Context):
"""Link your Github account through OAuth2 to gain access to Github related commands"""
expiry = datetime.datetime.utcnow() + datetime.timedelta(seconds=120)
url = "https://github.com/login/oauth/authorize?" + urlencode(
{
"client_id": github_oauth_config.client_id,
"scope": "gist",
"redirect_uri": "https://tech-struck.vercel.app/oauth/github",
"state": jwt.encode(
{"id": ctx.author.id, "expiry": str(expiry)}, config.secret
),
}
)
try:
await ctx.author.send(
embed=Embed(
title="Connect Github",
description=f"Click [this]({url}) to link your github account. This link invalidates in 2 minutes",
)
)
except Forbidden:
await ctx.send(
"Your DMs are closed. Open them so I can send you the authorization link."
)
@commands.group(name="gist", aliases=["gs"], invoke_without_command=True)
async def gist(self, ctx: commands.Context):
"""Commands related to Github gists"""
await ctx.send_help(self.gist)
@gist.command(name="create", aliases=["cr"])
async def create_gist(self, ctx: commands.Context, *, inp: Optional[str] = None):
"""
Create gists from within discord
Three ways to specify the files:
- Reply to a message with attachments
- Send attachments along with the command
- Use a filename and codeblock... format
Example:
filename.py
```
# Codeblock with contents of filename.py
```
filename2.txt
```
Codeblock containing filename2.txt's contents
```
"""
files, skipped = await process_files(ctx, inp)
req = await self.github_request(ctx, "POST", "/gists", json={"files": files})
res = await req.json()
# TODO: Make this more verbose to the user and log errors
embed = Embed(
title="Gist creation",
description=res.get("html_url", "Something went wrong."),
)
embed.add_field(name="Files", value="\n".join(files.keys()), inline=False)
if skipped:
embed.add_field(
name="Skipped files", value="\n".join(skipped), inline=False
)
await ctx.send(embed=embed)
@gist.command(name="list", aliases=["ls"])
async def list_gist(self, ctx: commands.Context):
"""
List 10 gists made by you
"""
req = await self.github_request(ctx, "GET", "/gists")
gists = (await req.json())[:10]
embed = Embed(title="Your gists", color=Color.green())
description = "\n\n".join(
[
"`{0[id]}`\n[{name}]({0[html_url]})".format(
gist, name=next(iter(gist["files"]))
)
for gist in gists
]
)
embed.description = description
await ctx.send(embed=embed)
@gist.command("delete", aliases=["del", "rm", "remove"])
async def delete_gist(self, ctx: commands.Context, *, gist_id: str):
"""
Delete a gist using its ID
You can get the ID from the list
"""
req = await self.github_request(ctx, "DELETE", "/gists/{}".format(gist_id))
if req.status == 204:
return await ctx.send("Deleted")
if req.status == 404:
return await ctx.send("Not found")
if req.status == 403:
return await ctx.send("Forbidden")
@commands.command(name="githubsearch", aliases=["ghsearch", "ghse"])
async def github_search(self, ctx: commands.Context, *, term: str):
"""
Search through all public repositories in Github
Github search filters work here
eg `ghse user:FalseDev`
"""
# TODO: Docs
req = await self.github_request(
ctx, "GET", "/search/repositories", dict(q=term, per_page=5)
)
data = await req.json()
if not data["items"]:
return await ctx.send(
embed=Embed(
title=f"Searched for {term}",
color=Color.red(),
description="No results found",
)
)
em = Embed(
title=f"Searched for {term}",
color=Color.green(),
description="\n\n".join(
[
"[{0[owner][login]}/{0[name]}]({0[html_url]})\n{0[stargazers_count]:,} :star:\u2800{0[forks_count]} \u2387\u2800\n{1}".format(
result, self.repo_desc_format(result)
)
for result in data["items"]
]
),
)
await ctx.send(embed=em)
@commands.command(name="githubstats", aliases=["ghstats", "ghst"])
async def github_stats(
self, ctx: commands.Context, username: str = None, theme="radical"
):
"""View statistics about you/any Github user in various themes"""
theme = self.process_theme(theme)
url = "https://github-readme-stats.codestackr.vercel.app/api"
username = username or await self.get_gh_user(ctx)
file = await self.get_file_from_svg_url(
url,
params={
"username": username,
"show_icons": "true",
"hide_border": "true",
"theme": theme,
},
exclude=[b"A++", b"A+"],
)
await ctx.send(file=File(file, filename="stats.png"))
@commands.command(name="githublanguages", aliases=["ghlangs", "ghtoplangs"])
async def github_top_languages(
self, ctx: commands.Context, username: str = None, theme: str = "radical"
):
"""View language usage statistics for you/any github user in various themes"""
username = username or await self.get_gh_user(ctx)
theme = self.process_theme(theme)
url = "https://github-readme-stats.codestackr.vercel.app/api/top-langs/"
file = await self.get_file_from_svg_url(
url, params={"username": username, "theme": theme}
)
await ctx.send(file=File(file, filename="langs.png"))
async def get_file_from_svg_url(
self, url: str, *, params={}, exclude=[], fmt="PNG"
):
res = await (await self.session.get(url, params=params)).content.read()
for i in exclude:
res = res.replace(
i, b""
) # removes everything that needs to be excluded (eg. the uncentered A+)
drawing = svg2rlg(BytesIO(res))
file = BytesIO(renderPM.drawToString(drawing, fmt=fmt))
return file
def process_theme(self, theme):
theme = theme.lower()
if theme not in self.themes:
raise InvalidTheme()
return theme
@staticmethod
def repo_desc_format(result):
description = result["description"]
if not description:
return ""
return description if len(description) < 100 else (description[:100] + "...")
async def github_request(
self,
ctx: commands.Context,
req_type: str,
endpoint: str,
params: dict = None,
json: dict = None,
):
return await self.session.request(
req_type,
f"https://api.github.com{endpoint}",
params=params,
json=json,
headers={"Authorization": f"Bearer {ctx.gh_token}"},
)
async def get_gh_user(self, ctx: commands.Context):
response = await (await self.github_request(ctx, "GET", "/user")).json()
return response.get("login")
def setup(bot: commands.Bot):
bot.add_cog(Github(bot))
================================================
FILE: bot/cogs/help_command.py
================================================
import discord
from discord.ext import commands
bot_links = """[Support](https://discord.gg/KgZRMch3b6)\u2800\
[Github](https://github.com/FalseDev/Tech-struck)\u2800\
[Suggestions](https://github.com/FalseDev/Tech-struck/issues)"""
class HelpCommand(commands.HelpCommand):
"""
An Embed help command
Based on https://gist.github.com/Rapptz/31a346ed1eb545ddeb0d451d81a60b3b
"""
COLOUR = discord.Colour.greyple()
def get_ending_note(self):
return "Use {0}{1} [command] for more info on a command.".format(
self.clean_prefix, self.invoked_with
)
def get_command_signature(self, command):
return "{0.qualified_name} {0.signature}".format(command)
async def send_bot_help(self, mapping):
embed = discord.Embed(title="Bot Commands", colour=self.COLOUR)
description = self.context.bot.description
if description:
embed.description = description
for cog, cmds in mapping.items():
if cog is None:
continue
name = cog.qualified_name
filtered = await self.filter_commands(cmds, sort=True)
if filtered:
value = "\u2002".join(f"`{c.name}`" for c in cmds)
if cog and cog.description:
value = "{0}\n{1}".format(cog.description, value)
embed.add_field(name=name, value=value)
embed.set_footer(text=self.get_ending_note())
self.add_support_server(embed)
await self.get_destination().send(embed=embed)
async def send_cog_help(self, cog):
embed = discord.Embed(
title="{0.qualified_name} Commands".format(cog), colour=self.COLOUR
)
if cog.description:
embed.description = cog.description
filtered = await self.filter_commands(cog.get_commands(), sort=True)
for command in filtered:
embed.add_field(
name=command.qualified_name,
value=command.short_doc or "...",
inline=False,
)
embed.set_footer(text=self.get_ending_note())
self.add_support_server(embed)
await self.get_destination().send(embed=embed)
async def send_group_help(self, group):
embed = discord.Embed(title=group.qualified_name, colour=self.COLOUR)
if group.help:
embed.description = group.help
filtered = await self.filter_commands(group.commands, sort=True)
for command in filtered:
embed.add_field(
name=command.qualified_name,
value=command.short_doc or "...",
inline=False,
)
embed.set_footer(text=self.get_ending_note())
self.add_support_server(embed)
await self.get_destination().send(embed=embed)
def add_support_server(self, embed):
return embed.add_field(name="Links", value=bot_links, inline=False)
async def send_command_help(self, command):
embed = discord.Embed(title=command.qualified_name, colour=self.COLOUR)
embed.add_field(name="Signatute", value=self.get_command_signature(command))
if command.help:
embed.description = command.help
embed.set_footer(text=self.get_ending_note())
self.add_support_server(embed)
await self.get_destination().send(embed=embed)
def setup(bot: commands.Bot):
bot._default_help_command = bot.help_command
bot.help_command = HelpCommand()
def teardown(bot):
bot.help_command = bot._default_help_command
================================================
FILE: bot/cogs/joke.py
================================================
import asyncio
from discord import Color, Embed, Message, RawReactionActionEvent, TextChannel, utils
from discord.ext import commands
from models import JokeModel, UserModel
joke_format = """**Setup**: {0.setup}\n
**End**: {0.end}\n
**Server**: {1.name} (`{1.id}`)\n
**Username**: {2} (`{2.id}`)\n
Joke ID: {0.id}"""
class Joke(commands.Cog):
"""Joke related commands"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.group(invoke_without_command=True)
async def joke(self, ctx: commands.Context):
"""Joke commands"""
await ctx.send_help(self.joke)
@joke.command()
@commands.cooldown(1, 60, type=commands.BucketType.user)
async def add(self, ctx: commands.Context):
"""Submit a joke that can then get approved and part of the collection"""
try:
setup = await self._get_input(
ctx,
"Enter joke setup",
"Enter the question/setup to be done before answering/finishing the joke",
)
end = await self._get_input(
ctx, "Enter joke end", "Enter the text to be used to finish the joke"
)
except asyncio.TimeoutError:
return await ctx.send("You didn't answer")
await UserModel.get_or_create(id=ctx.author.id)
joke = await JokeModel.create(setup=setup, end=end, creator_id=ctx.author.id)
msg = await self.joke_entries_channel.send(
embed=Embed(
title=f"Joke #{joke.id}",
description=joke_format.format(joke, ctx.guild, ctx.author),
color=Color.dark_gold(),
)
)
await ctx.send("Your submission has been recorded!")
await msg.add_reaction("\u2705")
await msg.add_reaction("\u274e")
await self.joke_entries_channel.send("<@&815237052639477792>", delete_after=1)
@property
def joke_entries_channel(self) -> TextChannel:
return self.bot.get_channel(815237244218114058)
async def _get_input(self, ctx: commands.Context, title: str, description: str):
await ctx.send(
embed=Embed(title=title, description=description, color=Color.dark_blue())
)
def check(m: Message):
return m.author == ctx.author and m.channel == ctx.channel
res: Message = await self.bot.wait_for("message", check=check, timeout=120)
return await commands.clean_content().convert(ctx, res.content)
@commands.Cog.listener("on_raw_reaction_add")
@commands.Cog.listener("on_raw_reaction_remove")
async def reaction_listener(self, payload: RawReactionActionEvent):
if payload.channel_id != 815237244218114058:
return
msg: Message = await self.joke_entries_channel.fetch_message(payload.message_id)
up_reaction = utils.get(msg.reactions, emoji="\u2705")
down_reaction = utils.get(msg.reactions, emoji="\u274e")
ups = (up_reaction and await up_reaction.users().flatten()) or []
# downs = (down_reaction and await up_reaction.users().flatten()) or []
# TODO: Add further stuff here for downvotes checking etc
embed = msg.embeds[0]
if len(ups) > 3:
await JokeModel.filter(id=int(embed.title[6:])).update(accepted=True)
embed.color = Color.green()
await msg.edit(embed=embed)
def setup(bot: commands.Bot):
bot.add_cog(Joke(bot))
================================================
FILE: bot/cogs/packages.py
================================================
from aiohttp import ContentTypeError
from discord import Color, Embed
from discord.ext.commands import Cog, Context, command
from ..bot import TechStruckBot
class Packages(Cog):
"""Commands related to Package Search"""
def __init__(self, bot: TechStruckBot):
self.bot = bot
@property
def session(self):
return self.bot.session
async def get_package(self, url: str):
return await self.session.get(url=url)
@command(aliases=["pypi"])
async def pypisearch(self, ctx: Context, arg: str):
"""Get info about a Python package directly from PyPi"""
res_raw = await self.get_package(f"https://pypi.org/pypi/{arg}/json")
try:
res_json = await res_raw.json()
except ContentTypeError:
return await ctx.send(
embed=Embed(
description="No such package found in the search query.",
color=Color.blurple(),
)
)
res = res_json["info"]
def getval(key):
return res[key] or "Unknown"
name = getval("name")
author = getval("author")
author_email = getval("author_email")
description = getval("summary")
home_page = getval("home_page")
project_url = getval("project_url")
version = getval("version")
_license = getval("license")
embed = Embed(
title=f"{name} PyPi Stats", description=description, color=Color.teal()
)
embed.add_field(name="Author", value=author, inline=True)
embed.add_field(name="Author Email", value=author_email, inline=True)
embed.add_field(name="Version", value=version, inline=False)
embed.add_field(name="License", value=_license, inline=True)
embed.add_field(name="Project Url", value=project_url, inline=False)
embed.add_field(name="Home Page", value=home_page)
embed.set_thumbnail(url="https://i.imgur.com/syDydkb.png")
await ctx.send(embed=embed)
@command(aliases=["npm"])
async def npmsearch(self, ctx: Context, arg: str):
"""Get info about a NPM package directly from the NPM Registry"""
res_raw = await self.get_package(f"https://registry.npmjs.org/{arg}/")
res_json = await res_raw.json()
if res_json.get("error"):
return await ctx.send(
embed=Embed(
description="No such package found in the search query.",
color=0xCC3534,
)
)
latest_version = res_json["dist-tags"]["latest"]
latest_info = res_json["versions"][latest_version]
def getval(*keys):
keys = list(keys)
val = latest_info.get(keys.pop(0)) or {}
if keys:
for i in keys:
try:
val = val.get(i)
except TypeError:
return "Unknown"
return val or "Unknown"
pkg_name = getval("name")
description = getval("description")
author = getval("author", "name")
author_email = getval("author", "email")
repository = (
getval("repository", "url").removeprefix("git+").removesuffix(".git")
)
homepage = getval("homepage")
_license = getval("license")
em = Embed(
title=f"{pkg_name} NPM Stats", description=description, color=0xCC3534
)
em.add_field(name="Author", value=author, inline=True)
em.add_field(name="Author Email", value=author_email, inline=True)
em.add_field(name="Latest Version", value=latest_version, inline=False)
em.add_field(name="License", value=_license, inline=True)
em.add_field(name="Repository", value=repository, inline=False)
em.add_field(name="Homepage", value=homepage, inline=True)
em.set_thumbnail(
url="https://upload.wikimedia.org/wikipedia/commons/thumb/d/db/Npm-logo.svg/800px-Npm-logo.svg.png"
)
await ctx.send(embed=em)
@command(aliases=["crates"])
async def crate(self, ctx: Context, arg: str):
"""Get info about a Rust package directly from the Crates.IO Registry"""
res_raw = await self.get_package(f"https://crates.io/api/v1/crates/{arg}")
res_json = await res_raw.json()
if res_json.get("errors"):
return await ctx.send(
embed=Embed(
description="No such package found in the search query.",
color=0xE03D29,
)
)
main_info = res_json["crate"]
latest_info = res_json["versions"][0]
def getmainval(key):
return main_info[key] or "Unknown"
def getversionvals(*keys):
keys = list(keys)
val = latest_info.get(keys.pop(0)) or {}
if keys:
for i in keys:
try:
val = val.get(i)
except TypeError:
return "Unknown"
return val or "Unknown"
pkg_name = getmainval("name")
description = getmainval("description")
downloads = getmainval("downloads")
publisher = getversionvals("published_by", "name")
latest_version = getversionvals("num")
repository = getmainval("repository")
homepage = getmainval("homepage")
_license = getversionvals("license")
em = Embed(
title=f"{pkg_name} crates.io Stats", description=description, color=0xE03D29
)
em.add_field(name="Published By", value=publisher, inline=True)
em.add_field(name="Downloads", value="{:,}".format(downloads), inline=True)
em.add_field(name="Latest Version", value=latest_version, inline=False)
em.add_field(name="License", value=_license, inline=True)
em.add_field(name="Repository", value=repository, inline=False)
em.add_field(name="Homepage", value=homepage, inline=True)
em.set_thumbnail(
url="https://upload.wikimedia.org/wikipedia/commons/thumb/d/d5/Rust_programming_language_black_logo.svg/2048px-Rust_programming_language_black_logo.svg.png"
)
await ctx.send(embed=em)
def setup(bot: TechStruckBot):
bot.add_cog(Packages(bot))
================================================
FILE: bot/cogs/quiz.py
================================================
from discord import Color, Embed, Message
from discord.ext import commands
from quizapi import create_quiz_api
from config.bot import bot_config
class Quiz(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.session = create_quiz_api(bot_config.quiz_api_token, async_mode=True)
@commands.command()
async def startquiz(self, ctx: commands.Context):
await ctx.send("Collecting questions!")
questions = await self.session.get_quiz(limit=5, category="linux")
embed = Embed(title="Big Brain Time", color=Color.darker_gray())
def check(m: Message):
return m.channel == ctx.channel
scoreboard = {}
for q in questions:
embed.clear_fields()
desc = q.description + "\n" if q.description else ""
desc += " ".join(["`" + t.name + "`" for t in q.tags]) + "\n"
for i, a in enumerate(q.answers, 65):
desc += chr(i) + ") " + a + "\n"
embed.add_field(name=q.question, value=desc)
correct_answers = []
print(q.correct_answers)
for i in range(q.correct_answers.count(True)):
correct_answers.append(chr(65 + q.correct_answers.index(True)))
q.correct_answers.remove(True)
print(correct_answers)
await ctx.send(embed=embed)
unanswered = True
while unanswered:
try:
resp = await self.bot.wait_for("message", check=check, timeout=45)
except:
return await ctx.send("No one answered")
# await resp.delete()
if resp.content.upper() in (correct_answers):
scoreboard[resp.author.id] = scoreboard.get(resp.author.id, 0) + 1
unanswered = False
scores = "\n".join(
[
f"<@!{mid}>: {score}"
for mid, score in sorted(scoreboard.items(), key=lambda i: i[1])
]
)
await ctx.send(
embed=Embed(title="Results", description=scores, color=Color.green())
)
def setup(bot: commands.Bot):
bot.add_cog(Quiz(bot))
================================================
FILE: bot/cogs/rtfm.py
================================================
import warnings
import aiohttp
from discord import Color, Embed
from discord.ext import commands, flags
from bot.bot import TechStruckBot
from bot.utils import fuzzy, rtfm
class RTFM(commands.Cog):
"""Search through manuals of several python modules and python itself"""
targets = {
"python": "https://docs.python.org/3",
"discord.py": "https://discordpy.readthedocs.io/en/latest",
"numpy": "https://numpy.readthedocs.io/en/latest",
"pandas": "https://pandas.pydata.org/docs",
"pillow": "https://pillow.readthedocs.io/en/stable",
"imageio": "https://imageio.readthedocs.io/en/stable",
"requests": "https://requests.readthedocs.io/en/master",
"aiohttp": "https://docs.aiohttp.org/en/stable",
"django": "https://django.readthedocs.io/en/stable",
"flask": "https://flask.palletsprojects.com/en/1.1.x",
"praw": "https://praw.readthedocs.io/en/latest",
"apraw": "https://apraw.readthedocs.io/en/latest",
"asyncpg": "https://magicstack.github.io/asyncpg/current",
"aiosqlite": "https://aiosqlite.omnilib.dev/en/latest",
"sqlalchemy": "https://docs.sqlalchemy.org/en/14",
"tensorflow": "https://www.tensorflow.org/api_docs/python",
"matplotlib": "https://matplotlib.org/stable",
"seaborn": "https://seaborn.pydata.org",
"pygame": "https://www.pygame.org/docs",
"simplejson": "https://simplejson.readthedocs.io/en/latest",
"wikipedia": "https://wikipedia.readthedocs.io/en/latest",
}
aliases = {
("py", "py3", "python3", "python"): "python",
("dpy", "discord.py", "discordpy"): "discord.py",
("np", "numpy", "num"): "numpy",
("pd", "pandas", "panda"): "pandas",
("pillow", "pil"): "pillow",
("imageio", "imgio", "img"): "imageio",
("requests", "req"): "requests",
("aiohttp", "http"): "aiohttp",
("django", "dj"): "django",
("flask", "fl"): "flask",
("reddit", "praw", "pr"): "praw",
("asyncpraw", "apraw", "apr"): "apraw",
("asyncpg", "pg"): "asyncpg",
("aiosqlite", "sqlite", "sqlite3", "sqli"): "aiosqlite",
("sqlalchemy", "sql", "alchemy", "alchem"): "sqlalchemy",
("tensorflow", "tf"): "tensorflow",
("matplotlib", "mpl", "plt"): "matplotlib",
("seaborn", "sea"): "seaborn",
("pygame", "pyg", "game"): "pygame",
("simplejson", "sjson", "json"): "simplejson",
("wiki", "wikipedia"): "wikipedia",
}
url_overrides = {
"tensorflow": "https://github.com/mr-ubik/tensorflow-intersphinx/raw/master/tf2_py_objects.inv"
}
def __init__(self, bot: TechStruckBot) -> None:
self.bot = bot
self.cache = {}
@property
def session(self) -> aiohttp.ClientSession:
return self.bot.http._HTTPClient__session # type: ignore
async def build(self, target) -> None:
url = self.targets[target]
req = await self.session.get(
self.url_overrides.get(target, url + "/objects.inv")
)
if req.status != 200:
warnings.warn(
Warning(
f"Received response with status code {req.status} when trying to build RTFM cache for {target} through {url}/objects.inv"
)
)
raise commands.CommandError("Failed to build RTFM cache")
self.cache[target] = rtfm.SphinxObjectFileReader(
await req.read()
).parse_object_inv(url)
@commands.group(invoke_without_command=True)
async def rtfm(self, ctx: commands.Context, doc: str, *, term: str = None):
"""
Search through docs of a module/python
Args: target, term
"""
doc = doc.lower()
target = None
for aliases, target_name in self.aliases.items():
if doc in aliases:
target = target_name
if not target:
return await ctx.reply("Alias/target not found")
if not term:
return await ctx.reply(self.targets[target])
cache = self.cache.get(target)
if not cache:
await ctx.trigger_typing()
await self.build(target)
cache = self.cache.get(target)
results = fuzzy.finder(term, list(cache.items()), key=lambda x: x[0], lazy=False)[:8] # type: ignore
if not results:
return await ctx.reply("Couldn't find any results")
await ctx.reply(
embed=Embed(
title=f"Searched in {target}",
description="\n".join([f"[`{key}`]({url})" for key, url in results]),
color=Color.dark_purple(),
)
)
@rtfm.command(name="list")
async def list_targets(self, ctx: commands.Context):
"""List all the avaliable documentation search targets"""
aliases = {v: k for k, v in self.aliases.items()}
embed = Embed(title="RTFM list of avaliable modules", color=Color.green())
embed.description = "\n".join(
[
"[{0}]({1}): {2}".format(
target,
link,
"\u2800".join([f"`{i}`" for i in aliases[target] if i != target]),
)
for target, link in self.targets.items()
]
)
await ctx.send(embed=embed)
@flags.add_flag("aliases", nargs="+")
@flags.add_flag("url")
@flags.add_flag("name")
@flags.add_flag("--override", "-o")
@rtfm.command(name="add", hidden=True, cls=flags.FlagCommand)
@commands.is_owner()
async def add_target(self, ctx: commands.Context, **kwargs):
print(kwargs)
name, url, aliases, override = (
kwargs.pop("name"),
kwargs.pop("url"),
kwargs.pop("aliases"),
kwargs.pop("override"),
)
print(name, url, aliases, override)
self.targets[name] = url
self.aliases[tuple(aliases)] = name
if override:
self.url_overrides[name] = override
await ctx.send(
"RTFM target {name} added with aliases {aliases}".format(
name=name, aliases=aliases
)
)
def setup(bot: TechStruckBot):
bot.add_cog(RTFM(bot))
================================================
FILE: bot/cogs/stackexchange.py
================================================
import datetime
import html
import json
import os
import traceback
from typing import Optional
from urllib.parse import urlencode
from cachetools import TTLCache
from discord import Color, Embed, Forbidden, Member
from discord.ext import commands, flags, tasks
from jose import jwt
from bot.utils import fuzzy
from config.common import config
from config.oauth import stack_oauth_config
from models import UserModel
search_result_template = "[View]({site[site_url]}/q/{q[question_id]})\u2800\u2800Score: {q[score]}\u2800\u2800Tags: {tags}"
class StackExchangeNotLinkedError(commands.CommandError):
def __str__(self):
return "Your stackexchange account hasn't been linked yet, please use the `linkstack` command to do it"
class StackExchangeError(commands.CommandError):
pass
class Stackexchange(commands.Cog):
"""Commands related to the StackExchange network"""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.ready = False
self.sites = None
self.token_cache = TTLCache(maxsize=1000, ttl=600)
self.load_sites.start()
@property
def session(self):
return self.bot.http._HTTPClient__session
@tasks.loop(count=1)
async def load_sites(self):
if os.path.isfile("cache/stackexchange_sites.json"):
with open("cache/stackexchange_sites.json") as f:
self.sites = json.load(f)
else:
try:
data = await self.stack_request(
None,
"GET",
"/sites",
params={"pagesize": "500", "filter": "*Ids4-aWV*RW_UxCPr0D"},
)
except Exception:
return traceback.print_exc()
else:
self.sites = data["items"]
if not os.path.isdir("cache"):
os.mkdir("cache")
with open("cache/stackexchange_sites.json", "w") as f:
json.dump(self.sites, f)
self.ready = True
async def cog_check(self, ctx: commands.Context):
if not self.ready:
raise StackExchangeError("Stackexchange commands are not ready yet")
return True
async def cog_before_invoke(self, ctx: commands.Context):
if ctx.command == self.link_stackoverflow:
return
token = self.token_cache.get(ctx.author.id)
if not token:
user = await UserModel.get_or_none(id=ctx.author.id)
if user is None or user.stackoverflow_oauth_token is None:
raise StackExchangeNotLinkedError()
token = user.stackoverflow_oauth_token
self.token_cache[ctx.author.id] = token
ctx.stack_token = token # type: ignore
@flags.add_flag("--site", type=str, default="stackoverflow")
@flags.command(
name="stackprofile",
aliases=["stackpro", "stackacc", "stackaccount"],
)
async def stack_profile(self, ctx: commands.Context, **kwargs):
"""Check your stackoverflow reputation"""
# TODO: Use a stackexchange filter here
# https://api.stackexchange.com/docs/filters
site = self.get_site(kwargs["site"])
data = await self.stack_request(
ctx,
"GET",
"/me",
data={
"site": site["api_site_parameter"],
},
)
if not data["items"]:
return await ctx.send("You don't have an account in this site!")
profile = data["items"][0]
embed = Embed(title=site["name"] + " Profile", color=0x0077CC)
embed.add_field(name="Username", value=profile["display_name"], inline=False)
embed.add_field(name="Reputation", value=profile["reputation"], inline=False)
embed.add_field(
name="Badges",
value="\U0001f947 {0[gold]} \u2502 \U0001f948 {0[silver]} \u2502 \U0001f949 {0[bronze]}".format(
profile["badge_counts"]
),
inline=False,
)
embed.set_thumbnail(url=profile["profile_image"])
await ctx.send(embed=embed)
@flags.add_flag("--site", type=str, default="stackoverflow")
@flags.add_flag("--tagged", type=str, nargs="+", default=[])
@flags.add_flag("term", nargs="+")
@flags.command(name="stacksearch", aliases=["stackser"])
async def stackexchange_search(self, ctx: commands.Context, **kwargs):
"""Search stackexchange for your question"""
term, sitename, tagged = (
" ".join(kwargs["term"]),
kwargs["site"],
kwargs["tagged"],
)
site = self.get_site(sitename)
data = await self.stack_request(
ctx,
"GET",
"/search/excerpts",
data={
"site": sitename,
"sort": "relevance",
"q": term,
"tagged": ";".join(tagged),
"pagesize": 5,
"filter": "ld-5YXYGN1SK1e",
},
)
embed = Embed(title=f"{site['name']} search", color=Color.green())
embed.set_thumbnail(url=site["icon_url"])
if data["items"]:
for i, q in enumerate(data["items"], 1):
tags = "\u2800".join(["`" + t + "`" for t in q["tags"]])
embed.add_field(
name=str(i) + " " + html.unescape(q["title"]),
value=search_result_template.format(site=site, q=q, tags=tags),
inline=False,
)
else:
embed.add_field(name="Oops", value="Couldn't find any results")
await ctx.send(embed=embed)
@commands.command(aliases=["stacksites"])
async def stacksite(self, ctx: commands.Context, *, term: str):
"""Search through list of stackexchange sites and find relevant ones"""
sites = fuzzy.finder(term, self.sites, key=lambda s: s["name"], lazy=False)[:5] # type: ignore
embed = Embed(color=Color.blue())
description = "\n".join(
["[`{0[name]}`]({0[site_url]})".format(site) for site in sites]
)
embed.description = description
await ctx.send(embed=embed)
def get_site(self, sitename: str):
sitename = sitename.lower()
for site in self.sites:
if site["api_site_parameter"] == sitename:
return site
raise StackExchangeError(f"Invalid site {sitename} provided")
async def stack_request(
self,
ctx: Optional[commands.Context],
method: str,
endpoint: str,
params: dict = {},
data: dict = {},
):
data.update(stack_oauth_config.dict())
if ctx:
data["access_token"] = (ctx.stack_token,)
res = await self.session.request(
method,
f"https://api.stackexchange.com/2.2{endpoint}",
params=params,
data=data,
)
data = await res.json()
if "error_message" in data:
raise StackExchangeError(data["error_message"])
return data
@commands.command(name="linkstack", aliases=["lnstack"])
async def link_stackoverflow(self, ctx: commands.Context):
"""Link your stackoverflow account"""
expiry = datetime.datetime.utcnow() + datetime.timedelta(seconds=120)
url = "https://stackoverflow.com/oauth/?" + urlencode(
{
"client_id": stack_oauth_config.client_id,
"scope": "no_expiry",
"redirect_uri": "https://tech-struck.vercel.app/oauth/stackexchange",
"state": jwt.encode(
{"id": ctx.author.id, "expiry": str(expiry)}, config.secret
),
}
)
try:
await ctx.author.send(
embed=Embed(
title="Connect Stackexchange",
description=f"Click [this]({url}) to link your stackexchange account. This link invalidates in 2 minutes",
color=Color.blue(),
)
)
except Forbidden:
await ctx.send(
"Your DMs (direct messages) are closed. Open them so I can send you a safe authorization link."
)
def setup(bot: commands.Bot):
bot.add_cog(Stackexchange(bot))
================================================
FILE: bot/cogs/thank.py
================================================
import asyncio
from typing import Optional
from discord import Color, Embed, Member, Reaction
from discord.ext import commands
from tortoise.functions import Count, Q
from models import ThankModel, UserModel
delete_thank_message = """**Thanked**: <@!{0.thanked_id}>
**Thanker**: <@!{0.thanker_id}>
**Description**: {0.description}
**Time**: {0.time}\n
Confirmation required!"""
thank_list_message = """`{0.time:%D %T}` ID:`{0.id}`
From: <@!{0.thanker_id}> ({0.thanker_id})
Description: {0.description}\n"""
class Thank(commands.Cog):
"""Commands related to thanking members/helpers for help received"""
def __init__(self, bot: commands.Bot):
self.bot = bot
@commands.group(invoke_without_command=True, aliases=["thanks", "rep"])
@commands.cooldown(5, 300, commands.BucketType.user)
async def thank(self, ctx: commands.Context, recv: Member, *, description: str):
"""Thank someone for their help with a description to show gratitude"""
des_len = len(description)
if des_len < 5 or des_len > 100:
return await ctx.send(
f"Thank description must be between 5 and 100 characters, yours was {des_len}"
)
if recv.id == ctx.author.id:
return await ctx.send(
embed=Embed(
title="Bruh",
description="You can't thank yourselves",
color=Color.red(),
)
)
if recv.bot:
return await ctx.send(
embed=Embed(
title="Bruh", description="You can't thank a bot", color=Color.red()
)
)
# TODO: Convert this to an expression (?) for efficiency
thanked, _ = await UserModel.get_or_create(id=recv.id)
thanker, _ = await UserModel.get_or_create(id=ctx.author.id)
await ThankModel.create(
thanker=thanker,
thanked=thanked,
description=description,
guild_id=ctx.guild.id,
)
await ctx.send(
embed=Embed(
description=f"You thanked {recv.mention}!", color=0x6EFFFF
)
)
@thank.command(name="stats", aliases=["check"])
async def thank_stats(
self, ctx: commands.Context, *, member: Optional[Member] = None
):
"""View stats for thanks you've received and sent, in the current server and globally"""
member = member or ctx.author
sent_thanks = await ThankModel.filter(thanker__id=member.id).count()
recv_thanks = await ThankModel.filter(thanked__id=member.id).count()
server_sent_thanks = await ThankModel.filter(
thanker__id=member.id, guild__id=ctx.guild.id
).count()
server_recv_thanks = await ThankModel.filter(
thanked__id=member.id, guild__id=ctx.guild.id
).count()
embed = Embed(title=f"Thank stats for: {member}", color=Color.green())
embed.add_field(
name="Thanks received",
value="Global: {}\nThis server: {}".format(recv_thanks, server_recv_thanks),
)
embed.add_field(
name="Thanks sent",
value="Global: {}\nThis server: {}".format(sent_thanks, server_sent_thanks),
)
await ctx.send(embed=embed)
@thank.command(name="leaderboard", aliases=["lb"])
async def thank_leaderboard(self, ctx: commands.Context):
"""View a leaderboard of top helpers in the current server"""
await ctx.trigger_typing()
lb = (
await UserModel.annotate(
thank_count=Count("thanks", _filter=Q(thanks__guild_id=ctx.guild.id))
)
.filter(thank_count__gt=0)
.order_by("-thank_count")
.limit(5)
)
if not lb:
return await ctx.send(
embed=Embed(
title="Oopsy",
description="There are no thanks here yet!",
color=Color.red(),
)
)
invis = "\u2800"
embed = Embed(
title="LeaderBoard",
color=Color.blue(),
description="\n\n".join(
[
f"**{m.thank_count} Thanks**{invis * (4 - len(str(m.thank_count)))}<@!{m.id}>"
for m in lb
]
),
)
await ctx.send(embed=embed)
@thank.command(name="delete")
@commands.has_guild_permissions(kick_members=True)
async def delete_thank(self, ctx: commands.Context, thank_id: int):
"""Remove an invalid/fake thank record"""
thank = await ThankModel.get_or_none(pk=thank_id, guild_id=ctx.guild.id)
if not thank:
return await ctx.send("Thank with given ID not found")
msg = await ctx.send(
embed=Embed(
title="Delete thank",
description=delete_thank_message.format(thank),
)
)
await msg.add_reaction("\u2705")
await msg.add_reaction("\u274e")
def check(r: Reaction, u: Member):
return u.id == ctx.author.id and str(r.emoji) in ("\u2705", "\u274e")
try:
r, _ = await self.bot.wait_for("reaction_add", check=check)
except asyncio.TimeoutError:
return await ctx.reply("Cancelled.")
if str(r.emoji) == "\u2705":
await thank.delete()
return await ctx.reply("Deleted.")
return await ctx.reply("Cancelled.")
@thank.command(name="list")
@commands.has_guild_permissions(kick_members=True)
async def list_thanks(self, ctx: commands.Context, member: Member):
"""List the most recent 10 thanks received by a user in the current server"""
thanks = (
await ThankModel.filter(thanked_id=member.id, guild_id=ctx.guild.id)
.order_by("-time")
.limit(10)
)
await ctx.send(
embed=Embed(
title="Listing",
description="\n".join([thank_list_message.format(t) for t in thanks]),
color=Color.dark_blue(),
)
)
def setup(bot: commands.Bot):
bot.add_cog(Thank(bot))
================================================
FILE: bot/cogs/utils.py
================================================
import sys
import os
import inspect
from discord import Embed, Message, TextChannel
from discord.ext import commands, flags
from bot.bot import TechStruckBot
from bot.utils.embed_flag_input import (
allowed_mentions_input,
dict_to_allowed_mentions,
dict_to_embed,
embed_input,
process_message_mentions,
webhook_input,
)
flags._converters.CONVERTERS["Message"] = commands.MessageConverter().convert
async def maybe_await(coro):
if not coro:
return
return await coro
class Utils(commands.Cog):
"""Utility commands"""
def __init__(self, bot: TechStruckBot):
self.bot = bot
@embed_input(all=True)
@allowed_mentions_input()
@webhook_input()
@flags.add_flag("--channel", "--in", type=TextChannel, default=None)
@flags.add_flag("--message", "--msg", "-m", default=None)
@flags.add_flag("--edit", "-e", type=Message, default=None)
@flags.command(
brief="Send an embed with any fields, in any channel, with command line like arguments"
)
@commands.has_guild_permissions(administrator=True)
@commands.bot_has_permissions(manage_webhooks=True, embed_links=True)
async def embed(self, ctx: commands.Context, **kwargs):
"""
Send an embed and its fully customizable
Default mention settings:
Users: Enabled
Roles: Disabled
Everyone: Disabled
"""
embed = dict_to_embed(kwargs, author=ctx.author)
allowed_mentions = dict_to_allowed_mentions(kwargs)
message = process_message_mentions(kwargs.pop("message"))
if kwargs.pop("webhook"):
if edit_message := kwargs.pop("edit"):
edit_message.close()
username, avatar_url = kwargs.pop("webhook_username"), kwargs.pop(
"webhook_avatar"
)
if kwargs.pop("webhook_auto_author"):
username, avatar_url = (
username or ctx.author.display_name,
avatar_url or ctx.author.avatar_url,
)
target = kwargs.pop("channel") or ctx.channel
if name := kwargs.pop("webhook_new_name"):
wh = await target.create_webhook(name=name)
elif name := kwargs.pop("webhook_name"):
try:
wh = next(
filter(
lambda wh: wh.name.casefold() == name.casefold(),
await target.webhooks(),
)
)
except StopIteration:
return await ctx.send(
"No pre existing webhook found with given name"
)
else:
return await ctx.send("No valid webhook identifiers provided")
await wh.send(
message,
embed=embed,
allowed_mentions=allowed_mentions,
username=username,
avatar_url=avatar_url,
)
if kwargs.pop("webhook_dispose"):
await wh.delete()
return await ctx.message.add_reaction("\u2705")
if edit := await maybe_await(kwargs.pop("edit")):
if edit.author != ctx.guild.me:
return await ctx.send(
f"The target message wasn't sent by me! It was sent by {edit.author}"
)
await edit.edit(
content=message, embed=embed, allowed_mentions=allowed_mentions
)
else:
target = kwargs.pop("channel") or ctx
await target.send(message, embed=embed, allowed_mentions=allowed_mentions)
await ctx.message.add_reaction("\u2705")
@commands.command()
async def rawembed(self, ctx: commands.Context):
ref = ctx.message.reference
if not ref or not ref.message_id:
return await ctx.send("Reply to an message with an embed")
message = ref.cached_message or await ctx.channel.fetch_message(ref.message_id)
if not message.embeds:
return await ctx.send("Message had no embeds")
em = message.embeds[0]
description = "```" + str(em.to_dict()) + "```"
embed = Embed(description=description)
await ctx.reply(embed=embed)
@commands.command()
async def source(self, ctx: commands.Context, *, command=None):
"""Get the source code of the bot or the provided command."""
if command is None:
return await ctx.send(
embed=Embed(
description=f"My source can be found [here](https://github.com/TechStruck/TechStruck-Bot)!",
color=0x8ADCED
)
)
if command == "help":
src = type(self.bot.help_command)
module = src.__module__
filename = inspect.getsourcefile(src)
else:
cmd = self.bot.get_command(command)
if cmd is None:
return await ctx.send(
embed=Embed(
description="No such command found.",
color=0x8ADCED
)
)
src = cmd.callback.__code__
module = cmd.callback.__module__
filename = src.co_filename
lines, firstline = inspect.getsourcelines(src)
lines = len(lines)
location = (
module.replace(".", "/") + ".py"
if module.startswith("discord")
else os.path.relpath(filename).replace(r"\\", "/")
)
url = f"https://github.com/TechStruck/TechStruck-Bot/blob/main/{location}#L{firstline}-L{firstline+lines-1}"
await ctx.send(
embed=Embed(
description=f"Source of {command} can be found [here]({url}).",
color=0x8ADCED
)
)
def setup(bot: TechStruckBot):
bot.add_cog(Utils(bot))
def teardown(bot: TechStruckBot):
del sys.modules["bot.utils.embed_flag_input"]
================================================
FILE: bot/core.py
================================================
import platform
import sys
import psutil
from discord import Color, Embed, NotFound
from discord import __version__ as discord_version
from discord.ext import commands
from models import GuildModel
from .bot import TechStruckBot
class Common(commands.Cog):
def __init__(self, bot: TechStruckBot):
self.bot = bot
@commands.command(aliases=["latency"])
async def ping(self, ctx: commands.Context):
"""Check latency of the bot"""
latency = str(round(self.bot.latency * 1000, 1))
await ctx.send(
embed=Embed(title="Pong!", description=f"{latency}ms", color=Color.blue())
)
@commands.command(aliases=["statistics"])
async def stats(self, ctx: commands.Context):
"""Stats of the bot"""
users = len(self.bot.users)
guilds = len(self.bot.guilds)
embed = Embed(color=Color.dark_green())
fields = (
("Guilds", guilds),
("Users", users),
("System", platform.release()),
(
"Memory",
"{:.4} MB".format(psutil.Process().memory_info().rss / 1024 ** 2),
),
("Python version", ".".join([str(v) for v in sys.version_info[:3]])),
("Discord version", discord_version),
)
for name, value in fields:
embed.add_field(name=name, value=str(value), inline=False)
embed.set_thumbnail(url=str(ctx.guild.me.avatar_url))
await ctx.send(embed=embed)
@commands.command(aliases=["re"])
async def redo(self, ctx: commands.Context):
"""Reply to a message to rerun it if its a command, helps when you've made typos"""
ref = ctx.message.reference
if ref is None or ref.message_id is None:
return
try:
message = await ctx.channel.fetch_message(ref.message_id)
except NotFound:
return await ctx.reply("Couldn't find that message")
if message.author != ctx.author:
return
await self.bot.process_commands(message)
@commands.command()
@commands.guild_only()
@commands.has_guild_permissions(manage_guild=True)
async def setprefix(self, ctx: commands.Context, *, prefix: str):
"""Set a custom prefix for the current server"""
if len(prefix) > 10:
return await ctx.send("Prefix too long, must be within 10 characters!")
self.bot.prefix_cache[ctx.guild.id] = prefix
await GuildModel.filter(id=ctx.guild.id).update(prefix=prefix)
await ctx.send(f"My prefix has been updated to `{prefix}`")
@commands.command()
async def prefix(self, ctx: commands.Context):
"""View current prefix of bot"""
await ctx.send(
f"My prefix here is `"
+ (self.bot.prefix_cache[ctx.guild.id] if ctx.guild else ".")
+ "`"
)
@commands.command()
async def invite(self, ctx: commands.Context):
embed = Embed(
title="Invite me!",
description="[Click here](https://discord.com/api/oauth2/authorize?client_id=790474885804982293&permissions=0&scope=bot%20applications.commands) to add me to your server with no extra role!",
color=Color.green(),
)
await ctx.send(embed=embed)
def setup(bot: TechStruckBot):
bot.add_cog(Common(bot))
================================================
FILE: bot/utils/embed_flag_input.py
================================================
import functools
import re
from typing import Dict, Iterable, TypeVar, Union
from urllib import parse
from discord import AllowedMentions, Embed, Member, User
from discord.ext import commands, flags # type: ignore
_F = TypeVar(
"_F",
)
class InvalidFieldArgs(commands.CommandError):
pass
class EmbeyEmbedError(commands.CommandError):
def __str__(self) -> str:
return "The embed has no fields/attributes populated"
class InvalidUrl(commands.CommandError):
def __init__(self, invalid_url: str, *, https_only: bool = False) -> None:
self.invalid_url = invalid_url
self.https_only = https_only
def __str__(self) -> str:
return "The url entered (`%s`) is invalid.%s" % (
self.invalid_url,
"\nThe url must be https" if self.https_only else "",
)
class InvalidColor(commands.CommandError):
def __init__(self, value) -> None:
self.value = value
def __str__(self):
return "%s isn't a valid color, eg: `#fff000`, `f0f0f0`" % self.value
class UrlValidator:
def __init__(self, *, https_only=False) -> None:
self.https_only = https_only
def __call__(self, value):
url = parse.urlparse(value)
schemes = ("https",) if self.https_only else ("http", "https")
if url.scheme not in schemes or not url.hostname:
raise InvalidUrl(value, https_only=self.https_only)
return value
def colortype(value: str):
try:
return int(value.removeprefix("#"), base=16)
except ValueError:
raise InvalidColor(value)
url_type = UrlValidator(https_only=True)
def process_message_mentions(message: str) -> str:
if not message:
return ""
for _type, _id in re.findall(r"(role|user):(\d{18})", message):
message = message.replace(
_type + ":" + _id, f"<@!{_id}>" if _type == "user" else f"<@&{_id}>"
)
for label in ("mention", "ping"):
for role in ("everyone", "here"):
message = message.replace(label + ":" + role, f"@{role}")
return message
class FlagAdder:
def __init__(self, kwarg_map: Dict[str, Iterable], *, default_mode: bool = False):
self.kwarg_map = kwarg_map
self.default_mode = default_mode
def call(self, func: _F, **kwargs) -> _F:
if kwargs.pop("all", False):
for flags in self.kwarg_map.values():
self.apply(flags=flags, func=func)
return func
kwargs = {**{k: self.default_mode for k in self.kwarg_map.keys()}, **kwargs}
for k, v in kwargs.items():
if v:
self.apply(flags=self.kwarg_map[k], func=func)
return func
def __call__(self, func=None, **kwargs):
if func is None:
return functools.partial(self.call, **kwargs)
return self.call(func, **kwargs)
def apply(self, *, flags: Iterable, func: _F) -> _F:
for flag in flags:
flag(func)
return func
embed_input = FlagAdder(
{
"basic": (
flags.add_flag("--title", "-t"),
flags.add_flag("--description", "-d"),
flags.add_flag("--fields", "-f", nargs="+"),
flags.add_flag("--colour", "--color", "-c", type=colortype),
),
"image": (
flags.add_flag("--thumbnail", "-th", type=url_type),
flags.add_flag("--image", "-i", type=url_type),
),
"author": (
flags.add_flag("--author-name", "--aname", "-an"),
flags.add_flag("--auto-author", "-aa", action="store_true", default=False),
flags.add_flag("--author-url", "--aurl", "-au", type=url_type),
flags.add_flag("--author-icon", "--aicon", "-ai", type=url_type),
),
"footer": (
flags.add_flag("--footer-icon", "-fi", type=url_type),
flags.add_flag("--footer-text", "-ft"),
),
}
)
allowed_mentions_input = FlagAdder(
{
"all": (
flags.add_flag(
"--everyone-mention", "-em", default=False, action="store_true"
),
flags.add_flag(
"--role-mentions", "-rm", default=False, action="store_true"
),
flags.add_flag(
"--user-mentions", "-um", default=True, action="store_false"
),
)
},
default_mode=True,
)
webhook_input = FlagAdder(
{
"all": (
flags.add_flag("--webhook", "-w", action="store_true", default=False),
flags.add_flag("--webhook-username", "-wun", type=str, default=None),
flags.add_flag("--webhook-avatar", "-wav", type=url_type, default=None),
flags.add_flag(
"--webhook-auto-author", "-waa", action="store_true", default=False
),
flags.add_flag("--webhook-new-name", "-wnn", type=str, default=None),
flags.add_flag("--webhook-name", "-wn", type=str, default=None),
flags.add_flag(
"--webhook-dispose", "-wd", action="store_true", default=False
),
)
},
default_mode=True,
)
def dict_to_embed(data: Dict[str, str], author: Union[User, Member] = None):
embed = Embed()
for field in ("title", "description", "colour"):
if value := data.pop(field, None):
setattr(embed, field, value)
for field in "thumbnail", "image":
if value := data.pop(field, None):
getattr(embed, "set_" + field)(url=value)
if data.pop("auto_author", False) and author:
embed.set_author(name=author.display_name, icon_url=str(author.avatar_url))
if "author_name" in data and data["author_name"]:
kwargs = {}
if icon_url := data.pop("author_icon", None):
kwargs["icon_url"] = icon_url
if author_url := data.pop("author_url", None):
kwargs["url"] = author_url
embed.set_author(name=data.pop("author_name"), **kwargs)
if "footer_text" in data and data["footer_text"]:
kwargs = {}
if footer_icon := data.pop("footer_icon", None):
kwargs["icon_url"] = footer_icon
embed.set_footer(text=data.pop("footer_text"), **kwargs)
fields = data.pop("fields", []) or []
if len(fields) % 2 == 1:
raise InvalidFieldArgs(
"Number of arguments for fields must be an even number, pairs of name and value"
)
for name, value in zip(fields[::2], fields[1::2]):
embed.add_field(name=name, value=value, inline=False)
if embed.to_dict() == {"type": "rich"}:
raise EmbeyEmbedError()
return embed
def dict_to_allowed_mentions(data):
return AllowedMentions(
everyone=data.pop("everyone_mention"),
roles=data.pop("role_mentions"),
users=data.pop("user_mentions"),
)
================================================
FILE: bot/utils/fuzzy.py
================================================
# -*- coding: utf-8 -*-
"""
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
"""
# help with: http://chairnerd.seatgeek.com/fuzzywuzzy-fuzzy-string-matching-in-python/
import heapq
import re
from difflib import SequenceMatcher
def ratio(a, b):
m = SequenceMatcher(None, a, b)
return int(round(100 * m.ratio()))
def quick_ratio(a, b):
m = SequenceMatcher(None, a, b)
return int(round(100 * m.quick_ratio()))
def partial_ratio(a, b):
short, long = (a, b) if len(a) <= len(b) else (b, a)
m = SequenceMatcher(None, short, long)
blocks = m.get_matching_blocks()
scores = []
for i, j, n in blocks:
start = max(j - i, 0)
end = start + len(short)
o = SequenceMatcher(None, short, long[start:end])
r = o.ratio()
if 100 * r > 99:
return 100
scores.append(r)
return int(round(100 * max(scores)))
_word_regex = re.compile(r"\W", re.IGNORECASE)
def _sort_tokens(a):
a = _word_regex.sub(" ", a).lower().strip()
return " ".join(sorted(a.split()))
def token_sort_ratio(a, b):
a = _sort_tokens(a)
b = _sort_tokens(b)
return ratio(a, b)
def quick_token_sort_ratio(a, b):
a = _sort_tokens(a)
b = _sort_tokens(b)
return quick_ratio(a, b)
def partial_token_sort_ratio(a, b):
a = _sort_tokens(a)
b = _sort_tokens(b)
return partial_ratio(a, b)
def _extraction_generator(query, choices, scorer=quick_ratio, score_cutoff=0):
try:
for key, value in choices.items():
score = scorer(query, key)
if score >= score_cutoff:
yield (key, score, value)
except AttributeError:
for choice in choices:
score = scorer(query, choice)
if score >= score_cutoff:
yield (choice, score)
def extract(query, choices, *, scorer=quick_ratio, score_cutoff=0, limit=10):
it = _extraction_generator(query, choices, scorer, score_cutoff)
key = lambda t: t[1]
if limit is not None:
return heapq.nlargest(limit, it, key=key)
return sorted(it, key=key, reverse=True)
def extract_one(query, choices, *, scorer=quick_ratio, score_cutoff=0):
it = _extraction_generator(query, choices, scorer, score_cutoff)
key = lambda t: t[1]
try:
return max(it, key=key)
except:
# iterator could return nothing
return None
def extract_or_exact(query, choices, *, limit=None, scorer=quick_ratio, score_cutoff=0):
matches = extract(
query, choices, scorer=scorer, score_cutoff=score_cutoff, limit=limit
)
if len(matches) == 0:
return []
if len(matches) == 1:
return matches
top = matches[0][1]
second = matches[1][1]
# check if the top one is exact or more than 30% more correct than the top
if top == 100 or top > (second + 30):
return [matches[0]]
return matches
def extract_matches(query, choices, *, scorer=quick_ratio, score_cutoff=0):
matches = extract(
query, choices, scorer=scorer, score_cutoff=score_cutoff, limit=None
)
if len(matches) == 0:
return []
top_score = matches[0][1]
to_return = []
index = 0
while True:
try:
match = matches[index]
except IndexError:
break
else:
index += 1
if match[1] != top_score:
break
to_return.append(match)
return to_return
def finder(text, collection, *, key=None, lazy=True):
suggestions = []
text = str(text)
pat = ".*?".join(map(re.escape, text))
regex = re.compile(pat, flags=re.IGNORECASE)
for item in collection:
to_search = key(item) if key else item
r = regex.search(to_search)
if r:
suggestions.append((len(r.group()), r.start(), item))
def sort_key(tup):
if key:
return tup[0], tup[1], key(tup[2])
return tup
if lazy:
return (z for _, _, z in sorted(suggestions, key=sort_key))
else:
return [z for _, _, z in sorted(suggestions, key=sort_key)]
def find(text, collection, *, key=None):
try:
return finder(text, collection, key=key, lazy=False)[0]
except IndexError:
return None
================================================
FILE: bot/utils/process_files.py
================================================
import re
from typing import Dict, List, Tuple
from discord.ext import commands
files_pattern = re.compile(r"\s{0,}```\w{0,}\s{0,}")
class NoValidFiles(commands.CommandError):
def __str__(self):
return "None of the files were valid or no files were given"
async def process_files(
ctx: commands.Context, inp: str
) -> Tuple[Dict[str, Dict[str, str]], List[str]]:
files = {}
attachments = ctx.message.attachments.copy()
skipped = []
msg = ctx.message
# If the message was a reply
if msg.reference and msg.reference.message_id:
replied = msg.reference.cached_message or await msg.channel.fetch_message(
msg.reference.message_id
)
attachments.extend(replied.attachments)
if inp:
# TODO: Change this to something better
files_and_names = files_pattern.split(inp)[:-1]
# Dict comprehension to create the files 'object'
files = {
name: {"content": content + "\n"}
for name, content in zip(files_and_names[0::2], files_and_names[1::2])
}
for attachment in attachments:
if attachment.size > 64 * 1024 or attachment.filename.endswith(
("jpg", "jpeg", "png")
):
skipped.append(attachment.filename)
continue
try:
b = (await attachment.read()).decode("utf-8")
except UnicodeDecodeError:
skipped.append(attachment.filename)
else:
files[attachment.filename] = {"content": b}
if not files:
raise NoValidFiles()
return files, skipped
================================================
FILE: bot/utils/rtfm.py
================================================
import io
import os
import re
import zlib
# Directly taken and modified from Rapptz/RoboDanny
# https://github.com/Rapptz/RoboDanny/blob/715a5cf8545b94d61823f62db484be4fac1c95b1/cogs/api.py
# This code is under the Mozilla Public License 2.0
class SphinxObjectFileReader:
# Inspired by Sphinx's InventoryFileReader
BUFSIZE = 16 * 1024
def __init__(self, buffer):
self.stream = io.BytesIO(buffer)
def readline(self):
return self.stream.readline().decode("utf-8")
def skipline(self):
self.stream.readline()
def read_compressed_chunks(self):
decompressor = zlib.decompressobj()
while True:
chunk = self.stream.read(self.BUFSIZE)
if len(chunk) == 0:
break
yield decompressor.decompress(chunk)
yield decompressor.flush()
def read_compressed_lines(self):
buf = b""
for chunk in self.read_compressed_chunks():
buf += chunk
pos = buf.find(b"\n")
while pos != -1:
yield buf[:pos].decode("utf-8")
buf = buf[pos + 1 :]
pos = buf.find(b"\n")
def parse_object_inv(self, url):
# key: URL
# n.b.: key doesn't have `discord` or `discord.ext.commands` namespaces
result = {}
# first line is version info
inv_version = self.readline().rstrip()
if inv_version != "# Sphinx inventory version 2":
raise RuntimeError("Invalid objects.inv file version.")
# next line is "# Project: "
# then after that is "# Version: "
projname = self.readline().rstrip()[11:]
version = self.readline().rstrip()[11:]
# next line says if it's a zlib header
line = self.readline()
if "zlib" not in line:
raise RuntimeError("Invalid objects.inv file, not z-lib compatible.")
# This code mostly comes from the Sphinx repository.
entry_regex = re.compile(r"(?x)(.+?)\s+(\S*:\S*)\s+(-?\d+)\s+(\S+)\s+(.*)")
for line in self.read_compressed_lines():
match = entry_regex.match(line.rstrip())
if not match:
continue
name, directive, prio, location, dispname = match.groups()
domain, _, subdirective = directive.partition(":")
if directive == "py:module" and name in result:
# From the Sphinx Repository:
# due to a bug in 1.1 and below,
# two inventory entries are created
# for Python modules, and the first
# one is correct
continue
# Most documentation pages have a label
if directive == "std:doc":
subdirective = "label"
if location.endswith("$"):
location = location[:-1] + name
key = name if dispname == "-" else dispname
prefix = f"{subdirective}:" if domain == "std" else ""
if projname == "discord.py":
key = key.replace("discord.ext.commands.", "").replace("discord.", "")
result[f"{prefix}{key}"] = os.path.join(url, location)
return result
================================================
FILE: bot.Dockerfile
================================================
FROM python:3.9
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
COPY requirements-bot.txt ./
RUN pip install --no-cache-dir -r requirements-bot.txt
COPY . .
CMD [ "python", "-m", "bot" ]
================================================
FILE: config/__init__.py
================================================
================================================
FILE: config/bot.py
================================================
from pydantic import BaseSettings
class BotConfig(BaseSettings):
bot_token: str
quiz_api_token: str
log_webhook: str
class Config:
env_file = ".env"
bot_config = BotConfig()
================================================
FILE: config/common.py
================================================
from pydantic import BaseSettings, PostgresDsn
class Settings(BaseSettings):
secret: str
database_uri: PostgresDsn
no_ssl: bool = False
class Config:
env_file = ".env"
fields = {
"database_uri": {"env": ["database_uri", "database_url", "database"]},
"no_ssl": {"env": "database_no_ssl"},
"secret": {"env": "signing_secret"},
}
config = Settings()
================================================
FILE: config/oauth.py
================================================
from pydantic import BaseSettings
class StackOAuthConfig(BaseSettings):
client_id: str
client_secret: str
redirect_uri: str
key: str
class Config:
env_file = ".env"
env_prefix = "stackexchange_"
class GithubOAuthConfig(BaseSettings):
client_id: str
client_secret: str
redirect_uri: str
class Config:
env_file = ".env"
env_prefix = "github_"
stack_oauth_config = StackOAuthConfig()
github_oauth_config = GithubOAuthConfig()
================================================
FILE: config/reddit.py
================================================
from pydantic import BaseSettings
class RedditConfig(BaseSettings):
client_id: str
client_secret: str
username: str
password: str
class Config:
env_file = ".env"
env_prefix = "reddit_"
reddit_config = RedditConfig()
================================================
FILE: config/webhook.py
================================================
from pydantic import BaseSettings
class Webhooks(BaseSettings):
git_tips: str
meme: str
authorization: str
class Config:
env_file = ".env"
env_prefix = "webhook_url_"
fields = {"authorization": {"env": "authorization"}}
webhook_config = Webhooks()
================================================
FILE: heroku.yml
================================================
build:
docker:
worker: bot.Dockerfile
================================================
FILE: models.py
================================================
from tortoise import Model, fields
class ThankModel(Model):
id = fields.IntField(pk=True)
guild = fields.ForeignKeyField(
model_name="main.GuildModel",
related_name="all_thanks",
description="Guild in which the user was thanked",
)
thanker = fields.ForeignKeyField(
model_name="main.UserModel",
related_name="sent_thanks",
description="The member who sent the thanks",
)
thanked = fields.ForeignKeyField(
model_name="main.UserModel",
related_name="thanks",
description="The member who was thanked",
)
time = fields.DatetimeField(auto_now_add=True)
description = fields.CharField(max_length=100)
class Meta:
table = "thanks"
table_description = "Represents a 'thank' given from one user to another"
class GuildModel(Model):
id = fields.BigIntField(pk=True, description="Discord ID of the guild")
all_thanks: fields.ForeignKeyRelation[ThankModel]
prefix = fields.CharField(
max_length=10, default=".", description="Custom prefix of the guild"
)
class Meta:
table = "guilds"
table_description = "Represents a discord guild's settings"
class UserModel(Model):
id = fields.BigIntField(pk=True, description="Discord ID of the user")
# External references
github_oauth_token = fields.CharField(
max_length=50, null=True, description="Github OAuth2 access token of the user"
)
stackoverflow_oauth_token = fields.CharField(
max_length=50,
null=True,
description="Stackoverflow OAuth2 access token of the user",
)
thanks: fields.ForeignKeyRelation[ThankModel]
sent_thanks: fields.ForeignKeyRelation[ThankModel]
class Meta:
table = "users"
table_description = "Represents all users"
class JokeModel(Model):
id = fields.IntField(pk=True, description="Joke ID")
setup = fields.CharField(max_length=150, description="Joke setup")
end = fields.CharField(max_length=150, description="Joke end")
tags = fields.JSONField(default=[], description="List of tags")
accepted = fields.BooleanField(
default=False, description="Whether the joke has been accepted in"
)
creator = fields.ForeignKeyField(
model_name="main.UserModel",
related_name="joke_submissions",
description="User who submitted this Joke",
)
class Meta:
table = "jokes"
table_description = "User submitted jokes being collected"
================================================
FILE: public/templates/404.html
================================================
404
HTTP: 404
this_page .not_found = True
if you_spelt_it_wrong :
try_again()
elif we_screwed_up :
print ("We're really sorry about that." ); return redirect (url_for ("home" ))
HOME
================================================
FILE: public/templates/oauth_error.html
================================================
Error!
================================================
FILE: public/templates/oauth_success.html
================================================
Success!
Success
Your {{oauth_provider}} has been linked.
Go Back
================================================
FILE: requirements-bot.txt
================================================
aerich==0.5.0
cachetools==4.2.1
discord.py==1.7.1
discord-flags==2.1.1
jishaku==1.20.0.220
psutil==5.8.0
python-dotenv==0.17.0
python-jose==3.2.0
PyYAML==5.4.1
svglib==1.1.0
tortoise-orm[asyncpg]==0.16.21
================================================
FILE: requirements-dev.txt
================================================
black==20.8b1
isort==5.8.0
================================================
FILE: requirements.txt
================================================
asyncpg==0.22.0
backports-datetime-fromisoformat==1.0.0; python_version < '3.7'
discord.py==1.7.1
fastapi==0.65.2
Jinja2==2.11.3
praw==7.2.0
python-dotenv==0.17.0
python-jose==3.2.0
requests==2.25.1
async-exit-stack; python_version < '3.7'
async-generator; python_version < '3.7'
================================================
FILE: tortoise_config.py
================================================
import ssl
from config import common
# TODO: Yet to find a fix for this
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
database_uri = common.config.database_uri
tortoise_config = {
"connections": {
"default": {
"engine": "tortoise.backends.asyncpg",
"credentials": {
"database": database_uri.path[1:],
"host": database_uri.host,
"password": database_uri.password,
"port": database_uri.port or 5432,
"user": database_uri.user,
"ssl": ctx if common.config.no_ssl else None,
},
}
},
"apps": {
"main": {"models": ["models", "aerich.models"], "default_connection": "default"}
},
}
================================================
FILE: utils/db_backup.py
================================================
import asyncio
import pickle
from datetime import datetime
import asyncpg
from config.common import config
async def backup():
conn = await asyncpg.connect(str(config.database_uri))
tables = ("users", "thanks", "guilds", "jokes")
data = {
field: [dict(rec) for rec in await conn.fetch("SELECT * FROM {}".format(field))]
for field in tables
}
return data
def main():
data = asyncio.get_event_loop().run_until_complete(backup())
filename = "backup-{:%d-%m-%y-%H:%M}.pickle".format(datetime.now())
with open(filename, "wb") as f:
pickle.dump(data, f)
if __name__ == "__main__":
main()
================================================
FILE: utils/embed.py
================================================
import datetime
import yaml
from discord import Color, Embed, File
def build_embed(embed_data, add_timestamp=False):
embed = Embed(
title=embed_data.get("title"),
description=embed_data.get("description"),
color=embed_data.get("color", Color.green()),
)
if "thumbnail" in embed_data:
embed.set_thumbnail(url=embed_data["thumbnail"])
if "image" in embed_data:
embed.set_image(url=embed_data["image"])
if "author" in embed_data:
embed.set_author(**embed_data["author"])
if "footer" in embed_data:
embed.set_footer(**embed_data["footer"])
if add_timestamp or embed_data.get("add_timestamp", False):
embed.timestamp = datetime.datetime.utcnow()
for f in embed_data.get("fields", []):
f.setdefault("inline", False)
embed.add_field(**f)
return embed
def bot_type_converter(data, add_timestamp=False):
text = data.get("text")
embed_data = data.get("embed")
file_names = data.get("files", [])
embed = None
if embed_data:
embed = build_embed(embed_data)
return text, embed, [File(fn) for fn in file_names]
def webhook_type_converter(data, add_timestamp=False):
messages_data = data
outputs = []
for message_data in messages_data.get("messages", []):
embeds_data = message_data.get("embeds", [])
embeds = [build_embed(embed_data) for embed_data in embeds_data]
outputs.append(
(
message_data.get("text"),
embeds,
[File(fn) for fn in message_data.get("files", [])] or None,
)
)
return outputs, messages_data.get("username"), messages_data.get("avatar_url")
def yaml_file_to_message(filename: str, **kwargs):
with open(filename) as f:
data = yaml.load(f, yaml.Loader)
if data["type"] == "bot":
return bot_type_converter(data, **kwargs)
if data["type"] == "webhook":
return webhook_type_converter(data, **kwargs)
raise RuntimeError("Incompatible type")
================================================
FILE: utils/webhook.py
================================================
from typing import Optional
from discord import RequestsWebhookAdapter, Webhook
from .embed import yaml_file_to_message
def make_webhook(url: str, adapter=RequestsWebhookAdapter()):
return Webhook.from_url(url, adapter=adapter)
def send_from_yaml(
*, webhook: Webhook, filename: str, text: Optional[str] = None, **kwargs
):
messages, username, avatar_url = yaml_file_to_message(filename)
kwargs.setdefault("username", username)
kwargs.setdefault("avatar_url", avatar_url)
return [
webhook.send(message[0] or text, embeds=message[1], files=message[2], **kwargs)
for message in messages
]
================================================
FILE: vercel.json
================================================
{
"functions": {
"api/main.py": { "maxDuration": 10 }
},
"routes": [{ "src": "/(.*)", "dest": "/api/main" }]
}