104 Commits

Author SHA1 Message Date
39290fb63c allow : and , after @bot mentions 2023-02-19 22:55:14 -06:00
55d856b8fd account for the discord bridge in the core bot addressed flag 2023-02-19 21:12:01 -06:00
88ea0dbbb4 test that <somebody> is only stripped when from the bridge user itself 2023-02-19 21:00:12 -06:00
cfeddfdc4e markov state queries need the context to be unique 2023-02-19 19:36:16 -06:00
d516c1b08e also clean up mentions that weren't cleaned because of the bridge nick 2023-02-19 18:39:36 -06:00
667a85aa46 add a basic learn/retrieve test since I broke it a while back 2023-02-19 18:27:05 -06:00
0227b74eee when creating a markov target, tie it to ircbot models 2023-02-19 18:26:00 -06:00
76a052e091 genericize chain remover to use it for bridge and addressed chains 2023-02-19 18:26:00 -06:00
19cd23879f management command to remove nicks from chains due to bridge 2023-02-19 18:26:00 -06:00
f59dc35b25 test the combination of bridge and addressing learning 2023-02-19 18:26:00 -06:00
debf086b8d test the ability to not learn our nick when addressed 2023-02-19 18:26:00 -06:00
ec1767e38b remove the speaker from messages coming over the bridge when learning 2023-02-19 18:26:00 -06:00
0bfe3f9549 variable tweak to match other plugins (nick -> who) 2023-02-19 18:26:00 -06:00
363ec49097 add test to confirm markov irc plugin behavior 2023-02-19 18:25:57 -06:00
8549c2ef8a set pytest settings to aid testing 2023-02-19 17:55:42 -06:00
d24f74e53f don't build trimmed_what until we know not to ignore chatter 2023-02-19 17:55:41 -06:00
f812857d75 add discord bridge field to the channel model
will be used in a future change to clean up markov chains
2023-02-19 17:55:41 -06:00
3d0be3c25a linter fixes for markov library methods 2023-02-19 17:55:38 -06:00
8b3caabb57 linting fixes on the markov models 2023-02-16 16:17:49 -06:00
1cf0364268 move templates aroudn to satisfy packaging now that it's being tested 2023-02-16 16:14:06 -06:00
337e4db650 update urls.pyes to use path() and add some tests 2023-02-16 00:04:25 -06:00
95396802de add safety dependency checking 2023-02-15 20:09:26 -06:00
4bcf06d5e7 version bumps, with some hopefully temporary pins
* django pinned to <4 since there are compatibility things to fix first
* tox pinned to <4 because of issues where it's incompatbile with safety

(safety itself coming in a moment)
2023-02-15 19:51:46 -06:00
54bf00b167 remove references from deleted twitter module 2023-02-15 19:49:27 -06:00
133c1df638 add python 3.9, 3.10 to support via tox 2023-02-15 18:17:32 -06:00
7c44becaa0 drop unsupported python 3.6, 3.7 2023-02-15 18:00:21 -06:00
76bcdd5fef config flag for having !dice/!random prefix the nick
with the discord-irc bridge in play, the bot was sending all !roll
results to the bridge user, which wasn't all that useful
2022-07-14 23:00:05 -05:00
1f5dc50d89 remove dead auth_login login URL variable 2021-12-13 15:08:24 -06:00
bc7108cfb7 properly calculate relative offsets via parseDT 2021-12-01 07:51:58 -06:00
dfdd6d6dc5 remove registration stuff from templates 2021-10-27 07:50:57 -05:00
75a1b8d7f3 version bump (new Django!) and remove twitter lib 2021-10-27 07:50:13 -05:00
fd5c4dad1a remove unused import 2021-10-26 23:10:45 -05:00
7264deee2a remove the long unsupported and unused twitter module 2021-10-26 22:58:24 -05:00
76e5546bcb remove django registration stuff, unused/desired 2021-10-26 22:25:08 -05:00
739d3fa2b7 new coat of paint on facts: !f shortcut, natural language fact adding
note that for now anyone can add a fact, going to assume responsible
usage on the part of users on my networks
2021-10-14 08:17:54 -05:00
6ab86f773c irc plugin to turn text into zalgo text 2021-05-17 10:07:34 -05:00
3d5e6754e8 add zalgo-text and bump pip-tools 2021-05-17 09:46:43 -05:00
ab1d3456ad only show PRIVMSGed if we had something to PRIVMSG 2021-05-06 09:36:28 -05:00
a550bf07ac discard from set to ignore KeyError 2021-05-06 09:36:28 -05:00
3fe0a8e59e initialize state stuff in __init__
this shouldn't practically matter but whatever
2021-05-06 09:36:23 -05:00
0ccb49e7ed make catchup regex insensitive, use alt wording 2021-05-06 09:27:41 -05:00
651399f5fc delete history pointer after reporting 2021-05-05 08:44:40 -05:00
0b3386e183 display channel count in the privmsg report 2021-05-05 08:42:23 -05:00
b21421a395 give feedback on lines missed regardless of pub/privmsg 2021-05-05 08:36:32 -05:00
9368c8b823 also report on where history was said
kind of an obvious thing to do which I naturally missed
2021-05-05 08:13:04 -05:00
def5964658 track, display timestamp of history 2021-05-05 08:08:47 -05:00
e43807fb27 initial (and working?) version of history tracking plugin 2021-05-05 08:03:56 -05:00
b3b8f832a2 remove unused import 2021-05-04 19:30:11 -05:00
b971b72af8 put current weather and forecast on separate lines
the long location string means this is often bleeding into two lines at
the very end anyway, so this just looks nicer
2021-04-26 11:52:43 -05:00
40286eeafc replace IRC color codes with nothing 2021-04-25 23:19:18 -05:00
d888c5f03b add Pi simulation values to serializer 2021-04-25 21:08:38 -05:00
d7b7bdf73d add another word match for countdown text triggers 2021-04-25 21:05:09 -05:00
a03c69258f use per-server IrcChannel for reminders
this makes it so that if we have multiple bot instances running, they
will only pay attention to the countdown items for their current server
2021-04-25 21:04:11 -05:00
43f2b09057 don't add the empty string to additional nicks
thinko on my part, this was making the regex for matching all nicks to
'|nick' when the field is '', because of split producing ['']. in
particular this was making markov trigger on every line
2021-04-25 21:00:34 -05:00
3aa3fb14e4 add RPC API to retrieve fact(s) 2021-04-25 18:48:34 -05:00
1fc8af09f8 use nearest area to produce return location 2021-04-25 16:34:01 -05:00
53c874dc21 option to replace IRC control chars with markdown
^C^B isn't allowed through Discord's API, and I'm sure some other stuff
like colors that I don't use. this makes it a server option to replace
them with Markdown, though I think this would only ever be interesting
for BitlBee + Discord
2021-04-25 12:11:59 -05:00
1036c08147 only autojoin channels for this connection 2021-04-25 11:38:19 -05:00
9c1109107b relate channels to their server
this is necessary for supporting multiple irc servers in one bot config.
this also has the side effect of requiring some code in ircbot and
markov which autocreates channels to also include the server (retrieved
via the connection). this will again help keep channels coherent for
multi-server arrangements

