Compare commits

..

No commits in common. "master" and "v1.5.0" have entirely different histories.

15 changed files with 302 additions and 590 deletions

4
.github/FUNDING.yml vendored
View File

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

View File

@ -1,30 +0,0 @@
# 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,6 +99,3 @@ ENV/
# mypy # mypy
.mypy_cache/ .mypy_cache/
# etc
.vscode/

View File

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

136
README.md
View File

@ -1,78 +1,15 @@
# pinhook # 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) [![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 the pluggable python framework for IRC bots and Twitch bots
* [Installation](#installation) ## Tutorial
* [Creating an IRC Bot](#creating-an-irc-bot) ### Installation
* [From Config File](#from-config-file) ```
* [From Python File](#from-python-file) $ pip install pinhook
* [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: To create the bot, just create a python file with the following:
```python ```python
@ -89,17 +26,15 @@ bot.start()
This will start a basic bot and look for plugins in the 'plugins' directory to add functionality. This will start a basic bot and look for plugins in the 'plugins' directory to add functionality.
Optional arguments are: Optional arguments are:
* `port`: choose a custom port to connect to the server (default: 6667)
* `port`: (default: `6667`) choose a custom port to connect to the server * `ops`: list of operators who can do things like make the bot join other channels or quit (default: empty list)
* `ops`: (default: empty list) list of operators who can do things like make the bot join other channels or quit * `plugin_dir`: directory where the bot should look for plugins (default: "plugins")
* `plugin_dir`: (default: `"plugins"`) directory where the bot should look for plugins * `log_level`: string indicating logging level. Logging can be disabled by setting this to "off". (default: "info")
* `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 * `ns_pass`: this is the password to identify with nickserv
* `server_pass`: password for the server * `server_pass`: password for the server
* `ssl_required`: (default: `False`) boolean to turn ssl on or off * `ssl_required`: 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 Pinhook has a baked in way to connect directly to a twitch channel
```python ```python
@ -112,29 +47,23 @@ bot = TwitchBot(
) )
bot.start() bot.start()
``` ```
This function has far less options, as the server, port, and ssl are already handled by twitch. This function has far less options, as the server, port, and ssl are already handled by twitch.
Optional aguments are: Optional aguments are:
* `ops` * `ops`
* `plugin_dir` * `plugin_dir`
* `log_level` * `log_level`
These options are the same for both IRC and Twitch These options are the same for both IRC and Twitch
## Creating plugins ### 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.
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: The function will need to be structured as such:
```python ```python
import pinhook.plugin import pinhook.plugin
@pinhook.plugin.command('!test') @pinhook.plugin.register('!test')
def test_plugin(msg): def test_plugin(msg):
message = '{}: this is a test!'.format(msg.nick) message = '{}: this is a test!'.format(msg.nick)
return pinhook.plugin.message(message) return pinhook.plugin.message(message)
@ -143,46 +72,19 @@ def test_plugin(msg):
The function will need to accept a single argument in order to accept a `Message` object from the bot. 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: 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 * `nick`: the user who triggered 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 * `arg`: 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 * `channel`: the channel where the command was initiated
* `ops`: the list of bot operators * `ops`: the list of bot operators
* `botnick`: the nickname of the bot * `botnick`: the nickname of the bot
* `logger`: instance of `Bot`'s logger * `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.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`) * `pinhook.plugin.action`: CTCP action in the channel where command was triggered (basically like using `/me does a thing`)
## Examples ## Examples
There are some basic examples in the `examples` directory in this repository. There are some basic examples in the `examples` directory in this repository.
Here is a list of live bots using pinhook: For a live and maintained bot running the current version of pinhook see [pinhook-tilde](https://github.com/archangelic/pinhook-tilde).
* [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)

137
README.rst Normal file
View File

@ -0,0 +1,137 @@
pinhook
=======
|Supported Python versions| |Package License| |PyPI package format|
|Package development status| |With love from tilde.town|
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>`__.
.. |Supported Python versions| image:: https://img.shields.io/pypi/pyversions/pinhook.svg
:target: https://pypi.org/project/pinhook
.. |Package License| image:: https://img.shields.io/pypi/l/pinhook.svg
:target: https://github.com/archangelic/pinhook/blob/master/LICENSE
.. |PyPI package format| image:: https://img.shields.io/pypi/format/pinhook.svg
:target: https://pypi.org/project/pinhook
.. |Package development status| image:: https://img.shields.io/pypi/status/pinhook.svg
:target: https://pypi.org/project/pinhook
.. |With love from tilde.town| image:: https://img.shields.io/badge/with%20love%20from-tilde%20town-e0b0ff.svg
:target: https://tilde.town

View File

@ -1 +1 @@
theme: jekyll-theme-minimal theme: jekyll-theme-midnight

View File

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

View File

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

View File

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

View File

@ -1,11 +1,9 @@
from collections import OrderedDict import imp
from datetime import datetime, timezone
import logging import logging
import os
import ssl import ssl
import time import time
import pinhook.plugin
from . import log
from . import plugin
import irc.bot import irc.bot
@ -14,34 +12,8 @@ irc.client.ServerConnection.buffer_class.errors = 'replace'
class Bot(irc.bot.SingleServerIRCBot): 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): def __init__(self, channels, nickname, server, **kwargs):
self.port = kwargs.get('port', 6667) self.set_kwargs(**kwargs)
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: if self.ssl_required:
factory = irc.connection.Factory(wrapper=ssl.wrap_socket) factory = irc.connection.Factory(wrapper=ssl.wrap_socket)
irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname, connect_factory=factory) irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname, connect_factory=factory)
@ -49,15 +21,13 @@ class Bot(irc.bot.SingleServerIRCBot):
irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname) irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname)
self.chanlist = channels self.chanlist = channels
self.bot_nick = nickname self.bot_nick = nickname
self.start_logging() self.start_logging(self.log_level)
self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()} self.output_message = pinhook.plugin.message
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix) self.output_action = pinhook.plugin.action
self.load_plugins()
class Message: class Message:
def __init__(self, bot, channel, nick, botnick, ops, logger, action, privmsg, notice, msg_type, cmd=None, arg=None, text=None, nick_list=None): def __init__(self, channel, nick, botnick, ops, logger, action, privmsg, notice, 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.channel = channel
self.nick = nick self.nick = nick
self.nick_list = nick_list self.nick_list = nick_list
@ -67,41 +37,90 @@ class Bot(irc.bot.SingleServerIRCBot):
self.action = action self.action = action
self.privmsg = privmsg self.privmsg = privmsg
self.notice = notice self.notice = notice
self.msg_type = msg_type
if cmd: if cmd:
self.cmd = cmd self.cmd = cmd
self.arg = arg self.arg = arg
if text != None: if text:
self.text = text self.text = text
if not (cmd==None or text==None): if not (cmd or text):
raise TypeError('missing cmd or text parameter') raise TypeError('missing cmd or text parameter')
def start_logging(self): def set_kwargs(self, **kwargs):
self.logger = log.logger kwarguments = {
if self.log_file: 'port': 6667,
log.set_log_file(self.log_file) 'ops': [],
else: 'plugin_dir': 'plugins',
log.set_log_file('{}.log'.format(self.bot_nick)) 'ssl_required': False,
if self.log_level == 'error': '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':
level = logging.ERROR level = logging.ERROR
elif self.log_level == 'warning': elif level == 'warning':
level = logging.WARNING level = logging.WARNING
elif self.log_level == 'info': elif level == 'info':
level = logging.INFO level = logging.INFO
elif self.log_level == 'debug': elif level == 'debug':
level = logging.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 # Set levels
if self.log_level != "off": if level != "off":
self.logger.setLevel(level) 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!') 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): def on_welcome(self, c, e):
if self.ns_pass: if self.ns_pass:
self.logger.info('identifying with nickserv') self.logger.info('identifying with nickserv')
c.privmsg(self.nickserv, 'identify {}'.format(self.ns_pass)) c.privmsg(self.nickserv, 'identify {}'.format(self.ns_pass))
for channel in self.chanlist: for channel in self.chanlist:
self.logger.info('joining channel {}'.format(channel.split()[0])) self.logger.info('joining channel {}'.format(channel))
c.join(*channel.split()) c.join(channel)
def on_pubmsg(self, c, e): def on_pubmsg(self, c, e):
self.process_event(c, e) self.process_event(c, e)
@ -112,97 +131,38 @@ class Bot(irc.bot.SingleServerIRCBot):
def on_action(self, c, e): def on_action(self, c, e):
self.process_event(c, e) self.process_event(c, e)
def call_help(self, nick, op): def call_help(self):
cmds = {k:v.help_text for k,v in plugin.cmds.items() if not plugin.cmds[k].ops} helplist = sorted([i for i in pinhook.plugin.cmds])
cmds.update({self.cmd_prefix + 'help': 'returns this output to private message'}) msg = ', '.join(helplist)
if op: return self.output_message('Available commands: {}'.format(msg))
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): 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 output = None
if nick in self.ops: if nick in self.ops:
op = True op = True
else: else:
op = False op = False
if cmd == 'join' and op: if cmd == '!join' and op:
try: c.join(arg)
c.join(*arg.split())
self.logger.info('joining {} per request of {}'.format(arg, nick)) self.logger.info('joining {} per request of {}'.format(arg, nick))
output = plugin.message('{}: joined {}'.format(nick, arg.split()[0])) output = self.output_message('{}: joined {}'.format(nick, arg))
except: elif cmd == '!quit' and op:
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)) self.logger.info('quitting per request of {}'.format(nick))
if not arg: c.quit("See y'all later!")
arg = "See y'all later!"
c.quit(arg)
quit() quit()
elif cmd == 'help' and not self.disable_help: elif cmd == '!help':
self.call_help(nick, op) output = self.call_help()
elif cmd == 'reload' and op: elif cmd == '!reload' and op:
self.logger.info('reloading plugins per request of {}'.format(nick)) 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) self.load_plugins()
output = plugin.message('Plugins reloaded') output = self.output_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 return output
def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, arg, msg_type): def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, arg):
output = None output = None
if cmd in plugin.cmds: if cmd in pinhook.plugin.cmds:
try: try:
if plugin.cmds[cmd].ops and nick not in self.ops: output = pinhook.plugin.cmds[cmd](self.Message(
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, channel=chan,
cmd=cmd, cmd=cmd,
nick_list=nick_list, nick_list=nick_list,
@ -213,18 +173,14 @@ class Bot(irc.bot.SingleServerIRCBot):
notice=notice, notice=notice,
botnick=self.bot_nick, botnick=self.bot_nick,
ops=self.ops, ops=self.ops,
logger=self.logger, logger=self.logger
msg_type=msg_type
)) ))
except Exception: except Exception as e:
self.logger.exception('issue with command {}'.format(cmd)) self.logger.exception('issue with command {}'.format(cmd))
else: else:
for lstnr in plugin.lstnrs: for lstnr in pinhook.plugin.lstnrs:
if plugin.lstnrs[lstnr].enabled:
try: try:
self.logger.debug('whispering to listener: {}'.format(lstnr)) output = pinhook.plugin.lstnrs[lstnr](self.Message(
listen_output = plugin.lstnrs[lstnr].run(self.Message(
bot=self,
channel=chan, channel=chan,
text=text, text=text,
nick_list=nick_list, nick_list=nick_list,
@ -234,29 +190,15 @@ class Bot(irc.bot.SingleServerIRCBot):
notice=notice, notice=notice,
botnick=self.bot_nick, botnick=self.bot_nick,
ops=self.ops, ops=self.ops,
logger=self.logger, logger=self.logger
msg_type=msg_type
)) ))
if listen_output: except Exception as e:
output = listen_output
except Exception:
self.logger.exception('issue with listener {}'.format(lstnr)) self.logger.exception('issue with listener {}'.format(lstnr))
if output:
self.logger.debug(f'returning output: {output.msg}')
return output return output
def process_event(self, c, e): def process_event(self, c, e):
nick = e.source.nick nick = e.source.nick
if nick == self.bot_nick:
pass
if e.arguments:
text = e.arguments[0] 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: if e.target == self.bot_nick:
chan = nick chan = nick
nick_list = [nick] nick_list = [nick]
@ -286,55 +228,38 @@ class Bot(irc.bot.SingleServerIRCBot):
'privmsg': c.privmsg, 'privmsg': c.privmsg,
'action': c.action, 'action': c.action,
'notice': c.notice, 'notice': c.notice,
'msg_type': msg_type
} }
output = self.call_plugins(**plugin_info) output = self.call_plugins(**plugin_info)
if output: if output:
self.logger.debug(f'sending output: {output.msg}')
self.process_output(c, chan, output) self.process_output(c, chan, output)
def process_output(self, c, chan, output): def process_output(self, c, chan, output):
if not output.msg:
return
for msg in output.msg: for msg in output.msg:
if output.msg_type == plugin.OutputType.Message: if len(msg.encode('UTF-8')) > 512:
self.logger.error('output message too long: {}'.format(msg))
elif output.msg_type == pinhook.plugin.OutputType.Message:
self.logger.debug('output message: {}'.format(msg)) self.logger.debug('output message: {}'.format(msg))
try:
c.privmsg(chan, msg) c.privmsg(chan, msg)
except irc.client.MessageTooLong: elif output.msg_type == pinhook.plugin.OutputType.Action:
self.logger.error('output message too long: {}'.format(msg))
break
elif output.msg_type == plugin.OutputType.Action:
self.logger.debug('output action: {}'.format(msg)) self.logger.debug('output action: {}'.format(msg))
try:
c.action(chan, msg) c.action(chan, msg)
except irc.client.MessageTooLong:
self.logger.error('output message too long: {}'.format(msg))
break
else: else:
self.logger.warning("Unsupported output type '{}'".format(output.msg_type)) self.logger.warning("Unsupported output type '{}'".format(output.msg_type))
time.sleep(.5) time.sleep(.5)
class TwitchBot(Bot): class TwitchBot(Bot):
def __init__(self, nickname, channel, token, **kwargs): def __init__(self, nickname, channel, token, plugin_dir='plugins', log_level='info', ops=[]):
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.bot_nick = nickname
self.start_logging() self.start_logging(log_level)
self.channel = channel self.channel = channel
self.plugin_dir = plugin_dir
self.ops = ops
server = 'irc.twitch.tv'
port = 6667
self.logger.info('Joining Twitch Server') self.logger.info('Joining Twitch Server')
irc.bot.SingleServerIRCBot.__init__(self, [('irc.twitch.tv', 6667, 'oauth:'+token)], nickname, nickname) irc.bot.SingleServerIRCBot.__init__(self, [(server, port, 'oauth:'+token)], nickname, nickname)
self.internal_commands = {self.cmd_prefix + k: v for k,v in self.internal_commands.items()} self.load_plugins()
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix)
def on_welcome(self, c, e): def on_welcome(self, c, e):
self.logger.info('requesting permissions') self.logger.info('requesting permissions')
@ -343,3 +268,4 @@ class TwitchBot(Bot):
c.cap('REQ', ':twitch.tv/commands') c.cap('REQ', ':twitch.tv/commands')
self.logger.info('Joining channel ' + self.channel) self.logger.info('Joining channel ' + self.channel)
c.join(self.channel) c.join(self.channel)

