reset the hitomi project, remove django, rebuild deps

This commit is contained in:
Brian S. Stephan 2019-06-22 10:39:56 -05:00
parent 8be039da80
commit f9baa76a24
35 changed files with 132 additions and 1121 deletions

4
.gitignore vendored
View File

@ -1,7 +1,3 @@
*.log
*.pyc
.idea/
db.sqlite3
localsettings.py
parser.out
parsetab.py

View File

@ -4,7 +4,7 @@
### Overview
dr.botzo is a Discord bot written in Python, using Django as a backend and the
hitomi is a Discord bot written in Python, using dr.botzo as a backend and the
discord.py library to handle most of the protocol stuff. It is mostly a fun
project for a couple of us to hack around on, but you may find it useful.

20
bot.py Normal file
View File

@ -0,0 +1,20 @@
"""Start the Discord bot and connect to Discord."""
import importlib
import logging
import hitomi.config as config
from hitomi import bot
logger = logging.getLogger(__name__)
def run_bot():
"""Initialize plugins and begin the bot."""
for plugin in config.BOT_PLUGINS:
importlib.import_module(plugin)
bot.run(config.BOT_TOKEN)
if __name__ == '__main__':
run_bot()

View File

@ -1,18 +0,0 @@
"""Discord bot Hitomi and its library functions."""
from discord.ext import commands
from django.conf import settings
BOT_DESCRIPTION = "A simple Discord bot."
class Hitomi(commands.Bot):
"""Extend the discord.py Bot, to add more cool stuff."""
def __init__(self, *args, **kwargs):
"""Initialize bot, and cool stuff."""
super(Hitomi, self).__init__(*args, **kwargs)
self.on_message_handlers = []
hitomi = Hitomi(command_prefix=settings.DISCORD_BOT_COMMAND_PREFIX, description=BOT_DESCRIPTION,
max_messages=settings.DISCORD_BOT_MAX_MESSAGES)

View File

@ -1,22 +0,0 @@
"""Create a simple API to the bot."""
from aiohttp import web
async def index(request):
"""Provide a basic index to the API."""
return web.Response(text="hitomi")
async def greet(request):
"""Provide a simple greeting."""
name = request.match_info.get('name', 'Anonymous')
text = "Hello {0:s}".format(name)
return web.Response(text=text)
# initialize the API
hitomi_api = web.Application()
# add our basic stuff above
hitomi_api.router.add_get('/', index)
hitomi_api.router.add_get('/greet', greet)
hitomi_api.router.add_get('/greet/{name}', greet)

View File

@ -1,54 +0,0 @@
"""Start the Discord bot and connect to Discord."""
import asyncio
import importlib
import logging
from django.conf import settings
from django.core.management.base import BaseCommand
from bot import hitomi
from bot.api import hitomi_api
logger = logging.getLogger(__name__)
@hitomi.event
async def on_ready():
"""Print some basic login info to the console, when the bot connects."""
logger.info("Logged in as {0:s} ({1:s})".format(hitomi.user.name, hitomi.user.id))
# now load bot plugins
for plugin in settings.BOT_PLUGINS:
importlib.import_module(plugin)
@hitomi.event
async def on_message(message):
"""Call all registered on_message handlers."""
for handler in hitomi.on_message_handlers:
handler(message)
# let other stuff happen
await hitomi.process_commands(message)
async def run_bot():
"""Initialize and begin the bot in an asyncio context."""
await hitomi.login(settings.DISCORD_BOT_TOKEN)
await hitomi.connect()
class Command(BaseCommand):
help = "Start the Discord bot and connect to Discord."
def handle(self, *args, **options):
loop = asyncio.get_event_loop()
handler = hitomi_api.make_handler()
api_server = loop.create_server(handler, '0.0.0.0', settings.API_PORT)
futures = asyncio.gather(run_bot(), api_server)
try:
loop.run_until_complete(futures)
except:
loop.run_until_complete(hitomi.logout())
finally:
loop.close()

View File

View File

