Compare commits

...

50 Commits

Author SHA1 Message Date
0d691ff431
tweak the green-ness of the middle !reaction result
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-24 16:48:30 -06:00
e7d63ee963
slight tweak to the !reaction reply string
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-24 16:43:44 -06:00
3100efd4a9
add a !reaction dice roll for a very simple oracle-style vibe check
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-24 16:39:06 -06:00
6d8ba18380
cypher: distinguish between a task roll and an attack roll
attack = A, otherwise the same options as with a generic task (= T)

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
3b7c871a94
cypher: case insensitivity when matching task difficulties
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
fa4815153a
cypher: slightly better display of output with no difficulty or mods
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
cc9b110531
fix a display issue in the karma key score
this would probably only matter if adding a key manually that doesn't
have a score, but a fix is a fix

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
af4746fb90
allow Markov contexts to be hidden from the web
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2025-02-08 23:57:23 -06:00
d0cbc815d1
update post_connect to accept a /command to do things other than "msg"
these are just /msg and /mode for now, and the slash is retained out of
convention

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-12-16 23:47:32 -06:00
166b506843
the weather module depends on requests
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-08 08:29:30 -06:00
12adb2a205
plug the cypher system roller into the irc plugin
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:32:06 -06:00
8e22ccb2a3
start a TODO for myself
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:16:12 -06:00
02e5769ab5
irc 20.5.0 breaks recursion checks/bot.reply()
it seems like the Command object (the event used in basically
everything) expects an argument to __new__() that deepcopy can't
resolve, and deepcopy is critical to recursion currently, so until that
can be refactored, just pin our library

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 16:13:06 -06:00
ac16ec99e5
add a Cypher System dice rolling library method
not available to the bot, yet, but that's next

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 14:33:00 -06:00
994f9ef50b
Revert "replace dice sanity checks as asserts"
not sure why I would have done this, asserts are not great

This reverts commit b917f78ca53a0cb1744e787ddd764d29cb600975.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-11-07 12:24:33 -06:00
f43b9e4de4
updates to the packages under test
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:25 -05:00
58fd0d0728
remove unused gitlab CI configuration
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 13:41:08 -05:00
cf8d24187f
requirements bumps, pinning Django to 5.0 for now
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:25:55 -05:00
8f76e54d30
add a couple dice roller tests
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:18:56 -05:00
1674300ec3
convert to pyproject.toml (no versioneer)
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-10-31 00:10:03 -05:00
355d17d171
flake8 cleanups
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-07-17 00:30:27 -05:00
e5b9f1634a
completely remove usages of django-adminplus
it was futzing up some /admin/ login stuff, and I hadn't actually used
the three views using it in years, so probably better to just yeet it
(technical term) into the sun.

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 13:24:38 -05:00
e63740ca70
replace discord bridge nick with sender nick inside the bot
when the bot has received a message through the discord bridge that
it'll end up reacting to (by creating a countdown item, for instance),
the nick in the event should, for all intents and purposes, be the
sender's nick on the discord side, not the discord bridge itself

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:52:58 -05:00
03e1269cf2
updates to bump the whole app to Django 5.0
note that this removes support for python 3.8 and 3.9!

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:44:57 -05:00
2dfb942f91
some trivial setup.py cleanups until we convert
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:17:09 -05:00
a92d0041cf
add the REUSE tool, not that this repo is compliant yet
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-03 12:16:43 -05:00
84eddf4b8a
requirements bumps
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-01 22:12:40 -05:00
b69053b279
add python 3.11 to versions under test in tox
Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-05-01 22:06:23 -05:00
a1a256ca3b
adopt the DCO and clean up license, etc. information
NOTE: for the moment this project is GPLv3 ONLY (no "or later" was ever
present). I will hopefully reach out to the couple other authors later
to see about adding the "or later" part

Signed-off-by: Brian S. Stephan <bss@incorporeal.org>
2024-02-23 00:09:44 -06:00
ee6ae7080e
remove the bridge-speaker from the message when relevant
this allows downstream event handlers to react to e.g.:

<discord_user> !weather 12345

as if they were normal leading IRC commands
2023-09-12 09:16:38 -05:00
333424025b support markov targets with identical names on different servers
markov targets are queried and autogenerated based on chatter, but had a
legacy name which is no longer in use for this, preferring the foreign
keys to channel and consequently server. the name is really just
informative these days, but was still being used to find targets, and
thus was breaking when two servers had the same channel name in them.
this fixes that
2023-05-04 17:24:07 -05:00
98abab560e
flake8 cleanups 2023-03-27 16:14:11 -05:00
86e55cb812
ignore migrations in the flake8 checking 2023-03-27 16:13:52 -05:00
420a7b1472
add the ability to disable the web display of some apps 2023-03-27 16:05:42 -05:00
a214d8acfd
remove unused weather underground key 2023-03-27 14:26:51 -05:00
93c522037f
use r-string for the regex path 2023-03-27 14:20:07 -05:00
b7976c46d1 fix the loading of the karma UI 2023-03-07 15:04:35 -06:00
d962b275ff
remove the gitlab bot, it's its own project now 2023-03-02 08:05:42 -06:00
f898f35ce6
replace execute_delayed with reactor.scheduler.execute_after
the former was deprecated forever ago, and apparently removed. this may
fix the disconnect detection logic
2023-03-02 00:51:22 -06:00
4289f95800
report on the version of dr.botzo in CTCP VERSION 2023-03-02 00:45:55 -06:00
572ecddceb
do some small cleanups 2023-03-02 00:45:29 -06:00
3aadde4b71
remove XMLRPC inheritence that overrode a method no longer in existence
this is probably from python 2 days; we inherited from
SimpleXMLRPCRequestHandler to change the logging, but the method
overrode no longer exists so this did nothing
2023-03-02 00:20:25 -06:00
c2d26f404e
deduplicate Channel object from irc library
I think this is an extremely ancient copy and paste job I never fully
corrected
2023-03-02 00:19:27 -06:00
ecaabbce89
unpin the irc library 2023-03-02 00:16:32 -06:00
051e656a82
fix errant reference to IrcChannel object rather than just the name 2023-03-02 00:15:06 -06:00
0ea54a5ee2
require authentication to get dispatch objects via API 2023-02-28 18:37:05 -06:00
ffcdc3f8d8
rename dispatcher action type to action_type 2023-02-28 18:31:53 -06:00
cff1a183cf
fix dispatcher API URLs to allow key-by-name 2023-02-28 18:19:46 -06:00
68f7c80b7e put the security middleware as the first middleware
I don't think I've ever gotten a solid idea that this is *necessary*,
but I've seen other docs refer to/assume this, so sure?
2023-02-20 10:30:13 -06:00
7baa70d8f6 customize the list view in the django admin 2023-02-20 08:59:54 -06:00
78 changed files with 1382 additions and 3637 deletions

1
.gitignore vendored
View File

@ -22,3 +22,4 @@ dr.botzo.markov
*.swp *.swp
*.urls *.urls
*~ *~
_version.py

View File

@ -1,9 +0,0 @@
before_script:
- virtualenv --python=python3.4 env-py3 --clear
- source env-py3/bin/activate
- pip install -Ur requirements-dev.txt
lint:
script:
- echo "--zero-exit should be set until only a few messages remain"
- prospector --zero-exit

View File

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

34
DCO.txt Normal file
View File

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

View File

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

10
MAINTAINERS.md Normal file
View File

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

6
TODO.md Normal file
View File

@ -0,0 +1,6 @@
# TODO
Notes to my future self.
* Refactor recursion --- see 02e5769ab5b9406725235c4e37968f93afd0e978 --- to remove deepcopy
* maybe I can push/pop onto arguments or something instead of duplicating the whole event?

View File

