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

• I'd like to contribute

You may help by adding features to Tech Struck or fix bugs in the code. Here's how:

  1. Fork the repository
  2. Clone your fork: git clone https://github.com/your-username/Tech-Struck.git
  3. Create your feature branch: git checkout -b my-new-feature
  4. Commit your changes: git commit -am 'uwu new feature'
  5. Push to the branch: git push origin my-new-feature
  6. Submit a pull request

• I found a bug!

Click me to join Tech Struck

================================================ 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!

Error

{{detail}}

Go Back
================================================ 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" }] }