@ -1,20 +0,0 @@
"""Roll dice and do other randomization type operations."""
import logging
from bot import hitomi
from dice import lib
logger = logging.getLogger(__name__)
logger.info("loading dice plugin")
@hitomi.command()
async def choose(*choices: str):
"""Randomly select one item from multiple choices."""
await hitomi.say(lib.choose(*choices))
@hitomi.command()
async def roll(*, roll_str: str):
"""Provided a dice string, roll the dice and return the result."""
await hitomi.say(lib.roll(roll_str))

View File

@ -1,278 +0,0 @@
"""Functions for use in the dice module."""
import logging
import random
import ply.lex as lex
import ply.yacc as yacc
logger = logging.getLogger(__name__)
class DiceRoller(object):
tokens = ['NUMBER', 'TEXT', 'ROLLSEP']
literals = ['#', '/', '+', '-', 'd']
t_TEXT = r'\s+[^;]+'
t_ROLLSEP = r';\s*'
def build(self):
lex.lex(module=self)
yacc.yacc(module=self)
def t_NUMBER(self, t):
r'\d+'
t.value = int(t.value)
return t
def t_error(self, t):
t.lexer.skip(1)
precedence = (
('left', 'ROLLSEP'),
('left', '+', '-'),
('right', 'd'),
('left', '#'),
('left', '/')
)
output = ""
def __init__(self):
"""Build the parsing tables."""
self.build()
def roll_dice(self, keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def process_roll(self, trials, mods, comment):
"""Processes rolls coming from the parser.
This generates the inputs for the roll_dice() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
output = ""
repeat = 1
if trials != None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] != None:
if m[0][0] != None:
keep = m[0][0]
dice = m[0][1]
size = m[1]
if keep > dice or keep == 0:
keep = dice
if size < 1:
output = "# of sides for die is incorrect: %d" % size
return output
if dice < 1:
output = "# of dice is incorrect: %d" % dice
return output
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment != None:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment != None:
output += " (%s)" % (comment.strip())
return output
def p_roll_r(self, p):
# Chain rolls together.
# General idea I had when creating this grammar: A roll string is a chain
# of modifiers, which may be repeated for a certain number of trials. It can
# have a comment that describes the roll
# Multiple roll strings can be chained with semicolon
'roll : roll ROLLSEP roll'
global output
p[0] = p[1] + "; " + p[3]
output = p[0]
def p_roll(self, p):
# Parse a basic roll string.
'roll : trial modifier comment'
global output
mods = []
if type(p[2]) == list:
mods = p[2]
else:
mods = [p[2]]
p[0] = self.process_roll(p[1], mods, p[3])
output = p[0]
def p_roll_no_trials(self, p):
# Parse a roll string without trials.
'roll : modifier comment'
global output
mods = []
if type(p[1]) == list:
mods = p[1]
else:
mods = [p[1]]
p[0] = self.process_roll(None, mods, p[2])
output = p[0]
def p_comment(self, p):
# Parse a comment.
'''comment : TEXT
|'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = None
def p_modifier(self, p):
# Parse a modifier on a roll string.
'''modifier : modifier "+" modifier
| modifier "-" modifier'''
# Use append to prevent nested lists (makes dealing with this easier)
if type(p[1]) == list:
p[1].append(p[2])
p[1].append(p[3])
p[0] = p[1]
elif type(p[3]) == list:
p[3].insert(0, p[2])
p[3].insert(0, p[1])
p[0] = p[3]
else:
p[0] = [p[1], p[2], p[3]]
def p_die(self, p):
# Return the left side before the "d", and the number of faces.
'modifier : left NUMBER'
p[0] = (p[1], p[2])
def p_die_num(self, p):
'modifier : NUMBER'
p[0] = p[1]
def p_left(self, p):
# Parse the number of dice we are rolling, and how many we are keeping.
'left : keep dice'
if p[1] == None:
p[0] = [None, p[2]]
else:
p[0] = [p[1], p[2]]
def p_left_all(self, p):
'left : dice'
p[0] = [None, p[1]]
def p_left_e(self, p):
'left :'
p[0] = None
def p_total(self, p):
'trial : NUMBER "#"'
if len(p) > 1:
p[0] = p[1]
else:
p[0] = None
def p_keep(self, p):
'keep : NUMBER "/"'
if p[1] != None:
p[0] = p[1]
else:
p[0] = None
def p_dice(self, p):
'dice : NUMBER "d"'
p[0] = p[1]
def p_dice_one(self, p):
'dice : "d"'
p[0] = 1
def p_error(self, p):
# Provide the user with something (albeit not much) when the roll can't be parsed.
global output
output = "Unable to parse roll"
def get_result(self):
global output
return output
def do_roll(self, dicestr):
"""
Roll some dice and get the result (with broken out rolls).
Keyword arguments:
dicestr - format:
N#X/YdS+M label
N#: do the following roll N times (optional)
X/: take the top X rolls of the Y times rolled (optional)
Y : roll the die specified Y times (optional, defaults to 1)
dS: roll a S-sided die
+M: add M to the result (-M for subtraction) (optional)
"""
logger.info("rolling {0:s}".format(dicestr))
yacc.parse(dicestr)
return self.get_result()
roller = DiceRoller()
def choose(*choices: str):
"""Randomly choose an item from the provided multiple choices."""
return random.choice(choices)
def roll(roll_str: str):
"""Parse a roll string with lex and yacc and return the result."""
return roller.do_roll(roll_str)

View File

@ -0,0 +1,29 @@
"""Create the basic bot for plugins to hook onto."""
import logging
from discord.ext import commands
import hitomi.config
BOT_DESCRIPTION = "A simple Discord bot."
logger = logging.getLogger(__name__)
class Hitomi(commands.Bot):
"""Extend the discord.py Bot, to add more cool stuff."""
def __init__(self, *args, **kwargs):
"""Initialize bot, and cool stuff."""
super(Hitomi, self).__init__(*args, **kwargs)
self.on_message_handlers = []
bot = Hitomi(command_prefix=hitomi.config.BOT_COMMAND_PREFIX, description=BOT_DESCRIPTION,
max_messages=hitomi.config.BOT_MAX_MESSAGES)
@bot.event
async def on_ready():
"""Print some basic login info to the console, when the bot connects."""
logger.info("Logged in as %s (%d)", bot.user.name, bot.user.id)

36
hitomi/config.py Normal file
View File

@ -0,0 +1,36 @@
"""Configuration dials for the hitomi bot."""
import logging.config
import os
BOT_TOKEN = os.environ.get('HITOMI_BOT_TOKEN')
BOT_COMMAND_PREFIX = os.environ.get('HITOMI_BOT_COMMAND_PREFIX', '!')
BOT_MAX_MESSAGES = int(os.environ.get('HITOMI_BOT_MAX_MESSAGES', '5000'))
BOT_PLUGIN_STR = os.environ.get('HITOMI_BOT_PLUGINS', '')
BOT_PLUGINS = BOT_PLUGIN_STR.split(',') if BOT_PLUGIN_STR != '' else []
logging.config.dictConfig({
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'standard': {
'format': '%(asctime)s [%(levelname)s] %(name)s: %(message)s'
},
},
'handlers': {
'default': {
'level': 'INFO',
'formatter': 'standard',
'class': 'logging.StreamHandler',
'stream': 'ext://sys.stdout',
},
},
'loggers': {
'': {
'handlers': ['default'],
'level': 'INFO',
'propagate': True
},
}
})

View File

@ -1,149 +0,0 @@
"""
Django settings for hitomi project.
Generated by 'django-admin startproject' using Django 2.0.1.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/2.0/ref/settings/
"""
import os
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/2.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'ckbvtgrnz3-wm1jnkx)7obtc(l=n&vvb7*yf7gg#8eowr7hfun'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'bot',
'dice',
'logger',
'markov',
'weather',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'hitomi.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [
os.path.join(BASE_DIR, 'templates'),
],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'hitomi.wsgi.application'
# Database
# https://docs.djangoproject.com/en/2.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}
# Password validation
# https://docs.djangoproject.com/en/2.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/2.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'CST6CDT'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/2.0/howto/static-files/
STATIC_URL = '/static/'
# default bot settings
DISCORD_BOT_TOKEN = 'token'
DISCORD_BOT_COMMAND_PREFIX = '!'
DISCORD_BOT_MAX_MESSAGES = 5000
LOGGER_BASE_DIR = './logs'
WEATHER_WEATHER_UNDERGROUND_API_KEY = 'key'
BOT_PLUGINS = []
API_PORT = 7777
# get local settings
try:
from hitomi.localsettings import *
except ImportError:
print("WARNING: no localsettings.py found!")

View File

@ -1,24 +0,0 @@
"""hitomi URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/2.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from hitomi.views import IndexView
urlpatterns = [
path('', IndexView.as_view(), name='index'),
path('admin/', admin.site.urls),
]

View File

@ -1,8 +0,0 @@
"""Views for miscellaneous site stuff."""
from django.views.generic import TemplateView
class IndexView(TemplateView):
"""Serve a very basic index page."""
template_name = 'index.html'

View File

@ -1,16 +0,0 @@
"""
WSGI config for hitomi project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/2.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hitomi.settings")
application = get_wsgi_application()

View File

View File

@ -1,80 +0,0 @@
"""Log messages showing up on Discord."""
import logging
from os import makedirs
from django.conf import settings
from bot import hitomi
logger = logging.getLogger(__name__)
logger.info("loading logger plugin")
def on_message(message):
"""Log the seen message."""
try:
log_file = log_file_for_message(message)
log_line = "{0:s}\t{1:s}\t{2:s}\t{3:s}".format(str(message.timestamp), str(message.author), str(message.id),
str(message.content))
logger.info("{0:s} - {1:s}".format(log_file, log_line))
with open(log_file, 'a') as log_fd:
print(log_line, file=log_fd)
except Exception as ex:
logger.error("error in creating log line")
logger.exception(ex)
hitomi.on_message_handlers.append(on_message)
@hitomi.event
async def on_message_delete(message):
"""Log the message deletion."""
try:
log_file = log_file_for_message(message)
log_line = "{0:s}\t{1:s}\tD:{2:s}\tDELETED: {3:s}".format(str(message.timestamp), str(message.author),
str(message.id), str(message.content))
logger.info("{0:s} - {1:s}".format(log_file, log_line))
with open(log_file, 'a') as log_fd:
print(log_line, file=log_fd)
except Exception as ex:
logger.error("error in creating log line")
logger.exception(ex)
# let other stuff happen
await hitomi.process_commands(message)
@hitomi.event
async def on_message_edit(before, after):
"""Log the message deletion."""
try:
log_file = log_file_for_message(after)
log_line = "{0:s}\t{1:s}\tE:{2:s}\tEDIT: {3:s} (was \"{4:s}\")".format(str(after.timestamp), str(after.author),
str(after.id), str(after.content),
str(before.content))
logger.info("{0:s} - {1:s}".format(log_file, log_line))
with open(log_file, 'a') as log_fd:
print(log_line, file=log_fd)
except Exception as ex:
logger.error("error in creating log line")
logger.exception(ex)
# let other stuff happen
await hitomi.process_commands(after)
def log_file_for_message(message):
"""For the given message, figure out where we'd log the message."""
if message.channel.is_private:
log_directory = 'DM/{0:s}/'.format(str(message.channel.user.name))
log_file = '{0:s}-{1:s}.log'.format(str(message.channel.user.name), str(message.timestamp.strftime('%Y%m')))
else:
log_directory = '{0:s}-{1:s}/{2:s}/'.format(str(message.server.id), str(message.server),
str(message.channel))
log_file = '{0:s}-{1:s}.log'.format(str(message.channel), str(message.timestamp.strftime('%Y%m')))
destination_dir = '{0:s}/{1:s}'.format(settings.LOGGER_BASE_DIR, log_directory)
makedirs(destination_dir, exist_ok=True)
return '{0:s}{1:s}'.format(destination_dir, log_file)

View File

@ -1,15 +0,0 @@
#!/usr/bin/env python
import os
import sys
if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "hitomi.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)

View File

View File

@ -1,7 +0,0 @@
"""Display Markov data elements in the Django admin."""
from django.contrib import admin
from markov.models import MarkovContext, MarkovState
admin.site.register(MarkovContext)
admin.site.register(MarkovState)

View File

@ -1,6 +0,0 @@
"""App config for the markov module."""
from django.apps import AppConfig
class MarkovConfig(AppConfig):
name = 'markov'

View File

@ -1,40 +0,0 @@
"""Observe Markov chains from Discord."""
import logging
from django.conf import settings
from bot import hitomi
from markov import lib
logger = logging.getLogger(__name__)
logger.info("loading markov plugin")
def on_message(message):
"""Keep the observed Markov chains."""
# ignore self
if message.author == hitomi.user:
logger.debug("markov ignoring message authored by self")
return
# ignore commands
if message.content[0] == settings.DISCORD_BOT_COMMAND_PREFIX:
logger.debug("markov ignoring message that looks like a command")
return
if message.channel.is_private:
# DMs have a context of the author
context_name = message.author
else:
# channels have a context of the server, but we should ignore channels that override @everyone's read ability
for changed_role in message.channel.changed_roles:
if changed_role.is_everyone:
if not changed_role.permissions.read_messages:
logger.debug("markov ignoring channel that @everyone can't read")
return
context_name = message.server.id
lib.learn_line(message.content, context_name)
hitomi.on_message_handlers.append(on_message)

View File

@ -1,30 +0,0 @@
"""Shared methods for the Markov module."""
import logging
from markov.models import MarkovContext, MarkovState
logger = logging.getLogger(__name__)
def learn_line(line, context_name):
"""Create a bunch of MarkovStates for a given line of text."""
logger.debug("learning %s...", line[:40])
context, created = MarkovContext.objects.get_or_create(name=context_name)
words = line.split()
words = [MarkovState.start1, MarkovState.start2] + words + [MarkovState.stop]
for word in words:
if len(word) > MarkovState._meta.get_field('k1').max_length:
return
for i, word in enumerate(words):
logger.debug("'{0:s}','{1:s}' -> '{2:s}'".format(words[i], words[i + 1], words[i + 2]))
state, created = MarkovState.objects.get_or_create(context=context, k1=words[i], k2=words[i + 1],
v=words[i + 2])
state.count += 1
state.save()
if i > len(words) - 4:
break

View File

@ -1,41 +0,0 @@
# Generated by Django 2.0.1 on 2018-01-09 19:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='MarkovContext',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, unique=True)),
],
),
migrations.CreateModel(
name='MarkovState',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('k1', models.CharField(max_length=256)),
('k2', models.CharField(max_length=256)),
('v', models.CharField(max_length=256)),
('count', models.IntegerField(default=0)),
('context', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='markov.MarkovContext')),
],
),
migrations.AlterUniqueTogether(
name='markovstate',
unique_together={('context', 'k1', 'k2', 'v')},
),
migrations.AlterIndexTogether(
name='markovstate',
index_together={('context', 'k1', 'k2'), ('context', 'v')},
),
]