@ -1,5 +1,4 @@
"""An acromania style game for IRC.""" """An acromania style game for IRC."""
import logging import logging
import random import random
import threading import threading
@ -9,21 +8,17 @@ from irc.client import NickMask, is_channel
from ircbot.lib import Plugin from ircbot.lib import Plugin
logger = logging.getLogger('acro.ircplugin')
log = logging.getLogger('acro.ircplugin')
class Acro(Plugin): class Acro(Plugin):
"""Play a game where users come up with silly definitions for randomly-generated acronyms.""" """Play a game where users come up with silly definitions for randomly-generated acronyms."""
class AcroGame(object): class AcroGame(object):
"""Track game details.""" """Track game details."""
def __init__(self): def __init__(self):
"""Initialize basic stuff.""" """Initialize basic stuff."""
# running state # running state
self.state = 0 self.state = 0
self.quit = False self.quit = False
@ -32,12 +27,10 @@ class Acro(Plugin):
self.channel = '' self.channel = ''
class AcroRound(object): class AcroRound(object):
"""Track a particular round of a game.""" """Track a particular round of a game."""
def __init__(self): def __init__(self):
"""Initialize basic stuff.""" """Initialize basic stuff."""
self.acro = "" self.acro = ""
self.submissions = dict() self.submissions = dict()
self.sub_shuffle = [] self.sub_shuffle = []
@ -51,7 +44,6 @@ class Acro(Plugin):
def __init__(self, bot, connection, event): def __init__(self, bot, connection, event):
"""Set up the game tracking and such.""" """Set up the game tracking and such."""
# game state # game state
self.game = self.AcroGame() self.game = self.AcroGame()
@ -59,7 +51,6 @@ class Acro(Plugin):
def start(self): def start(self):
"""Set up handlers.""" """Set up handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+status$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+status$',
self.handle_status, -20) self.handle_status, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+start$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!acro\s+start$',
@ -75,7 +66,6 @@ class Acro(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_status) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_status)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_start) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_start)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_submit) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_submit)
@ -86,7 +76,6 @@ class Acro(Plugin):
def handle_status(self, connection, event, match): def handle_status(self, connection, event, match):
"""Return the status of the currently-running game, if there is one.""" """Return the status of the currently-running game, if there is one."""
if self.game.state == 0: if self.game.state == 0:
return self.bot.reply(event, "there currently isn't a game running.") return self.bot.reply(event, "there currently isn't a game running.")
else: else:
@ -94,7 +83,6 @@ class Acro(Plugin):
def handle_start(self, connection, event, match): def handle_start(self, connection, event, match):
"""Start the game, notify the channel, and begin timers.""" """Start the game, notify the channel, and begin timers."""
if self.game.state != 0: if self.game.state != 0:
return self.bot.reply(event, "the game is already running.") return self.bot.reply(event, "the game is already running.")
else: else:
@ -105,7 +93,6 @@ class Acro(Plugin):
def handle_submit(self, connection, event, match): def handle_submit(self, connection, event, match):
"""Take a submission for an acro.""" """Take a submission for an acro."""
nick = NickMask(event.source).nick nick = NickMask(event.source).nick
submission = match.group(1) submission = match.group(1)
@ -113,7 +100,6 @@ class Acro(Plugin):
def handle_vote(self, connection, event, match): def handle_vote(self, connection, event, match):
"""Take a vote for an acro.""" """Take a vote for an acro."""
nick = NickMask(event.source).nick nick = NickMask(event.source).nick
vote = match.group(1) vote = match.group(1)
@ -121,26 +107,22 @@ class Acro(Plugin):
def handle_quit(self, connection, event, match): def handle_quit(self, connection, event, match):
"""Quit the game after the current round ends.""" """Quit the game after the current round ends."""
if self.game.state != 0: if self.game.state != 0:
self.game.quit = True self.game.quit = True
return self.bot.reply(event, "the game will end after the current round.") return self.bot.reply(event, "the game will end after the current round.")
def thread_do_process_submissions(self, sleep_time): def thread_do_process_submissions(self, sleep_time):
"""Wait for players to provide acro submissions, and then kick off voting.""" """Wait for players to provide acro submissions, and then kick off voting."""
time.sleep(sleep_time) time.sleep(sleep_time)
self._start_voting() self._start_voting()
def thread_do_process_votes(self): def thread_do_process_votes(self):
"""Wait for players to provide votes, and then continue or quit.""" """Wait for players to provide votes, and then continue or quit."""
time.sleep(self.game.rounds[-1].seconds_to_vote) time.sleep(self.game.rounds[-1].seconds_to_vote)
self._end_current_round() self._end_current_round()
def _start_new_game(self, channel): def _start_new_game(self, channel):
"""Begin a new game, which will have multiple rounds.""" """Begin a new game, which will have multiple rounds."""
self.game.state = 1 self.game.state = 1
self.game.channel = channel self.game.channel = channel
self.bot.reply(None, "starting a new game of acro. it will run until you tell it to quit.", self.bot.reply(None, "starting a new game of acro. it will run until you tell it to quit.",
@ -150,12 +132,12 @@ class Acro(Plugin):
def _start_new_round(self): def _start_new_round(self):
"""Start a new round for play.""" """Start a new round for play."""
self.game.state = 2 self.game.state = 2
self.game.rounds.append(self.AcroRound()) self.game.rounds.append(self.AcroRound())
acro = self._generate_acro() acro = self._generate_acro()
self.game.rounds[-1].acro = acro self.game.rounds[-1].acro = acro
sleep_time = self.game.rounds[-1].seconds_to_submit + (self.game.rounds[-1].seconds_to_submit_step * (len(acro)-3)) sleep_time = (self.game.rounds[-1].seconds_to_submit +
(self.game.rounds[-1].seconds_to_submit_step * (len(acro)-3)))
self.bot.reply(None, "the round has started! your acronym is '{0:s}'. " self.bot.reply(None, "the round has started! your acronym is '{0:s}'. "
"submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time), "submit within {1:d} seconds via !acro submit [meaning]".format(acro, sleep_time),
@ -166,17 +148,16 @@ class Acro(Plugin):
t.start() t.start()
@staticmethod @staticmethod
def _generate_acro(): def _generate_acro(): # noqa: C901
"""Generate an acro to play with. """Generate an acro to play with.
Letter frequencies pinched from Letter frequencies pinched from
http://www.math.cornell.edu/~mec/2003-2004/cryptography/subs/frequencies.html http://www.math.cornell.edu/~mec/2003-2004/cryptography/subs/frequencies.html
""" """
acro = [] acro = []
# generate acro 3-8 characters long # generate acro 3-8 characters long
for i in range(1, random.randint(4, 9)): for i in range(1, random.SystemRandom.randint(4, 9)):
letter = random.randint(1, 182303) letter = random.SystemRandom.randint(1, 182303)
if letter <= 21912: if letter <= 21912:
acro.append('E') acro.append('E')
elif letter <= 38499: elif letter <= 38499:
@ -234,7 +215,6 @@ class Acro(Plugin):
def _take_acro_submission(self, nick, submission): def _take_acro_submission(self, nick, submission):
"""Take an acro submission and record it.""" """Take an acro submission and record it."""
if self.game.state == 2: if self.game.state == 2:
sub_acro = self._turn_text_into_acro(submission) sub_acro = self._turn_text_into_acro(submission)
@ -247,7 +227,6 @@ class Acro(Plugin):
@staticmethod @staticmethod
def _turn_text_into_acro(text): def _turn_text_into_acro(text):
"""Turn text into an acronym.""" """Turn text into an acronym."""
words = text.split() words = text.split()
acro = [] acro = []
for w in words: for w in words:
@ -256,10 +235,9 @@ class Acro(Plugin):
def _start_voting(self): def _start_voting(self):
"""Begin the voting period.""" """Begin the voting period."""
self.game.state = 3 self.game.state = 3
self.game.rounds[-1].sub_shuffle = list(self.game.rounds[-1].submissions.keys()) self.game.rounds[-1].sub_shuffle = list(self.game.rounds[-1].submissions.keys())
random.shuffle(self.game.rounds[-1].sub_shuffle) random.SystemRandom.shuffle(self.game.rounds[-1].sub_shuffle)
self.bot.reply(None, "here are the results. vote with !acro vote [number]", explicit_target=self.game.channel) self.bot.reply(None, "here are the results. vote with !acro vote [number]", explicit_target=self.game.channel)
self._print_round_acros() self._print_round_acros()
@ -269,21 +247,19 @@ class Acro(Plugin):
def _print_round_acros(self): def _print_round_acros(self):
"""Take the current round's acros and spit them to the channel.""" """Take the current round's acros and spit them to the channel."""
i = 0 i = 0
for s in self.game.rounds[-1].sub_shuffle: for s in self.game.rounds[-1].sub_shuffle:
log.debug("%s is %s", str(i), s) logger.debug("%s is %s", str(i), s)
self.bot.reply(None, " {0:d}: {1:s}".format(i+1, self.game.rounds[-1].submissions[s]), self.bot.reply(None, " {0:d}: {1:s}".format(i+1, self.game.rounds[-1].submissions[s]),
explicit_target=self.game.channel) explicit_target=self.game.channel)
i += 1 i += 1
def _take_acro_vote(self, nick, vote): def _take_acro_vote(self, nick, vote):
"""Take an acro vote and record it.""" """Take an acro vote and record it."""
if self.game.state == 3: if self.game.state == 3:
acros = self.game.rounds[-1].submissions acros = self.game.rounds[-1].submissions
if int(vote) > 0 and int(vote) <= len(acros): if int(vote) > 0 and int(vote) <= len(acros):
log.debug("%s is %s", vote, self.game.rounds[-1].sub_shuffle[int(vote)-1]) logger.debug("%s is %s", vote, self.game.rounds[-1].sub_shuffle[int(vote)-1])
key = self.game.rounds[-1].sub_shuffle[int(vote)-1] key = self.game.rounds[-1].sub_shuffle[int(vote)-1]
if key != nick: if key != nick:
@ -296,7 +272,6 @@ class Acro(Plugin):
def _end_current_round(self): def _end_current_round(self):
"""Clean up and output for ending the current round.""" """Clean up and output for ending the current round."""
self.game.state = 4 self.game.state = 4
self.bot.reply(None, "voting's over! here are the scores for the round:", explicit_target=self.game.channel) self.bot.reply(None, "voting's over! here are the scores for the round:", explicit_target=self.game.channel)
self._print_round_scores() self._print_round_scores()
@ -308,7 +283,6 @@ class Acro(Plugin):
def _print_round_scores(self): def _print_round_scores(self):
"""For the acros in the round, find the votes for them.""" """For the acros in the round, find the votes for them."""
i = 0 i = 0
for s in list(self.game.rounds[-1].submissions.keys()): for s in list(self.game.rounds[-1].submissions.keys()):
votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s] votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s]
@ -318,7 +292,6 @@ class Acro(Plugin):
def _add_round_scores_to_game_scores(self): def _add_round_scores_to_game_scores(self):
"""Apply the final round scores to the totall scores for the game.""" """Apply the final round scores to the totall scores for the game."""
for s in list(self.game.rounds[-1].votes.values()): for s in list(self.game.rounds[-1].votes.values()):
votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s] votes = [x for x in list(self.game.rounds[-1].votes.values()) if x == s]
if s in list(self.game.scores.keys()): if s in list(self.game.scores.keys()):
@ -328,7 +301,6 @@ class Acro(Plugin):
def _continue_or_quit(self): def _continue_or_quit(self):
"""Decide whether the game should continue or quit.""" """Decide whether the game should continue or quit."""
if self.game.state == 4: if self.game.state == 4:
if self.game.quit: if self.game.quit:
self._end_game() self._end_game()
@ -337,7 +309,6 @@ class Acro(Plugin):
def _end_game(self): def _end_game(self):
"""Clean up the entire game.""" """Clean up the entire game."""
self.game.state = 0 self.game.state = 0
self.game.quit = False self.game.quit = False
self.bot.reply(None, "game's over! here are the final scores:", explicit_target=self.game.channel) self.bot.reply(None, "game's over! here are the final scores:", explicit_target=self.game.channel)
@ -345,7 +316,6 @@ class Acro(Plugin):
def _print_game_scores(self): def _print_game_scores(self):
"""Print the final calculated scores.""" """Print the final calculated scores."""
for s in list(self.game.scores.keys()): for s in list(self.game.scores.keys()):
self.bot.reply(None, " {0:s}: {1:d}".format(s, self.game.scores[s]), explicit_target=self.game.channel) self.bot.reply(None, " {0:s}: {1:d}".format(s, self.game.scores[s]), explicit_target=self.game.channel)

View File

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

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('countdown', '0007_countdownitem_reminder_target_new'),
]
operations = [
migrations.AlterField(
model_name='countdownitem',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -4,14 +4,18 @@ import random
import re import re
from django.conf import settings from django.conf import settings
from irc.client import NickMask from irc.client import NickMask
from dice.lib import cypher_roll, reaction_roll
from dice.roller import DiceRoller from dice.roller import DiceRoller
from ircbot.lib import Plugin from ircbot.lib import Plugin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
CYPHER_ROLL_REGEX = r'((?P<type>A|T)(?P<difficulty>\d+))?(?P<mods>(?:\s*(-|\+)\d+)*)\s*(?P<comment>.*)?'
CYPHER_COMMAND_REGEX = r'^!cypher\s+(' + CYPHER_ROLL_REGEX + ')'
REACTION_COMMAND_REGEX = r'^!reaction$'
class Dice(Plugin): class Dice(Plugin):
"""Roll simple or complex dice strings.""" """Roll simple or complex dice strings."""
@ -24,10 +28,14 @@ class Dice(Plugin):
def start(self): def start(self):
"""Set up the handlers.""" """Set up the handlers."""
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], CYPHER_COMMAND_REGEX,
self.handle_cypher_roll, -20)
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'^!random\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!random\s+(.*)$',
self.handle_random, -20) self.handle_random, -20)
self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], REACTION_COMMAND_REGEX,
self.handle_reaction_roll, -20)
super(Dice, self).start() super(Dice, self).start()
@ -35,9 +43,52 @@ class Dice(Plugin):
"""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_random) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_random)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_cypher_roll)
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_reaction_roll)
super(Dice, self).stop() super(Dice, self).stop()
def handle_cypher_roll(self, connection, event, match):
"""Handle the !cypher roll."""
nick = NickMask(event.source).nick
task = match.group(1)
task_group = re.search(CYPHER_ROLL_REGEX, task, re.IGNORECASE)
difficulty = int(task_group.group('difficulty')) if task_group.group('difficulty') else None
mods = task_group.group('mods')
is_attack = True if task_group.group('type') and task_group.group('type').upper() == 'A' else False
comment = task_group.group('comment')
result, beats, success, effect = cypher_roll(difficulty=difficulty, mods=mods, is_attack=is_attack)
if success is not None:
if success:
if effect:
result_str = f"9succeeded, with {effect}!"
else:
result_str = "9succeeded!"
else:
if effect:
result_str = f"4failed, with {effect}!"
else:
result_str = "4failed."
else:
if effect:
result_str = f"beats a difficulty {beats} task, with {effect}!"
else:
result_str = f"beats a difficulty {beats} task."
if success is not None:
# show the adjusted difficulty
detail_str = f"14(d20={result} vs. diff. {difficulty}{mods})"
else:
detail_str = f"14(d20={result}{f' with {mods} levels' if mods else ''})"
if comment:
return self.bot.reply(event, f"{nick}: {comment} {result_str} {detail_str}")
else:
type_str = 'attack' if is_attack else 'check'
return self.bot.reply(event, f"{nick}: your {type_str} {result_str} {detail_str}")
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
@ -55,6 +106,20 @@ class Dice(Plugin):
reply = "{0:s}".format(choice) reply = "{0:s}".format(choice)
return self.bot.reply(event, reply) return self.bot.reply(event, reply)
def handle_reaction_roll(self, connection, event, match):
"""Handle the !reaction roll."""
nick = NickMask(event.source).nick
roll, label, summary = reaction_roll()
if summary in ('--', '-'):
result_str = f"4{label}"
elif summary == '~':
result_str = f"3{label}"
else:
result_str = f"9{label}"
return self.bot.reply(event, f"{nick}: the current disposition is {result_str} 14({roll})")
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

71
dice/lib.py Normal file
View File

