Compare commits

..

No commits in common. "master" and "backend-frameworkification" have entirely different histories.

122 changed files with 2059 additions and 1956 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ megahal.*
dr.botzo.log
dr.botzo.markov
*.facts
*.json
*.log
*.pyc
*.sqlite3

View File

@ -1,44 +1,10 @@
# Contributing Guidelines
## Contributing
This is a pretty fun, non-serious hacking project, so if you're interested in
contributing, sign up, clone the project, and submit some merge requests!
There's a lot to add and work on, so join in.
dr.botzo is made available under the GPLv3 license. Contributions are welcome via pull requests. This document outlines
the process to get your contribution accepted.
## Code Style
4 spaces per indent level. 120 character line length. Follow PEP8 as closely
as reasonable. There's a prospector config, use it.
## Sign Offs/Custody of Contributions
I do not request the copyright of contributions be assigned to me or to the project, and I require no provision that I
be allowed to relicense your contributions. My personal oath is to maintain inbound=outbound in my open source projects,
and the expectation is authors are responsible for their contributions.
I am following the the [Developer Certificate of Origin (DCO)](https://developercertificate.org/), also available at
`DCO.txt`. The DCO is a way for contributors to certify that they wrote or otherwise have the right to license their
code contributions to the project. Contributors must sign-off that they adhere to these requirements by adding a
`Signed-off-by` line to their commit message, and/or, for frequent contributors, by signing off on their entry in
`MAINTAINERS.md`.
This process is followed by a number of open source projects, most notably the Linux kernel. Here's the gist of it:
```
[Your normal Git commit message here.]
Signed-off-by: Random J Developer <random@developer.example.org>
```
`git help commit` has more info on adding this:
```
-s, --signoff
Add Signed-off-by line by the committer at the end of the commit log
message. The meaning of a signoff depends on the project, but it typically
certifies that committer has the rights to submit this work under the same
license and agrees to a Developer Certificate of Origin (see
http://developercertificate.org/ for more information).
```

View File

@ -1,7 +1,7 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
@ -645,7 +645,7 @@ the "copyright" line and a pointer to where the full notice is found.
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
@ -664,11 +664,11 @@ might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
<http://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
<http://www.gnu.org/philosophy/why-not-lgpl.html>.

34
DCO.txt
View File

@ -1,34 +0,0 @@
Developer Certificate of Origin
Version 1.1
Copyright (C) 2004, 2006 The Linux Foundation and its contributors.
Everyone is permitted to copy and distribute verbatim copies of this
license document, but changing it is not allowed.
Developer's Certificate of Origin 1.1
By making a contribution to this project, I certify that:
(a) The contribution was created in whole or in part by me and I
have the right to submit it under the open source license
indicated in the file; or
(b) The contribution is based upon previous work that, to the best
of my knowledge, is covered under an appropriate open source
license and I have the right under that license to submit that
work with modifications, whether created in whole or in part
by me, under the same open source license (unless I am
permitted to submit under a different license), as indicated
in the file; or
(c) The contribution was provided directly to me by some other
person who certified (a), (b) or (c) and I have not modified
it.
(d) I understand and agree that this project and the contribution
are public and that a record of the contribution (including all
personal information I submit with it, including my sign-off) is
maintained indefinitely and may be redistributed consistent with
this project or the open source license(s) involved.

View File

@ -1,10 +0,0 @@
# Maintainers
This file contains information about people permitted to make major decisions and direction on the project.
## Contributing Under the DCO
By adding your name and email address to this section, you certify that all of your subsequent contributions to dr.botzo
are made under the terms of the Developer's Certificate of Origin 1.1, available at `DCO.txt`.
* Brian S. Stephan (<bss@incorporeal.org>)

View File

@ -1,8 +1,2 @@
include versioneer.py
include dr_botzo/_version.py
graft dr_botzo/templates
graft facts/templates
graft ircbot/templates
graft karma/templates
graft markbot/templates
graft races/templates

View File

@ -5,10 +5,4 @@ from django.contrib import admin
from countdown.models import CountdownItem
class CountdownItemAdmin(admin.ModelAdmin):
"""Custom display for the countdown items."""
list_display = ('__str__', 'reminder_message')
admin.site.register(CountdownItem, CountdownItemAdmin)
admin.site.register(CountdownItem)

View File

@ -12,7 +12,6 @@ from django.utils import timezone
from countdown.models import CountdownItem
from ircbot.lib import Plugin, most_specific_message
from ircbot.models import IrcChannel
log = logging.getLogger('countdown.ircplugin')
@ -23,14 +22,13 @@ class Countdown(Plugin):
new_reminder_regex = (r'remind\s+(?P<who>[^\s]+)\s+(?P<when_type>at|in|on)\s+(?P<when>.*?)\s+'
r'(and\s+every\s+(?P<recurring_period>.*?)\s+)?'
r'(until\s+(?P<recurring_until>.*?)\s+)?'
r'(to|that|about|with)\s+(?P<text>.*?)'
r'(to|that|about)\s+(?P<text>.*?)'
r'(?=\s+\("(?P<name>.*)"\)|$)')
def __init__(self, bot, connection, event):
"""Initialize some stuff."""
self.running_reminders = []
self.send_reminders = True
self.server_config = connection.server_config
t = threading.Thread(target=self.reminder_thread)
t.daemon = True
@ -68,22 +66,20 @@ class Countdown(Plugin):
# TODO: figure out if we need this sleep, which exists so we don't send reminders while still connecting to IRC
time.sleep(30)
while self.send_reminders:
reminders = CountdownItem.objects.filter(is_reminder=True, sent_reminder=False,
at_time__lte=timezone.now(),
reminder_target_new__server=self.server_config)
reminders = CountdownItem.objects.filter(is_reminder=True, sent_reminder=False, at_time__lte=timezone.now())
for reminder in reminders:
log.debug("%s @ %s", reminder.reminder_message, reminder.at_time)
if reminder.at_time <= timezone.now():
log.info("sending %s to %s", reminder.reminder_message, reminder.reminder_target_new.name)
self.bot.reply(None, reminder.reminder_message, explicit_target=reminder.reminder_target_new.name)
log.info("sending %s to %s", reminder.reminder_message, reminder.reminder_target)
self.bot.reply(None, reminder.reminder_message, explicit_target=reminder.reminder_target)
# if recurring and not hit until, set a new at time, otherwise stop reminding
if reminder.recurring_until is not None and timezone.now() >= reminder.recurring_until:
reminder.sent_reminder = True
elif reminder.recurring_period != '':
calendar = pdt.Calendar()
when_t = calendar.parseDT(f'in one {reminder.recurring_period}', reminder.at_time,
when_t = calendar.parseDT(reminder.recurring_period, reminder.at_time,
tzinfo=reminder.at_time.tzinfo)[0]
if reminder.recurring_until is None or when_t <= reminder.recurring_until:
reminder.at_time = when_t
@ -147,12 +143,8 @@ class Countdown(Plugin):
log.debug("%s / %s / %s", item_name, when_t, message)
# get the IrcChannel to send to
reminder_target, _ = IrcChannel.objects.get_or_create(name=event.sent_location,
server=connection.server_config)
countdown_item = CountdownItem.objects.create(name=item_name, at_time=when_t, is_reminder=True,
reminder_message=message, reminder_target_new=reminder_target)
reminder_message=message, reminder_target=event.sent_location)
if recurring_period:
countdown_item.recurring_period = recurring_period
if recurring_until:

View File

@ -1,20 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-26 00:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0018_ircserver_replace_irc_control_with_markdown'),
('countdown', '0006_auto_20201025_1716'),
]
operations = [
migrations.AddField(
model_name='countdownitem',
name='reminder_target_new',
field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, to='ircbot.ircchannel'),
),
]

View File

@ -14,8 +14,6 @@ class CountdownItem(models.Model):
reminder_message = models.TextField(default="")
reminder_target = models.CharField(max_length=64, blank=True, default='')
reminder_target_new = models.ForeignKey('ircbot.IrcChannel', null=True, blank=True,
default=None, on_delete=models.CASCADE)
recurring_period = models.CharField(max_length=64, blank=True, default='')
recurring_until = models.DateTimeField(null=True, blank=True, default=None)

View File

@ -1,6 +1,5 @@
"""URL patterns for the countdown views."""
from django.conf.urls import include
from django.urls import path
from django.conf.urls import include, url
from rest_framework.routers import DefaultRouter
from countdown.views import CountdownItemViewSet
@ -9,5 +8,5 @@ router = DefaultRouter()
router.register(r'items', CountdownItemViewSet)
urlpatterns = [
path('api/', include(router.urls)),
url(r'^api/', include(router.urls)),
]

View File

@ -1,4 +1,6 @@
"""Provide an interface to countdown items."""
# from rest_framework.decorators import action
# from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet

View File

@ -1,9 +1,7 @@
"""Roll dice when asked, intended for RPGs."""
import logging
import random
import re
from django.conf import settings
import random
from irc.client import NickMask
@ -40,23 +38,23 @@ class Dice(Plugin):
def handle_random(self, connection, event, match):
"""Handle the !random command which picks an item from a list."""
nick = NickMask(event.source).nick
choices = match.group(1)
choices_list = choices.split(' ')
choice = random.SystemRandom().choice(choices_list)
choice = random.choice(choices_list)
logger.debug(event.recursing)
if event.recursing:
reply = "{0:s}".format(choice)
elif settings.DICE_PREFIX_ROLLER:
reply = "{0:s}: {1:s}".format(nick, choice)
else:
reply = "{0:s}".format(choice)
reply = "{0:s}: {1:s}".format(nick, choice)
return self.bot.reply(event, reply)
def handle_roll(self, connection, event, match):
"""Handle the !roll command which covers most common dice stuff."""
nick = NickMask(event.source).nick
dicestr = match.group(1)
@ -70,11 +68,8 @@ class Dice(Plugin):
if event.recursing:
reply = "{0:s}".format(reply_str)
elif settings.DICE_PREFIX_ROLLER:
reply = "{0:s}: {1:s}".format(nick, reply_str)
else:
reply = "{0:s}".format(reply_str)
reply = "{0:s}: {1:s}".format(nick, reply_str)
return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply))
plugin = Dice

View File

@ -1,23 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-25 14:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatch', '0005_auto_20160116_1955'),
]
operations = [
migrations.AddField(
model_name='dispatcher',
name='bot_xmlrpc_host',
field=models.CharField(default='localhost', max_length=200),
),
migrations.AddField(
model_name='dispatcher',
name='bot_xmlrpc_port',
field=models.PositiveSmallIntegerField(default=13132),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.18 on 2023-03-01 00:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dispatch', '0006_xmlrpc_settings'),
]
operations = [
migrations.RenameField(
model_name='dispatcheraction',
old_name='type',
new_name='action_type',
),
]

View File

@ -1,8 +1,10 @@
"""Track dispatcher configurations."""
import logging
from django.db import models
log = logging.getLogger('dispatch.models')
@ -11,9 +13,6 @@ class Dispatcher(models.Model):
key = models.CharField(max_length=16, unique=True)
bot_xmlrpc_host = models.CharField(max_length=200, default='localhost')
bot_xmlrpc_port = models.PositiveSmallIntegerField(default=13132)
class Meta:
"""Meta options."""
@ -22,7 +21,7 @@ class Dispatcher(models.Model):
)
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s}".format(self.key)
@ -38,10 +37,10 @@ class DispatcherAction(models.Model):
)
dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE)
action_type = models.CharField(max_length=16, choices=TYPE_CHOICES)
type = models.CharField(max_length=16, choices=TYPE_CHOICES)
destination = models.CharField(max_length=200)
include_key = models.BooleanField(default=False)
def __str__(self):
"""Provide string representation."""
return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.action_type, self.destination)
"""String representation."""
return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.type, self.destination)

View File

@ -12,7 +12,7 @@ class DispatcherActionSerializer(serializers.ModelSerializer):
"""Meta options."""
model = DispatcherAction
fields = ('id', 'dispatcher', 'action_type', 'destination')
fields = ('id', 'dispatcher', 'type', 'destination')
class DispatcherSerializer(serializers.ModelSerializer):

View File