View File

@ -1,60 +0,0 @@
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()

View File

@ -1,14 +0,0 @@
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,14 +1,10 @@
from enum import Enum from enum import Enum
from functools import wraps
import importlib
import os
from .log import logger
plugins = {}
cmds = {} cmds = {}
lstnrs = {} lstnrs = {}
class OutputType(Enum): class OutputType(Enum):
Message = 'message' Message = 'message'
Action = 'action' Action = 'action'
@ -26,150 +22,36 @@ class Output:
return msg return 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): def action(msg):
return Output(OutputType.Action, msg) return Output(OutputType.Action, msg)
def message(msg): def message(msg):
return Output(OutputType.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 _ops_plugin(command, ops_msg, func): def _add_plugin(command, func):
if command not in cmds: cmds[command] = func
Command(command, ops=True, ops_msg=ops_msg)
else:
cmds[command]._enable_ops(ops_msg)
def _add_listener(name, func): def _add_listener(name, func):
Listener(name, run=func) lstnrs[name] = func
def clear_plugins(): def clear_plugins():
cmds.clear() cmds.clear()
lstnrs.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 command(command, help_text='N/A', ops=False, ops_msg=''): def register(command):
@wraps(command)
def register_for_command(func): def register_for_command(func):
_add_command(command, help_text, func, ops=ops, ops_msg=ops_msg) _add_plugin(command, func)
return func return func
return register_for_command 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 listener(name):
def register_as_listener(func): def register_as_listener(func):
_add_listener(name, func) _add_listener(name, func)
return func return func
return register_as_listener 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 python3 #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Note: To use the 'upload' functionality of this file, you must: # Note: To use the 'upload' functionality of this file, you must:
@ -17,21 +17,13 @@ DESCRIPTION = 'a pluggable irc bot framework in python'
URL = 'https://github.com/archangelic/pinhook' URL = 'https://github.com/archangelic/pinhook'
EMAIL = 'mhancock@archangelic.space' EMAIL = 'mhancock@archangelic.space'
AUTHOR = 'M. Hancock' AUTHOR = 'M. Hancock'
VERSION = None
# What packages are required for this module to be executed? # What packages are required for this module to be executed?
REQUIRED = [ REQUIRED = [
'irc', 'irc',
'enum34', 'enum34',
'click',
'marshmallow',
] ]
EXTRAS = {
'toml': ['toml'],
'yaml': ['pyyaml']
}
# The rest you shouldn't have to touch too much :) # The rest you shouldn't have to touch too much :)
# ------------------------------------------------ # ------------------------------------------------
# Except, perhaps the License and Trove Classifiers! # Except, perhaps the License and Trove Classifiers!
@ -41,18 +33,9 @@ here = os.path.abspath(os.path.dirname(__file__))
# Import the README and use it as the long-description. # 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! # Note: this will only work if 'README.rst' is present in your MANIFEST.in file!
with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f:
long_description = '\n' + f.read() 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): class UploadCommand(Command):
"""Support setup.py upload.""" """Support setup.py upload."""
@ -90,20 +73,14 @@ class UploadCommand(Command):
# Where the magic happens: # Where the magic happens:
setup( setup(
name=NAME, name=NAME,
version=about['__version__'], version='1.5.0',
description=DESCRIPTION, description=DESCRIPTION,
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown',
author=AUTHOR, author=AUTHOR,
author_email=EMAIL, author_email=EMAIL,
url=URL, url=URL,
packages=['pinhook'], packages=['pinhook'],
entry_points={
'console_scripts':
['pinhook=pinhook.cli:cli']
},
install_requires=REQUIRED, install_requires=REQUIRED,
extras_require=EXTRAS,
include_package_data=True, include_package_data=True,
license='MIT', license='MIT',
classifiers=[ classifiers=[