@ -0,0 +1,71 @@
"""Dice rolling operations (outside of the lex/yacc roller)."""
import random
import numexpr
rand = random.SystemRandom()
def cypher_roll(difficulty=None, mods=0, is_attack=False):
"""Make a Cypher System roll.
Args:
difficulty: the original difficulty to beat; if provided, success or failure is indicated in the results
mods: eases(-) and hindrances(+) to apply to the check, as a string (e.g. '-3+1')
is_attack: if the roll is an attack (in which case the damage-only effects are included)
Returns:
tuple of:
- the result on the d20
- the highest difficulty beaten
- if the difficulty is known, if the target was beat
- miscellaneous effects
"""
roll = rand.randint(1, 20)
if roll == 1:
return (roll, None, False if difficulty else None, 'a GM intrusion')
effect = None
if roll == 17 and is_attack:
effect = '+1 damage'
elif roll == 18 and is_attack:
effect = '+2 damage'
elif roll == 19:
effect = 'a minor effect'
elif roll == 20:
effect = 'a MAJOR EFFECT'
# if we know the difficulty, the mods would adjust the difficulty, but for the case where we don't,
# and maybe just in general, it's easier to modify the difficulty that the roll beats, so we flip the logic
# if incoming eases are a negative number, they should add to the difficulty the roll beats
beats = (roll // 3) - (numexpr.evaluate(mods).item() if mods else 0)
beats = 0 if beats < 0 else beats
return (roll, beats, difficulty <= beats if difficulty else None, effect)
def reaction_roll():
"""Make a reaction roll, which gives some oracle-like randomness to uncertain situations (for solo play or similar).
Returns:
tuple of:
- the result on the d20
- the label for the result
- a summary of the result ('++', '+', '~', '-', '--') (best to worst)
"""
roll = rand.randint(1, 20)
if 1 == roll:
label = 'VERY negative'
summary = '--'
elif 2 <= roll <= 6:
label = 'negative'
summary = '-'
elif 7 <= roll <= 14:
label = 'positive, with complications'
summary = '~'
elif 15 <= roll <= 19:
label = 'positive'
summary = '+'
else:
label = 'VERY positive'
summary = '++'
return (roll, label, summary)

View File

@ -93,8 +93,12 @@ class DiceRoller(object):
size = m[1] size = m[1]
if keep > dice or keep == 0: if keep > dice or keep == 0:
keep = dice keep = dice
assert size >= 1, f"Die must have at least one side." if size < 1:
assert dice >= 1, f"At least one die must be rolled." 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) res = self.roll_dice(keep, dice, size)
curr_str += "%d%s" % (res[0], res[1]) curr_str += "%d%s" % (res[0], res[1])
res = res[0] res = res[0]

