9 |
14 |
--------------------------------------------------------------------------------
/supervisord.conf:
--------------------------------------------------------------------------------
1 | [supervisord]
2 | loglevel = error
3 | #logfile = /var/nginx/logs/supervisord.log
4 | pidfile = /tmp/supervisord.pid
5 | nodaemon = true
6 |
7 | [supervisorctl]
8 | serverurl=unix:///tmp/supervisor.sock ; use a unix:// URL for a unix socket
9 |
10 | [program:lubot]
11 | command = /opt/openresty/nginx/sbin/nginx -c /var/nginx/nginx.conf
12 | stdout_logfile = /dev/stdout
13 | stdout_logfile_maxbytes = 0
14 | #stdout_logfile = /var/nginx/logs/%(program_name)s.log
15 | stderr_logfile = /dev/stderr
16 | stderr_logfile_maxbytes = 0
17 | #stderr_logfile = /var/nginx/logs/%(program_name)s.log
18 | autorestart = true
19 |
--------------------------------------------------------------------------------
/var_nginx/content/html/docs/websocket-workflow.md:
--------------------------------------------------------------------------------
1 | # Nginx and Websocket Workflow
2 | The following convoluted diagram traces the flow from nginx startup to message proccessing.
3 |
4 | 
5 |
6 | As stated previously, one worker is selected through a locking mechanism to actually connect to the Slack websocket. Since this a blocking behaviour, that worker is essentially removed from the pool for handling http requests. This isn't a big deal if lubot is only dealing with being a bot and responding to messages. It's a bigger deal if lubot is embedded in an existing nginx installation that normally services http requests.
7 |
--------------------------------------------------------------------------------
/var_nginx/content/css/prettify.css:
--------------------------------------------------------------------------------
1 | .pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
--------------------------------------------------------------------------------
/var_nginx/content/html/plugins.html:
--------------------------------------------------------------------------------
1 |
Plugin Status
2 |
3 |
4 |
5 |
6 |
name
7 |
version
8 |
errors
9 |
executions
10 |
test
11 |
help
12 |
13 |
14 |
15 |
21 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/core/lubot_ping.lua:
--------------------------------------------------------------------------------
1 | local id = "ping"
2 | local version = "0.0.1"
3 | local regex = [[ping]]
4 | local p = require 'utils.plugins'
5 | local slack = require 'utils.slack'
6 |
7 | local function run(data)
8 | local tstamp = ngx.now()
9 |
10 | local text = "pong ["..tstamp.."]"
11 | return slack.say(text)
12 | end
13 |
14 | local function test(data)
15 | local res = run(data)
16 | local expects = [=[^pong .*$]=]
17 | local params = {
18 | mock_data = data,
19 | expects = expects,
20 | run_data = res
21 | }
22 | local t = require('utils.test').new(params)
23 | t:add("responds_text")
24 | t:add("response_contains")
25 | t:run()
26 | return t:report()
27 | end
28 |
29 | local plugin = {
30 | run = run,
31 | id = id,
32 | version = version,
33 | regex = regex,
34 | test = test
35 | }
36 |
37 | return plugin
38 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/brainz/memory_brain.lua:
--------------------------------------------------------------------------------
1 | local _brain = {}
2 | local _B = {}
3 | _brain._VERSION = "0.0.1"
4 | _brain.name = "memory"
5 | _brain.persistent = false
6 | local inspect = require 'inspect'
7 |
8 | function _brain.new(...)
9 | local self = {}
10 | setmetatable(self, {__index = _B})
11 | self._brainpan = {}
12 | return self
13 | end
14 |
15 | function _B:save()
16 | -- noop
17 | return true
18 | end
19 |
20 | function _B:keys()
21 | local t = {}
22 | for k, v in pairs(self._brainpan) do
23 | table.insert(t, k)
24 | end
25 | return t
26 | end
27 |
28 | function _B:export()
29 | -- noop
30 | end
31 |
32 | function _B:populate(t)
33 | self._brainpan = t
34 | return self._brainpan
35 | end
36 |
37 | function _B:get(k)
38 | return self._brainpan[k]
39 | end
40 |
41 | function _B:set(k, v)
42 | self._brainpan[k] = v
43 | return 1
44 | end
45 |
46 | function _B:safe_set(k, v)
47 | if self._brainpan[k] then
48 | return nil
49 | else
50 | self._brainpan[k] = v
51 | return 1
52 | end
53 | end
54 |
55 | function _B:delete(k)
56 | self._brainpan[k] = nil
57 | end
58 |
59 | function _B:flush()
60 | self._brainpan = {}
61 | end
62 | return _brain
63 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/core/lubot_help.lua:
--------------------------------------------------------------------------------
1 | _plugin = {}
2 |
3 | _plugin.id = "help"
4 | _plugin.version = "0.0.1"
5 | _plugin.regex = [=[help\s?(?\w+)?$]=]
6 | local p = require 'utils.plugins'
7 | local chat = require 'utils.slack'
8 | local ngu = require 'utils.nginx'
9 | local log = require 'utils.log'
10 |
11 | function _plugin.run(data)
12 | if not data.text then
13 | return nil, "Missing message text"
14 | end
15 | local m, err = ngx.re.match(data.text, _plugin.regex, 'jo')
16 | if not m then
17 | return nil, "Unable to match '"..data.text.."' to '".._plugin.regex.."'"
18 | else
19 | if not m.plugin_name then
20 | return chat.say(_plugin.help())
21 | else
22 | return chat.say(p.plugin_help(m.plugin_name))
23 | end
24 | end
25 | end
26 |
27 | function _plugin.help()
28 | local h = [[
29 | help : Displays help for a given plugin
30 | ]]
31 | return h
32 | end
33 |
34 | function _plugin.test(...)
35 | -- use our own data here
36 | local data = {user = "foo", channel = "bar", text = "plugin help help"}
37 | local res, err = _plugin.run(data)
38 | local params = {
39 | mock_data = data,
40 | run_data = res
41 | }
42 | local t = require('utils.test').new(params)
43 | t:add("responds_text")
44 | t:add("response_contains", "Displays help for a given plugin")
45 | t:run()
46 | return t:report()
47 | end
48 |
49 | return _plugin
50 |
--------------------------------------------------------------------------------
/var_nginx/lua/ui.lua:
--------------------------------------------------------------------------------
1 | local _VERSION = "0.0.1"
2 | local template = require 'resty.template'
3 | template.caching(false)
4 |
5 | local shared_dict = ngx.shared.ng_shared_dict
6 | local botname, err = shared_dict:get("bot_name")
7 |
8 | local function landing_page()
9 | local content = template.compile("
"..botname.." - the openresty chatbot
")
10 | template.render("index.html", {
11 | ngx = ngx,
12 | botname = botname,
13 | content = content,
14 | menubar = menubar
15 | })
16 | end
17 |
18 | local function get_page(regex)
19 | local pagename = regex['path'] or '404'
20 | local plu = require 'utils.plugins'
21 | local active_plugins = plu.dicts.active:get_keys()
22 | local plugins = {}
23 | for _, k in pairs(active_plugins) do
24 | plugins[k] = {
25 | name = k,
26 | errors = plu.dicts.errors:get(k) or 0,
27 | config = plu.dicts.config:get(k) or "none",
28 | executions = plu.dicts.executions:get(k) or 0
29 | }
30 | end
31 | local subcontent = template.compile(pagename..".html"){active_plugins = plugins}
32 | if subcontent == pagename..".html" then
33 | subcontent = template.compile('404.html')
34 | end
35 | template.render("index.html", {
36 | menubar = menubar,
37 | ngx = ngx,
38 | botname = botname,
39 | content = subcontent
40 | })
41 | end
42 |
43 | local m, err = ngx.re.match(ngx.var.uri, [=[^/(?\w+)(?/.*$)?]=])
44 | if not m then
45 | return landing_page()
46 | else
47 | return get_page(m)
48 | end
49 |
--------------------------------------------------------------------------------
/var_nginx/lua/utils/nginx.lua:
--------------------------------------------------------------------------------
1 | local m = {}
2 | m._VERSION = "0.0.1"
3 |
4 | function m.logerr(...)
5 | local caller = debug.getinfo(2).name
6 | if not caller then
7 | ngx.log(ngx.ERR, ...)
8 | else
9 | ngx.log(ngx.ERR,"["..caller.."] ", ...)
10 | end
11 | end
12 |
13 | function m.logwarn(...)
14 | local caller = debug.getinfo(2).name
15 | if not caller then
16 | ngx.log(ngx.WARN, ...)
17 | else
18 | ngx.log(ngx.WARN,"["..caller.."] ", ...)
19 | end
20 | end
21 |
22 | function m.loginfo(...)
23 | local caller = debug.getinfo(2).name
24 | if not caller then
25 | ngx.log(ngx.INFO, ...)
26 | else
27 | ngx.log(ngx.INFO,"["..caller.."] ", ...)
28 | end
29 | end
30 |
31 | function m.logdebug(...)
32 | local caller = debug.getinfo(2).name
33 | if not caller then
34 | ngx.log(ngx.DEBUG, ...)
35 | else
36 | ngx.log(ngx.DEBUG,"["..caller.."] ", ...)
37 | end
38 | end
39 |
40 | function m.lognotice(...)
41 | local caller = debug.getinfo(2).name
42 | if not caller then
43 | ngx.log(ngx.NOTICE, ...)
44 | else
45 | ngx.log(ngx.NOTICE,"["..caller.."] ", ...)
46 | end
47 | end
48 |
49 | function m.logalert(...)
50 | local caller = debug.getinfo(2).name
51 | if not caller then
52 | ngx.log(ngx.ALERT,...)
53 | else
54 | ngx.log(ngx.ALERT,"["..caller.."] ",...)
55 | end
56 | end
57 |
58 | function m.inspect(...)
59 | local caller = debug.getinfo(2).name
60 | local inspect = require 'inspect'
61 | if not caller then
62 | ngx.log(ngx.ALERT,"Inspecting: ",inspect(...))
63 | else
64 | ngx.log(ngx.ALERT, "Inspecting "..caller..":", inspect(...))
65 | end
66 | end
67 |
68 | return m
69 |
--------------------------------------------------------------------------------
/var_nginx/content/html/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
20 | {{botname}}
21 |
22 |
23 |
45 |
46 |
47 | {*content*}
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/var_nginx/lua/utils/brain.lua:
--------------------------------------------------------------------------------
1 | local _brain = {}
2 | local _B = {}
3 | _brain._VERSION = "0.0.1"
4 |
5 | local log = require 'utils.log'
6 |
7 | function _brain.new(driver, driver_opts)
8 | local self = {}
9 | setmetatable(self, {__index = _B})
10 | local d
11 | if not driver then
12 | print('No driver specified. Using memory')
13 | d = 'memory_brain'
14 | else
15 | print('Using driver: '..driver)
16 | d = driver..'_brain'
17 | end
18 | local p_ok, p = pcall(require, d)
19 | if not p_ok then
20 | print("Got error requiring brain module: "..p)
21 | return nil
22 | else
23 | self.__driver = p
24 | self.driver_name = p.name
25 | if p.persistent == false then
26 | print("Warning! Using a non-persistent brain. Data will be lost at shutdown")
27 | end
28 | end
29 | self.driver = p.new(unpack{driver_opts})
30 | if not self.driver then
31 | print("Could not instantiate new brain")
32 | return nil
33 | end
34 | return self
35 | end
36 |
37 | function _B:save(...)
38 | return self.driver:save(unpack({...}))
39 | end
40 |
41 | function _B:keys(...)
42 | return self.driver:keys(unpack({...}))
43 | end
44 |
45 | function _B:export(...)
46 | return self.driver:export(unpack({...}))
47 | end
48 |
49 | function _B:populate(...)
50 | return self.driver:populate(unpack({...}))
51 | end
52 |
53 | function _B:get(...)
54 | return self.driver:get(unpack({...}))
55 | end
56 |
57 | function _B:set(...)
58 | return self.driver:set(unpack({...}))
59 | end
60 |
61 | function _B:safe_set(...)
62 | return self.driver:safe_set(unpack({...}))
63 | end
64 |
65 | function _B:delete(...)
66 | return self.driver:delete(unpack({...}))
67 | end
68 |
69 | function _B:flush(...)
70 | return self.driver:flush(unpack({...}))
71 | end
72 |
73 |
74 | return _brain
75 |
--------------------------------------------------------------------------------
/var_nginx/conf.d/lubot.conf:
--------------------------------------------------------------------------------
1 | upstream private_api {
2 | server unix:/var/nginx/tmp/ngx.private.sock;
3 | #server 127.0.0.1:3131;
4 | }
5 |
6 | server {
7 | listen 3232;
8 | server_name 127.0.0.1 localhost;
9 | lua_need_request_body on;
10 | client_max_body_size 2048M;
11 | default_type text/html;
12 | underscores_in_headers on;
13 | set $template_root '/var/nginx/content/html';
14 |
15 | location /capture {
16 | internal;
17 | proxy_buffering off;
18 | proxy_max_temp_file_size 0;
19 | resolver 8.8.8.8;
20 | set_unescape_uri $clean_url $arg_url;
21 | proxy_pass $clean_url;
22 | }
23 |
24 | #location /slack-sse {
25 | # chunked_transfer_encoding off;
26 | # proxy_pass http://127.0.0.1:3131/slack;
27 | # proxy_buffering off;
28 | # proxy_cache off;
29 | # proxy_set_header Connection '';
30 | # proxy_http_version 1.1;
31 | #}
32 |
33 | location /docs {
34 | lua_code_cache off;
35 | content_by_lua_file '/var/nginx/lua/markdown.lua';
36 | }
37 |
38 | location /api/logs {
39 | lua_code_cache off;
40 | postpone_output 0;
41 | lua_check_client_abort on;
42 | content_by_lua_file '/var/nginx/lua/sse.lua';
43 | }
44 |
45 | location /api {
46 | lua_code_cache off;
47 | proxy_pass_request_headers on;
48 | proxy_redirect off;
49 | proxy_buffering off;
50 | proxy_cache off;
51 | rewrite ^/api/(.*) /_private/api/$1 break;
52 | proxy_pass http://private_api;
53 | }
54 |
55 | location /fonts {
56 | alias /var/nginx/content/fonts/;
57 | }
58 |
59 | location /css {
60 | alias /var/nginx/content/css/;
61 | }
62 |
63 | location /js {
64 | alias /var/nginx/content/js/;
65 | }
66 |
67 | location /img {
68 | alias /var/nginx/content/img/;
69 | }
70 |
71 | location / {
72 | lua_code_cache off;
73 | content_by_lua_file '/var/nginx/lua/ui.lua';
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/var_nginx/lua/utils/log.lua:
--------------------------------------------------------------------------------
1 | local m = {}
2 | m._VERSION = "0.0.1"
3 | local n = require 'utils.nginx'
4 |
5 | function m.err(...)
6 | local caller = debug.getinfo(2).name
7 | if not caller then
8 | ngx.log(ngx.ERR, ...)
9 | else
10 | ngx.log(ngx.ERR,"["..caller.."] ", ...)
11 | end
12 | end
13 |
14 | function m.warn(...)
15 | local caller = debug.getinfo(2).name
16 | if not caller then
17 | ngx.log(ngx.WARN, ...)
18 | else
19 | ngx.log(ngx.WARN,"["..caller.."] ", ...)
20 | end
21 | end
22 |
23 | function m.info(...)
24 | local caller = debug.getinfo(2).name
25 | if not caller then
26 | ngx.log(ngx.INFO, ...)
27 | else
28 | ngx.log(ngx.INFO,"["..caller.."] ", ...)
29 | end
30 | end
31 |
32 | function m.debug(...)
33 | local caller = debug.getinfo(2).name
34 | if not caller then
35 | ngx.log(ngx.DEBUG, ...)
36 | else
37 | ngx.log(ngx.DEBUG,"["..caller.."] ", ...)
38 | end
39 | end
40 |
41 | function m.notice(...)
42 | local caller = debug.getinfo(2).name
43 | if not caller then
44 | ngx.log(ngx.NOTICE, ...)
45 | else
46 | ngx.log(ngx.NOTICE,"["..caller.."] ", ...)
47 | end
48 | end
49 |
50 | function m.alert(...)
51 | local caller = debug.getinfo(2).name
52 | if not caller then
53 | ngx.log(ngx.ALERT,...)
54 | else
55 | ngx.log(ngx.ALERT,"["..caller.."] ",...)
56 | end
57 | end
58 |
59 | function m.inspect(t, ...)
60 | local inspect = require 'inspect'
61 | local caller = debug.getinfo(2).name
62 | local args = {...}
63 | if #args > 0 then
64 | if not caller then
65 | m.alert(table.concat(args, " "), inspect(t))
66 | else
67 | m.alert(caller..":", table.concat(args, " "), inspect(t))
68 | end
69 | else
70 | if not caller then
71 | m.alert("inspecting :", inspect(t))
72 | else
73 | m.alert("inspecting "..caller..":", inspect(t))
74 | end
75 | end
76 | end
77 |
78 | return m
79 |
--------------------------------------------------------------------------------
/var_nginx/lua/sse.lua:
--------------------------------------------------------------------------------
1 | local shared_dict = ngx.shared.ng_shared_dict
2 | local log_dict = ngx.shared.plugin_log
3 |
4 | -- following helper function was cribbed from the fine folks at 3scale
5 | -- https://github.com/3scale/nginx-oauth-templates/blob/master/oauth2/authorization-code-flow/no-token-generation/nginx.lua#L76-L90
6 | function string:split(delimiter)
7 | local result = { }
8 | local from = 1
9 | local delim_from, delim_to = string.find( self, delimiter, from )
10 |
11 | while delim_from do
12 | table.insert( result, string.sub( self, from , delim_from-1 ) )
13 | from = delim_to + 1
14 | delim_from, delim_to = string.find( self, delimiter, from )
15 | end
16 |
17 | table.insert( result, string.sub( self, from ) )
18 |
19 | return result
20 | end
21 |
22 | local function start_log_tailer()
23 | ngx.log(ngx.ALERT, "Client connected to sse")
24 | ngx.header.content_type = 'text/event-stream'
25 | ngx.say("event: keepalive\ndata: "..ngx.utctime().."\n")
26 | while true do
27 | local log_entries = log_dict:get_keys()
28 | for _, k in ipairs(log_entries) do
29 | local e = log_dict:get(k)
30 | local str_t = k:split(":")
31 | local msg = {
32 | id = k,
33 | timestamp = str_t[2],
34 | sender = str_t[1],
35 | message = e
36 | }
37 | local json = safe_json_encode(msg)
38 | if not json then
39 | ngx.say("data: ["..k.."] "..e.."\n")
40 | else
41 | ngx.say("id: "..k.."\nevent: logevent\ndata: "..json.."\n")
42 | end
43 | end
44 | ngx.say("event: keepalive\ndata: "..ngx.utctime().."\n")
45 | ngx.flush(true)
46 | ngx.sleep(15)
47 | end
48 | end
49 | local ok, err = ngx.on_abort(function ()
50 | ngx.log(ngx.ALERT, "Client disconnected from sse stream")
51 | ngx.exit(499)
52 | end)
53 | if not ok then
54 | ngx.log(ngx.ERR, "Can't register on_abort function.")
55 | ngx.exit(500)
56 | end
57 | start_log_tailer()
58 |
--------------------------------------------------------------------------------
/var_nginx/conf.d/private.conf:
--------------------------------------------------------------------------------
1 | server {
2 | access_log off;
3 | # Internal services that shouldn't be exposed externally
4 | # we only listen on a domain socket now
5 | # listen 3131;
6 | listen unix:/var/nginx/tmp/ngx.private.sock;
7 | server_name 127.0.0.1 localhost;
8 | lua_need_request_body on;
9 | client_max_body_size 2048M;
10 | default_type text/html;
11 | index index.html;
12 | underscores_in_headers on;
13 |
14 | # A private proxy pass for ngx.location.capture calls
15 | location /capture {
16 | internal;
17 | proxy_buffering off;
18 | proxy_max_temp_file_size 0;
19 | resolver 8.8.8.8;
20 | set_unescape_uri $clean_url $arg_url;
21 | proxy_pass $clean_url;
22 | }
23 |
24 | # Private internal-only API
25 | # This should never be exposed directly
26 | location /_private/api {
27 | lua_code_cache off;
28 | content_by_lua_file '/var/nginx/lua/api.lua';
29 | }
30 |
31 | # Private slack chat.postMessage proxy
32 | location /_private/slackpost {
33 | lua_code_cache off;
34 | content_by_lua_file '/var/nginx/lua/slackproxy.lua';
35 | }
36 | }
37 |
38 | server {
39 | access_log off;
40 | # wss proxy only
41 | # as soon as lua-resty-websocket supports ssl, this will go away
42 | listen 3131;
43 | server_name 127.0.0.1 localhost;
44 | lua_need_request_body on;
45 | client_max_body_size 2048M;
46 | default_type text/html;
47 | index index.html;
48 | underscores_in_headers on;
49 | # Private websocket ssl proxy
50 | location /wssproxy {
51 | resolver 8.8.8.8;
52 | proxy_max_temp_file_size 0;
53 | proxy_buffering off;
54 | proxy_connect_timeout 1d;
55 | proxy_read_timeout 1d;
56 | proxy_send_timeout 1d;
57 | set_unescape_uri $clean_url $arg_url;
58 | proxy_pass $clean_url;
59 | proxy_set_header X-Real-IP $remote_addr;
60 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
61 | proxy_http_version 1.1;
62 | proxy_set_header Upgrade $http_upgrade;
63 | proxy_set_header Connection "upgrade";
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/var_nginx/content/js/sse.js:
--------------------------------------------------------------------------------
1 | var evtSource;
2 | var sse_url = window.location.protocol+"//"+window.location.hostname+":"+window.location.port+"/api/logs";
3 | if (!String.prototype.startsWith) {
4 | Object.defineProperty(String.prototype, 'startsWith', {
5 | enumerable: false,
6 | configurable: false,
7 | writable: false,
8 | value: function(searchString, position) {
9 | position = position || 0;
10 | return this.lastIndexOf(searchString, position) === position;
11 | }
12 | });
13 | }
14 |
15 | function sseConnect() {
16 | evtSource = new EventSource(sse_url);
17 | $("#sse_connect_status").attr("onclick", "sseDisconnect();");
18 | evtSource.addEventListener("keepalive", function(e) {
19 | console.log("Keepalive from server: "+e.data);
20 | $("#sse_connect_status").css("color", "green");
21 | })
22 | evtSource.addEventListener("logevent", function(e) {
23 | var evt = JSON.parse(e.data);
24 | sseLog("["+evt.timestamp+"] ("+evt.sender+") "+evt.message);
25 | })
26 | evtSource.onerror = function(e) {
27 | switch(evtSource.readyState) {
28 | case 0:
29 | $("#sse_connect_status").attr("onclick", "sseDisconnect();");
30 | $("#sse_connect_status").css("color", "yellow");
31 | break;
32 | case 1:
33 | $("#sse_connect_status").attr("onclick", "sseDisconnect();");
34 | $("#sse_connect_status").css("color", "green");
35 | break;
36 | case 2:
37 | $("#sse_connect_status").attr("onclick", "sseConnect();");
38 | $("#sse_connect_status").css("color", "red");
39 | break;
40 | default:
41 | $("#sse_connect_status").attr("onclick", "sseDisconnect();");
42 | $("#sse_connect_status").css("color", "green");
43 | }
44 | }
45 |
46 | return false;
47 | }
48 |
49 | function sseDisconnect() {
50 | $("#sse_connect_status").css("color", "red");
51 | $("#sse_connect_status").attr("onclick", "sseConnect();");
52 | evtSource.close();
53 | }
54 |
55 | function sseLog(text) {
56 | $("#logwindow").append(document.createTextNode(text+"\n"));
57 | $("#logwindow").scrollTop = $("#logwindow").scrollHeight;
58 | console.log(text);
59 | return false;
60 | }
61 |
--------------------------------------------------------------------------------
/var_nginx/content/html/docs/index.md:
--------------------------------------------------------------------------------
1 | # Lubot Documentation
2 | Lubot is an experimental [Slack](https://slack.com) [chatbot](https://www.youtube.com/watch?v=NST3u-GjjFw) written in Lua hosted inside [OpenResty](http://openresty.org)
3 |
4 | ## Usage
5 | To use lubot, you'll need four things:
6 |
7 | - A slack bot integration
8 | - A slack incoming webhook API key
9 | - Docker
10 | - This repo cloned locally
11 |
12 | ## Building
13 | The repository ships with a `Makefile` you can use to run everything inside Docker.
14 | From the root of the repository run the following:
15 |
16 | ```
17 | make image
18 | ```
19 |
20 | This will build a Docker image named `lubot` for you.
21 |
22 | or to use a different name for the image:
23 |
24 | ```
25 | docker build --rm -t
26 | ```
27 |
28 | You should never blindly trust a random `Dockerfile` so look closely at the one shipped with the repo. Feel free to make changes but do so AFTER the core stuff is built.
29 |
30 | ## Running
31 | If you used the `Makefile` to build, then you can start it up with:
32 |
33 | ```
34 | DOCKER_ENV='--env LUBOT_PLUGIN_CONFIG=/var/nginx/lubot_plugins/lubot/plugins/plugins.json \
35 | --env SLACK_API_TOKEN= \
36 | --env SLACK_WEBHOOK_URL=""' \
37 | make run
38 | ```
39 | If you build the container with a different name, the run command in the `Makefile` can be used as a starting point.
40 |
41 | At this point you should see your bot visible in slack. Three basic example plugins ship out of the box:
42 |
43 | - `ping`: Works similar to hubot's ping
44 | - `status`: Returns some basic stats about the bot and slack account being used
45 | - `image`: Basic version of the hubot `image me` plugin
46 | - `help`: Help support for plugins (yes you can call `help` on the `help` plugin)
47 | - `plugins`: Plugin management itself is a plugin
48 |
49 | There is also a [webui](/docs/webui) available with some basic functionality and also an [api](/docs/api) that you can hit with curl.
50 |
51 | ## Nginx and Websocket internals
52 | See [workflow](/docs/websocket-workflow)
53 |
54 | ## Customizing
55 | See [customization](/docs/customizing)
56 |
57 | ## Logging
58 | See [logging](/docs/logging)
59 |
60 | ## Persistence
61 | See [brains](/docs/brains)
62 |
63 | ## Additional plugin documentation
64 | See [plugins](/docs/plugins)
65 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/core/lubot_image.lua:
--------------------------------------------------------------------------------
1 | local id = "image"
2 | local version = "0.0.1"
3 | local regex = [=[(image|img)( me)? (?.*)$]=]
4 |
5 | local p = require 'utils.plugins'
6 | local slack = require 'utils.slack'
7 | local ngu = require 'utils.nginx'
8 | local log = require 'utils.log'
9 |
10 | local function query_google(str)
11 | local gurl = "http://ajax.googleapis.com/ajax/services/search/images"
12 | local params = {
13 | v = "1.0",
14 | rsz = "8",
15 | q = str,
16 | safe = "active"
17 | }
18 | local cjson = require 'cjson'
19 | local hc = require("httpclient").new('httpclient.ngx_driver')
20 | local res = hc:get(gurl, {params = params})
21 | if res.err then
22 | return nil
23 | else
24 |
25 | local b = safe_json_decode(res.body)
26 | if not b then return nil end
27 | local candidates = b.responseData.results
28 | return p.pick_random(candidates).unescapedUrl
29 | end
30 | end
31 |
32 | local function match(str)
33 | local m, err = ngx.re.match(str, regex, 'jo')
34 | if not m then
35 | log.err(err or "No match for "..str)
36 | return nil
37 | else
38 | return m['search_string']
39 | end
40 | end
41 |
42 | local function run(data)
43 | if not data.text then
44 | return nil, "Missing message text"
45 | end
46 | local m = match(data.text)
47 | if not m then
48 | log.err("Missing string to search for")
49 | return nil, "Missing string to search for"
50 | else
51 | local img = query_google(m)
52 | if not img then
53 | log.err("No image found")
54 | return nil, "No image found"
55 | end
56 | return slack.say(img)
57 | end
58 | end
59 |
60 | local function test(data)
61 | local res = run(data)
62 | -- response should be a url
63 | local expects = [=[^http.*$]=]
64 | local params = {
65 | mock_data = data,
66 | run_data = res
67 | }
68 | local t = require('utils.test').new(params)
69 | -- basic tests
70 | t:add("responds_text")
71 | t:add("response_contains", expects)
72 | t:add("parses_text", regex, 'search_string')
73 | t:add("captures_value", data.expects, regex, 'search_string')
74 | t:run()
75 | return t:report()
76 | end
77 |
78 | local plugin = {
79 | run = run,
80 | id = id,
81 | version = version,
82 | regex = regex,
83 | test = test
84 | }
85 |
86 | return plugin
87 |
--------------------------------------------------------------------------------
/var_nginx/lua/slack.lua:
--------------------------------------------------------------------------------
1 | local _VERSION = "0.0.1"
2 | local ngu = require 'utils.nginx'
3 |
4 | -- shared dicts
5 | local shared_dict = ngx.shared.ng_shared_dict
6 | local locks = ngx.shared.shared_locks
7 |
8 | local slack_token = shared_dict:get('slack_token')
9 |
10 | local function start_rtm(premature)
11 | if premature then return nil end
12 | local slock = require 'resty.lock'
13 | local mylock = slock:new("shared_locks",{timeout = 60, exptime = 120})
14 | local locked, err = mylock:lock("slack_polling")
15 | if err then
16 | ngx.sleep(60)
17 | else
18 | local slack_running = ngx.shared.slack_running
19 | local ok, err = slack_running:add("locked", ngx.worker.pid())
20 | if err then
21 | if slack_running:get("locked") == ngx.worker.pid() then
22 | -- we couldn't get a lock to run but we're listed as the pid owner
23 | -- this likely means our thread crashed
24 | -- let's clear this lock and start fresh
25 | ngu.logwarn("previous lock holder thread likely crashed. starting over")
26 | slack_running:delete("locked")
27 | end
28 | local ok, err = mylock:unlock()
29 | if not ok then
30 | ngu.logerr("Unable to clear lock: ", err)
31 | else
32 | ngu.logdebug("Locked held for ", locked)
33 | end
34 | ngx.sleep(60)
35 | else
36 | ngu.logdebug(ngx.worker.pid(), " set the internal lock")
37 | -- we can clear the lock now as we've set our pid
38 | local ok, err = mylock:unlock()
39 | if not ok then
40 | ngu.logerr("Unable to clear lock: ", err)
41 | else
42 | ngu.logdebug("Locked held for ", locked)
43 | end
44 | res = slackbot()
45 | if res == false then
46 | slack_running:delete("locked")
47 | ngu.logerr("Slackbot function exited for some reason")
48 | end
49 | end
50 | end
51 | -- wait before starting the loop again
52 | -- previous we relied on lock waits but
53 | -- since we release after setting our other lock
54 | -- we have to emulate that
55 | start_rtm(nil)
56 | end
57 |
58 | -- main entry
59 | if not slack_token then
60 | ngu.logerr("Gotta set a slack token")
61 | return nil
62 | else
63 | local ok, err = ngx.timer.at(1, start_rtm)
64 | if not ok then
65 | -- skip
66 | else
67 | ngu.logdebug("Initial timer started")
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/var_nginx/lua/markdown.lua:
--------------------------------------------------------------------------------
1 | local _VERSION = "0.0.1"
2 | -- lua-httpclient ships with a better url/uri parser
3 | local neturl = require 'httpclient.neturl'
4 |
5 | -- load the template stuff
6 | local template = require 'resty.template'
7 |
8 | -- We need to read files with something that reads our files and then wraps the response
9 | -- in the markdown parsing syntax
10 | -- based off https://github.com/bungle/lua-resty-template/blob/master/lib/resty/template.lua#L44-L50
11 | local function load_wrapper(s, template_instance)
12 | local md_file = ngx.var.template_root.."/docs/"..s..".md"
13 | local file = io.open(md_file, "rb")
14 | if not file then ngx.log(ngx.ERR, "Unable to open file: ", md_file); return nil end
15 | local content = file:read("*a")
16 | file:close()
17 | if not content or content == '' then content = [[# Missing file]] end
18 | --local compiled_content = template_instance.compile(content)
19 | --local prestyle = ""
20 | return "{*markdown([["..content.."]], {extensions = {'fenced_code', 'tables', 'quote'}, renderer = 'html', nesting = 2})*}"
21 | end
22 | --template.load = load_wrapper
23 | template.caching(false)
24 | template.markdown = require "resty.hoedown"
25 |
26 | -- following helper function was cribbed from the fine folks at 3scale
27 | -- https://github.com/3scale/nginx-oauth-templates/blob/master/oauth2/authorization-code-flow/no-token-generation/nginx.lua#L76-L90
28 | function string:split(delimiter)
29 | local result = { }
30 | local from = 1
31 | local delim_from, delim_to = string.find( self, delimiter, from )
32 |
33 | while delim_from do
34 | table.insert( result, string.sub( self, from , delim_from-1 ) )
35 | from = delim_to + 1
36 | delim_from, delim_to = string.find( self, delimiter, from )
37 | end
38 |
39 | table.insert( result, string.sub( self, from ) )
40 |
41 | return result
42 | end
43 |
44 | -- split the path into parts and then get the last element
45 | local paths = string.split(neturl.parse(ngx.var.uri).path, "/")
46 | local doc = paths[#paths]
47 | local content = load_wrapper(doc, template)
48 | if not content then
49 | content = nil
50 | end
51 | local shared_dict = ngx.shared.ng_shared_dict
52 | local botname, err = shared_dict:get("bot_name")
53 | if not botname then botname = 'lubot' end
54 | template.render("index.html", { menubar = menubar, ngx = ngx, botname = botname, content = template.compile(content)})
55 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/core/lubot_status.lua:
--------------------------------------------------------------------------------
1 | local id = "status"
2 | local version = "0.0.1"
3 | local regex = [[status]]
4 |
5 | local p = require 'utils.plugins'
6 | local slack = require 'utils.slack'
7 |
8 | local function run(data)
9 |
10 | local slack_running = ngx.shared.slack_running
11 | local slack_users = ngx.shared.slack_users
12 | local slack_groups = ngx.shared.slack_groups
13 | local slack_channels = ngx.shared.slack_channels
14 | local slack_ims = ngx.shared.slack_ims
15 | local slack_bots = ngx.shared.slack_bots
16 | local lubot_config = ngx.shared.lubot_config
17 |
18 | local worker_pid = slack_running:get("locked")
19 | local users = #slack_users:get_keys()
20 | local groups = #slack_groups:get_keys()
21 | local channels = #slack_channels:get_keys()
22 | local ims = #slack_ims:get_keys()
23 | local bots = #slack_bots:get_keys()
24 | local config_file = lubot_config:get("config_file")
25 |
26 |
27 | fields = {
28 | {
29 | title = "Current worker pid",
30 | value = worker_pid,
31 | short = true
32 | },
33 | {
34 | title = "Config file",
35 | value = "`"..config_file.."`",
36 | short = true,
37 | mrkdwn = true
38 | },
39 | {
40 | title = "User count",
41 | value = users,
42 | short = true
43 | },
44 | {
45 | title = "My Private Groups",
46 | value = groups,
47 | short = true
48 | },
49 | {
50 | title = "Public Channels",
51 | value = channels,
52 | short = true
53 | },
54 | {
55 | title = "IMs",
56 | value = ims,
57 | short = true
58 | },
59 | {
60 | title = "Total integrations",
61 | value = bots,
62 | short = true
63 | }
64 | }
65 | local t = {
66 | text = "Current Status",
67 | fields = fields,
68 | channel = data.channel,
69 | username = p.get_botname()
70 | }
71 | local response = slack.to_rich_message(t)
72 | if not response then
73 | return nil
74 | else
75 | return response
76 | end
77 | end
78 |
79 | local function test(data)
80 | local res = run(data)
81 | local params = {
82 | mock_data = data,
83 | run_data = res
84 | }
85 | local t = require('utils.test').new(params)
86 | t:add('is_valid_rich_text')
87 | t:run()
88 | return t:report()
89 | end
90 |
91 | local plugin = {
92 | run = run,
93 | id = id,
94 | version = version,
95 | regex = regex,
96 | test = test
97 | }
98 |
99 | return plugin
100 |
--------------------------------------------------------------------------------
/var_nginx/content/js/lang-lua.js:
--------------------------------------------------------------------------------
1 | // Copyright (C) 2008 Google Inc.
2 | //
3 | // Licensed under the Apache License, Version 2.0 (the "License");
4 | // you may not use this file except in compliance with the License.
5 | // You may obtain a copy of the License at
6 | //
7 | // http://www.apache.org/licenses/LICENSE-2.0
8 | //
9 | // Unless required by applicable law or agreed to in writing, software
10 | // distributed under the License is distributed on an "AS IS" BASIS,
11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 | // See the License for the specific language governing permissions and
13 | // limitations under the License.
14 |
15 |
16 |
17 | /**
18 | * @fileoverview
19 | * Registers a language handler for Lua.
20 | *
21 | *
22 | * To use, include prettify.js and this file in your HTML page.
23 | * Then put your code in an HTML tag like
24 | *
(my Lua code)
25 | *
26 | *
27 | * I used http://www.lua.org/manual/5.1/manual.html#2.1
28 | * Because of the long-bracket concept used in strings and comments, Lua does
29 | * not have a regular lexical grammar, but luckily it fits within the space
30 | * of irregular grammars supported by javascript regular expressions.
31 | *
32 | * @author mikesamuel@gmail.com
33 | */
34 |
35 | PR['registerLangHandler'](
36 | PR['createSimpleLexer'](
37 | [
38 | // Whitespace
39 | [PR['PR_PLAIN'], /^[\t\n\r \xA0]+/, null, '\t\n\r \xA0'],
40 | // A double or single quoted, possibly multi-line, string.
41 | [PR['PR_STRING'], /^(?:\"(?:[^\"\\]|\\[\s\S])*(?:\"|$)|\'(?:[^\'\\]|\\[\s\S])*(?:\'|$))/, null, '"\'']
42 | ],
43 | [
44 | // A comment is either a line comment that starts with two dashes, or
45 | // two dashes preceding a long bracketed block.
46 | [PR['PR_COMMENT'], /^--(?:\[(=*)\[[\s\S]*?(?:\]\1\]|$)|[^\r\n]*)/],
47 | // A long bracketed block not preceded by -- is a string.
48 | [PR['PR_STRING'], /^\[(=*)\[[\s\S]*?(?:\]\1\]|$)/],
49 | [PR['PR_KEYWORD'], /^(?:and|break|do|else|elseif|end|false|for|function|if|in|local|nil|not|or|repeat|return|then|true|until|while)\b/, null],
50 | // A number is a hex integer literal, a decimal real literal, or in
51 | // scientific notation.
52 | [PR['PR_LITERAL'],
53 | /^[+-]?(?:0x[\da-f]+|(?:(?:\.\d+|\d+(?:\.\d*)?)(?:e[+\-]?\d+)?))/i],
54 | // An identifier
55 | [PR['PR_PLAIN'], /^[a-z_]\w*/i],
56 | // A run of punctuation
57 | [PR['PR_PUNCTUATION'], /^[^\w\t\n\r \xA0][^\w\t\n\r \xA0\"\'\-\+=]*/]
58 | ]),
59 | ['lua']);
60 |
--------------------------------------------------------------------------------
/var_nginx/nginx.conf:
--------------------------------------------------------------------------------
1 | user nginx;
2 | daemon off;
3 | worker_processes 5;
4 | include /var/nginx/local.conf.d/env.conf.d/*.conf;
5 | error_log /dev/stderr warn;
6 | #error_log /var/nginx/logs/nginx.log warn;
7 |
8 | pid /var/nginx/run/nginx.pid;
9 |
10 |
11 | events {
12 | worker_connections 1024;
13 | }
14 |
15 |
16 | http {
17 | ssl_session_cache shared:SSL:10m;
18 | server_tokens off;
19 | more_set_headers 'Server: Lubot 0.0.1';
20 | ssl_protocols TLSv1.2;
21 | ssl_prefer_server_ciphers on;
22 | ssl_session_timeout 5m;
23 | ssl_ciphers 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-SHA256:DHE-DSS-AES128-GCM-SHA256:kE
24 | DH+AESGCM:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA:ECDHE-ECDSA-
25 | AES256-SHA:DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-DSS-AES128-SHA256:DHE-RSA-AES256-SHA256:DHE-DSS-AES256-SHA:DHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256
26 | -SHA:AES:CAMELLIA:DES-CBC3-SHA:!aNULL:!eNULL:!EXPORT:!DES:!RC4:!MD5:!PSK:!aECDH:!EDH-DSS-DES-CBC3-SHA:!EDH-RSA-DES-CBC3-SHA:!KRB5-DES-CBC3-SHA';
27 | include /opt/openresty/nginx/conf/mime.types;
28 | default_type application/octet-stream;
29 |
30 | log_format main '$remote_addr - $http_remote_user [$time_local] "$request" '
31 | '$status $body_bytes_sent "$http_referer" '
32 | '"$http_user_agent" "$http_x_forwarded_for"';
33 |
34 | #access_log /var/nginx/logs/access.log main;
35 | access_log off;
36 |
37 | sendfile on;
38 | tcp_nopush on;
39 | tcp_nodelay on;
40 |
41 | #keepalive_timeout 65;
42 |
43 |
44 | gzip on;
45 | gzip_http_version 1.0;
46 | gzip_comp_level 2;
47 | gzip_proxied any;
48 | gzip_types text/plain text/css application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript application/json;
49 |
50 | server_names_hash_bucket_size 1024;
51 | variables_hash_bucket_size 1024;
52 | client_header_buffer_size 64k;
53 | large_client_header_buffers 4 64k;
54 |
55 | server {
56 | listen 22002;
57 | access_log off;
58 |
59 | location /_status/http {
60 | check_status;
61 | }
62 | location /_status/stub {
63 | stub_status on;
64 | }
65 | location /_status/tcp {
66 | tcp_check_status;
67 | }
68 | }
69 | include /var/nginx/conf.d/*.conf;
70 | }
71 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/brainz/ngx_shared_brain.lua:
--------------------------------------------------------------------------------
1 | local _brain = {}
2 | local _B = {}
3 | _brain._VERSION = "0.0.1"
4 | _brain.name = "ngx_shared"
5 | _brain.persistent = false
6 |
7 | local log = require 'utils.log'
8 |
9 | function _brain.new(...)
10 | local self = {}
11 | local args = ... or {}
12 | setmetatable(self, {__index = _B})
13 | if not args.shared_dict then
14 | print("No shared dictionary specified. Using default.")
15 | self._shared_dict = ngx.shared.lubot_brain
16 | else
17 | self._shared_dict = ngx.shared[args.shared_dict]
18 | end
19 | local success, err, forcible = self._shared_dict:set('activated_brain', true)
20 | if not success then
21 | print("Could not set test data in brain: "..err)
22 | return nil
23 | else
24 | print("Successfully wrote to the brain")
25 | end
26 | return self
27 | end
28 |
29 | local function json_decode(str)
30 | local cjson = require 'cjson'
31 | local json_ok, json = pcall(cjson.decode, str)
32 | if not json_ok then
33 | return nil
34 | else
35 | return json
36 | end
37 | end
38 |
39 | local function json_encode(t)
40 | local cjson = require 'cjson'
41 | local json_ok, json = pcall(cjson.encode, t)
42 | if not json_ok then
43 | return nil
44 | else
45 | return json
46 | end
47 | end
48 |
49 | function _B:save()
50 | -- noop for now
51 | end
52 |
53 | function _B:keys()
54 | local keys = self._shared_dict:get_keys(0)
55 | return keys
56 | end
57 |
58 | function _B:export()
59 | local t = {}
60 | end
61 |
62 | function _B:populate()
63 | end
64 |
65 | function _B:get(k)
66 | local value = self._shared_dict:get(k)
67 | if not value then
68 | return nil
69 | else
70 | local dec = json_decode(value)
71 | if not dec then
72 | return nil
73 | else
74 | return dec
75 | end
76 | end
77 | end
78 |
79 | function _B:set(k, v)
80 | local enc = json_encode(v)
81 | if enc then
82 | local ok, err, force = self._shared_dict:set(k, enc)
83 | if not ok then
84 | log.err("Unable to set key in shared_dict")
85 | return nil
86 | else
87 | return 1
88 | end
89 | else
90 | log.err("Unable to encode value for shared_dict")
91 | return nil
92 | end
93 | end
94 |
95 | function _B:safe_set(k, v)
96 | local encoded_value
97 | local e = json_encode(v)
98 | if not e then
99 | log.err("Unable to encode data to json for key: ", k)
100 | return nil
101 | else
102 | encoded_value = e
103 | end
104 | local success, err, forcible = self._shared_dict:safe_add(k, encoded_value)
105 | if not success then
106 | log.err("Unable to insert value for key "..k.." into dict")
107 | return nil
108 | else
109 | return encoded_value
110 | end
111 | end
112 |
113 | function _B:delete(k)
114 | self._shared_dict:delete(k)
115 | end
116 |
117 | function _B:flush()
118 | self._shared_dict:flush_all()
119 | self._shared_dict:flush_expired()
120 | end
121 |
122 | return _brain
123 |
--------------------------------------------------------------------------------
/lua-releng:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env perl
2 |
3 | use strict;
4 | use warnings;
5 |
6 | use Getopt::Std;
7 |
8 | my (@luas, @tests);
9 |
10 | my %opts;
11 | getopts('Lse', \%opts) or die "Usage: lua-releng [-L] [-s] [-e] [files]\n";
12 |
13 | my $silent = $opts{s};
14 | my $stop_on_error = $opts{e};
15 | my $no_long_line_check = $opts{L};
16 |
17 | if ($#ARGV != -1) {
18 | @luas = @ARGV;
19 |
20 | } else {
21 | @luas = map glob, qw{ *.lua lib/*.lua lib/*/*.lua lib/*/*/*.lua lib/*/*/*/*.lua lib/*/*/*/*/*.lua };
22 | if (-d 't') {
23 | @tests = map glob, qw{ t/*.t t/*/*.t t/*/*/*.t };
24 | }
25 | }
26 |
27 | for my $f (sort @luas) {
28 | process_file($f);
29 | }
30 |
31 | for my $t (@tests) {
32 | blank(qq{grep -H -n --color -E '\\--- ?(ONLY|LAST)' $t});
33 | }
34 | # p: prints a string to STDOUT appending \n
35 | # w: prints a string to STDERR appending \n
36 | # Both respect the $silent value
37 | sub p { print "$_[0]\n" if (!$silent) }
38 | sub w { warn "$_[0]\n" if (!$silent) }
39 |
40 | # blank: runs a command and looks at the output. If the output is not
41 | # blank it is printed (and the program dies if stop_on_error is 1)
42 | sub blank {
43 | my ($command) = @_;
44 | if ($stop_on_error) {
45 | my $output = `$command`;
46 | if ($output ne '') {
47 | die $output;
48 | }
49 | } else {
50 | system($command);
51 | }
52 | }
53 |
54 | my $version;
55 | sub process_file {
56 | my $file = shift;
57 | # Check the sanity of each .lua file
58 | open my $in, $file or
59 | die "ERROR: Can't open $file for reading: $!\n";
60 | my $found_ver;
61 | while (<$in>) {
62 | my ($ver, $skipping);
63 | if (/(?x) (?:_VERSION|version) \s* = .*? ([\d\.]*\d+) (.*? SKIP)?/) {
64 | my $orig_ver = $ver = $1;
65 | $found_ver = 1;
66 | $skipping = $2;
67 | $ver =~ s{^(\d+)\.(\d{3})(\d{3})$}{join '.', int($1), int($2), int($3)}e;
68 | w("$file: $orig_ver ($ver)");
69 | last;
70 |
71 | } elsif (/(?x) (?:_VERSION|version) \s* = \s* ([a-zA-Z_]\S*)/) {
72 | w("$file: $1");
73 | $found_ver = 1;
74 | last;
75 | }
76 |
77 | if ($ver and $version and !$skipping) {
78 | if ($version ne $ver) {
79 | die "$file: $ver != $version\n";
80 | }
81 | } elsif ($ver and !$version) {
82 | $version = $ver;
83 | }
84 | }
85 | if (!$found_ver) {
86 | w("WARNING: No \"_VERSION\" or \"version\" field found in `$file`.");
87 | }
88 | close $in;
89 |
90 | p("Checking use of Lua global variables in file $file...");
91 | p("\top no.\tline\tinstruction\targs\t; code");
92 | blank("luac -p -l $file | grep -E '[GS]ETGLOBAL' | grep -vE '\\<(require|type|tostring|error|ngx|ndk|jit|setmetatable|getmetatable|string|table|io|os|print|tonumber|math|pcall|xpcall|unpack|pairs|ipairs|assert|module|package|coroutine|[gs]etfenv|next|rawget|rawset|rawlen)\\>'");
93 | unless ($no_long_line_check) {
94 | p("Checking line length exceeding 80...");
95 | blank("grep -H -n -E --color '.{81}' $file");
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/var_nginx/lua/utils/slack.lua:
--------------------------------------------------------------------------------
1 | local ngu = require 'utils.nginx'
2 | local pu = require 'utils.plugins'
3 | local log = require 'utils.log'
4 | local m = {}
5 | m._VERSION = "0.0.1"
6 |
7 | local function fields_to_fallback(fields)
8 | local t = {}
9 | for k, v in pairs(fields) do
10 | local txt = string.lower(v.title:gsub("%s+","_"))
11 | table.insert(t,txt..": "..v.value)
12 | end
13 | return table.concat(t, " | ")
14 | end
15 |
16 | m.users_dict = ngx.shared.slack_users
17 | m.groups_dict = ngx.shared.slack_groups
18 | m.channels_dict = ngx.shared.slack_channels
19 | m.ims_dict = ngx.shared.slack_ims
20 | m.bots_dict = ngx.shared.slack_bots
21 |
22 | function m.make_slack_attachment(text, fields, fallback)
23 | local t = {}
24 | t.text = text
25 | t.fields = fields
26 | t.mrkdwn_in = {"pretext", "text", "title", "fields", "fallback"}
27 | if not fallback then
28 | t.fallback = fields_to_fallback(fields)
29 | else
30 | t.fallback = fallback
31 | end
32 | return t
33 | end
34 |
35 | function m.to_rts_message(text, channel)
36 | local t = {
37 | ["type"] = "message",
38 | channel = channel,
39 | id = pu.generate_id(),
40 | text = text
41 | }
42 | return t
43 | end
44 |
45 | function m.say(...)
46 | return {text = table.concat({...}," ")}
47 | end
48 |
49 | function m.to_rich_message(...)
50 | local required_fields = {"text", "fields", "channel", "username"}
51 | local args = ...
52 | for _,k in pairs(required_fields) do
53 | if not args[k] then return nil end
54 | end
55 | local fallback = args.fallback or nil
56 | local attachments = m.make_slack_attachment(args.text, args.fields, fallback)
57 | local t = {
58 | channel = args.channel,
59 | username = args.username,
60 | attachments = {attachments}
61 | }
62 | local shared_dict = ngx.shared.ng_shared_dict
63 | local slack_webhook_url = shared_dict:get('slack_webhook_url')
64 | if not slack_webhook_url then
65 | ngu.logwarn("No slack webhook url, converting message to rts fallback")
66 | return m.to_rts_message(attachments.fallback, args.channel)
67 | else
68 | return t
69 | end
70 | end
71 |
72 | function m.post_chat_message(...)
73 | local args = ...
74 | local hc = require 'httpclient'.new('httpclient.ngx_driver')
75 | local shared_dict = ngx.shared.ng_shared_dict
76 | local webhook_url = shared_dict:get('slack_webhook_url')
77 | if not webhook_url then return nil, "Slack webhook url missing" end
78 | local res = hc:post(webhook_url, args, {headers = {accept = "application/json"}, content_type = "application/json"})
79 | return res
80 | end
81 |
82 | function m.lookup_by_id(c)
83 | local users = m.users_dict
84 | local groups = m.groups_dict
85 | local channels = m.channels_dict
86 | local ims = m.ims_dict
87 | local bots = m.bots_dict
88 |
89 | local result
90 | local match, err = ngx.re.match(c, "^([A-Z]).*", "jo")
91 | if not match then
92 | log.err("Unable to match id to type")
93 | result = nil
94 | else
95 | if match[1] == 'D' then result = safe_json_decode(ims:get(c)) end
96 | if match[1] == 'C' then result = safe_json_decode(channels:get(c)) end
97 | if match[1] == 'G' then result = safe_json_decode(groups:get(c)) end
98 | if match[1] == 'B' then result = safe_json_decode(bots:get(c)) end
99 | if match[1] == 'U' then result = safe_json_decode(users:get(c)) end
100 | end
101 | return result
102 | end
103 |
104 | function m.id_to_user(u)
105 |
106 | end
107 |
108 | function m.id_to_channel(c)
109 | end
110 |
111 | function m.user_is_admin(u)
112 | end
113 |
114 | function m.user_is_restricted(u)
115 | end
116 |
117 | function m.user_id_ultra_restricted(u)
118 | end
119 | return m
120 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM debian:wheezy
2 |
3 | MAINTAINER John E Vincent
4 |
5 | ENV DEBIAN_FRONTEND noninteractive
6 | ENV LANGUAGE en_US.UTF-8
7 | ENV LANG en_US.UTF-8
8 | ENV LC_ALL en_US.UTF-8
9 |
10 |
11 |
12 | RUN apt-get update && apt-get install -y \
13 | supervisor \
14 | git \
15 | curl \
16 | build-essential \
17 | ruby1.9.1-full \
18 | libssl-dev \
19 | libreadline-dev \
20 | libxslt1-dev \
21 | libxml2-dev \
22 | libcurl4-openssl-dev \
23 | zlib1g-dev \
24 | libexpat1-dev \
25 | libicu-dev \
26 | unzip \
27 | libpcre3-dev && \
28 | apt-get clean && \
29 | rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
30 |
31 | RUN curl -L https://github.com/coreos/etcd/releases/download/v0.4.6/etcd-v0.4.6-linux-amd64.tar.gz -o etcd-v0.4.6-linux-amd64.tar.gz && \
32 | tar -zxvf etcd-v0.4.6-linux-amd64.tar.gz && \
33 | cp etcd-v0.4.6-linux-amd64/etcd /
34 |
35 | RUN mkdir /build && \
36 | curl -LO http://openresty.org/download/ngx_openresty-1.7.7.1.tar.gz && \
37 | cd /build/ && \
38 | tar ozxf /ngx_openresty-1.7.7.1.tar.gz
39 |
40 | # Get some additional patches mostly proxy related (in its own group to optimize)
41 | RUN cd /build && \
42 | git clone https://github.com/yaoweibin/nginx_upstream_check_module && \
43 | git clone https://github.com/gnosek/nginx-upstream-fair && \
44 | git clone https://github.com/lusis/nginx-sticky-module && \
45 | git clone https://github.com/yaoweibin/nginx_tcp_proxy_module
46 |
47 | # Patch and build
48 | RUN cd /build/ngx_openresty-1.7.7.1/bundle/nginx-1.7.7 && \
49 | patch -p1 < /build/nginx_upstream_check_module/check_1.7.5+.patch && \
50 | patch -p1 < /build/nginx_tcp_proxy_module/tcp.patch
51 |
52 | RUN mkdir /tmp/client_body_tmp && mkdir /tmp/proxy_temp && cd /build/ngx_openresty-1.7.7.1 && \
53 | ./configure --prefix=/opt/openresty \
54 | --with-luajit \
55 | --with-luajit-xcflags=-DLUAJIT_ENABLE_LUA52COMPAT \
56 | --http-client-body-temp-path=/tmp/client_body_temp \
57 | --http-proxy-temp-path=/tmp/proxy_temp \
58 | --http-log-path=/var/nginx/logs/access.log \
59 | --error-log-path=/var/nginx/logs/error.log \
60 | --pid-path=/var/nginx/run/nginx.pid \
61 | --lock-path=/var/nginx/run/nginx.lock \
62 | --with-http_stub_status_module \
63 | --with-http_ssl_module \
64 | --with-http_secure_link_module \
65 | --with-http_gzip_static_module \
66 | --with-http_sub_module \
67 | --with-http_realip_module \
68 | --without-http_scgi_module \
69 | --with-md5-asm \
70 | --with-sha1-asm \
71 | --with-file-aio \
72 | --with-pcre \
73 | --with-pcre-jit \
74 | --add-module=/build/nginx_upstream_check_module \
75 | --add-module=/build/nginx-upstream-fair \
76 | --add-module=/build/nginx-sticky-module \
77 | --add-module=/build/nginx_tcp_proxy_module
78 |
79 | RUN cd /build/ngx_openresty-1.7.7.1 && \
80 | make && \
81 | make install && \
82 | cd / && \
83 | rm -rf /build && \
84 | ln -sf /opt/openresty/luajit/bin/luajit-2.1.0-alpha /opt/openresty/luajit/bin/lua
85 |
86 | # Hoedown needed for the resty-template markdown rendering
87 | RUN cd /tmp && \
88 | git clone https://github.com/hoedown/hoedown.git && \
89 | cd hoedown && \
90 | make all install && \
91 | cd / && \
92 | rm -rf /tmp/hoedown && \
93 | ldconfig
94 |
95 | # Now the luarocks stuff
96 | RUN curl -s http://luarocks.org/releases/luarocks-2.2.0.tar.gz | tar xvz -C /tmp/ \
97 | && cd /tmp/luarocks-* \
98 | && ./configure --with-lua=/opt/openresty/luajit \
99 | --with-lua-include=/opt/openresty/luajit/include/luajit-2.1 \
100 | --with-lua-lib=/opt/openresty/lualib \
101 | && make && make install \
102 | && ln -sf /opt/openresty/luajit/bin/lua /usr/local/bin/lua \
103 | && rm -rf /tmp/luarocks-*
104 |
105 | RUN /usr/local/bin/luarocks install luasec && \
106 | /usr/local/bin/luarocks install lua-resty-template && \
107 | /usr/local/bin/luarocks install httpclient && \
108 | /usr/local/bin/luarocks install lua-resty-http && \
109 | /usr/local/bin/luarocks install inspect && \
110 | /usr/local/bin/luarocks install lua-resty-hoedown && \
111 | /usr/local/bin/luarocks install xml
112 |
113 | RUN useradd -r -d /var/nginx nginx && chown -R nginx:nginx /var/nginx/ /tmp/client_body_tmp /tmp/proxy_temp
114 |
115 | EXPOSE 3232
116 |
117 | ADD ./supervisord.conf /supervisord.conf
118 | CMD /usr/bin/supervisord -c /supervisord.conf
119 |
--------------------------------------------------------------------------------
/var_nginx/lua/utils/test.lua:
--------------------------------------------------------------------------------
1 | local m = {}
2 | local _M = {}
3 |
4 | m._VERSION = "0.0.1"
5 |
6 | function m.new(...)
7 | local self = {}
8 | local args = ... or {}
9 | self.mock_data = args.mock_data or nil
10 | self.expects = args.expects or nil
11 | self.run_data = args.run_data or nil
12 | self.failures = {}
13 | self.tests = {}
14 | setmetatable(self, {__index = _M})
15 | return self
16 | end
17 |
18 | function _M:set_expects(str)
19 | self.expects = str
20 | end
21 |
22 | function _M:set_mock_data(t)
23 | self.mock_data = t
24 | end
25 |
26 | function _M:set_rundata(t)
27 | self.run_data = t
28 | end
29 |
30 | -- Verify the slack attachment response is correctly formatted
31 | function _M:is_valid_rich_text(...)
32 | if not self.run_data then
33 | self:fail("Missing run data")
34 | else
35 | if not self.run_data.attachments then self:fail("Missing attachments") end
36 | if not self.run_data.attachments[1].fallback then self:fail("Missing fallback text") end
37 | if self.run_data.channel ~= self.mock_data.channel then
38 | self:fail("Channel mismatch in rich response")
39 | end
40 | end
41 | end
42 |
43 | -- Verify the plugin response contains a text element
44 | function _M:responds_text()
45 | if not self.run_data then
46 | self:fail("Missing run data")
47 | else
48 | if not self.run_data.text then
49 | self:fail("No text in response")
50 | end
51 | end
52 | end
53 |
54 | -- Verify the plugin parses a message correctly
55 | function _M:parses_text(regex, named_captures)
56 | local captures
57 | if not self.run_data then
58 | self:fail("Missing run data")
59 | else
60 | if type(named_captures) == 'string' then
61 | captures = {named_captures}
62 | elseif type(named_captures) == 'table' then
63 | captures = named_captures
64 | else
65 | self:fail("Named captures was not provided as a table or string")
66 | end
67 | local m, err = ngx.re.match(self.mock_data.text, regex, 'jo')
68 | if not m then
69 | self:fail("Got no matches for "..regex)
70 | else
71 | for _, c in ipairs(captures) do
72 | if not m[c] then
73 | self:fail("Did not get named capture "..c)
74 | end
75 | end
76 | end
77 | end
78 | end
79 |
80 | -- Verify a specific value was captured
81 | function _M:captures_value(str, regex, capture)
82 | if not self.run_data then
83 | self:fail("Missing run data")
84 | else
85 | local m, err = ngx.re.match(self.mock_data.text, regex, 'jo')
86 | if not m then
87 | self:fail("Did not get any matches")
88 | else
89 | if not m[capture] then
90 | self:fail("Capture element "..capture.." was not found")
91 | else
92 | if m[capture] ~= str then
93 | self:fail("Expected "..str.." but got "..m[capture])
94 | end
95 | end
96 | end
97 | end
98 | end
99 |
100 | -- Verify the plugin reponse contains specific text
101 | function _M:response_contains(...)
102 | local expects = self.expects or ...
103 | if not self.run_data then
104 | self:fail("Missing run data")
105 | elseif not expects then
106 | self:fail("Missing expectation")
107 | else
108 | local m, err = ngx.re.match(self.run_data.text, expects, 'jo')
109 | if not m then
110 | self:fail("Expected "..expects.." but got no matches")
111 | end
112 | end
113 | end
114 |
115 | function _M:reset()
116 | self.failures = {}
117 | self.tests = {}
118 | end
119 |
120 | function _M:fail(msg)
121 | table.insert(self.failures, msg)
122 | end
123 |
124 | function _M:add(func, ...)
125 | if ... then
126 | t = {
127 | name = func,
128 | args = {...}
129 | }
130 | table.insert(self.tests, t)
131 | else
132 | table.insert(self.tests, func)
133 | end
134 | end
135 |
136 | function _M:run(...)
137 | local args = ...
138 | if not self.tests then
139 | self:pass("No tests to run")
140 | else
141 | for _, t in ipairs(self.tests) do
142 | local test_ok, test_res, test_name
143 | if type(t) == 'string' then
144 | test_name = t
145 | test_ok, test_res = pcall(_M[t], self)
146 | else
147 | test_name = t.name
148 | test_ok, test_res = pcall(_M[t.name], self, unpack(t.args))
149 | end
150 | if not test_ok then
151 | self:fail(test_name.." failed to run: "..test_res)
152 | end
153 | end
154 | end
155 | end
156 |
157 | function _M:report()
158 | local report = {}
159 | local failed = #self.failures
160 | if failed > 0 then
161 | report.tests = {}
162 | report.passed = false
163 | report.failures = self.failures
164 | for _, t in ipairs(self.tests) do
165 | if type(t) == 'string' then table.insert(report.tests, t) end
166 | if type(t) == 'table' then table.insert(report.tests, t.name) end
167 | end
168 | report.msg = "Failed "..failed.."/"..#report.tests
169 | else
170 | report.passed = true
171 | report.tests = self.tests
172 | report.msg = "All tests passed"
173 | report.response = self.run_data.text
174 | end
175 | return report
176 | end
177 |
178 | return m
179 |
--------------------------------------------------------------------------------
/var_nginx/lua/api.lua:
--------------------------------------------------------------------------------
1 | local _VERSION = "0.0.1"
2 | local router = require 'router'
3 | local ngu = require 'utils.nginx'
4 | local plugutils = require 'utils.plugins'
5 | local log = require 'utils.log'
6 |
7 | local r = router.new()
8 |
9 | r:get('/_private/api/brain/inspect', function(params)
10 | local inspect = require('inspect')
11 | ngx.header.content_type = "text/plain"
12 | local response = inspect(robot.brain)
13 | ngx.say(response)
14 | ngx.exit(ngx.HTTP_OK)
15 | end)
16 |
17 | r:get('/_private/api/brain/all', function(params)
18 | local data = robot.brain:keys()
19 | if not data then
20 | log.alert("No data in brain")
21 | plugutils.respond_as_json({count = 0, msg = "No data found in brain"})
22 | else
23 | local t = {}
24 | for _, k in ipairs(data) do
25 | local v = robot.brain:get(k)
26 | if not v then
27 | --log.alert("Missing data for key ", k)
28 | else
29 | t[k] = v
30 | end
31 | end
32 | plugutils.respond_as_json(t)
33 | end
34 | end)
35 |
36 | r:get('/_private/api/brain/:key', function(params)
37 | local data = robot.brain:get(params.key)
38 | if not data then return {} end
39 | local t = {}
40 | t[params.key] = data
41 | plugutils.respond_as_json(t)
42 | end)
43 |
44 | r:post('/_private/api/plugins/find_match', function(params)
45 | local data = params.data
46 | local plugin = plugutils.find_plugin_for(data)
47 | if not plugin then
48 | plugutils.respond_as_json({count = 0})
49 | else
50 | local results = {}
51 | for _, k in ipairs(plugin) do table.insert(results, k.id) end
52 | plugutils.respond_as_json({count = #plugin, results = results})
53 | end
54 | end)
55 |
56 | r:get('/_private/api/plugins/last_error/:plugin_name', function(params)
57 | local plugin = plugutils.get_last_error(params.plugin_name)
58 | if not plugin then
59 | plugutils.respond_as_json({
60 | msg = "no logs available for plugin "..params.plugin_name
61 | })
62 | else
63 | plugutils.respond_as_json(plugin)
64 | end
65 | end)
66 |
67 | r:get('/_private/api/plugins/logs/:plugin_name', function(params)
68 | local plugin = plugutils.get_logs(params.plugin_name)
69 | if not plugin then
70 | plugutils.respond_as_json({
71 | msg = "no logs available for plugin "..params.plugin_name
72 | })
73 | else
74 | plugutils.respond_as_json(plugin)
75 | end
76 | end)
77 |
78 | r:get('/_private/api/plugins/help/:plugin_name', function(params)
79 | local plugin = plugutils.plugin_help(params.plugin_name)
80 | if not plugin then
81 | plugutils.respond_as_json({
82 | msg = "no help available for plugin "..params.plugin_name
83 | })
84 | else
85 | plugutils.respond_as_json({msg = plugin})
86 | end
87 | end)
88 |
89 | r:post('/_private/api/plugins/run/:plugin_name', function(params)
90 | local data
91 | if params.data then
92 | data = safe_json_decode(params.data)
93 | end
94 | local plugin = plugutils.safe_plugin_run(params.plugin_name, data)
95 | plugutils.respond_as_json(plugin)
96 | end)
97 |
98 | r:post('/_private/api/plugins/test/:plugin_name', function(params)
99 | local data
100 | if params.data then
101 | data = safe_json_decode(params.data)
102 | end
103 | local plugin = plugutils.plugin_test(params.plugin_name, data)
104 | plugutils.respond_as_json(plugin)
105 | end)
106 |
107 | r:get('/_private/api/plugins/stats/all', function(params)
108 | local list = plugutils.get_active()
109 | local resp = {}
110 | if not list then plugutils.respond_as_json({}) end
111 | for _, p in ipairs(list) do
112 | local stats = plugutils.plugin_stats(p)
113 | if not stats then
114 | resp[p] = {}
115 | else
116 | resp[p] = {errors = stats.errors, executions = stats.executions}
117 | end
118 | end
119 | plugutils.respond_as_json(resp)
120 | end)
121 |
122 | r:get('/_private/api/plugins/stats/:plugin_name', function(params)
123 | local plugin = plugutils.plugin_stats(params.plugin_name)
124 | plugutils.respond_as_json(plugin)
125 | end)
126 |
127 | r:get('/_private/api/plugins/details/all', function(params)
128 | local list = plugutils.get_active()
129 | local resp = {}
130 | if not list then plugutils.respond_as_json({}) end
131 | for _, p in ipairs(list) do
132 | local details = plugutils.plugin_details(p)
133 | if not details or details.err then
134 | resp[p] = {}
135 | else
136 | resp[p] = details
137 | end
138 | end
139 | plugutils.respond_as_json(resp)
140 | end)
141 |
142 | r:get('/_private/api/plugins/details/:plugin_name', function(params)
143 | local plugin = plugutils.plugin_details(params.plugin_name)
144 | plugutils.respond_as_json(plugin)
145 | end)
146 |
147 | r:get('/_private/api/plugins/list', function(params)
148 | local active_plugins = plugutils.get_active()
149 | plugutils.respond_as_json(active_plugins)
150 | end)
151 |
152 | r:get('/_private/api/lubot/botname', function(params)
153 | local botname = robot.brain:get('botname')
154 | plugutils.respond_as_json({botname = botname})
155 | end)
156 |
157 | local method = string.lower(ngx.req.get_method())
158 | local path = ngx.var.uri
159 | local query_params = {}
160 | ngx.req.read_body()
161 |
162 | if method == "post" or method == "put" then
163 | local q, err = ngx.req.get_body_data()
164 | if not q then
165 | log.warn("POST/PUT with no body data")
166 | else
167 | query_params.data = q
168 | end
169 | else
170 | query_params = ngx.var.args
171 | end
172 | r:execute(method, path, query_params)
173 |
--------------------------------------------------------------------------------
/var_nginx/lua/router.lua:
--------------------------------------------------------------------------------
1 | local router = {
2 | _VERSION = 'router.lua v0.6',
3 | _DESCRIPTION = 'A simple router for Lua',
4 | _LICENSE = [[
5 | MIT LICENSE
6 |
7 | * table_copyright (c) 2013 Enrique García Cota
8 | * table_copyright (c) 2013 Raimon Grau
9 |
10 | Permission is hereby granted, free of charge, to any person obtaining a
11 | table_copy of this software and associated documentation files (the
12 | "Software"), to deal in the Software without restriction, including
13 | without limitation the rights to use, table_copy, modify, merge, publish,
14 | distribute, sublicense, and/or sell copies of the Software, and to
15 | permit persons to whom the Software is furnished to do so, subject to
16 | the following conditions:
17 |
18 | The above table_copyright notice and this permission notice shall be included
19 | in all copies or substantial portions of the Software.
20 |
21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
22 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
23 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
24 | IN NO EVENT SHALL THE AUTHORS OR table_copyRIGHT HOLDERS BE LIABLE FOR ANY
25 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
26 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
27 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
28 | ]]
29 | }
30 |
31 | local function split(str, delimiter)
32 | local result = {}
33 | delimiter = delimiter or " "
34 | for chunk in str:gmatch("[^".. delimiter .. "]+") do
35 | result[#result + 1] = chunk
36 | end
37 | return result
38 | end
39 |
40 | local function array_get_head_and_tail(t)
41 | local tail = {}
42 | for i=2, #t do tail[i-1] = t[i] end
43 | return t[1], tail
44 | end
45 |
46 | local function table_merge(dest, src)
47 | if not src then return end
48 | for k,v in pairs(src) do
49 | dest[k] = tostring(v)
50 | end
51 | end
52 |
53 | local function table_copy(t)
54 | local result = {}
55 | for k,v in pairs(t) do result[k] = v end
56 | return result
57 | end
58 |
59 | local function resolve_rec(remaining_path, node, params)
60 | if not node then return nil end
61 |
62 | -- node is a _LEAF and no remaining tokens; found end
63 | if #remaining_path == 0 then return node[router._LEAF], params end
64 |
65 | local current_token, child_path = array_get_head_and_tail(remaining_path)
66 |
67 | -- always resolve static strings first
68 | for key, child in pairs(node) do
69 | if key == current_token then
70 | local f, bindings = resolve_rec(child_path, child, params)
71 | if f then return f, bindings end
72 | end
73 | end
74 |
75 | -- then resolve parameters
76 | for key, child in pairs(node) do
77 | if type(key) == "table" and key.param then
78 | local child_params = table_copy(params)
79 | child_params[key.param] = current_token
80 | local f, bindings = resolve_rec(child_path, child, child_params)
81 | if f then return f, bindings end
82 | end
83 | end
84 |
85 | return false
86 | end
87 |
88 | local function find_key_for(token, node)
89 | local param_name = token:match("^:(.+)$")
90 | -- if token is not a param( it does not begin with :) then return the token
91 | if not param_name then return token end
92 |
93 | -- otherwise, it's a param, like :id. If it exists as a child of the node, we return it
94 | for key,_ in pairs(node) do
95 | if type(key) == 'table' and key.param == param_name then return key end
96 | end
97 |
98 | -- otherwise, it's a new key to be inserted
99 | return {param = param_name}
100 | end
101 |
102 |
103 | local function match_one_path(self, method, path, f)
104 | self._tree[method] = self._tree[method] or {}
105 | local node = self._tree[method]
106 | for _,token in ipairs(split(path, "/")) do
107 | local key = find_key_for(token, node)
108 | node[key] = node[key] or {}
109 | node = node[key]
110 | end
111 | node[router._LEAF] = f
112 | end
113 |
114 | ------------------------------ INSTANCE METHODS ------------------------------------
115 |
116 | local Router = {}
117 |
118 | function Router:resolve(method, path)
119 | return resolve_rec(split(path, "/"), self._tree[method] , {})
120 | end
121 |
122 | function Router:execute(method, path, query_params)
123 | local f,params = self:resolve(method, path)
124 | if not f then return nil, ('Could not resolve %s %s'):format(method, path) end
125 |
126 | table_merge(params, query_params)
127 |
128 | return true, f(params)
129 | end
130 |
131 | function Router:match(method, path, f)
132 | if type(method) == 'table' then
133 | local t = method
134 | for method, routes in pairs(t) do
135 | for path, f in pairs(routes) do
136 | match_one_path(self, method, path, f)
137 | end
138 | end
139 | else
140 | match_one_path(self, method, path, f)
141 | end
142 | end
143 |
144 | for _,http_method in ipairs({'get', 'post', 'put', 'delete', 'trace', 'connect', 'options', 'head'}) do
145 | Router[http_method] = function(self, path, f) -- Router.get = function(self, path, f)
146 | return self:match(http_method, path, f) -- return self:match('get', path, f)
147 | end -- end
148 | end
149 |
150 | local router_mt = { __index = Router }
151 |
152 | ------------------------------ PUBLIC INTERFACE ------------------------------------
153 | router._LEAF = {}
154 |
155 | router.new = function()
156 | return setmetatable({ _tree = {} }, router_mt)
157 | end
158 |
159 | return router
160 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Lubot
2 | Proof of concept Slack chatbot hosted entirely inside nginx
3 |
4 | ## Motivation
5 | The main motivation for this was an extension of work I did for my [SysAdvent 2014 post](http://sysadvent.blogspot.com/2014/12/day-22-largely-unappreciated.html) on Nginx, Lua and OpenResty.
6 | For that post I created a [docker container](https://github.com/lusis/sysadvent-2014) to run the examples/tutorials. Part of those tutorials were a couple of Slack RTM clients - one of which was nginx/openresty operating as a [websocket client](https://github.com/lusis/sysadvent-2014/blob/master/var_nginx/lua/slack.lua).
7 |
8 | I got it in my head that I could write a [hubot clone](https://github.com/github/hubot) using that.
9 |
10 | ## How it works
11 | This is a prefab container like the previous example. A `Makefile` is provided to set everything up. You'll need to create a [Slack Bot User](https://api.slack.com/bot-users) and optionally you'll need an incoming webhook url (if you want to use rich formatting) as the [RTM Api doesn't support those yet](https://twitter.com/slackapi/status/542912319957643265)
12 |
13 | Build and start the container like so:
14 | `DOCKER_ENV='--env SLACK_API_TOKEN=xoxb-XXXXXXX --env SLACK_WEBHOOK_URL="https://hooks.slack.com/services/XXXXXX"' make all`
15 |
16 | (future invocations after the initial build can use `make run` in place of `make all`)
17 |
18 | You can watch the logs via `tail -f var_nginx/logs/error.log`.
19 |
20 | # How it works
21 | During startup, one of the nginx worker threads will connect as a websocket client to the RTM api. During the authentication response from Slack, the user/group/etc data is loaded into a shared dict. This is largely unused right now.
22 |
23 | The bot will be auto-joined to the `#general` channel. I'd suggest either opening a private message session with it or a dedicated private channel.
24 |
25 | ## Plugins
26 | Hubot has plugins. Lubot has plugins but they're "different". The way lubot plugins work are:
27 |
28 | - A message prefixed with the botname (default `lubot`) is noted.
29 | - The first word after the botname is considered the command (this will change)
30 | - The command is parsed and an http request is made to `http://127.0.0.1:3131/lubot_plugin?plugin=` (this is a `content_by_lua_file` script - `var_nginx/lua/lubot_plugin.lua`)
31 | - The plugin is executed in a fairly "safe" manner and the response is returned to be sent to slack via the existing websocket connection
32 | - If the result has an attachment element, it attempts to send that over the incoming webhook. If you've not provided a webhook url the `fallback` text required by the slack api is used instead and sent over websockets. If you do not provide a fallback yourself, the fields of your attachment will be converted to build a fallback message.
33 |
34 | ### Plugin location
35 | The plugins are located in the `var_nginx/lubot_plugins/` directory. There are three sub-directories:
36 | - `core`: core plugins
37 | - `contrib`: third-party plugins
38 | - `user`: local plugins
39 |
40 | The lua search path for lubot will look in the following order: user -> core -> contrib. This feels like the sanest mechanism for overrides.
41 |
42 | They currently have the following restrictions:
43 | - Must be named `lubot_.lua` and must return a table matching an RTM message object
44 | - If returning an attachment, you must follow the slack formatting rules for attachments returned as a table of attachments
45 |
46 | For examples see the three existing plugins. The `status` plugin sends an attachment.
47 |
48 | One nice thing about these plugins is that you can test them with curl:
49 |
50 | ```
51 | jvbuntu :: ~ » curl -XPOST -d'{"channel":"foo","user":"test","text":"lubot image me foobar","expects":"foobar"}' http://localhost:3232/api/plugins/test/image
52 |
53 | {"expected":"foobar","results":"http:\/\/khromov.files.wordpress.com\/2011\/02\/foobar_cover.png","passed":true,"got":"foobar"}
54 | ```
55 |
56 | Some plugins don't have any assertions you can provide. Take the ping plugin:
57 |
58 | ```
59 | jvbuntu :: ~ » curl -XPOST -d'{"channel":"foo","user":"test"}' http://localhost:3232/api/plugins/test/ping
60 | {"expected":"^pong .*$","passed":true,"got":"pong (1420583382)"}
61 | ```
62 |
63 | ## API
64 | You may have noticed in the plugin testing section, the call to `/api/plugins`. Pretty much everything inside lubot is an api call to itself. This provides the benefit of being able to use it with multiple tools. Lubot listens on two ports - `3232` and `3131`. Public communications are handled over `3232`. However internally, all api calls go to `3131`. You should never expose `3131` to the public. Instead you should `proxy_pass` requests to the private port. The api called for testing does just that (`var_nginx/conf.d/lubot.conf`):
65 |
66 | ```
67 | location /api {
68 | lua_code_cache off;
69 | proxy_pass_request_headers on;
70 | proxy_redirect off;
71 | proxy_buffering off;
72 | proxy_cache off;
73 | rewrite ^/api/(.*) /_private/api/$1 break;
74 | proxy_pass http://private_api;
75 | }
76 | ```
77 |
78 | The corresponding private api config (in `var_nginx/conf.d/private.conf`):
79 |
80 | ```
81 | location /_private/api {
82 | lua_code_cache off;
83 | content_by_lua_file '/var/nginx/lua/api.lua';
84 | }
85 | ```
86 |
87 | # Customization
88 | More customization information is available from the web ui inside lubot. These are just markdown files served by lubot and they are available in `var_nginx/content/html/docs`
89 |
90 | The general idea for customization is a combination of:
91 |
92 | - nginx includes at critical points
93 | - environment variables
94 | - predefined site-specific directories on the lua load path before core load paths
95 |
96 | In general you should not need to touch ANY shipped files unless you are developing core functionality.
97 |
98 | # Production ready
99 | Actually...yeah...kinda. I'll probably move the websocket connection back out of the `init_by_lua` and into the worker the way it works in the sysadvent container.
100 | Also note that having a worker handling the websocket stuff means that worker cannot service nginx requests because it's being blocked.
101 |
102 | # TODO
103 | - Create a management API and page to be served
104 | - Possibly migrate the websocket logic back into `init_worker_by_lua`
105 | - Maybe consider porting this to hipchat or something
106 |
107 |
--------------------------------------------------------------------------------
/var_nginx/lubot_plugins/core/lubot_plugins.lua:
--------------------------------------------------------------------------------
1 | _plugin = {}
2 |
3 | _plugin.id = "plugins"
4 | _plugin.version = "0.0.1"
5 | _plugin.regex = [=[(plugins|plugin) (?(enable|disable|list|stats|logs|last_error))?\s?(?\w+)?$]=]
6 |
7 | local p = require 'utils.plugins'
8 | local chat = require 'utils.slack'
9 | local ngu = require 'utils.nginx'
10 | local log = require 'utils.log'
11 |
12 | local function process_action(...)
13 | local args = ...
14 | if args.plugin_action == "list" then
15 | local active = p.get_active()
16 | return "active plugins: "..table.concat(active, " | ")
17 | end
18 | if args.plugin_action == "stats" then
19 | if not args.plugin_name then
20 | return "Missing plugin name"
21 | else
22 | if args.plugin_name == "all" then
23 | local errors = 0
24 | local executions = 0
25 | local active = p.get_active()
26 | for _, v in ipairs(active) do
27 | local stats = p.plugin_stats(v)
28 | if stats then
29 | errors = errors + stats.errors
30 | executions = executions + stats.executions
31 | end
32 | end
33 | return "stats for all plugins: errors = "..errors.." | executions = "..executions
34 | else
35 | local stats = p.plugin_stats(args.plugin_name)
36 | if not stats then
37 | return "no stats for "..args.plugin_name
38 | else
39 | local s = {}
40 | for k,v in pairs(stats) do if k ~= "plugin" then table.insert(s, k.."="..v) end end
41 | return "stats for plugin '"..stats.plugin.."': "..table.concat(s, " | ")
42 | end
43 | end
44 | end
45 | end
46 | if args.plugin_action == "disable" then
47 | if not args.plugin_name then
48 | return "Missing plugin name"
49 | end
50 | if args.plugin_name == _plugin.id then
51 | return "You can't disable this plugin this way"
52 | end
53 | p.disable_plugin(args.plugin_name)
54 | if args.data and args.data.user and args.data.channel then
55 | local who = args.data.user
56 | local where = args.data.channel
57 | local user = chat.lookup_by_id(args.data.user)
58 | if type(user) == 'table' then
59 | who = user.name
60 | end
61 | local channel = chat.lookup_by_id(args.data.channel)
62 | if type(channel) == 'table' then
63 | if channel.is_group then where = "private group "..channel.name end
64 | if channel.is_channel then where = "public channel "..channel.name end
65 | if channel.is_im then where = "a private chat" end
66 | end
67 | p.plog(_plugin.id, "Plugin "..args.plugin_name.." disabled by "..who.." in "..where)
68 | end
69 | return "Disabled plugin "..args.plugin_name
70 | end
71 | if args.plugin_action == "enable" then
72 | if not args.plugin_name then
73 | return "Missing plugin name"
74 | end
75 | local active = p.get_active()
76 |
77 | if args.plugin_name == _plugin.id or active[args.plugin_name] then
78 | return "plugin already active"
79 | end
80 | local enable, err = p.enable_plugin(args.plugin_name)
81 | if not enable then
82 | if err then return err end
83 | end
84 | if args.data and args.data.user and args.data.channel then
85 | local who = args.data.user
86 | local where = args.data.channel
87 | local user = chat.lookup_by_id(args.data.user)
88 | if type(user) == 'table' then
89 | who = user.name
90 | end
91 | local channel = chat.lookup_by_id(args.data.channel)
92 | if type(channel) == 'table' then
93 | if channel.is_group then where = "private group "..channel.name end
94 | if channel.is_channel then where = "public channel "..channel.name end
95 | if channel.is_im then where = "a private chat" end
96 | end
97 | p.plog(_plugin.id, "Plugin "..args.plugin_name.." enabled by "..who.." in "..where)
98 | end
99 | return "Enabled plugin "..args.plugin_name
100 | end
101 | if args.plugin_action == "logs" then
102 | if not args.plugin_name then
103 | return "Missing plugin name"
104 | end
105 | local logs = p.get_logs(args.plugin_name)
106 | if #logs == 0 then return "No logs" end
107 | local lines = {}
108 | for _, k in ipairs(logs) do
109 | table.insert(lines, "["..ngx.http_time(k.timestamp).."] "..k.msg)
110 | end
111 | return table.concat(lines, "\n")
112 | end
113 | if args.plugin_action == "last_error" then
114 | if not args.plugin_name then
115 | return "Missing plugin name"
116 | end
117 | local logs = p.get_last_error(args.plugin_name)
118 | if not logs then return "No errors" end
119 | return "["..logs.tstamp.."] "..logs.msg
120 | end
121 | end
122 |
123 | function _plugin.run(data)
124 | log.inspect(data)
125 | if not data.text then
126 | return nil, "Missing message text"
127 | end
128 | local m, err = ngx.re.match(data.text, _plugin.regex, 'jo')
129 | if not m then
130 | return nil, "Unable to match '"..data.text.."' to '".._plugin.regex.."'"
131 | else
132 | if not m.plugin_action then
133 | return nil, "Unable to find an action to take"
134 | end
135 | local params = {
136 | plugin_name = m.plugin_name,
137 | plugin_action = m.plugin_action,
138 | data = data
139 | }
140 | local resp = process_action(params)
141 | return chat.say(resp)
142 | end
143 | end
144 |
145 | function _plugin.help()
146 | local h = [[
147 | [plugin|plugins] enable : enables the specified plugin
148 | [plugin|plugins] disable : disabled the specified plugin
149 | [plugin|plugins] list: lists all active plugins
150 | [plugin|plugins] stats : returns cumulative stats for all plugins or just the specified plugin
151 | [plugin|plugins] last_error : returns last error generated by the specified plugin
152 | [plugin|plugins] logs : returns any logs generated by the specified plugin
153 | ]]
154 | return h
155 | end
156 |
157 | function _plugin.test(...)
158 | -- use our own data here
159 | local data = {user = "foo", channel = "bar", text = "plugin stats ping"}
160 | local res, err = _plugin.run(data)
161 | local params = {
162 | mock_data = data,
163 | run_data = res
164 | }
165 | local t = require('utils.test').new(params)
166 | t:add("responds_text")
167 | t:add("response_contains", [=[(^stats for plugin '\w+': errors=\d+ | executions=\d+)$]=])
168 | t:run()
169 | return t:report()
170 | end
171 |
172 | return _plugin
173 |
--------------------------------------------------------------------------------
/var_nginx/content/html/docs/plugins.md:
--------------------------------------------------------------------------------
1 | # Plugins
2 | Plugins in lubot are pretty straight forward. The boilerplate required is very minimal. In general a lubot plugin has the following required structure:
3 |
4 | ```lua
5 | local plugin = {}
6 |
7 | plugin.id = "something" -- The name of your plugin. Will be concatenated with `lubot_` to locate the module
8 | plugin.version = "0.0.1" -- A version string - arbitrary text
9 | plugin.regex = [[something]] -- The keyword that will route the message to this plugin. Never use a start-of-string token here
10 |
11 | -- internal requirements/shipped helpers. You probably want these.
12 | local plugins = require 'utils.plugins'
13 | local slack = require 'utils.slack'
14 |
15 | function plugin.run(data)
16 | local text = "Hello from lubot"
17 | return slack.say(text)
18 | end
19 |
20 | function plugin.help()
21 | local h = [[This plugin says things]]
22 | return h
23 | end
24 |
25 | -- optional test support
26 | function plugin.test(data)
27 | local res = plugin.run(data)
28 | local expects = [=[^Hello from .*$]=]
29 | local params = {
30 | mock_data = data,
31 | run_data = res
32 | }
33 | local t = require('utils.test').new(params)
34 | -- does it contain a text key (required for slack RTS responses)
35 | t:add("responds_text")
36 | -- does the response match a given regex or string
37 | t:add("response_contains", expects)
38 | -- run the test
39 | t:run()
40 | -- return the report
41 | return t:report()
42 | end
43 |
44 | return plugin
45 | ```
46 |
47 | ## Breakdown
48 | Plugin routing uses the following flow (using the above plugin as an example)
49 |
50 | - match msg text (without the botname prefix) against the regex for each active plugin (this needs instrumenting)
51 | - makes an internal http api request (over unix domain socket) to `/_private/api/plugins/run/` passing in the message data
52 |
53 | Once the api request takes over, the following happens:
54 |
55 | - concatenate the id of plugin with `lubot_` (i.e `lubot_something` using the above example)
56 | - safely load the plugin
57 | - call the `run` function on the plugin with the message data as the argument
58 |
59 | The api passes the json response back up to the websocket session. If the message was a rich text message, it's sent via webhook otherwise response is over websockets
60 |
61 | It seems complicated but the benefit here is that by internally using an HTTP api, the same flow can be used for testing entirely outside of the chat session. No need for a console plugin of any kind.
62 |
63 | In general, all you need to know is that plugins must define the following:
64 |
65 | - `id`: used to concatenate and load the module as `lubot_`
66 | - `regex`: used to match the text addressed to the botname (excluding the botname)
67 | - `version`: unused for now but still required
68 | - a run function
69 |
70 | ## Activating the plugin
71 | Save the above plugin as `var_nginx/lubot_plugins/user/lubot_something.lua`.
72 | Activate the plugin either via API (`/api/plugins/activate/`) or chat (`lubot plugins activate `)
73 |
74 | ## Running the plugin
75 | You can use plugins with curl fairly easily:
76 |
77 | ```
78 | » curl -s -XPOST -d'{"channel":"foo","user":"test"}' http://localhost:3232/api/plugins/run/something | python -mjson.tool
79 | {
80 | "text": "Hello from lubot"
81 | }
82 | ```
83 |
84 | or you can use the chat interface.
85 |
86 | ### Debugging
87 | If your plugin failed to run for whatever reason, you can always use the `plugins` plugin to get the last error generated like so:
88 |
89 | ```
90 | plugins last_error
91 | ```
92 |
93 | Additionally inside your plugin, you can always call `plugins.log("some message")` assuming you required the `utils.plugins` module as `plugins`. Otherwise use the name you chose when including the module.
94 |
95 | When you log via `plugins.log()`, you can get to these log entries via chat:
96 |
97 | ```
98 | plugins logs
99 | ```
100 |
101 | ## Plugin tests
102 | Lubot plugins have optional support for testing using a mini-test suite approach. It's a tad bit verbose but as this test actually runs the plugin, it allows you to define precisely how far you want to test. Using the following tests for our sample plugin above:
103 |
104 | ```lua
105 | function plugin.test(data)
106 | local res = plugin.run(data)
107 | local expects = [=[^Hello from .*$]=]
108 | local params = {
109 | mock_data = data,
110 | run_data = res
111 | }
112 | local t = require('utils.test').new(params)
113 | -- does it contain a text key (required for slack RTS responses)
114 | t:add("responds_text")
115 | -- does the response match a given regex or string
116 | t:add("response_contains", expects)
117 | -- run the test
118 | t:run()
119 | -- return the report
120 | return t:report()
121 | end
122 | ```
123 |
124 | You can test the plugin with curl like so:
125 |
126 | ```
127 | » curl -s -XPOST -d'{"channel":"foo","user":"test", "text":"lubot something"}' http://localhost:3232/api/plugins/test/something | python -mjson.tool
128 | {
129 | "msg": "All tests passed",
130 | "passed": true,
131 | "response": "Hello from lubot",
132 | "tests": [
133 | "responds_text",
134 | {
135 | "args": [
136 | "^Hello from .*$"
137 | ],
138 | "name": "response_contains"
139 | }
140 | ]
141 | }
142 | ```
143 |
144 | Tests with failures look like this:
145 |
146 | ```
147 | » curl -s -XPOST -d'{"channel":"foo","user":"test","text":"lubot something"}' http://localhost:3232/api/plugins/test/something | python -mjson.tool
148 | {
149 | "failures": [
150 | "Expected ^foobar but got no matches"
151 | ],
152 | "msg": "Failed 1/2",
153 | "passed": false,
154 | "tests": [
155 | "responds_text",
156 | "response_contains"
157 | ]
158 | }
159 | ```
160 |
161 | ### Test Methods
162 | The following methods are available for use in tests:
163 |
164 | - `is_valid_rich_text()`: is the response valid for sending as a webhook with attachments
165 | - `responds_text()`: does the response contain a text response
166 | - `parses_text(regex, named_captures)`: does `regex` result contain the `named_captures`. `named_captures` can be either a string or a table containing multiple named captures
167 | - `captures_value(str, regex, capture)`: does running `regex` against `str` result in `capture`. `capture` can either be an integer (for an index) or a named capture. Reminder lua indexes start at `1` not `0`
168 | - `response_contains(str)`: does the response contain `str`. `str` is not required. If not provided, the value of `t.expects` will be used.
169 |
170 |
171 |
--------------------------------------------------------------------------------
/var_nginx/content/js/lb.js:
--------------------------------------------------------------------------------
1 | var plugin_base_url = window.location.protocol+"//"+window.location.hostname+":"+window.location.port+"/api/plugins";
2 | var run_api_url = plugin_base_url+"/run/";
3 | var help_api_url = plugin_base_url+"/help/";
4 | var plugin_list_url = plugin_base_url+"/list";
5 | var plugin_stats_url = plugin_base_url+"/stats/";
6 | var plugin_details_url = plugin_base_url+"/details/";
7 |
8 | function getPlugins(callback) {
9 | console.log("Getting plugin list");
10 | $.ajax({
11 | type: "GET",
12 | url: plugin_details_url+"all",
13 | success: callback,
14 | error: function(res){ console.log("failed to pull list of plugins: "+JSON.stringify(res));}
15 | });
16 | }
17 |
18 | function getPluginStats(plugin, callback) {
19 | console.log("Getting stats for plugin: "+plugin);
20 | $.ajax({
21 | type: "GET",
22 | url: plugin_stats_url+plugin,
23 | success: callback,
24 | error: function(res){ console.log("failed to pull stats for plugin "+plugin+": "+JSON.stringify(res));}
25 | });
26 | }
27 |
28 | function toggleTestRow(plugin) {
29 | $("#plugin_"+plugin+"_test_input_row").toggle();
30 | $("#plugin_"+plugin+"_test_output").toggle();
31 | }
32 |
33 | function toggleHelpRow(plugin) {
34 | $("#plugin_"+plugin+"_help_output_row").toggle();
35 | runPluginHelp(plugin);
36 | }
37 | function getPluginsCallback(res) {
38 | _.each(res, function(thing) {
39 | var rowId = "plugin_"+thing.id
40 | var nameId = "plugin_name_"+thing.id
41 | var versionId = "plugin_version_"+thing.id
42 | var errorsId = "plugin_errors_"+thing.id
43 | var execsId = "plugin_executions_"+thing.id
44 | var testId = "plugin_test_"+thing.id
45 | var helpId = "plugin_help_"+thing.id
46 | var testInputRowId = "plugin_"+thing.id+"_test_input_row"
47 | var testInputId = "plugin_"+thing.id+"_test_input"
48 | var testOutputRowId = "plugin_"+thing.id+"_test_output_row"
49 | var testOutputId = "plugin_"+thing.id+"_test_output"
50 | var helpOutputId = "plugin_"+thing.id+"_help_output"
51 | var helpOutputRowId = "plugin_"+thing.id+"_help_output_row"
52 | var plugin_row = [
53 | "