Skip to content
This repository has been archived by the owner on Jul 4, 2020. It is now read-only.

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
LostLuma committed Dec 6, 2017
0 parents commit 5309463
Show file tree
Hide file tree
Showing 12 changed files with 421 additions and 0 deletions.
8 changes: 8 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FROM python:latest

WORKDIR /app
ADD . /app

RUN pip install -r requirements.txt

CMD ["python", "run.py"]
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2017 Blob Emoji

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.
24 changes: 24 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
=============
BlobGivingBot
=============

.. image:: https://img.shields.io/badge/python-3.6-blue.svg
:target: https://www.python.org/

.. image:: https://img.shields.io/badge/License-MIT-blue.svg
:target: https://github.com/BlobEmoji/blobgivingbot/blob/master/LICENSE

BlobGivingBot is a simple Discord Bot to hold giveaways in a single Discord Server.

-------------------
Using BlobGivingBot
-------------------

BlobGivingBot is meant for use in the `Blob Emoji Discord Server <https://discord.gg/xTf9URq>`_,
there's no public bot instance you can simply invite.

It is however really simple to run your own instance for your own Discord Servers:

- Simply rename ``config_example.py`` to ``config.py`` and fill it out with your credentials.

- Invoke commands in any specified command channels using your prefix.
3 changes: 3 additions & 0 deletions blobgivingbot/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-

from .bot import BlobGivingBot
37 changes: 37 additions & 0 deletions blobgivingbot/bot.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-

import discord
from discord.ext import commands

import config

from .utils import Timer


class BlobGivingBot(commands.AutoShardedBot):
def __init__(self):
super().__init__(
command_prefix=config.prefix,
owner_id=config.owner_id,
)

self.add_command(self.rtt)
self.load_extension('blobgivingbot.giveaways')

async def on_message(self, message: discord.Message):
if message.author.bot:
return

if message.channel.id not in config.command_channels:
return

await self.process_commands(message)

@commands.command(aliases=['ping', 'p'])
async def rtt(self, ctx: commands.Context):
"""Shows the bots HTTP and websocket latency to Discord."""
with Timer() as rtt:
msg = await ctx.send('...')

ws = self.latency * 1000
await msg.edit(content=f'Pong! rtt: {rtt}, ws: {ws:.3f}ms')
92 changes: 92 additions & 0 deletions blobgivingbot/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-

# copied from https://github.com/Rapptz/RoboDanny

import json
import os
import uuid
import asyncio


def _create_encoder(cls):
def _default(self, o):
if isinstance(o, cls):
return o.to_json()
return super().default(o)

return type('_Encoder', (json.JSONEncoder,), { 'default': _default })


class Config:
"""The "database" object. Internally based on ``json``."""

def __init__(self, name, **options):
self.name = f'{name}.json'
self.object_hook = options.pop('object_hook', None)
self.encoder = options.pop('encoder', None)

self._db = None

try:
hook = options.pop('hook')
except KeyError:
pass
else:
self.object_hook = hook.from_json
self.encoder = _create_encoder(hook)

self.loop = options.pop('loop', asyncio.get_event_loop())
self.lock = asyncio.Lock()
if options.pop('load_later', False):
self.loop.create_task(self.load())
else:
self.load_from_file()

def load_from_file(self):
try:
with open(self.name, 'r') as f:
self._db = json.load(f, object_hook=self.object_hook)
except FileNotFoundError:
self._db = {}

async def load(self):
with await self.lock:
await self.loop.run_in_executor(None, self.load_from_file)

def _dump(self):
temp = '%s-%s.tmp' % (uuid.uuid4(), self.name)
with open(temp, 'w', encoding='utf-8') as tmp:
json.dump(self._db.copy(), tmp, ensure_ascii=True, cls=self.encoder, separators=(',', ':'))

# atomically move the file
os.replace(temp, self.name)

async def save(self):
with await self.lock:
await self.loop.run_in_executor(None, self._dump)

def get(self, key, *args):
"""Retrieves a config entry."""
return self._db.get(str(key), *args)

async def put(self, key, value, *args):
"""Edits a config entry."""
self._db[str(key)] = value
await self.save()

async def remove(self, key):
"""Removes a config entry."""
del self._db[str(key)]
await self.save()

def __contains__(self, item):
return str(item) in self._db

def __getitem__(self, item):
return self._db[str(item)]

def __len__(self):
return len(self._db)

def all(self):
return self._db
148 changes: 148 additions & 0 deletions blobgivingbot/giveaways.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# -*- coding: utf-8 -*-

import asyncio
import datetime
import logging
import random

import discord
from discord.ext import commands

from .bot import BlobGivingBot
from .config import Config
import config


log = logging.getLogger(__name__)


class NoWinnerFound(Exception):
pass


class Giveaways:
"""Simple giveaway management commands."""

def __init__(self, bot: BlobGivingBot):
self.bot = bot

self.config = Config('giveaways')
self._giveaway_task = bot.loop.create_task(self.giveaway_loop())