View File

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

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dispatch', '0007_rename_type_dispatcheraction_action_type'),
]
operations = [
migrations.AlterField(
model_name='dispatcher',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='dispatcheraction',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -38,10 +38,10 @@ class DispatcherAction(models.Model):
) )
dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE) dispatcher = models.ForeignKey('Dispatcher', related_name='actions', on_delete=models.CASCADE)
type = models.CharField(max_length=16, choices=TYPE_CHOICES) action_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):
"""Provide 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.action_type, self.destination)

View File

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

View File

@ -6,11 +6,11 @@ from dispatch.views import (DispatcherActionDetail, DispatcherActionList, Dispat
urlpatterns = [ urlpatterns = [
path('api/dispatchers/', DispatcherList.as_view(), name='dispatch_api_dispatchers'), path('api/dispatchers/', DispatcherList.as_view(), name='dispatch_api_dispatchers'),
path('api/dispatchers/<pk>/', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'), path('api/dispatchers/<int:pk>/', DispatcherDetail.as_view(), name='dispatch_api_dispatcher_detail'),
path('api/dispatchers/<pk>/message', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'), path('api/dispatchers/<int:pk>/message', DispatchMessage.as_view(), name='dispatch_api_dispatch_message'),
path('api/dispatchers/<key>/', DispatcherDetailByKey.as_view(), name='dispatch_api_dispatcher_detail'), path('api/dispatchers/<key>/', DispatcherDetailByKey.as_view(), name='dispatch_api_dispatcher_detail'),
path('api/dispatchers/<key>/message', DispatchMessageByKey.as_view(), name='dispatch_api_dispatch_message'), path('api/dispatchers/<key>/message', DispatchMessageByKey.as_view(), name='dispatch_api_dispatch_message'),
path('api/actions/', DispatcherActionList.as_view(), name='dispatch_api_actions'), path('api/actions/', DispatcherActionList.as_view(), name='dispatch_api_actions'),
path('api/actions/<pk>/', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'), path('api/actions/<int:pk>/', DispatcherActionDetail.as_view(), name='dispatch_api_action_detail'),
] ]

View File

@ -28,6 +28,8 @@ class HasSendMessagePermission(IsAuthenticated):
class DispatcherList(generics.ListAPIView): class DispatcherList(generics.ListAPIView):
"""List all dispatchers.""" """List all dispatchers."""
permission_classes = (IsAuthenticated,)
queryset = Dispatcher.objects.all() queryset = Dispatcher.objects.all()
serializer_class = DispatcherSerializer serializer_class = DispatcherSerializer
@ -35,6 +37,8 @@ class DispatcherList(generics.ListAPIView):
class DispatcherDetail(generics.RetrieveAPIView): class DispatcherDetail(generics.RetrieveAPIView):
"""Detail the given dispatcher.""" """Detail the given dispatcher."""
permission_classes = (IsAuthenticated,)
queryset = Dispatcher.objects.all() queryset = Dispatcher.objects.all()
serializer_class = DispatcherSerializer serializer_class = DispatcherSerializer
@ -71,19 +75,19 @@ class DispatchMessage(generics.GenericAPIView):
else: else:
text = message.data['message'] text = message.data['message']
if action.type == DispatcherAction.PRIVMSG_TYPE: if action.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(dispatcher.bot_xmlrpc_host, dispatcher.bot_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)
except Exception as e: except xmlrpc.client.Fault as xmlex:
new_data = copy.deepcopy(message.data) new_data = copy.deepcopy(message.data)
new_data['status'] = "FAILED - {0:s}".format(str(e)) new_data['status'] = "FAILED - {0:s}".format(str(xmlex))
new_message = self.serializer_class(data=new_data) new_message = self.serializer_class(data=new_data)
return Response(new_message.initial_data) return Response(new_message.initial_data)
elif action.type == DispatcherAction.FILE_TYPE: elif action.action_type == DispatcherAction.FILE_TYPE:
# write to file # write to file
filename = os.path.abspath(action.destination) filename = os.path.abspath(action.destination)
log.debug("sending '%s' to file %s", text, filename) log.debug("sending '%s' to file %s", text, filename)
@ -107,6 +111,8 @@ class DispatchMessageByKey(DispatchMessage):
class DispatcherActionList(generics.ListAPIView): class DispatcherActionList(generics.ListAPIView):
"""List all dispatchers.""" """List all dispatchers."""
permission_classes = (IsAuthenticated,)
queryset = DispatcherAction.objects.all() queryset = DispatcherAction.objects.all()
serializer_class = DispatcherActionSerializer serializer_class = DispatcherActionSerializer
@ -114,5 +120,7 @@ class DispatcherActionList(generics.ListAPIView):
class DispatcherActionDetail(generics.RetrieveAPIView): class DispatcherActionDetail(generics.RetrieveAPIView):
"""Detail the given dispatcher.""" """Detail the given dispatcher."""
permission_classes = (IsAuthenticated,)
queryset = DispatcherAction.objects.all() queryset = DispatcherAction.objects.all()
serializer_class = DispatcherActionSerializer serializer_class = DispatcherActionSerializer

View File

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

View File

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

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

View File

@ -2,20 +2,21 @@
Django settings for dr_botzo project. Django settings for dr_botzo project.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/1.6/topics/settings/ https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.6/ref/settings/ https://docs.djangoproject.com/en/5.0/ref/settings/
""" """
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
import os import os
from pathlib import Path
BASE_DIR = os.path.dirname(os.path.dirname(__file__)) # Build paths inside the project like this: BASE_DIR / 'subdir'
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.6/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret! # SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '8@frp#a5wb)40g=#rbbxy($_!ttqw(*t_^os37_a*9kbx1xuvp' SECRET_KEY = '8@frp#a5wb)40g=#rbbxy($_!ttqw(*t_^os37_a*9kbx1xuvp'
@ -28,8 +29,8 @@ ALLOWED_HOSTS = []
# Application definition # Application definition
INSTALLED_APPS = ( INSTALLED_APPS = [
'django.contrib.admin.apps.SimpleAdminConfig', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
@ -37,13 +38,11 @@ INSTALLED_APPS = (
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.sites', 'django.contrib.sites',
'django_extensions', 'django_extensions',
'adminplus',
'bootstrap3', 'bootstrap3',
'rest_framework', 'rest_framework',
'countdown', 'countdown',
'dispatch', 'dispatch',
'facts', 'facts',
'gitlab_bot',
'ircbot', 'ircbot',
'karma', 'karma',
'markov', 'markov',
@ -51,17 +50,17 @@ INSTALLED_APPS = (
'races', 'races',
'seen', 'seen',
'storycraft', 'storycraft',
) ]
MIDDLEWARE = ( MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'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.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
'django.middleware.security.SecurityMiddleware', ]
)
ROOT_URLCONF = 'dr_botzo.urls' ROOT_URLCONF = 'dr_botzo.urls'
@ -86,21 +85,37 @@ WSGI_APPLICATION = 'dr_botzo.wsgi.application'
# Database # Database
# https://docs.djangoproject.com/en/1.6/ref/settings/#databases # https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 'NAME': BASE_DIR / 'db.sqlite3',
} }
} }
# inherited default, look at changing to BigAutoField
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' # Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/1.6/topics/i18n/ # https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
@ -108,8 +123,6 @@ TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_L10N = True
USE_TZ = True USE_TZ = True
@ -118,15 +131,21 @@ SITE_ID = 1
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.6/howto/static-files/ # https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = '/static/' STATIC_URL = 'static/'
STATICFILES_DIRS = ( STATICFILES_DIRS = (
os.path.join(BASE_DIR, 'static'), os.path.join(BASE_DIR, 'static'),
) )
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
REST_FRAMEWORK = { REST_FRAMEWORK = {
# Use Django's standard `django.contrib.auth` permissions, # Use Django's standard `django.contrib.auth` permissions,
# or allow read-only access for unauthenticated users. # or allow read-only access for unauthenticated users.
@ -146,6 +165,16 @@ BOOTSTRAP3 = {
'javascript_in_head': True, 'javascript_in_head': True,
} }
###############
# web options #
###############
# choose which apps to display in the web UI, for those that support this config
WEB_ENABLED_APPS = [
'karma',
'races',
]
# IRC module stuff # IRC module stuff
@ -173,10 +202,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
# weather
WEATHER_WEATHER_UNDERGROUND_API_KEY = None
# load local settings # load local settings

View File

@ -30,9 +30,9 @@
{% block navbar_menu %} {% block navbar_menu %}
<ul class="nav navbar-nav"> <ul class="nav navbar-nav">
<li><a href="{% url 'facts_index' %}">Item Sets</a></li> <li><a href="{% url 'facts_index' %}">Item Sets</a></li>
<li><a href="{% url 'karma_index' %}">Karma</a></li> {% if "karma" in WEB_ENABLED_APPS %}<li><a href="{% url 'karma_index' %}">Karma</a></li>{% endif %}
<li><a href="{% url 'markov_index' %}">Markov</a></li> <li><a href="{% url 'markov_index' %}">Markov</a></li>
<li><a href="{% url 'races_index' %}">Races</a></li> {% if "races" in WEB_ENABLED_APPS %}<li><a href="{% url 'races_index' %}">Races</a></li>{% endif %}
</ul> </ul>
{% endblock %} {% endblock %}
<div class="navbar-right"> <div class="navbar-right">

View File

@ -1,14 +1,9 @@
"""General/baselite/site-wide URLs.""" """General/baselite/site-wide URLs."""
from adminplus.sites import AdminSitePlus
from django.conf.urls import include from django.conf.urls import include
from django.contrib import admin from django.contrib import admin
from django.urls import path from django.urls import path
from django.views.generic import TemplateView from django.views.generic import TemplateView
admin.site = AdminSitePlus()
admin.sites.site = admin.site
admin.autodiscover()
urlpatterns = [ urlpatterns = [
path('', TemplateView.as_view(template_name='index.html'), name='index'), path('', TemplateView.as_view(template_name='index.html'), name='index'),

View File

@ -2,7 +2,6 @@
from django.db import models, migrations from django.db import models, migrations
import datetime import datetime
from django.utils.timezone import utc
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='fact', model_name='fact',
name='time', name='time',
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 15, 22, 20, 481856, tzinfo=utc), auto_now_add=True), field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 15, 22, 20, 481856, tzinfo=datetime.timezone.utc), auto_now_add=True),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('facts', '0006_factcategory_show_all_entries'),
]
operations = [
migrations.AlterField(
model_name='fact',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='factcategory',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,40 +1,15 @@
"""Manage ircbot models and admin actions in the admin interface.""" """Manage ircbot models and admin actions in the admin interface."""
import logging import logging
import xmlrpc.client
from django.contrib import admin from django.contrib import admin
from django.shortcuts import render
from ircbot.forms import PrivmsgForm
from ircbot.models import Alias, BotUser, IrcChannel, IrcPlugin, IrcServer from ircbot.models import Alias, BotUser, IrcChannel, IrcPlugin, IrcServer
log = logging.getLogger('ircbot.admin') log = logging.getLogger('ircbot.admin')
def send_privmsg(request):
"""Send a privmsg over XML-RPC to the IRC bot."""
if request.method == 'POST':
form = PrivmsgForm(request.POST)
if form.is_valid():
target = form.cleaned_data['target']
message = form.cleaned_data['message']
bot_url = 'http://{0:s}:{1:d}/'.format(form.cleaned_data['xmlrpc_host'],
form.cleaned_data['xmlrpc_port'])
bot = xmlrpc.client.ServerProxy(bot_url, allow_none=True)
bot.reply(None, message, False, target)
form = PrivmsgForm()
else:
form = PrivmsgForm()
return render(request, 'privmsg.html', {'form': form})
admin.site.register(Alias) admin.site.register(Alias)
admin.site.register(BotUser) admin.site.register(BotUser)
admin.site.register(IrcChannel) admin.site.register(IrcChannel)
admin.site.register(IrcPlugin) admin.site.register(IrcPlugin)
admin.site.register(IrcServer) admin.site.register(IrcServer)
admin.site.register_view('ircbot/privmsg/', "Ircbot - privmsg", view=send_privmsg, urlname='ircbot_privmsg')

View File

@ -1,46 +1,36 @@
"""Provide the base IRC client bot which other code can latch onto.""" """Provide the base IRC client bot which other code can latch onto."""
import bisect import bisect
import collections import collections
import copy import copy
import importlib import importlib
import logging import logging
import re import re
from xmlrpc.server import SimpleXMLRPCServer, SimpleXMLRPCRequestHandler
import socket import socket
import ssl import ssl
import sys import sys
import threading import threading
import time import time
from xmlrpc.server import SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
from django.conf import settings
import irc.buffer
import irc.client import irc.client
import irc.modes
from irc.bot import Channel
from irc.connection import Factory from irc.connection import Factory
from irc.dict import IRCDict from irc.dict import IRCDict
import irc.modes from jaraco.stream import buffer
import ircbot.lib as ircbotlib import ircbot.lib as ircbotlib
from dr_botzo._version import __version__
from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer from ircbot.models import Alias, IrcChannel, IrcPlugin, IrcServer
log = logging.getLogger('ircbot.bot') log = logging.getLogger('ircbot.bot')
class IrcBotXMLRPCRequestHandler(SimpleXMLRPCRequestHandler):
"""Override the basic request handler to change the logging."""
def log_message(self, format, *args):
"""Use a logger rather than stderr."""
log.debug("XML-RPC - %s - %s", self.client_address[0], format % args)
class PrioritizedRegexHandler(collections.namedtuple('Base', ('priority', 'regex', 'callback'))): class PrioritizedRegexHandler(collections.namedtuple('Base', ('priority', 'regex', 'callback'))):
"""Regex handler that still uses the normal handler priority stuff.""" """Regex handler that still uses the normal handler priority stuff."""
def __lt__(self, other): def __lt__(self, other):
"""When sorting prioritized handlers, only use the priority""" """When sorting prioritized handlers, only use the priority."""
return self.priority < other.priority return self.priority < other.priority
@ -52,7 +42,7 @@ class LenientServerConnection(irc.client.ServerConnection):
method on a Reactor object. method on a Reactor object.
""" """
buffer_class = irc.buffer.LenientDecodingLineBuffer buffer_class = buffer.LenientDecodingLineBuffer
server_config = None server_config = None
@ -65,6 +55,9 @@ class LenientServerConnection(irc.client.ServerConnection):
class DrReactor(irc.client.Reactor): class DrReactor(irc.client.Reactor):
"""Customize the basic IRC library's Reactor with more features.""" """Customize the basic IRC library's Reactor with more features."""
# used by Reactor.server() to initialize
connection_class = LenientServerConnection
def __do_nothing(*args, **kwargs): def __do_nothing(*args, **kwargs):
pass pass
@ -73,18 +66,10 @@ class DrReactor(irc.client.Reactor):
super(DrReactor, self).__init__(on_connect=on_connect, on_disconnect=on_disconnect) super(DrReactor, self).__init__(on_connect=on_connect, on_disconnect=on_disconnect)
self.regex_handlers = {} self.regex_handlers = {}
def server(self):
"""Creates and returns a ServerConnection object."""
c = LenientServerConnection(self)
with self.mutex:
self.connections.append(c)
return c
def add_global_regex_handler(self, events, regex, handler, priority=0): def add_global_regex_handler(self, events, regex, handler, priority=0):
"""Adds a global handler function for a specific event type and regex. """Add a global handler function for a specific event type and regex.
Arguments: Arguments:
events --- Event type(s) (a list of strings). events --- Event type(s) (a list of strings).
handler -- Callback function taking connection and event handler -- Callback function taking connection and event
@ -114,10 +99,9 @@ class DrReactor(irc.client.Reactor):
bisect.insort(event_regex_handlers, handler) bisect.insort(event_regex_handlers, handler)
def remove_global_regex_handler(self, events, handler): def remove_global_regex_handler(self, events, handler):
"""Removes a global regex handler function. """Remove a global regex handler function.
Arguments: Arguments:
events -- Event type(s) (a list of strings). events -- Event type(s) (a list of strings).
handler -- Callback function. handler -- Callback function.
@ -177,7 +161,14 @@ class DrReactor(irc.client.Reactor):
channel = IrcChannel.objects.get(name=sent_location) channel = IrcChannel.objects.get(name=sent_location)
if sender_nick == channel.discord_bridge: if sender_nick == channel.discord_bridge:
short_what = ' '.join(what.split(' ')[1:]) short_what = ' '.join(what.split(' ')[1:])
real_source = re.sub(r'^<(\S+)> .*', r'\1!user@discord-bridge', what)
match = re.match(addressed_pattern, short_what, re.IGNORECASE) match = re.match(addressed_pattern, short_what, re.IGNORECASE)
event.arguments[0] = short_what
event.source = real_source
sender_nick = irc.client.NickMask(event.source).nick
event.sender_nick = sender_nick
event.in_privmsg = sender_nick == sent_location
else: else:
match = re.match(addressed_pattern, what, re.IGNORECASE) match = re.match(addressed_pattern, what, re.IGNORECASE)
except IrcChannel.DoesNotExist: except IrcChannel.DoesNotExist:
@ -190,10 +181,10 @@ class DrReactor(irc.client.Reactor):
log.debug("all_nicks: %s, addressed: %s", all_nicks, event.addressed) 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", event.arguments[0])
for alias in Alias.objects.all(): for alias in Alias.objects.all():
repl = alias.replace(what) repl = alias.replace(event.arguments[0])
if repl: if repl:
# we found an alias for our given string, doing a replace # we found an alias for our given string, doing a replace
event.arguments[0] = repl event.arguments[0] = repl
@ -228,8 +219,7 @@ class DrReactor(irc.client.Reactor):
if result == "NO MORE": if result == "NO MORE":
return return
except Exception as ex: except Exception as ex:
log.error("caught exception!") log.exception("caught exception!")
log.exception(ex)
connection.privmsg(event.target, str(ex)) connection.privmsg(event.target, str(ex))
def try_recursion(self, connection, event): def try_recursion(self, connection, event):
@ -416,7 +406,7 @@ class IRCBot(irc.client.SimpleIRCClient):
# load XML-RPC server # load XML-RPC server
self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port), self.xmlrpc = SimpleXMLRPCServer((self.server_config.xmlrpc_host, self.server_config.xmlrpc_port),
requestHandler=IrcBotXMLRPCRequestHandler, allow_none=True) requestHandler=SimpleXMLRPCRequestHandler, allow_none=True)
self.xmlrpc.register_introspection_functions() self.xmlrpc.register_introspection_functions()
t = threading.Thread(target=self._xmlrpc_listen, args=()) t = threading.Thread(target=self._xmlrpc_listen, args=())
@ -429,8 +419,7 @@ class IRCBot(irc.client.SimpleIRCClient):
def _connected_checker(self): def _connected_checker(self):
if not self.connection.is_connected(): if not self.connection.is_connected():
self.connection.execute_delayed(self.reconnection_interval, self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
self._connected_checker)
self.jump_server() self.jump_server()
def _connect(self): def _connect(self):
@ -448,8 +437,7 @@ class IRCBot(irc.client.SimpleIRCClient):
def _on_disconnect(self, c, e): def _on_disconnect(self, c, e):
self.channels = IRCDict() self.channels = IRCDict()
self.connection.execute_delayed(self.reconnection_interval, self.reactor.scheduler.execute_after(self.reconnection_interval, self._connected_checker)
self._connected_checker)
def _on_join(self, c, e): def _on_join(self, c, e):
ch = e.target ch = e.target
@ -549,16 +537,20 @@ class IRCBot(irc.client.SimpleIRCClient):
# run automsg commands # run automsg commands
if self.server_config.post_connect: if self.server_config.post_connect:
for cmd in self.server_config.post_connect.split('\n'): for cmd in self.server_config.post_connect.split('\n'):
cmd_split = cmd.split(' ')
# TODO NOTE: if the bot is sending something that changes the vhost # TODO NOTE: if the bot is sending something that changes the vhost
# (like 'hostserv on') we don't pick it up # (like 'hostserv on') we don't pick it up
self.connection.privmsg(cmd.split(' ')[0], ' '.join(cmd.split(' ')[1:])) if cmd_split[0] == '/msg':
self.connection.privmsg(cmd.split(' ')[1], ' '.join(cmd.split(' ')[2:]))
elif cmd_split[0] == '/mode':
self.connection.mode(*cmd_split[1:])
# sleep before doing autojoins # sleep before doing autojoins
time.sleep(self.server_config.delay_before_joins) time.sleep(self.server_config.delay_before_joins)
for chan in IrcChannel.objects.filter(autojoin=True, server=connection.server_config): 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.name)
for plugin in IrcPlugin.objects.filter(autoload=True): for plugin in IrcPlugin.objects.filter(autoload=True):
log.info("autoloading %s", plugin.path) log.info("autoloading %s", plugin.path)
@ -592,12 +584,11 @@ class IRCBot(irc.client.SimpleIRCClient):
self.connection.disconnect(msg) self.connection.disconnect(msg)
def get_version(self): def get_version(self):
"""Returns the bot version. """Return the bot version.
Used when answering a CTCP VERSION request. Used when answering a CTCP VERSION request.
""" """
return "Python irc.bot ({version})".format( return f"dr.botzo {__version__}"
version=irc.client.VERSION_STRING)
def jump_server(self, msg="Changing servers"): def jump_server(self, msg="Changing servers"):
"""Connect to a new server, potentially disconnecting from the current one.""" """Connect to a new server, potentially disconnecting from the current one."""
@ -607,7 +598,7 @@ class IRCBot(irc.client.SimpleIRCClient):
self._connect() self._connect()
def on_ctcp(self, c, e): def on_ctcp(self, c, e):
"""Default handler for ctcp events. """Handle for ctcp events.
Replies to VERSION and PING requests and relays DCC requests Replies to VERSION and PING requests and relays DCC requests
to the on_dccchat method. to the on_dccchat method.
@ -997,165 +988,3 @@ class IRCBot(irc.client.SimpleIRCClient):
del sys.modules[path] del sys.modules[path]
self.die(msg="Shutting down...") self.die(msg="Shutting down...")
class Channel(object):
"""A class for keeping information about an IRC channel."""
def __init__(self):
"""Initialize channel object."""
self.userdict = IRCDict()
self.operdict = IRCDict()
self.voiceddict = IRCDict()
self.ownerdict = IRCDict()
self.halfopdict = IRCDict()
self.modes = {}
def users(self):
"""Returns an unsorted list of the channel's users."""
return list(self.userdict.keys())
def opers(self):
"""Returns an unsorted list of the channel's operators."""
return list(self.operdict.keys())
def voiced(self):
"""Returns an unsorted list of the persons that have voice mode set in the channel."""
return list(self.voiceddict.keys())
def owners(self):
"""Returns an unsorted list of the channel's owners."""
return list(self.ownerdict.keys())
def halfops(self):
"""Returns an unsorted list of the channel's half-operators."""
return list(self.halfopdict.keys())
def has_user(self, nick):
"""Check whether the channel has a user."""
return nick in self.userdict
def is_oper(self, nick):
"""Check whether a user has operator status in the channel."""
return nick in self.operdict
def is_voiced(self, nick):
"""Check whether a user has voice mode set in the channel."""
return nick in self.voiceddict
def is_owner(self, nick):
"""Check whether a user has owner status in the channel."""
return nick in self.ownerdict
def is_halfop(self, nick):
"""Check whether a user has half-operator status in the channel."""
return nick in self.halfopdict
def add_user(self, nick):
"""Add user."""
self.userdict[nick] = 1
def remove_user(self, nick):
"""Remove user."""
for d in self.userdict, self.operdict, self.voiceddict:
if nick in d:
del d[nick]
def change_nick(self, before, after):
"""Handle a nick change."""
self.userdict[after] = self.userdict.pop(before)
if before in self.operdict:
self.operdict[after] = self.operdict.pop(before)
if before in self.voiceddict:
self.voiceddict[after] = self.voiceddict.pop(before)
def set_userdetails(self, nick, details):
"""Set user details."""
if nick in self.userdict:
self.userdict[nick] = details
def set_mode(self, mode, value=None):
"""Set mode on the channel.
Arguments:
mode -- The mode (a single-character string).
value -- Value
"""
if mode == "o":
self.operdict[value] = 1
elif mode == "v":
self.voiceddict[value] = 1
elif mode == "q":
self.ownerdict[value] = 1
elif mode == "h":
self.halfopdict[value] = 1
else:
self.modes[mode] = value
def clear_mode(self, mode, value=None):
"""Clear mode on the channel.
Arguments:
mode -- The mode (a single-character string).
value -- Value
"""
try:
if mode == "o":
del self.operdict[value]
elif mode == "v":
del self.voiceddict[value]
elif mode == "q":
del self.ownerdict[value]
elif mode == "h":
del self.halfopdict[value]
else:
del self.modes[mode]
except KeyError:
pass
def has_mode(self, mode):
"""Return if mode is in channel modes."""
return mode in self.modes
def is_moderated(self):
"""Return if the channel is +m."""
return self.has_mode("m")
def is_secret(self):
"""Return if the channel is +s."""
return self.has_mode("s")
def is_protected(self):
"""Return if the channel is +p."""
return self.has_mode("p")
def has_topic_lock(self):
"""Return if the channel is +t."""
return self.has_mode("t")
def is_invite_only(self):
"""Return if the channel is +i."""
return self.has_mode("i")
def has_allow_external_messages(self):
"""Return if the channel is +n."""
return self.has_mode("n")
def has_limit(self):
"""Return if the channel is +l."""
return self.has_mode("l")
def limit(self):
"""Return the channel limit count."""
if self.has_limit():
return self.modes["l"]
else:
return None
def has_key(self):
"""Return if the channel is +k."""
return self.has_mode("k")