View File

@ -1,43 +0,0 @@
"""Database objects for storing Markov chains."""
import logging
from django.db import models
log = logging.getLogger(__name__)
class MarkovContext(models.Model):
"""Define contexts for Markov chains."""
name = models.CharField(max_length=200, unique=True)
def __str__(self):
"""String representation."""
return "{0:s}".format(self.name)
class MarkovState(models.Model):
"""One element in a Markov chain, some text or something."""
start1 = '__start1'
start2 = '__start2'
stop = '__stop'
k1 = models.CharField(max_length=256)
k2 = models.CharField(max_length=256)
v = models.CharField(max_length=256)
count = models.IntegerField(default=0)
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
class Meta:
index_together = [
['context', 'k1', 'k2'],
['context', 'v'],
]
unique_together = ('context', 'k1', 'k2', 'v')
def __str__(self):
"""String representation."""
return "{0:s},{1:s} -> {2:s} (count: {3:d})".format(self.k1, self.k2, self.v, self.count)

View File

@ -2,44 +2,43 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements-dev.txt requirements-dev.in
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
#
aiohttp==1.0.5
astroid==1.6.0 # via pylint, pylint-celery, pylint-flask, pylint-plugin-utils, requirements-detector
async-timeout==2.0.0 # via aiohttp
certifi==2017.11.5 # via requests
aiohttp==3.5.4 # via discord.py
astroid==2.2.5 # via pylint, pylint-celery, pylint-flask, requirements-detector
async-timeout==3.0.1 # via aiohttp
attrs==19.1.0 # via aiohttp
certifi==2019.6.16 # via requests
chardet==3.0.4 # via aiohttp, requests
click==6.7 # via pip-tools
colorama==0.3.9 # via pylint
discord.py==0.16.12
django==2.0.1
click==7.0 # via pip-tools
discord.py==1.2.2
dodgy==0.1.9 # via prospector
first==2.0.1 # via pip-tools
idna==2.6 # via requests
isort==4.2.15 # via pylint
lazy-object-proxy==1.3.1 # via astroid
idna-ssl==1.1.0 # via aiohttp
idna==2.8 # via idna-ssl, requests, yarl
isort==4.3.20 # via pylint
lazy-object-proxy==1.4.1 # via astroid
mccabe==0.6.1 # via prospector, pylint
multidict==3.3.2 # via aiohttp
multidict==4.5.2 # via aiohttp, yarl
pep8-naming==0.4.1 # via prospector
pip-tools==1.11.0
ply==3.10
prospector==0.12.7
pycodestyle==2.0.0 # via prospector
pydocstyle==2.1.1 # via prospector
pip-tools==3.8.0
prospector==1.1.6.4
pycodestyle==2.4.0 # via prospector
pydocstyle==3.0.0 # via prospector
pyflakes==1.6.0 # via prospector
pylint-celery==0.3 # via prospector
pylint-common==0.2.5 # via prospector
pylint-django==0.7.2 # via prospector
pylint-flask==0.5 # via prospector
pylint-plugin-utils==0.2.6 # via prospector, pylint-celery, pylint-common, pylint-django, pylint-flask
pylint==1.8.1 # via prospector, pylint-celery, pylint-common, pylint-django, pylint-flask, pylint-plugin-utils
pytz==2017.3 # via django
pyyaml==3.12 # via prospector
requests==2.18.4
requirements-detector==0.5.2 # via prospector
pylint-django==2.0.9 # via prospector
pylint-flask==0.6 # via prospector
pylint-plugin-utils==0.5 # via prospector, pylint-celery, pylint-django, pylint-flask
pylint==2.3.1 # via prospector, pylint-celery, pylint-django, pylint-flask, pylint-plugin-utils
pyyaml==5.1.1 # via prospector
requests==2.22.0
requirements-detector==0.6 # via prospector
setoptconf==0.2.0 # via prospector
six==1.11.0 # via astroid, pip-tools, pydocstyle, pylint
six==1.12.0 # via astroid, pip-tools, pydocstyle
snowballstemmer==1.2.1 # via pydocstyle
urllib3==1.22 # via requests
websockets==3.4 # via discord.py
wrapt==1.10.11 # via astroid
typed-ast==1.4.0 # via astroid
typing-extensions==3.7.4 # via aiohttp
urllib3==1.25.3 # via requests
websockets==6.0 # via discord.py
wrapt==1.11.2 # via astroid
yarl==1.3.0 # via aiohttp