the twitter bot change here is untested but seems like the right idea (I
haven't used the twitter package in forever)
2021-04-25 11:13:10 -05:00
6136127c5f move IRC server settings to database
this is the first step in trying to get the bot to support multiple
servers with different channels, countdown triggers, and so on

this also ends up affecting some configuration around:
* dispatch
* markov
* admin privmsg form
2021-04-25 10:17:41 -05:00
44d8b7db00 lint cleanups 2021-04-24 20:49:19 -05:00
d518cb2b77 lint cleanups 2021-04-24 20:49:14 -05:00
cbbf6eb311 Merge branch 'backend-frameworkification' of bss/dr.botzo into master
Merge the (aborted) backend/Discord attempt. See PR #2
2021-04-24 15:36:22 -05:00
2f4156ce26 quote wttr.in requests 2021-04-24 13:00:18 -05:00
e64af1a0a1 report on karma keys 2021-04-24 13:00:18 -05:00
ca8798453c fix Dice plugin init 2021-04-24 13:00:18 -05:00
dc5f243608 optionally provide name of new countdown items 2021-04-24 13:00:18 -05:00
ab0d738851 for migrated pi history, set their x,y to -1.0 to be more obvious 2020-10-25 12:28:08 -05:00
9d94155f66 implement basic API GETs for countdown items 2020-10-25 12:20:39 -05:00
a6f8fc5dc1 make more countdown item fields optional in admin 2020-10-25 12:16:33 -05:00
bcc5f767ba little cleanup TODO in PiLog simulate() 2020-10-25 11:19:20 -05:00
a0a1aa10f4 wow, unit tests! pi is covered (except for the irc plugin)
I think I'm going to move the irc stuff into a separate project in the
future so I'm not worried about testing that one yet
2020-10-25 11:16:48 -05:00
d5e5343193 have pi simulate via API return a 201 2020-10-25 11:16:23 -05:00
da815a1fc3 provide DRF action to run a pi simulation 2020-10-24 23:58:45 -05:00
ef08cec0fb fix field reference in the PiLog.hit property 2020-10-24 23:58:02 -05:00
691ee7696b serve basic list/detail pi log simulation views 2020-10-24 11:58:43 -05:00
665f56a430 avoid divide by 0 in getting pi simulation value 2020-10-24 11:56:48 -05:00
4d94322c55 refactor PiLog to retain x,y values 2020-10-24 11:47:36 -05:00
10d73f570a start using tox, despite 100000 errors 2020-10-24 10:06:41 -05:00
819bbe74c6 recompile requirements
basically everything changed here, so... fingers crossed
2020-10-24 10:06:37 -05:00
1833430c5d put requirements in my now-usual spot 2020-10-24 09:54:58 -05:00
42a1efed79 Merge branch 'backend-frameworkification' of bss/dr.botzo into master 2019-10-11 09:00:37 -05:00
c670072c86 add versioneer stuff to the project 2019-10-11 08:37:37 -05:00
d5e1a2ed45 actually use an f-string when querying wttr.in 2019-10-06 10:44:21 -05:00
5f6e255ded fix some line length violations in weather/ircplugin.py 2019-10-06 09:34:01 -05:00
c0c0306419 expose weather report as an rpc view 2019-10-06 09:33:46 -05:00
56d0e26c6d reimplement !weather in the IRC bot 2019-10-06 09:13:51 -05:00
b42d0ac0e9 pin irc==15.0.6 in requirements 2019-10-05 11:34:28 -05:00
31758b80b6 weather: start rewriting weather plugin to use wttr.in 2019-10-05 10:36:28 -05:00
802072caed add markov RPC method for learning a line 2019-09-19 00:21:18 -05:00
9e4bc595a4 add markov RPC method for generating a line from a context 2019-09-19 00:12:36 -05:00
d34fb18949 rename 'home' view as 'index', fixes DEBUG page behavior 2019-06-29 09:41:28 -05:00
abce0262f3 provide dice_str in exceptions, where applicable 2019-06-29 09:24:16 -05:00
b917f78ca5 replace dice sanity checks as asserts 2019-06-29 09:23:35 -05:00
c2aa3df13f fix some '!= None' checks, rewrite as 'is not None' 2019-06-29 09:23:02 -05:00
6b8dd1a645 dos2unix dice/* 2019-06-22 11:45:09 -05:00
649a148209 don't assert on trials if they aren't provided 2019-06-22 11:43:30 -05:00
8fcc8365e3 add a dice rolling API view to the django app 2019-06-21 18:07:10 -05:00
f78d407d4c move DiceRoller to its own module
while I'm doing that, standardize the usage of raising exceptions when parsing goes wrong
2019-06-21 16:53:40 -05:00
8528152483 remove dice cthulhutech roll. hasn't been used in forever 2019-06-21 16:51:09 -05:00
f2fb0a26a4 remove unnecessary unicode_literal future imports, we py3 now 2019-06-21 15:23:33 -05:00
0f88715ffd remove unnecessary requirements-server.* 2019-06-21 10:06:17 -05:00
2f98a64cdd version bumps and migration to django 2.2 2019-06-21 10:05:40 -05:00
0589939137 support multiple strings as counting as nick highlights
also, treat @nicks as being addressed, since we are doing discord
through bitlbee now
2019-01-10 08:48:15 -06:00
151 changed files with 5160 additions and 2099 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
dr_botzo/_version.py export-subst

26
.gitignore vendored
View File

@@ -1,14 +1,10 @@
*.facts .idea/
*.json build/
*.log dist/
*.pyc tags/
*.sqlite3 *.egg-info/
*.swo .tox/
*.swp .coverage
*.urls
*~
.idea
tags
dr.botzo.data dr.botzo.data
dr.botzo.cfg dr.botzo.cfg
localsettings.py localsettings.py
@@ -18,3 +14,11 @@ parsetab.py
megahal.* megahal.*
dr.botzo.log dr.botzo.log
dr.botzo.markov dr.botzo.markov
*.facts
*.log
*.pyc
*.sqlite3
*.swo
*.swp
*.urls
*~

View File

@@ -1,20 +0,0 @@
doc-warnings: true
strictness: high
ignore-paths:
- migrations
ignore-patterns:
- \.log$
- localsettings.py$
- parsetab.py$
pylint:
enable:
- relative-import
options:
max-line-length: 120
good-names: log
pep8:
options:
max-line-length: 120
pep257:
disable:
- D203

8
MANIFEST.in Normal file
View File

@@ -0,0 +1,8 @@
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

@@ -12,6 +12,7 @@ from django.utils import timezone
from countdown.models import CountdownItem from countdown.models import CountdownItem
from ircbot.lib import Plugin, most_specific_message from ircbot.lib import Plugin, most_specific_message
from ircbot.models import IrcChannel
log = logging.getLogger('countdown.ircplugin') log = logging.getLogger('countdown.ircplugin')
@@ -22,12 +23,14 @@ class Countdown(Plugin):
new_reminder_regex = (r'remind\s+(?P<who>[^\s]+)\s+(?P<when_type>at|in|on)\s+(?P<when>.*?)\s+' 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'(and\s+every\s+(?P<recurring_period>.*?)\s+)?'
r'(until\s+(?P<recurring_until>.*?)\s+)?' r'(until\s+(?P<recurring_until>.*?)\s+)?'
r'(to|that|about)\s+(?P<text>.*)') r'(to|that|about|with)\s+(?P<text>.*?)'
r'(?=\s+\("(?P<name>.*)"\)|$)')
def __init__(self, bot, connection, event): def __init__(self, bot, connection, event):
"""Initialize some stuff.""" """Initialize some stuff."""
self.running_reminders = [] self.running_reminders = []
self.send_reminders = True self.send_reminders = True
self.server_config = connection.server_config
t = threading.Thread(target=self.reminder_thread) t = threading.Thread(target=self.reminder_thread)
t.daemon = True t.daemon = True
@@ -42,7 +45,7 @@ class Countdown(Plugin):
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+list$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+list$',
self.handle_item_list, -20) self.handle_item_list, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+(\S+)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!countdown\s+(.+)$',
self.handle_item_detail, -20) self.handle_item_detail, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], self.new_reminder_regex, self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], self.new_reminder_regex,
self.handle_new_reminder, -50) self.handle_new_reminder, -50)
@@ -65,20 +68,22 @@ class Countdown(Plugin):
# TODO: figure out if we need this sleep, which exists so we don't send reminders while still connecting to IRC # TODO: figure out if we need this sleep, which exists so we don't send reminders while still connecting to IRC
time.sleep(30) time.sleep(30)
while self.send_reminders: while self.send_reminders:
reminders = CountdownItem.objects.filter(is_reminder=True, sent_reminder=False, at_time__lte=timezone.now()) reminders = CountdownItem.objects.filter(is_reminder=True, sent_reminder=False,
at_time__lte=timezone.now(),
reminder_target_new__server=self.server_config)
for reminder in reminders: for reminder in reminders:
log.debug("%s @ %s", reminder.reminder_message, reminder.at_time) log.debug("%s @ %s", reminder.reminder_message, reminder.at_time)
if reminder.at_time <= timezone.now(): if reminder.at_time <= timezone.now():
log.info("sending %s to %s", reminder.reminder_message, reminder.reminder_target) 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) self.bot.reply(None, reminder.reminder_message, explicit_target=reminder.reminder_target_new.name)
# if recurring and not hit until, set a new at time, otherwise stop reminding # 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: if reminder.recurring_until is not None and timezone.now() >= reminder.recurring_until:
reminder.sent_reminder = True reminder.sent_reminder = True
elif reminder.recurring_period != '': elif reminder.recurring_period != '':
calendar = pdt.Calendar() calendar = pdt.Calendar()
when_t = calendar.parseDT(reminder.recurring_period, reminder.at_time, when_t = calendar.parseDT(f'in one {reminder.recurring_period}', reminder.at_time,
tzinfo=reminder.at_time.tzinfo)[0] tzinfo=reminder.at_time.tzinfo)[0]
if reminder.recurring_until is None or when_t <= reminder.recurring_until: if reminder.recurring_until is None or when_t <= reminder.recurring_until:
reminder.at_time = when_t reminder.at_time = when_t
@@ -99,9 +104,16 @@ class Countdown(Plugin):
recurring_period = match.group('recurring_period') recurring_period = match.group('recurring_period')
recurring_until = match.group('recurring_until') recurring_until = match.group('recurring_until')
text = match.group('text') text = match.group('text')
name = match.group('name')
log.debug("%s / %s / %s", who, when, text) log.debug("%s / %s / %s", who, when, text)
item_name = '{0:s}-{1:s}'.format(event.sender_nick, timezone.now().strftime('%s')) if not name:
item_name = '{0:s}-{1:s}'.format(event.sender_nick, timezone.now().strftime('%s'))
else:
if CountdownItem.objects.filter(name=name).count() > 0:
self.bot.reply(event, "item with name '{0:s}' already exists".format(name))
return 'NO MORE'
item_name = name
# parse when to send the notification # parse when to send the notification
if when_type == 'in': if when_type == 'in':
@@ -135,8 +147,12 @@ class Countdown(Plugin):
log.debug("%s / %s / %s", item_name, when_t, message) 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, countdown_item = CountdownItem.objects.create(name=item_name, at_time=when_t, is_reminder=True,
reminder_message=message, reminder_target=event.sent_location) reminder_message=message, reminder_target_new=reminder_target)
if recurring_period: if recurring_period:
countdown_item.recurring_period = recurring_period countdown_item.recurring_period = recurring_period
if recurring_until: if recurring_until:

View File

@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-23 02:25 # Generated by Django 1.10.5 on 2017-02-23 02:25
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-24 01:06 # Generated by Django 1.10.5 on 2017-02-24 01:06
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-24 01:12 # Generated by Django 1.10.5 on 2017-02-24 01:12
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1.2 on 2020-10-25 17:16
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('countdown', '0005_countdownitem_recurring_until'),
]
operations = [
migrations.AlterField(
model_name='countdownitem',
name='recurring_period',
field=models.CharField(blank=True, default='', max_length=64),
),
migrations.AlterField(
model_name='countdownitem',
name='reminder_target',
field=models.CharField(blank=True, default='', max_length=64),
),
]

View File

@@ -0,0 +1,20 @@
# 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

@@ -1,14 +1,8 @@
"""Countdown item models.""" """Countdown item models."""
import logging
from django.db import models from django.db import models
from django.utils import timezone from django.utils import timezone
log = logging.getLogger('countdown.models')
class CountdownItem(models.Model): class CountdownItem(models.Model):
"""Track points in time.""" """Track points in time."""
@@ -19,13 +13,15 @@ class CountdownItem(models.Model):
sent_reminder = models.BooleanField(default=False) sent_reminder = models.BooleanField(default=False)
reminder_message = models.TextField(default="") reminder_message = models.TextField(default="")
reminder_target = models.CharField(max_length=64, 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, default='') recurring_period = models.CharField(max_length=64, blank=True, default='')
recurring_until = models.DateTimeField(null=True, blank=True, default=None) recurring_until = models.DateTimeField(null=True, blank=True, default=None)
created_time = models.DateTimeField(auto_now_add=True) created_time = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
"""String representation.""" """Summarize object."""
return "{0:s} @ {1:s}".format(self.name, timezone.localtime(self.at_time).strftime('%Y-%m-%d %H:%M:%S %Z')) return "{0:s} @ {1:s}".format(self.name, timezone.localtime(self.at_time).strftime('%Y-%m-%d %H:%M:%S %Z'))

14
countdown/serializers.py Normal file
View File

@@ -0,0 +1,14 @@
"""REST serializers for countdown items."""
from rest_framework import serializers
from countdown.models import CountdownItem
class CountdownItemSerializer(serializers.ModelSerializer):
"""Countdown item serializer for the REST API."""
class Meta:
"""Meta options."""
model = CountdownItem
fields = '__all__'

13
countdown/urls.py Normal file
View File

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

14
countdown/views.py Normal file
View File

@@ -0,0 +1,14 @@
"""Provide an interface to countdown items."""
from rest_framework.permissions import IsAuthenticated
from rest_framework.viewsets import ReadOnlyModelViewSet
from countdown.models import CountdownItem
from countdown.serializers import CountdownItemSerializer
class CountdownItemViewSet(ReadOnlyModelViewSet):
"""Provide list and detail actions for countdown items."""
queryset = CountdownItem.objects.all()
serializer_class = CountdownItemSerializer
permission_classes = [IsAuthenticated]

View File

@@ -1,36 +1,31 @@
"""Roll dice when asked, intended for RPGs.""" """Roll dice when asked, intended for RPGs."""
# this breaks yacc, but ply might be happy in py3
#from __future__ import unicode_literals
import logging import logging
import math
import re
import random import random
import re
from django.conf import settings
from irc.client import NickMask from irc.client import NickMask
import ply.lex as lex from dice.roller import DiceRoller
import ply.yacc as yacc
from ircbot.lib import Plugin from ircbot.lib import Plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class Dice(Plugin): class Dice(Plugin):
"""Roll simple or complex dice strings.""" """Roll simple or complex dice strings."""
def __init__(self, bot, connection, event):
"""Set up the plugin."""
self.roller = DiceRoller()
super(Dice, self).__init__(bot, connection, event)
def start(self): def start(self):
"""Set up the handlers.""" """Set up the handlers."""
self.roller = DiceRoller()
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!roll\s+(.*)$',
self.handle_roll, -20) self.handle_roll, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!ctech\s+(.*)$',
self.handle_ctech, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$',
self.handle_random, -20) self.handle_random, -20)
@@ -38,398 +33,48 @@ class Dice(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_roll) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_roll)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_ctech)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random)
super(Dice, self).stop() super(Dice, self).stop()
def handle_random(self, connection, event, match): def handle_random(self, connection, event, match):
"""Handle the !random command which picks an item from a list.""" """Handle the !random command which picks an item from a list."""
nick = NickMask(event.source).nick nick = NickMask(event.source).nick
choices = match.group(1) choices = match.group(1)
choices_list = choices.split(' ') choices_list = choices.split(' ')
choice = random.choice(choices_list) choice = random.SystemRandom().choice(choices_list)
logger.debug(event.recursing) logger.debug(event.recursing)
if event.recursing: if event.recursing:
reply = "{0:s}".format(choice) reply = "{0:s}".format(choice)
else: elif settings.DICE_PREFIX_ROLLER:
reply = "{0:s}: {1:s}".format(nick, choice) reply = "{0:s}: {1:s}".format(nick, choice)
else:
reply = "{0:s}".format(choice)
return self.bot.reply(event, reply) return self.bot.reply(event, reply)
def handle_roll(self, connection, event, match): def handle_roll(self, connection, event, match):
"""Handle the !roll command which covers most common dice stuff.""" """Handle the !roll command which covers most common dice stuff."""
nick = NickMask(event.source).nick nick = NickMask(event.source).nick
dicestr = match.group(1) dicestr = match.group(1)
logger.debug(event.recursing) logger.debug(event.recursing)
try:
reply_str = self.roller.do_roll(dicestr)
except AssertionError as aex:
reply_str = f"Could not roll dice: {aex}"
except ValueError:
reply_str = "Unable to parse roll"
if event.recursing: if event.recursing:
reply = "{0:s}".format(self.roller.do_roll(dicestr)) reply = "{0:s}".format(reply_str)
elif settings.DICE_PREFIX_ROLLER:
reply = "{0:s}: {1:s}".format(nick, reply_str)
else: else:
reply = "{0:s}: {1:s}".format(nick, self.roller.do_roll(dicestr)) reply = "{0:s}".format(reply_str)
return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply)) return self.bot.reply(event, re.sub(r'(\d+)(.*?\s+)(\(.*?\))', r'\1\214\3', reply))
def handle_ctech(self, connection, event, match):
"""Handle cthulhutech dice rolls."""
nick = NickMask(event.source).nick
rollitrs = re.split(';\s*', match.group(1))
reply = ""
for count, roll in enumerate(rollitrs):
pattern = '^(\d+)d(?:(\+|\-)(\d+))?(?:\s+(.*))?'
regex = re.compile(pattern)
matches = regex.search(roll)
if matches is not None:
dice = int(matches.group(1))
modifier = 0
if matches.group(2) is not None and matches.group(3) is not None:
if str(matches.group(2)) == '-':
modifier = -1 * int(matches.group(3))
else:
modifier = int(matches.group(3))
result = roll + ': '
rolls = []
for d in range(dice):
rolls.append(random.randint(1, 10))
rolls.sort()
rolls.reverse()
# highest single die method
method1 = rolls[0]
# highest set method
method2 = 0
rolling_sum = 0
for i, r in enumerate(rolls):
# if next roll is same as current, sum and continue, else see if sum is best so far
if i+1 < len(rolls) and rolls[i+1] == r:
if rolling_sum == 0:
rolling_sum = r
rolling_sum += r
else:
if rolling_sum > method2:
method2 = rolling_sum
rolling_sum = 0
# check for set in progress (e.g. lots of 1s)
if rolling_sum > method2:
method2 = rolling_sum
# straight method
method3 = 0
rolling_sum = 0
count = 0
for i, r in enumerate(rolls):
# if next roll is one less as current, sum and continue, else check len and see if sum is best so far
if i+1 < len(rolls) and rolls[i+1] == r-1:
if rolling_sum == 0:
rolling_sum = r
count += 1
rolling_sum += r-1
count += 1
else:
if count >= 3 and rolling_sum > method3:
method3 = rolling_sum
rolling_sum = 0
# check for straight in progress (e.g. straight ending in 1)
if count >= 3 and rolling_sum > method3:
method3 = rolling_sum
# get best roll
best = max([method1, method2, method3])
# check for critical failure
botch = False
ones = 0
for r in rolls:
if r == 1:
ones += 1
if ones >= math.ceil(float(len(rolls))/2):
botch = True
if botch:
result += 'BOTCH'
else:
result += str(best + modifier)
rollres = ''
for i,r in enumerate(rolls):
rollres += str(r)
if i is not len(rolls)-1:
rollres += ','
result += ' [' + rollres
if modifier != 0:
if modifier > 0:
result += ' +' + str(modifier)
else:
result += ' -' + str(modifier * -1)
result += ']'
reply += result
if count is not len(rollitrs)-1:
reply += "; "
if reply is not "":
msg = "{0:s}: {1:s}".format(nick, reply)
return self.bot.reply(event, msg)
class DiceRoller(object):
tokens = ['NUMBER', 'TEXT', 'ROLLSEP']
literals = ['#', '/', '+', '-', 'd']
t_TEXT = r'\s+[^;]+'
t_ROLLSEP = r';\s*'
def build(self):
lex.lex(module=self)
yacc.yacc(module=self)
def t_NUMBER(self, t):
r'\d+'
t.value = int(t.value)
return t
def t_error(self, t):
t.lexer.skip(1)
precedence = (
('left', 'ROLLSEP'),
('left', '+', '-'),
('right', 'd'),
('left', '#'),
('left', '/')
)
output = ""
def roll_dice(self, keep, dice, size):
"""Takes the parsed dice string for a single roll (eg 3/4d20) and performs
the actual roll. Returns a string representing the result.
"""
a = list(range(dice))
for i in range(dice):
a[i] = random.randint(1, size)
if keep != dice:
b = sorted(a, reverse=True)
b = b[0:keep]
else:
b = a
total = sum(b)
outstr = "[" + ",".join(str(i) for i in a) + "]"
return (total, outstr)
def process_roll(self, trials, mods, comment):
"""Processes rolls coming from the parser.
This generates the inputs for the roll_dice() command, and returns
the full string representing the whole current dice string (the part
up to a semicolon or end of line).
"""
output = ""
repeat = 1
if trials != None:
repeat = trials
for i in range(repeat):
mode = 1
total = 0
curr_str = ""
if i > 0:
output += ", "
for m in mods:
keep = 0
dice = 1
res = 0
# if m is a tuple, then it is a die roll
# m[0] = (keep, num dice)
# m[1] = num faces on the die
if type(m) == tuple:
if m[0] != None:
if m[0][0] != None:
keep = m[0][0]
dice = m[0][1]
size = m[1]
if keep > dice or keep == 0:
keep = dice
if size < 1:
output = "# of sides for die is incorrect: %d" % size
return output
if dice < 1:
output = "# of dice is incorrect: %d" % dice
return output
res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1])
res = res[0]
elif m == "+":
mode = 1
curr_str += "+"
elif m == "-":
mode = -1
curr_str += "-"
else:
res = m
curr_str += str(m)
total += mode * res
if repeat == 1:
if comment != None:
output = "%d %s (%s)" % (total, comment.strip(), curr_str)
else:
output = "%d (%s)" % (total, curr_str)
else:
output += "%d (%s)" % (total, curr_str)
if i == repeat - 1:
if comment != None:
output += " (%s)" % (comment.strip())
return output
def p_roll_r(self, p):
# Chain rolls together.
# General idea I had when creating this grammar: A roll string is a chain
# of modifiers, which may be repeated for a certain number of trials. It can
# have a comment that describes the roll
# Multiple roll strings can be chained with semicolon
'roll : roll ROLLSEP roll'
global output
p[0] = p[1] + "; " + p[3]
output = p[0]
def p_roll(self, p):
# Parse a basic roll string.
'roll : trial modifier comment'
global output
mods = []
if type(p[2]) == list:
mods = p[2]
else:
mods = [p[2]]
p[0] = self.process_roll(p[1], mods, p[3])
output = p[0]
def p_roll_no_trials(self, p):
# Parse a roll string without trials.
'roll : modifier comment'
global output
mods = []
if type(p[1]) == list:
mods = p[1]
else:
mods = [p[1]]
p[0] = self.process_roll(None, mods, p[2])
output = p[0]
def p_comment(self, p):
# Parse a comment.
'''comment : TEXT
|'''
if len(p) == 2:
p[0] = p[1]
else:
p[0] = None
def p_modifier(self, p):
# Parse a modifier on a roll string.
'''modifier : modifier "+" modifier
| modifier "-" modifier'''
# Use append to prevent nested lists (makes dealing with this easier)
if type(p[1]) == list:
p[1].append(p[2])
p[1].append(p[3])
p[0] = p[1]
elif type(p[3]) == list:
p[3].insert(0, p[2])
p[3].insert(0, p[1])
p[0] = p[3]
else:
p[0] = [p[1], p[2], p[3]]
def p_die(self, p):
# Return the left side before the "d", and the number of faces.
'modifier : left NUMBER'
p[0] = (p[1], p[2])
def p_die_num(self, p):
'modifier : NUMBER'
p[0] = p[1]
def p_left(self, p):
# Parse the number of dice we are rolling, and how many we are keeping.
'left : keep dice'
if p[1] == None:
p[0] = [None, p[2]]
else:
p[0] = [p[1], p[2]]
def p_left_all(self, p):
'left : dice'
p[0] = [None, p[1]]
def p_left_e(self, p):
'left :'
p[0] = None
def p_total(self, p):
'trial : NUMBER "#"'
if len(p) > 1:
p[0] = p[1]
else:
p[0] = None
def p_keep(self, p):
'keep : NUMBER "/"'
if p[1] != None:
p[0] = p[1]
else:
p[0] = None
def p_dice(self, p):
'dice : NUMBER "d"'
p[0] = p[1]
def p_dice_one(self, p):
'dice : "d"'
p[0] = 1
def p_error(self, p):
# Provide the user with something (albeit not much) when the roll can't be parsed.
global output
output = "Unable to parse roll"
def get_result(self):
global output
return output
def do_roll(self, dicestr):
"""
Roll some dice and get the result (with broken out rolls).
Keyword arguments:
dicestr - format:
N#X/YdS+M label
N#: do the following roll N times (optional)
X/: take the top X rolls of the Y times rolled (optional)
Y : roll the die specified Y times (optional, defaults to 1)
dS: roll a S-sided die
+M: add M to the result (-M for subtraction) (optional)
"""
self.build()
yacc.parse(dicestr)
return self.get_result()
plugin = Dice plugin = Dice

260
dice/roller.py Normal file
View File

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

8
dice/urls.py Normal file
View File

@@ -0,0 +1,8 @@
"""URL patterns for the dice views."""
from django.urls import path
from dice.views import rpc_roll_dice
urlpatterns = [
path('rpc/roll/', rpc_roll_dice, name='dice_rpc_roll_dice'),
]

36
dice/views.py Normal file
View File

@@ -0,0 +1,36 @@
"""Views for rolling dice."""
import json
import logging
from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
from dice.roller import DiceRoller
logger = logging.getLogger(__name__)
roller = DiceRoller()
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_roll_dice(request):
"""Get a dice string from the client, roll the dice, and give the result."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
try:
roll_data = json.loads(request.body)
dice_str = roll_data['dice']
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request must be JSON with a 'dice' parameter."}, status=400)
try:
result_str = roller.do_roll(dice_str)
return Response({'dice': dice_str, 'result': result_str})
except AssertionError as aex:
return Response({'detail': f"Could not roll dice: {aex}", 'dice': dice_str}, status=400)
except ValueError:
return Response({'detail': f"Could not parse requested dice '{dice_str}'.", 'dice': dice_str}, status=400)

View File

@@ -29,6 +29,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='dispatcheraction', model_name='dispatcheraction',
name='dispatcher', name='dispatcher',
field=models.ForeignKey(to='dispatch.Dispatcher'), field=models.ForeignKey(to='dispatch.Dispatcher', on_delete=models.CASCADE),
), ),
] ]

View File

@@ -17,6 +17,6 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name='dispatcheraction', model_name='dispatcheraction',
name='dispatcher', name='dispatcher',
field=models.ForeignKey(related_name='actions', to='dispatch.Dispatcher'), field=models.ForeignKey(related_name='actions', to='dispatch.Dispatcher', on_delete=models.CASCADE),
), ),
] ]

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -0,0 +1,23 @@
# 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,10 +1,8 @@
"""Track dispatcher configurations.""" """Track dispatcher configurations."""
import logging import logging
from django.db import models from django.db import models
log = logging.getLogger('dispatch.models') log = logging.getLogger('dispatch.models')
@@ -13,6 +11,9 @@ class Dispatcher(models.Model):
key = models.CharField(max_length=16, unique=True) 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: class Meta:
"""Meta options.""" """Meta options."""
@@ -21,7 +22,7 @@ class Dispatcher(models.Model):
) )
def __str__(self): def __str__(self):
"""String representation.""" """Provide string representation."""
return "{0:s}".format(self.key) return "{0:s}".format(self.key)
@@ -36,11 +37,11 @@ class DispatcherAction(models.Model):
(FILE_TYPE, "Write to file"), (FILE_TYPE, "Write to file"),
) )
dispatcher = models.ForeignKey('Dispatcher', related_name='actions') dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE)
type = models.CharField(max_length=16, choices=TYPE_CHOICES) type = models.CharField(max_length=16, choices=TYPE_CHOICES)
destination = models.CharField(max_length=200) destination = models.CharField(max_length=200)
include_key = models.BooleanField(default=False) include_key = models.BooleanField(default=False)
def __str__(self): def __str__(self):
"""String representation.""" """Provide string representation."""
return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.type, self.destination) return "{0:s} -> {1:s} {2:s}".format(self.dispatcher.key, self.type, self.destination)

View File

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

View File

@@ -1,18 +1,15 @@
"""Handle dispatcher API requests.""" """Handle dispatcher API requests."""
import copy import copy
import logging import logging
import os import os
import xmlrpc.client import xmlrpc.client
from django.conf import settings
from rest_framework import generics, status from rest_framework import generics, status
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from dispatch.models import Dispatcher, DispatcherAction from dispatch.models import Dispatcher, DispatcherAction
from dispatch.serializers import DispatchMessageSerializer, DispatcherSerializer, DispatcherActionSerializer from dispatch.serializers import DispatcherActionSerializer, DispatcherSerializer, DispatchMessageSerializer
log = logging.getLogger('dispatch.views') log = logging.getLogger('dispatch.views')
@@ -77,7 +74,7 @@ class DispatchMessage(generics.GenericAPIView):
if action.type == DispatcherAction.PRIVMSG_TYPE: if action.type == DispatcherAction.PRIVMSG_TYPE:
# connect over XML-RPC and send # connect over XML-RPC and send
try: try:
bot_url = 'http://{0:s}:{1:d}/'.format(settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT) bot_url = 'http://{0:s}:{1:d}/'.format(dispatcher.bot_xmlrpc_host, dispatcher.bot_xmlrpc_port)
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True) bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
log.debug("sending '%s' to channel %s", text, action.destination) log.debug("sending '%s' to channel %s", text, action.destination)
bot.reply(None, text, False, action.destination) bot.reply(None, text, False, action.destination)

View File

@@ -0,0 +1,4 @@
"""Set up the version number of the bot."""
from ._version import get_versions
__version__ = get_versions()['version']
del get_versions

520
dr_botzo/_version.py Normal file
View File

@@ -0,0 +1,520 @@
# This file helps to compute a version number in source trees obtained from
# git-archive tarball (such as those provided by githubs download-from-tag
# feature). Distribution tarballs (built by setup.py sdist) and build
# directories (produced by setup.py build) will contain a much shorter file
# that just contains the computed version number.
# This file is released into the public domain. Generated by
# versioneer-0.18 (https://github.com/warner/python-versioneer)
"""Git implementation of _version.py."""
import errno
import os
import re
import subprocess
import sys
def get_keywords():
"""Get the keywords needed to look up the version information."""
# these strings will be replaced by git during git-archive.
# setup.py/versioneer.py will grep for the variable names, so they must
# each be defined on a line of their own. _version.py will just call
# get_keywords().
git_refnames = "$Format:%d$"
git_full = "$Format:%H$"
git_date = "$Format:%ci$"
keywords = {"refnames": git_refnames, "full": git_full, "date": git_date}
return keywords
class VersioneerConfig:
"""Container for Versioneer configuration parameters."""
def get_config():
"""Create, populate and return the VersioneerConfig() object."""
# these strings are filled in when 'setup.py versioneer' creates
# _version.py
cfg = VersioneerConfig()
cfg.VCS = "git"
cfg.style = "pep440"
cfg.tag_prefix = "v"
cfg.parentdir_prefix = "None"
cfg.versionfile_source = "dr_botzo/_version.py"
cfg.verbose = False
return cfg
class NotThisMethod(Exception):
"""Exception raised if a method is not valid for the current scenario."""
LONG_VERSION_PY = {}
HANDLERS = {}
def register_vcs_handler(vcs, method): # decorator
"""Decorator to mark a method as the handler for a particular VCS."""
def decorate(f):
"""Store f in HANDLERS[vcs][method]."""
if vcs not in HANDLERS:
HANDLERS[vcs] = {}
HANDLERS[vcs][method] = f
return f
return decorate
def run_command(commands, args, cwd=None, verbose=False, hide_stderr=False,
env=None):
"""Call the given command(s)."""
assert isinstance(commands, list)
p = None
for c in commands:
try:
dispcmd = str([c] + args)
# remember shell=False, so use git.cmd on windows, not just git
p = subprocess.Popen([c] + args, cwd=cwd, env=env,
stdout=subprocess.PIPE,
stderr=(subprocess.PIPE if hide_stderr
else None))
break
except EnvironmentError:
e = sys.exc_info()[1]
if e.errno == errno.ENOENT:
continue
if verbose:
print("unable to run %s" % dispcmd)
print(e)
return None, None
else:
if verbose:
print("unable to find command, tried %s" % (commands,))
return None, None
stdout = p.communicate()[0].strip()
if sys.version_info[0] >= 3:
stdout = stdout.decode()
if p.returncode != 0:
if verbose:
print("unable to run %s (error)" % dispcmd)
print("stdout was %s" % stdout)
return None, p.returncode
return stdout, p.returncode
def versions_from_parentdir(parentdir_prefix, root, verbose):
"""Try to determine the version from the parent directory name.
Source tarballs conventionally unpack into a directory that includes both
the project name and a version string. We will also support searching up
two directory levels for an appropriately named parent directory
"""
rootdirs = []
for i in range(3):
dirname = os.path.basename(root)
if dirname.startswith(parentdir_prefix):
return {"version": dirname[len(parentdir_prefix):],
"full-revisionid": None,
"dirty": False, "error": None, "date": None}
else:
rootdirs.append(root)
root = os.path.dirname(root) # up a level
if verbose:
print("Tried directories %s but none started with prefix %s" %
(str(rootdirs), parentdir_prefix))
raise NotThisMethod("rootdir doesn't start with parentdir_prefix")
@register_vcs_handler("git", "get_keywords")
def git_get_keywords(versionfile_abs):
"""Extract version information from the given file."""
# the code embedded in _version.py can just fetch the value of these
# keywords. When used from setup.py, we don't want to import _version.py,
# so we do it with a regexp instead. This function is not used from
# _version.py.
keywords = {}
try:
f = open(versionfile_abs, "r")
for line in f.readlines():
if line.strip().startswith("git_refnames ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["refnames"] = mo.group(1)
if line.strip().startswith("git_full ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["full"] = mo.group(1)
if line.strip().startswith("git_date ="):
mo = re.search(r'=\s*"(.*)"', line)
if mo:
keywords["date"] = mo.group(1)
f.close()
except EnvironmentError:
pass
return keywords
@register_vcs_handler("git", "keywords")
def git_versions_from_keywords(keywords, tag_prefix, verbose):
"""Get version information from git keywords."""
if not keywords:
raise NotThisMethod("no keywords at all, weird")
date = keywords.get("date")
if date is not None:
# git-2.2.0 added "%cI", which expands to an ISO-8601 -compliant
# datestamp. However we prefer "%ci" (which expands to an "ISO-8601
# -like" string, which we must then edit to make compliant), because
# it's been around since git-1.5.3, and it's too difficult to
# discover which version we're using, or to work around using an
# older one.
date = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
refnames = keywords["refnames"].strip()
if refnames.startswith("$Format"):
if verbose:
print("keywords are unexpanded, not using")
raise NotThisMethod("unexpanded keywords, not a git-archive tarball")
refs = set([r.strip() for r in refnames.strip("()").split(",")])
# starting in git-1.8.3, tags are listed as "tag: foo-1.0" instead of
# just "foo-1.0". If we see a "tag: " prefix, prefer those.
TAG = "tag: "
tags = set([r[len(TAG):] for r in refs if r.startswith(TAG)])
if not tags:
# Either we're using git < 1.8.3, or there really are no tags. We use
# a heuristic: assume all version tags have a digit. The old git %d
# expansion behaves like git log --decorate=short and strips out the
# refs/heads/ and refs/tags/ prefixes that would let us distinguish
# between branches and tags. By ignoring refnames without digits, we
# filter out many common branch names like "release" and
# "stabilization", as well as "HEAD" and "master".
tags = set([r for r in refs if re.search(r'\d', r)])
if verbose:
print("discarding '%s', no digits" % ",".join(refs - tags))
if verbose:
print("likely tags: %s" % ",".join(sorted(tags)))
for ref in sorted(tags):
# sorting will prefer e.g. "2.0" over "2.0rc1"
if ref.startswith(tag_prefix):
r = ref[len(tag_prefix):]
if verbose:
print("picking %s" % r)
return {"version": r,
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": None,
"date": date}
# no suitable tags, so version is "0+unknown", but full hex is still there
if verbose:
print("no suitable tags, using unknown + full revision id")
return {"version": "0+unknown",
"full-revisionid": keywords["full"].strip(),
"dirty": False, "error": "no suitable tags", "date": None}
@register_vcs_handler("git", "pieces_from_vcs")
def git_pieces_from_vcs(tag_prefix, root, verbose, run_command=run_command):
"""Get version from 'git describe' in the root of the source tree.
This only gets called if the git-archive 'subst' keywords were *not*
expanded, and _version.py hasn't already been rewritten with a short
version string, meaning we're inside a checked out source tree.
"""
GITS = ["git"]
if sys.platform == "win32":
GITS = ["git.cmd", "git.exe"]
out, rc = run_command(GITS, ["rev-parse", "--git-dir"], cwd=root,
hide_stderr=True)
if rc != 0:
if verbose:
print("Directory %s not under git control" % root)
raise NotThisMethod("'git rev-parse --git-dir' returned error")
# if there is a tag matching tag_prefix, this yields TAG-NUM-gHEX[-dirty]
# if there isn't one, this yields HEX[-dirty] (no NUM)
describe_out, rc = run_command(GITS, ["describe", "--tags", "--dirty",
"--always", "--long",
"--match", "%s*" % tag_prefix],
cwd=root)
# --long was added in git-1.5.5
if describe_out is None:
raise NotThisMethod("'git describe' failed")
describe_out = describe_out.strip()
full_out, rc = run_command(GITS, ["rev-parse", "HEAD"], cwd=root)
if full_out is None:
raise NotThisMethod("'git rev-parse' failed")
full_out = full_out.strip()
pieces = {}
pieces["long"] = full_out
pieces["short"] = full_out[:7] # maybe improved later
pieces["error"] = None
# parse describe_out. It will be like TAG-NUM-gHEX[-dirty] or HEX[-dirty]
# TAG might have hyphens.
git_describe = describe_out
# look for -dirty suffix
dirty = git_describe.endswith("-dirty")
pieces["dirty"] = dirty
if dirty:
git_describe = git_describe[:git_describe.rindex("-dirty")]
# now we have TAG-NUM-gHEX or HEX
if "-" in git_describe:
# TAG-NUM-gHEX
mo = re.search(r'^(.+)-(\d+)-g([0-9a-f]+)$', git_describe)
if not mo:
# unparseable. Maybe git-describe is misbehaving?
pieces["error"] = ("unable to parse git-describe output: '%s'"
% describe_out)
return pieces
# tag
full_tag = mo.group(1)
if not full_tag.startswith(tag_prefix):
if verbose:
fmt = "tag '%s' doesn't start with prefix '%s'"
print(fmt % (full_tag, tag_prefix))
pieces["error"] = ("tag '%s' doesn't start with prefix '%s'"
% (full_tag, tag_prefix))
return pieces
pieces["closest-tag"] = full_tag[len(tag_prefix):]
# distance: number of commits since tag
pieces["distance"] = int(mo.group(2))
# commit: short hex revision ID
pieces["short"] = mo.group(3)
else:
# HEX: no tags
pieces["closest-tag"] = None
count_out, rc = run_command(GITS, ["rev-list", "HEAD", "--count"],
cwd=root)
pieces["distance"] = int(count_out) # total number of commits
# commit date: see ISO-8601 comment in git_versions_from_keywords()
date = run_command(GITS, ["show", "-s", "--format=%ci", "HEAD"],
cwd=root)[0].strip()
pieces["date"] = date.strip().replace(" ", "T", 1).replace(" ", "", 1)
return pieces
def plus_or_dot(pieces):
"""Return a + if we don't already have one, else return a ."""
if "+" in pieces.get("closest-tag", ""):
return "."
return "+"
def render_pep440(pieces):
"""Build up version string, with post-release "local version identifier".
Our goal: TAG[+DISTANCE.gHEX[.dirty]] . Note that if you
get a tagged build and then dirty it, you'll get TAG+0.gHEX.dirty
Exceptions:
1: no tags. git_describe was just HEX. 0+untagged.DISTANCE.gHEX[.dirty]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += plus_or_dot(pieces)
rendered += "%d.g%s" % (pieces["distance"], pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
else:
# exception #1
rendered = "0+untagged.%d.g%s" % (pieces["distance"],
pieces["short"])
if pieces["dirty"]:
rendered += ".dirty"
return rendered
def render_pep440_pre(pieces):
"""TAG[.post.devDISTANCE] -- No -dirty.
Exceptions:
1: no tags. 0.post.devDISTANCE
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += ".post.dev%d" % pieces["distance"]
else:
# exception #1
rendered = "0.post.dev%d" % pieces["distance"]
return rendered
def render_pep440_post(pieces):
"""TAG[.postDISTANCE[.dev0]+gHEX] .
The ".dev0" means dirty. Note that .dev0 sorts backwards
(a dirty tree will appear "older" than the corresponding clean one),
but you shouldn't be releasing software with -dirty anyways.
Exceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += plus_or_dot(pieces)
rendered += "g%s" % pieces["short"]
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
rendered += "+g%s" % pieces["short"]
return rendered
def render_pep440_old(pieces):
"""TAG[.postDISTANCE[.dev0]] .
The ".dev0" means dirty.
Eexceptions:
1: no tags. 0.postDISTANCE[.dev0]
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"] or pieces["dirty"]:
rendered += ".post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
else:
# exception #1
rendered = "0.post%d" % pieces["distance"]
if pieces["dirty"]:
rendered += ".dev0"
return rendered
def render_git_describe(pieces):
"""TAG[-DISTANCE-gHEX][-dirty].
Like 'git describe --tags --dirty --always'.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
if pieces["distance"]:
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render_git_describe_long(pieces):
"""TAG-DISTANCE-gHEX[-dirty].
Like 'git describe --tags --dirty --always -long'.
The distance/hash is unconditional.
Exceptions:
1: no tags. HEX[-dirty] (note: no 'g' prefix)
"""
if pieces["closest-tag"]:
rendered = pieces["closest-tag"]
rendered += "-%d-g%s" % (pieces["distance"], pieces["short"])
else:
# exception #1
rendered = pieces["short"]
if pieces["dirty"]:
rendered += "-dirty"
return rendered
def render(pieces, style):
"""Render the given version pieces into the requested style."""
if pieces["error"]:
return {"version": "unknown",
"full-revisionid": pieces.get("long"),
"dirty": None,
"error": pieces["error"],
"date": None}
if not style or style == "default":
style = "pep440" # the default
if style == "pep440":
rendered = render_pep440(pieces)
elif style == "pep440-pre":
rendered = render_pep440_pre(pieces)
elif style == "pep440-post":
rendered = render_pep440_post(pieces)
elif style == "pep440-old":
rendered = render_pep440_old(pieces)
elif style == "git-describe":
rendered = render_git_describe(pieces)
elif style == "git-describe-long":
rendered = render_git_describe_long(pieces)
else:
raise ValueError("unknown style '%s'" % style)
return {"version": rendered, "full-revisionid": pieces["long"],
"dirty": pieces["dirty"], "error": None,
"date": pieces.get("date")}
def get_versions():
"""Get version information or return default if unable to do so."""
# I am in _version.py, which lives at ROOT/VERSIONFILE_SOURCE. If we have
# __file__, we can work backwards from there to the root. Some
# py2exe/bbfreeze/non-CPython implementations don't do __file__, in which
# case we can only use expanded keywords.
cfg = get_config()
verbose = cfg.verbose
try:
return git_versions_from_keywords(get_keywords(), cfg.tag_prefix,
verbose)
except NotThisMethod:
pass
try:
root = os.path.realpath(__file__)
# versionfile_source is the relative path from the top of the source
# tree (where the .git directory might live) to this file. Invert
# this to find the root from __file__.
for i in cfg.versionfile_source.split('/'):
root = os.path.dirname(root)
except NameError:
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to find root of source tree",
"date": None}
try:
pieces = git_pieces_from_vcs(cfg.tag_prefix, root, verbose)
return render(pieces, cfg.style)
except NotThisMethod:
pass
try:
if cfg.parentdir_prefix:
return versions_from_parentdir(cfg.parentdir_prefix, root, verbose)
except NotThisMethod:
pass
return {"version": "0+unknown", "full-revisionid": None,
"dirty": None,
"error": "unable to compute version", "date": None}

View File

@@ -11,8 +11,6 @@ https://docs.djangoproject.com/en/1.6/ref/settings/
# Build paths inside the project like this: os.path.join(BASE_DIR, ...) # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
from django.core.urlresolvers import reverse_lazy
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) BASE_DIR = os.path.dirname(os.path.dirname(__file__))
@@ -41,7 +39,6 @@ INSTALLED_APPS = (
'django_extensions', 'django_extensions',
'adminplus', 'adminplus',
'bootstrap3', 'bootstrap3',
'registration',
'rest_framework', 'rest_framework',
'countdown', 'countdown',
'dispatch', 'dispatch',
@@ -54,15 +51,13 @@ INSTALLED_APPS = (
'races', 'races',
'seen', 'seen',
'storycraft', 'storycraft',
'twitter',
) )
MIDDLEWARE_CLASSES = ( MIDDLEWARE = (
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware', 'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware', 'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
@@ -73,7 +68,7 @@ ROOT_URLCONF = 'dr_botzo.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')], 'DIRS': [os.path.join(BASE_DIR, 'dr_botzo', 'templates')],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
@@ -100,6 +95,9 @@ DATABASES = {
} }
} }
# inherited default, look at changing to BigAutoField
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/ # https://docs.djangoproject.com/en/1.6/topics/i18n/
@@ -149,36 +147,12 @@ BOOTSTRAP3 = {
} }
# 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
# IRC module stuff # IRC module stuff
# dice
DICE_PREFIX_ROLLER = False
# karma # karma
KARMA_IGNORE_CHATTER_TARGETS = [] KARMA_IGNORE_CHATTER_TARGETS = []
@@ -199,11 +173,6 @@ STORYCRAFT_DEFAULT_GAME_LENGTH = 20
STORYCRAFT_DEFAULT_LINE_LENGTH = 140 STORYCRAFT_DEFAULT_LINE_LENGTH = 140
STORYCRAFT_DEFAULT_LINES_PER_TURN = 2 STORYCRAFT_DEFAULT_LINES_PER_TURN = 2
# twitter
TWITTER_CONSUMER_KEY = None
TWITTER_CONSUMER_SECRET = None
# weather # weather
WEATHER_WEATHER_UNDERGROUND_API_KEY = None WEATHER_WEATHER_UNDERGROUND_API_KEY = None

View File

@@ -15,7 +15,7 @@
<div class="container"> <div class="container">
<div class="navbar-header"> <div class="navbar-header">
{% block navbarbrand %} {% block navbarbrand %}
<a class="navbar-brand" href="{% url 'home' %}">{{ site.domain }}</a> <a class="navbar-brand" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %} {% endblock %}
<!-- .navbar-toggle is used as the toggle for collapsed navbar content --> <!-- .navbar-toggle is used as the toggle for collapsed navbar content -->
@@ -44,18 +44,6 @@
<b class="caret"></b> <b class="caret"></b>
</a> </a>
<ul class="dropdown-menu"> <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 %} {% if user.is_authenticated and user.is_staff %}
<li> <li>
<a href="{% url 'admin:index' %}"> <a href="{% url 'admin:index' %}">
@@ -66,13 +54,6 @@
{% endif %} {% endif %}
</ul> </ul>
</li> </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 %} {% endif %}
</ul> </ul>
</div> </div>

View File

@@ -3,7 +3,7 @@
{% block title %}{{ site.domain }}{% endblock %} {% block title %}{{ site.domain }}{% endblock %}
{% block navbarbrand %} {% block navbarbrand %}
<a class="navbar-brand navbar-brand-active" href="{% url 'home' %}">{{ site.domain }}</a> <a class="navbar-brand navbar-brand-active" href="{% url 'index' %}">{{ site.domain }}</a>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@@ -1,24 +1,26 @@
"""General/baselite/site-wide URLs.""" """General/baselite/site-wide URLs."""
from django.conf.urls import include, url
from django.contrib import admin
from django.views.generic import TemplateView
from adminplus.sites import AdminSitePlus from adminplus.sites import AdminSitePlus
from django.conf.urls import include
from django.contrib import admin
from django.urls import path
from django.views.generic import TemplateView
admin.site = AdminSitePlus() admin.site = AdminSitePlus()
admin.sites.site = admin.site admin.sites.site = admin.site
admin.autodiscover() admin.autodiscover()
urlpatterns = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='index.html'), name='home'), path('', TemplateView.as_view(template_name='index.html'), name='index'),
url(r'^dispatch/', include('dispatch.urls')), path('countdown/', include('countdown.urls')),
url(r'^itemsets/', include('facts.urls')), path('dice/', include('dice.urls')),
url(r'^karma/', include('karma.urls')), path('dispatch/', include('dispatch.urls')),
url(r'^markov/', include('markov.urls')), path('itemsets/', include('facts.urls')),
url(r'^races/', include('races.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'^accounts/', include('registration.backends.default.urls')), path('admin/', admin.site.urls),
url(r'^admin/', include(admin.site.urls)),
] ]

View File

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

View File

@@ -26,6 +26,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='fact', model_name='fact',
name='category', name='category',
field=models.ForeignKey(to='facts.FactCategory'), field=models.ForeignKey(to='facts.FactCategory', on_delete=models.CASCADE),
), ),
] ]

View File

@@ -1,7 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by Django 1.10.5 on 2017-02-11 15:00 # Generated by Django 1.10.5 on 2017-02-11 15:00
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -52,7 +52,7 @@ class Fact(models.Model):
"""Define facts.""" """Define facts."""
fact = models.TextField() fact = models.TextField()
category = models.ForeignKey(FactCategory) category = models.ForeignKey(FactCategory, on_delete=models.CASCADE)
nickmask = models.CharField(max_length=200, default='', blank=True) nickmask = models.CharField(max_length=200, default='', blank=True)
time = models.DateTimeField(auto_now_add=True) time = models.DateTimeField(auto_now_add=True)

14
facts/serializers.py Normal file
View File

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

View File

@@ -2,12 +2,49 @@
import logging import logging
from django.shortcuts import get_object_or_404, render 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.models import FactCategory
from facts.serializers import FactSerializer
log = logging.getLogger(__name__) 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): def index(request):
"""Display a simple list of the fact categories, for the moment.""" """Display a simple list of the fact categories, for the moment."""
factcategories = FactCategory.objects.all() factcategories = FactCategory.objects.all()

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models
@@ -27,7 +25,7 @@ class Migration(migrations.Migration):
('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)), ('code_reviews_necessary', models.PositiveSmallIntegerField(default=0)),
('code_reviewers', models.TextField(blank=True, default='')), ('code_reviewers', models.TextField(blank=True, default='')),
('code_review_final_merge_assignees', 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)), ('gitlab_config', models.ForeignKey(to='gitlab_bot.GitlabConfig', null=True, on_delete=models.CASCADE)),
], ],
), ),
] ]

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -23,7 +23,7 @@ class GitlabProjectConfig(models.Model):
"""Maintain settings for a particular project in GitLab.""" """Maintain settings for a particular project in GitLab."""
gitlab_config = models.ForeignKey('GitlabConfig', null=True) gitlab_config = models.ForeignKey('GitlabConfig', null=True, on_delete=models.CASCADE)
project_id = models.CharField(max_length=64, unique=True) project_id = models.CharField(max_length=64, unique=True)
manage_merge_request_code_reviews = models.BooleanField(default=False) manage_merge_request_code_reviews = models.BooleanField(default=False)

1
history/__init__.py Normal file
View File

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

160
history/ircplugin.py Normal file
View File

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

View File

@@ -22,7 +22,7 @@ from irc.dict import IRCDict
import irc.modes import irc.modes
import ircbot.lib as ircbotlib import ircbot.lib as ircbotlib
from ircbot.models import Alias, IrcChannel, IrcPlugin from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
log = logging.getLogger('ircbot.bot') log = logging.getLogger('ircbot.bot')
@@ -54,6 +54,8 @@ class LenientServerConnection(irc.client.ServerConnection):
buffer_class = irc.buffer.LenientDecodingLineBuffer buffer_class = irc.buffer.LenientDecodingLineBuffer
server_config = None
def _prep_message(self, string): def _prep_message(self, string):
"""Override SimpleIRCClient._prep_message to add some logging.""" """Override SimpleIRCClient._prep_message to add some logging."""
log.debug("preparing message %s", string) log.debug("preparing message %s", string)
@@ -163,13 +165,30 @@ class DrReactor(irc.client.Reactor):
event.original_msg = what event.original_msg = what
# check if we were addressed or not # check if we were addressed or not
my_nick = connection.get_nickname() if connection.server_config.additional_addressed_nicks:
addressed_pattern = r'^{0:s}[:,]\s+(?P<addressed_msg>.*)'.format(my_nick) all_nicks = '|'.join(connection.server_config.additional_addressed_nicks.split('\n') +
match = re.match(addressed_pattern, what, re.IGNORECASE) [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)
else:
match = re.match(addressed_pattern, what, re.IGNORECASE)
except IrcChannel.DoesNotExist:
match = re.match(addressed_pattern, what, re.IGNORECASE)
if match: if match:
event.addressed = True event.addressed = True
event.addressed_msg = match.group('addressed_msg') event.addressed_msg = match.group('addressed_msg')
log.debug("all_nicks: %s, addressed: %s", all_nicks, event.addressed)
# only do aliasing for pubmsg/privmsg # only do aliasing for pubmsg/privmsg
log.debug("checking for alias for %s", what) log.debug("checking for alias for %s", what)
@@ -351,15 +370,16 @@ class IRCBot(irc.client.SimpleIRCClient):
reactor_class = DrReactor reactor_class = DrReactor
splitter = "..." splitter = "..."
def __init__(self, reconnection_interval=60): def __init__(self, server_name, reconnection_interval=60):
"""Initialize bot.""" """Initialize bot."""
super(IRCBot, self).__init__() super(IRCBot, self).__init__()
self.channels = IRCDict() self.channels = IRCDict()
self.plugins = [] self.plugins = []
# set up the server list self.server_config = IrcServer.objects.get(name=server_name)
self.server_list = settings.IRCBOT_SERVER_LIST # the reactor made the connection, save the server reference in it since we pass that around
self.connection.server_config = self.server_config
# set reconnection interval # set reconnection interval
if not reconnection_interval or reconnection_interval < 0: if not reconnection_interval or reconnection_interval < 0:
@@ -367,8 +387,8 @@ class IRCBot(irc.client.SimpleIRCClient):
self.reconnection_interval = reconnection_interval self.reconnection_interval = reconnection_interval
# set basic stuff # set basic stuff
self._nickname = settings.IRCBOT_NICKNAME self._nickname = self.server_config.nickname
self._realname = settings.IRCBOT_REALNAME self._realname = self.server_config.realname
# guess at nickmask. hopefully _on_welcome() will set this, but this should be # guess at nickmask. hopefully _on_welcome() will set this, but this should be
# a pretty good guess if not # a pretty good guess if not
@@ -395,7 +415,7 @@ class IRCBot(irc.client.SimpleIRCClient):
getattr(self, 'handle_reload'), -20) getattr(self, 'handle_reload'), -20)
# load XML-RPC server # load XML-RPC server
self.xmlrpc = SimpleXMLRPCServer((settings.IRCBOT_XMLRPC_HOST, settings.IRCBOT_XMLRPC_PORT), self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port),
requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True) requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True)
self.xmlrpc.register_introspection_functions() self.xmlrpc.register_introspection_functions()
@@ -414,16 +434,15 @@ class IRCBot(irc.client.SimpleIRCClient):
self.jump_server() self.jump_server()
def _connect(self): def _connect(self):
server = self.server_list[0]
try: try:
# build the connection factory as determined by IPV6/SSL settings # build the connection factory as determined by IPV6/SSL settings
if settings.IRCBOT_SSL: if self.server_config.use_ssl:
connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=settings.IRCBOT_IPV6) connect_factory = Factory(wrapper=ssl.wrap_socket, ipv6=self.server_config.use_ipv6)
else: else:
connect_factory = Factory(ipv6=settings.IRCBOT_IPV6) connect_factory = Factory(ipv6=self.server_config.use_ipv6)
self.connect(server[0], server[1], self._nickname, server[2], ircname=self._realname, self.connect(self.server_config.hostname, self.server_config.port, self._nickname,
connect_factory=connect_factory) self.server_config.password, ircname=self._realname, connect_factory=connect_factory)
except irc.client.ServerConnectionError: except irc.client.ServerConnectionError:
pass pass
@@ -528,15 +547,16 @@ class IRCBot(irc.client.SimpleIRCClient):
log.debug("welcome: %s", what) log.debug("welcome: %s", what)
# run automsg commands # run automsg commands
for cmd in settings.IRCBOT_POST_CONNECT_COMMANDS: if self.server_config.post_connect:
# TODO NOTE: if the bot is sending something that changes the vhost for cmd in self.server_config.post_connect.split('\n'):
# (like 'hostserv on') we don't pick it up # TODO NOTE: if the bot is sending something that changes the vhost
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:])) # (like 'hostserv on') we don't pick it up
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:]))
# sleep before doing autojoins # sleep before doing autojoins
time.sleep(settings.IRCBOT_SLEEP_BEFORE_AUTOJOIN_SECONDS) time.sleep(self.server_config.delay_before_joins)
for chan in IrcChannel.objects.filter(autojoin=True): for chan in IrcChannel.objects.filter(autojoin=True, server=connection.server_config):
log.info("autojoining %s", chan.name) log.info("autojoining %s", chan.name)
self.connection.join(chan) self.connection.join(chan)
@@ -584,7 +604,6 @@ class IRCBot(irc.client.SimpleIRCClient):
if self.connection.is_connected(): if self.connection.is_connected():
self.connection.disconnect(msg) self.connection.disconnect(msg)
self.server_list.append(self.server_list.pop(0))
self._connect() self._connect()
def on_ctcp(self, c, e): def on_ctcp(self, c, e):
@@ -889,6 +908,14 @@ class IRCBot(irc.client.SimpleIRCClient):
log.warning("reply() called with no event and no explicit target, aborting") log.warning("reply() called with no event and no explicit target, aborting")
return 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) log.debug("replypath: %s", replypath)
if replystr is not None: if replystr is not None:

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='botadmin', model_name='botadmin',
name='user', name='user',
field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL), field=models.ForeignKey(default=1, to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -0,0 +1,32 @@
# 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

@@ -0,0 +1,26 @@
# 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

@@ -0,0 +1,30 @@
# 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

@@ -0,0 +1,18 @@
# 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

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

View File

@@ -6,6 +6,7 @@ import re
import irc.client import irc.client
from django.conf import settings from django.conf import settings
from django.db.models import Count, Sum
from ircbot.lib import Plugin from ircbot.lib import Plugin
from karma.models import KarmaKey, KarmaLogEntry from karma.models import KarmaKey, KarmaLogEntry
@@ -23,6 +24,9 @@ class Karma(Plugin):
self.connection.add_global_handler('pubmsg', self.handle_chatter, -20) self.connection.add_global_handler('pubmsg', self.handle_chatter, -20)
self.connection.add_global_handler('privmsg', self.handle_chatter, -20) self.connection.add_global_handler('privmsg', self.handle_chatter, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'],
(r'^!karma\s+keyreport\s+(.*)'),
self.handle_keyreport, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'],
r'^!karma\s+rank\s+(.*)$', r'^!karma\s+rank\s+(.*)$',
self.handle_rank, -20) self.handle_rank, -20)
@@ -42,6 +46,7 @@ class Karma(Plugin):
self.connection.remove_global_handler('pubmsg', self.handle_chatter) self.connection.remove_global_handler('pubmsg', self.handle_chatter)
self.connection.remove_global_handler('privmsg', self.handle_chatter) self.connection.remove_global_handler('privmsg', self.handle_chatter)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_keyreport)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_rank) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_rank)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_report) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_report)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_stats) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_stats)
@@ -102,6 +107,19 @@ class Karma(Plugin):
except KarmaKey.DoesNotExist: except KarmaKey.DoesNotExist:
return self.bot.reply(event, "i have not seen any karma for {0:s}".format(match.group(1))) return self.bot.reply(event, "i have not seen any karma for {0:s}".format(match.group(1)))
def handle_keyreport(self, connection, event, match):
"""Provide report on a karma key."""
key = match.group(1).lower().rstrip()
try:
karma_key = KarmaKey.objects.get(key=key)
karmaers = KarmaLogEntry.objects.filter(key=karma_key)
karmaers = karmaers.values('nickmask').annotate(Sum('delta')).annotate(Count('delta')).order_by('-delta__count')
karmaers_list = [f"{irc.client.NickMask(x['nickmask']).nick} ({x['delta__count']}, {'+' if x['delta__sum'] >= 0 else ''}{x['delta__sum']})" for x in karmaers]
karmaers_list_str = ", ".join(karmaers_list[:10])
return self.bot.reply(event, f"most opinionated on {key}: {karmaers_list_str}")
except KarmaKey.DoesNotExist:
return self.bot.reply(event, "i have not seen any karma for {0:s}".format(match.group(1)))
def handle_report(self, connection, event, match): def handle_report(self, connection, event, match):
"""Provide some karma reports.""" """Provide some karma reports."""

View File

@@ -23,7 +23,7 @@ class Migration(migrations.Migration):
('delta', models.SmallIntegerField()), ('delta', models.SmallIntegerField()),
('nickmask', models.CharField(default='', max_length=200, blank=True)), ('nickmask', models.CharField(default='', max_length=200, blank=True)),
('created', models.DateTimeField(auto_now_add=True)), ('created', models.DateTimeField(auto_now_add=True)),
('key', models.ForeignKey(to='karma.KarmaKey')), ('key', models.ForeignKey(to='karma.KarmaKey', on_delete=models.CASCADE)),
], ],
), ),
] ]

View File

@@ -115,7 +115,7 @@ class KarmaLogEntryManager(models.Manager):
class KarmaLogEntry(models.Model): class KarmaLogEntry(models.Model):
"""Track each karma increment/decrement.""" """Track each karma increment/decrement."""
key = models.ForeignKey('KarmaKey') key = models.ForeignKey('KarmaKey', on_delete=models.CASCADE)
delta = models.SmallIntegerField() delta = models.SmallIntegerField()
nickmask = models.CharField(max_length=200, default='', blank=True) nickmask = models.CharField(max_length=200, default='', blank=True)
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)

View File

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

View File

@@ -1,22 +1,22 @@
"""IRC support for Markov chain learning and text generation."""
import logging import logging
import re import re
import irc.client import irc.client
import markov.lib as markovlib
from ircbot.lib import Plugin, reply_destination_for_event from ircbot.lib import Plugin, reply_destination_for_event
from ircbot.models import IrcChannel from ircbot.models import IrcChannel
import markov.lib as markovlib from markov.models import MarkovContext, MarkovTarget
log = logging.getLogger('markov.ircplugin') log = logging.getLogger('markov.ircplugin')
class Markov(Plugin): class Markov(Plugin):
"""Build Markov chains and reply with them.""" """Build Markov chains and reply with them."""
def start(self): def start(self):
"""Set up the handlers.""" """Set up the handlers."""
self.connection.add_global_handler('pubmsg', self.handle_chatter, -20) self.connection.add_global_handler('pubmsg', self.handle_chatter, -20)
self.connection.add_global_handler('privmsg', self.handle_chatter, -20) self.connection.add_global_handler('privmsg', self.handle_chatter, -20)
@@ -28,7 +28,6 @@ class Markov(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.remove_global_handler('pubmsg', self.handle_chatter) self.connection.remove_global_handler('pubmsg', self.handle_chatter)
self.connection.remove_global_handler('privmsg', self.handle_chatter) self.connection.remove_global_handler('privmsg', self.handle_chatter)
@@ -38,12 +37,11 @@ class Markov(Plugin):
def handle_reply(self, connection, event, match): def handle_reply(self, connection, event, match):
"""Generate a reply to one line, without learning it.""" """Generate a reply to one line, without learning it."""
target = reply_destination_for_event(event) target = reply_destination_for_event(event)
min_size = 15 min_size = 15
max_size = 30 max_size = 30
context = markovlib.get_or_create_target_context(target) context = self.get_or_create_target_context(target)
if match.group(2): if match.group(2):
min_size = int(match.group(2)) min_size = int(match.group(2))
@@ -62,41 +60,53 @@ class Markov(Plugin):
def handle_chatter(self, connection, event): def handle_chatter(self, connection, event):
"""Learn from IRC chatter.""" """Learn from IRC chatter."""
what = event.arguments[0] what = event.arguments[0]
my_nick = connection.get_nickname() who = irc.client.NickMask(event.source).nick
trimmed_what = re.sub(r'^{0:s}[:,]\s+'.format(my_nick), '', what)
nick = irc.client.NickMask(event.source).nick
target = reply_destination_for_event(event) 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 # check to see whether or not we should learn from this channel
channel = None channel = None
if irc.client.is_channel(target): if irc.client.is_channel(target):
channel, c = IrcChannel.objects.get_or_create(name=target) channel, c = IrcChannel.objects.get_or_create(name=target, server=connection.server_config)
if channel and not channel.markov_learn_from_channel: if channel and not channel.markov_learn_from_channel:
log.debug("not learning from %s as i've been told to ignore it", channel) log.debug("not learning from %s as i've been told to ignore it", channel)
else: else:
# learn the line # 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) recursing = getattr(event, 'recursing', False)
if not recursing: if not recursing:
log.debug("learning %s", trimmed_what) log.debug("learning %s", learning_what)
context = markovlib.get_or_create_target_context(target) context = self.get_or_create_target_context(target)
markovlib.learn_line(trimmed_what, context) markovlib.learn_line(learning_what, context)
log.debug("searching '%s' for '%s'", what, my_nick) log.debug("searching '%s' for '%s'", what, all_nicks)
if re.search(my_nick, what, re.IGNORECASE) is not None: if re.search(all_nicks, what, re.IGNORECASE) is not None:
context = markovlib.get_or_create_target_context(target) context = self.get_or_create_target_context(target)
addressed_pattern = r'^{0:s}[:,]\s+(.*)'.format(my_nick) addressed_pattern = r'^(({nicks})[:,]|@({nicks})[:,]?)\s+(?P<addressed_msg>.*)'.format(nicks=all_nicks)
addressed_re = re.compile(addressed_pattern) match = re.match(addressed_pattern, what, re.IGNORECASE)
if addressed_re.match(what): if match:
# i was addressed directly, so respond, addressing # i was addressed directly, so respond, addressing
# the speaker # the speaker
topics = [x for x in addressed_re.match(what).group(1).split(' ') if len(x) >= 3] topics = [x for x in match.group('addressed_msg').split(' ') if len(x) >= 3]
return self.bot.reply(event, "{0:s}: {1:s}" return self.bot.reply(event, "{0:s}: {1:s}"
"".format(nick, " ".join(markovlib.generate_line(context, topics=topics)))) "".format(who, " ".join(markovlib.generate_line(context, topics=topics))))
else: else:
# i wasn't addressed directly, so just respond # i wasn't addressed directly, so just respond
topics = [x for x in what.split(' ') if len(x) >= 3] topics = [x for x in what.split(' ') if len(x) >= 3]
@@ -104,5 +114,31 @@ class Markov(Plugin):
return self.bot.reply(event, "{0:s}" return self.bot.reply(event, "{0:s}"
"".format(" ".join(markovlib.generate_line(context, topics=topics)))) "".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
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
channel, c = IrcChannel.objects.get_or_create(name=target_name, server=self.connection.server_config)
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 plugin = Markov

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
"""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

@@ -27,7 +27,7 @@ class Migration(migrations.Migration):
('k2', models.CharField(max_length=128)), ('k2', models.CharField(max_length=128)),
('v', models.CharField(max_length=128)), ('v', models.CharField(max_length=128)),
('count', models.IntegerField(default=0)), ('count', models.IntegerField(default=0)),
('context', models.ForeignKey(to='markov.MarkovContext')), ('context', models.ForeignKey(to='markov.MarkovContext', on_delete=models.CASCADE)),
], ],
options={ options={
'permissions': set([('teach_line', 'Can teach lines'), ('import_log_file', 'Can import states from a log file')]), 'permissions': set([('teach_line', 'Can teach lines'), ('import_log_file', 'Can import states from a log file')]),
@@ -40,7 +40,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(unique=True, max_length=64)), ('name', models.CharField(unique=True, max_length=64)),
('chatter_chance', models.IntegerField(default=0)), ('chatter_chance', models.IntegerField(default=0)),
('context', models.ForeignKey(to='markov.MarkovContext')), ('context', models.ForeignKey(to='markov.MarkovContext', on_delete=models.CASCADE)),
], ],
options={ options={
}, },

View File

@@ -1,6 +1,4 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models from django.db import migrations, models

View File

@@ -0,0 +1,19 @@
# 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

@@ -0,0 +1,20 @@
# 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

@@ -0,0 +1,24 @@
"""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

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

View File

@@ -1,11 +1,13 @@
"""URL patterns for markov stuff.""" """URL patterns for markov stuff."""
from django.urls import path
from django.conf.urls import url
from django.views.generic import TemplateView from django.views.generic import TemplateView
from markov.views import context_index from markov.views import context_index, rpc_generate_line_for_context, rpc_learn_line_for_context
urlpatterns = [ urlpatterns = [
url(r'^$', TemplateView.as_view(template_name='index.html'), name='markov_index'), path('', TemplateView.as_view(template_name='index.html'), name='markov_index'),
url(r'^context/(?P<context_id>\d+)/$', context_index, name='markov_context_index'), path('context/<int:context_id>/', context_index, name='markov_context_index'),
path('rpc/context/<context>/generate/', rpc_generate_line_for_context, name='markov_rpc_generate_line'),
path('rpc/context/<context>/learn/', rpc_learn_line_for_context, name='markov_rpc_learn_line'),
] ]

View File

@@ -1,16 +1,19 @@
"""Manipulate Markov data via the Django site.""" """Manipulate Markov data via the Django site."""
import json
import logging import logging
import time import time
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
from rest_framework.authentication import BasicAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.decorators import api_view, authentication_classes, permission_classes
import markov.lib as markovlib import markov.lib as markovlib
from markov.models import MarkovContext from markov.models import MarkovContext
logger = logging.getLogger(__name__)
log = logging.getLogger('markov.views')
def index(request): def index(request):
@@ -28,3 +31,52 @@ def context_index(request, context_id):
end_t = time.time() end_t = time.time()
return render(request, 'markov/context.html', {'chain': chain, 'context': context, 'elapsed': end_t - start_t}) return render(request, 'markov/context.html', {'chain': chain, 'context': context, 'elapsed': end_t - start_t})
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_generate_line_for_context(request, context):
"""Generate a line from a given context, with optional topics included."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
topics = None
try:
if request.body:
markov_data = json.loads(request.body)
topics = markov_data.get('topics', [])
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request body, if provided, must be JSON with an optional 'topics' parameter."},
status=400)
context_id = markovlib.get_or_create_target_context(context)
try:
generated_words = markovlib.generate_line(context_id, topics)
except ValueError as vex:
return Response({'detail': f"Could not generate line: {vex}", 'context': context, 'topics': topics},
status=400)
else:
return Response({
'context': context, 'topics': topics,
'generated_line': ' '.join(generated_words), 'generated_words': generated_words
})
@api_view(['POST'])
@authentication_classes((BasicAuthentication, ))
@permission_classes((IsAuthenticated, ))
def rpc_learn_line_for_context(request, context):
"""Learn a line for a given context."""
if request.method != 'POST':
return Response({'detail': "Supported method: POST."}, status=405)
try:
markov_data = json.loads(request.body)
line = markov_data.get('line', [])
except (json.decoder.JSONDecodeError, KeyError):
return Response({'detail': "Request body must be JSON with a 'line' parameter."}, status=400)
context_id = markovlib.get_or_create_target_context(context)
markovlib.learn_line(line, context_id)
return Response({'status': "OK", 'context': context, 'line': line})

View File

@@ -1,21 +1,14 @@
# coding: utf-8 # coding: utf-8
"""Provide pi simulation results to IRC."""
import logging
from ircbot.lib import Plugin from ircbot.lib import Plugin
from pi.models import PiLog from pi.models import PiLog
log = logging.getLogger('pi.ircplugin')
class Pi(Plugin): class Pi(Plugin):
"""Use the Monte Carlo method to simulate pi.""" """Use the Monte Carlo method to simulate pi."""
def start(self): def start(self):
"""Set up the handlers.""" """Set up the handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!pi$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!pi$',
self.handle_pi, -20) self.handle_pi, -20)
@@ -23,17 +16,16 @@ class Pi(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_pi) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_pi)
super(Pi, self).stop() super(Pi, self).stop()
def handle_pi(self, connection, event, match): def handle_pi(self, connection, event, match):
"""Handle the pi command by generating another value and presenting it.""" """Handle the pi command by generating another value and presenting it."""
newest, x, y = PiLog.objects.simulate()
newest, x, y, hit = PiLog.objects.simulate()
msg = ("({0:.10f}, {1:.10f}) is {2}within the unit circle. π is {5:.10f}. (i:{3:d} p:{4:d})" msg = ("({0:.10f}, {1:.10f}) is {2}within the unit circle. π is {5:.10f}. (i:{3:d} p:{4:d})"
"".format(x, y, "" if hit else "not ", newest.count_inside, newest.count_total, newest.value())) "".format(x, y, "" if newest.hit else "not ", newest.total_count_inside,
newest.total_count, newest.value))
return self.bot.reply(event, msg) return self.bot.reply(event, msg)

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1.2 on 2020-10-24 16:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pi', '0002_auto_20150521_2204'),
]
operations = [
migrations.RenameField(
model_name='pilog',
old_name='count_total',
new_name='total_count',
),
migrations.RenameField(
model_name='pilog',
old_name='count_inside',
new_name='total_count_inside',
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 3.1.2 on 2020-10-24 16:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pi', '0003_rename_count_fields'),
]
operations = [
migrations.AddField(
model_name='pilog',
name='simulation_x',
field=models.DecimalField(decimal_places=10, default=-1.0, max_digits=11),
preserve_default=False,
),
migrations.AddField(
model_name='pilog',
name='simulation_y',
field=models.DecimalField(decimal_places=10, default=-1.0, max_digits=11),
preserve_default=False,
),
]

View File

@@ -1,67 +1,70 @@
"""Karma logging models.""" """Karma logging models."""
import logging
import math import math
import pytz
import random import random
import pytz
from django.conf import settings from django.conf import settings
from django.db import models from django.db import models
log = logging.getLogger('pi.models')
class PiLogManager(models.Manager): class PiLogManager(models.Manager):
"""Assemble some queries against PiLog.""" """Assemble some queries against PiLog."""
def simulate(self): def simulate(self):
"""Add one more entry to the log, and return it.""" """Add one more entry to the log, and return it."""
try: try:
latest = self.latest() latest = self.latest()
except PiLog.DoesNotExist: except PiLog.DoesNotExist:
latest = PiLog(count_inside=0, count_total=0) latest = PiLog.objects.create(simulation_x=0.0, simulation_y=0.0,
total_count_inside=0, total_count=0)
latest.save() latest.save()
inside = latest.count_inside inside = latest.total_count_inside
total = latest.count_total total = latest.total_count
x = random.random() x = random.random()
y = random.random() y = random.random()
hit = True if math.hypot(x,y) < 1 else False total += 1
if math.hypot(x, y) < 1:
inside += 1
if hit: newest = PiLog.objects.create(simulation_x=x, simulation_y=y,
newest = PiLog(count_inside=inside+1, count_total=total+1) total_count_inside=inside, total_count=total)
else:
newest = PiLog(count_inside=inside, count_total=total+1)
newest.save()
return newest, x, y, hit # TODO: remove the x, y return values, now that we track them in the object
return newest, x, y
class PiLog(models.Model): class PiLog(models.Model):
"""Track pi as it is estimated over time.""" """Track pi as it is estimated over time."""
count_inside = models.PositiveIntegerField() simulation_x = models.DecimalField(max_digits=11, decimal_places=10)
count_total = models.PositiveIntegerField() simulation_y = models.DecimalField(max_digits=11, decimal_places=10)
total_count_inside = models.PositiveIntegerField()
total_count = models.PositiveIntegerField()
created = models.DateTimeField(auto_now_add=True) created = models.DateTimeField(auto_now_add=True)
objects = PiLogManager() objects = PiLogManager()
class Meta: class Meta:
"""Options for the PiLog class."""
get_latest_by = 'created' get_latest_by = 'created'
def __str__(self): def __str__(self):
"""String representation.""" """Provide string representation."""
tz = pytz.timezone(settings.TIME_ZONE) tz = pytz.timezone(settings.TIME_ZONE)
return "({0:d}/{1:d}) @ {2:s}".format(self.count_inside, self.count_total, return "({0:d}/{1:d}) @ {2:s}".format(self.total_count_inside, self.total_count,
self.created.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S %Z')) self.created.astimezone(tz).strftime('%Y-%m-%d %H:%M:%S %Z'))
@property
def value(self): def value(self):
"""Return this log entry's value of pi.""" """Return this log entry's estimated value of pi."""
if self.total_count == 0:
return 0.0
return 4.0 * int(self.total_count_inside) / int(self.total_count)
return 4.0 * int(self.count_inside) / int(self.count_total) @property
def hit(self):
"""Return if this log entry is inside the unit circle."""
return math.hypot(self.simulation_x, self.simulation_y) < 1

16
pi/serializers.py Normal file
View File

@@ -0,0 +1,16 @@
"""REST serializers for pi simulations."""
from rest_framework import serializers
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."""
model = PiLog
fields = ('id', 'simulation_x', 'simulation_y', 'total_count', 'total_count_inside', 'value', 'hit')

13
pi/urls.py Normal file
View File

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

22
pi/views.py Normal file
View File

@@ -0,0 +1,22 @@
"""Provide pi simulation results."""
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet
from pi.models import PiLog
from pi.serializers import PiLogSerializer
class PiLogViewSet(ReadOnlyModelViewSet):
"""Provide list and detail actions for pi simulation log entries."""
queryset = PiLog.objects.all()
serializer_class = PiLogSerializer
permission_classes = [IsAuthenticated]
@action(detail=False, methods=['post'])
def simulate(self, request):
"""Run one simulation of the pi estimator."""
simulation, _, _ = PiLog.objects.simulate()
return Response(self.get_serializer(simulation).data, 201)

View File

@@ -29,7 +29,7 @@ class Migration(migrations.Migration):
('joined', models.BooleanField(default=False)), ('joined', models.BooleanField(default=False)),
('started', models.BooleanField(default=False)), ('started', models.BooleanField(default=False)),
('finished', models.BooleanField(default=False)), ('finished', models.BooleanField(default=False)),
('race', models.ForeignKey(to='races.Race')), ('race', models.ForeignKey(to='races.Race', on_delete=models.CASCADE)),
], ],
options={ options={
}, },
@@ -41,8 +41,8 @@ class Migration(migrations.Migration):
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('update', models.TextField()), ('update', models.TextField()),
('event_time', models.DateTimeField(default=django.utils.timezone.now)), ('event_time', models.DateTimeField(default=django.utils.timezone.now)),
('race', models.ForeignKey(to='races.Race')), ('race', models.ForeignKey(to='races.Race', on_delete=models.CASCADE)),
('racer', models.ForeignKey(to='races.Racer')), ('racer', models.ForeignKey(to='races.Racer', on_delete=models.CASCADE)),
], ],
options={ options={
'ordering': ['event_time'], 'ordering': ['event_time'],

View File

@@ -28,7 +28,7 @@ class Racer(models.Model):
"""Track a racer in a race.""" """Track a racer in a race."""
nick = models.CharField(max_length=64) nick = models.CharField(max_length=64)
race = models.ForeignKey(Race) race = models.ForeignKey(Race, on_delete=models.CASCADE)
joined = models.BooleanField(default=False) joined = models.BooleanField(default=False)
started = models.BooleanField(default=False) started = models.BooleanField(default=False)
@@ -47,8 +47,8 @@ class RaceUpdate(models.Model):
"""Periodic updates for a racer.""" """Periodic updates for a racer."""
race = models.ForeignKey(Race) race = models.ForeignKey(Race, on_delete=models.CASCADE)
racer = models.ForeignKey(Racer) racer = models.ForeignKey(Racer, on_delete=models.CASCADE)
update = models.TextField() update = models.TextField()
event_time = models.DateTimeField(default=timezone.now) event_time = models.DateTimeField(default=timezone.now)

View File

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

View File

@@ -1,5 +0,0 @@
-r requirements.in
logilab-common # prospector thing, i guess
pip-tools # pip-compile
prospector # code quality

View File

@@ -1,65 +0,0 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements-dev.txt requirements-dev.in
#
appdirs==1.4.0 # via setuptools
astroid==1.4.9 # via pylint, pylint-celery, pylint-flask, pylint-plugin-utils, requirements-detector
click==6.7 # via pip-tools
django-adminplus==0.5
django-bootstrap3==8.1.0
django-extensions==1.7.6
django-registration-redux==1.4
django==1.10.5
djangorestframework==3.5.3
dodgy==0.1.9 # via prospector
first==2.0.1 # via pip-tools
future==0.16.0 # via parsedatetime
inflect==0.2.5 # via jaraco.itertools
irc==15.0.6
isort==4.2.5 # via pylint
jaraco.classes==1.4 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text
jaraco.functools==1.15.1 # via irc, jaraco.text
jaraco.itertools==2.0 # via irc
jaraco.logging==1.5 # via irc
jaraco.stream==1.1.1 # via irc
jaraco.text==1.9 # via irc, jaraco.collections
lazy-object-proxy==1.2.2 # via astroid
logilab-common==1.3.0
mccabe==0.6.1 # via prospector, pylint
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib
packaging==16.8 # via setuptools
parsedatetime==2.2
pep8-naming==0.4.1 # via prospector
pip-tools==1.8.0
ply==3.10
prospector==0.12.4
pycodestyle==2.0.0 # via prospector
pydocstyle==1.0.0 # via prospector
pyflakes==1.5.0 # via prospector
pylint-celery==0.3 # via prospector
pylint-common==0.2.2 # via prospector
pylint-django==0.7.2 # via prospector
pylint-flask==0.5 # via prospector
pylint-plugin-utils==0.2.4 # via prospector, pylint-celery, pylint-django, pylint-flask
pylint==1.6.5 # via prospector, pylint-celery, pylint-common, pylint-django, pylint-flask, pylint-plugin-utils
pyparsing==2.1.10 # via packaging
python-dateutil==2.6.0
python-gitlab==0.18
python-mpd2==0.5.5
pytz==2016.10
pyyaml==3.12 # via prospector
requests-oauthlib==0.7.0 # via twython
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython
requirements-detector==0.5.2 # via prospector
setoptconf==0.2.0 # via prospector
six==1.10.0 # via astroid, django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, logilab-common, more-itertools, packaging, pip-tools, pylint, python-dateutil, python-gitlab, setuptools, tempora
tempora==1.6.1 # via irc, jaraco.logging
twython==3.4.0
wrapt==1.10.8 # via astroid
# The following packages are considered to be unsafe in a requirements file:
# setuptools # via logilab-common

View File

@@ -1,3 +0,0 @@
-r requirements.in
psycopg2

View File

@@ -1,36 +0,0 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements-server.txt requirements-server.in
#
django-adminplus==0.5
django-bootstrap3==8.1.0
django-extensions==1.7.6
django-registration-redux==1.4
django==1.10.5
djangorestframework==3.5.3
future==0.16.0 # via parsedatetime
inflect==0.2.5 # via jaraco.itertools
irc==15.0.6
jaraco.classes==1.4 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text
jaraco.functools==1.15.1 # via irc, jaraco.text
jaraco.itertools==2.0 # via irc
jaraco.logging==1.5 # via irc
jaraco.stream==1.1.1 # via irc
jaraco.text==1.9 # via irc, jaraco.collections
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib
parsedatetime==2.2
ply==3.10
psycopg2==2.6.2
python-dateutil==2.6.0
python-gitlab==0.18
python-mpd2==0.5.5
pytz==2016.10
requests-oauthlib==0.7.0 # via twython
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython
six==1.10.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, more-itertools, python-dateutil, python-gitlab, tempora
tempora==1.6.1 # via irc, jaraco.logging
twython==3.4.0

View File

@@ -1,35 +0,0 @@
#
# This file is autogenerated by pip-compile
# To update, run:
#
# pip-compile --output-file requirements.txt requirements.in
#
django-adminplus==0.5
django-bootstrap3==8.1.0
django-extensions==1.7.6
django-registration-redux==1.4
django==1.10.5
djangorestframework==3.5.3
future==0.16.0 # via parsedatetime
inflect==0.2.5 # via jaraco.itertools
irc==15.0.6
jaraco.classes==1.4 # via jaraco.collections
jaraco.collections==1.5 # via irc, jaraco.text
jaraco.functools==1.15.1 # via irc, jaraco.text
jaraco.itertools==2.0 # via irc
jaraco.logging==1.5 # via irc
jaraco.stream==1.1.1 # via irc
jaraco.text==1.9 # via irc, jaraco.collections
more-itertools==2.5.0 # via irc, jaraco.functools, jaraco.itertools
oauthlib==2.0.1 # via requests-oauthlib
parsedatetime==2.2
ply==3.10
python-dateutil==2.6.0
python-gitlab==0.18
python-mpd2==0.5.5
pytz==2016.10
requests-oauthlib==0.7.0 # via twython
requests==2.13.0 # via python-gitlab, requests-oauthlib, twython
six==1.10.0 # via django-extensions, irc, jaraco.classes, jaraco.collections, jaraco.itertools, jaraco.logging, jaraco.stream, more-itertools, python-dateutil, python-gitlab, tempora
tempora==1.6.1 # via irc, jaraco.logging
twython==3.4.0

View File

@@ -0,0 +1,26 @@
-r requirements.in
# testing runner, test reporting, packages used during testing (e.g. requests-mock), etc.
pytest
pytest-cov
pytest-django
# linting and other static code analysis
bandit
dlint
flake8 # flake8 and plugins, for local dev linting in vim
flake8-blind-except
flake8-builtins
flake8-docstrings
flake8-executable
flake8-fixme
flake8-isort
flake8-logging-format
flake8-mutable
safety
# maintenance utilities and tox
pip-tools # pip-compile
tox<4 # CI stuff
tox-wheel # build wheels in tox
versioneer # automatic version numbering

View File

@@ -0,0 +1,249 @@
#
# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# 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.1.0
# 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.30
# via bandit
idna==3.4
# via requests
importlib-resources==5.10.2
# via jaraco-text
inflect==6.0.2
# via
# jaraco-itertools
# jaraco-text
iniconfig==2.0.0
# via pytest
irc==15.0.6
# 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.5.2
# via
# irc
# jaraco-text
# tempora
jaraco-itertools==6.2.1
# via irc
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.0.0
# via
# irc
# jaraco-classes
# jaraco-functools
# jaraco-itertools
# 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.2
# 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-gitlab==3.13.0
# 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
# python-gitlab
# requests-toolbelt
# safety
requests-toolbelt==0.10.1
# via python-gitlab
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
# irc
# 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.19.0
# via tox
wheel==0.38.4
# via
# pip-tools
# tox-wheel
zalgo-text==0.6
# via -r requirements/requirements.in
zipp==3.13.0
# via importlib-resources
# The following packages are considered to be unsafe in a requirements file:
# pip
# setuptools

View File

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

View File

@@ -0,0 +1,109 @@
#
# This file is autogenerated by pip-compile with Python 3.8
# by the following command:
#
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in
#
asgiref==3.6.0
# via django
autocommand==2.2.2
# via jaraco-text
certifi==2022.12.7
# via requests
charset-normalizer==3.0.1
# via requests
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
idna==3.4
# via requests
importlib-resources==5.10.2
# via jaraco-text
inflect==6.0.2
# via
# jaraco-itertools
# jaraco-text
irc==15.0.6
# 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.5.2
# via
# irc
# jaraco-text
# tempora
jaraco-itertools==6.2.1
# via irc
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.0.0
# via
# irc
# jaraco-classes
# jaraco-functools
# jaraco-itertools
# 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-gitlab==3.13.0
# 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
requests==2.28.2
# via
# python-gitlab
# requests-toolbelt
requests-toolbelt==0.10.1
# via python-gitlab
six==1.16.0
# via
# irc
# python-dateutil
sqlparse==0.4.3
# via django
tempora==5.2.1
# via
# irc
# jaraco-logging
typing-extensions==4.5.0
# via pydantic
urllib3==1.26.14
# via requests
zalgo-text==0.6
# via -r requirements/requirements.in
zipp==3.13.0
# via importlib-resources

View File

@@ -1,7 +1,4 @@
"""Use dr.botzo's Dispatch to send mpd notifications.""" """Use dr.botzo's Dispatch to send mpd notifications."""
from __future__ import unicode_literals
import argparse import argparse
import getpass import getpass
import logging import logging

6
setup.cfg Normal file
View File

@@ -0,0 +1,6 @@
[versioneer]
VCS = git
style = pep440
versionfile_source = dr_botzo/_version.py
versionfile_build = dr_botzo/_version.py
tag_prefix = v

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