@ -1,16 +1,20 @@
"""URL patterns for the dispatcher API."""
from django.urls import path
from dispatch.views import (DispatcherActionDetail, DispatcherActionList, DispatcherDetail, DispatcherDetailByKey,
DispatcherList, DispatchMessage, DispatchMessageByKey)
from django.conf.urls import url
from dispatch.views import (DispatchMessage, DispatchMessageByKey, DispatcherList, DispatcherDetail,
DispatcherDetailByKey, DispatcherActionList, DispatcherActionDetail)
urlpatterns = [
path('api/dispatchers/', DispatcherList.as_view(), name='dispatch_api_dispatchers'),
path('api/dispatchers/<int:pk>/', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'),
path('api/dispatchers/<int:pk>/message', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'),
path('api/dispatchers/<key>/', DispatcherDetailByKey.as_view(), name='dispatch_api_dispatcher_detail'),
path('api/dispatchers/<key>/message', DispatchMessageByKey.as_view(), name='dispatch_api_dispatch_message'),
url(r'^api/dispatchers/$', DispatcherList.as_view(), name='dispatch_api_dispatchers'),
url(r'^api/dispatchers/(?P<pk>[0-9]+)/$', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'),
url(r'^api/dispatchers/(?P<pk>[0-9]+)/message$', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'),
url(r'^api/dispatchers/(?P<key>[A-Za-z-]+)/$', DispatcherDetailByKey.as_view(),
name='dispatch_api_dispatcher_detail'),
url(r'^api/dispatchers/(?P<key>[A-Za-z-]+)/message$', DispatchMessageByKey.as_view(),
name='dispatch_api_dispatch_message'),
path('api/actions/', DispatcherActionList.as_view(), name='dispatch_api_actions'),
path('api/actions/<int:pk>/', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'),
url(r'^api/actions/$', DispatcherActionList.as_view(), name='dispatch_api_actions'),
url(r'^api/actions/(?P<pk>[0-9]+)/$', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'),
]

View File

@ -1,15 +1,18 @@
"""Handle dispatcher API requests."""
import copy
import logging
import os
import xmlrpc.client
from django.conf import settings
from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from dispatch.models import Dispatcher, DispatcherAction
from dispatch.serializers import DispatcherActionSerializer, DispatcherSerializer, DispatchMessageSerializer
from dispatch.serializers import DispatchMessageSerializer, DispatcherSerializer, DispatcherActionSerializer
log = logging.getLogger('dispatch.views')
@ -28,8 +31,6 @@ class HasSendMessagePermission(IsAuthenticated):
class DispatcherList(generics.ListAPIView):
"""List all dispatchers."""
permission_classes = (IsAuthenticated,)
queryset = Dispatcher.objects.all()
serializer_class = DispatcherSerializer
@ -37,8 +38,6 @@ class DispatcherList(generics.ListAPIView):
class DispatcherDetail(generics.RetrieveAPIView):
"""Detail the given dispatcher."""
permission_classes = (IsAuthenticated,)
queryset = Dispatcher.objects.all()
serializer_class = DispatcherSerializer
@ -75,19 +74,19 @@ class DispatchMessage(generics.GenericAPIView):
else:
text = message.data['message']
if action.action_type == DispatcherAction.PRIVMSG_TYPE:
if action.type == DispatcherAction.PRIVMSG_TYPE:
# connect over XML-RPC and send
try:
bot_url = 'http://{0:s}:{1:d}/'.format(dispatcher.bot_xmlrpc_host, dispatcher.bot_xmlrpc_port)
bot_url = 'http://{0:s}:{1:d}/'.format(settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT)
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
log.debug("sending '%s' to channel %s", text, action.destination)
bot.reply(None, text, False, action.destination)
except xmlrpc.client.Fault as xmlex:
except Exception as e:
new_data = copy.deepcopy(message.data)
new_data['status'] = "FAILED - {0:s}".format(str(xmlex))
new_data['status'] = "FAILED - {0:s}".format(str(e))
new_message = self.serializer_class(data=new_data)
return Response(new_message.initial_data)
elif action.action_type == DispatcherAction.FILE_TYPE:
elif action.type == DispatcherAction.FILE_TYPE:
# write to file
filename = os.path.abspath(action.destination)
log.debug("sending '%s' to file %s", text, filename)
@ -111,8 +110,6 @@ class DispatchMessageByKey(DispatchMessage):
class DispatcherActionList(generics.ListAPIView):
"""List all dispatchers."""
permission_classes = (IsAuthenticated,)
queryset = DispatcherAction.objects.all()
serializer_class = DispatcherActionSerializer
@ -120,7 +117,5 @@ class DispatcherActionList(generics.ListAPIView):
class DispatcherActionDetail(generics.RetrieveAPIView):
"""Detail the given dispatcher."""
permission_classes = (IsAuthenticated,)
queryset = DispatcherAction.objects.all()
serializer_class = DispatcherActionSerializer

View File

@ -1,5 +1,5 @@
"""Site processors to add additional template tags and whatnot."""
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
from django.utils.functional import SimpleLazyObject
@ -11,5 +11,4 @@ def site(request):
return {
'site': site,
'site_root': SimpleLazyObject(lambda: "{0}://{1}".format(protocol, site.domain)),
'WEB_ENABLED_APPS': settings.WEB_ENABLED_APPS,
}

View File

@ -11,6 +11,8 @@ https://docs.djangoproject.com/en/1.6/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os
from django.urls import reverse_lazy
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@ -39,10 +41,12 @@ INSTALLED_APPS = (
'django_extensions',
'adminplus',
'bootstrap3',
'registration',
'rest_framework',
'countdown',
'dispatch',
'facts',
'gitlab_bot',
'ircbot',
'karma',
'markov',
@ -50,16 +54,17 @@ INSTALLED_APPS = (
'races',
'seen',
'storycraft',
'twitter',
)
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',
'django.middleware.security.SecurityMiddleware',
)
ROOT_URLCONF = 'dr_botzo.urls'
@ -67,7 +72,7 @@ ROOT_URLCONF = 'dr_botzo.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'dr_botzo', 'templates')],
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@ -94,9 +99,6 @@ DATABASES = {
}
}
# inherited default, look at changing to BigAutoField
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/
@ -145,23 +147,41 @@ BOOTSTRAP3 = {
'javascript_in_head': True,
}
###############
# web options #
###############
# choose which apps to display in the web UI, for those that support this config
WEB_ENABLED_APPS = [
'karma',
'races',
# registration
LOGIN_REDIRECT_URL = reverse_lazy('index')
ACCOUNT_ACTIVATION_DAYS = 7
REGISTRATION_AUTO_LOGIN = True
# IRC bot stuff
# tuple of hostname, port number, and password (or None)
IRCBOT_SERVER_LIST = [
('localhost', 6667, None),
]
IRCBOT_NICKNAME = 'dr_botzo'
IRCBOT_REALNAME = 'Dr. Botzo'
IRCBOT_SSL = False
IRCBOT_IPV6 = False
# post-connect, pre-autojoin stuff
IRCBOT_SLEEP_BEFORE_AUTOJOIN_SECONDS = 10
IRCBOT_POST_CONNECT_COMMANDS = [ ]
# XML-RPC settings
IRCBOT_XMLRPC_HOST = 'localhost'
IRCBOT_XMLRPC_PORT = 13132
# nick hack for discord through bitlbee
ADDITIONAL_NICK_MATCHES = []
# IRC module stuff
# dice
DICE_PREFIX_ROLLER = False
# karma
KARMA_IGNORE_CHATTER_TARGETS = []
@ -182,6 +202,15 @@ STORYCRAFT_DEFAULT_GAME_LENGTH = 20
STORYCRAFT_DEFAULT_LINE_LENGTH = 140
STORYCRAFT_DEFAULT_LINES_PER_TURN = 2
# twitter
TWITTER_CONSUMER_KEY = None
TWITTER_CONSUMER_SECRET = None
# weather
WEATHER_WEATHER_UNDERGROUND_API_KEY = None
# load local settings

View File

@ -1,8 +1,7 @@
"""General/baselite/site-wide URLs."""
from adminplus.sites import AdminSitePlus
from django.conf.urls import include
from django.conf.urls import include, url
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
admin.site = AdminSitePlus()
@ -10,17 +9,18 @@ admin.sites.site = admin.site
admin.autodiscover()
urlpatterns = [
path('', TemplateView.as_view(template_name='index.html'), name='index'),
url(r'^$', TemplateView.as_view(template_name='index.html'), name='index'),
path('countdown/', include('countdown.urls')),
path('dice/', include('dice.urls')),
path('dispatch/', include('dispatch.urls')),
path('itemsets/', include('facts.urls')),
path('karma/', include('karma.urls')),
path('markov/', include('markov.urls')),
path('pi/', include('pi.urls')),
path('races/', include('races.urls')),
path('weather/', include('weather.urls')),
url(r'^countdown/', include('countdown.urls')),
url(r'^dice/', include('dice.urls')),
url(r'^dispatch/', include('dispatch.urls')),
url(r'^itemsets/', include('facts.urls')),
url(r'^karma/', include('karma.urls')),
url(r'^markov/', include('markov.urls')),
url(r'^pi/', include('pi.urls')),
url(r'^races/', include('races.urls')),
url(r'^weather/', include('weather.urls')),
path('admin/', admin.site.urls),
url(r'^accounts/', include('registration.backends.default.urls')),
url(r'^admin/', admin.site.urls),
]

View File

@ -3,8 +3,8 @@ import logging
from irc.client import NickMask
from ircbot.lib import Plugin, has_permission
from facts.models import Fact, FactCategory
from ircbot.lib import Plugin
log = logging.getLogger('facts.ircplugin')
@ -14,14 +14,10 @@ class Facts(Plugin):
def start(self):
"""Set up the handlers."""
self.connection.reactor.add_global_regex_handler(
['pubmsg', 'privmsg'], r'remember\s+that\s+(?P<which>\S+)\s+(is|means)\s+(?P<what>.*)$',
self.handle_add_fact, -20
)
self.connection.reactor.add_global_regex_handler(
['pubmsg', 'privmsg'], r'^!f(acts)?\s+(?P<which>\S+)(\s+(?P<what>.*)$|$)',
self.handle_facts, -20
)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!facts\s+add\s+(\S+)\s+(.*)$',
self.handle_add_fact, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!facts\s+(\S+)(\s+(.*)$|$)',
self.handle_facts, -20)
super(Facts, self).start()
@ -34,10 +30,10 @@ class Facts(Plugin):
def handle_facts(self, connection, event, match):
"""Respond to the facts command with desired fact."""
category = match.group('which')
category = match.group(1)
regex = None
if match.group(2) != '':
regex = match.group('what')
regex = match.group(3)
fact = Fact.objects.random_fact(category, regex)
if fact:
@ -51,16 +47,15 @@ class Facts(Plugin):
def handle_add_fact(self, connection, event, match):
"""Add a new fact to the database."""
if event.in_privmsg or event.addressed:
category_name = match.group('which')
fact_text = match.group('what')
category_name = match.group(1)
fact_text = match.group(2)
if has_permission(event.source, 'facts.add_fact'):
# create the category
category, created = FactCategory.objects.get_or_create(name=category_name)
fact = Fact.objects.create(fact=fact_text, category=category, nickmask=event.source)
if fact:
self.bot.reply(event, f"ok, I now know more about {category.name}")
return 'NO MORE'
return self.bot.reply(event, "fact added to {0:s}".format(category.name))
plugin = Facts

View File

@ -1,14 +0,0 @@
"""Serializers for the fact objects."""
from rest_framework import serializers
from facts.models import Fact
class FactSerializer(serializers.ModelSerializer):
"""Serializer for the REST API."""
class Meta:
"""Meta options."""
model = Fact
fields = ('id', 'fact', 'category')

View File

@ -1,12 +1,9 @@
"""URL patterns for the facts web views."""
from django.urls import path
from django.conf.urls import url
from facts.views import factcategory_detail, index, rpc_get_facts, rpc_get_random_fact
from facts.views import index, factcategory_detail
urlpatterns = [
path('rpc/<category>/', rpc_get_facts, name='weather_rpc_get_facts'),
path('rpc/<category>/random/', rpc_get_random_fact, name='weather_rpc_get_random_fact'),
path('', index, name='facts_index'),
path('<factcategory_name>/', factcategory_detail, name='facts_factcategory_detail'),
url(r'^$', index, name='facts_index'),
url(r'^(?P<factcategory_name>.+)/$', factcategory_detail, name='facts_factcategory_detail'),
]

View File

@ -2,49 +2,12 @@
import logging
from django.shortcuts import get_object_or_404, render
from rest_framework.authentication import BasicAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from facts.models import FactCategory
from facts.serializers import FactSerializer
log = logging.getLogger(__name__)
@api_view(['GET'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_get_facts(request, category):
"""Get all the facts in a category."""
if request.method != 'GET':
return Response({'detail': "Supported method: GET."}, status=405)
try:
fact_category = FactCategory.objects.get(name=category)
except FactCategory.DoesNotExist:
return Response({'detail': f"Item set category '{category}' not found."}, status=404)
return Response(FactSerializer(fact_category.fact_set.all(), many=True).data)
@api_view(['GET'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_get_random_fact(request, category):
"""Get all the facts in a category."""
if request.method != 'GET':
return Response({'detail': "Supported method: GET."}, status=405)
try:
fact_category = FactCategory.objects.get(name=category)
except FactCategory.DoesNotExist:
return Response({'detail': f"Item set category '{category}' not found."}, status=404)
return Response(FactSerializer(fact_category.random_fact()).data)
def index(request):
"""Display a simple list of the fact categories, for the moment."""
factcategories = FactCategory.objects.all()

0
gitlab_bot/__init__.py Normal file
View File

8
gitlab_bot/admin.py Normal file
View File

@ -0,0 +1,8 @@
"""Admin stuff for GitLab bot models."""
from django.contrib import admin
from gitlab_bot.models import GitlabConfig, GitlabProjectConfig
admin.site.register(GitlabConfig)
admin.site.register(GitlabProjectConfig)

253
gitlab_bot/lib.py Normal file
View File

@ -0,0 +1,253 @@
"""Wrapped client for the configured GitLab bot."""
import logging
import random
import re
import gitlab
from gitlab_bot.models import GitlabConfig
log = logging.getLogger(__name__)
class GitlabBot(object):
"""Bot for doing GitLab stuff. Might be used by the IRC bot, or daemons, or whatever."""
REVIEWS_START_FORMAT = "Starting review process."
REVIEWS_RESET_FORMAT = "Review process reset."
REVIEWS_REVIEW_REGISTERED_FORMAT = "Review identified."
REVIEWS_PROGRESS_FORMAT = "{0:d} reviews of {1:d} necessary to proceed."
REVIEWS_REVIEWS_COMPLETE = "Reviews complete."
NEW_REVIEWER_FORMAT = "Assigning to {0:s} to review merge request."
NEW_ACCEPTER_FORMAT = "Assigning to {0:s} to accept merge request."
NOTE_COMMENT = [
"The computer is your friend.",
"Trust the computer.",
"Quality is mandatory.",
"Errors show your disloyalty to the computer.",
]
def __init__(self):
"""Initialize the actual GitLab client."""
config = GitlabConfig.objects.first()
self.client = gitlab.Gitlab(config.url, private_token=config.token)
self.client.auth()
def random_reviews_start_message(self):
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_START_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
random.choice(self.NOTE_COMMENT))
def random_reviews_reset_message(self):
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_RESET_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
random.choice(self.NOTE_COMMENT))
def random_review_progress_message(self):
return "{0:s} {1:s} {2:s}".format(self.REVIEWS_REVIEW_REGISTERED_FORMAT, self.REVIEWS_PROGRESS_FORMAT,
random.choice(self.NOTE_COMMENT))
def random_reviews_complete_message(self):
return "{0:s} {1:s}".format(self.REVIEWS_REVIEWS_COMPLETE, random.choice(self.NOTE_COMMENT))
def random_new_reviewer_message(self):
return "{0:s} {1:s}".format(self.NEW_REVIEWER_FORMAT, random.choice(self.NOTE_COMMENT))
def random_reviews_done_message(self):
return "{0:s} {1:s}".format(self.NEW_ACCEPTER_FORMAT, random.choice(self.NOTE_COMMENT))
def scan_project_for_reviews(self, project, merge_request_ids=None):
project_obj = self.client.projects.get(project.project_id)
if not project_obj:
return
if merge_request_ids:
merge_requests = []
for merge_request_id in merge_request_ids:
merge_requests.append(project_obj.mergerequests.get(id=merge_request_id))
else:
merge_requests = project_obj.mergerequests.list(state='opened')
for merge_request in merge_requests:
log.debug("scanning merge request '%s'", merge_request.title)
if merge_request.state in ['merged', 'closed']:
log.info("merge request '%s' is already %s, doing nothing", merge_request.title,
merge_request.state)
continue
request_state = _MergeRequestScanningState()
notes = sorted(self.client.project_mergerequest_notes.list(project_id=merge_request.project_id,
merge_request_id=merge_request.id,
all=True),
key=lambda x: x.id)
for note in notes:
if not note.system:
log.debug("merge request '%s', note '%s' is a normal message", merge_request.title, note.id)
# note that we can't ignore note.system = True + merge_request.author, that might have been
# a new push or something, so we only ignore normal notes from the author
if note.author == merge_request.author:
log.debug("skipping note from the merge request author")
elif note.author.username == self.client.user.username:
log.debug("saw a message from myself, i might have already sent a notice i'm sitting on")
if note.body.find(self.REVIEWS_RESET_FORMAT) >= 0 and request_state.unlogged_approval_reset:
log.debug("saw a reset message, unsetting the flag")
request_state.unlogged_approval_reset = False
elif note.body.find(self.REVIEWS_START_FORMAT) >= 0 and request_state.unlogged_review_start:
log.debug("saw a start message, unsetting the flag")
request_state.unlogged_review_start = False
elif note.body.find(self.REVIEWS_REVIEW_REGISTERED_FORMAT) >= 0 and request_state.unlogged_approval:
log.debug("saw a review log message, unsetting the flag")
request_state.unlogged_approval = False
elif note.body.find(self.REVIEWS_REVIEWS_COMPLETE) >= 0 and request_state.unlogged_review_complete:
log.debug("saw a review complete message, unsetting the flag")
request_state.unlogged_review_complete = False
else:
log.debug("nothing in particular relevant in '%s'", note.body)
else:
if note.body.find("LGTM") >= 0:
log.debug("merge request '%s', note '%s' has a LGTM", merge_request.title, note.id)
request_state.unlogged_approval = True
request_state.approver_list.append(note.author.username)
log.debug("approvers: %s", request_state.approver_list)
if len(request_state.approver_list) < project.code_reviews_necessary:
log.debug("not enough code reviews yet, setting needs_reviewer")
request_state.needs_reviewer = True
request_state.needs_accepter = False
else:
log.debug("enough code reviews, setting needs_accepter")
request_state.needs_accepter = True
request_state.needs_reviewer = False
request_state.unlogged_review_complete = True
else:
log.debug("merge request '%s', note '%s' does not have a LGTM", merge_request.title,
note.id)
else:
log.debug("merge request '%s', note '%s' is a system message", merge_request.title, note.id)
if re.match(r'Added \d+ commit', note.body):
log.debug("resetting approval list, '%s' looks like a push!", note.body)
# only set the unlogged approval reset flag if there's some kind of progress
if len(request_state.approver_list) > 0:
request_state.unlogged_approval_reset = True
request_state.needs_reviewer = True
request_state.approver_list.clear()
else:
log.debug("leaving the approval list as it is, i don't think '%s' is a push", note.body)
# do some cleanup
excluded_review_candidates = request_state.approver_list + [merge_request.author.username]
review_candidates = [x for x in project.code_reviewers.split(',') if x not in excluded_review_candidates]
if merge_request.assignee:
if request_state.needs_reviewer and merge_request.assignee.username in review_candidates:
log.debug("unsetting the needs_reviewer flag, the request is already assigned to one")
request_state.needs_reviewer = False
elif request_state.needs_reviewer and merge_request.assignee.username == merge_request.author.username:
log.info("unsetting the needs_reviewer flag, the request is assigned to the author")
log.info("in this case we are assuming that the author has work to do, and will re/unassign")
request_state.needs_reviewer = False
excluded_accept_candidates = [merge_request.author.username]
accept_candidates = [x for x in project.code_review_final_merge_assignees.split(',') if x not in excluded_accept_candidates]
if merge_request.assignee:
if request_state.needs_accepter and merge_request.assignee.username in accept_candidates:
log.debug("unsetting the needs_accepter flag, the request is already assigned to one")
request_state.needs_accepter = False
elif request_state.needs_accepter and merge_request.assignee.username == merge_request.author.username:
log.info("unsetting the needs_accepter flag, the request is assigned to the author")
log.info("in this case we are assuming that the author has work to do, and will re/unassign")
request_state.needs_accepter = False
log.debug("%s", request_state.__dict__)
# status message stuff
if request_state.unlogged_review_start:
log.info("sending message for start of reviews")
msg = {'body': self.random_reviews_start_message().format(len(request_state.approver_list),
project.code_reviews_necessary)}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
if request_state.unlogged_approval_reset:
log.info("sending message for review reset")
msg = {'body': self.random_reviews_reset_message().format(len(request_state.approver_list),
project.code_reviews_necessary)}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
if request_state.unlogged_approval:
log.info("sending message for code review progress")
msg = {'body': self.random_review_progress_message().format(len(request_state.approver_list),
project.code_reviews_necessary)}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
if request_state.unlogged_review_complete:
log.info("sending message for code review complete")
msg = {'body': self.random_reviews_complete_message()}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
# if there's a reviewer necessary, assign the merge request
if len(request_state.approver_list) < project.code_reviews_necessary and request_state.needs_reviewer:
log.debug("%s needs a code review", merge_request.title)
if merge_request.assignee is not None:
log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username)
if merge_request.assignee is None or merge_request.assignee.username not in review_candidates:
if len(review_candidates) > 0:
new_reviewer = review_candidates[merge_request.iid % len(review_candidates)]
log.debug("%s is the new reviewer", new_reviewer)
# get the user object for the new reviewer
new_reviewer_obj = self.client.users.get_by_username(new_reviewer)
# create note for the update
msg = {'body': self.random_new_reviewer_message().format(new_reviewer_obj.name)}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
# assign the merge request to the new reviewer
self.client.update(merge_request, assignee_id=new_reviewer_obj.id)
else:
log.warning("no reviewers left to review %s, doing nothing", merge_request.title)
else:
log.debug("needs_reviewer set but the request is assigned to a reviewer, doing nothing")
# if there's an accepter necessary, assign the merge request
if len(request_state.approver_list) >= project.code_reviews_necessary and request_state.needs_accepter:
log.debug("%s needs an accepter", merge_request.title)
if merge_request.assignee is not None:
log.debug("%s currently assigned to %s", merge_request.title, merge_request.assignee.username)
if merge_request.assignee is None or merge_request.assignee.username not in accept_candidates:
if len(accept_candidates) > 0:
new_accepter = accept_candidates[merge_request.iid % len(accept_candidates)]
log.debug("%s is the new accepter", new_accepter)
# get the user object for the new accepter
new_accepter_obj = self.client.users.get_by_username(new_accepter)
# create note for the update
msg = {'body': self.random_reviews_done_message().format(new_accepter_obj.name)}
self.client.project_mergerequest_notes.create(msg, project_id=project_obj.id,
merge_request_id=merge_request.id)
# assign the merge request to the new reviewer
self.client.update(merge_request, assignee_id=new_accepter_obj.id)
else:
log.warning("no accepters left to accept %s, doing nothing", merge_request.title)
class _MergeRequestScanningState(object):
"""Track the state of a merge request as it is scanned and appropriate action identified."""
def __init__(self):
"""Set default flags/values."""
self.approver_list = []
self.unlogged_review_start = True
self.unlogged_approval_reset = False
self.unlogged_approval = False
self.unlogged_review_complete = False
self.needs_reviewer = True
self.needs_accepter = False

View File

View File

@ -0,0 +1,20 @@
"""Find merge requests that need code reviewers."""
import logging
from django.core.management import BaseCommand
from gitlab_bot.lib import GitlabBot
from gitlab_bot.models import GitlabProjectConfig
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Find merge requests needing code reviewers/accepters"
def handle(self, *args, **options):
bot = GitlabBot()
projects = GitlabProjectConfig.objects.filter(manage_merge_request_code_reviews=True)
for project in projects:
bot.scan_project_for_reviews(project)

View File

@ -0,0 +1,25 @@
"""Run the code review process on a specific merge request."""
import logging
from django.core.management import BaseCommand
from gitlab_bot.lib import GitlabBot
from gitlab_bot.models import GitlabProjectConfig
log = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Assign code reviewers/accepters for a specific merge request"
def add_arguments(self, parser):
parser.add_argument('project_id', type=int)
parser.add_argument('merge_request_id', type=int)
def handle(self, *args, **options):
project = GitlabProjectConfig.objects.get(pk=options['project_id'])
merge_request_ids = [options['merge_request_id'], ]
bot = GitlabBot()
bot.scan_project_for_reviews(project, merge_request_ids=merge_request_ids)

View File

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='GitlabConfig',
fields=[
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
('url', models.URLField()),
('token', models.CharField(max_length=64)),
],
),
migrations.CreateModel(
name='GitlabProjectConfig',
fields=[
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
('project_id', models.CharField(max_length=64)),
('manage_merge_request_code_reviews', models.BooleanField(default=False)),
('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)),
('code_reviewers', models.TextField(blank=True, default='')),
('code_review_final_merge_assignees', models.TextField(blank=True, default='')),
('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True, on_delete=models.CASCADE)),
],
),
]

View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('gitlab_bot', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='gitlabprojectconfig',
name='project_id',
field=models.CharField(unique=True, max_length=64),
),
]

View File

36
gitlab_bot/models.py Normal file
View File

@ -0,0 +1,36 @@
"""Bot/daemons for doing stuff with GitLab."""
import logging
from django.db import models
log = logging.getLogger(__name__)
class GitlabConfig(models.Model):
"""Maintain bot-wide settings (URL, auth key, etc.)."""
url = models.URLField()
token = models.CharField(max_length=64)
def __str__(self):
"""String representation."""
return "bot @ {0:s}".format(self.url)
class GitlabProjectConfig(models.Model):
"""Maintain settings for a particular project in GitLab."""
gitlab_config = models.ForeignKey('GitlabConfig', null=True, on_delete=models.CASCADE)
project_id = models.CharField(max_length=64, unique=True)
manage_merge_request_code_reviews = models.BooleanField(default=False)
code_reviews_necessary = models.PositiveSmallIntegerField(default=0)
code_reviewers = models.TextField(default='', blank=True)
code_review_final_merge_assignees = models.TextField(default='', blank=True)
def __str__(self):
"""String representation."""
return "configuration for {0:s} @ {1:s}".format(self.project_id, self.gitlab_config.url)

View File

@ -1 +0,0 @@
"""Track IRC channel history and report on it for users."""

View File

@ -1,160 +0,0 @@
"""Monitor and playback IRC stuff."""
import logging
from datetime import datetime
import irc.client
from ircbot.lib import Plugin, most_specific_message
logger = logging.getLogger(__name__)
class History(Plugin):
"""Watch the history of IRC channels and try to track what users may have missed."""
what_missed_regex = r'(?i)(what did I miss\?|did I miss anything\?)$'
def __init__(self, bot, connection, event):
"""Initialize some tracking stuff."""
super(History, self).__init__(bot, connection, event)
self.channel_history = {}
self.channel_participants = {}
self.channel_leave_points = {}
def start(self):
"""Set up the handlers."""
logger.debug("%s starting up", __name__)
self.connection.add_global_handler('pubmsg', self.handle_chatter, 50)
self.connection.add_global_handler('join', self.handle_join, 50)
self.connection.add_global_handler('part', self.handle_part, 50)
self.connection.add_global_handler('quit', self.handle_quit, 50)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], self.what_missed_regex,
self.handle_what_missed, 60)
super(History, self).start()
def stop(self):
"""Tear down handlers."""
logger.debug("%s shutting down", __name__)
self.connection.remove_global_handler('pubmsg', self.handle_chatter)
self.connection.remove_global_handler('join', self.handle_join)
self.connection.remove_global_handler('part', self.handle_part)
self.connection.remove_global_handler('quit', self.handle_quit)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_what_missed)
super(History, self).stop()
def handle_chatter(self, connection, event):
"""Track IRC chatter."""
what = event.arguments[0]
where = event.target
who = irc.client.NickMask(event.source).nick
when = datetime.now()
logger.debug("tracking message for %s: (%s,%s)", where, who, what)
history = self.channel_history.setdefault(where, [])
history.append((where, when.isoformat(), who, what))
logger.debug("history for %s: %s", where, history)
# for when we maybe don't see a join, if they talked in the channel, add them to it
self._add_channel_participant(where, who)
def handle_join(self, connection, event):
"""Track who is entitled to see channel history."""
where = event.target
who = irc.client.NickMask(event.source).nick
logger.debug("%s joined %s", who, where)
self._add_channel_participant(where, who)
def handle_part(self, connection, event):
"""Note when people leave IRC channels."""
where = event.target
who = irc.client.NickMask(event.source).nick
logger.debug("%s left %s", who, where)
# if they parted the channel, they must have been in it, so note their point in history
self._add_channel_leave_point(where, who)
self._remove_channel_participant(where, who)
def handle_quit(self, connection, event):
"""Note when people leave IRC."""
who = irc.client.NickMask(event.source).nick
logger.debug("%s disconnected", who)
# find all channels the quitter was in, save their leave points
for channel in self.channel_participants.keys():
self._add_channel_leave_point(channel, who)
self._remove_channel_participant(channel, who)
def handle_what_missed(self, connection, event, match):
"""Tell the user what they missed."""
who = irc.client.NickMask(event.source).nick
if event.in_privmsg or event.addressed:
logger.debug("<%s> %s is asking for an update", who, most_specific_message(event))
if event.in_privmsg:
total_history = []
channel_count = 0
for channel in self.channel_leave_points.keys():
logger.debug("checking history slice for %s", channel)
total_history += self._missed_slice(channel, who)
self._delete_channel_leave_point(channel, who)
logger.debug("total history so far: %s", total_history)
channel_count += 1
logger.debug("final missed history: %s", total_history)
self._send_history(who, total_history)
self.bot.reply(event, f"{len(total_history)} line(s) over {channel_count} channel(s)")
return 'NO MORE'
else:
where = event.target
history = self._missed_slice(where, who)
self._delete_channel_leave_point(where, who)
self._send_history(who, history)
privmsged_str = " (PRIVMSGed)" if history else ""
self.bot.reply(event, f"{len(history)} line(s){privmsged_str}")
return 'NO MORE'
def _send_history(self, who, history):
"""Reply to who with missed history."""
for line in history:
self.bot.privmsg(who, f"{line[0]}: [{line[1]}] <{line[2]}> {line[3]}")
def _add_channel_leave_point(self, where, who):
"""Note that the given who left the channel at the current history point."""
leave_points = self.channel_leave_points.setdefault(where, {})
leave_points[who] = len(self.channel_history.setdefault(where, [])) - 1
logger.debug("leave points for %s: %s", where, leave_points)
def _delete_channel_leave_point(self, where, who):
"""Remove tracking for user's history point."""
leave_points = self.channel_leave_points.setdefault(where, {})
leave_points.pop(who, None)
logger.debug("leave points for %s: %s", where, leave_points)
def _add_channel_participant(self, where, who):
"""Add a who to the list of people who are/were in a channel."""
participants = self.channel_participants.setdefault(where, set())
participants.add(who)
logger.debug("participants for %s: %s", where, participants)
def _missed_slice(self, where, who):
"""Get the lines in where since who last left."""
leave_points = self.channel_leave_points.setdefault(where, {})
if leave_points.get(who) is not None:
leave_point = leave_points.get(who) + 1
history = self.channel_history.setdefault(where, [])
missed_history = history[leave_point:]
logger.debug("in %s, %s missed: %s", where, who, missed_history)
return missed_history
return []
def _remove_channel_participant(self, where, who):
"""Remove the specified who from the where channel's participants list."""
participants = self.channel_participants.setdefault(where, set())
participants.discard(who)
logger.debug("participants for %s: %s", where, participants)
plugin = History

View File

@ -3,15 +3,23 @@
import logging
import xmlrpc.client
from django.conf import settings
from django.contrib import admin
from django.shortcuts import render
from ircbot.forms import PrivmsgForm
from ircbot.models import Alias, BotUser, IrcChannel, IrcPlugin, IrcServer
from ircbot.models import Alias, BotUser, IrcChannel, IrcPlugin
log = logging.getLogger('ircbot.admin')
admin.site.register(Alias)
admin.site.register(BotUser)
admin.site.register(IrcChannel)
admin.site.register(IrcPlugin)
def send_privmsg(request):
"""Send a privmsg over XML-RPC to the IRC bot."""
if request.method == 'POST':
@ -20,8 +28,7 @@ def send_privmsg(request):
target = form.cleaned_data['target']
message = form.cleaned_data['message']
bot_url = 'http://{0:s}:{1:d}/'.format(form.cleaned_data['xmlrpc_host'],
form.cleaned_data['xmlrpc_port'])
bot_url = 'http://{0:s}:{1:d}/'.format(settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT)
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
bot.reply(None, message, False, target)
form = PrivmsgForm()
@ -30,11 +37,4 @@ def send_privmsg(request):
return render(request, 'privmsg.html', {'form': form})
admin.site.register(Alias)
admin.site.register(BotUser)
admin.site.register(IrcChannel)
admin.site.register(IrcPlugin)
admin.site.register(IrcServer)
admin.site.register_view('ircbot/privmsg/', "Ircbot - privmsg", view=send_privmsg, urlname='ircbot_privmsg')

View File

@ -1,36 +1,46 @@
"""Provide the base IRC client bot which other code can latch onto."""
import bisect
import collections
import copy
import importlib
import logging
import re
from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler
import socket
import ssl
import sys
import threading
import time
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
from django.conf import settings
import irc.buffer
import irc.client
import irc.modes
from irc.bot import Channel
from irc.connection import Factory
from irc.dict import IRCDict
from jaraco.stream import buffer
import irc.modes
import ircbot.lib as ircbotlib
from dr_botzo import __version__
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
from ircbot.models import Alias, IrcChannel, IrcPlugin
log = logging.getLogger('ircbot.bot')
class IrcBotXMLRPCRequestHandler(SimpleXMLRPCRequestHandler):
"""Override the basic request handler to change the logging."""
def log_message(self, format, *args):
"""Use a logger rather than stderr."""
log.debug("XML-RPC - %s - %s", self.client_address[0], format % args)
class PrioritizedRegexHandler(collections.namedtuple('Base', ('priority', 'regex', 'callback'))):
"""Regex handler that still uses the normal handler priority stuff."""
def __lt__(self, other):
"""When sorting prioritized handlers, only use the priority."""
"""When sorting prioritized handlers, only use the priority"""
return self.priority < other.priority
@ -42,9 +52,7 @@ class LenientServerConnection(irc.client.ServerConnection):
method on a Reactor object.
"""
buffer_class = buffer.LenientDecodingLineBuffer
server_config = None
buffer_class = irc.buffer.LenientDecodingLineBuffer
def _prep_message(self, string):
"""Override SimpleIRCClient._prep_message to add some logging."""
@ -55,9 +63,6 @@ class LenientServerConnection(irc.client.ServerConnection):
class DrReactor(irc.client.Reactor):
"""Customize the basic IRC library's Reactor with more features."""
# used by Reactor.server() to initialize
connection_class = LenientServerConnection
def __do_nothing(*args, **kwargs):
pass
@ -66,10 +71,18 @@ class DrReactor(irc.client.Reactor):
super(DrReactor, self).__init__(on_connect=on_connect, on_disconnect=on_disconnect)
self.regex_handlers = {}
def server(self):
"""Creates and returns a ServerConnection object."""
c = LenientServerConnection(self)
with self.mutex:
self.connections.append(c)
return c
def add_global_regex_handler(self, events, regex, handler, priority=0):
"""Add a global handler function for a specific event type and regex.
"""Adds a global handler function for a specific event type and regex.
Arguments:
events --- Event type(s) (a list of strings).
handler -- Callback function taking connection and event
@ -99,9 +112,10 @@ class DrReactor(irc.client.Reactor):
bisect.insort(event_regex_handlers, handler)
def remove_global_regex_handler(self, events, handler):
"""Remove a global regex handler function.
"""Removes a global regex handler function.
Arguments:
events -- Event type(s) (a list of strings).
handler -- Callback function.
@ -149,31 +163,13 @@ class DrReactor(irc.client.Reactor):
event.original_msg = what
# check if we were addressed or not
if connection.server_config.additional_addressed_nicks:
all_nicks = '|'.join(connection.server_config.additional_addressed_nicks.split('\n') +
[connection.get_nickname()])
else:
all_nicks = connection.get_nickname()
addressed_pattern = r'^(({nicks})[:,]|@({nicks})[:,]?)\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
# ignore the first word, a nick, if the speaker is the bridge
try:
channel = IrcChannel.objects.get(name=sent_location)
if sender_nick == channel.discord_bridge:
short_what = ' '.join(what.split(' ')[1:])
match = re.match(addressed_pattern, short_what, re.IGNORECASE)
event.arguments[0] = short_what
else:
match = re.match(addressed_pattern, what, re.IGNORECASE)
except IrcChannel.DoesNotExist:
match = re.match(addressed_pattern, what, re.IGNORECASE)
all_nicks = '|'.join(settings.ADDITIONAL_NICK_MATCHES + [connection.get_nickname()])
addressed_pattern = r'^(({nicks})[:,]|@({nicks}))\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
match = re.match(addressed_pattern, what, re.IGNORECASE)
if match:
event.addressed = True
event.addressed_msg = match.group('addressed_msg')
log.debug("all_nicks: %s, addressed: %s", all_nicks, event.addressed)
# only do aliasing for pubmsg/privmsg
log.debug("checking for alias for %s", what)
@ -213,7 +209,8 @@ class DrReactor(irc.client.Reactor):
if result == "NO MORE":
return
except Exception as ex:
log.exception("caught exception!")
log.error("caught exception!")
log.exception(ex)
connection.privmsg(event.target, str(ex))
def try_recursion(self, connection, event):
@ -354,16 +351,15 @@ class IRCBot(irc.client.SimpleIRCClient):
reactor_class = DrReactor
splitter = "..."
def __init__(self, server_name, reconnection_interval=60):
def __init__(self, reconnection_interval=60):
"""Initialize bot."""
super(IRCBot, self).__init__()
self.channels = IRCDict()
self.plugins = []
self.server_config = IrcServer.objects.get(name=server_name)
# the reactor made the connection, save the server reference in it since we pass that around
self.connection.server_config = self.server_config
# set up the server list
self.server_list = settings.IRCBOT_SERVER_LIST
# set reconnection interval
if not reconnection_interval or reconnection_interval < 0:
@ -371,8 +367,8 @@ class IRCBot(irc.client.SimpleIRCClient):
self.reconnection_interval = reconnection_interval
# set basic stuff
self._nickname = self.server_config.nickname
self._realname = self.server_config.realname
self._nickname = settings.IRCBOT_NICKNAME
self._realname = settings.IRCBOT_REALNAME
# guess at nickmask. hopefully _on_welcome() will set this, but this should be
# a pretty good guess if not
@ -399,8 +395,8 @@ class IRCBot(irc.client.SimpleIRCClient):
getattr(self, 'handle_reload'), -20)
# load XML-RPC server
self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port),
requestHandler=SimpleXMLRPCRequestHandler, allow_none=True)
self.xmlrpc = SimpleXMLRPCServer((settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT),
requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True)
self.xmlrpc.register_introspection_functions()
t = threading.Thread(target=self._xmlrpc_listen, args=())
@ -413,25 +409,28 @@ class IRCBot(irc.client.SimpleIRCClient):
def _connected_checker(self):
if not self.connection.is_connected():
self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
self.connection.execute_delayed(self.reconnection_interval,
self._connected_checker)
self.jump_server()
def _connect(self):
server = self.server_list[0]
try:
# build the connection factory as determined by IPV6/SSL settings
if self.server_config.use_ssl:
connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=self.server_config.use_ipv6)
if settings.IRCBOT_SSL:
connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=settings.IRCBOT_IPV6)
else:
connect_factory = Factory(ipv6=self.server_config.use_ipv6)
connect_factory = Factory(ipv6=settings.IRCBOT_IPV6)
self.connect(self.server_config.hostname, self.server_config.port, self._nickname,
self.server_config.password, ircname=self._realname, connect_factory=connect_factory)
self.connect(server[0], server[1], self._nickname, server[2], ircname=self._realname,
connect_factory=connect_factory)
except irc.client.ServerConnectionError:
pass
def _on_disconnect(self, c, e):
self.channels = IRCDict()
self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
self.connection.execute_delayed(self.reconnection_interval,
self._connected_checker)
def _on_join(self, c, e):
ch = e.target
@ -529,18 +528,17 @@ class IRCBot(irc.client.SimpleIRCClient):
log.debug("welcome: %s", what)
# run automsg commands
if self.server_config.post_connect:
for cmd in self.server_config.post_connect.split('\n'):
# TODO NOTE: if the bot is sending something that changes the vhost
# (like 'hostserv on') we don't pick it up
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:]))
for cmd in settings.IRCBOT_POST_CONNECT_COMMANDS:
# TODO NOTE: if the bot is sending something that changes the vhost
# (like 'hostserv on') we don't pick it up
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:]))
# sleep before doing autojoins
time.sleep(self.server_config.delay_before_joins)
time.sleep(settings.IRCBOT_SLEEP_BEFORE_AUTOJOIN_SECONDS)
for chan in IrcChannel.objects.filter(autojoin=True, server=connection.server_config):
for chan in IrcChannel.objects.filter(autojoin=True):
log.info("autojoining %s", chan.name)
self.connection.join(chan.name)
self.connection.join(chan)
for plugin in IrcPlugin.objects.filter(autoload=True):
log.info("autoloading %s", plugin.path)
@ -574,21 +572,23 @@ class IRCBot(irc.client.SimpleIRCClient):
self.connection.disconnect(msg)
def get_version(self):
"""Return the bot version.
"""Returns the bot version.
Used when answering a CTCP VERSION request.
"""
return f"dr.botzo {__version__}"
return "Python irc.bot ({version})".format(
version=irc.client.VERSION_STRING)
def jump_server(self, msg="Changing servers"):
"""Connect to a new server, potentially disconnecting from the current one."""
if self.connection.is_connected():
self.connection.disconnect(msg)
self.server_list.append(self.server_list.pop(0))
self._connect()
def on_ctcp(self, c, e):
"""Handle for ctcp events.
"""Default handler for ctcp events.
Replies to VERSION and PING requests and relays DCC requests
to the on_dccchat method.
@ -889,14 +889,6 @@ class IRCBot(irc.client.SimpleIRCClient):
log.warning("reply() called with no event and no explicit target, aborting")
return
# convert characters that don't make sense for Discord (like ^C^B)
if self.connection.server_config.replace_irc_control_with_markdown:
log.debug("old replystr: %s", replystr)
replystr = replystr.replace('\x02', '**')
replystr = replystr.replace('\x0F', '')
replystr = re.sub('\x03..', '', replystr)
log.debug("new replystr: %s", replystr)
log.debug("replypath: %s", replypath)
if replystr is not None:
@ -978,3 +970,165 @@ class IRCBot(irc.client.SimpleIRCClient):
del sys.modules[path]
self.die(msg="Shutting down...")
class Channel(object):
"""A class for keeping information about an IRC channel."""
def __init__(self):
"""Initialize channel object."""
self.userdict = IRCDict()
self.operdict = IRCDict()
self.voiceddict = IRCDict()
self.ownerdict = IRCDict()
self.halfopdict = IRCDict()
self.modes = {}
def users(self):
"""Returns an unsorted list of the channel's users."""
return list(self.userdict.keys())
def opers(self):
"""Returns an unsorted list of the channel's operators."""
return list(self.operdict.keys())
def voiced(self):
"""Returns an unsorted list of the persons that have voice mode set in the channel."""
return list(self.voiceddict.keys())
def owners(self):
"""Returns an unsorted list of the channel's owners."""
return list(self.ownerdict.keys())
def halfops(self):
"""Returns an unsorted list of the channel's half-operators."""
return list(self.halfopdict.keys())
def has_user(self, nick):
"""Check whether the channel has a user."""
return nick in self.userdict
def is_oper(self, nick):
"""Check whether a user has operator status in the channel."""
return nick in self.operdict
def is_voiced(self, nick):
"""Check whether a user has voice mode set in the channel."""
return nick in self.voiceddict
def is_owner(self, nick):
"""Check whether a user has owner status in the channel."""
return nick in self.ownerdict
def is_halfop(self, nick):
"""Check whether a user has half-operator status in the channel."""
return nick in self.halfopdict
def add_user(self, nick):
"""Add user."""
self.userdict[nick] = 1
def remove_user(self, nick):
"""Remove user."""
for d in self.userdict, self.operdict, self.voiceddict:
if nick in d:
del d[nick]
def change_nick(self, before, after):
"""Handle a nick change."""
self.userdict[after] = self.userdict.pop(before)
if before in self.operdict:
self.operdict[after] = self.operdict.pop(before)
if before in self.voiceddict:
self.voiceddict[after] = self.voiceddict.pop(before)
def set_userdetails(self, nick, details):
"""Set user details."""
if nick in self.userdict:
self.userdict[nick] = details
def set_mode(self, mode, value=None):
"""Set mode on the channel.
Arguments:
mode -- The mode (a single-character string).
value -- Value
"""
if mode == "o":
self.operdict[value] = 1
elif mode == "v":
self.voiceddict[value] = 1
elif mode == "q":
self.ownerdict[value] = 1
elif mode == "h":
self.halfopdict[value] = 1
else:
self.modes[mode] = value
def clear_mode(self, mode, value=None):
"""Clear mode on the channel.
Arguments:
mode -- The mode (a single-character string).
value -- Value
"""
try:
if mode == "o":
del self.operdict[value]
elif mode == "v":
del self.voiceddict[value]
elif mode == "q":
del self.ownerdict[value]
elif mode == "h":
del self.halfopdict[value]
else:
del self.modes[mode]
except KeyError:
pass
def has_mode(self, mode):
"""Return if mode is in channel modes."""
return mode in self.modes
def is_moderated(self):
"""Return if the channel is +m."""
return self.has_mode("m")
def is_secret(self):
"""Return if the channel is +s."""
return self.has_mode("s")
def is_protected(self):
"""Return if the channel is +p."""
return self.has_mode("p")
def has_topic_lock(self):
"""Return if the channel is +t."""
return self.has_mode("t")
def is_invite_only(self):
"""Return if the channel is +i."""
return self.has_mode("i")
def has_allow_external_messages(self):
"""Return if the channel is +n."""
return self.has_mode("n")
def has_limit(self):
"""Return if the channel is +l."""
return self.has_mode("l")
def limit(self):
"""Return the channel limit count."""
if self.has_limit():
return self.modes["l"]
else:
return None
def has_key(self):
"""Return if the channel is +k."""
return self.has_mode("k")