View File

@ -0,0 +1,38 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ircbot', '0019_ircchannel_discord_bridge'),
]
operations = [
migrations.AlterField(
model_name='alias',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='botuser',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircchannel',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircplugin',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='ircserver',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -118,7 +118,7 @@ class IrcChannel(models.Model):
def __str__(self): def __str__(self):
"""Provide string representation.""" """Provide string representation."""
return "{0:s}".format(self.name) return "{0:s} on {1:s}".format(self.name, self.server.name)
class IrcPlugin(models.Model): class IrcPlugin(models.Model):

View File

@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Ircbot - Privmsg{% endblock %}
{% block content %}
<div id="content-main">
<form id="ircbot_privmsg_form" action="{% url 'admin:ircbot_privmsg' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Privmsg"/>
</form>
</div>
{% endblock %}

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('karma', '0002_auto_20150519_2156'),
]
operations = [
migrations.AlterField(
model_name='karmakey',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='karmalogentry',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -1,14 +1,13 @@
"""Karma logging models.""" """Karma logging models."""
from datetime import timedelta
import logging import logging
from datetime import timedelta
import pytz import pytz
from irc.client import NickMask
from django.conf import settings 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
from irc.client import NickMask
log = logging.getLogger('karma.models') log = logging.getLogger('karma.models')
@ -44,12 +43,13 @@ class KarmaKey(models.Model):
objects = KarmaKeyManager() objects = KarmaKeyManager()
def __str__(self): def __str__(self):
"""String representation.""" """Display the karma key and score."""
return "{0:s} ({1:d})".format(self.key, self.score()) return "{0:s} ({1:d})".format(self.key, self.score())
def score(self): def score(self):
"""Determine the score for this karma entry.""" """Determine the score for this karma entry."""
return KarmaLogEntry.objects.filter(key=self).aggregate(models.Sum('delta'))['delta__sum'] score = KarmaLogEntry.objects.filter(key=self).aggregate(models.Sum('delta'))['delta__sum']
return score if score else 0
def rank(self): def rank(self):
"""Determine the rank of this karma entry relative to the others.""" """Determine the rank of this karma entry relative to the others."""

View File

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

View File

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

View File

@ -1,15 +1,9 @@
"""Manage Markov models and administrative commands.""" """Manage Markov models and administrative commands."""
import logging import logging
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.decorators import permission_required
from django.shortcuts import render
from markov.forms import LogUploadForm, TeachLineForm
import markov.lib as markovlib
from markov.models import MarkovContext, MarkovTarget, MarkovState
from markov.models import MarkovContext, MarkovState, MarkovTarget
log = logging.getLogger('markov.admin') log = logging.getLogger('markov.admin')
@ -17,96 +11,3 @@ log = logging.getLogger('markov.admin')
admin.site.register(MarkovContext) admin.site.register(MarkovContext)
admin.site.register(MarkovTarget) admin.site.register(MarkovTarget)
admin.site.register(MarkovState) admin.site.register(MarkovState)
@permission_required('import_text_file', raise_exception=True)
def import_file(request):
"""Accept a file upload and turn it into markov stuff.
Current file formats supported:
* weechat
"""
if request.method == 'POST':
form = LogUploadForm(request.POST, request.FILES)
if form.is_valid():
if form.cleaned_data['text_file_format'] == LogUploadForm.FILE_FORMAT_WEECHAT:
text_file = request.FILES['text_file']
context = form.cleaned_data['context']
ignores = form.cleaned_data['ignore_nicks'].split(',')
strips = form.cleaned_data['strip_prefixes'].split(' ')
whos = []
for line in text_file:
log.debug(line)
(timestamp, who, what) = line.decode('utf-8').split('\t', 2)
if who in ('-->', '<--', '--', ' *'):
continue
if who in ignores:
continue
whos.append(who)
# this is a line we probably care about now
what = [x for x in what.rstrip().split(' ') if x not in strips]
markovlib.learn_line(' '.join(what), context)
log.debug("learned")
log.debug(set(whos))
form = LogUploadForm()
elif form.cleaned_data['text_file_format'] == LogUploadForm.FILE_FORMAT_RAW_TEXT:
text_file = request.FILES['text_file']
context = form.cleaned_data['context']
k1 = MarkovState._start1
k2 = MarkovState._start2
for line in text_file:
for word in [x for x in line.decode('utf-8') .rstrip().split(' ')]:
log.info(word)
if word:
state, created = MarkovState.objects.get_or_create(context=context, k1=k1,
k2=k2, v=word)
state.count += 1
state.save()
if word[-1] in ['.', '?', '!']:
state, created = MarkovState.objects.get_or_create(context=context, k1=k2,
k2=word, v=MarkovState._stop)
state.count += 1
state.save()
k1 = MarkovState._start1
k2 = MarkovState._start2
else:
k1 = k2
k2 = word
else:
form = LogUploadForm()
return render(request, 'markov/import_file.html', {'form': form})
@permission_required('teach_line', raise_exception=True)
def teach_line(request):
"""Teach one line directly."""
if request.method == 'POST':
form = TeachLineForm(request.POST)
if form.is_valid():
line = form.cleaned_data['line']
context = form.cleaned_data['context']
strips = form.cleaned_data['strip_prefixes'].split(' ')
what = [x for x in line.rstrip().split(' ') if x not in strips]
markovlib.learn_line(' '.join(what), context)
form = TeachLineForm()
else:
form = TeachLineForm()
return render(request, 'markov/teach_line.html', {'form': form})
admin.site.register_view('markov/importfile/', "Markov - Import log file", view=import_file,
urlname='markov_import_file')
admin.site.register_view('markov/teach/', "Markov - Teach line", view=teach_line, urlname='markov_teach_line')

View File

@ -119,12 +119,12 @@ class Markov(Plugin):
target_name = target_name.lower() target_name = target_name.lower()
# find the stuff, or create it # find the stuff, or create it
channel, c = IrcChannel.objects.get_or_create(name=target_name, server=self.connection.server_config)
try: try:
target = MarkovTarget.objects.get(name=target_name) target = MarkovTarget.objects.get(channel=channel)
except MarkovTarget.DoesNotExist: except MarkovTarget.DoesNotExist:
# we need to create a context and a target, and we have to make the context first # 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 # 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) context, c = MarkovContext.objects.get_or_create(name=target_name)
target, c = MarkovTarget.objects.get_or_create(name=target_name, context=context, channel=channel) target, c = MarkovTarget.objects.get_or_create(name=target_name, context=context, channel=channel)

View File

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

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('markov', '0008_alter_markovtarget_name'),
]
operations = [
migrations.AlterField(
model_name='markovcontext',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='markovstate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='markovtarget',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.9 on 2024-11-14 15:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('markov', '0009_alter_markovcontext_id_alter_markovstate_id_and_more'),
]
operations = [
migrations.AddField(
model_name='markovcontext',
name='web_enabled',
field=models.BooleanField(default=True),
),
]

View File

@ -12,6 +12,7 @@ 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)
web_enabled = models.BooleanField(default=True)
def __str__(self): def __str__(self):
"""Provide string representation.""" """Provide string representation."""
@ -21,7 +22,7 @@ class MarkovContext(models.Model):
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)
context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE) context = models.ForeignKey(MarkovContext, on_delete=models.CASCADE)
channel = models.ForeignKey(IrcChannel, on_delete=models.CASCADE) channel = models.ForeignKey(IrcChannel, on_delete=models.CASCADE)
@ -29,7 +30,7 @@ class MarkovTarget(models.Model):
def __str__(self): def __str__(self):
"""Provide string representation.""" """Provide string representation."""
return "{0:s} -> {1:s}".format(self.name, self.context.name) return "{0:s} -> {1:s}".format(str(self.channel), self.context.name)
class MarkovState(models.Model): class MarkovState(models.Model):

View File

@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Markov - Import log file{% endblock %}
{% block content %}
<div id="content-main">
<form id="markov_import_file_form" enctype="multipart/form-data" action="{% url 'admin:markov_import_file' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Import"/>
</form>
</div>
{% endblock %}

View File

@ -1,15 +0,0 @@
{% extends 'adminplus/index.html' %}
{% block title %}Markov - Teach line{% endblock %}
{% block content %}
<div id="content-main">
<form id="markov_teach_line_form" action="{% url 'admin:markov_teach_line' %}" method="post">
{% csrf_token %}
<table>
{{ form }}
</table>
<input class="submit-button" type="submit" value="Teach"/>
</form>
</div>
{% endblock %}

View File

