Compare commits

..

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

19 changed files with 208 additions and 833 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

163
README.md
View File

@ -1,84 +1,19 @@
# pinhook # pinhook
a pluggable irc bot framework in python
[![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) ## Tutorial
### Installation
The pluggable python framework for IRC bots and Twitch bots ```
$ pip install git+git://github.com/archangelic/pinhook.git
* [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 the 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
from pinhook.bot import Bot import pinhook.bot
bot = Bot( bot = pinhook.bot.Bot(
channels=['#foo', '#bar'], channels=['#foo', '#bar'],
nickname='ph-bot', nickname='ph-bot',
server='irc.freenode.net' server='irc.freenode.net'
@ -89,52 +24,18 @@ 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)
* `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")
* `port`: (default: `6667`) choose a custom port to connect to the server ### Creating plugins
* `ops`: (default: empty list) list of operators who can do things like make the bot join other channels or quit 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.
* `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
## Creating a Twitch Bot
Pinhook has a baked in way to connect directly to a twitch channel
```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
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 +44,18 @@ 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
* `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)

92
README.rst Normal file
View File

@ -0,0 +1,92 @@
pinhook
=======
a pluggable irc bot framework in python
Tutorial
--------
Installation
~~~~~~~~~~~~
::
$ pip install git+git://github.com/archangelic/pinhook.git
Creating the Bot
~~~~~~~~~~~~~~~~
To create the bot, just create a python file with the following:
.. code:: python
import pinhook.bot
bot = pinhook.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”)
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
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`_.
.. _pinhook-tilde: https://github.com/archangelic/pinhook-tilde

View File

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

View File

@ -1,11 +0,0 @@
#!/usr/bin/env python
from pinhook.bot import Bot
ph = Bot(
channels=['#dicechannel'],
nickname='dicebot',
server='irc.freenode.net',
ops=['archangelic']
)
ph.start()

View File

@ -1,29 +0,0 @@
import random
import re
import pinhook.plugin
dicepattern = re.compile('(?P<amount>\d+)d(?P<sides>\d+)\+?(?P<modifier>\d+)?')
def build_output(rolls, modifier):
if len(rolls) == 1:
start = str(sum(rolls))
else:
all_rolls = ''.join([str(i)+', ' for i in rolls]).strip(', ')
start = '{} = {}'.format(all_rolls, sum(rolls))
if modifier:
output = start + ' + {} = {}'.format(modifier, sum(rolls) + int(modifier))
else:
output = start
return output
@pinhook.plugin.command('!roll')
def roll(msg):
matches = dicepattern.match(msg.arg)
if matches:
msg.logger.info('Valid dice roll: {}'.format(msg.arg))
rolls = [random.randrange(1, int(matches.group('sides'))+1) for i in range(int(matches.group('amount')))]
output = build_output(rolls, matches.group('modifier'))
else:
output = '{}: improper format, should be NdN+N'.format(msg.nick)
return pinhook.plugin.message(output)

4
examples/ph.py Normal file
View File

@ -0,0 +1,4 @@
import pinhook.bot
ph = pinhook.bot.Bot(['#arch-dev'], 'ph-bot', 'localhost', ops=['archangelic'])
ph.start()

6
examples/plugins/test.py Normal file
View File

@ -0,0 +1,6 @@
import pinhook.plugin
@pinhook.plugin.register('!test')
def test(msg):
return pinhook.plugin.message("{}: Test".format(msg.nick))

View File

@ -1,10 +0,0 @@
#!/usr/bin/env python
from pinhook.bot import TwitchBot
bot = TwitchBot(
nickname='dicebot',
channel='#dicechannel',
token='supersecrettokenhere'
)
bot.start()

View File

@ -1,29 +0,0 @@
import random
import re
import pinhook.plugin
dicepattern = re.compile('(?P<amount>\d+)d(?P<sides>\d+)\+?(?P<modifier>\d+)?')
def build_output(rolls, modifier):
if len(rolls) == 1:
start = str(sum(rolls))
else:
all_rolls = ''.join([str(i)+', ' for i in rolls]).strip(', ')
start = '{} = {}'.format(all_rolls, sum(rolls))
if modifier:
output = start + ' + {} = {}'.format(modifier, sum(rolls) + int(modifier))
else:
output = start
return output
@pinhook.plugin.command('!roll')
def roll(msg):
matches = dicepattern.match(msg.arg)
if matches:
msg.logger.info('Valid dice roll: {}'.format(msg.arg))
rolls = [random.randrange(1, int(matches.group('sides'))+1) for i in range(int(matches.group('amount')))]
output = build_output(rolls, matches.group('modifier'))
else:
output = '{}: improper format, should be NdN+N'.format(msg.nick)
return pinhook.plugin.message(output)

View File

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

View File

@ -1,345 +1,109 @@
from collections import OrderedDict import imp
from datetime import datetime, timezone import os
import logging
import ssl import ssl
import time
from . import log
from . import plugin
import irc.bot import irc.bot
irc.client.ServerConnection.buffer_class.errors = 'replace' irc.client.ServerConnection.buffer_class.errors = 'replace'
class Message:
def __init__(self, channel, nick, cmd, arg, botnick, ops):
self.channel = channel
self.nick = nick
self.cmd = cmd
self.arg = arg
self.botnick = botnick
self.ops = ops
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)], nickname, nickname, connect_factory=factory)
else: else:
irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port, self.server_pass)], nickname, nickname) irc.bot.SingleServerIRCBot.__init__(self, [(server, self.port)], nickname, nickname)
self.chanlist = channels self.chanlist = channels
self.bot_nick = nickname self.bot_nick = nickname
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: # load all plugins
def __init__(self, bot, channel, nick, botnick, ops, logger, action, privmsg, notice, msg_type, cmd=None, arg=None, text=None, nick_list=None): plugins = []
self.bot = bot for m in os.listdir(self.plugin_dir):
self.datetime = datetime.now(timezone.utc) if m.endswith('.py'):
self.timestamp = self.datetime.timestamp() name = m[:-3]
self.channel = channel fp, pathname, description = imp.find_module(name, [self.plugin_dir])
self.nick = nick plugins.append(imp.load_module(name, fp, pathname, description))
self.nick_list = nick_list
self.botnick = botnick # gather all commands
self.ops = ops self.cmds = {}
self.logger = logger for plugin in plugins:
self.action = action for cmd in plugin.pinhook.plugin.cmds:
self.privmsg = privmsg self.cmds[cmd['cmd']] = cmd['func']
self.notice = notice
self.msg_type = msg_type def set_kwargs(self, **kwargs):
if cmd: kwarguments = {
self.cmd = cmd 'port': 6667,
self.arg = arg 'ops': [],
if text != None: 'plugin_dir': 'plugins',
self.text = text 'ssl_required': False,
if not (cmd==None or text==None): 'ns_pass': None,
raise TypeError('missing cmd or text parameter') 'nickserv': 'NickServ',
}
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):
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 self.log_level == 'warning':
level = logging.WARNING
elif self.log_level == 'info':
level = logging.INFO
elif self.log_level == 'debug':
level = logging.DEBUG
# Set levels
if self.log_level != "off":
self.logger.setLevel(level)
self.logger.info('Logging started!')
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')
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])) c.join(channel)
c.join(*channel.split())
def on_pubmsg(self, c, e): def on_pubmsg(self, c, e):
self.process_event(c, e) self.process_command(c, e, e.arguments[0])
def on_privmsg(self, c, e): def on_privmsg(self, c, e):
self.process_event(c, e) self.process_command(c, e, e.arguments[0])
def on_action(self, c, e): def process_command(self, c, e, text):
self.process_event(c, e)
def call_help(self, nick, op):
cmds = {k:v.help_text for k,v in plugin.cmds.items() if not plugin.cmds[k].ops}
cmds.update({self.cmd_prefix + 'help': 'returns this output to private message'})
if op:
cmds.update({k:v.help_text for k,v in plugin.cmds.items() if plugin.cmds[k].ops})
cmds.update({k:v for k,v in self.internal_commands.items()})
helpout = OrderedDict(sorted(cmds.items()))
for h in helpout:
self.connection.privmsg(nick, '{} -- {}'.format(h, helpout[h]))
time.sleep(.1)
self.connection.privmsg(nick, 'List of listeners: {}'.format(', '.join([l for l in plugin.lstnrs])))
return None
def call_internal_commands(self, channel, nick, cmd, text, arg, c):
if not cmd.startswith(self.cmd_prefix):
return None
else:
cmd = cmd[len(self.cmd_prefix):]
output = None
if nick in self.ops:
op = True
else:
op = False
if cmd == 'join' and op:
try:
c.join(*arg.split())
self.logger.info('joining {} per request of {}'.format(arg, nick))
output = plugin.message('{}: joined {}'.format(nick, arg.split()[0]))
except:
self.logger.exception('issue with join command: {}join #channel <channel key>'.format(self.cmd_prefix))
elif cmd == 'quit' and op:
self.logger.info('quitting per request of {}'.format(nick))
if not arg:
arg = "See y'all later!"
c.quit(arg)
quit()
elif cmd == 'help' and not self.disable_help:
self.call_help(nick, op)
elif cmd == 'reload' and op:
self.logger.info('reloading plugins per request of {}'.format(nick))
plugin.load_plugins(self.plugin_dir, use_prefix=self.use_prefix_for_plugins, cmd_prefix=self.cmd_prefix)
output = plugin.message('Plugins reloaded')
elif cmd == 'enable' and op:
if arg in plugin.plugins:
if plugin.plugins[arg].enabled:
output = plugin.message("{}: '{}' already enabled".format(nick, arg))
else:
plugin.plugins[arg].enable()
output = plugin.message("{}: '{}' enabled!".format(nick, arg))
else:
output = plugin.message("{}: '{}' not found".format(nick, arg))
elif cmd == 'disable' and op:
if arg in plugin.plugins:
if not plugin.plugins[arg].enabled:
output = plugin.message("{}: '{}' already disabled".format(nick, arg))
else:
plugin.plugins[arg].disable()
output = plugin.message("{}: '{}' disabled!".format(nick, arg))
elif cmd == 'op' and op:
for o in arg.split(' '):
self.ops.append(o)
output = plugin.message('{}: {} added as op'.format(nick, arg))
elif cmd == 'deop' and op:
for o in arg.split(' '):
self.ops = [i for i in self.ops if i != o]
output = plugin.message('{}: {} removed as op'.format(nick, arg))
elif cmd == 'ops' and op:
output = plugin.message('current ops: {}'.format(', '.join(self.ops)))
elif cmd == 'ban' and op:
for o in arg.split(' '):
self.banned_users.append(o)
output = plugin.message('{}: banned {}'.format(nick, arg))
elif cmd == 'unban' and op:
for o in arg.split(' '):
self.banned_users = [i for i in self.banned_users if i != o]
output = plugin.message('{}: removed ban for {}'.format(nick, arg))
elif cmd == 'banlist':
output = plugin.message('currently banned: {}'.format(', '.join(self.banned_users)))
return output
def call_plugins(self, privmsg, action, notice, chan, cmd, text, nick_list, nick, arg, msg_type):
output = None
if cmd in plugin.cmds:
try:
if plugin.cmds[cmd].ops and nick not in self.ops:
if plugin.cmds[cmd].ops_msg:
output = plugin.message(plugin.cmds[cmd].ops_msg)
elif plugin.cmds[cmd].enabled:
self.logger.debug('executing {}'.format(cmd))
output = plugin.cmds[cmd].run(self.Message(
bot=self,
channel=chan,
cmd=cmd,
nick_list=nick_list,
nick=nick,
arg=arg,
privmsg=privmsg,
action=action,
notice=notice,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger,
msg_type=msg_type
))
except Exception:
self.logger.exception('issue with command {}'.format(cmd))
else:
for lstnr in plugin.lstnrs:
if plugin.lstnrs[lstnr].enabled:
try:
self.logger.debug('whispering to listener: {}'.format(lstnr))
listen_output = plugin.lstnrs[lstnr].run(self.Message(
bot=self,
channel=chan,
text=text,
nick_list=nick_list,
nick=nick,
privmsg=privmsg,
action=action,
notice=notice,
botnick=self.bot_nick,
ops=self.ops,
logger=self.logger,
msg_type=msg_type
))
if listen_output:
output = listen_output
except Exception:
self.logger.exception('issue with listener {}'.format(lstnr))
if output:
self.logger.debug(f'returning output: {output.msg}')
return output
def process_event(self, c, e):
nick = e.source.nick nick = e.source.nick
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: if e.target == self.bot_nick:
chan = nick chan = nick
nick_list = [nick]
else: else:
chan = e.target chan = e.target
nick_list = list(self.channels[chan].users()) cmd = text.split(' ')[0]
if e.type == 'action':
cmd = ''
else:
cmd = text.split(' ')[0]
self.logger.debug(
'Message info: channel: {}, nick: {}, cmd: {}, text: {}'.format(chan, nick, cmd, text)
)
if len(text.split(' ')) > 1: if len(text.split(' ')) > 1:
arg = ''.join([i + ' ' for i in text.split(' ')[1:]]).strip() arg = ''.join([i + ' ' for i in text.split(' ')[1:]]).strip()
else: else:
arg = '' arg = ''
output = self.call_internal_commands(chan, nick, cmd, text, arg, c) output = None
if not output: if cmd == '!join' and nick in self.ops:
plugin_info = { c.join(arg)
'chan': chan, c.privmsg(chan, '{}: joined {}'.format(nick, arg))
'cmd': cmd, elif cmd == '!quit' and nick in self.ops:
'text': text, c.quit("See y'all later!")
'nick_list': nick_list, quit()
'nick': nick, elif cmd == '!help':
'arg': arg, helplist = sorted([i for i in self.cmds])
'privmsg': c.privmsg, msg = ', '.join(helplist)
'action': c.action, c.privmsg(chan, 'Available commands: {}'.format(msg))
'notice': c.notice, elif cmd in self.cmds:
'msg_type': msg_type output = self.cmds[cmd](Message(
} channel=chan,
output = self.call_plugins(**plugin_info) cmd=cmd,
nick=nick,
arg=arg,
botnick=self.bot_nick,
ops=self.ops
))
if output: if output:
self.logger.debug(f'sending output: {output.msg}') if output.msg_type == 'message':
self.process_output(c, chan, output) c.privmsg(chan, output.msg)
elif output.msg_type == 'action':
c.action(chan, output.msg)
def process_output(self, c, chan, output):
if not output.msg:
return
for msg in output.msg:
if output.msg_type == plugin.OutputType.Message:
self.logger.debug('output message: {}'.format(msg))
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))
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, **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()
self.channel = channel
self.logger.info('Joining Twitch Server')
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')
c.cap('REQ', ':twitch.tv/membership')
c.cap('REQ', ':twitch.tv/tags')
c.cap('REQ', ':twitch.tv/commands')
self.logger.info('Joining channel ' + 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,175 +1,26 @@
from enum import Enum cmds = []
from functools import wraps
import importlib
import os
from .log import logger
plugins = {}
cmds = {}
lstnrs = {}
class OutputType(Enum):
Message = 'message'
Action = 'action'
class Output: class Output:
def __init__(self, msg_type, msg): def __init__(self, msg_type, msg):
self.msg_type = msg_type self.msg_type = msg_type
self.msg = self.sanitize(msg) self.msg = msg
def sanitize(self, msg):
try:
return msg.splitlines()
except AttributeError:
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('action', msg)
def message(msg): def message(msg):
return Output(OutputType.Message, msg) return Output('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.append({'cmd': command, 'func': func})
Command(command, ops=True, ops_msg=ops_msg)
else:
cmds[command]._enable_ops(ops_msg)
def _add_listener(name, func):
Listener(name, run=func)
def clear_plugins(): def register(command):
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 command(command, help_text='N/A', ops=False, ops_msg=''):
@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 register_as_listener(func):
_add_listener(name, func)
return func
return register_as_listener
def ops(command, msg=None):
logger.warn('use of the @ops decorator has been deprecated in favor of using the @command decorator with the ops and ops_msg options. Use will cause errors in future versions.')
@wraps(command)
def register_ops_command(func):
_ops_plugin(command, msg, func)
return func
return register_ops_command

View File

@ -1,4 +1,4 @@
#!/usr/bin/env 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,12 @@ 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',
'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 +32,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,33 +72,28 @@ class UploadCommand(Command):
# Where the magic happens: # Where the magic happens:
setup( setup(
name=NAME, name=NAME,
version=about['__version__'], version='1.0.2',
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=[
# Trove classifiers # Trove classifiers
# Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers # Full list: https://pypi.python.org/pypi?%3Aaction=list_classifiers
'License :: OSI Approved :: MIT License', 'License :: OSI Approved :: MIT License',
'Development Status :: 5 - Production/Stable',
'Programming Language :: Python', 'Programming Language :: Python',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3',
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.5',
'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.6',
'Programming Language :: Python :: 3.7',
], ],
# $ setup.py publish support. # $ setup.py publish support.
cmdclass={ cmdclass={