View File

@ -1,9 +1,10 @@
"""Forms for doing ircbot stuff."""
import logging
from django.forms import CharField, Form, IntegerField, Textarea
from django.forms import Form, CharField, Textarea
log = logging.getLogger('ircbot.forms')
log = logging.getLogger('markov.forms')
class PrivmsgForm(Form):
@ -11,5 +12,3 @@ class PrivmsgForm(Form):
target = CharField()
message = CharField(widget=Textarea)
xmlrpc_host = CharField()
xmlrpc_port = IntegerField()

View File

@ -1,17 +1,19 @@
"""Provide some commands for basic IRC functionality."""
import logging
from ircbot.lib import Plugin, has_permission
from ircbot.models import IrcChannel
log = logging.getLogger('ircbot.ircplugins.ircmgmt')
class ChannelManagement(Plugin):
"""Have IRC commands to do IRC things (join channels, quit, etc.)."""
def start(self):
"""Set up the handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!join\s+([\S]+)',
self.handle_join, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!part\s+([\S]+)',
@ -23,6 +25,7 @@ class ChannelManagement(Plugin):
def stop(self):
"""Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_join)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_part)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_quit)
@ -31,10 +34,11 @@ class ChannelManagement(Plugin):
def handle_join(self, connection, event, match):
"""Handle the join command."""
if has_permission(event.source, 'ircbot.manage_current_channels'):
channel = match.group(1)
# put it in the database if it isn't already
chan_mod, c = IrcChannel.objects.get_or_create(name=channel, server=connection.server_config)
chan_mod, c = IrcChannel.objects.get_or_create(name=channel)
log.debug("joining channel %s", channel)
self.connection.join(channel)
@ -42,10 +46,11 @@ class ChannelManagement(Plugin):
def handle_part(self, connection, event, match):
"""Handle the join command."""
if has_permission(event.source, 'ircbot.manage_current_channels'):
channel = match.group(1)
# put it in the database if it isn't already
chan_mod, c = IrcChannel.objects.get_or_create(name=channel, server=connection.server_config)
chan_mod, c = IrcChannel.objects.get_or_create(name=channel)
log.debug("parting channel %s", channel)
self.connection.part(channel)
@ -53,6 +58,7 @@ class ChannelManagement(Plugin):
def handle_quit(self, connection, event, match):
"""Handle the join command."""
if has_permission(event.source, 'ircbot.quit_bot'):
self.bot.die(msg=match.group(1))

