Compare commits

..

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

13 changed files with 159 additions and 526 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/

115
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,25 @@ 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
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. 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. In your chosen plugins directory ("plugins" by default) make a python file with a function. You use the `@pinhook.plugin.register` 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,7 +74,6 @@ 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`: (for command plugins) 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`: (for command plugins) all the trailing text after the command. This is what you will use to get optional information for the command
@ -154,35 +84,32 @@ The `Message` object has the following attributes:
* `logger`: instance of `Bot`'s logger * `logger`: instance of `Bot`'s logger
* `datetime`: aware `datetime.datetime` object when the `Message` object was created * `datetime`: aware `datetime.datetime` object when the `Message` object was created
* `timestamp`: float for the unix timestamp 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: It also contains the following IRC functions:
* `privmsg`: send a message to an arbitrary channel or user * `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`) * `action`: same as privmsg, but does a CTCP action. (i.e., `/me does a thing`)
* `notice`: send a notice * `notice`: send a notice
You can optionally set a command to be used only by ops You can optionally use the `@pinhook.plugin.ops` decorator to denote that a command should only be executable by a bot op.
* If you specify the optional second argument, it will be displayed when a non-op attempts to execute the command
The function will need to be structured as such: The function will need to be structured as such:
```python ```python
@pinhook.plugin.command('!test', ops=True, ops_msg='This command can only be run by an op') @pinhook.plugin.register('!test')
@pinhook.plugin.ops('!test', 'Only ops can run this command!')
def test_plugin(msg): def test_plugin(msg):
return pinhook.plugin.message('This was run by an op!') 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: **OR**
The plugin function can 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: Here is a list of live bots using pinhook:
* [pinhook-tilde](https://github.com/archangelic/pinhook-tilde) - fun bot for tilde.town * [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 * [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 +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,10 @@
from collections import OrderedDict
from datetime import datetime, timezone from datetime import datetime, timezone
import imp
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,20 +13,6 @@ 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.port = kwargs.get('port', 6667)
self.ops = kwargs.get('ops', []) self.ops = kwargs.get('ops', [])
@ -36,12 +21,9 @@ class Bot(irc.bot.SingleServerIRCBot):
self.ns_pass = kwargs.get('ns_pass', None) self.ns_pass = kwargs.get('ns_pass', None)
self.nickserv = kwargs.get('nickserv', 'NickServ') self.nickserv = kwargs.get('nickserv', 'NickServ')
self.log_level = kwargs.get('log_level', 'info') 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.server_pass = kwargs.get('server_pass', None)
self.cmd_prefix = kwargs.get('cmd_prefix', '!') self.cmd_prefix = kwargs.get('cmd_prefix', '!')
self.use_prefix_for_plugins = kwargs.get('use_prefix_for_plugins', False) 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,13 +31,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.datetime = datetime.now(timezone.utc)
self.timestamp = self.datetime.timestamp() self.timestamp = self.datetime.timestamp()
self.channel = channel self.channel = channel
@ -67,34 +49,68 @@ 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 start_logging(self, level):
self.logger = log.logger if level == 'error':
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 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
if self.use_prefix_for_plugins: # use prefixes if needed
pinhook.plugin.cmds = {self.cmd_prefix + k: v for k,v in pinhook.plugin.cmds.items()}
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')
@ -112,97 +128,43 @@ 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, op):
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 if op or not ('ops' in pinhook.plugin.cmds[i] and pinhook.plugin.cmds[i]['ops'])])
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 == self.cmd_prefix + 'join' and op:
try: c.join(*arg.split())
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 = self.output_message('{}: joined {}'.format(nick, arg.split()[0]))
output = plugin.message('{}: joined {}'.format(nick, arg.split()[0])) elif cmd == self.cmd_prefix + 'quit' and op:
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)) 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 == self.cmd_prefix + 'help':
self.call_help(nick, op) output = self.call_help(op)
elif cmd == 'reload' and op: elif cmd == self.cmd_prefix + '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: if 'ops' in pinhook.plugin.cmds[cmd] and nick not in self.ops:
if plugin.cmds[cmd].ops_msg: if pinhook.plugin.cmds[cmd]['ops_msg']:
output = plugin.message(plugin.cmds[cmd].ops_msg) output = self.output_message(pinhook.plugin.cmds[cmd]['ops_msg'])
elif plugin.cmds[cmd].enabled: else:
self.logger.debug('executing {}'.format(cmd)) self.logger.debug('executing {}'.format(cmd))
output = plugin.cmds[cmd].run(self.Message( output = pinhook.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,50 +175,37 @@ 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))
self.logger.debug('whispering to listener: {}'.format(lstnr)) listen_output = pinhook.plugin.lstnrs[lstnr](self.Message(
listen_output = plugin.lstnrs[lstnr].run(self.Message( channel=chan,
bot=self, text=text,
channel=chan, nick_list=nick_list,
text=text, nick=nick,
nick_list=nick_list, privmsg=privmsg,
nick=nick, action=action,
privmsg=privmsg, notice=notice,
action=action, botnick=self.bot_nick,
notice=notice, ops=self.ops,
botnick=self.bot_nick, logger=self.logger
ops=self.ops, ))
logger=self.logger, if listen_output:
msg_type=msg_type output = listen_output
)) except Exception as e:
if listen_output: self.logger.exception('issue with listener {}'.format(lstnr))
output = listen_output
except Exception:
self.logger.exception('issue with listener {}'.format(lstnr))
if output: if output:
self.logger.debug(f'returning output: {output.msg}') 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: text = e.arguments[0]
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: if e.target == self.bot_nick:
chan = nick chan = nick
nick_list = [nick] nick_list = [nick]
@ -286,7 +235,6 @@ 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:
@ -297,44 +245,31 @@ class Bot(irc.bot.SingleServerIRCBot):
if not output.msg: if not output.msg:
return 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) elif output.msg_type == pinhook.plugin.OutputType.Action:
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)) 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 +278,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,11 @@
from enum import Enum from enum import Enum
from functools import wraps 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,139 +23,47 @@ 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=''):
def _add_plugin(command, help_text, func):
if command not in cmds: if command not in cmds:
Command(command, help_text=help_text, ops=ops, ops_msg=ops_msg, run=func) cmds[command] = {}
else: cmds[command].update({
cmds[command]._update_plugin(help_text=help_text, run=func) 'run': func,
'help': help_text
})
def _ops_plugin(command, ops_msg, func): def _ops_plugin(command, ops_msg, func):
if command not in cmds: if command not in cmds:
Command(command, ops=True, ops_msg=ops_msg) cmds[command] = {}
else: cmds[command].update({
cmds[command]._enable_ops(ops_msg) 'ops': True,
'ops_msg': 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, help_text=None):
@wraps(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, help_text, 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):
@ -167,7 +72,6 @@ def listener(name):
return register_as_listener return register_as_listener
def ops(command, msg=None): 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) @wraps(command)
def register_ops_command(func): def register_ops_command(func):
_ops_plugin(command, msg, func) _ops_plugin(command, msg, func)

View File

@ -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!
@ -44,15 +36,6 @@ here = os.path.abspath(os.path.dirname(__file__))
with io.open(os.path.join(here, 'README.md'), encoding='utf-8') as f: with io.open(os.path.join(here, 'README.md'), 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,7 +73,7 @@ class UploadCommand(Command):
# Where the magic happens: # Where the magic happens:
setup( setup(
name=NAME, name=NAME,
version=about['__version__'], version='1.6.2',
description=DESCRIPTION, description=DESCRIPTION,
long_description=long_description, long_description=long_description,
long_description_content_type='text/markdown', long_description_content_type='text/markdown',
@ -98,12 +81,7 @@ setup(
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=[