Skip to content
This repository was archived by the owner on Aug 15, 2022. It is now read-only.

Commit 5d443a1

Browse files
author
Jeff Ammons
committed
New string-based plugin importing works and docs for it
Updated docs to what I'd like them to be and started stepping through changes in code to make them work. Now to make new-style plugins actually work...
1 parent cccf2b5 commit 5d443a1

File tree

4 files changed

+162
-57
lines changed

4 files changed

+162
-57
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
*.pyc
22
/rtmbot.conf
33
/plugins/**
4+
/new_plugins/**
45
/build/**
56
*.log
67
env

README.md

Lines changed: 86 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ python-rtmbot
44
[![Build Status](https://travis-ci.org/slackhq/python-rtmbot.png)](https://travis-ci.org/slackhq/python-rtmbot)
55
[![Coverage Status](https://coveralls.io/repos/github/slackhq/python-rtmbot/badge.svg?branch=master)](https://coveralls.io/github/slackhq/python-rtmbot?branch=master)
66

7-
A Slack bot written in python that connects via the RTM API.
7+
A Slack bot written in Python that connects via the RTM API.
88

9-
Python-rtmbot is a callback based bot engine. The plugins architecture should be familiar to anyone with knowledge to the [Slack API](https://api.slack.com) and Python. The configuration file format is YAML.
9+
Python-rtmbot is a bot engine. The plugins architecture should be familiar to anyone with knowledge to the [Slack API](https://api.slack.com) and Python. The configuration file format is YAML.
1010

1111
Some differences to webhooks:
1212

@@ -23,32 +23,69 @@ Dependencies
2323
Installation
2424
-----------
2525

26-
1. Download the python-rtmbot code
26+
1. Create your project
27+
mkdir myproject
28+
cd myproject
2729

28-
git clone https://github.com/slackhq/python-rtmbot.git
29-
cd python-rtmbot
30+
2. Install rtmbot (ideally into a virtualenv https://virtualenv.readthedocs.io/en/latest/)
3031

31-
2. Install dependencies ([virtualenv](http://virtualenv.readthedocs.org/en/latest/) is recommended.)
32+
pip install rtmbot
3233

33-
pip install -r requirements.txt
34+
3. Create conf file and configure rtmbot (https://api.slack.com/bot-users)
3435

35-
3. Configure rtmbot (https://api.slack.com/bot-users)
36-
37-
cp docs/example-config/rtmbot.conf .
3836
vi rtmbot.conf
39-
SLACK_TOKEN: "xoxb-11111111111-222222222222222"
4037

41-
*Note*: At this point rtmbot is ready to run, however no plugins are configured.
38+
DEBUG: True # make this False in production
39+
SLACK_TOKEN: "xoxb-11111111111-222222222222222"
40+
ACTIVE_PLUGINS:
41+
- plugins.repeat.RepeatPlugin
42+
- plugins.test.TestPlugin
43+
44+
```DEBUG``` will adjust logging verbosity and cause the runner to exit on exceptions, generally making dubugging more pleasant.
45+
46+
```SLACK_TOKEN``` is needed to authenticate with your Slack team. More info at https://api.slack.com/web#authentication
47+
48+
```ACTIVE_PLUGINS``` RTMBot will attempt to import any Plugin specified in `ACTIVE_PLUGINS` (relative to your python path) and instantiate them as plugins. These specified classes should inherit from the core Plugin class.
49+
50+
For example, if your python path includes '/path/to/myproject' and you include `plugins.repeat.RepeatPlugin` in ACTIVE_PLUGINS, it will find the RepeatPlugin class within /path/to/myproject/plugins/repeat.py and instantiate it then attach it to your running RTMBot.
51+
52+
A Word on Structure
53+
-------
54+
To give you a quick sense of how this library is structured, there is a RtmBot class which does the setup and handles input and outputs of messages. It will also search for and register Plugins within the specified directory(ies). These Plugins handle different message types with various methods and can also register periodic Jobs which will be executed by the Plugins.
55+
```
56+
RtmBot
57+
|--> Plugin
58+
|---> Job
59+
|---> Job
60+
|--> Plugin
61+
|--> Plugin
62+
|---> Job
63+
```
4264

4365
Add Plugins
4466
-------
4567

46-
Plugins can be installed as .py files in the ```plugins/``` directory OR as a .py file in any first level subdirectory. If your plugin uses multiple source files and libraries, it is recommended that you create a directory. You can install as many plugins as you like, and each will handle every event received by the bot indepentently.
68+
To add a plugin, create a file within your plugin directory (./plugins is a good place for it).
69+
70+
cd plugins
71+
vi myplugin.py
72+
73+
Add your plugin content into this file. Here's an example that will just print all of the requests it receives. See below for more information on available methods.
74+
75+
from future import print_function
76+
from rtmbot.core import Plugin
77+
78+
class MyPlugin(Plugin):
79+
80+
def catch_all(self, data):
81+
print(data)
82+
83+
You can install as many plugins as you like, and each will handle every event received by the bot indepentently.
4784

4885
To install the example 'repeat' plugin
4986

50-
mkdir plugins/repeat
51-
cp docs/example-plugins/repeat.py plugins/repeat/
87+
mkdir plugins/
88+
cp docs/example-plugins/repeat.py plugins/repeat.py
5289

5390
The repeat plugin will now be loaded by the bot on startup.
5491

@@ -58,33 +95,56 @@ Create Plugins
5895
--------
5996

6097
####Incoming data
61-
Plugins are callback based and respond to any event sent via the rtm websocket. To act on an event, create a function definition called process_(api_method) that accepts a single arg. For example, to handle incoming messages:
98+
All events from the RTM websocket are sent to the registered plugins. To act on an event, create a function definition called process_(api_method) that accepts a single arg. For example, to handle incoming messages:
6299

63-
def process_message(data):
100+
def process_message(self, data):
64101
print data
65102

66103
This will print the incoming message json (dict) to the screen where the bot is running.
67104

68105
Plugins having a method defined as ```catch_all(data)``` will receive ALL events from the websocket. This is useful for learning the names of events and debugging.
69106

107+
For a list of all possible API Methods, look here: https://api.slack.com/rtm
108+
70109
Note: If you're using Python 2.x, the incoming data should be a unicode string, be careful you don't coerce it into a normal str object as it will cause errors on output. You can add `from __future__ import unicode_literals` to your plugin file to avoid this.
71110

72111
####Outgoing data
73-
Plugins can send messages back to any channel, including direct messages. This is done by appending a two item array to the outputs global array. The first item in the array is the channel ID and the second is the message text. Example that writes "hello world" when the plugin is started:
74112

75-
outputs = []
76-
outputs.append(["C12345667", "hello world"])
113+
#####RTM Output
114+
Plugins can send messages back to any channel or direct message. This is done by appending a two item array to the Plugin's output array (```myPluginInstance.output```). The first item in the array is the channel or DM ID and the second is the message text. Example that writes "hello world" when the plugin is started:
115+
116+
class myPlugin(Plugin):
117+
118+
def process_message(self, data):
119+
self.outputs.append(["C12345667", "hello world"])
120+
121+
#####SlackClient Web API Output
122+
Plugins also have access to the connected SlackClient instance for more complex output (or to fetch data you may need).
123+
124+
def process_message(self, data):
125+
self.slack_client.api_call(
126+
"chat.postMessage", channel="#general", text="Hello from Python! :tada:",
127+
username='pybot', icon_emoji=':robot_face:'
77128

78-
*Note*: you should always create the outputs array at the start of your program, i.e. ```outputs = []```
79129

80130
####Timed jobs
81131
Plugins can also run methods on a schedule. This allows a plugin to poll for updates or perform housekeeping during its lifetime. This is done by appending a two item array to the crontable array. The first item is the interval in seconds and the second item is the method to run. For example, this will print "hello world" every 10 seconds.
82132

83-
outputs = []
84-
crontable = []
85-
crontable.append([10, "say_hello"])
86-
def say_hello():
87-
outputs.append(["C12345667", "hello world"])
133+
from core import Plugin, Job
134+
135+
136+
class myJob(Job):
137+
138+
def say_hello(self):
139+
self.outputs.append(["C12345667", "hello world"])
140+
141+
142+
class myPlugin(Plugin):
143+
144+
def register_jobs(self):
145+
job = myJob(10, 'say_hello', self.debug)
146+
self.jobs.append(job)
147+
88148

89149
####Plugin misc
90150
The data within a plugin persists for the life of the rtmbot process. If you need persistent data, you should use something like sqlite or the python pickle libraries.

rtmbot/core.py

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88

99
from slackclient import SlackClient
1010

11+
from utils.module_loading import import_string
12+
1113
sys.dont_write_bytecode = True
1214

1315

@@ -28,10 +30,15 @@ def __init__(self, config):
2830
self.config = config
2931

3032
# set slack token
31-
self.token = config.get('SLACK_TOKEN')
33+
self.token = config.get('SLACK_TOKEN', None)
34+
# TODO: Raise an exception if no SLACK_TOKEN is specified
35+
36+
# get list of directories to search for loading plugins
37+
self.active_plugins = config.get('ACTIVE_PLUGINS', None)
3238

33-
# set working directory for loading plugins or other files
39+
# set base directory for logs and plugin search
3440
working_directory = os.path.abspath(os.path.dirname(sys.argv[0]))
41+
3542
self.directory = self.config.get('BASE_PATH', working_directory)
3643
if not self.directory.startswith('/'):
3744
path = os.path.join(os.getcwd(), self.directory)
@@ -60,7 +67,7 @@ def connect(self):
6067

6168
def _start(self):
6269
self.connect()
63-
self.load_plugins()
70+
self.load_plugins() # <<<<------------------------
6471
while True:
6572
for reply in self.slack_client.rtm_read():
6673
self.input(reply)
@@ -109,51 +116,64 @@ def crons(self):
109116
plugin.do_jobs()
110117

111118
def load_plugins(self):
112-
plugin_dir = os.path.join(self.directory, 'plugins')
113-
for plugin in glob.glob(os.path.join(plugin_dir, '*')):
114-
sys.path.insert(0, plugin)
115-
sys.path.insert(0, plugin_dir)
116-
for plugin in glob.glob(os.path.join(plugin_dir, '*.py')) + \
117-
glob.glob(os.path.join(plugin_dir, '*', '*.py')):
118-
logging.info(plugin)
119-
name = plugin.split(os.sep)[-1][:-3]
120-
if name in self.config:
121-
logging.info("config found for: " + name)
122-
plugin_config = self.config.get(name, {})
123-
plugin_config['DEBUG'] = self.debug
124-
self.bot_plugins.append(Plugin(name, plugin_config))
119+
''' Given a set of plugin_path strings (directory names on the python path),
120+
load any classes with Plugin in the name from any files within those dirs.
121+
'''
122+
self._dbg("Loading plugins")
123+
for plugin_path in self.active_plugins:
124+
self._dbg("Importing {}".format(plugin_path))
125+
126+
if self.debug is True:
127+
# this makes the plugin fail with stack trace in debug mode
128+
cls = import_string(plugin_path)
129+
else:
130+
# otherwise we log the exception and carry on
131+
try:
132+
cls = import_string(plugin_path)
133+
except ImportError:
134+
logging.exception("Problem importing {}".format(plugin_path))
135+
136+
plugin_config = self.config.get(cls.__name__, {})
137+
plugin = cls(slack_client=self.slack_client, plugin_config=plugin_config) # instatiate!
138+
self.bot_plugins.append(plugin)
139+
self._dbg("Plugin registered: {}".format(plugin))
125140

126141

127142
class Plugin(object):
128143

129-
def __init__(self, name, plugin_config=None):
144+
def __init__(self, name=None, slack_client=None, plugin_config=None):
130145
'''
131146
A plugin in initialized with:
132147
- name (str)
148+
- slack_client - a connected instance of SlackClient - can be used to make API
149+
calls within the plugins
133150
- plugin config (dict) - (from the yaml config)
134151
Values in config:
135152
- DEBUG (bool) - this will be overridden if debug is set in config for this plugin
136153
'''
154+
if name is None:
155+
self.name = type(self).__name__
156+
else:
157+
self.name = name
137158
if plugin_config is None:
138-
plugin_config = {}
139-
self.name = name
159+
self.plugin_config = {}
160+
else:
161+
self.plugin_config = plugin_config
162+
self.slack_client = slack_client
140163
self.jobs = []
141-
self.module = __import__(name)
142-
self.module.config = plugin_config
143-
self.debug = self.module.config.get('DEBUG', False)
164+
self.debug = self.plugin_config.get('DEBUG', False)
144165
self.register_jobs()
145166
self.outputs = []
146-
if 'setup' in dir(self.module):
147-
self.module.setup()
148167

149168
def register_jobs(self):
150-
if 'crontable' in dir(self.module):
151-
for interval, function in self.module.crontable:
152-
self.jobs.append(Job(interval, eval("self.module." + function), self.debug))
153-
logging.info(self.module.crontable)
154-
self.module.crontable = []
155-
else:
156-
self.module.crontable = []
169+
# if 'crontable' in dir(self.module):
170+
# for interval, function in self.module.crontable:
171+
# self.jobs.append(Job(interval, eval("self.module." + function), self.debug))
172+
# logging.info(self.module.crontable)
173+
# self.module.crontable = []
174+
# else:
175+
# self.module.crontable = []
176+
pass
157177

158178
def do(self, function_name, data):
159179
if function_name in dir(self.module):

utils/module_loading.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from importlib import import_module
2+
3+
4+
def import_string(dotted_path):
5+
"""
6+
Source: django.utils.module_loading
7+
8+
Import a dotted module path and return the attribute/class designated by the
9+
last name in the path. Raise ImportError if the import failed.
10+
"""
11+
try:
12+
module_path, class_name = dotted_path.rsplit('.', 1)
13+
except ValueError:
14+
msg = "%s doesn't look like a module path" % dotted_path
15+
raise ImportError(msg)
16+
17+
module = import_module(module_path)
18+
19+
try:
20+
return getattr(module, class_name)
21+
except AttributeError:
22+
msg = 'Module "%s" does not define a "%s" attribute/class' % (
23+
module_path, class_name)
24+
raise ImportError(msg)

0 commit comments

Comments
 (0)