View File

@ -1,4 +1,5 @@
"""Watch channel topics for changes and note them."""
import logging
from django.utils import timezone
@ -6,20 +7,24 @@ from django.utils import timezone
from ircbot.lib import Plugin
from ircbot.models import IrcChannel
log = logging.getLogger('ircbot.ircplugins.topicmonitor')
class TopicMonitor(Plugin):
"""Have IRC commands to do IRC things (join channels, quit, etc.)."""
def start(self):
"""Set up the handlers."""
self.connection.reactor.add_global_handler('topic', handle_topic, -20)
super(TopicMonitor, self).start()
def stop(self):
"""Tear down handlers."""
self.connection.reactor.remove_global_handler('topic', handle_topic)
super(TopicMonitor, self).stop()
@ -27,12 +32,13 @@ class TopicMonitor(Plugin):
def handle_topic(connection, event):
"""Store topic changes in the channel model."""
channel = event.target
topic = event.arguments[0]
setter = event.source
log.debug("topic change '%s' by %s in %s", topic, setter, channel)
channel, c = IrcChannel.objects.get_or_create(name=channel, server=connection.server_config)
channel, c = IrcChannel.objects.get_or_create(name=channel)
channel.topic_msg = topic
channel.topic_time = timezone.now()
channel.topic_by = setter

