view mod_slack_webhooks/mod_slack_webhooks.lua @ 4542:fb4a50bf60f1

mod_prometheus: Invoke stats collection if in 'manual' mode Since 10d13e0554f9 a special value for statistics_interval "manual" exists, where a module is expected to invoke processing in connection to collection of stats. This makes internal collection and exporting to Prometheus happens at the same time with no chance of timers getting out of sync.
author Kim Alvefur <zash@zash.se>
date Tue, 13 Apr 2021 23:53:53 +0200
parents 284d8c18060e
children 724003f24308
line wrap: on
line source

-- 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 msg = require "util.stanza".message;
local jid = require "util.jid";
local now = require "util.datetime".datetime;
local json = require "util.json"
local formdecode = require "net.http".formdecode;
local http = require "net.http";
local dataform = require "util.dataforms";

local mod_muc = module:depends"muc";
local rooms = rawget(mod_muc, "rooms");
local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or
	function (room_jid)
		return rooms[room_jid];
	end

local button_ns = "xmpp:prosody.im/community/mod_slack_webhooks#buttons";
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(_, code)
	module:log("debug", "HTTP result %d", code)
end

function check_message(data)
	local stanza = data.stanza;

	local this_room = get_room_from_jid(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 form = stanza:get_child("x", "jabber:x:form");
	if form and form.attr.type == "submit" then
		local callback_id, button_name, button_value;
		for field in form:childtags("field") do
			if field.attr.var == "callback_id" then
				button_name = field:get_child_text("text");
			elseif field.attr.var == "button_name" then
				button_name = field:get_child_text("text");
			elseif field.attr.var ~= "FORM_TYPE" or field:get_child_text("text") ~= button_ns then
				callback_id, button_name, button_value = nil, nil, nil;
				break;
			end
		end
		if callback_id and button_name and button_value then
			json_out.callback_id = callback_id;
			json_out.actions = {
				{ type = "button", name = button_name, value = button_value }
			};
		end
	end

	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);
	if url == "DEBUG" then
		module:log("debug", "json_out = %s", json_out);
		return;
	end
	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 bare_room = jid.join(path, module.host);
		if not get_room_from_jid(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 request = event.request;
	local headers = request.headers;

	local body_type = headers.content_type;
	local post_body;
	if body_type == "application/x-www-form-urlencoded" then
		post_body = formdecode(request.body);
	elseif body_type == "application/json" then
		post_body = json.decode(request.body)
		if not post_body then
			return 420;
		end
	else
		return 422;
	end
	local bare_room = jid.join(path, module.host);
	local dest_room = get_room_from_jid(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"]);
	local message = msg({ to = bare_room, from = sender, type = "groupchat", id="webhookbot" .. now()},post_body["text"]);

	if type(post_body["attachments"]) == "table" then -- Buttons?
		-- luacheck: ignore 631
		-- defensive against JSON having whatever data in it, enjoy

		for _, attachment in ipairs(post_body["attachments"]) do
			if type(attachment) == "table" and type(attachment.actions) == "table" and type(attachment.callback_id) == "string" then
				local buttons = {};
				local button_name;
				for _, action in ipairs(attachment.actions) do
					if type(attachment.text) == "string" then
						buttons.label = attachment.text;
					end
					if type(action) == "table" and action.type == "button" and type(action.name) == "string" and type(action.value) == "string" then
						if not button_name then
							button_name = action.name;
						end
						if button_name == action.name then
							local button = {
								value = action.value;
							};
							if type(action.text) == "string" then
								button.label = action.text;
							end
							table.insert(buttons, button);
						end
					end
				end
				if button_name then
					message:add_direct_child(dataform.new({
						{
							type = "hidden", name = "FORM_TYPE",
							value = button_ns,
						},
						{
							type = "hidden", name = "callback_id",
							value = attachment.callback_id,
						},
						{
							type = "hidden", name = "button_name",
							value = button_name,
						},
						{
							type = "list-single", name = "buttons",
							value = "", -- FIXME util.dataforms can't do options without a value
							options = buttons;
						}
					}):form());
					break;
				end
			end
		end
	end
	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;
	}
});