changeset 3000:02fc3b64cbb7

Initial commit of mod_slack_webhooks. This provides an HTTP-based interface to and from Prosody-hosted MUCs equivalent to Slack's incoming and outgoing webhook interfaces, allowing a variety of Slack integrations to be used with a Prosody MUC.
author Nathan Whitehorn <nwhitehorn@physics.ucla.edu>
date Sun, 15 Apr 2018 08:45:43 -0700
parents d631fd9a3300
children 1108a40c3118
files mod_slack_webhooks/README.markdown mod_slack_webhooks/mod_slack_webhooks.lua
diffstat 2 files changed, 219 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_slack_webhooks/README.markdown	Sun Apr 15 08:45:43 2018 -0700
@@ -0,0 +1,87 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: 'Allow Slack integrations to work with Prosody MUCs'
+...
+
+Introduction
+============
+
+This module provides a Slack-compatible "web hook" interface to Prosody MUCs.
+Both "incoming" web hooks, which allow Slack integrations to post messages
+to Prosody MUCs, and "outgoing" web hooks, which copy messages from Prosody
+MUCs to Slack-style integrations by HTTP, are supported. This can also be
+used, in conjunction with various Slack inter-namespace bridging tools, to
+provide a bidirectional bridge between a Prosody-hosted XMPP MUC and a Slack
+channel.
+
+Usage
+=====
+
+First copy the module to the prosody plugins directory.
+
+Then add "slack\_webhooks" to your modules\_enabled list:
+
+``` {.lua}
+Component "conference.example.org" "muc"
+modules_enabled = {
+  "slack_webhooks",
+}
+```
+
+Configuration
+=============
+
+The normal use for this module is to provide an incoming webhook to allow
+integrations to post to prosody MUCs:
+
+``` {.lua}
+incoming_webhook_path = "/msg/DFSDF56587658765NBDSA"
+default_from_nick = "Bot" -- Unless otherwise specified, posts as "Bot"
+```
+
+This allows Slack-style JSON messages posted to http://conference.example.org/msg/DFSDF56587658765NBDSA/chat to appear in the MUC chat@conference.example.org. A username field in the message is honored as the nick attached to the message; if no username is specified, the message will use the value of default_from_nick.
+Specifying a string of random gibberish in the URL is important to prevent spam.
+
+In addition, there is a second operating mode equivalent to Slack's outgoing
+webhooks. This allows all messages from a set of specified chat rooms to be
+routed to an external server over HTTP in the format used by Slack's
+outgoing webhooks.
+``` {.lua}
+outgoing_webhook_routing = {
+	-- Send all messages from chat@conference.example.org to
+	-- a web server.
+	["chat"] = "http://example.org/cgi-bin/messagedest",
+}
+```
+
+Known Issues
+============
+
+The users from whom messages delivered from integrations are apparently
+delivered are not, in general, members of the MUC. Other prosody modules
+that try to look up information about the users who most messages, mostly
+logging modules, may become confused and fail (clients all work fine because
+replayed history also can come from non-present users). In at least some cases,
+such as with mod_muc_mam, this can be fixed by hiding the JIDs of the
+participants in the room configuration.
+
+There are a few smaller UI issues:
+
+* If an integration posts with the same username as a room member, there is
+  no indication (like Slack's [bot] suffix) that the message is not from that
+  room member.
+* It is not currently possible to prevent posting to some MUCs (this is
+  also true of Slack).
+* It should be possible to set the webhook configuration for a room in the
+  room configuration rather than statically in Prosody's configuration file.
+
+Compatibility
+=============
+
+  ------- -----------------
+  trunk   Untested
+  0.10    Works
+  0.9     Works
+  ------- -----------------
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_slack_webhooks/mod_slack_webhooks.lua	Sun Apr 15 08:45:43 2018 -0700
@@ -0,0 +1,132 @@
+-- Allow Slack-style incoming and outgoing hooks to MUC rooms
+-- Based on mod_muc_intercom and mod_post_msg
+-- Copyright 2016-2017 Nathan Whitehorn <nwhitehorn@physics.ucla.edu>
+--
+-- This file is MIT/X11 licensed.
+
+module:depends"http"
+
+local host_session = prosody.hosts[module.host];
+local msg = require "util.stanza".message;
+local jid = require "util.jid";
+local now = require "util.datetime".datetime;
+local b64_decode = require "util.encodings".base64.decode;
+local json = require "util.json"
+local formdecode = require "net.http".formdecode;
+local xml = require "util.xml";
+local http = require "net.http";
+
+local function get_room_by_jid(mod_muc, jid)
+	if mod_muc.get_room_by_jid then
+		return mod_muc.get_room_by_jid(jid);
+	elseif mod_muc.rooms then
+		return mod_muc.rooms[jid]; -- COMPAT 0.9, 0.10
+	end
+end
+
+local routing = module:get_option("outgoing_webhook_routing") or {};
+local listen_path = module:get_option("incoming_webhook_path") or "/webhook";
+local default_from_nick = module:get_option("incoming_webhook_default_nick") or "Bot";
+
+function postcallback(content, code)
+	module:log("debug", "HTTP result %d", code)
+end
+
+function check_message(data)
+	local origin, stanza = data.origin, data.stanza;
+	local mod_muc = host_session.muc;
+	if not mod_muc then return; end
+
+	local this_room = get_room_by_jid(mod_muc, stanza.attr.to);
+	if not this_room then return; end -- no such room
+
+	local from_room_jid = this_room._jid_nick[stanza.attr.from];
+	if not from_room_jid then return; end -- no such nick
+
+	local from_room, from_host, from_nick = jid.split(from_room_jid);
+
+	local body = stanza:get_child("body");
+	if not body then return; end -- No body, like topic changes
+	body = body and body:get_text(); -- I feel like I want to do `or ""` there :/
+
+	if not routing[from_room] then
+		return;
+	end
+
+	local json_out = {channel_name = from_room, timestamp = now(), text = body, team_domain = from_host, user_name = from_nick};
+	local stanzaid = stanza:get_child("id");
+	if stanzaid and string.sub(stanzaid,1,string.len("webhookbot"))=="webhookbot" then
+		json_out["bot_id"] = "webhookbot";
+	end
+
+	json_out = json.encode(json_out)
+	local url = routing[from_room];
+	module:log("debug", "message from %s in %s to %s", from_nick, from_room, url);
+	local headers = {
+		["Content-Type"] = "application/json",
+	};
+	http.request(url, { method = "POST", body = json_out, headers = headers }, postcallback)
+end
+
+module:hook("message/bare", check_message, 10);
+
+local function route_post(f)
+	return function(event, path)
+		local request = event.request;
+		local headers = request.headers;
+		local bare_room = jid.join(path, module.host);
+		local mod_muc = host_session.muc;
+		if not get_room_by_jid(mod_muc, bare_room) then
+			module:log("warn", "mod_slack_webhook: invalid JID: %s", bare_room);
+			return 404;
+		end
+		-- Check secret?
+		return f(event, path)
+	end
+end
+
+local function handle_post(event, path)
+	local mod_muc = host_session.muc;
+	local request = event.request;
+	local response = event.response;
+	local headers = request.headers;
+
+	local body_type = headers.content_type;
+	local message;
+	local post_body;
+	if body_type == "application/x-www-form-urlencoded" then
+		post_body = formdecode(request.body)["payload"];
+	elseif body_type == "application/json" then
+		if not pcall(function() post_body = json.decode(request.body) end) then
+			return 420;
+		end
+	else
+		return 422;
+	end
+	local bare_room = jid.join(path, module.host);
+	local dest_room = get_room_by_jid(mod_muc, bare_room);
+	local from_nick = default_from_nick;
+	if post_body["username"] then
+		from_nick = post_body["username"];
+	end
+	local sender = jid.join(path, module.host, from_nick);
+	module:log("debug", "message to %s from %s", bare_room, sender);
+	module:log("debug", "body: %s", post_body["text"]);
+	message = msg({ to = bare_room, from = sender, type = "groupchat", id="webhookbot" .. now()},post_body["text"]);
+	dest_room:broadcast_message(message, true);
+	return 201;
+end
+
+module:provides("http", {
+	default_path = listen_path;
+	route = {
+		["POST /*"] = route_post(handle_post);
+		OPTIONS = function(e)
+			local headers = e.response.headers;
+			headers.allow = "POST";
+			headers.accept = "application/x-www-form-urlencoded, application/json";
+			return 200;
+		end;
+	}
+});
+