View File

@ -1,4 +1,5 @@
"""Start the IRC bot via Django management command."""
import logging
import signal
@ -6,6 +7,7 @@ from django.core.management import BaseCommand
from ircbot.bot import IRCBot
log = logging.getLogger('ircbot')
@ -17,13 +19,9 @@ class Command(BaseCommand):
help = "Start the IRC bot"
def add_arguments(self, parser):
"""Add arguments to the bot startup."""
parser.add_argument('server_name')
def handle(self, *args, **options):
"""Start the IRC bot and spin forever."""
self.stdout.write(self.style.NOTICE(f"Starting up {options['server_name']} bot"))
irc = IRCBot(options['server_name'])
irc = IRCBot()
signal.signal(signal.SIGINT, irc.sigint_handler)
irc.start()

View File

@ -1,32 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-25 14:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0014_auto_20160116_1955'),
]
operations = [
migrations.CreateModel(
name='IrcServer',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=200, unique=True)),
('hostname', models.CharField(max_length=200)),
('port', models.PositiveSmallIntegerField(default=6667)),
('password', models.CharField(blank=True, default=None, max_length=200, null=True)),
('nickname', models.CharField(max_length=32)),
('realname', models.CharField(blank=True, default='', max_length=32)),
('additional_addressed_nicks', models.TextField(blank=True, default='', help_text='For e.g. BitlBee alternative nicks')),
('use_ssl', models.BooleanField(default=False)),
('use_ipv6', models.BooleanField(default=False)),
('post_connect', models.TextField(blank=True, default='')),
('delay_before_joins', models.PositiveSmallIntegerField(default=0)),
('xmlrpc_host', models.CharField(default='localhost', max_length=200)),
('xmlrpc_port', models.PositiveSmallIntegerField(default=13132)),
],
),
]

View File

@ -1,26 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-25 04:11
from django.db import migrations
def create_placeholder_server(apps, schema_editor):
"""Create the first server entry, to be configured by the admin."""
IrcServer = apps.get_model('ircbot', 'IrcServer')
IrcServer.objects.create(name='default', hostname='irc.example.org', port=6667)
def delete_placeholder_server(apps, schema_editor):
"""Remove the default server."""
IrcServer = apps.get_model('ircbot', 'IrcServer')
IrcServer.objects.filter(name='default').delete()
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0015_ircserver'),
]
operations = [
migrations.RunPython(create_placeholder_server, delete_placeholder_server),
]

View File

@ -1,30 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-25 16:11
from django.db import migrations, models
import django.db.models.deletion
import ircbot.models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0016_placeholder_ircserver'),
]
operations = [
migrations.AddField(
model_name='ircchannel',
name='server',
field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='ircbot.ircserver'),
preserve_default=False,
),
migrations.AlterField(
model_name='ircchannel',
name='name',
field=ircbot.models.LowerCaseCharField(max_length=200),
),
migrations.AddConstraint(
model_name='ircchannel',
constraint=models.UniqueConstraint(fields=('name', 'server'), name='unique_server_channel'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.1.2 on 2021-04-25 17:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0017_ircchannel_server'),
]
operations = [
migrations.AddField(
model_name='ircserver',
name='replace_irc_control_with_markdown',
field=models.BooleanField(default=False),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.18 on 2023-02-16 22:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0018_ircserver_replace_irc_control_with_markdown'),
]
operations = [
migrations.AddField(
model_name='ircchannel',
name='discord_bridge',
field=models.CharField(blank=True, default='', max_length=32),
),
]

View File

@ -1,4 +1,5 @@
"""Track basic IRC settings and similar."""
import logging
import re
@ -6,14 +7,12 @@ from django.conf import settings
from django.db import models
from django.utils import timezone
log = logging.getLogger('ircbot.models')
class LowerCaseCharField(models.CharField):
"""Provide a case-insensitive, forced-lower CharField."""
def get_prep_value(self, value):
"""Manipulate the field value to make it lowercase."""
value = super(LowerCaseCharField, self).get_prep_value(value)
if value is not None:
value = value.lower()
@ -21,22 +20,21 @@ class LowerCaseCharField(models.CharField):
class Alias(models.Model):
"""Allow for aliasing of arbitrary regexes to normal supported commands."""
pattern = models.CharField(max_length=200, unique=True)
replacement = models.CharField(max_length=200)
class Meta:
"""Settings for the model."""
verbose_name_plural = "aliases"
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s} -> {1:s}".format(self.pattern, self.replacement)
def replace(self, what):
"""Match the regex and replace with the command."""
command = None
if re.search(self.pattern, what, flags=re.IGNORECASE):
command = re.sub(self.pattern, self.replacement, what, flags=re.IGNORECASE)
@ -45,57 +43,28 @@ class Alias(models.Model):
class BotUser(models.Model):
"""Configure bot users, which can do things through the bot and standard Django auth."""
nickmask = models.CharField(max_length=200, unique=True)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
class Meta:
"""Settings for the model."""
permissions = (
('quit_bot', "Can tell the bot to quit via IRC"),
)
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s} (Django user {1:s})".format(self.nickmask, self.user.username)
class IrcServer(models.Model):
"""Contain server information in an object, and help contextualize channels."""
name = models.CharField(max_length=200, unique=True)
hostname = models.CharField(max_length=200)
port = models.PositiveSmallIntegerField(default=6667)
password = models.CharField(max_length=200, default=None, null=True, blank=True)
nickname = models.CharField(max_length=32)
realname = models.CharField(max_length=32, default='', blank=True)
additional_addressed_nicks = models.TextField(default='', blank=True,
help_text="For e.g. BitlBee alternative nicks")
use_ssl = models.BooleanField(default=False)
use_ipv6 = models.BooleanField(default=False)
post_connect = models.TextField(default='', blank=True)
delay_before_joins = models.PositiveSmallIntegerField(default=0)
xmlrpc_host = models.CharField(max_length=200, default='localhost')
xmlrpc_port = models.PositiveSmallIntegerField(default=13132)
replace_irc_control_with_markdown = models.BooleanField(default=False)
def __str__(self):
"""Provide string summary of the server."""
return f"{self.name} ({self.hostname}/{self.port})"
class IrcChannel(models.Model):
"""Track channel settings."""
name = LowerCaseCharField(max_length=200)
server = models.ForeignKey('IrcServer', on_delete=models.CASCADE)
name = LowerCaseCharField(max_length=200, unique=True)
autojoin = models.BooleanField(default=False)
topic_msg = models.TextField(default='', blank=True)
@ -104,37 +73,31 @@ class IrcChannel(models.Model):
markov_learn_from_channel = models.BooleanField(default=True)
discord_bridge = models.CharField(default='', max_length=32, blank=True)
class Meta:
"""Settings for the model."""
constraints = (
models.UniqueConstraint(fields=['name', 'server'], name='unique_server_channel'),
)
permissions = (
('manage_current_channels', "Can join/part channels via IRC"),
)
def __str__(self):
"""Provide string representation."""
return "{0:s} on {1:s}".format(self.name, self.server.name)
"""String representation."""
return "{0:s}".format(self.name)
class IrcPlugin(models.Model):
"""Represent an IRC plugin and its loading settings."""
path = models.CharField(max_length=200, unique=True)
autoload = models.BooleanField(default=False)
class Meta:
"""Settings for the model."""
ordering = ['path']
permissions = (
('manage_loaded_plugins', "Can load/unload plugins via IRC"),
)
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s}".format(self.path)

View File

@ -1,16 +1,16 @@
"""URL patterns for the karma views."""
from django.conf.urls import include
from django.urls import path, re_path
from django.conf.urls import url, include
from rest_framework.routers import DefaultRouter
from karma.views import KarmaKeyViewSet, index, key_detail
from karma.views import key_detail, index, KarmaKeyViewSet
router = DefaultRouter()
router.register(r'keys', KarmaKeyViewSet)
urlpatterns = [
path('', index, name='karma_index'),
re_path(r'^key/(?P<karma_key>.+)/', key_detail, name='karma_key_detail'),
url(r'^$', index, name='karma_index'),
url(r'^key/(?P<karma_key>.+)/', key_detail, name='karma_key_detail'),
path('api/', include(router.urls)),
url(r'^api/', include(router.urls)),
]

View File

@ -1,32 +1,27 @@
"""Present karma data."""
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, render
from rest_framework import viewsets
from karma.models import KarmaKey
from karma.serializers import KarmaKeySerializer
log = logging.getLogger(__name__)
log = logging.getLogger('karma.views')
def index(request):
"""Display all karma keys."""
if 'karma' not in settings.WEB_ENABLED_APPS:
raise PermissionDenied()
entries = KarmaKey.objects.all().order_by('key')
return render(request, 'karma/index.html', {'entries': entries})
def key_detail(request, karma_key):
"""Display the requested karma key."""
if 'karma' not in settings.WEB_ENABLED_APPS:
raise PermissionDenied()
entry = get_object_or_404(KarmaKey, key=karma_key.lower())
return render(request, 'karma/karma_key.html', {'entry': entry, 'entry_history': entry.history(mode='date')})

View File

@ -1,22 +1,23 @@
"""IRC support for Markov chain learning and text generation."""
import logging
import re
import irc.client
from django.conf import settings
import markov.lib as markovlib
from ircbot.lib import Plugin, reply_destination_for_event
from ircbot.models import IrcChannel
from markov.models import MarkovContext, MarkovTarget
import markov.lib as markovlib
log = logging.getLogger('markov.ircplugin')
class Markov(Plugin):
"""Build Markov chains and reply with them."""
def start(self):
"""Set up the handlers."""
self.connection.add_global_handler('pubmsg', self.handle_chatter, -20)
self.connection.add_global_handler('privmsg', self.handle_chatter, -20)
@ -28,6 +29,7 @@ class Markov(Plugin):
def stop(self):
"""Tear down handlers."""
self.connection.remove_global_handler('pubmsg', self.handle_chatter)
self.connection.remove_global_handler('privmsg', self.handle_chatter)
@ -37,11 +39,12 @@ class Markov(Plugin):
def handle_reply(self, connection, event, match):
"""Generate a reply to one line, without learning it."""
target = reply_destination_for_event(event)
min_size = 15
max_size = 30
context = self.get_or_create_target_context(target)
context = markovlib.get_or_create_target_context(target)
if match.group(2):
min_size = int(match.group(2))
@ -60,45 +63,33 @@ class Markov(Plugin):
def handle_chatter(self, connection, event):
"""Learn from IRC chatter."""
what = event.arguments[0]
who = irc.client.NickMask(event.source).nick
all_nicks = '|'.join(settings.ADDITIONAL_NICK_MATCHES + [connection.get_nickname()])
trimmed_what = re.sub(r'^(({nicks})[:,]|@({nicks}))\s+'.format(nicks=all_nicks), '', what)
nick = irc.client.NickMask(event.source).nick
target = reply_destination_for_event(event)
log.debug("what: '%s', who: '%s', target: '%s'", what, who, target)
# check to see whether or not we should learn from this channel
channel = None
if irc.client.is_channel(target):
channel, c = IrcChannel.objects.get_or_create(name=target, server=connection.server_config)
channel, c = IrcChannel.objects.get_or_create(name=target)
if channel and not channel.markov_learn_from_channel:
log.debug("not learning from %s as i've been told to ignore it", channel)
else:
# learn the line
learning_what = what
# don't learn the speaker's nick if this came over a bridge
if channel and who == channel.discord_bridge:
learning_what = ' '.join(learning_what.split(' ')[1:])
# remove our own nick and aliases from what we learn
if connection.server_config.additional_addressed_nicks:
all_nicks = '|'.join(connection.server_config.additional_addressed_nicks.split('\n') +
[connection.get_nickname()])
else:
all_nicks = connection.get_nickname()
learning_what = re.sub(r'^(({nicks})[:,]|@({nicks})[:,]?)\s+'.format(nicks=all_nicks), '', learning_what)
recursing = getattr(event, 'recursing', False)
if not recursing:
log.debug("learning %s", learning_what)
context = self.get_or_create_target_context(target)
markovlib.learn_line(learning_what, context)
log.debug("learning %s", trimmed_what)
context = markovlib.get_or_create_target_context(target)
markovlib.learn_line(trimmed_what, context)
log.debug("searching '%s' for '%s'", what, all_nicks)
if re.search(all_nicks, what, re.IGNORECASE) is not None:
context = self.get_or_create_target_context(target)
context = markovlib.get_or_create_target_context(target)
addressed_pattern = r'^(({nicks})[:,]|@({nicks})[:,]?)\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
addressed_pattern = r'^(({nicks})[:,]|@({nicks}))\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
match = re.match(addressed_pattern, what, re.IGNORECASE)
if match:
# i was addressed directly, so respond, addressing
@ -106,7 +97,7 @@ class Markov(Plugin):
topics = [x for x in match.group('addressed_msg').split(' ') if len(x) >= 3]
return self.bot.reply(event, "{0:s}: {1:s}"
"".format(who, " ".join(markovlib.generate_line(context, topics=topics))))
"".format(nick, " ".join(markovlib.generate_line(context, topics=topics))))
else:
# i wasn't addressed directly, so just respond
topics = [x for x in what.split(' ') if len(x) >= 3]
@ -114,31 +105,5 @@ class Markov(Plugin):
return self.bot.reply(event, "{0:s}"
"".format(" ".join(markovlib.generate_line(context, topics=topics))))
def get_or_create_target_context(self, target_name):
"""Return the context for a provided nick/channel, creating missing ones."""
target_name = target_name.lower()
# find the stuff, or create it
channel, c = IrcChannel.objects.get_or_create(name=target_name, server=self.connection.server_config)
try:
target = MarkovTarget.objects.get(channel=channel)
except MarkovTarget.DoesNotExist:
# we need to create a context and a target, and we have to make the context first
# make a context --- lacking a good idea, just create one with this target name until configured otherwise
context, c = MarkovContext.objects.get_or_create(name=target_name)
target, c = MarkovTarget.objects.get_or_create(name=target_name, context=context, channel=channel)
return target.context
try:
return target.context
except MarkovContext.DoesNotExist:
# make a context --- lacking a good idea, just create one with this target name until configured otherwise
context, c = MarkovContext.objects.get_or_create(name=target_name)
target.context = context
target.save()
return target.context
plugin = Markov