@ -3,6 +3,7 @@ import json
import logging import logging
import time import time
from django.core.exceptions import PermissionDenied
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.authentication import BasicAuthentication
@ -27,6 +28,8 @@ def context_index(request, context_id):
start_t = time.time() start_t = time.time()
context = get_object_or_404(MarkovContext, pk=context_id) context = get_object_or_404(MarkovContext, pk=context_id)
if not context.web_enabled:
raise PermissionDenied()
chain = " ".join(markovlib.generate_line(context)) chain = " ".join(markovlib.generate_line(context))
end_t = time.time() end_t = time.time()

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pi', '0004_simulation_x_y_logging'),
]
operations = [
migrations.AlterField(
model_name='pilog',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

80
pyproject.toml Normal file
View File

@ -0,0 +1,80 @@
[build-system]
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
build-backend = "setuptools.build_meta"
[project]
name = "dr.botzo"
description = "A modularized IRC bot with a Django backend."
readme = "README.md"
license = {text = "GPL-3.0"}
authors = [
{name = "Brian S. Stephan", email = "bss@incorporeal.org"},
]
requires-python = ">=3.10,<3.12"
dependencies = ["Django<5.1", "django-bootstrap3", "django-extensions", "djangorestframework", "irc<20.5.0", "numexpr",
"parsedatetime", "ply", "python-dateutil", "python-mpd2", "pytz", "requests", "zalgo-text"]
dynamic = ["version"]
classifiers = [
"Framework :: Django",
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
"Operating System :: OS Independent",
"Topic :: Communications :: Chat :: Internet Relay Chat",
]
[project.urls]
"Homepage" = "https://git.incorporeal.org/bss/dr.botzo"
"Changelog" = "https://git.incorporeal.org/bss/dr.botzo/releases"
"Bug Tracker" = "https://git.incorporeal.org/bss/dr.botzo/issues"
[project.optional-dependencies]
dev = ["bandit", "dlint", "flake8", "flake8-blind-except", "flake8-builtins", "flake8-docstrings",
"flake8-executable", "flake8-fixme", "flake8-isort", "flake8-logging-format", "flake8-mutable",
"flake8-pyproject", "pip-tools", "pytest", "pytest-cov", "pytest-django", "reuse", "safety", "tox"]
[tool.flake8]
enable-extensions = "G,M"
exclude = [".tox/", "dr_botzo/_version.py", "**/migrations/"]
extend-ignore = "T101"
max-complexity = 10
max-line-length = 120
[tool.isort]
line_length = 120
# TODO: mypy
[tool.mypy]
ignore_missing_imports = true
[tool.pytest.ini_options]
python_files = ["*_tests.py", "tests.py", "test_*.py"]
DJANGO_SETTINGS_MODULE = "dr_botzo.settings"
django_find_project = false
# I think this can go away if I switch to a src/ based repo
[tool.setuptools]
packages = [
"acro",
"countdown",
"dice",
"dispatch",
"dr_botzo",
"facts",
"history",
"ircbot",
"karma",
"markov",
"mpdbot",
"pi",
"races",
"seen",
"static",
"storycraft",
"text_manip",
"transform",
"weather",
]
[tool.setuptools_scm]
write_to = "dr_botzo/_version.py"

View File

@ -0,0 +1,23 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('races', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='racer',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='raceupdate',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

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

View File

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

@ -1,248 +1,290 @@
# #
# This file is autogenerated by pip-compile with Python 3.8 # This file is autogenerated by pip-compile with Python 3.11
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements/requirements-dev.txt requirements/requirements-dev.in # pip-compile --extra=dev --output-file=requirements/requirements-dev.txt
# #
asgiref==3.6.0 annotated-types==0.7.0
# via pydantic
asgiref==3.8.1
# via django # via django
attrs==22.2.0 attrs==24.2.0
# via pytest # via reuse
authlib==1.3.2
# via safety
autocommand==2.2.2 autocommand==2.2.2
# via jaraco-text # via jaraco-text
bandit==1.7.4 backports-tarfile==1.2.0
# via -r requirements/requirements-dev.in # via jaraco-context
build==0.10.0 bandit==1.7.10
# via dr.botzo (pyproject.toml)
binaryornot==0.4.4
# via reuse
boolean-py==4.0
# via
# license-expression
# reuse
build==1.2.2.post1
# via pip-tools # via pip-tools
certifi==2022.12.7 cachetools==5.5.0
# via tox
certifi==2024.8.30
# via requests # via requests
charset-normalizer==3.0.1 cffi==1.17.1
# via cryptography
chardet==5.2.0
# via
# binaryornot
# python-debian
# tox
charset-normalizer==3.4.0
# via requests # via requests
click==8.1.3 click==8.1.7
# via # via
# pip-tools # pip-tools
# safety # safety
coverage[toml]==7.1.0 # typer
colorama==0.4.6
# via tox
coverage[toml]==7.6.4
# via pytest-cov # via pytest-cov
distlib==0.3.6 cryptography==43.0.3
# via authlib
distlib==0.3.9
# via virtualenv # via virtualenv
django==3.2.18 django==5.0.9
# via # via
# -r requirements/requirements.in
# django-bootstrap3 # django-bootstrap3
# django-extensions # django-extensions
# djangorestframework # djangorestframework
django-adminplus==0.5 # dr.botzo (pyproject.toml)
# via -r requirements/requirements.in django-bootstrap3==24.3
django-bootstrap3==22.2 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in django-extensions==3.2.3
django-extensions==3.2.1 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in djangorestframework==3.15.2
djangorestframework==3.14.0 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in dlint==0.15.0
dlint==0.14.0 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements-dev.in dparse==0.6.4b0
dparse==0.6.2
# via safety
exceptiongroup==1.1.0
# via pytest
filelock==3.9.0
# via # via
# safety
# safety-schemas
filelock==3.12.4
# via
# safety
# tox # tox
# virtualenv # virtualenv
flake8==6.0.0 flake8==7.1.1
# via # via
# -r requirements/requirements-dev.in
# dlint # dlint
# dr.botzo (pyproject.toml)
# flake8-builtins # flake8-builtins
# flake8-docstrings # flake8-docstrings
# flake8-executable # flake8-executable
# flake8-isort # flake8-isort
# flake8-mutable # flake8-mutable
# flake8-pyproject
flake8-blind-except==0.2.1 flake8-blind-except==0.2.1
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-builtins==2.1.0 flake8-builtins==2.5.0
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-docstrings==1.7.0 flake8-docstrings==1.7.0
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-executable==2.1.3 flake8-executable==2.1.3
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-fixme==1.1.1 flake8-fixme==1.1.1
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-isort==6.0.0 flake8-isort==6.1.1
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-logging-format==0.9.0 flake8-logging-format==2024.24.12
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
flake8-mutable==1.2.0 flake8-mutable==1.2.0
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
gitdb==4.0.10 flake8-pyproject==1.2.3
# via gitpython # via dr.botzo (pyproject.toml)
gitpython==3.1.30 idna==3.10
# via bandit
idna==3.4
# via requests # via requests
importlib-resources==5.10.2
# via jaraco-text
inflect==6.0.2
# via
# jaraco-itertools
# jaraco-text
iniconfig==2.0.0 iniconfig==2.0.0
# via pytest # via pytest
irc==15.0.6 irc==20.4.3
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
isort==5.12.0 isort==5.13.2
# via flake8-isort # via flake8-isort
jaraco-classes==3.2.3 jaraco-collections==5.1.0
# via jaraco-collections
jaraco-collections==3.8.0
# via irc # via irc
jaraco-context==4.3.0 jaraco-context==6.0.1
# via jaraco-text # via jaraco-text
jaraco-functools==3.5.2 jaraco-functools==4.1.0
# via # via
# irc # irc
# jaraco-text # jaraco-text
# tempora # tempora
jaraco-itertools==6.2.1 jaraco-logging==3.3.0
# via irc # via irc
jaraco-logging==3.1.2 jaraco-stream==3.0.4
# via irc # via irc
jaraco-stream==3.0.3 jaraco-text==4.0.0
# via irc
jaraco-text==3.11.1
# via # via
# irc # irc
# jaraco-collections # jaraco-collections
jinja2==3.1.4
# via
# reuse
# safety
license-expression==30.4.0
# via reuse
markdown-it-py==3.0.0
# via rich
markupsafe==3.0.2
# via jinja2
marshmallow==3.23.0
# via safety
mccabe==0.7.0 mccabe==0.7.0
# via flake8 # via flake8
more-itertools==9.0.0 mdurl==0.1.2
# via markdown-it-py
more-itertools==10.5.0
# via # via
# irc # irc
# jaraco-classes
# jaraco-functools # jaraco-functools
# jaraco-itertools # jaraco-stream
# jaraco-text # jaraco-text
packaging==21.3 numexpr==2.10.1
# via dr.botzo (pyproject.toml)
numpy==2.1.3
# via numexpr
packaging==24.1
# via # via
# build # build
# dparse # dparse
# marshmallow
# pyproject-api
# pytest # pytest
# safety # safety
# safety-schemas
# tox # tox
parsedatetime==2.6 parsedatetime==2.6
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
pbr==5.11.1 pbr==6.1.0
# via stevedore # via stevedore
pip-tools==6.12.2 pip-tools==7.4.1
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
platformdirs==3.0.0 platformdirs==4.3.6
# via virtualenv # via
pluggy==1.0.0 # tox
# virtualenv
pluggy==1.5.0
# via # via
# pytest # pytest
# tox # tox
ply==3.11 ply==3.11
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
py==1.11.0 psutil==6.0.0
# via tox # via safety
pycodestyle==2.10.0 pycodestyle==2.12.1
# via flake8 # via flake8
pydantic==1.10.5 pycparser==2.22
# via inflect # via cffi
pydantic==2.5.3
# via
# safety
# safety-schemas
pydantic-core==2.14.6
# via pydantic
pydocstyle==6.3.0 pydocstyle==6.3.0
# via flake8-docstrings # via flake8-docstrings
pyflakes==3.0.1 pyflakes==3.2.0
# via flake8 # via flake8
pyparsing==3.0.9 pygments==2.18.0
# via packaging # via rich
pyproject-hooks==1.0.0 pyproject-api==1.8.0
# via build # via tox
pytest==7.2.1 pyproject-hooks==1.2.0
# via # via
# -r requirements/requirements-dev.in # build
# pip-tools
pytest==8.3.3
# via
# dr.botzo (pyproject.toml)
# pytest-cov # pytest-cov
# pytest-django # pytest-django
pytest-cov==4.0.0 pytest-cov==6.0.0
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
pytest-django==4.5.2 pytest-django==4.9.0
# via -r requirements/requirements-dev.in # via dr.botzo (pyproject.toml)
python-dateutil==2.8.2 python-dateutil==2.9.0.post0
# 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 # via
# -r requirements/requirements.in # dr.botzo (pyproject.toml)
# django
# djangorestframework
# irc
# tempora # tempora
pyyaml==6.0 python-debian==0.1.49
# via bandit # via reuse
requests==2.28.2 python-mpd2==3.1.1
# via # via dr.botzo (pyproject.toml)
# python-gitlab pytz==2024.2
# 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 # via
# dr.botzo (pyproject.toml)
# irc # irc
# python-dateutil pyyaml==6.0.2
# tox # via bandit
smmap==5.0.0 requests==2.32.3
# via gitdb # via
# dr.botzo (pyproject.toml)
# safety
reuse==4.0.3
# via dr.botzo (pyproject.toml)
rich==13.9.3
# via
# bandit
# safety
# typer
ruamel-yaml==0.18.6
# via
# safety
# safety-schemas
ruamel-yaml-clib==0.2.12
# via ruamel-yaml
safety==3.2.10
# via dr.botzo (pyproject.toml)
safety-schemas==0.0.8
# via safety
shellingham==1.5.4
# via typer
six==1.16.0
# via python-dateutil
snowballstemmer==2.2.0 snowballstemmer==2.2.0
# via pydocstyle # via pydocstyle
sqlparse==0.4.3 sqlparse==0.5.1
# via django # via django
stevedore==5.0.0 stevedore==5.3.0
# via bandit # via bandit
tempora==5.2.1 tempora==5.7.0
# via # via
# irc # irc
# jaraco-logging # jaraco-logging
toml==0.10.2 tomlkit==0.13.2
# via dparse # via reuse
tomli==2.0.1 tox==4.11.4
# via dr.botzo (pyproject.toml)
typer==0.12.5
# via safety
typing-extensions==4.12.2
# via # via
# build # pydantic
# coverage # pydantic-core
# pyproject-hooks # safety
# pytest # safety-schemas
# tox # typer
tox==3.28.0 urllib3==2.2.3
# via # via
# -r requirements/requirements-dev.in # requests
# tox-wheel # safety
tox-wheel==1.0.0 virtualenv==20.27.1
# 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 # via tox
wheel==0.38.4 wheel==0.44.0
# via # via pip-tools
# pip-tools
# tox-wheel
zalgo-text==0.6 zalgo-text==0.6
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
zipp==3.13.0
# via importlib-resources
# The following packages are considered to be unsafe in a requirements file: # The following packages are considered to be unsafe in a requirements file:
# pip # pip

View File

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

View File

@ -1,109 +1,87 @@
# #
# This file is autogenerated by pip-compile with Python 3.8 # This file is autogenerated by pip-compile with Python 3.11
# by the following command: # by the following command:
# #
# pip-compile --output-file=requirements/requirements.txt requirements/requirements.in # pip-compile --output-file=requirements/requirements.txt
# #
asgiref==3.6.0 asgiref==3.8.1
# via django # via django
autocommand==2.2.2 autocommand==2.2.2
# via jaraco-text # via jaraco-text
certifi==2022.12.7 backports-tarfile==1.2.0
# via jaraco-context
certifi==2024.8.30
# via requests # via requests
charset-normalizer==3.0.1 charset-normalizer==3.4.0
# via requests # via requests
django==3.2.18 django==5.0.9
# via # via
# -r requirements/requirements.in
# django-bootstrap3 # django-bootstrap3
# django-extensions # django-extensions
# djangorestframework # djangorestframework
django-adminplus==0.5 # dr.botzo (pyproject.toml)
# via -r requirements/requirements.in django-bootstrap3==24.3
django-bootstrap3==22.2 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in django-extensions==3.2.3
django-extensions==3.2.1 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in djangorestframework==3.15.2
djangorestframework==3.14.0 # via dr.botzo (pyproject.toml)
# via -r requirements/requirements.in idna==3.10
idna==3.4
# via requests # via requests
importlib-resources==5.10.2 irc==20.4.3
# via jaraco-text # via dr.botzo (pyproject.toml)
inflect==6.0.2 jaraco-collections==5.1.0
# 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 # via irc
jaraco-context==4.3.0 jaraco-context==6.0.1
# via jaraco-text # via jaraco-text
jaraco-functools==3.5.2 jaraco-functools==4.1.0
# via # via
# irc # irc
# jaraco-text # jaraco-text
# tempora # tempora
jaraco-itertools==6.2.1 jaraco-logging==3.3.0
# via irc # via irc
jaraco-logging==3.1.2 jaraco-stream==3.0.4
# via irc # via irc
jaraco-stream==3.0.3 jaraco-text==4.0.0
# via irc
jaraco-text==3.11.1
# via # via
# irc # irc
# jaraco-collections # jaraco-collections
more-itertools==9.0.0 more-itertools==10.5.0
# via # via
# irc # irc
# jaraco-classes
# jaraco-functools # jaraco-functools
# jaraco-itertools # jaraco-stream
# jaraco-text # jaraco-text
numexpr==2.10.1
# via dr.botzo (pyproject.toml)
numpy==2.1.3
# via numexpr
parsedatetime==2.6 parsedatetime==2.6
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
ply==3.11 ply==3.11
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
pydantic==1.10.5 python-dateutil==2.9.0.post0
# 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 # via
# -r requirements/requirements.in # dr.botzo (pyproject.toml)
# django
# djangorestframework
# irc
# tempora # tempora
requests==2.28.2 python-mpd2==3.1.1
# via # via dr.botzo (pyproject.toml)
# python-gitlab pytz==2024.2
# requests-toolbelt
requests-toolbelt==0.10.1
# via python-gitlab
six==1.16.0
# via # via
# dr.botzo (pyproject.toml)
# irc # irc
# python-dateutil requests==2.32.3
sqlparse==0.4.3 # via dr.botzo (pyproject.toml)
six==1.16.0
# via python-dateutil
sqlparse==0.5.1
# via django # via django
tempora==5.2.1 tempora==5.7.0
# via # via
# irc # irc
# jaraco-logging # jaraco-logging
typing-extensions==4.5.0 urllib3==2.2.3
# via pydantic
urllib3==1.26.14
# via requests # via requests
zalgo-text==0.6 zalgo-text==0.6
# via -r requirements/requirements.in # via dr.botzo (pyproject.toml)
zipp==3.13.0
# via importlib-resources

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('seen', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='seennick',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

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

View File

@ -1,28 +0,0 @@
"""Setuptools configuration."""
import os
from setuptools import find_packages, setup
import versioneer
HERE = os.path.dirname(os.path.abspath(__file__))
def extract_requires():
with open(os.path.join(HERE, 'requirements/requirements.in'), 'r') as reqs:
return [line.split(' ')[0] for line in reqs if not line[0] == '-']
setup(
name="dr.botzo",
description="A Django-backed IRC bot that also provides a WS framework for other bots to call.",
url="https://git.incorporeal.org/bss/dr.botzo",
license='GPLv3',
author="Brian S. Stephan",
author_email="bss@incorporeal.org",
version=versioneer.get_version(),
cmdclass=versioneer.get_cmdclass(),
packages=find_packages(),
include_package_data=True,
zip_safe=False,
install_requires=extract_requires(),
)

View File

@ -2,7 +2,6 @@
from django.db import models, migrations from django.db import models, migrations
import datetime import datetime
from django.utils.timezone import utc
class Migration(migrations.Migration): class Migration(migrations.Migration):
@ -15,7 +14,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='storycraftgame', model_name='storycraftgame',
name='create_time', name='create_time',
field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 1, 51, 18, 778824, tzinfo=utc), auto_now_add=True), field=models.DateTimeField(default=datetime.datetime(2015, 6, 20, 1, 51, 18, 778824, tzinfo=datetime.timezone.utc), auto_now_add=True),
preserve_default=False, preserve_default=False,
), ),
] ]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.4 on 2024-05-03 17:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('storycraft', '0005_storycraftgame_create_time'),
]
operations = [
migrations.AlterField(
model_name='storycraftgame',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='storycraftline',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
migrations.AlterField(
model_name='storycraftplayer',
name='id',
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
),
]

View File

@ -0,0 +1,135 @@
"""Test IRC behavior of the dice plugin."""
import re
from unittest import mock
from django.test import TestCase
import dice.ircplugin
from ircbot.models import IrcServer
class MarkovTestCase(TestCase):
"""Test the markov plugin."""
fixtures = ['tests/fixtures/irc_server_fixture.json']
def setUp(self):
"""Create common objects."""
self.mock_bot = mock.MagicMock()
self.mock_connection = mock.MagicMock()
self.mock_connection.get_nickname.return_value = 'test_bot'
self.mock_connection.server_config = IrcServer.objects.get(pk=1)
self.plugin = dice.ircplugin.Dice(self.mock_bot, self.mock_connection, mock.MagicMock())
def test_cypher_roll_strings(self):
"""Simulate incoming Cypher System requests."""
mock_event = mock.MagicMock()
mock_event.source = 'test!test@test'
mock_event.target = '#test'
mock_event.recursing = False
# general task roll (no damage output on a 17)
mock_event.arguments = ['!cypher T3']
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your check 9succeeded! 14(d20=17 vs. diff. 3)'
)
# general attack roll (incl. damage output on a 17)
mock_event.arguments = ['!cypher A3']
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your attack 9succeeded, with +1 damage! 14(d20=17 vs. diff. 3)'
)
# general task roll, case insensitive
mock_event.arguments = ['!cypher t3']
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your check 9succeeded! 14(d20=17 vs. diff. 3)'
)
# unknown target roll
mock_event.arguments = ['!cypher +1']
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=17):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: your check beats a difficulty 4 task. 14(d20=17 with +1 levels)'
)
# no mod or known difficulty
mock_event.arguments = ['!cypher unmodded attempt']
match = re.search(dice.ircplugin.CYPHER_COMMAND_REGEX, mock_event.arguments[0])
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
with mock.patch('random.SystemRandom.randint', return_value=9):
self.plugin.handle_cypher_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: unmodded attempt beats a difficulty 3 task. 14(d20=9)'
)
def test_reaction_roll_strings(self):
"""Simulate incoming reaction requests."""
mock_event = mock.MagicMock()
mock_event.source = 'test!test@test'
mock_event.target = '#test'
mock_event.recursing = False
# good and very good are bold green
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=18):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 9positive 14(18)'
)
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=20):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 9VERY positive 14(20)'
)
# decent is green
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=10):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 3positive, with complications 14(10)'
)
# bad and very bad are bold red
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=4):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 4negative 14(4)'
)
mock_event.arguments = ['!reaction']
match = re.search(dice.ircplugin.REACTION_COMMAND_REGEX, mock_event.arguments[0])
with mock.patch('random.SystemRandom.randint', return_value=1):
self.plugin.handle_reaction_roll(self.mock_connection, mock_event, match)
self.mock_bot.reply.assert_called_with(
mock_event,
'test: the current disposition is 4VERY negative 14(1)'
)