def __unload(self):
self._giveaway_task.cancel()

@property
def emoji(self):
return self.bot.get_emoji(config.giveaway_emoji)

@property
def channel(self):
return self.bot.get_channel(config.giveaway_channel)

@commands.command()
async def giveaway(self, ctx: commands.Context, *, description: str):
"""Start a new Giveaway."""
ends_at = ctx.message.created_at + config.giveaway_duration

embed = discord.Embed(
title=description,
description=f'React with {self.emoji} to win!',
color=discord.Color.magenta(),
timestamp=ends_at,
)
embed.set_footer(text='Ends at')

msg = await self.channel.send(embed=embed)
await msg.add_reaction(self.emoji)

await self.config.put(ends_at.timestamp(), msg.id)
await ctx.send('Giveaway started!')

if self._giveaway_task.done():
self._giveaway_task = self.bot.loop.create_task(self.giveaway_loop())

@commands.command()
async def reroll(self, ctx: commands.Context, *, message_id: int):
"""Reroll a giveaway based on a message/giveaway ID."""
try:
message = await self.channel.get_message(message_id)
except discord.NotFound:
return await ctx.send(f'Couldn\'t find message with ID {message_id} in the giveaway channel!')

# don't allow rerolling a giveaway which is running
if message.created_at > ctx.message.created_at - config.giveaway_duration:
return await ctx.send('This giveaway is still running! Rerolling can only be done after it has ended.')

try:
winner = await self.roll_user(message)
except NoWinnerFound as e:
await ctx.send(e)
else:
embed = message.embeds[0]
giveaway_desc = embed.title

embed.description = f'{winner.mention} is the new winner!'
await message.edit(embed=embed)

await ctx.send(f'Rerolled! {winner.mention} is the new winner for **{giveaway_desc}**!')

async def giveaway_loop(self):
# channels / emoji aren't loaded before being ready
await self.bot.wait_until_ready()

# run until Config.__len__ returns 0
while self.config:
# the keys of the config are unix timestamps so we can easily sort them
oldest = min(self.config.all())

until_end = float(oldest) - datetime.datetime.utcnow().timestamp()
await asyncio.sleep(until_end)

message_id = self.config.get(oldest)

try:
message = await self.channel.get_message(message_id)
except discord.NotFound:
# giveaways might be removed for moderation purposes, we don't want to post anything in this case
log.warning(f'Couldn\'t find message associated with giveaway {message_id}, skipping')
await self.config.remove(oldest)
continue

# prepare editing the embed
embed = message.embeds[0]
embed.set_footer(text='Ended at')

# the name / title of this giveaway
giveaway_desc = embed.title

try:
winner = await self.roll_user(message)
except NoWinnerFound:
embed.description = f'No one won this giveaway!'
await message.edit(embed=embed)

await self.channel.send(f'No winner found for **{giveaway_desc}**!')
else:
embed.description = f'{winner.mention} won this giveaway!'
await message.edit(embed=embed)

await self.channel.send(f'Congratulations {winner.mention}! You won **{giveaway_desc}**!')
finally:
await self.config.remove(oldest)

async def roll_user(self, message: discord.Message) -> discord.Member:
try:
reaction = next(x for x in message.reactions if getattr(x.emoji, 'id', None) == self.emoji.id)
except StopIteration: # if a moderator deleted the emoji for some reason
raise NoWinnerFound('Couldn\'t find giveaway emoji on specified message')

users = await reaction.users().filter(lambda x: not x.bot).flatten()
if not users:
raise NoWinnerFound('No human reacted with the giveaway emoji on this message')
else:
return random.choice(users)


def setup(bot: BlobGivingBot):
bot.add_cog(Giveaways(bot))
32 changes: 32 additions & 0 deletions blobgivingbot/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-

import time


class Timer:
"""Context manager to measure how long the indented block takes to run."""

def __init__(self):
self.start: float = None
self.end: float = None

def __enter__(self):
self.start = time.perf_counter()
return self

async def __aenter__(self):
return self.__enter__()

def __exit__(self, exc_type, exc_val, exc_tb):
self.end = time.perf_counter()

async def __aexit__(self, exc_type, exc_val, exc_tb):
return self.__exit__(exc_type, exc_val, exc_tb)

def __str__(self):
return f'{self.duration:.3f}ms'

@property
def duration(self):
"""Duration in ms."""
return (self.end - self.start) * 1000
8 changes: 8 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
version: '2'

services:

bot:
build: .
volumes:
- ./:/app
16 changes: 16 additions & 0 deletions example_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-

import datetime

token = 'beep.boop.secret'

prefix = '$'
owner_id = 69198249432449024

giveaway_channel = 384121527437885440
giveaway_emoji = 384394753749417996

giveaway_duration = datetime.timedelta(days=1)

# channels in which the bot responds to commands, all others are ignored
command_channels = {384121527437885440}
Loading

0 comments on commit 5309463

Please sign in to comment.