View File

@ -1,5 +1,2 @@
aiohttp # API server
discord.py # discord client
Django # DB backend/management commands
ply # lex and yacc, for the dice module
requests # HTTP requests, for the weather module and likely others
requests # HTTP requests, for accessing the dr.botzo RPC API

View File

@ -2,18 +2,19 @@
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements.txt requirements.in
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
#
aiohttp==1.0.5
async-timeout==2.0.0 # via aiohttp
certifi==2017.11.5 # via requests
aiohttp==3.5.4 # via discord.py
async-timeout==3.0.1 # via aiohttp
attrs==19.1.0 # via aiohttp
certifi==2019.6.16 # via requests
chardet==3.0.4 # via aiohttp, requests
discord.py==0.16.12
django==2.0.1
idna==2.6 # via requests
multidict==3.3.2 # via aiohttp
ply==3.10
pytz==2017.3 # via django
requests==2.18.4
urllib3==1.22 # via requests
websockets==3.4 # via discord.py
discord.py==1.2.2
idna-ssl==1.1.0 # via aiohttp
idna==2.8 # via idna-ssl, requests, yarl
multidict==4.5.2 # via aiohttp, yarl
requests==2.22.0
typing-extensions==3.7.4 # via aiohttp
urllib3==1.25.3 # via requests
websockets==6.0 # via discord.py
yarl==1.3.0 # via aiohttp

