Compare commits

...

95 Commits

Author SHA1 Message Date
Mal Hancock
004632537b Set theme jekyll-theme-minimal 2022-01-09 22:29:47 -08:00
mallory
d528213c5f fix for listeners 2020-12-18 09:48:21 -08:00
mallory
c3613f196a change message logic 2020-12-18 09:35:37 -08:00
mallory
fdca6da624 changes to allow processing other events 2020-12-18 08:43:54 -08:00
Mal Hancock
5c9278bc63
Update __version__.py 2020-11-12 09:02:55 -08:00
Lars Kellogg-Stedman
35f5bb684b
correct UnboundLocalError in read_conf (#76)
When encountering an error, read_conf uses click.echo(...) to emit an
error message but then continue execution, causing the read_conf
method to throw an error of the form:

    UnboundLocalError: local variable 'output' referenced before assignment

By raising click.ClickException instead of calling click.echo, we
ensure that pinhook exits with an error message (and no traceback).
2020-11-12 08:10:31 -08:00
mallory
7c62e59c3a fix issue with reloading plugins in some contexts 2020-09-23 11:25:15 -07:00
mallory
b995b866ec do not duplicate output function 2020-09-23 11:17:59 -07:00
Mal Hancock
9f62c6c67e
use built-in setup.py upload 2020-05-29 09:50:04 -07:00
Mal Hancock
855413a093
Create python-publish.yml 2020-05-29 09:25:06 -07:00
Mal Hancock
cea5e6f855
Update FUNDING.yml 2020-05-29 09:19:04 -07:00
Mal Hancock
e7b7a5b832
Update FUNDING.yml 2020-05-29 09:16:44 -07:00
Mal Hancock
0417dddf2f
Create FUNDING.yml 2020-05-29 08:37:20 -07:00
Mal Hancock
a0378e09c9
version 1.9.3 (#74)
* bring TwitchBot up to date

* prevent responses from bot nick
2020-05-28 14:51:56 -07:00
Mal Hancock
5b241eee13
change example plugins to match current standards 2020-04-28 13:10:43 -07:00
Mal Hancock
0e510cc8b6
change example plugins to match current standards 2020-04-28 13:10:17 -07:00
Mallory Hancock
1c4fdb8d9e update readme to use updated decorators 2020-01-31 12:03:46 -08:00
Mallory Hancock
8c71f7bae0 fix for internal commands 2019-10-14 12:57:38 -07:00
Mallory Hancock
9faa59a41d iterate version 2019-10-04 10:58:58 -07:00
Mallory Hancock
787a69757f add ops option to command decorator (closes #72) 2019-10-02 09:56:56 -07:00
Mallory Hancock
23355bb699 clean if statements, ban users, closes #51 2019-10-02 09:25:38 -07:00
Mallory Hancock
9af3abd4a5 add and remove ops in command, closes #66 2019-10-02 09:04:50 -07:00
Mallory Hancock
4da89fa815 disable help using argument, closes #47 2019-09-30 11:47:55 -07:00
Mallory Hancock
ff3520f8fd fix join command issue #41 2019-09-30 11:35:35 -07:00
Mallory Hancock
a98c82b332 adds msg_type, closes #54 2019-09-30 11:23:19 -07:00
Mallory Hancock
8d01f70a5c move loading to plugin, issue w/ disabled plugins 2019-09-27 18:18:19 -07:00
Mallory Hancock
80dcc75a58 Merge branch 'master' of github.com:archangelic/pinhook 2019-09-23 12:26:17 -07:00
Mallory Hancock
0bb6eb6b6a add logging, @register future deprecation warning 2019-09-23 12:25:08 -07:00
Mallory Hancock
5b6ec72d0b break out logging into separate module 2019-09-23 12:23:41 -07:00
Mal Hancock
0b8bbb4213
add new live bot! 2019-09-19 10:01:37 -07:00
Mallory Hancock
9960209c47 iterate version 2019-09-18 10:10:23 -07:00
Mallory Hancock
74a028dfca remove rst 2019-09-18 09:44:23 -07:00
Mallory Hancock
cbc9550f97 slow down help output 2019-09-18 09:42:45 -07:00
Mallory Hancock
9797f731a7 add full support for command-based plugins (#27) 2019-09-18 09:28:22 -07:00
Mallory Hancock
1aa17eebfb add enable/disable (#12) and enhanced help output 2019-09-18 09:27:41 -07:00
Mallory Hancock
0da04acc4e make commands entirely class-based, enhance help 2019-09-16 12:27:43 -07:00
Mallory Hancock
4ae8753095 preliminary work for class based plugins #27 2019-09-12 10:14:56 -07:00
Mallory Hancock
fdb16287ce remove unused variable 2019-09-12 10:13:57 -07:00
Mallory Hancock
bb7ff3fdea fix setup for markdown 2019-08-30 13:39:46 -07:00
Mallory Hancock
ff8a825846 update readme 2019-08-30 13:29:05 -07:00
Mallory Hancock
9e68e8e8a2 fix yaml warning 2019-08-29 10:43:24 -07:00
Mallory Hancock
29d34320f1 iterate version 2019-08-29 10:43:09 -07:00
Mallory Hancock
05c83a50ec fix issues with toml loading 2019-08-29 10:13:59 -07:00
Mallory Hancock
6eb4325f3f fix more typos 2019-08-29 09:57:57 -07:00
Mallory Hancock
3a93b9e639 fix comparison typo 2019-08-29 09:55:46 -07:00
Mallory Hancock
93cb2784ee add arguments 2019-08-28 12:22:56 -07:00
Mallory Hancock
abfc6189d3 add preliminary support for yaml and toml configs 2019-08-28 12:20:43 -07:00
Mallory Hancock
2063072cbb add new requirements 2019-08-27 12:15:48 -07:00
Mallory Hancock
eee71ad774 basic json config-only command 2019-08-27 12:12:26 -07:00
Mallory Hancock
fcd456ea52 add .vscode 2019-06-20 18:01:09 -07:00
Mallory Hancock
da0dc3576e remove or 2019-04-01 07:15:02 -07:00
Mallory Hancock
6449f17864 reverting back to 2 readmes 2019-03-02 12:26:36 -08:00
Mallory Hancock
abc0dc8c70 reverting back to 2 readmes 2019-03-02 12:26:21 -08:00
Mallory Hancock
dd8f02618b iterate version 2019-03-02 12:00:41 -08:00
Mal Hancock
3b07ed99b2
Hotfix/too big error (#63)
* hotfix for message too long

* fix spacing error

* fix error name

* add breaks
2019-03-02 11:02:14 -08:00
Mallory Hancock
23f5c76f13 iterate version 2019-02-28 16:35:22 -08:00
Matan Shenhav
709d05da73 Add bot as argument for Message class & pass self in calls (#60)
Passing the bot object as an argument to the Message class make
the pinhook Bot class more extensible. Now it is easy to inherit and
pass attributes of the inherited class into plugins.

See https://github.com/archangelic/pinhook/issues/59
2019-02-23 16:56:03 -08:00
Mallory Hancock
ac2491c005 remove crashing logging statements 2019-02-22 13:59:30 -08:00
Mallory Hancock
07fe4d1c6e fix critical bug 2019-02-22 13:55:48 -08:00
Mallory Hancock
73c3de9c99 iterate version 2019-02-22 13:40:30 -08:00
Mal Hancock
81af6ba8bd
fix issue with listeners not sending any output (#58) 2019-02-22 13:39:16 -08:00
Mallory Hancock
836a696dc5 fix typo 2019-02-22 13:16:56 -08:00
Mallory Hancock
8bcf296f2f explicit logging 2019-02-22 13:15:31 -08:00
Mallory Hancock
d8cdaae7b3 fix dumb mistake 2019-02-22 13:07:41 -08:00
Mallory Hancock
5e46ab940d add some debugging statements 2019-02-22 12:56:21 -08:00
Mal Hancock
a182e55a65 iterate version 2019-02-16 10:53:14 -08:00
Mal Hancock
3cc712b835 fix typo 2019-02-16 10:51:02 -08:00
Mal Hancock
2118cc26eb
changes to __init__ and add command prefix capability (#53)
* changes to __init__ and add command prefix capability
2019-02-16 10:46:38 -08:00
Russell
bf984bacd0 Create ops only commands (#50)
* Add support for ops commands. Fixes #48

* Update readme. remove prints
2019-02-16 10:40:10 -08:00
Russell
7c7d92948a Fixed issue with bot crashing when processing a None message (#49)
Fixes #46
2019-02-08 11:25:49 -08:00
Mallory Hancock
39be6ea582 fix typo 2019-01-25 13:17:31 -08:00
Mal Hancock
c0fc471a8f iterate version 2019-01-25 13:14:47 -08:00
Mal Hancock
479d009346
add timestamp functionality (#44)
closes #43
2019-01-25 13:13:48 -08:00
Mallory Hancock
4fdb5d19e6 update readme with more tutorial info 2019-01-24 09:46:44 -08:00
Mallory Hancock
7a28374d7a update README and set up to use markdown for PyPI 2019-01-24 09:36:21 -08:00
Mallory Hancock
67e36fca81 iterate version 2018-12-28 11:21:58 -08:00
Mal Hancock
6c87e6d9f0
fix for channel keys (#40) 2018-12-28 11:20:52 -08:00
Mallory Hancock
334ed442fd iterate version 2018-11-02 13:54:17 -07:00
Mal Hancock
1523cfaf3a
add ability to gather help text (#38) 2018-11-02 13:53:04 -07:00
Mal Hancock
7d0844e156 iterate version 2018-10-24 14:58:27 -07:00
Mal Hancock
34590dfa32 make help overridable, add ability for plugins to msg, action or notice at will 2018-10-24 14:48:55 -07:00
Mallory Hancock
f989deb0d9 fix to classifiers 2018-10-10 11:03:20 -07:00
Mal Hancock
6df2cb9d79
Add shields to repo (#37)
* add shields to repo and iterate version

* fix line break
2018-10-10 10:49:33 -07:00
Lucidiot
f65248f797 Output type as an Enum (#35)
* Add enum34 requirement

* Use an Enum for output types
2018-10-10 10:25:03 -07:00
Lucidiot
86eddea996 Make example scripts executable (#36) 2018-10-10 10:23:31 -07:00
Mal Hancock
764e88a222 update version 2018-10-06 18:07:05 -07:00
Mal Hancock
df7007efd1
insert check to log errors for messages over 512 bytes (#29)
fixes #24
2018-10-06 17:46:04 -07:00
Mal Hancock
6cf5e4bacc Set theme jekyll-theme-midnight 2018-10-06 17:20:36 -07:00
Mal Hancock
eb2e59656e Set theme jekyll-theme-hacker 2018-10-06 17:15:31 -07:00
Mallory Hancock
b8aed568f4 add nick as argument 2018-04-10 12:15:08 -07:00
Mallory Hancock
101bd91718 pass nick to plugins 2018-04-10 12:13:54 -07:00
Mallory Hancock
9ea10b18f1 fix another typo 2018-04-10 12:07:13 -07:00
Mallory Hancock
966ac38603 forgot to make functions part of self 2018-04-10 12:02:48 -07:00
Mallory Hancock
2bc2c70d1b fix typo 2018-04-10 12:00:18 -07:00
Mallory Hancock
b91731e4ca refactoring code for testability 2018-04-10 11:58:33 -07:00
17 changed files with 659 additions and 301 deletions

4
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,4 @@
github: archangelic
ko_fi: archangelic
liberapay: archangelic
custom: ["https://paypal.me/archangelic", "https://cash.app/$archangelic"]

30
.github/workflows/python-publish.yml vendored Normal file
View File

@ -0,0 +1,30 @@
# This workflows will upload a Python Package using Twine when a release is created
# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries
name: Upload Python Package
on:
release:
types: [created]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.x'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install setuptools wheel twine
- name: Build and publish
env:
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
run: |
python setup.py upload

3
.gitignore vendored
View File

@ -99,3 +99,6 @@ ENV/
# mypy
.mypy_cache/
# etc
.vscode/

View File

@ -1 +1 @@
include README.rst
include README.md

138
README.md
View File

@ -1,13 +1,78 @@
# pinhook
the pluggable python framework for IRC bots and Twitch bots
## Tutorial
### Installation
```
$ pip install pinhook
[![Supported Python versions](https://img.shields.io/pypi/pyversions/pinhook.svg)](https://pypi.org/project/pinhook) [![Package License](https://img.shields.io/pypi/l/pinhook.svg)](https://github.com/archangelic/pinhook/blob/master/LICENSE) [![PyPI package format](https://img.shields.io/pypi/format/pinhook.svg)](https://pypi.org/project/pinhook) [![Package development status](https://img.shields.io/pypi/status/pinhook.svg)](https://pypi.org/project/pinhook) [![With love from tilde.town](https://img.shields.io/badge/with%20love%20from-tilde%20town-e0b0ff.svg)](https://tilde.town)
The pluggable python framework for IRC bots and Twitch bots
* [Installation](#installation)
* [Creating an IRC Bot](#creating-an-irc-bot)
* [From Config File](#from-config-file)
* [From Python File](#from-python-file)
* [Creating a Twitch Bot](#creating-a-twitch-bot)
* [Creating plugins](#creating-plugins)
* [Examples](#examples)
## Installation
Pinhook can be installed from PyPI:
``` bash
pip install pinhook
```
### Creating an IRC Bot
## Creating an IRC Bot
A pinhook bot can be initialized using the command line tool `pinhook` with a config file, or by importing it into a python file to extend the base class.
### From Config File
Pinhook supports configuration files in YAML, TOML, and JSON formats.
Example YAML config:
```YAML
nickname: "ph-bot"
server: "irc.somewhere.net"
channels:
- "#foo"
- "#bar"
```
Required configuration keys:
* `nickname`: (string) nickname for your bot
* `server`: (string) server for the bot to connect
* `channels`: (array of strings) list of channels to connect to once connected
Optional keys:
* `port`: (default: `6667`) choose a custom port to connect to the server
* `ops`: (default: empty list) list of operators who can do things like make the bot join other channels or quit
* `plugin_dir`: (default: `"plugins"`) directory where the bot should look for plugins
* `log_level`: (default: `"info"`) string indicating logging level. Logging can be disabled by setting this to `"off"`
* `ns_pass`: this is the password to identify with nickserv
* `server_pass`: password for the server
* `ssl_required`: (default: `False`) boolean to turn ssl on or off
Once you have your configuration file ready and your plugins in place, you can start your bot from the command line:
```bash
pinhook config.yaml
```
Pinhook will try to detect the config format from the file extension, but the format can also be supplied using the `--format` option.
```bash
$ pinhook --help
Usage: pinhook [OPTIONS] CONFIG
Options:
-f, --format [json|yaml|toml]
--help Show this message and exit.
```
### From Python File
To create the bot, just create a python file with the following:
```python
@ -24,15 +89,17 @@ bot.start()
This will start a basic bot and look for plugins in the 'plugins' directory to add functionality.
Optional arguments are:
* `port`: choose a custom port to connect to the server (default: 6667)
* `ops`: list of operators who can do things like make the bot join other channels or quit (default: empty list)
* `plugin_dir`: directory where the bot should look for plugins (default: "plugins")
* `log_level`: string indicating logging level. Logging can be disabled by setting this to "off". (default: "info")
* `port`: (default: `6667`) choose a custom port to connect to the server
* `ops`: (default: empty list) list of operators who can do things like make the bot join other channels or quit
* `plugin_dir`: (default: `"plugins"`) directory where the bot should look for plugins
* `log_level`: (default: `"info"`) string indicating logging level. Logging can be disabled by setting this to `"off"`
* `ns_pass`: this is the password to identify with nickserv
* `server_pass`: password for the server
* `ssl_required`: boolean to turn ssl on or off
* `ssl_required`: (default: `False`) boolean to turn ssl on or off
## Creating a Twitch Bot
### Creating a Twitch Bot
Pinhook has a baked in way to connect directly to a twitch channel
```python
@ -45,23 +112,29 @@ bot = TwitchBot(
)
bot.start()
```
This function has far less options, as the server, port, and ssl are already handled by twitch.
Optional aguments are:
* `ops`
* `plugin_dir`
* `log_level`
These options are the same for both IRC and Twitch
### Creating plugins
In your chosen plugins directory ("plugins" by default) make a python file with a function. You can use the `@pinhook.plugin.register` decorator to tell the bot the command to activate the function.
## Creating plugins
There are two types of plugins, commands and listeners. Commands only activate if a message starts with the command word, while listeners receive all messages and are parsed by the plugin for maximum flexibility.
In your chosen plugins directory ("plugins" by default) make a python file with a function. You use the `@pinhook.plugin.command` decorator to create command plugins, or `@pinhook.plugin.listener` to create listeners.
The function will need to be structured as such:
```python
import pinhook.plugin
@pinhook.plugin.register('!test')
@pinhook.plugin.command('!test')
def test_plugin(msg):
message = '{}: this is a test!'.format(msg.nick)
return pinhook.plugin.message(message)
@ -70,19 +143,46 @@ def test_plugin(msg):
The function will need to accept a single argument in order to accept a `Message` object from the bot.
The `Message` object has the following attributes:
* `cmd`: the command that triggered the function
* `cmd`: (for command plugins) the command that triggered the function
* `nick`: the user who triggered the command
* `arg`: all the trailing text after the command. This is what you will use to get optional information for the command
* `arg`: (for command plugins) all the trailing text after the command. This is what you will use to get optional information for the command
* `text`: (for listener plugins) the entire text of the message
* `channel`: the channel where the command was initiated
* `ops`: the list of bot operators
* `botnick`: the nickname of the bot
* `logger`: instance of `Bot`'s logger
* `datetime`: aware `datetime.datetime` object when the `Message` object was created
* `timestamp`: float for the unix timestamp when the `Message` object was created
* `bot`: the initialized Bot class
It also contains the following IRC functions:
* `privmsg`: send a message to an arbitrary channel or user
* `action`: same as privmsg, but does a CTCP action. (i.e., `/me does a thing`)
* `notice`: send a notice
You can optionally set a command to be used only by ops
The function will need to be structured as such:
```python
@pinhook.plugin.command('!test', ops=True, ops_msg='This command can only be run by an op')
def test_plugin(msg):
return pinhook.plugin.message('This was run by an op!')
```
The plugin function can return one of the following in order to give a response to the command:
The plugin function **must** return one of the following in order to give a response to the command:
* `pinhook.plugin.message`: basic message in channel where command was triggered
* `pinhook.plugin.action`: CTCP action in the channel where command was triggered (basically like using `/me does a thing`)
## Examples
There are some basic examples in the `examples` directory in this repository.
For a live and maintained bot running the current version of pinhook see [pinhook-tilde](https://github.com/archangelic/pinhook-tilde).
Here is a list of live bots using pinhook:
* [pinhook-tilde](https://github.com/archangelic/pinhook-tilde) - fun bot for tilde.town
* [adminbot](https://github.com/tildetown/adminbot) - admin helper bot for tilde.town, featuring some of the ways you can change the Bot class to suit your needs
* [lucibot](https://github.com/Lucidiot/lucibot)

View File

@ -1,123 +0,0 @@
pinhook
=======
the pluggable python framework for IRC bots and Twitch bots
Tutorial
--------
Installation
~~~~~~~~~~~~
::
$ pip install pinhook
Creating an IRC Bot
~~~~~~~~~~~~~~~~~~~
To create the bot, just create a python file with the following:
.. code:: python
from pinhook.bot import Bot
bot = Bot(
channels=['#foo', '#bar'],
nickname='ph-bot',
server='irc.freenode.net'
)
bot.start()
This will start a basic bot and look for plugins in the 'plugins'
directory to add functionality.
Optional arguments are:
- ``port``: choose a custom port to connect to the server (default:
6667)
- ``ops``: list of operators who can do things like make the bot join
other channels or quit (default: empty list)
- ``plugin_dir``: directory where the bot should look for plugins
(default: "plugins")
- ``log_level``: string indicating logging level. Logging can be
disabled by setting this to "off". (default: "info")
- ``ns_pass``: this is the password to identify with nickserv
- ``server_pass``: password for the server
- ``ssl_required``: boolean to turn ssl on or off
Creating a Twitch Bot
~~~~~~~~~~~~~~~~~~~~~
Pinhook has a baked in way to connect directly to a twitch channel
.. code:: python
from pinhook.bot import TwitchBot
bot = TwitchBot(
nickname='ph-bot',
channel='#channel',
token='super-secret-oauth-token'
)
bot.start()
This function has far less options, as the server, port, and ssl are
already handled by twitch.
Optional aguments are:
- ``ops``
- ``plugin_dir``
- ``log_level``
These options are the same for both IRC and Twitch
Creating plugins
~~~~~~~~~~~~~~~~
In your chosen plugins directory ("plugins" by default) make a python
file with a function. You can use the ``@pinhook.plugin.register``
decorator to tell the bot the command to activate the function.
The function will need to be structured as such:
.. code:: python
import pinhook.plugin
@pinhook.plugin.register('!test')
def test_plugin(msg):
message = '{}: this is a test!'.format(msg.nick)
return pinhook.plugin.message(message)
The function will need to accept a single argument in order to accept a
``Message`` object from the bot.
The ``Message`` object has the following attributes:
- ``cmd``: the command that triggered the function
- ``nick``: the user who triggered the command
- ``arg``: all the trailing text after the command. This is what you
will use to get optional information for the command
- ``channel``: the channel where the command was initiated
- ``ops``: the list of bot operators
- ``botnick``: the nickname of the bot
- ``logger``: instance of ``Bot``'s logger
The plugin function **must** return one of the following in order to
give a response to the command:
- ``pinhook.plugin.message``: basic message in channel where command
was triggered
- ``pinhook.plugin.action``: CTCP action in the channel where command
was triggered (basically like using ``/me does a thing``)
Examples
--------
There are some basic examples in the ``examples`` directory in this
repository.
For a live and maintained bot running the current version of pinhook see
`pinhook-tilde <https://github.com/archangelic/pinhook-tilde>`__.

1
_config.yml Normal file
View File

@ -0,0 +1 @@
theme: jekyll-theme-minimal

2
examples/irc/dicebot.py Normal file → Executable file
View File

@ -1,3 +1,5 @@
#!/usr/bin/env python
from pinhook.bot import Bot
ph = Bot(

View File

@ -17,7 +17,7 @@ def build_output(rolls, modifier):
output = start
return output
@pinhook.plugin.register('!roll')
@pinhook.plugin.command('!roll')
def roll(msg):
matches = dicepattern.match(msg.arg)
if matches:

4
examples/twitch/dicebot.py Normal file → Executable file
View File

@ -1,3 +1,5 @@
#!/usr/bin/env python
from pinhook.bot import TwitchBot
bot = TwitchBot(
@ -5,4 +7,4 @@ bot = TwitchBot(
channel='#dicechannel',
token='supersecrettokenhere'
)
bot.start()
bot.start()

View File

@ -17,7 +17,7 @@ def build_output(rolls, modifier):
output = start
return output
@pinhook.plugin.register('!roll')
@pinhook.plugin.command('!roll')
def roll(msg):
matches = dicepattern.match(msg.arg)
if matches:

1
pinhook/__version__.py Normal file
View File

@ -0,0 +1 @@
__version__ = '1.9.7'

View File

@ -1,9 +1,11 @@
import imp
from collections import OrderedDict
from datetime import datetime, timezone
import logging
import os
import ssl
import time
import pinhook.plugin
from . import log
from . import plugin
import irc.bot
@ -12,8 +14,34 @@ irc.client.ServerConnection.buffer_class.errors = 'replace'
class Bot(irc.bot.SingleServerIRCBot):
internal_commands = {
'join': 'join a channel',
'quit': 'force the bot to quit',
'reload': 'force bot to reload all plugins',
'enable': 'enable a plugin',
'disable': 'disable a plugin',
'op': 'add a user as bot operator',
'deop': 'remove a user as bot operator',
'ops': 'list all ops',
'ban': 'ban a user from using the bot',
'unban': 'remove bot ban for user',
'banlist': 'currently banned nicks'
}
def __init__(self, channels, nickname, server, **kwargs):
self.set_kwargs(**kwargs)
self.port = kwargs.get('port', 6667)
self.ops = kwargs.get('ops', [])
self.plugin_dir = kwargs.get('plugin_dir', 'plugins')
self.ssl_required = kwargs.get('ssl_required', False)
self.ns_pass = kwargs.get('ns_pass', None)
self.nickserv = kwargs.get('nickserv', 'NickServ')
self.log_level = kwargs.get('log_level', 'info')
self.log_file = kwargs.get('log_file', None)
self.server_pass = kwargs.get('server_pass', None)
self.cmd_prefix = kwargs.get('cmd_prefix', '!')
self.use_prefix_for_plugins = kwargs.get('use_prefix_for_plugins', False)
self.disable_help = kwargs.get('disable_help', False)
self.banned_users = kwargs.get('banned_users', [])
if self.ssl_required:
factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname, connect_factory=factory)
@ -21,114 +49,214 @@ class Bot(irc.bot.SingleServerIRCBot):
irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname)
self.chanlist = channels
self.bot_nick = nickname
self.start_logging(self.log_level)
self.load_plugins()
self.start_logging()
self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()}
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix)
class Message:
def __init__(self, channel, nick, botnick, ops, logger, cmd=None, arg=None, text=None, nick_list=None):
def __init__(self, bot, channel, nick, botnick, ops, logger, action, privmsg, notice, msg_type, cmd=None, arg=None, text=None, nick_list=None):
self.bot = bot
self.datetime = datetime.now(timezone.utc)
self.timestamp = self.datetime.timestamp()
self.channel = channel
self.nick = nick
self.nick_list = nick_list
self.botnick = botnick
self.ops = ops
self.logger = logger
self.action = action
self.privmsg = privmsg
self.notice = notice
self.msg_type = msg_type
if cmd:
self.cmd = cmd
self.arg = arg
if text:
if text != None:
self.text = text
if not (cmd or text):
if not (cmd==None or text==None):
raise TypeError('missing cmd or text parameter')
def set_kwargs(self, **kwargs):
kwarguments = {
'port': 6667,
'ops': [],
'plugin_dir': 'plugins',
'ssl_required': False,
'ns_pass': None,
'nickserv': 'NickServ',
'log_level': 'info',
'server_pass': None,
}
for k, v in kwargs.items():
setattr(self, k, v)
for a in kwarguments:
if a not in kwargs:
setattr(self, a, kwarguments[a])
def start_logging(self, level):
if level == 'error':
def start_logging(self):
self.logger = log.logger
if self.log_file:
log.set_log_file(self.log_file)
else:
log.set_log_file('{}.log'.format(self.bot_nick))
if self.log_level == 'error':
level = logging.ERROR
elif level == 'warning':
elif self.log_level == 'warning':
level = logging.WARNING
elif level == 'info':
elif self.log_level == 'info':
level = logging.INFO
elif level == 'debug':
elif self.log_level == 'debug':
level = logging.DEBUG
self.logger = logging.getLogger(self.bot_nick)
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(module)s - %(message)s')
# Set console logger
ch = logging.StreamHandler()
ch.setFormatter(formatter)
# Set file logger
fh = logging.FileHandler('{}.log'.format(self.bot_nick))
fh.setFormatter(formatter)
# Set levels
if level != "off":
if self.log_level != "off":
self.logger.setLevel(level)
ch.setLevel(level)
fh.setLevel(level)
# Add handlers
self.logger.addHandler(ch)
self.logger.addHandler(fh)
self.logger.info('Logging started!')
def load_plugins(self):
# clear plugin list to ensure no old plugins remain
self.logger.info('clearing plugin cache')
pinhook.plugin.clear_plugins()
# ensure plugin folder exists
self.logger.info('checking plugin directory')
if not os.path.exists(self.plugin_dir):
self.logger.info('plugin directory {} not found, creating'.format(self.plugin_dir))
os.makedirs(self.plugin_dir)
# load all plugins
for m in os.listdir(self.plugin_dir):
if m.endswith('.py'):
try:
name = m[:-3]
self.logger.info('loading plugin {}'.format(name))
fp, pathname, description = imp.find_module(name, [self.plugin_dir])
imp.load_module(name, fp, pathname, description)
except Exception as e:
self.logger.exception('could not load plugin')
# gather all commands and listeners
for cmd in pinhook.plugin.cmds:
self.logger.debug('adding command {}'.format(cmd))
for lstnr in pinhook.plugin.lstnrs:
self.logger.debug('adding listener {}'.format(lstnr))
def on_welcome(self, c, e):
if self.ns_pass:
self.logger.info('identifying with nickserv')
c.privmsg(self.nickserv, 'identify {}'.format(self.ns_pass))
for channel in self.chanlist:
self.logger.info('joining channel {}'.format(channel))
c.join(channel)
self.logger.info('joining channel {}'.format(channel.split()[0]))
c.join(*channel.split())
def on_pubmsg(self, c, e):
self.process_command(c, e)
self.process_event(c, e)
def on_privmsg(self, c, e):
self.process_command(c, e)
self.process_event(c, e)
def on_action(self, c, e):
self.process_command(c, e)
self.process_event(c, e)
def process_command(self, c, e):
def call_help(self, nick, op):
cmds = {k:v.help_text for k,v in plugin.cmds.items() if not plugin.cmds[k].ops}
cmds.update({self.cmd_prefix + 'help': 'returns this output to private message'})
if op:
cmds.update({k:v.help_text for k,v in plugin.cmds.items() if plugin.cmds[k].ops})
cmds.update({k:v for k,v in self.internal_commands.items()})
helpout = OrderedDict(sorted(cmds.items()))
for h in helpout:
self.connection.privmsg(nick, '{} -- {}'.format(h, helpout[h]))
time.sleep(.1)
self.connection.privmsg(nick, 'List of listeners: {}'.format(', '.join([l for l in plugin.lstnrs])))
return None
def call_internal_commands(self, channel, nick, cmd, text, arg, c):
if not cmd.startswith(self.cmd_prefix):
return None
else:
cmd = cmd[len(self.cmd_prefix):]
output = None
if nick in self.ops:
op = True
else:
op = False
if cmd == 'join' and op:
try:
c.join(*arg.split())
self.logger.info('joining {} per request of {}'.format(arg, nick))
output = plugin.message('{}: joined {}'.format(nick, arg.split()[0]))
except:
self.logger.exception('issue with join command: {}join #channel <channel key>'.format(self.cmd_prefix))
elif cmd == 'quit' and op:
self.logger.info('quitting per request of {}'.format(nick))
if not arg:
arg = "See y'all later!"
c.quit(arg)
quit()
elif cmd == 'help' and not self.disable_help:
self.call_help(nick, op)
elif cmd == 'reload' and op:
self.logger.info('reloading plugins per request of {}'.format(nick))
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix)
output = plugin.message('Plugins reloaded')
elif cmd == 'enable' and op:
if arg in plugin.plugins:
if plugin.plugins[arg].enabled:
output = plugin.message("{}: '{}' already enabled".format(nick, arg))
else:
plugin.plugins[arg].enable()
output = plugin.message("{}: '{}' enabled!".format(nick, arg))
else:
output = plugin.message("{}: '{}' not found".format(nick, arg))
elif cmd == 'disable' and op:
if arg in plugin.plugins:
if not plugin.plugins[arg].enabled:
output = plugin.message("{}: '{}' already disabled".format(nick, arg))
else:
plugin.plugins[arg].disable()
output = plugin.message("{}: '{}' disabled!".format(nick, arg))
elif cmd == 'op' and op:
for o in arg.split(' '):
self.ops.append(o)
output = plugin.message('{}: {} added as op'.format(nick, arg))
elif cmd == 'deop' and op:
for o in arg.split(' '):
self.ops = [i for i in self.ops if i != o]
output = plugin.message('{}: {} removed as op'.format(nick, arg))
elif cmd == 'ops' and op:
output = plugin.message('current ops: {}'.format(', '.join(self.ops)))
elif cmd == 'ban' and op:
for o in arg.split(' '):
self.banned_users.append(o)
output = plugin.message('{}: banned {}'.format(nick, arg))
elif cmd == 'unban' and op:
for o in arg.split(' '):
self.banned_users = [i for i in self.banned_users if i != o]
output = plugin.message('{}: removed ban for {}'.format(nick, arg))
elif cmd == 'banlist':
output = plugin.message('currently banned: {}'.format(', '.join(self.banned_users)))
return output
def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, arg, msg_type):
output = None
if cmd in plugin.cmds:
try:
if plugin.cmds[cmd].ops and nick not in self.ops:
if plugin.cmds[cmd].ops_msg:
output = plugin.message(plugin.cmds[cmd].ops_msg)
elif plugin.cmds[cmd].enabled:
self.logger.debug('executing {}'.format(cmd))
output = plugin.cmds[cmd].run(self.Message(
bot=self,
channel=chan,
cmd=cmd,
nick_list=nick_list,
nick=nick,
arg=arg,
privmsg=privmsg,
action=action,
notice=notice,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger,
msg_type=msg_type
))
except Exception:
self.logger.exception('issue with command {}'.format(cmd))
else:
for lstnr in plugin.lstnrs:
if plugin.lstnrs[lstnr].enabled:
try:
self.logger.debug('whispering to listener: {}'.format(lstnr))
listen_output = plugin.lstnrs[lstnr].run(self.Message(
bot=self,
channel=chan,
text=text,
nick_list=nick_list,
nick=nick,
privmsg=privmsg,
action=action,
notice=notice,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger,
msg_type=msg_type
))
if listen_output:
output = listen_output
except Exception:
self.logger.exception('issue with listener {}'.format(lstnr))
if output:
self.logger.debug(f'returning output: {output.msg}')
return output
def process_event(self, c, e):
nick = e.source.nick
text = e.arguments[0]
if nick == self.bot_nick:
pass
if e.arguments:
text = e.arguments[0]
else:
text = ''
if e.type == 'privmsg' or e.type == 'pubmsg':
msg_type = 'message'
else:
msg_type = e.type
if e.target == self.bot_nick:
chan = nick
nick_list = [nick]
@ -146,79 +274,67 @@ class Bot(irc.bot.SingleServerIRCBot):
arg = ''.join([i + ' ' for i in text.split(' ')[1:]]).strip()
else:
arg = ''
output = None
if cmd == '!join' and nick in self.ops:
self.logger.info('joining {} per request of {}'.format(arg, nick))
c.join(arg)
c.privmsg(chan, '{}: joined {}'.format(nick, arg))
elif cmd == '!quit' and nick in self.ops:
self.logger.info('quitting per request of {}'.format(nick))
c.quit("See y'all later!")
quit()
elif cmd == '!help':
helplist = sorted([i for i in pinhook.plugin.cmds])
msg = ', '.join(helplist)
c.privmsg(chan, 'Available commands: {}'.format(msg))
elif cmd == '!reload' and nick in self.ops:
self.logger.info('reloading plugins per request of {}'.format(nick))
self.load_plugins()
c.privmsg(chan, 'Plugins reloaded')
elif cmd in pinhook.plugin.cmds:
try:
output = pinhook.plugin.cmds[cmd](self.Message(
channel=chan,
cmd=cmd,
nick_list=nick_list,
nick=nick,
arg=arg,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger
))
if output:
self.process_output(c, chan, output)
except Exception as e:
self.logger.exception('issue with command {}'.format(cmd))
else:
for lstnr in pinhook.plugin.lstnrs:
try:
output = pinhook.plugin.lstnrs[lstnr](self.Message(
channel=chan,
text=text,
nick_list=nick_list,
nick=nick,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger
))
if output:
self.process_output(c, chan, output)
except Exception as e:
self.logger.exception('issue with listener {}'.format(lstnr))
output = self.call_internal_commands(chan, nick, cmd, text, arg, c)
if not output:
plugin_info = {
'chan': chan,
'cmd': cmd,
'text': text,
'nick_list': nick_list,
'nick': nick,
'arg': arg,
'privmsg': c.privmsg,
'action': c.action,
'notice': c.notice,
'msg_type': msg_type
}
output = self.call_plugins(**plugin_info)
if output:
self.logger.debug(f'sending output: {output.msg}')
self.process_output(c, chan, output)
def process_output(self, c, chan, output):
if not output.msg:
return
for msg in output.msg:
if output.msg_type == 'message':
if output.msg_type == plugin.OutputType.Message:
self.logger.debug('output message: {}'.format(msg))
c.privmsg(chan, msg)
elif output.msg_type == 'action':
try:
c.privmsg(chan, msg)
except irc.client.MessageTooLong:
self.logger.error('output message too long: {}'.format(msg))
break
elif output.msg_type == plugin.OutputType.Action:
self.logger.debug('output action: {}'.format(msg))
c.action(chan, msg)
try:
c.action(chan, msg)
except irc.client.MessageTooLong:
self.logger.error('output message too long: {}'.format(msg))
break
else:
self.logger.warning("Unsupported output type '{}'".format(output.msg_type))
time.sleep(.5)
class TwitchBot(Bot):
def __init__(self, nickname, channel, token, plugin_dir='plugins', log_level='info', ops=[]):
def __init__(self, nickname, channel, token, **kwargs):
self.port = kwargs.get('port', 6667)
self.ops = kwargs.get('ops', [])
self.plugin_dir = kwargs.get('plugin_dir', 'plugins')
self.log_level = kwargs.get('log_level', 'info')
self.log_file = kwargs.get('log_file', None)
self.server_pass = kwargs.get('server_pass', None)
self.cmd_prefix = kwargs.get('cmd_prefix', '!')
self.use_prefix_for_plugins = kwargs.get('use_prefix_for_plugins', False)
self.disable_help = kwargs.get('disable_help', False)
self.banned_users = kwargs.get('banned_users', [])
self.bot_nick = nickname
self.start_logging(log_level)
self.start_logging()
self.channel = channel
self.plugin_dir = plugin_dir
self.ops = ops
server = 'irc.twitch.tv'
port = 6667
self.logger.info('Joining Twitch Server')
irc.bot.SingleServerIRCBot.__init__(self, [(server, port, 'oauth:'+token)], nickname, nickname)
self.load_plugins()
irc.bot.SingleServerIRCBot.__init__(self, [('irc.twitch.tv', 6667, 'oauth:'+token)], nickname, nickname)
self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()}
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix)
def on_welcome(self, c, e):
self.logger.info('requesting permissions')
@ -227,4 +343,3 @@ class TwitchBot(Bot):
c.cap('REQ', ':twitch.tv/commands')
self.logger.info('Joining channel ' + self.channel)
c.join(self.channel)

60
pinhook/cli.py Normal file
View File

@ -0,0 +1,60 @@
import click
from .bot import Bot
from marshmallow import Schema, fields, validate, INCLUDE
class Config(Schema):
nickname = fields.Str(required=True)
channels = fields.List(fields.Str(), required=True)
server = fields.Str(required=True)
port = fields.Int()
ops = fields.List(fields.Str())
ssl_required = fields.Bool()
plugin_dir = fields.Str()
ns_pass = fields.Str()
log_level = fields.Str(validate=validate.OneOf(['debug', 'warn', 'info', 'off', 'error']))
server_pass = fields.Str()
class Meta:
unknown = INCLUDE
def read_conf(config, conf_format):
schema = Config()
if not conf_format:
if config.name.endswith('.json'):
conf_format = 'json'
elif config.name.endswith(('.yaml', '.yml')):
conf_format = 'yaml'
elif config.name.endswith(('.toml', '.tml')):
conf_format = 'toml'
else:
raise click.ClickException('Could not detect file format, please supply using --format option')
if conf_format == 'json':
import json
to_json = json.loads(config.read())
output = schema.load(to_json)
elif conf_format == 'yaml':
try:
import yaml
except ImportError:
raise click.ClickException('yaml not installed, please use `pip3 install pinhook[yaml]` to install')
else:
to_yaml = yaml.load(config.read(), Loader=yaml.FullLoader)
output = schema.load(to_yaml)
elif conf_format == 'toml':
try:
import toml
except ImportError:
raise click.ClicKException('toml not installed, please use `pip3 install pinhook[toml]` to install')
else:
to_toml = toml.load(config.name)
output = schema.load(to_toml)
return output
@click.command()
@click.argument('config', type=click.File('rb'))
@click.option('--format', '-f', 'conf_format', type=click.Choice(['json', 'yaml', 'toml']))
def cli(config, conf_format):
config = read_conf(config, conf_format)
bot = Bot(**config)
bot.start()

14
pinhook/log.py Normal file
View File

@ -0,0 +1,14 @@
import logging
logger = logging.getLogger('bot')
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(module)s - %(message)s')
# Set console logger
streamhandler = logging.StreamHandler()
streamhandler.setFormatter(formatter)
logger.addHandler(streamhandler)
def set_log_file(filename):
# Set file logger
filehandler = logging.FileHandler(filename)
filehandler.setFormatter(formatter)
logger.addHandler(filehandler)

View File

@ -1,6 +1,18 @@
from enum import Enum
from functools import wraps
import importlib
import os
from .log import logger
plugins = {}
cmds = {}
lstnrs = {}
class OutputType(Enum):
Message = 'message'
Action = 'action'
class Output:
def __init__(self, msg_type, msg):
@ -14,36 +26,150 @@ class Output:
return msg
def action(msg):
return Output('action', msg)
class _BasePlugin:
enabled = True
logger = logger
def enable(self):
self.enabled = True
def disable(self):
self.enabled = False
class Listener(_BasePlugin):
def __init__(self, name, run=None):
self.name = name
if run:
self.run = run
self._add_listener()
def __str__(self):
return self.name
def run(self):
pass
def _add_listener(self):
lstnrs[self.name] = self
plugins[self.name] = self
class Command(_BasePlugin):
def __init__(self, cmd, **kwargs):
self.cmd = cmd
self.help_text = kwargs.get('help_text', 'N/A')
self.ops = kwargs.get('ops', False)
self.ops_msg = kwargs.get('ops_msg', '')
self.run = kwargs.get('run', self.run)
self._add_command()
def __str__(self):
return self.cmd
def run(self, msg):
pass
def _enable_ops(self, ops_msg):
self.ops = True
self.ops_msg = ops_msg
def _update_plugin(self, **kwargs):
self.help_text = kwargs.get('help_text', 'N/A')
self.run = kwargs.get('run', self.run)
def _add_command(self):
cmds[self.cmd] = self
plugins[self.cmd] = self
def action(msg):
return Output(OutputType.Action, msg)
def message(msg):
return Output('message', msg)
return Output(OutputType.Message, msg)
def _add_command(command, help_text, func, ops=False, ops_msg=''):
if command not in cmds:
Command(command, help_text=help_text, ops=ops, ops_msg=ops_msg, run=func)
else:
cmds[command]._update_plugin(help_text=help_text, run=func)
def _add_plugin(command, func):
cmds[command] = func
def _ops_plugin(command, ops_msg, func):
if command not in cmds:
Command(command, ops=True, ops_msg=ops_msg)
else:
cmds[command]._enable_ops(ops_msg)
def _add_listener(name, func):
lstnrs[name] = func
Listener(name, run=func)
def clear_plugins():
cmds.clear()
lstnrs.clear()
def load_plugins(plugin_dir, use_prefix=False, cmd_prefix='!'):
# i'm not sure why i need this but i do
global cmds
global plugins
global lstnrs
#check for all the disabled plugins so that we don't re-enable them
disabled_plugins = [i for i in plugins if not plugins[i].enabled]
logger.debug(disabled_plugins)
# clear plugin list to ensure no old plugins remain
logger.info('clearing plugin cache')
clear_plugins()
# ensure plugin folder exists
logger.info('checking plugin directory')
if not os.path.exists(plugin_dir):
logger.info('plugin directory {} not found, creating'.format(plugin_dir))
os.makedirs(plugin_dir)
# load all plugins
for m in os.listdir(plugin_dir):
if m.endswith('.py'):
try:
name = m[:-3]
logger.info('loading plugin {}'.format(name))
spec = importlib.machinery.PathFinder().find_spec(name, [plugin_dir])
spec.loader.load_module()
except Exception:
logger.exception('could not load plugin')
# gather all commands and listeners
if use_prefix: # use prefixes if needed
cmds = {cmd_prefix + k: v for k,v in cmds.items()}
for p in plugins:
if p in disabled_plugins:
plugins[p].disable()
for cmd in cmds:
logger.debug('adding command {}'.format(cmd))
for lstnr in lstnrs:
logger.debug('adding listener {}'.format(lstnr))
def register(command):
def command(command, help_text='N/A', ops=False, ops_msg=''):
@wraps(command)
def register_for_command(func):
_add_plugin(command, func)
_add_command(command, help_text, func, ops=ops, ops_msg=ops_msg)
return func
return register_for_command
def register(command, help_text='N/A'):
logger.warn('@register decorator has been deprecated in favor of @command. This will cause errors in future versions.')
@wraps(command)
def register_for_command(func):
_add_command(command, help_text, func)
return func
return register_for_command
def listener(name):
def register_as_listener(func):
_add_listener(name, func)
return func
return register_as_listener
def ops(command, msg=None):
logger.warn('use of the @ops decorator has been deprecated in favor of using the @command decorator with the ops and ops_msg options. Use will cause errors in future versions.')
@wraps(command)
def register_ops_command(func):
_ops_plugin(command, msg, func)
return func
return register_ops_command

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Note: To use the 'upload' functionality of this file, you must:
@ -17,12 +17,21 @@ DESCRIPTION = 'a pluggable irc bot framework in python'
URL = 'https://github.com/archangelic/pinhook'
EMAIL = 'mhancock@archangelic.space'
AUTHOR = 'M. Hancock'
VERSION = None
# What packages are required for this module to be executed?
REQUIRED = [
'irc',
'enum34',
'click',
'marshmallow',
]
EXTRAS = {
'toml': ['toml'],
'yaml': ['pyyaml']
}
# The rest you shouldn't have to touch too much :)
# ------------------------------------------------
# Except, perhaps the License and Trove Classifiers!
@ -32,9 +41,18 @@ here = os.path.abspath(os.path.dirname(__file__))
# Import the README and use it as the long-description.
# Note: this will only work if 'README.rst' is present in your MANIFEST.in file!
with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f:
with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f:
long_description = '\n' + f.read()
# Load the package's __version__.py module as a dictionary.
about = {}
if not VERSION:
project_slug = NAME.lower().replace("-", "_").replace(" ", "_")
with open(os.path.join(here, project_slug, '__version__.py')) as f:
exec(f.read(), about)
else:
about['__version__'] = VERSION
class UploadCommand(Command):
"""Support setup.py upload."""
@ -72,28 +90,33 @@ class UploadCommand(Command):
# Where the magic happens:
setup(
name=NAME,
version='1.4.3',
version=about['__version__'],
description=DESCRIPTION,
long_description=long_description,
long_description_content_type='text/markdown',
author=AUTHOR,
author_email=EMAIL,
url=URL,
packages=['pinhook'],
entry_points={
'console_scripts':
['pinhook=pinhook.cli:cli']
},
install_requires=REQUIRED,
extras_require=EXTRAS,
include_package_data=True,
license='MIT',
classifiers=[
# Trove classifiers
# Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
'License :: OSI Approved :: MIT License',
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
],
# $ setup.py publish support.
cmdclass={