View File

@ -1,16 +1,17 @@
"""Provide methods for manipulating markov chain processing."""
import logging
import random
from django.db.models import Sum
from markov.models import MarkovState
from markov.models import MarkovContext, MarkovState, MarkovTarget
log = logging.getLogger(__name__)
log = logging.getLogger('markov.lib')
def generate_line(context, topics=None, min_words=15, max_words=30, sentence_bias=2, max_tries=5):
"""Combine multiple sentences together into a coherent sentence."""
"""String multiple sentences together into a coherent sentence."""
tries = 0
line = []
min_words_per_sentence = min_words / sentence_bias
@ -22,7 +23,7 @@ def generate_line(context, topics=None, min_words=15, max_words=30, sentence_bia
else:
if len(line) > 0:
if line[-1][-1] not in [',', '.', '!', '?', ':']:
line[-1] += random.SystemRandom().choice(['?', '.', '!'])
line[-1] += random.choice(['?', '.', '!'])
tries += 1
@ -32,6 +33,7 @@ def generate_line(context, topics=None, min_words=15, max_words=30, sentence_bia
def generate_longish_sentence(context, topics=None, min_words=15, max_words=30, max_tries=100):
"""Generate a Markov chain, but throw away the short ones unless we get desperate."""
sent = ""
tries = 0
while tries < max_tries:
@ -50,19 +52,20 @@ def generate_longish_sentence(context, topics=None, min_words=15, max_words=30,
def generate_sentence(context, topics=None, min_words=15, max_words=30):
"""Generate a Markov chain."""
words = []
# if we have topics, try to work from it and work backwards
if topics:
topic_word = random.SystemRandom().choice(topics)
topic_word = random.choice(topics)
topics.remove(topic_word)
log.debug("looking for topic '%s'", topic_word)
log.debug("looking for topic '{0:s}'".format(topic_word))
new_states = MarkovState.objects.filter(context=context, v=topic_word)
if len(new_states) > 0:
log.debug("found '%s', starting backwards", topic_word)
log.debug("found '{0:s}', starting backwards".format(topic_word))
words.insert(0, topic_word)
while len(words) <= max_words and words[0] != MarkovState._start2:
log.debug("looking backwards for '%s'", words[0])
log.debug("looking backwards for '{0:s}'".format(words[0]))
new_states = MarkovState.objects.filter(context=context, v=words[0])
# if we find a start, use it
if MarkovState._start2 in new_states:
@ -84,7 +87,7 @@ def generate_sentence(context, topics=None, min_words=15, max_words=30):
i = len(words)
while words[-1] != MarkovState._stop:
log.debug("looking for '%s','%s'", words[i-2], words[i-1])
log.debug("looking for '{0:s}','{1:s}'".format(words[i-2], words[i-1]))
new_states = MarkovState.objects.filter(context=context, k1=words[i-2], k2=words[i-1])
log.debug("states retrieved")
@ -100,7 +103,7 @@ def generate_sentence(context, topics=None, min_words=15, max_words=30):
words.append(MarkovState._stop)
elif len(target_hits) > 0:
# if there's a target word in the states, pick it
target_hit = random.SystemRandom().choice(target_hits)
target_hit = random.choice(target_hits)
log.debug("found a topic hit %s, using it", target_hit)
topics.remove(target_hit)
words.append(target_hit)
@ -124,8 +127,36 @@ def generate_sentence(context, topics=None, min_words=15, max_words=30):
return words
def get_or_create_target_context(target_name):
"""Return the context for a provided nick/channel, creating missing ones."""
target_name = target_name.lower()
# find the stuff, or create it
try:
target = MarkovTarget.objects.get(name=target_name)
except MarkovTarget.DoesNotExist:
# we need to create a context and a target, and we have to make the context first
# make a context --- lacking a good idea, just create one with this target name until configured otherwise
context, c = MarkovContext.objects.get_or_create(name=target_name)
target, c = MarkovTarget.objects.get_or_create(name=target_name, context=context)
return target.context
try:
return target.context
except MarkovContext.DoesNotExist:
# make a context --- lacking a good idea, just create one with this target name until configured otherwise
context, c = MarkovContext.objects.get_or_create(name=target_name)
target.context = context
target.save()
return target.context
def get_word_out_of_states(states, backwards=False):
"""Pick one random word out of the given states."""
# work around possible broken data, where a k1,k2 should have a value but doesn't
if len(states) == 0:
states = MarkovState.objects.filter(v=MarkovState._stop)
@ -137,9 +168,9 @@ def get_word_out_of_states(states, backwards=False):
# this being None probably means there's no data for this context
raise ValueError("no markov states to generate from")
hit = random.SystemRandom().randint(0, count_sum)
hit = random.randint(0, count_sum)
log.debug("sum: %s hit: %s", count_sum, hit)
log.debug("sum: {0:d} hit: {1:d}".format(count_sum, hit))
states_itr = states.iterator()
for state in states_itr:
@ -152,12 +183,13 @@ def get_word_out_of_states(states, backwards=False):
break
log.debug("found '%s'", new_word)
log.debug("found '{0:s}'".format(new_word))
return new_word
def learn_line(line, context):
"""Create a bunch of MarkovStates for a given line of text."""
log.debug("learning %s...", line[:40])
words = line.split()
@ -168,7 +200,7 @@ def learn_line(line, context):
return
for i, word in enumerate(words):
log.debug("'%s','%s' -> '%s'", words[i], words[i+1], words[i+2])
log.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],

View File

@ -1 +0,0 @@
"""Management operations for the markov plugin and models."""

View File

@ -1 +0,0 @@
"""Management commands for the markov plugin and models."""

View File

@ -1,90 +0,0 @@
"""Clean up learned chains with speaker nicks (from the bridge) or self (because the bridge broke the regex)."""
from django.core.management import BaseCommand
from ircbot.models import IrcChannel
from markov.models import MarkovContext, MarkovState
class Command(BaseCommand):
"""Find markov chains that erroneously have speaker/self nicks and remove them."""
def handle(self, *args, **kwargs):
"""Scan the DB, looking for bad chains, and repair them."""
candidate_channels = IrcChannel.objects.exclude(discord_bridge='')
markov_contexts = MarkovContext.objects.filter(markovtarget__name__in=list(candidate_channels))
for context in markov_contexts:
self.stdout.write(self.style.NOTICE(f"scanning context {context}..."))
# get starting states that look like they came over the bridge
bridge_states = context.states.filter(k1=MarkovState._start1, k2=MarkovState._start2,
v__regex=r'<[A-Za-z0-9_]+>', context=context)
self._chain_remover(context, bridge_states)
# get states that look like mentions
for target in context.markovtarget_set.all():
if target.channel.server.additional_addressed_nicks:
all_nicks = '|'.join(target.channel.server.additional_addressed_nicks.split('\n') +
[target.channel.server.nickname])
else:
all_nicks = target.channel.server.nickname
mention_regex = r'^(({nicks})[:,]|@({nicks}))$'.format(nicks=all_nicks)
mention_states = context.states.filter(k1=MarkovState._start1, k2=MarkovState._start2,
v__regex=mention_regex, context=context)
self._chain_remover(context, mention_states)
def _chain_remover(self, context, start_states):
"""Remove a given k from markov states, deleting the found states after rebuilding subsequent states.
As in, if trying to remove A,B -> X, then B,X -> C and X,C -> D must be rebuilt (A,B -> C / B,C -> D)
then the three states with X deleted.
"""
for start_state in start_states:
self.stdout.write(self.style.NOTICE(f" diving into {start_state}..."))
# find the states that build off of the start
second_states = context.states.filter(k1=start_state.k2, k2=start_state.v)
for second_state in second_states:
self.stdout.write(self.style.NOTICE(f" diving into {second_state}..."))
# find the third states
leaf_states = context.states.filter(k1=second_state.k2, k2=second_state.v)
for leaf_state in leaf_states:
self.stdout.write(self.style.NOTICE(f" upserting state based on {leaf_state}"))
# get/update state without the nick from the bridge
try:
updated_leaf = MarkovState.objects.get(k1=second_state.k1, k2=leaf_state.k2, v=leaf_state.v,
context=context)
updated_leaf.count += leaf_state.count
updated_leaf.save()
self.stdout.write(self.style.SUCCESS(f" updated count for {updated_leaf}"))
except MarkovState.DoesNotExist:
new_leaf = MarkovState.objects.create(k1=second_state.k1, k2=leaf_state.k2, v=leaf_state.v,
context=context)
new_leaf.count = leaf_state.count
new_leaf.save()
self.stdout.write(self.style.SUCCESS(f" created {new_leaf}"))
# remove the migrated leaf state
self.stdout.write(self.style.SUCCESS(f" deleting {leaf_state}"))
leaf_state.delete()
# take care of the new middle state
self.stdout.write(self.style.NOTICE(f" upserting state based on {second_state}"))
try:
updated_second = MarkovState.objects.get(k1=start_state.k1, k2=start_state.k2, v=second_state.v,
context=context)
updated_second.count += second_state.count
updated_second.save()
self.stdout.write(self.style.SUCCESS(f" updated count for {updated_second}"))
except MarkovState.DoesNotExist:
new_second = MarkovState.objects.create(k1=start_state.k1, k2=start_state.k2, v=second_state.v,
context=context)
new_second.count = second_state.count
new_second.save()
self.stdout.write(self.style.SUCCESS(f" created {new_second}"))
# remove the migrated second state
self.stdout.write(self.style.SUCCESS(f" deleting {second_state}"))
second_state.delete()
# remove the dead end original start
self.stdout.write(self.style.SUCCESS(f" deleting {start_state}"))
start_state.delete()

View File

@ -1,19 +0,0 @@
# Generated by Django 3.2.18 on 2023-02-19 19:00
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('markov', '0003_auto_20161112_2348'),
]
operations = [
migrations.AlterField(
model_name='markovstate',
name='context',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='states', to='markov.markovcontext'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.2.18 on 2023-02-20 00:09
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0019_ircchannel_discord_bridge'),
('markov', '0004_alter_markovstate_context'),
]
operations = [
migrations.AddField(
model_name='markovtarget',
name='channel',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='ircbot.ircchannel'),
),
]

View File

@ -1,24 +0,0 @@
"""Generated by Django 3.2.18 on 2023-02-19 23:15."""
from django.db import migrations
def link_markovcontext_to_ircchannel(apps, schema_editor):
"""Link the markov targets to a hopefully matching channel, by name."""
IrcChannel = apps.get_model('ircbot', 'IrcChannel')
MarkovTarget = apps.get_model('markov', 'MarkovTarget')
for target in MarkovTarget.objects.all():
channel = IrcChannel.objects.get(name=target.name)
target.channel = channel
target.save()
class Migration(migrations.Migration):
"""Populate the markov target to IRC channel link."""
dependencies = [
('markov', '0005_markovtarget_channel'),
]
operations = [
migrations.RunPython(link_markovcontext_to_ircchannel)
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.2.18 on 2023-02-20 00:11
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0019_ircchannel_discord_bridge'),
('markov', '0006_link_markovtarget_to_ircchannel'),
]
operations = [
migrations.AlterField(
model_name='markovtarget',
name='channel',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='ircbot.ircchannel'),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 3.2.18 on 2023-05-04 22:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('markov', '0007_alter_markovtarget_channel'),
]
operations = [
migrations.AlterField(
model_name='markovtarget',
name='name',
field=models.CharField(max_length=200),
),
]

View File

@ -1,38 +1,45 @@
"""Save brain pieces as markov chains for chaining."""
"""
markov/models.py --- save brain pieces for chaining
"""
import logging
from django.db import models
from ircbot.models import IrcChannel
log = logging.getLogger(__name__)
log = logging.getLogger('markov.models')
class MarkovContext(models.Model):
"""Define contexts for Markov chains."""
name = models.CharField(max_length=200, unique=True)
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s}".format(self.name)
class MarkovTarget(models.Model):
"""Define IRC targets that relate to a context, and can occasionally be talked to."""
name = models.CharField(max_length=200)
name = models.CharField(max_length=200, unique=True)
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
channel = models.ForeignKey(IrcChannel, on_delete=models.CASCADE)
chatter_chance = models.IntegerField(default=0)
def __str__(self):
"""Provide string representation."""
return "{0:s} -> {1:s}".format(str(self.channel), self.context.name)
"""String representation."""
return "{0:s} -> {1:s}".format(self.name, self.context.name)
class MarkovState(models.Model):
"""One element in a Markov chain, some text or something."""
_start1 = '__start1'
@ -44,11 +51,9 @@ class MarkovState(models.Model):
v = models.CharField(max_length=128)
count = models.IntegerField(default=0)
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE, related_name='states')
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
class Meta:
"""Options for the model itself."""
index_together = [
['context', 'k1', 'k2'],
['context', 'v'],
@ -60,5 +65,6 @@ class MarkovState(models.Model):
unique_together = ('context', 'k1', 'k2', 'v')
def __str__(self):
"""Provide string representation."""
"""String representation."""
return "{0:s},{1:s} -> {2:s} (count: {3:d})".format(self.k1, self.k2, self.v, self.count)

View File

@ -6,8 +6,6 @@ from pi.models import PiLog
class PiLogSerializer(serializers.ModelSerializer):
"""Pi simulation log entry serializer for the REST API."""
simulation_x = serializers.DecimalField(11, 10, coerce_to_string=False)
simulation_y = serializers.DecimalField(11, 10, coerce_to_string=False)
class Meta:
"""Meta options."""

View File

@ -1,6 +1,5 @@
"""URL patterns for the pi views."""
from django.conf.urls import include
from django.urls import path
from django.conf.urls import include, url
from rest_framework.routers import DefaultRouter
from pi.views import PiLogViewSet
@ -9,5 +8,5 @@ router = DefaultRouter()
router.register(r'simulations', PiLogViewSet)
urlpatterns = [
path('api/', include(router.urls)),
url(r'^api/', include(router.urls)),
]

View File

@ -1,8 +1,8 @@
from django.urls import path
from django.conf.urls import url
from races.views import index, race_detail
urlpatterns = [
path('', index, name='races_index'),
path('race/<race_id>/', race_detail, name='race_detail'),
url(r'^$', index, name='races_index'),
url(r'^race/(?P<race_id>[A-Za-z0-9]+)/$', race_detail, name='race_detail'),
]

View File

@ -1,28 +1,26 @@
"""Display race statuses and whatnot."""
import logging
from django.conf import settings
from django.core.exceptions import PermissionDenied
from django.shortcuts import get_object_or_404, render
from races.models import Race, Racer, RaceUpdate
log = logging.getLogger(__name__)
log = logging.getLogger('races.views')
def index(request):
"""Display a list of races."""
if 'races' not in settings.WEB_ENABLED_APPS:
raise PermissionDenied()
races = Race.objects.all()
return render(request, 'races/index.html', {'races': races})
def race_detail(request, race_id):
"""Display a race detail."""
if 'races' not in settings.WEB_ENABLED_APPS:
raise PermissionDenied()
race = get_object_or_404(Race, pk=race_id)
return render(request, 'races/race_detail.html', {'race': race})

View File

@ -17,10 +17,9 @@ flake8-fixme
flake8-isort
flake8-logging-format
flake8-mutable
safety
# maintenance utilities and tox
pip-tools # pip-compile
tox<4 # CI stuff
tox # CI stuff
tox-wheel # build wheels in tox
versioneer # automatic version numbering

View File

@ -1,231 +1,91 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in
#
asgiref==3.6.0
# via django
attrs==22.2.0
# via pytest
autocommand==2.2.2
# via jaraco-text
bandit==1.7.4
# via -r requirements/requirements-dev.in
build==0.10.0
# via pip-tools
certifi==2022.12.7
# via requests
charset-normalizer==3.0.1
# via requests
click==8.1.3
# via
# pip-tools
# safety
coverage[toml]==7.2.1
# via pytest-cov
distlib==0.3.6
# via virtualenv
django==3.2.18
# via
# -r requirements/requirements.in
# django-bootstrap3
# django-extensions
# djangorestframework
django-adminplus==0.5
# via -r requirements/requirements.in
django-bootstrap3==22.2
# via -r requirements/requirements.in
django-extensions==3.2.1
# via -r requirements/requirements.in
djangorestframework==3.14.0
# via -r requirements/requirements.in
dlint==0.14.0
# via -r requirements/requirements-dev.in
dparse==0.6.2
# via safety
exceptiongroup==1.1.0
# via pytest
filelock==3.9.0
# via
# tox
# virtualenv
flake8==6.0.0
# via
# -r requirements/requirements-dev.in
# dlint
# flake8-builtins
# flake8-docstrings
# flake8-executable
# flake8-isort
# flake8-mutable
flake8-blind-except==0.2.1
# via -r requirements/requirements-dev.in
flake8-builtins==2.1.0
# via -r requirements/requirements-dev.in
flake8-docstrings==1.7.0
# via -r requirements/requirements-dev.in
flake8-executable==2.1.3
# via -r requirements/requirements-dev.in
flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in
flake8-isort==6.0.0
# via -r requirements/requirements-dev.in
flake8-logging-format==0.9.0
# via -r requirements/requirements-dev.in
flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in
gitdb==4.0.10
# via gitpython
gitpython==3.1.31
# via bandit
idna==3.4
# via requests
inflect==6.0.2
# via jaraco-text
iniconfig==2.0.0
# via pytest
irc==20.1.0
# via -r requirements/requirements.in
isort==5.12.0
# via flake8-isort
jaraco-classes==3.2.3
# via jaraco-collections
jaraco-collections==3.8.0
# via irc
jaraco-context==4.3.0
# via jaraco-text
jaraco-functools==3.6.0
# via
# irc
# jaraco-text
# tempora
jaraco-logging==3.1.2
# via irc
jaraco-stream==3.0.3
# via irc
jaraco-text==3.11.1
# via
# irc
# jaraco-collections
mccabe==0.7.0
# via flake8
more-itertools==9.1.0
# via
# irc
# jaraco-classes
# jaraco-functools
# jaraco-text
packaging==21.3
# via
# build
# dparse
# pytest
# safety
# tox
parsedatetime==2.6
# via -r requirements/requirements.in
pbr==5.11.1
# via stevedore
pip-tools==6.12.3
# via -r requirements/requirements-dev.in
platformdirs==3.0.0
# via virtualenv
pluggy==1.0.0
# via
# pytest
# tox
ply==3.11
# via -r requirements/requirements.in
py==1.11.0
# via tox
pycodestyle==2.10.0
# via flake8
pydantic==1.10.5
# via inflect
pydocstyle==6.3.0
# via flake8-docstrings
pyflakes==3.0.1
# via flake8
pyparsing==3.0.9
# via packaging
pyproject-hooks==1.0.0
# via build
pytest==7.2.1
# via
# -r requirements/requirements-dev.in
# pytest-cov
# pytest-django
pytest-cov==4.0.0
# via -r requirements/requirements-dev.in
pytest-django==4.5.2
# via -r requirements/requirements-dev.in
python-dateutil==2.8.2
# via -r requirements/requirements.in
python-mpd2==3.0.5
# via -r requirements/requirements.in
pytz==2022.7.1
# via
# -r requirements/requirements.in
# django
# djangorestframework
# irc
# tempora
pyyaml==6.0
# via bandit
requests==2.28.2
# via safety
ruamel-yaml==0.17.21
# via safety
ruamel-yaml-clib==0.2.7
# via ruamel-yaml
safety==2.3.5
# via -r requirements/requirements-dev.in
six==1.16.0
# via
# python-dateutil
# tox
smmap==5.0.0
# via gitdb
snowballstemmer==2.2.0
# via pydocstyle
sqlparse==0.4.3
# via django
stevedore==5.0.0
# via bandit
tempora==5.2.1
# via
# irc
# jaraco-logging
toml==0.10.2
# via dparse
tomli==2.0.1
# via
# build
# coverage
# pyproject-hooks
# pytest
# tox
tox==3.28.0
# via
# -r requirements/requirements-dev.in
# tox-wheel
tox-wheel==1.0.0
# via -r requirements/requirements-dev.in
typing-extensions==4.5.0
# via pydantic
urllib3==1.26.14
# via requests
versioneer==0.28
# via -r requirements/requirements-dev.in
virtualenv==20.20.0
# via tox
wheel==0.38.4
# via
# pip-tools
# tox-wheel
zalgo-text==0.6
# via -r requirements/requirements.in
appdirs==1.4.4 # via virtualenv
asgiref==3.2.10 # via django
attrs==20.2.0 # via pytest
bandit==1.6.2 # via -r requirements/requirements-dev.in
certifi==2020.6.20 # via requests
chardet==3.0.4 # via requests
click==7.1.2 # via pip-tools
coverage==5.3 # via pytest-cov
distlib==0.3.1 # via virtualenv
django-adminplus==0.5 # via -r requirements/requirements.in
django-bootstrap3==14.2.0 # via -r requirements/requirements.in
django-extensions==3.0.9 # via -r requirements/requirements.in
django-registration-redux==2.8 # via -r requirements/requirements.in
django==3.1.2 # via -r requirements/requirements.in, django-bootstrap3, djangorestframework
djangorestframework==3.12.1 # via -r requirements/requirements.in
dlint==0.10.3 # via -r requirements/requirements-dev.in
filelock==3.0.12 # via tox, virtualenv
flake8-blind-except==0.1.1 # via -r requirements/requirements-dev.in
flake8-builtins==1.5.3 # via -r requirements/requirements-dev.in
flake8-docstrings==1.5.0 # via -r requirements/requirements-dev.in
flake8-executable==2.0.4 # via -r requirements/requirements-dev.in
flake8-fixme==1.1.1 # via -r requirements/requirements-dev.in
flake8-isort==4.0.0 # via -r requirements/requirements-dev.in
flake8-logging-format==0.6.0 # via -r requirements/requirements-dev.in
flake8-mutable==1.2.0 # via -r requirements/requirements-dev.in
flake8==3.8.4 # via -r requirements/requirements-dev.in, dlint, flake8-builtins, flake8-docstrings, flake8-executable, flake8-isort, flake8-mutable
gitdb==4.0.5 # via gitpython
gitpython==3.1.11 # via bandit
idna==2.10 # via requests
importlib-metadata==1.7.0 # via django-bootstrap3, flake8, inflect, pluggy, pytest, stevedore, tox, virtualenv
importlib-resources==3.1.1 # via jaraco.text, virtualenv
inflect==4.1.0 # via jaraco.itertools
iniconfig==1.1.1 # via pytest
irc==15.0.6 # via -r requirements/requirements.in
isort==5.6.4 # via flake8-isort
jaraco.classes==3.1.0 # via jaraco.collections
jaraco.collections==3.0.0 # via irc
jaraco.functools==3.0.1 # via irc, jaraco.text, tempora
jaraco.itertools==5.0.0 # via irc
jaraco.logging==3.0.0 # via irc
jaraco.stream==3.0.0 # via irc
jaraco.text==3.2.0 # via irc, jaraco.collections
mccabe==0.6.1 # via flake8
more-itertools==8.5.0 # via irc, jaraco.classes, jaraco.functools, jaraco.itertools
oauthlib==3.1.0 # via requests-oauthlib
packaging==20.4 # via pytest, tox
parsedatetime==2.6 # via -r requirements/requirements.in
pbr==5.5.1 # via stevedore
pip-tools==5.3.1 # via -r requirements/requirements-dev.in
pluggy==0.13.1 # via pytest, tox
ply==3.11 # via -r requirements/requirements.in
py==1.9.0 # via pytest, tox
pycodestyle==2.6.0 # via flake8
pydocstyle==5.1.1 # via flake8-docstrings
pyflakes==2.2.0 # via flake8
pyparsing==2.4.7 # via packaging
pytest-cov==2.10.1 # via -r requirements/requirements-dev.in
pytest-django==4.1.0 # via -r requirements/requirements-dev.in
pytest==6.1.1 # via -r requirements/requirements-dev.in, pytest-cov, pytest-django
python-dateutil==2.8.1 # via -r requirements/requirements.in
python-gitlab==2.5.0 # via -r requirements/requirements.in
python-mpd2==1.1.0 # via -r requirements/requirements.in
pytz==2020.1 # via -r requirements/requirements.in, django, irc, tempora
pyyaml==5.3.1 # via bandit
requests-oauthlib==1.3.0 # via twython
requests==2.24.0 # via python-gitlab, requests-oauthlib, twython
six==1.15.0 # via bandit, irc, jaraco.collections, jaraco.logging, jaraco.text, packaging, pip-tools, python-dateutil, tox, virtualenv
smmap==3.0.4 # via gitdb
snowballstemmer==2.0.0 # via pydocstyle
sqlparse==0.4.1 # via django
stevedore==3.2.2 # via bandit
tempora==4.0.0 # via irc, jaraco.logging
testfixtures==6.15.0 # via flake8-isort
toml==0.10.1 # via pytest, tox
tox-wheel==0.5.0 # via -r requirements/requirements-dev.in
tox==3.20.1 # via -r requirements/requirements-dev.in, tox-wheel
twython==3.8.2 # via -r requirements/requirements.in
urllib3==1.25.11 # via requests
versioneer==0.18 # via -r requirements/requirements-dev.in
virtualenv==20.0.35 # via tox
wheel==0.35.1 # via tox-wheel
zipp==3.3.2 # via importlib-metadata, importlib-resources
# The following packages are considered to be unsafe in a requirements file:
# pip

View File

@ -1,12 +1,14 @@
Django<4.0 # core
Django # core
django-adminplus # admin.site.register_view
django-bootstrap3 # bootstrap layout
django-extensions # more commands
djangorestframework # WS API
irc # core
django-registration-redux # registration views/forms
djangorestframework # dispatch WS API
irc==15.0.6 # core, pinned until I can bother to update --- 17.x has API changes
parsedatetime # relative date stuff in countdown
ply # dice lex/yacc compiler
python-dateutil # countdown relative math
python-gitlab # client for the gitlab bot
python-mpd2 # client for mpd
pytz # timezone awareness
zalgo-text # zalgoify text
twython # twitter client

View File

@ -1,82 +1,43 @@
#
# This file is autogenerated by pip-compile with Python 3.10
# by the following command:
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
#
asgiref==3.6.0
# via django
autocommand==2.2.2
# via jaraco-text
django==3.2.18
# via
# -r requirements/requirements.in
# django-bootstrap3
# django-extensions
# djangorestframework
django-adminplus==0.5
# via -r requirements/requirements.in
django-bootstrap3==22.2
# via -r requirements/requirements.in
django-extensions==3.2.1
# via -r requirements/requirements.in
djangorestframework==3.14.0
# via -r requirements/requirements.in
inflect==6.0.2
# via jaraco-text
irc==20.1.0
# via -r requirements/requirements.in
jaraco-classes==3.2.3
# via jaraco-collections
jaraco-collections==3.8.0
# via irc
jaraco-context==4.3.0
# via jaraco-text
jaraco-functools==3.6.0
# via
# irc
# jaraco-text
# tempora
jaraco-logging==3.1.2
# via irc
jaraco-stream==3.0.3
# via irc
jaraco-text==3.11.1
# via
# irc
# jaraco-collections
more-itertools==9.1.0
# via
# irc
# jaraco-classes
# jaraco-functools
# jaraco-text
parsedatetime==2.6
# via -r requirements/requirements.in
ply==3.11
# via -r requirements/requirements.in
pydantic==1.10.5
# via inflect
python-dateutil==2.8.2
# via -r requirements/requirements.in
python-mpd2==3.0.5
# via -r requirements/requirements.in
pytz==2022.7.1
# via
# -r requirements/requirements.in
# django
# djangorestframework
# irc
# tempora
six==1.16.0
# via python-dateutil
sqlparse==0.4.3
# via django
tempora==5.2.1
# via
# irc
# jaraco-logging
typing-extensions==4.5.0
# via pydantic
zalgo-text==0.6
# via -r requirements/requirements.in
asgiref==3.2.10 # via django
certifi==2020.6.20 # via requests
chardet==3.0.4 # via requests
django-adminplus==0.5 # via -r requirements/requirements.in
django-bootstrap3==14.2.0 # via -r requirements/requirements.in
django-extensions==3.0.9 # via -r requirements/requirements.in
django-registration-redux==2.8 # via -r requirements/requirements.in
django==3.1.2 # via -r requirements/requirements.in, django-bootstrap3, djangorestframework
djangorestframework==3.12.1 # via -r requirements/requirements.in
idna==2.10 # via requests
importlib-metadata==1.7.0 # via django-bootstrap3, inflect
importlib-resources==3.1.1 # via jaraco.text
inflect==4.1.0 # via jaraco.itertools
irc==15.0.6 # via -r requirements/requirements.in
jaraco.classes==3.1.0 # via jaraco.collections
jaraco.collections==3.0.0 # via irc
jaraco.functools==3.0.1 # via irc, jaraco.text, tempora
jaraco.itertools==5.0.0 # via irc
jaraco.logging==3.0.0 # via irc
jaraco.stream==3.0.0 # via irc
jaraco.text==3.2.0 # via irc, jaraco.collections
more-itertools==8.5.0 # via irc, jaraco.classes, jaraco.functools, jaraco.itertools
oauthlib==3.1.0 # via requests-oauthlib
parsedatetime==2.6 # via -r requirements/requirements.in
ply==3.11 # via -r requirements/requirements.in
python-dateutil==2.8.1 # via -r requirements/requirements.in
python-gitlab==2.5.0 # via -r requirements/requirements.in
python-mpd2==1.1.0 # via -r requirements/requirements.in
pytz==2020.1 # via -r requirements/requirements.in, django, irc, tempora
requests-oauthlib==1.3.0 # via twython
requests==2.24.0 # via python-gitlab, requests-oauthlib, twython
six==1.15.0 # via irc, jaraco.collections, jaraco.logging, jaraco.text, python-dateutil
sqlparse==0.4.1 # via django
tempora==4.0.0 # via irc, jaraco.logging
twython==3.8.2 # via -r requirements/requirements.in
urllib3==1.25.11 # via requests
zipp==3.3.2 # via importlib-metadata, importlib-resources

View File

@ -30,9 +30,9 @@
{% block navbar_menu %}
<ul class="nav navbar-nav">
<li><a href="{% url 'facts_index' %}">Item Sets</a></li>
{% if "karma" in WEB_ENABLED_APPS %}<li><a href="{% url 'karma_index' %}">Karma</a></li>{% endif %}
<li><a href="{% url 'karma_index' %}">Karma</a></li>
<li><a href="{% url 'markov_index' %}">Markov</a></li>
{% if "races" in WEB_ENABLED_APPS %}<li><a href="{% url 'races_index' %}">Races</a></li>{% endif %}
<li><a href="{% url 'races_index' %}">Races</a></li>
</ul>
{% endblock %}
<div class="navbar-right">
@ -44,6 +44,18 @@
<b class="caret"></b>
</a>
<ul class="dropdown-menu">
<li>
<a href="{% url 'auth_password_change' %}">
<i class="fa fa-pencil-square-o"></i>
Change password
</a>
</li>
<li>
<a href="{% url 'auth_logout' %}">
<i class="fa fa-power-off"></i>
Log out
</a>
</li>
{% if user.is_authenticated and user.is_staff %}
<li>
<a href="{% url 'admin:index' %}">
@ -54,6 +66,13 @@
{% endif %}
</ul>
</li>
{% else %}
<li>
<a href="{% url 'auth_login' %}?next={{ request.path }}">Log in</a>
</li>
<li>
<a href="{% url 'registration_register' %}">Sign up</a>
</li>
{% endif %}
</ul>
</div>

View File

@ -0,0 +1,16 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block registration_content %}
<p>{% trans "Account activation failed." %}</p>
{% endblock %}
{% comment %}
**registration/activate.html**
Used if account activation fails. With the default setup, has the following context:
``activation_key``
The activation key used during the activation attempt.
{% endcomment %}

