aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/PULL_REQUEST_TEMPLATE/pull_request_template.md21
-rw-r--r--.travis.yml17
-rw-r--r--CONTRIBUTING.md2
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock44
-rw-r--r--README.md20
-rw-r--r--azure-pipelines.yml68
-rw-r--r--bot/__init__.py4
-rw-r--r--bot/__main__.py24
-rw-r--r--bot/cogs/__init__.py0
-rw-r--r--bot/cogs/evergreen/__init__.py0
-rw-r--r--bot/cogs/evergreen/uptime.py33
-rw-r--r--bot/cogs/hacktober/__init__.py0
-rw-r--r--bot/cogs/hacktober/hacktoberstats.py326
-rw-r--r--bot/cogs/hacktober/halloween_facts.py75
-rw-r--r--bot/cogs/hacktober/halloweenify.py (renamed from bot/cogs/halloweenify.py)0
-rw-r--r--bot/cogs/hacktober/movie.py (renamed from bot/cogs/movie.py)0
-rw-r--r--bot/cogs/hacktober/spookyreact.py69
-rw-r--r--bot/cogs/hacktoberstats.py193
-rw-r--r--bot/cogs/spookyreact.py31
-rw-r--r--bot/cogs/template.py4
-rw-r--r--bot/resources/halloween_facts.json14
-rwxr-xr-xdocker/build.sh18
-rw-r--r--docker/docker-compose.yml6
-rwxr-xr-xscripts/deploy-azure.sh22
25 files changed, 690 insertions, 302 deletions
diff --git a/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
new file mode 100644
index 00000000..c8b15b09
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md
@@ -0,0 +1,21 @@
+---
+name: Pull Request
+about: A simple pull request template.
+issue: Issue # if applicable
+
+---
+
+Provide a simple description of what the PR achieves.
+
+## Pull Request Details
+
+Please ensure your PR fulfills the following criteria:
+
+- [ ] Have you joined the [PythonDiscord Community](https://pythondiscord.com/invite)?
+- [ ] Were your changes made in a Pipenv environment?
+- [ ] Does flake8 pass (```pipenv run lint```)
+
+
+## Additional information
+
+Provide any additional information or clarifications here. \ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index ae1d7653..00000000
--- a/.travis.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-language: python
-
-python:
- - "3.7-dev"
-
-sudo: required
-
-services:
- - docker
-
-install:
- - pip install flake8 pipenv salt-pepper
- - pipenv install --deploy
-
-script:
- - pipenv run lint
- - bash docker/build.sh
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 35c951d1..5c16e605 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -11,4 +11,4 @@ This project is a community project for the Python Discord community over at htt
# Installation & Dependency Management
-Hacktoberbot utilizes [Pipenv](https://pipenv.readthedocs.io/en/latest/) for installation and dependency management. For users unfamiliar with the Pipenv workflow, a [Pipenv Primer](https://github.com/discord-python/hacktoberbot/wiki/Hacktoberbot-and-Pipenv) is provided in Hactoberbot's Wiki.
+Hacktoberbot utilizes [Pipenv](https://pipenv.readthedocs.io/en/latest/) for installation and dependency management. For users unfamiliar with the Pipenv workflow, a [Pipenv Primer](https://github.com/python-discord/seasonalbot/wiki/Hacktoberbot-and-Pipenv) is provided in Seasonalbot's Wiki.
diff --git a/Pipfile b/Pipfile
index a702616f..6a254005 100644
--- a/Pipfile
+++ b/Pipfile
@@ -5,6 +5,7 @@ name = "pypi"
[packages]
"discord.py" = {ref = "rewrite", git = "https://github.com/Rapptz/discord.py"}
+arrow = "*"
[dev-packages]
"flake8" = "*"
diff --git a/Pipfile.lock b/Pipfile.lock
index 8d330b8d..5e3d2a77 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "4ea08ef13815b0819e975097df6bb77d74d756524db2ad106ec93873fd1594de"
+ "sha256": "9d82195bc8a856a7c9f00057caf3af26c64d3d1daf09f63c6de55ad63f47f470"
},
"pipfile-spec": 6,
"requires": {
@@ -16,9 +16,30 @@
]
},
"default": {
+ "arrow": {
+ "hashes": [
+ "sha256:a558d3b7b6ce7ffc74206a86c147052de23d3d4ef0e17c210dd478c53575c4cd"
+ ],
+ "index": "pypi",
+ "version": "==0.12.1"
+ },
"discord.py": {
"git": "https://github.com/Rapptz/discord.py",
- "ref": "1da696258095d5c1171a1cdbe75f56c535c6683e"
+ "ref": "rewrite"
+ },
+ "python-dateutil": {
+ "hashes": [
+ "sha256:063df5763652e21de43de7d9e00ccf239f953a832941e37be541614732cdfc93",
+ "sha256:88f9287c0174266bb0d8cedd395cfba9c58e87e5ad86b2ce58859bc11be3cf02"
+ ],
+ "version": "==2.7.5"
+ },
+ "six": {
+ "hashes": [
+ "sha256:70e8a77beed4562e7f14fe23a786b54f6296e34344c23bc42f07b15018ff98e9",
+ "sha256:832dc0e10feb1aa2c68dcc57dbb658f1c7e65b9b61af69048abc87a2db00a0eb"
+ ],
+ "version": "==1.11.0"
}
},
"develop": {
@@ -31,11 +52,11 @@
},
"flake8": {
"hashes": [
- "sha256:7253265f7abd8b313e3892944044a365e3f4ac3fcdcfb4298f55ee9ddf188ba0",
- "sha256:c7841163e2b576d435799169b78703ad6ac1bbb0f199994fc05f700b2a90ea37"
+ "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670",
+ "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2"
],
"index": "pypi",
- "version": "==3.5.0"
+ "version": "==3.6.0"
},
"flake8-bugbear": {
"hashes": [
@@ -85,17 +106,18 @@
},
"pycodestyle": {
"hashes": [
- "sha256:682256a5b318149ca0d2a9185d365d8864a768a28db66a84a2ea946bcc426766",
- "sha256:6c4245ade1edfad79c3446fadfc96b0de2759662dc29d07d80a6f27ad1ca6ba9"
+ "sha256:74abc4e221d393ea5ce1f129ea6903209940c1ecd29e002e8c6933c2b21026e0",
+ "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83",
+ "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a"
],
- "version": "==2.3.1"
+ "version": "==2.4.0"
},
"pyflakes": {
"hashes": [
- "sha256:08bd6a50edf8cffa9fa09a463063c425ecaaf10d1eb0335a7e8b1401aef89e6f",
- "sha256:8d616a382f243dbf19b54743f280b80198be0bca3a5396f1d2e1fca6223e8805"
+ "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49",
+ "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae"
],
- "version": "==1.6.0"
+ "version": "==2.0.0"
}
}
}
diff --git a/README.md b/README.md
index 27671cd4..b83421ff 100644
--- a/README.md
+++ b/README.md
@@ -1,18 +1,16 @@
-# hacktoberbot
-A community project for [Hacktoberfest 2018](https://hacktoberfest.digitalocean.com). A Discord bot primarily designed to help teach Python learners from the PythonDiscord community how to contribute to open source.
+# SeasonalBot
+
+[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Seasonal%20Bot%20(Mainline))](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=3)
+[![Discord](https://discordapp.com/api/guilds/267624335836053506/embed.png)](https://discord.gg/2B963hn)
+
+A community project initially started for [Hacktoberfest 2018](https://hacktoberfest.digitalocean.com). A Discord bot for the Python Discord community which changes with the seasons, and provides useful event features.
You can find our community by going to https://discord.gg/python
## Motivations
We know it can be difficult to get into the whole open source thing at first. To help out, we've decided to start a little community project during hacktober that you can all choose to contribute to if you're finding the event a little overwhelming, or if you're new to this whole thing and just want someone to hold your hand at first.
-## Commands
-
-!repository - Links to this repository
-
-### Git
-!Git - Links to getting started with Git page
-!Git.commit - An example commit command
+This later evolved into a bot that will be running all through the year, providing season-appropriate functionality and issues that beginners can work on.
## Getting started
@@ -83,9 +81,9 @@ You should get a menu like below:
![](https://i.imgur.com/xA5ga4C.png)
#### 6. Pull Requests (PR or PRs)
-Goto https://github.com/discord-python/hacktoberbot/pulls and the green New Pull Request button!
+Goto https://github.com/python-discord/seasonalbot/pulls and the green New Pull Request button!
![](https://i.imgur.com/fB4a2wQ.png)
-Now you should hit `Compare across forks` then on the third dropdown select your fork (it will be `your username/hacktoberbot`) then hit Create Pull request.
+Now you should hit `Compare across forks` then on the third dropdown select your fork (it will be `your username/seasonalbot`) then hit Create Pull request.
1[](https://i.imgur.com/N2X9A9v.png)
Now to tell other people what your PR does
1. Title - be concise and informative
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
new file mode 100644
index 00000000..2b43cc57
--- /dev/null
+++ b/azure-pipelines.yml
@@ -0,0 +1,68 @@
+# https://aka.ms/yaml
+
+variables:
+ LIBRARY_PATH: /lib:/usr/lib
+ PIPENV_HIDE_EMOJIS: 1
+ PIPENV_IGNORE_VIRTUALENVS: 1
+ PIPENV_NOSPIN: 1
+ PIPENV_VENV_IN_PROJECT: 1
+
+jobs:
+- job: test
+ displayName: 'Lint & Test'
+
+ pool:
+ vmImage: 'Ubuntu 16.04'
+
+ variables:
+ PIPENV_CACHE_DIR: ".cache/pipenv"
+ PIP_CACHE_DIR: ".cache/pip"
+
+ steps:
+ - script: sudo apt-get install build-essential curl docker libffi-dev libfreetype6-dev libxml2 libxml2-dev libxslt1-dev zlib1g zlib1g-dev
+ displayName: 'Install base dependencies'
+
+ - task: UsePythonVersion@0
+ displayName: 'Set Python version'
+ inputs:
+ versionSpec: '3.7.x'
+ addToPath: true
+
+ - script: sudo pip install pipenv
+ displayName: 'Install pipenv'
+
+ - script: pipenv install --dev --deploy --system
+ displayName: 'Install project using pipenv'
+
+ - script: python -m flake8
+ displayName: 'Run linter'
+
+- job: build
+ displayName: 'Build Containers'
+ dependsOn: 'test'
+
+ variables:
+ PIPENV_CACHE_DIR: ".cache/pipenv"
+ PIP_CACHE_DIR: ".cache/pip"
+
+ steps:
+ - task: Docker@1
+ displayName: 'Login: Docker Hub'
+
+ inputs:
+ containerregistrytype: 'Container Registry'
+ dockerRegistryEndpoint: 'DockerHub'
+ command: 'login'
+
+ - script: sudo apt-get install python3-setuptools
+ displayName: 'Install setuptools'
+
+ - script: sudo pip3 install salt-pepper
+ displayName: 'Install pepper'
+
+ - task: ShellScript@2
+ displayName: 'Build and deploy containers'
+
+ inputs:
+ scriptPath: scripts/deploy-azure.sh
+ args: '$(SALTAPI_TARGET) $(SALTAPI_USER) $(SALTAPI_PASS) $(SALTAPI_URL)'
diff --git a/bot/__init__.py b/bot/__init__.py
index c411deb6..6b3a2a6f 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,6 +2,10 @@ import logging.handlers
import os
from pathlib import Path
+import arrow
+
+# start datetime
+start_time = arrow.utcnow()
# set up logging
log_dir = Path("bot", "log")
diff --git a/bot/__main__.py b/bot/__main__.py
index ccd69b0b..a1484cbc 100644
--- a/bot/__main__.py
+++ b/bot/__main__.py
@@ -5,35 +5,29 @@ from traceback import format_exc
from discord.ext import commands
-HACKTOBERBOT_TOKEN = environ.get('HACKTOBERBOT_TOKEN')
+SEASONALBOT_TOKEN = environ.get('SEASONALBOT_TOKEN')
log = logging.getLogger()
-if HACKTOBERBOT_TOKEN:
- token_dl = len(HACKTOBERBOT_TOKEN) // 8
- log.info(f'Bot token loaded: {HACKTOBERBOT_TOKEN[:token_dl]}...{HACKTOBERBOT_TOKEN[-token_dl:]}')
+if SEASONALBOT_TOKEN:
+ token_dl = len(SEASONALBOT_TOKEN) // 8
+ log.info(f'Bot token loaded: {SEASONALBOT_TOKEN[:token_dl]}...{SEASONALBOT_TOKEN[-token_dl:]}')
else:
- log.error(f'Bot token not found: {HACKTOBERBOT_TOKEN}')
+ log.error(f'Bot token not found: {SEASONALBOT_TOKEN}')
ghost_unicode = "\N{GHOST}"
bot = commands.Bot(command_prefix=commands.when_mentioned_or(".", f"{ghost_unicode} ", ghost_unicode))
-log.info('Start loading extensions from ./bot/cogs/')
+log.info('Start loading extensions from ./bot/cogs/evergreen/')
if __name__ == '__main__':
# Scan for files in the /cogs/ directory and make a list of the file names.
- cogs = [file.stem for file in Path('bot', 'cogs').glob('*.py')]
+ cogs = [file.stem for file in Path('bot', 'cogs', 'evergreen').glob('*.py') if not file.stem.startswith("__")]
for extension in cogs:
try:
- bot.load_extension(f'bot.cogs.{extension}')
+ bot.load_extension(f'bot.cogs.evergreen.{extension}')
log.info(f'Successfully loaded extension: {extension}')
except Exception as e:
log.error(f'Failed to load extension {extension}: {repr(e)} {format_exc()}')
- # print(f'Failed to load extension {extension}.', file=stderr)
- # print_exc()
-log.info(f'Spooky Launch Sequence Initiated...')
-
-bot.run(HACKTOBERBOT_TOKEN)
-
-log.info(f'HackBot has been slain!')
+bot.run(SEASONALBOT_TOKEN)
diff --git a/bot/cogs/__init__.py b/bot/cogs/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/cogs/__init__.py
diff --git a/bot/cogs/evergreen/__init__.py b/bot/cogs/evergreen/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/cogs/evergreen/__init__.py
diff --git a/bot/cogs/evergreen/uptime.py b/bot/cogs/evergreen/uptime.py
new file mode 100644
index 00000000..ec4a3083
--- /dev/null
+++ b/bot/cogs/evergreen/uptime.py
@@ -0,0 +1,33 @@
+import arrow
+from dateutil.relativedelta import relativedelta
+from discord.ext import commands
+
+from bot import start_time
+
+
+class Uptime:
+ """
+ A cog for posting the bots uptime.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ @commands.command(name='uptime')
+ async def uptime(self, ctx):
+ """
+ Returns the uptime of the bot.
+ """
+ difference = relativedelta(start_time - arrow.utcnow())
+ uptime_string = start_time.shift(
+ seconds=-difference.seconds,
+ minutes=-difference.minutes,
+ hours=-difference.hours,
+ days=-difference.days
+ ).humanize()
+ await ctx.send(f"I started up {uptime_string}.")
+
+
+# Required in order to load the cog, use the class name in the add_cog function.
+def setup(bot):
+ bot.add_cog(Uptime(bot))
diff --git a/bot/cogs/hacktober/__init__.py b/bot/cogs/hacktober/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/cogs/hacktober/__init__.py
diff --git a/bot/cogs/hacktober/hacktoberstats.py b/bot/cogs/hacktober/hacktoberstats.py
new file mode 100644
index 00000000..0755503c
--- /dev/null
+++ b/bot/cogs/hacktober/hacktoberstats.py
@@ -0,0 +1,326 @@
+import json
+import logging
+import re
+import typing
+from collections import Counter
+from datetime import datetime
+from pathlib import Path
+
+import aiohttp
+import discord
+from discord.ext import commands
+
+
+class Stats:
+ def __init__(self, bot):
+ self.bot = bot
+ self.link_json = Path('./bot/resources', 'github_links.json')
+ self.linked_accounts = self.load_linked_users()
+
+ @commands.group(
+ name='stats',
+ aliases=('hacktoberstats', 'getstats', 'userstats'),
+ invoke_without_command=True
+ )
+ async def hacktoberstats_group(self, ctx: commands.Context, github_username: str = None):
+ """
+ If invoked without a subcommand or github_username, get the invoking user's stats if
+ they've linked their Discord name to GitHub using .stats link
+
+ If invoked with a github_username, get that user's contributions
+ """
+ if not github_username:
+ author_id, author_mention = Stats._author_mention_from_context(ctx)
+
+ if str(author_id) in self.linked_accounts.keys():
+ github_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"Getting stats for {author_id} linked GitHub account '{github_username}'")
+ else:
+ msg = (
+ f"{author_mention}, you have not linked a GitHub account\n\n"
+ f"You can link your GitHub account using:\n```{ctx.prefix}stats link github_username```\n"
+ f"Or query GitHub stats directly using:\n```{ctx.prefix}stats github_username```"
+ )
+ await ctx.send(msg)
+ return
+
+ await self.get_stats(ctx, github_username)
+
+ @hacktoberstats_group.command(name="link")
+ async def link_user(self, ctx: commands.Context, github_username: str = None):
+ """
+ Link the invoking user's Github github_username to their Discord ID
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ author_id, author_mention = Stats._author_mention_from_context(ctx)
+ if github_username:
+ if str(author_id) in self.linked_accounts.keys():
+ old_username = self.linked_accounts[author_id]["github_username"]
+ logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'")
+ else:
+ logging.info(f"{author_id} has added a github link to '{github_username}'")
+ await ctx.send(f"{author_mention}, your GitHub username has been added")
+
+ self.linked_accounts[author_id] = {
+ "github_username": github_username,
+ "date_added": datetime.now()
+ }
+
+ self.save_linked_users()
+ else:
+ logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username")
+ await ctx.send(f"{author_mention}, a GitHub username is required to link your account")
+
+ @hacktoberstats_group.command(name="unlink")
+ async def unlink_user(self, ctx: commands.Context):
+ """
+ Remove the invoking user's account link from the log
+ """
+ author_id, author_mention = Stats._author_mention_from_context(ctx)
+
+ stored_user = self.linked_accounts.pop(author_id, None)
+ if stored_user:
+ await ctx.send(f"{author_mention}, your GitHub profile has been unlinked")
+ logging.info(f"{author_id} has unlinked their GitHub account")
+ else:
+ await ctx.send(f"{author_mention}, you do not currently have a linked GitHub account")
+ logging.info(f"{author_id} tried to unlink their GitHub account but no account was linked")
+
+ self.save_linked_users()
+
+ def load_linked_users(self) -> typing.Dict:
+ """
+ Load list of linked users from local JSON file
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ if self.link_json.exists():
+ logging.info(f"Loading linked GitHub accounts from '{self.link_json}'")
+ with open(self.link_json, 'r') as fID:
+ linked_accounts = json.load(fID)
+
+ logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'")
+ return linked_accounts
+ else:
+ logging.info(f"Linked account log: '{self.link_json}' does not exist")
+ return {}
+
+ def save_linked_users(self):
+ """
+ Save list of linked users to local JSON file
+
+ Linked users are stored as a nested dict:
+ {
+ Discord_ID: {
+ "github_username": str
+ "date_added": datetime
+ }
+ }
+ """
+ logging.info(f"Saving linked_accounts to '{self.link_json}'")
+ with open(self.link_json, 'w') as fID:
+ json.dump(self.linked_accounts, fID, default=str)
+ logging.info(f"linked_accounts saved to '{self.link_json}'")
+
+ async def get_stats(self, ctx: commands.Context, github_username: str):
+ """
+ Query GitHub's API for PRs created by a GitHub user during the month of October that
+ do not have an 'invalid' tag
+
+ For example:
+ !getstats heavysaturn
+
+ If a valid github_username is provided, an embed is generated and posted to the channel
+
+ Otherwise, post a helpful error message
+ """
+ prs = await self.get_october_prs(github_username)
+
+ if prs:
+ stats_embed = self.build_embed(github_username, prs)
+ await ctx.send('Here are some stats!', embed=stats_embed)
+ else:
+ await ctx.send(f"No October GitHub contributions found for '{github_username}'")
+
+ def build_embed(self, github_username: str, prs: typing.List[dict]) -> discord.Embed:
+ """
+ Return a stats embed built from github_username's PRs
+ """
+ logging.info(f"Building Hacktoberfest embed for GitHub user: '{github_username}'")
+ pr_stats = self._summarize_prs(prs)
+
+ n = pr_stats['n_prs']
+ if n >= 5:
+ shirtstr = f"**{github_username} has earned a tshirt!**"
+ elif n == 4:
+ shirtstr = f"**{github_username} is 1 PR away from a tshirt!**"
+ else:
+ shirtstr = f"**{github_username} is {5 - n} PRs away from a tshirt!**"
+
+ stats_embed = discord.Embed(
+ title=f"{github_username}'s Hacktoberfest",
+ color=discord.Color(0x9c4af7),
+ description=f"{github_username} has made {n} {Stats._contributionator(n)} in October\n\n{shirtstr}\n\n"
+ )
+
+ stats_embed.set_thumbnail(url=f"https://www.github.com/{github_username}.png")
+ stats_embed.set_author(
+ name="Hacktoberfest",
+ url="https://hacktoberfest.digitalocean.com",
+ icon_url="https://hacktoberfest.digitalocean.com/assets/logo-hacktoberfest.png"
+ )
+ stats_embed.add_field(
+ name="Top 5 Repositories:",
+ value=self._build_top5str(pr_stats)
+ )
+
+ logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'")
+ return stats_embed
+
+ @staticmethod
+ async def get_october_prs(github_username: str) -> typing.List[dict]:
+ """
+ Query GitHub's API for PRs created during the month of October by github_username
+ that do not have an 'invalid' tag
+
+ If PRs are found, return a list of dicts with basic PR information
+
+ For each PR:
+ {
+ "repo_url": str
+ "repo_shortname": str (e.g. "python-discord/seasonalbot")
+ "created_at": datetime.datetime
+ }
+
+ Otherwise, return None
+ """
+ logging.info(f"Generating Hacktoberfest PR query for GitHub user: '{github_username}'")
+ base_url = "https://api.github.com/search/issues?q="
+ not_label = "invalid"
+ action_type = "pr"
+ is_query = f"public+author:{github_username}"
+ date_range = "2018-10-01..2018-10-31"
+ per_page = "300"
+ query_url = (
+ f"{base_url}"
+ f"-label:{not_label}"
+ f"+type:{action_type}"
+ f"+is:{is_query}"
+ f"+created:{date_range}"
+ f"&per_page={per_page}"
+ )
+
+ headers = {"user-agent": "Discord Python Hactoberbot"}
+ async with aiohttp.ClientSession() as session:
+ async with session.get(query_url, headers=headers) as resp:
+ jsonresp = await resp.json()
+
+ if "message" in jsonresp.keys():
+ # One of the parameters is invalid, short circuit for now
+ api_message = jsonresp["errors"][0]["message"]
+ logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")
+ return
+ else:
+ if jsonresp["total_count"] == 0:
+ # Short circuit if there aren't any PRs
+ logging.info(f"No Hacktoberfest PRs found for GitHub user: '{github_username}'")
+ return
+ else:
+ logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'")
+ outlist = []
+ for item in jsonresp["items"]:
+ shortname = Stats._get_shortname(item["repository_url"])
+ itemdict = {
+ "repo_url": f"https://www.github.com/{shortname}",
+ "repo_shortname": shortname,
+ "created_at": datetime.strptime(
+ item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
+ ),
+ }
+ outlist.append(itemdict)
+ return outlist
+
+ @staticmethod
+ def _get_shortname(in_url: str) -> str:
+ """
+ Extract shortname from https://api.github.com/repos/* URL
+
+ e.g. "https://api.github.com/repos/python-discord/seasonalbot"
+ |
+ V
+ "python-discord/seasonalbot"
+ """
+ exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)"
+ return re.findall(exp, in_url)[0]
+
+ @staticmethod
+ def _summarize_prs(prs: typing.List[dict]) -> typing.Dict:
+ """
+ Generate statistics from an input list of PR dictionaries, as output by get_october_prs
+
+ Return a dictionary containing:
+ {
+ "n_prs": int
+ "top5": [(repo_shortname, ncontributions), ...]
+ }
+ """
+ contributed_repos = [pr["repo_shortname"] for pr in prs]
+ return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
+
+ @staticmethod
+ def _build_top5str(stats: typing.List[tuple]) -> str:
+ """
+ Build a string from the Top 5 contributions that is compatible with a discord.Embed field
+
+ Top 5 contributions should be a list of tuples, as output in the stats dictionary by
+ _summarize_prs
+
+ String is of the form:
+ n contribution(s) to [shortname](url)
+ ...
+ """
+ baseURL = "https://www.github.com/"
+ contributionstrs = []
+ for repo in stats['top5']:
+ n = repo[1]
+ contributionstrs.append(f"{n} {Stats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})")
+
+ return "\n".join(contributionstrs)
+
+ @staticmethod
+ def _contributionator(n: int) -> str:
+ """
+ Return "contribution" or "contributions" based on the value of n
+ """
+ if n == 1:
+ return "contribution"
+ else:
+ return "contributions"
+
+ @staticmethod
+ def _author_mention_from_context(ctx: commands.Context) -> typing.Tuple:
+ """
+ Return stringified Message author ID and mentionable string from commands.Context
+ """
+ author_id = str(ctx.message.author.id)
+ author_mention = ctx.message.author.mention
+
+ return author_id, author_mention
+
+
+def setup(bot):
+ bot.add_cog(Stats(bot))
diff --git a/bot/cogs/hacktober/halloween_facts.py b/bot/cogs/hacktober/halloween_facts.py
new file mode 100644
index 00000000..e97c80d2
--- /dev/null
+++ b/bot/cogs/hacktober/halloween_facts.py
@@ -0,0 +1,75 @@
+import asyncio
+import json
+import random
+from datetime import timedelta
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+SPOOKY_EMOJIS = [
+ "\N{BAT}",
+ "\N{DERELICT HOUSE BUILDING}",
+ "\N{EXTRATERRESTRIAL ALIEN}",
+ "\N{GHOST}",
+ "\N{JACK-O-LANTERN}",
+ "\N{SKULL}",
+ "\N{SKULL AND CROSSBONES}",
+ "\N{SPIDER WEB}",
+]
+PUMPKIN_ORANGE = discord.Color(0xFF7518)
+HACKTOBERBOT_CHANNEL_ID = 498804484324196362
+INTERVAL = timedelta(hours=6).total_seconds()
+
+
+class HalloweenFacts:
+
+ def __init__(self, bot):
+ self.bot = bot
+ with open(Path("./bot/resources", "halloween_facts.json"), "r") as file:
+ self.halloween_facts = json.load(file)
+ self.channel = None
+ self.last_fact = None
+
+ async def on_ready(self):
+ self.channel = self.bot.get_channel(HACKTOBERBOT_CHANNEL_ID)
+ self.bot.loop.create_task(self._fact_publisher_task())
+
+ async def _fact_publisher_task(self):
+ """
+ A background task that runs forever, sending Halloween facts at random to the Discord channel with id equal to
+ HACKTOBERFEST_CHANNEL_ID every INTERVAL seconds.
+ """
+ facts = list(enumerate(self.halloween_facts))
+ while True:
+ # Avoid choosing each fact at random to reduce chances of facts being reposted soon.
+ random.shuffle(facts)
+ for index, fact in facts:
+ embed = self._build_embed(index, fact)
+ await self.channel.send("Your regular serving of random Halloween facts", embed=embed)
+ self.last_fact = (index, fact)
+ await asyncio.sleep(INTERVAL)
+
+ @commands.command(name="hallofact", aliases=["hallofacts"], brief="Get the most recent Halloween fact")
+ async def get_last_fact(self, ctx):
+ """
+ Reply with the most recent Halloween fact.
+ """
+ if ctx.channel != self.channel:
+ return
+ index, fact = self.last_fact
+ embed = self._build_embed(index, fact)
+ await ctx.send("Halloween fact recap", embed=embed)
+
+ @staticmethod
+ def _build_embed(index, fact):
+ """
+ Builds a Discord embed from the given fact and its index.
+ """
+ emoji = random.choice(SPOOKY_EMOJIS)
+ title = f"{emoji} Halloween Fact #{index + 1}"
+ return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE)
+
+
+def setup(bot):
+ bot.add_cog(HalloweenFacts(bot))
diff --git a/bot/cogs/halloweenify.py b/bot/cogs/hacktober/halloweenify.py
index a5fe45ef..a5fe45ef 100644
--- a/bot/cogs/halloweenify.py
+++ b/bot/cogs/hacktober/halloweenify.py
diff --git a/bot/cogs/movie.py b/bot/cogs/hacktober/movie.py
index 925f813f..925f813f 100644
--- a/bot/cogs/movie.py
+++ b/bot/cogs/hacktober/movie.py
diff --git a/bot/cogs/hacktober/spookyreact.py b/bot/cogs/hacktober/spookyreact.py
new file mode 100644
index 00000000..8e9e8db6
--- /dev/null
+++ b/bot/cogs/hacktober/spookyreact.py
@@ -0,0 +1,69 @@
+import logging
+import re
+
+import discord
+
+SPOOKY_TRIGGERS = {
+ 'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"),
+ 'skeleton': (r"\bskeleton\b", "\U0001F480"),
+ 'doot': (r"\bdo{2,}t\b", "\U0001F480"),
+ 'pumpkin': (r"\bpumpkin\b", "\U0001F383"),
+ 'halloween': (r"\bhalloween\b", "\U0001F383"),
+ 'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"),
+ 'danger': (r"\bdanger\b", "\U00002620")
+}
+
+
+class SpookyReact:
+
+ """
+ A cog that makes the bot react to message triggers.
+ """
+
+ def __init__(self, bot):
+ self.bot = bot
+
+ async def on_message(self, ctx: discord.Message):
+ """
+ A command to send the seasonalbot github project
+
+ Lines that begin with the bot's command prefix are ignored
+
+ Seasonalbot's own messages are ignored
+ """
+ for trigger in SPOOKY_TRIGGERS.keys():
+ trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower())
+ if trigger_test:
+ # Check message for bot replies and/or command invocations
+ # Short circuit if they're found, logging is handled in _short_circuit_check
+ if await self._short_circuit_check(ctx):
+ return
+ else:
+ await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1])
+ logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}")
+
+ async def _short_circuit_check(self, ctx: discord.Message) -> bool:
+ """
+ Short-circuit helper check.
+
+ Return True if:
+ * author is the bot
+ * prefix is not None
+ """
+ # Check for self reaction
+ if ctx.author == self.bot.user:
+ logging.info(f"Ignoring reactions on self message. Message ID: {ctx.id}")
+ return True
+
+ # Check for command invocation
+ # Because on_message doesn't give a full Context object, generate one first
+ tmp_ctx = await self.bot.get_context(ctx)
+ if tmp_ctx.prefix:
+ logging.info(f"Ignoring reactions on command invocation. Message ID: {ctx.id}")
+ return True
+
+ return False
+
+
+def setup(bot):
+ bot.add_cog(SpookyReact(bot))
diff --git a/bot/cogs/hacktoberstats.py b/bot/cogs/hacktoberstats.py
deleted file mode 100644
index ac81b887..00000000
--- a/bot/cogs/hacktoberstats.py
+++ /dev/null
@@ -1,193 +0,0 @@
-import re
-import typing
-from collections import Counter
-from datetime import datetime
-
-import aiohttp
-import discord
-from discord.ext import commands
-
-
-class Stats:
- def __init__(self, bot):
- self.bot = bot
-
- @commands.command(
- name="stats",
- aliases=["getstats", "userstats"],
- brief="Get a user's Hacktoberfest contribution stats",
- )
- async def get_stats(self, ctx, username: str):
- """
- Query GitHub's API for PRs created by a GitHub user during the month of October that
- do not have an 'invalid' tag
-
- For example:
- !getstats heavysaturn
-
- If a valid username is provided, an embed is generated and posted to the channel
-
- Otherwise, post a helpful error message
-
- The first input argument is treated as the username, any additional inputs are discarded
- """
- prs = await self.get_october_prs(username)
-
- if prs:
- stats_embed = self.build_embed(username, prs)
- await ctx.send('Here are some stats!', embed=stats_embed)
- else:
- await ctx.send(f"No October GitHub contributions found for '{username}'")
-
- def build_embed(self, username: str, prs: typing.List[dict]) -> discord.Embed:
- """
- Return a stats embed built from username's PRs
- """
- pr_stats = self._summarize_prs(prs)
-
- n = pr_stats['n_prs']
- if n >= 5:
- shirtstr = f"**{username} has earned a tshirt!**"
- elif n == 4:
- shirtstr = f"**{username} is 1 PR away from a tshirt!**"
- else:
- shirtstr = f"**{username} is {5 - n} PRs away from a tshirt!**"
-
- stats_embed = discord.Embed(
- title=f"{username}'s Hacktoberfest",
- color=discord.Color(0x9c4af7),
- description=f"{username} has made {n} {Stats._contributionator(n)} in October\n\n{shirtstr}\n\n"
- )
-
- stats_embed.set_thumbnail(url=f"https://www.github.com/{username}.png")
- stats_embed.set_author(
- name="Hacktoberfest",
- url="https://hacktoberfest.digitalocean.com",
- icon_url="https://hacktoberfest.digitalocean.com/assets/logo-hacktoberfest.png"
- )
- stats_embed.add_field(
- name="Top 5 Repositories:",
- value=self._build_top5str(pr_stats)
- )
-
- return stats_embed
-
- @staticmethod
- async def get_october_prs(username: str) -> typing.List[dict]:
- """
- Query GitHub's API for PRs created during the month of October by username that do
- not have an 'invalid' tag
-
- If PRs are found, return a list of dicts with basic PR information
-
- For each PR:
- {
- "repo_url": str
- "repo_shortname": str (e.g. "discord-python/hacktoberbot")
- "created_at": datetime.datetime
- }
-
- Otherwise, return None
- """
- base_url = "https://api.github.com/search/issues?q="
- not_label = "invalid"
- action_type = "pr"
- is_query = f"public+author:{username}"
- date_range = "2018-10-01..2018-10-31"
- per_page = "300"
- query_url = (
- f"{base_url}"
- f"-label:{not_label}"
- f"+type:{action_type}"
- f"+is:{is_query}"
- f"+created:{date_range}"
- f"&per_page={per_page}"
- )
-
- headers = {"user-agent": "Discord Python Hactoberbot"}
- async with aiohttp.ClientSession() as session:
- async with session.get(query_url, headers=headers) as resp:
- jsonresp = await resp.json()
-
- if "message" in jsonresp.keys():
- # One of the parameters is invalid, short circuit for now
- # In the future, log: jsonresp["errors"][0]["message"]
- return
- else:
- if jsonresp["total_count"] == 0:
- # Short circuit if there aren't any PRs
- return
- else:
- outlist = []
- for item in jsonresp["items"]:
- shortname = Stats._get_shortname(item["repository_url"])
- itemdict = {
- "repo_url": f"https://www.github.com/{shortname}",
- "repo_shortname": shortname,
- "created_at": datetime.strptime(
- item["created_at"], r"%Y-%m-%dT%H:%M:%SZ"
- ),
- }
- outlist.append(itemdict)
- return outlist
-
- @staticmethod
- def _get_shortname(in_url: str) -> str:
- """
- Extract shortname from https://api.github.com/repos/* URL
-
- e.g. "https://api.github.com/repos/discord-python/hacktoberbot"
- |
- V
- "discord-python/hacktoberbot"
- """
- exp = r"https?:\/\/api.github.com\/repos\/([/\-\w]+)"
- return re.findall(exp, in_url)[0]
-
- @staticmethod
- def _summarize_prs(prs: typing.List[dict]) -> typing.Dict:
- """
- Generate statistics from an input list of PR dictionaries, as output by get_october_prs
-
- Return a dictionary containing:
- {
- "n_prs": int
- "top5": [(repo_shortname, ncontributions), ...]
- }
- """
- contributed_repos = [pr["repo_shortname"] for pr in prs]
- return {"n_prs": len(prs), "top5": Counter(contributed_repos).most_common(5)}
-
- @staticmethod
- def _build_top5str(stats: typing.List[tuple]) -> str:
- """
- Build a string from the Top 5 contributions that is compatible with a discord.Embed field
-
- Top 5 contributions should be a list of tuples, as output in the stats dictionary by
- _summarize_prs
-
- String is of the form:
- n contribution(s) to [shortname](url)
- ...
- """
- baseURL = "https://www.github.com/"
- contributionstrs = []
- for repo in stats['top5']:
- n = repo[1]
- contributionstrs.append(f"{n} {Stats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})")
-
- return "\n".join(contributionstrs)
-
- @staticmethod
- def _contributionator(n: int) -> str:
- """
- Return "contribution" or "contributions" based on the value of n
- """
- if n == 1:
- return "contribution"
- else:
- return "contributions"
-
-
-def setup(bot):
- bot.add_cog(Stats(bot))
diff --git a/bot/cogs/spookyreact.py b/bot/cogs/spookyreact.py
deleted file mode 100644
index 2652a60e..00000000
--- a/bot/cogs/spookyreact.py
+++ /dev/null
@@ -1,31 +0,0 @@
-SPOOKY_TRIGGERS = {
- 'spooky': "\U0001F47B",
- 'skeleton': "\U0001F480",
- 'doot': "\U0001F480",
- 'pumpkin': "\U0001F383",
- 'halloween': "\U0001F383",
- 'jack-o-lantern': "\U0001F383",
- 'danger': "\U00002620"
-}
-
-
-class SpookyReact:
-
- """
- A cog that makes the bot react to message triggers.
- """
-
- def __init__(self, bot):
- self.bot = bot
-
- async def on_message(self, ctx):
- """
- A command to send the hacktoberbot github project
- """
- for trigger in SPOOKY_TRIGGERS.keys():
- if trigger in ctx.content.lower():
- await ctx.add_reaction(SPOOKY_TRIGGERS[trigger])
-
-
-def setup(bot):
- bot.add_cog(SpookyReact(bot))
diff --git a/bot/cogs/template.py b/bot/cogs/template.py
index aa01432c..e1b646e3 100644
--- a/bot/cogs/template.py
+++ b/bot/cogs/template.py
@@ -13,9 +13,9 @@ class Template:
@commands.command(name='repo', aliases=['repository', 'project'], brief='A link to the repository of this bot.')
async def repository(self, ctx):
"""
- A command to send the hacktoberbot github project
+ A command to send the seasonalbot github project
"""
- await ctx.send('https://github.com/discord-python/hacktoberbot')
+ await ctx.send('https://github.com/python-discord/seasonalbot')
@commands.group(name='git', invoke_without_command=True, brief="A link to resources for learning Git")
async def github(self, ctx):
diff --git a/bot/resources/halloween_facts.json b/bot/resources/halloween_facts.json
new file mode 100644
index 00000000..fc6fa85f
--- /dev/null
+++ b/bot/resources/halloween_facts.json
@@ -0,0 +1,14 @@
+[
+ "Halloween or Hallowe'en is also known as Allhalloween, All Hallows' Eve and All Saints' Eve.",
+ "It is widely believed that many Halloween traditions originated from ancient Celtic harvest festivals, particularly the Gaelic festival Samhain, which means \"summer's end\".",
+ "It is believed that the custom of making jack-o'-lanterns at Halloween began in Ireland. In the 19th century, turnips or mangel wurzels, hollowed out to act as lanterns and often carved with grotesque faces, were used at Halloween in parts of Ireland and the Scottish Highlands.",
+ "Halloween is the second highest grossing commercial holiday after Christmas.",
+ "The word \"witch\" comes from the Old English *wicce*, meaning \"wise woman\". In fact, *wiccan* were highly respected people at one time. According to popular belief, witches held one of their two main meetings, or *sabbats*, on Halloween night.",
+ "Samhainophobia is the fear of Halloween.",
+ "The owl is a popular Halloween image. In Medieval Europe, owls were thought to be witches, and to hear an owl's call meant someone was about to die.",
+ "An Irish legend about jack-o'-lanterns goes as follows:\n*On route home after a night's drinking, Jack encounters the Devil and tricks him into climbing a tree. A quick-thinking Jack etches the sign of the cross into the bark, thus trapping the Devil. Jack strikes a bargain that Satan can never claim his soul. After a life of sin, drink, and mendacity, Jack is refused entry to heaven when he dies. Keeping his promise, the Devil refuses to let Jack into hell and throws a live coal straight from the fires of hell at him. It was a cold night, so Jack places the coal in a hollowed out turnip to stop it from going out, since which time Jack and his lantern have been roaming looking for a place to rest.*",
+ "Trick-or-treating evolved from the ancient Celtic tradition of putting out treats and food to placate spirits who roamed the streets at Samhain, a sacred festival that marked the end of the Celtic calendar year.",
+ "Comedian John Evans once quipped: \"What do you get if you divide the circumference of a jack-o’-lantern by its diameter? Pumpkin π.\"",
+ "Dressing up as ghouls and other spooks originated from the ancient Celtic tradition of townspeople disguising themselves as demons and spirits. The Celts believed that disguising themselves this way would allow them to escape the notice of the real spirits wandering the streets during Samhain.",
+ "In Western history, black cats have typically been looked upon as a symbol of evil omens, specifically being suspected of being the familiars of witches, or actually shape-shifting witches themselves. They are, however, too cute to be evil."
+]
diff --git a/docker/build.sh b/docker/build.sh
deleted file mode 100755
index 153fbccc..00000000
--- a/docker/build.sh
+++ /dev/null
@@ -1,18 +0,0 @@
-#!/bin/bash
-
-# Build and deploy on master branch
-if [[ $TRAVIS_BRANCH == 'master' && $TRAVIS_PULL_REQUEST == 'false' ]]; then
- echo "Connecting to docker hub"
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
-
- echo "Building image"
- docker build -t pythondiscord/hacktober-bot:latest -f docker/Dockerfile .
-
- echo "Pushing image"
- docker push pythondiscord/hacktober-bot:latest
-
- echo "Deploying on server"
- pepper ${SALTAPI_TARGET} state.apply docker/hacktoberbot --out=no_out --non-interactive &> /dev/null
-else
- echo "Skipping deploy"
-fi
diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml
index 0a802ddc..de1f4cf2 100644
--- a/docker/docker-compose.yml
+++ b/docker/docker-compose.yml
@@ -1,11 +1,11 @@
version: "3"
services:
dumbo:
- image: pythondiscord/hacktober-bot:latest
- container_name: hacktoberbot
+ image: pythondiscord/seasonalbot:latest
+ container_name: seasonalbot
restart: always
environment:
- - HACKTOBERBOT_TOKEN
+ - SEASONALBOT_TOKEN
diff --git a/scripts/deploy-azure.sh b/scripts/deploy-azure.sh
new file mode 100755
index 00000000..abefbf6b
--- /dev/null
+++ b/scripts/deploy-azure.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+
+cd ..
+
+export SALTAPI_USER=$2
+export SALTAPI_PASS=$3
+export SALTAPI_URL=$4
+export SALTAPI_EAUTH=pam
+
+# Build and deploy on master branch, only if not a pull request
+if [[ ($BUILD_SOURCEBRANCHNAME == 'master') && ($SYSTEM_PULLREQUEST_PULLREQUESTID == '') ]]; then
+ echo "Building image"
+ docker build -t pythondiscord/seasonalbot:latest -f docker/Dockerfile .
+
+ echo "Pushing image to Docker Hub"
+ docker push pythondiscord/seasonalbot:latest
+
+ echo "Deploying on server"
+ pepper $1 state.apply docker/seasonalbot --out=no_out --non-interactive &> /dev/null
+else
+ echo "Skipping deploy"
+fi