View File

View File

View File

@ -1,18 +0,0 @@
"""Provide weather information over Discord."""
import logging
from bot import hitomi
from weather import lib
logger = logging.getLogger(__name__)
logger.info("loading weather plugin")
@hitomi.command()
async def forecast(query: str):
await hitomi.say(lib.get_forecast_for_query([query]))
@hitomi.command()
async def weather(query: str):
await hitomi.say(lib.get_conditions_for_query([query]))

View File

@ -1,198 +0,0 @@
# coding: utf-8
"""Library methods for looking up and presenting weather."""
import logging
import re
import requests
from django.conf import settings
wu_base_url = 'http://api.wunderground.com/api/{0:s}/'.format(settings.WEATHER_WEATHER_UNDERGROUND_API_KEY)
logger = logging.getLogger(__name__)
def get_conditions_for_query(queryitems):
"""Make a wunderground conditions call, return as string."""
# recombine the query into a string
query = ' '.join(queryitems)
query = query.replace(' ', '_')
try:
logger.info("looking up conditions for {0:s}".format(query))
url = wu_base_url + ('{0:s}/q/{1:s}.json'.format('conditions', query))
logger.debug("calling %s", url)
resp = requests.get(url)
condition_data = resp.json()
except IOError as e:
logger.error("error while making conditions query")
logger.exception(e)
raise
# condition data is loaded. the rest of this is obviously specific to
# http://www.wunderground.com/weather/api/d/docs?d=data/conditions
logger.debug(condition_data)
try:
# just see if we have current_observation data
current = condition_data['current_observation']
except KeyError as e:
# ok, try to see if the ambiguous results stuff will help
logger.debug(e)
logger.debug("potentially ambiguous results, checking")
try:
results = condition_data['response']['results']
reply = "Multiple results, try one of the following zmw codes:"
for res in results[:-1]:
q = res['l'].strip('/q/')
reply += " {0:s} ({1:s}, {2:s}),".format(q, res['name'], res['country_name'])
q = results[-1]['l'].strip('/q/')
reply += " or {0:s} ({1:s}, {2:s}).".format(q, results[-1]['name'], results[-1]['country_name'])
return reply
except KeyError as e:
# now we really know something is wrong
logger.error("error or bad query in conditions lookup")
logger.exception(e)
return "No results."
else:
try:
location = current['display_location']['full']
reply = "Conditions for **{0:s}**: ".format(location)
weather_str = current['weather']
if weather_str != '':
reply += "{0:s}, ".format(weather_str)
temp_f = current['temp_f']
temp_c = current['temp_c']
temp_str = current['temperature_string']
if temp_f != '' and temp_c != '':
reply += "{0:.1f}°F ({1:.1f}°C)".format(temp_f, temp_c)
elif temp_str != '':
reply += "{0:s}".format(temp_str)
# append feels like if we have it
feelslike_f = current['feelslike_f']
feelslike_c = current['feelslike_c']
feelslike_str = current['feelslike_string']
if feelslike_f != '' and feelslike_c != '':
reply += ", feels like {0:s}°F ({1:s}°C)".format(feelslike_f, feelslike_c)
elif feelslike_str != '':
reply += ", feels like {0:s}".format(feelslike_str)
# whether this is current or current + feelslike, terminate sentence
reply += ". "
humidity_str = current['relative_humidity']
if humidity_str != '':
reply += "**Humidity**: {0:s}. ".format(humidity_str)
wind_str = current['wind_string']
if wind_str != '':
reply += "**Wind**: {0:s}. ".format(wind_str)
pressure_in = current['pressure_in']
pressure_trend = current['pressure_trend']
if pressure_in != '':
reply += "**Pressure**: {0:s}\"".format(pressure_in)
if pressure_trend != '':
reply += " and {0:s}".format("dropping" if pressure_trend == '-' else "rising")
reply += ". "
heat_index_str = current['heat_index_string']
if heat_index_str != '' and heat_index_str != 'NA':
reply += "**Heat index**: {0:s}. ".format(heat_index_str)
windchill_str = current['windchill_string']
if windchill_str != '' and windchill_str != 'NA':
reply += "**Wind chill**: {0:s}. ".format(windchill_str)
visibility_mi = current['visibility_mi']
if visibility_mi != '':
reply += "**Visibility**: {0:s} miles. ".format(visibility_mi)
precip_in = current['precip_today_in']
if precip_in != '':
reply += "**Precipitation today**: {0:s}\". ".format(precip_in)
observation_time = current['observation_time']
if observation_time != '':
reply += "{0:s}. ".format(observation_time)
return _prettify_weather_strings(reply.rstrip())
except KeyError as e:
logger.error("error or unexpected results in conditions reply")
logger.exception(e)
return "Error parsing results."
def get_forecast_for_query(queryitems):
"""Make a wunderground forecast call, return as string."""
# recombine the query into a string
query = ' '.join(queryitems)
query = query.replace(' ', '_')
try:
logger.info("looking up forecast for {0:s}".format(query))
url = wu_base_url + ('{0:s}/q/{1:s}.json'.format('forecast', query))
resp = requests.get(url)
forecast_data = resp.json()
except IOError as e:
logger.error("error while making forecast query")
logger.exception(e)
raise
# forecast data is loaded. the rest of this is obviously specific to
# http://www.wunderground.com/weather/api/d/docs?d=data/forecast
logger.debug(forecast_data)
try:
# just see if we have forecast data
forecasts = forecast_data['forecast']['txt_forecast']
except KeyError as e:
# ok, try to see if the ambiguous results stuff will help
logger.debug(e)
logger.debug("potentially ambiguous results, checking")
try:
results = forecast_data['response']['results']
reply = "Multiple results, try one of the following zmw codes:"
for res in results[:-1]:
q = res['l'].strip('/q/')
reply += " {0:s} ({1:s}, {2:s}),".format(q, res['name'],
res['country_name'])
q = results[-1]['l'].strip('/q/')
reply += " or {0:s} ({1:s}, {2:s}).".format(q, results[-1]['name'],
results[-1]['country_name'])
return reply
except KeyError as e:
# now we really know something is wrong
logger.error("error or bad query in forecast lookup")
logger.exception(e)
return "No results."
else:
try:
reply = "Forecast: "
for forecast in forecasts['forecastday'][0:5]:
reply += "**{0:s}**: {1:s} ".format(forecast['title'],
forecast['fcttext'])
return _prettify_weather_strings(reply.rstrip())
except KeyError as e:
logger.error("error or unexpected results in forecast reply")
logger.exception(e)
return "Error parsing results."
def _prettify_weather_strings(weather_str):
"""
Clean up output strings.
For example, turn 32F into 32°F in input string.
Input:
weather_str --- the string to clean up
"""
return re.sub(r'(\d+)\s*([FC])', r'\\2', weather_str)