View File

@ -0,0 +1,22 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Account Activated" %}{% endblock %}
{% block registration_content %}
<p>
{% trans "Your account is now activated." %}
{% if not user.is_authenticated %}
{% trans "You can log in." %}
{% endif %}
</p>
{% endblock %}
{% comment %}
**registration/activation_complete.html**
Used after successful account activation. This template has no context
variables of its own, and should simply inform the user that their
account is now active.
{% endcomment %}

View File

@ -0,0 +1,77 @@
{% load i18n %}
{% load url from future %}
<!doctype html>
<html lang="en">
<head>
<title>{{ site.name }} {% trans "registration" %}</title>
</head>
<body>
<p>
{% blocktrans with site_name=site.name %}
You (or someone pretending to be you) have asked to register an account at
{{ site_name }}. If this wasn't you, please ignore this email
and your address will be removed from our records.
{% endblocktrans %}
</p>
<p>
{% blocktrans %}
To activate this account, please click the following link within the next
{{ expiration_days }} days:
{% endblocktrans %}
</p>
<p>
<a href="http://{{site.domain}}{% url 'registration_activate' activation_key %}">
{{site.domain}}{% url 'registration_activate' activation_key %}
</a>
</p>
<p>
{% blocktrans with site_name=site.name %}
Sincerely,
{{ site_name }} Management
{% endblocktrans %}
</p>
</body>
</html>
{% comment %}
**registration/activation_email.html**
Used to generate the html body of the activation email. Should display a
link the user can click to activate the account. This template has the
following context:
``activation_key``
The activation key for the new account.
``expiration_days``
The number of days remaining during which the account may be
activated.
``site``
An object representing the site on which the user registered;
depending on whether ``django.contrib.sites`` is installed, this
may be an instance of either ``django.contrib.sites.models.Site``
(if the sites application is installed) or
``django.contrib.sites.models.RequestSite`` (if not). Consult `the
documentation for the Django sites framework
<http://docs.djangoproject.com/en/dev/ref/contrib/sites/>`_ for
details regarding these objects' interfaces.
``user``
The new user account
``request``
``HttpRequest`` instance for better flexibility.
For example it can be used to compute absolute register URL:
http{% if request.is_secure %}s{% endif %}://{{ request.get_host }}{% url 'registration_activate' activation_key %}
or when using Django >= 1.7:
{{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %}
{% endcomment %}

View File

@ -0,0 +1,57 @@
{% load i18n %}
{% load url from future %}
{% blocktrans with site_name=site.name %}
You (or someone pretending to be you) have asked to register an account at
{{ site_name }}. If this wasn't you, please ignore this email
and your address will be removed from our records.
{% endblocktrans %}
{% blocktrans %}
To activate this account, please click the following link within the next
{{ expiration_days }} days:
{% endblocktrans %}
http://{{site.domain}}{% url 'registration_activate' activation_key %}
{% blocktrans with site_name=site.name %}
Sincerely,
{{ site_name }} Management
{% endblocktrans %}
{% comment %}
**registration/activation_email.txt**
Used to generate the text body of the activation email. Should display a
link the user can click to activate the account. This template has the
following context:
``activation_key``
The activation key for the new account.
``expiration_days``
The number of days remaining during which the account may be
activated.
``site``
An object representing the site on which the user registered;
depending on whether ``django.contrib.sites`` is installed, this
may be an instance of either ``django.contrib.sites.models.Site``
(if the sites application is installed) or
``django.contrib.sites.models.RequestSite`` (if not). Consult `the
documentation for the Django sites framework
<http://docs.djangoproject.com/en/dev/ref/contrib/sites/>`_ for
details regarding these objects' interfaces.
``user``
The new user account
``request``
``HttpRequest`` instance for better flexibility.
For example it can be used to compute absolute register URL:
http{% if request.is_secure %}s{% endif %}://{{ request.get_host }}{% url 'registration_activate' activation_key %}
or when using Django >= 1.7:
{{ request.scheme }}://{{ request.get_host }}{% url 'registration_activate' activation_key %}
{% endcomment %}

View File

@ -0,0 +1,28 @@
{% load i18n %}{% trans "Account activation on" %} {{ site.name }}
{% comment %}
**registration/activation_email_subject.txt**
Used to generate the subject line of the activation email. Because the
subject line of an email must be a single line of text, any output
from this template will be forcibly condensed to a single line before
being used. This template has the following context:
``activation_key``
The activation key for the new account.
``expiration_days``
The number of days remaining during which the account may be
activated.
``site``
An object representing the site on which the user registered;
depending on whether ``django.contrib.sites`` is installed, this
may be an instance of either ``django.contrib.sites.models.Site``
(if the sites application is installed) or
``django.contrib.sites.models.RequestSite`` (if not). Consult `the
documentation for the Django sites framework
<http://docs.djangoproject.com/en/dev/ref/contrib/sites/>`_ for
details regarding these objects' interfaces.
{% endcomment %}

View File

@ -0,0 +1,47 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Log in" %}{% endblock %}
{% block registration_content %}
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<input type="hidden" name="next" value="{{ next }}" />
{% buttons submit='Log In' reset='Cancel' layout='horizontal' %}{% endbuttons %}
</form>
<div class="extra">
<p>{% trans "Forgot your password?" %} <a href="{% url 'auth_password_reset' %}">{% trans "Reset it" %}</a>.
{% trans "Not a member?" %} <a href="{% url 'registration_register' %}">{% trans "Register" %}</a>.</p>
</div>
{% endblock %}
{% comment %}
**registration/login.html**
It's your responsibility to provide the login form in a template called
registration/login.html by default. This template gets passed four
template context variables:
``form``
A Form object representing the login form. See the forms
documentation for more on Form objects.
``next``
The URL to redirect to after successful login. This may contain a
query string, too.
``site``
The current Site, according to the SITE_ID setting. If you don't
have the site framework installed, this will be set to an instance
of RequestSite, which derives the site name and domain from the
current HttpRequest.
``site_name``
An alias for site.name. If you don't have the site framework
installed, this will be set to the value of
request.META['SERVER_NAME']. For more on sites, see The
"sites" framework.
{% endcomment %}

View File

@ -0,0 +1,8 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Logged out" %}{% endblock %}
{% block registration_content %}
<p>{% trans "Successfully logged out" %}.</p>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Password changed" %}{% endblock %}
{% block registration_content %}
<p>{% trans "Password successfully changed!" %}</p>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,17 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Change password" %}{% endblock %}
{% block registration_content %}
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<input type="hidden" name="next" value="{{ next }}" />
{% buttons submit='Change Password' reset='Cancel' layout='horizontal' %}{% endbuttons %}
</form>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,14 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Password reset complete" %}{% endblock %}
{% block registration_content %}
<p>
{% trans "Your password has been reset!" %}
{% trans "You may now" %} <a href="{{ login_url }}">{% trans "log in" %}</a>.
</p>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,18 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Confirm password reset" %}{% endblock %}
{% block registration_content %}
<p>{% trans "Enter your new password below to reset your password:" %}</p>
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<input type="hidden" name="next" value="{{ next }}" />
{% buttons submit='Set Password' reset='Cancel' layout='horizontal' %}{% endbuttons %}
</form>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,16 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Password reset" %}{% endblock %}
{% block registration_content %}
<p>
{% blocktrans %}
We have sent you an email with a link to reset your password. Please check
your email and click the link to continue.
{% endblocktrans %}
</p>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,26 @@
{% load i18n %}
{% load url from future %}
{% blocktrans %}Greetings{% endblocktrans %} {% if user.get_full_name %}{{ user.get_full_name }}{% else %}{{ user }}{% endif %},
{% blocktrans %}
You are receiving this email because you (or someone pretending to be you)
requested that your password be reset on the {{ domain }} site. If you do not
wish to reset your password, please ignore this message.
{% endblocktrans %}
{% blocktrans %}
To reset your password, please click the following link, or copy and paste it
into your web browser:
{% endblocktrans %}
{{ protocol }}://{{ domain }}{% url 'auth_password_reset_confirm' uid token %}
{% blocktrans %}Your username, in case you've forgotten:{% endblocktrans %} {{ user.username }}
{% blocktrans %}Best regards{% endblocktrans %},
{{ site_name }} {% blocktrans %}Management{% endblocktrans %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,18 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Reset password" %}{% endblock %}
{% block registration_content %}
<p>Forgot your password? Enter your email in the form below and we'll send you instructions for creating a new one.</p>
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<input type="hidden" name="next" value="{{ next }}" />
{% buttons submit='Reset' reset='Cancel' layout='horizontal' %}{% endbuttons %}
</form>
{% endblock %}
{# This is used by django.contrib.auth #}

View File

@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block extra_media %}{% load static %}
<link href="{% get_static_prefix %}css/registration.css" rel="stylesheet" type='text/css' />
{% endblock %}
{% block navbarbrand %}
<a class="navbar-brand navbar-brand-active" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %}
{% block content %}
<div class="registration_wrap">
{% block registration_content %}{% endblock %}
</div>
{% endblock %}

View File

@ -0,0 +1,8 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Registration is closed" %}{% endblock %}
{% block registration_content %}
<p>{% trans "Sorry, but registration is closed at this moment. Come back later." %}</p>
{% endblock %}

View File

@ -0,0 +1,18 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% block title %}{% trans "Activation email sent" %}{% endblock %}
{% block registration_content %}
<p>{% trans "Please check your email to complete the registration process." %}</p>
{% endblock %}
{% comment %}
**registration/registration_complete.html**
Used after successful completion of the registration form. This
template has no context variables of its own, and should simply inform
the user that an email containing account-activation information has
been sent.
{% endcomment %}