106
tests/test_dice_lib.py Normal file
View File

@ -0,0 +1,106 @@
"""Tests for dice operations."""
from unittest import mock
from django.test import TestCase
import dice.lib
class DiceLibTestCase(TestCase):
"""Test that a variety of dice rolls work as expected."""
def test_cypher_rolls(self):
"""Roll a variety of Cypher System rolls."""
# simple task, simple check
with mock.patch('random.SystemRandom.randint', return_value=5):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (5, 1, True, None))
# simple failure
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (2, 0, False, None))
# rolled a 1
with mock.patch('random.SystemRandom.randint', return_value=1):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (1, None, False, 'a GM intrusion'))
# rolled a 17 on an attack
with mock.patch('random.SystemRandom.randint', return_value=17):
result = dice.lib.cypher_roll(difficulty=1, is_attack=True)
self.assertEqual(result, (17, 5, True, '+1 damage'))
# rolled a 18 on an attack
with mock.patch('random.SystemRandom.randint', return_value=18):
result = dice.lib.cypher_roll(difficulty=1, is_attack=True)
self.assertEqual(result, (18, 6, True, '+2 damage'))
# rolled a 17
with mock.patch('random.SystemRandom.randint', return_value=17):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (17, 5, True, None))
# rolled a 18
with mock.patch('random.SystemRandom.randint', return_value=18):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (18, 6, True, None))
# rolled a 19
with mock.patch('random.SystemRandom.randint', return_value=19):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (19, 6, True, 'a minor effect'))
# rolled a 20
with mock.patch('random.SystemRandom.randint', return_value=20):
result = dice.lib.cypher_roll(difficulty=1)
self.assertEqual(result, (20, 6, True, 'a MAJOR EFFECT'))
# mods affect the result of what the roll beats
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(difficulty=1, mods='-5')
self.assertEqual(result, (2, 5, True, None))
# complex mods
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(difficulty=3, mods='+1-4')
self.assertEqual(result, (2, 3, True, None))
# complex mods
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(difficulty=3, mods='-4+1')
self.assertEqual(result, (2, 3, True, None))
# ...even without a difficulty known
with mock.patch('random.SystemRandom.randint', return_value=2):
result = dice.lib.cypher_roll(mods='-5')
self.assertEqual(result, (2, 5, None, None))
# general "don't know the difficulty" kind of check
with mock.patch('random.SystemRandom.randint', return_value=10):
result = dice.lib.cypher_roll(mods='-2')
self.assertEqual(result, (10, 5, None, None))
# general "don't know the difficulty" kind of check in the other direction
with mock.patch('random.SystemRandom.randint', return_value=10):
result = dice.lib.cypher_roll(mods='2')
self.assertEqual(result, (10, 1, None, None))
def test_reaction_roll(self):
"""Roll possible reactions."""
with mock.patch('random.SystemRandom.randint', return_value=1):
self.assertEqual(dice.lib.reaction_roll(), (1, 'VERY negative', '--'))
with mock.patch('random.SystemRandom.randint', return_value=2):
self.assertEqual(dice.lib.reaction_roll(), (2, 'negative', '-'))
with mock.patch('random.SystemRandom.randint', return_value=6):
self.assertEqual(dice.lib.reaction_roll(), (6, 'negative', '-'))
with mock.patch('random.SystemRandom.randint', return_value=7):
self.assertEqual(dice.lib.reaction_roll(), (7, 'positive, with complications', '~'))
with mock.patch('random.SystemRandom.randint', return_value=14):
self.assertEqual(dice.lib.reaction_roll(), (14, 'positive, with complications', '~'))
with mock.patch('random.SystemRandom.randint', return_value=15):
self.assertEqual(dice.lib.reaction_roll(), (15, 'positive', '+'))
with mock.patch('random.SystemRandom.randint', return_value=19):
self.assertEqual(dice.lib.reaction_roll(), (19, 'positive', '+'))
with mock.patch('random.SystemRandom.randint', return_value=20):
self.assertEqual(dice.lib.reaction_roll(), (20, 'VERY positive', '++'))

