Compare commits

...

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

115
README.md
View File

@ -1,15 +1,78 @@
# 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
The pluggable python framework for IRC bots and Twitch bots
## Tutorial
### Installation
```
$ pip install pinhook
* [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
@ -26,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
@ -47,25 +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
## 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.register` 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.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)
@ -74,6 +143,7 @@ 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`: (for command plugins) the command that triggered the function
* `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
@ -84,32 +154,35 @@ The `Message` object has the following attributes:
* `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 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
You can optionally set a command to be used only by ops
The function will need to be structured as such:
```python
@pinhook.plugin.register('!test')
@pinhook.plugin.ops('!test', 'Only ops can run this command!')
@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!')
```
**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.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.
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 +1 @@
theme: jekyll-theme-midnight
theme: jekyll-theme-minimal

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:

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,10 +1,11 @@
from collections import OrderedDict
from datetime import datetime, timezone
import imp
import logging
import os
import ssl
import time
import pinhook.plugin
from . import log
from . import plugin
import irc.bot
@ -13,6 +14,20 @@ 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.port = kwargs.get('port', 6667)
self.ops = kwargs.get('ops', [])
@ -21,9 +36,12 @@ class Bot(irc.bot.SingleServerIRCBot):
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)
@ -31,13 +49,13 @@ 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.output_message = pinhook.plugin.message
self.output_action = pinhook.plugin.action
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, action, privmsg, notice, 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
@ -49,68 +67,34 @@ class Bot(irc.bot.SingleServerIRCBot):
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 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
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):
if self.ns_pass:
self.logger.info('identifying with nickserv')
@ -128,43 +112,97 @@ class Bot(irc.bot.SingleServerIRCBot):
def on_action(self, c, e):
self.process_event(c, e)
def call_help(self, op):
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'])])
msg = ', '.join(helplist)
return self.output_message('Available commands: {}'.format(msg))
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 == self.cmd_prefix + 'join' and op:
c.join(*arg.split())
self.logger.info('joining {} per request of {}'.format(arg, nick))
output = self.output_message('{}: joined {}'.format(nick, arg.split()[0]))
elif cmd == self.cmd_prefix + 'quit' and op:
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))
c.quit("See y'all later!")
if not arg:
arg = "See y'all later!"
c.quit(arg)
quit()
elif cmd == self.cmd_prefix + 'help':
output = self.call_help(op)
elif cmd == self.cmd_prefix + 'reload' and op:
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))
self.load_plugins()
output = self.output_message('Plugins reloaded')
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):
def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, arg, msg_type):
output = None
if cmd in pinhook.plugin.cmds:
if cmd in plugin.cmds:
try:
if 'ops' in pinhook.plugin.cmds[cmd] and nick not in self.ops:
if pinhook.plugin.cmds[cmd]['ops_msg']:
output = self.output_message(pinhook.plugin.cmds[cmd]['ops_msg'])
else:
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 = pinhook.plugin.cmds[cmd]['run'](self.Message(
output = plugin.cmds[cmd].run(self.Message(
bot=self,
channel=chan,
cmd=cmd,
nick_list=nick_list,
@ -175,37 +213,50 @@ class Bot(irc.bot.SingleServerIRCBot):
notice=notice,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger
logger=self.logger,
msg_type=msg_type
))
except Exception as e:
except Exception:
self.logger.exception('issue with command {}'.format(cmd))
else:
for lstnr in pinhook.plugin.lstnrs:
try:
self.logger.debug('whispering to listener: {}'.format(lstnr))
listen_output = pinhook.plugin.lstnrs[lstnr](self.Message(
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
))
if listen_output:
output = listen_output
except Exception as e:
self.logger.exception('issue with listener {}'.format(lstnr))
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]
@ -235,6 +286,7 @@ class Bot(irc.bot.SingleServerIRCBot):
'privmsg': c.privmsg,
'action': c.action,
'notice': c.notice,
'msg_type': msg_type
}
output = self.call_plugins(**plugin_info)
if output:
@ -245,31 +297,44 @@ class Bot(irc.bot.SingleServerIRCBot):
if not output.msg:
return
for msg in output.msg:
if len(msg.encode('UTF-8')) > 512:
self.logger.error('output message too long: {}'.format(msg))
elif output.msg_type == pinhook.plugin.OutputType.Message:
if output.msg_type == plugin.OutputType.Message:
self.logger.debug('output message: {}'.format(msg))
c.privmsg(chan, msg)
elif output.msg_type == pinhook.plugin.OutputType.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')
@ -278,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,11 +1,14 @@
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'
@ -23,47 +26,139 @@ class Output:
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):
return Output(OutputType.Action, msg)
def message(msg):
return Output(OutputType.Message, msg)
def _add_plugin(command, help_text, func):
def _add_command(command, help_text, func, ops=False, ops_msg=''):
if command not in cmds:
cmds[command] = {}
cmds[command].update({
'run': func,
'help': help_text
})
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):
if command not in cmds:
cmds[command] = {}
cmds[command].update({
'ops': True,
'ops_msg': ops_msg,
})
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, help_text=None):
def command(command, help_text='N/A', ops=False, ops_msg=''):
@wraps(command)
def register_for_command(func):
_add_plugin(command, help_text, 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):
@ -72,6 +167,7 @@ def listener(name):
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)

View File

@ -17,13 +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!
@ -36,6 +44,15 @@ here = os.path.abspath(os.path.dirname(__file__))
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."""
@ -73,7 +90,7 @@ class UploadCommand(Command):
# Where the magic happens:
setup(
name=NAME,
version='1.6.2',
version=about['__version__'],
description=DESCRIPTION,
long_description=long_description,
long_description_content_type='text/markdown',
@ -81,7 +98,12 @@ setup(
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=[