View File

@ -0,0 +1,27 @@
{% extends "registration/registration_base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Register for an account" %}{% endblock %}
{% block registration_content %}
<form role="form" method="post">
{% csrf_token %}
{% bootstrap_form form %}
<input type="hidden" name="next" value="{{ next }}" />
{% buttons submit='Sign Up' reset='Cancel' layout='horizontal' %}{% endbuttons %}
</form>
{% endblock %}
{% comment %}
**registration/registration_form.html**
Used to show the form users will fill out to register. By default, has
the following context:
``form``
The registration form. This will be an instance of some subclass
of ``django.forms.Form``; consult `Django's forms documentation
<http://docs.djangoproject.com/en/dev/topics/forms/>`_ for
information on how to display this in a template.
{% endcomment %}

View File

@ -1,36 +0,0 @@
[
{
"model": "ircbot.ircserver",
"pk": 1,
"fields": {
"name": "Localhost",
"hostname": "localhost",
"port": 6697,
"password": null,
"nickname": "test_bot",
"realname": "test_bot",
"additional_addressed_nicks": "",
"use_ssl": true,
"use_ipv6": true,
"post_connect": "",
"delay_before_joins": 5,
"xmlrpc_host": "localhost",
"xmlrpc_port": 13132,
"replace_irc_control_with_markdown": false
}
},
{
"model": "ircbot.ircchannel",
"pk": 1,
"fields": {
"name": "#test",
"server": 1,
"autojoin": true,
"topic_msg": "",
"topic_time": "2022-09-09T18:28:29Z",
"topic_by": "",
"markov_learn_from_channel": true,
"discord_bridge": "bridge"
}
}
]

View File

@ -1,19 +0,0 @@
[
{
"model": "markov.markovcontext",
"pk": 1,
"fields": {
"name": "#factory"
}
},
{
"model": "markov.markovtarget",
"pk": 1,
"fields": {
"name": "#factory",
"context": 1,
"channel": 1,
"chatter_chance": 0
}
}
]

View File

@ -1,26 +0,0 @@
"""Test the countdown package's webservice."""
from django.contrib.auth.models import User
from django.utils.timezone import now
from rest_framework.status import HTTP_200_OK
from rest_framework.test import APITestCase
from countdown.models import CountdownItem
class CountdownAPITest(APITestCase):
"""Test countdown DRF views."""
def setUp(self):
"""Do pre-test stuff."""
self.client = self.client_class()
self.user = User.objects.create(username='test')
self.client.force_authenticate(user=self.user)
def test_items_retrieval(self):
"""Test that the items endpoint returns objects."""
CountdownItem.objects.create(at_time=now())
resp = self.client.get('/countdown/api/items/')
self.assertEqual(resp.status_code, HTTP_200_OK)
self.assertEqual(len(resp.json()), CountdownItem.objects.count())

View File

@ -1,44 +0,0 @@
"""Test the dispatch package's webservice."""
from django.contrib.auth.models import User
from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN
from rest_framework.test import APITestCase
from dispatch.models import Dispatcher, DispatcherAction
class DispatchAPITest(APITestCase):
"""Test dispatch DRF views."""
def setUp(self):
"""Do pre-test stuff."""
self.client = self.client_class()
self.user = User.objects.create(username='test')
self.client.force_authenticate(user=self.user)
def test_dispatch_object_retrieval(self):
"""Test that the list endpoints returns objects."""
dispatcher = Dispatcher.objects.create()
DispatcherAction.objects.create(dispatcher=dispatcher)
resp = self.client.get('/dispatch/api/dispatchers/')
self.assertEqual(resp.status_code, HTTP_200_OK)
self.assertEqual(len(resp.json()), Dispatcher.objects.count())
resp = self.client.get('/dispatch/api/actions/')
self.assertEqual(resp.status_code, HTTP_200_OK)
self.assertEqual(len(resp.json()), DispatcherAction.objects.count())
def test_unauthed_dispatch_object_retrieval(self):
"""Test that the list endpoints require authentication."""
client = self.client_class()
resp = client.get('/dispatch/api/dispatchers/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/dispatchers/111/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/dispatchers/fake/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/actions/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/actions/111/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)

Some files were not shown because too many files have changed in this diff Show More