30
tests/test_dice_roller.py Normal file
View File

@ -0,0 +1,30 @@
"""Test the dice parsing and generator."""
from unittest import mock
from django.test import TestCase
import dice.roller
class DiceRollerTestCase(TestCase):
"""Test that a variety of dice rolls can be parsed."""
def setUp(self):
"""Create the dice roller."""
self.roller = dice.roller.DiceRoller()
def test_standard_rolls(self):
"""Roll a variety of normal rolls."""
with mock.patch('random.randint', return_value=5):
result = self.roller.do_roll('1d20')
self.assertEqual(result, '5 (5[5])')
with mock.patch('random.randint', side_effect=[5, 6]):
result = self.roller.do_roll('2d20')
self.assertEqual(result, '11 (11[5,6])')
with mock.patch('random.randint', side_effect=[1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3,
4, 4, 4, 4, 5, 5, 5, 5, 6, 6, 6, 6]):
result = self.roller.do_roll('6#3/4d6')
self.assertEqual(result, '3 (3[1,1,1,1]), 6 (6[2,2,2,2]), 9 (9[3,3,3,3]), '
'12 (12[4,4,4,4]), 15 (15[5,5,5,5]), 18 (18[6,6,6,6])')

View File

@ -1,6 +1,6 @@
"""Test the dispatch package's webservice.""" """Test the dispatch package's webservice."""
from django.contrib.auth.models import User from django.contrib.auth.models import User
from rest_framework.status import HTTP_200_OK from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from dispatch.models import Dispatcher, DispatcherAction from dispatch.models import Dispatcher, DispatcherAction
@ -27,3 +27,18 @@ class DispatchAPITest(APITestCase):
resp = self.client.get('/dispatch/api/actions/') resp = self.client.get('/dispatch/api/actions/')
self.assertEqual(resp.status_code, HTTP_200_OK) self.assertEqual(resp.status_code, HTTP_200_OK)
self.assertEqual(len(resp.json()), DispatcherAction.objects.count()) self.assertEqual(len(resp.json()), DispatcherAction.objects.count())
def test_unauthed_dispatch_object_retrieval(self):
"""Test that the list endpoints require authentication."""
client = self.client_class()
resp = client.get('/dispatch/api/dispatchers/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/dispatchers/111/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/dispatchers/fake/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/actions/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)
resp = client.get('/dispatch/api/actions/111/')
self.assertEqual(resp.status_code, HTTP_403_FORBIDDEN)

View File

@ -1,8 +1,5 @@
"""Test the race views.""" """Test the race views."""
from unittest import mock
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import now
from races.models import Race, Racer, RaceUpdate from races.models import Race, Racer, RaceUpdate

View File

@ -0,0 +1,46 @@
"""Test views and templates' adherence to WEB_ENABLED_APPS."""
from django.conf import settings
from django.test import TestCase
class WebEnabledAppsAPITest(TestCase):
"""Test that certain display elements and views can be enabled/disabled via settings."""
def setUp(self):
"""Store the old setting so that we can restore it later."""
self.old_sites_list = settings.WEB_ENABLED_APPS
print("butt")
def tearDown(self):
"""Restore the old setting stored earlier."""
settings.WEB_ENABLED_APPS = self.old_sites_list
print("butt")
def test_default_enabled(self):
"""Test that the expected sites can be reached and displayed by default."""
resp = self.client.get('/karma/')
self.assertEqual(resp.status_code, 200)
self.assertIn(b'<a href="/karma/">', resp.content)
resp = self.client.get('/races/')
self.assertEqual(resp.status_code, 200)
self.assertIn(b'<a href="/races/">', resp.content)
def test_one_disabled(self):
"""Test that we can disable one site but not all sites using this setting."""
settings.WEB_ENABLED_APPS = ['karma']
resp = self.client.get('/karma/')
self.assertEqual(resp.status_code, 200)
self.assertIn(b'<a href="/karma/">', resp.content)
resp = self.client.get('/races/')
self.assertEqual(resp.status_code, 403)
self.assertNotIn(b'<a href="/races/">', resp.content)
def test_all_disabled(self):
"""Test that we can disable all sites using this setting."""
settings.WEB_ENABLED_APPS = []
resp = self.client.get('/karma/')
self.assertEqual(resp.status_code, 403)
self.assertNotIn(b'<a href="/karma/">', resp.content)
resp = self.client.get('/races/')
self.assertEqual(resp.status_code, 403)
self.assertNotIn(b'<a href="/races/">', resp.content)

121
tox.ini
View File

@ -4,21 +4,11 @@
# and then run "tox" from this directory. # and then run "tox" from this directory.
[tox] [tox]
envlist = begin,py38,py39,py310,coverage,security,lint,bundle isolated_build = true
envlist = begin,py310,py311,coverage,security,lint
[testenv] [testenv]
# build a wheel and test it allow_externals = pytest, coverage
wheel = true
wheel_build_env = build
# whitelist commands we need
whitelist_externals = cp
# install everything via requirements-dev.txt, so that developer environment
# is the same as the tox environment (for ease of use/no weird gotchas in
# local dev results vs. tox results) and also to avoid ticky-tacky maintenance
# of "oh this particular env has weird results unless I install foo" --- just
# shotgun blast install everything everywhere
deps = deps =
-rrequirements/requirements-dev.txt -rrequirements/requirements-dev.txt
@ -31,50 +21,6 @@ deps = setuptools
skip_install = true skip_install = true
commands = coverage erase commands = coverage erase
[testenv:py38]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py39]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py310] [testenv:py310]
# run pytest with coverage # run pytest with coverage
commands = commands =
@ -85,7 +31,7 @@ commands =
--cov={envsitepackagesdir}/dispatch/ \ --cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \ --cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \ --cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/gitlab_bot/ \ --cov={envsitepackagesdir}/history/ \
--cov={envsitepackagesdir}/ircbot/ \ --cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \ --cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \ --cov={envsitepackagesdir}/markov/ \
@ -94,6 +40,30 @@ commands =
--cov={envsitepackagesdir}/races/ \ --cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \ --cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \ --cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/
[testenv:py311]
# run pytest with coverage
commands =
pytest --cov-append --cov-branch \
--cov={envsitepackagesdir}/acro/ \
--cov={envsitepackagesdir}/countdown/ \
--cov={envsitepackagesdir}/dice/ \
--cov={envsitepackagesdir}/dispatch/ \
--cov={envsitepackagesdir}/dr_botzo/ \
--cov={envsitepackagesdir}/facts/ \
--cov={envsitepackagesdir}/history/ \
--cov={envsitepackagesdir}/ircbot/ \
--cov={envsitepackagesdir}/karma/ \
--cov={envsitepackagesdir}/markov/ \
--cov={envsitepackagesdir}/mpdbot/ \
--cov={envsitepackagesdir}/pi/ \
--cov={envsitepackagesdir}/races/ \
--cov={envsitepackagesdir}/seen/ \
--cov={envsitepackagesdir}/storycraft/ \
--cov={envsitepackagesdir}/text_manip/ \
--cov={envsitepackagesdir}/transform/ \ --cov={envsitepackagesdir}/transform/ \
--cov={envsitepackagesdir}/weather/ --cov={envsitepackagesdir}/weather/
@ -116,7 +86,7 @@ commands =
{envsitepackagesdir}/dispatch/ \ {envsitepackagesdir}/dispatch/ \
{envsitepackagesdir}/dr_botzo/ \ {envsitepackagesdir}/dr_botzo/ \
{envsitepackagesdir}/facts/ \ {envsitepackagesdir}/facts/ \
{envsitepackagesdir}/gitlab_bot/ \ {envsitepackagesdir}/history/ \
{envsitepackagesdir}/ircbot/ \ {envsitepackagesdir}/ircbot/ \
{envsitepackagesdir}/karma/ \ {envsitepackagesdir}/karma/ \
{envsitepackagesdir}/markov/ \ {envsitepackagesdir}/markov/ \
@ -125,6 +95,7 @@ commands =
{envsitepackagesdir}/races/ \ {envsitepackagesdir}/races/ \
{envsitepackagesdir}/seen/ \ {envsitepackagesdir}/seen/ \
{envsitepackagesdir}/storycraft/ \ {envsitepackagesdir}/storycraft/ \
{envsitepackagesdir}/text_manip/ \
{envsitepackagesdir}/transform/ \ {envsitepackagesdir}/transform/ \
{envsitepackagesdir}/weather/ \ {envsitepackagesdir}/weather/ \
-r -r
@ -136,12 +107,6 @@ commands =
flake8 flake8
- flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO - flake8 --disable-noqa --ignore= --select=E,W,F,C,D,A,G,B,I,T,M,DUO
[testenv:bundle]
# take extra actions (build sdist, sphinx, whatever) to completely package the app
commands =
cp -r {distdir} .
python setup.py sdist
[coverage:paths] [coverage:paths]
source = source =
./ ./
@ -160,7 +125,7 @@ include =
{envsitepackagesdir}/dispatch/ {envsitepackagesdir}/dispatch/
{envsitepackagesdir}/dr_botzo/ {envsitepackagesdir}/dr_botzo/
{envsitepackagesdir}/facts/ {envsitepackagesdir}/facts/
{envsitepackagesdir}/gitlab_bot/ {envsitepackagesdir}/history/
{envsitepackagesdir}/ircbot/ {envsitepackagesdir}/ircbot/
{envsitepackagesdir}/karma/ {envsitepackagesdir}/karma/
{envsitepackagesdir}/markov/ {envsitepackagesdir}/markov/
@ -169,31 +134,9 @@ include =
{envsitepackagesdir}/races/ {envsitepackagesdir}/races/
{envsitepackagesdir}/seen/ {envsitepackagesdir}/seen/
{envsitepackagesdir}/storycraft/ {envsitepackagesdir}/storycraft/
{envsitepackagesdir}/text_manip/
{envsitepackagesdir}/transform/ {envsitepackagesdir}/transform/
{envsitepackagesdir}/weather/ {envsitepackagesdir}/weather/
omit = omit =
**/_version.py **/_version.py
[flake8]
enable-extensions = G,M
exclude =
.tox/
versioneer.py
_version.py
instance/
extend-ignore = T101
max-complexity = 10
max-line-length = 120
[isort]
line_length = 120
[pytest]
python_files =
*_tests.py
tests.py
test_*.py
log_level=DEBUG
DJANGO_SETTINGS_MODULE = dr_botzo.settings
django_find_project = false

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,17 @@
"""Report on the weather via wttr.in."""
import logging import logging
from ircbot.lib import Plugin from ircbot.lib import Plugin
from weather.lib import weather_summary from weather.lib import weather_summary
log = logging.getLogger('weather.ircplugin') log = logging.getLogger('weather.ircplugin')
class Weather(Plugin): class Weather(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'^!weather\s+(.*)$', self.connection.reactor.add_global_regex_handler(['pubmsg', 'privmsg'], r'^!weather\s+(.*)$',
self.handle_weather, -20) self.handle_weather, -20)
@ -22,12 +19,12 @@ class Weather(Plugin):
def stop(self): def stop(self):
"""Tear down handlers.""" """Tear down handlers."""
self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_weather) self.connection.reactor.remove_global_regex_handler(['pubmsg', 'privmsg'], self.handle_weather)
super(Weather, self).stop() super(Weather, self).stop()
def handle_weather(self, connection, event, match): def handle_weather(self, connection, event, match):
"""Make the weather query and format it for IRC."""
query = match.group(1) query = match.group(1)
queryitems = query.split(" ") queryitems = query.split(" ")
if len(queryitems) <= 0: if len(queryitems) <= 0: