# HG changeset patch # User Matthew Wild # Date 1522068553 -3600 # Node ID 18e6d437003fe4f94862e5cb5b17065f81e34a4b # Parent 39994c6bb3148c78eba43f8d1156d42431e47a0b mod_component_http: Allow implementing a component over HTTP diff -r 39994c6bb314 -r 18e6d437003f mod_component_http/README.markdown --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_component_http/README.markdown Mon Mar 26 13:49:13 2018 +0100 @@ -0,0 +1,94 @@ +--- +summary: 'Allows implementing a component or bot over HTTP' +... + +Introduction +============ + +This module allows you to implement a component that speaks HTTP. Stanzas (such as messages) coming from XMPP are sent to +a configurable URL as a HTTP POST. If the POST returns a response, that response is returned to the sender over XMPP. + +See also mod_post_msg. + +Example usage +------------- + +Example echo bot in PHP: + +``` +body; + +// Send response +header('Content-Type: application/json'); +echo json_encode(array( + 'body' => "Did you say $received?" +)); + +?> +``` + +Configuration +------------- + + Option Description + ------------------------------------ ------------------------------------------------------------------------------------------------------------------------------------------------- + component\_post\_url The URL that will handle incoming stanzas + component\_post\_stanzas A list of stanza types to forward over HTTP. Defaults to `{ "message" }`. + +Details +------- + +Requests +======== + +Each received stanza is converted into a JSON object, and submitted to `component_post_url` using a HTTP POST request. + +The JSON object always has the following properties: + + Property Description + -------------------------- ------------ + to The JID that the stanza was sent to (e.g. foobar@your.component.domain) + from The sender's JID. + kind The kind of stanza (will always be "message", "presence" or "iq". + stanza The full XML of the stanza. + +Additionally, the JSON object may contain the following properties: + + Property Description + -------------------------- ------------ + body If the stanza is a message, and it contains a body, this is the string content of the body. + + +Responses +========= + +If you wish to respond to a stanza, you may include a reply when you respond to the HTTP request. + +Responses must have a HTTP status 200 (OK), and must set the Conent-Type header to `application/json`. + +A response may contain any of the properties of a request. If not supplied, then defaults are chosen. + +If 'to' and 'from' are not specified in the response, they are automatically swapped so that the reply is sent to the original sender of the stanza. + +If 'kind' is not set, it defaults to 'message', and if 'body' is set, this is automatically added as a message body. + +If 'stanza' is set, it overrides all of the above, and the supplied stanza is sent as-is using Prosody's normal routing rules. Note that stanzas +sent by components must have a 'to' and 'from'. + +Presence +======== + +By default the module automatically handles presence to provide an always-on component, that automatically accepts subscription requests. + +This means that by default presence stanzas are not forwarded to the configured URL. To provide your own presence handling, you can override +this by adding "presence" to the component\_post\_stanzas option in your config. + + +Compatibility +------------- + +Should work with all versions of Prosody from 0.9 upwards. diff -r 39994c6bb314 -r 18e6d437003f mod_component_http/mod_component_http.lua --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mod_component_http/mod_component_http.lua Mon Mar 26 13:49:13 2018 +0100 @@ -0,0 +1,102 @@ +local http = require "net.http"; +local json = require "util.json"; +local st = require "util.stanza"; +local xml = require "util.xml"; +local unpack = rawget(_G, "unpack") or table.unpack; + +local url = module:get_option_string("component_post_url"); +assert(url, "Missing required config option 'component_post_url'"); + +local stanza_kinds = module:get_option_set("post_stanza_types", { "message" }); + +local http_error_map = { + [0] = { "cancel", "remote-server-timeout", "Connection failure" }; + -- 4xx + [400] = { "modify", "bad-request" }; + [401] = { "auth", "not-authorized" }; + [402] = { "auth", "forbidden", "Payment required" }; + [403] = { "auth", "forbidden" }; + [404] = { "cancel", "item-not-found" }; + [410] = { "cancel", "gone" }; + -- 5xx + [500] = { "cancel", "internal-server-error" }; + [501] = { "cancel", "feature-not-implemented" }; + [502] = { "cancel", "remote-server-timeout", "Bad gateway" }; + [503] = { "wait", "remote-server-timeout", "Service temporarily unavailable" }; + [504] = { "wait", "remote-server-timeout", "Gateway timeout" }; +} + +local function error_reply(stanza, code) + local error = http_error_map[code] or { "cancel", "service-unavailable" }; + return st.error_reply(stanza, unpack(error, 1, 3)); +end + +function handle_stanza(event) + local origin, stanza = event.origin, event.stanza; + local request_body = json.encode({ + to = stanza.attr.to; + from = stanza.attr.from; + kind = stanza.name; + body = stanza.name == "message" and stanza:get_child_text("body") or nil; + stanza = tostring(stanza); + }); + http.request(url, { + body = request_body; + }, function (response_text, code, req, response) + if stanza.attr.type == "error" then return; end -- Avoid error loops, don't reply to error stanzas + if code == 200 and response_text and response.headers["content-type"] == "application/json" then + local response_data = json.decode(response_text); + if response_data.stanza then + local reply_stanza = xml.parse(response_data.stanza); + if reply_stanza then + reply_stanza.attr.from, reply_stanza.attr.to = stanza.attr.to, stanza.attr.from; + return origin.send(reply_stanza); + else + module:log("warn", "Unable to parse reply stanza"); + end + else + local stanza_kind = response_data.kind or "message"; + local to = response_data.to or stanza.attr.from; + local from = response_data.from or stanza.attr.to; + local reply_stanza = st.stanza(stanza_kind, { + to = to, from = from; + type = response_data.type or (stanza_kind == "message" and "chat") or nil; + }); + if stanza_kind == "message" and response_data.body then + reply_stanza:tag("body"):text(tostring(response_data.body)):up(); + end + module:log("debug", "Sending %s", tostring(reply_stanza)); + return origin.send(reply_stanza); + end + return; + elseif code >= 200 and code <= 299 then + return true; + else + return origin.send(error_reply(stanza, code)); + end + end); + return true; +end + +for stanza_kind in stanza_kinds do + for _, jid_type in ipairs({ "host", "bare", "full" }) do + module:hook(stanza_kind.."/"..jid_type, handle_stanza); + end +end + +-- Simple handler for an always-online JID that allows everyone to subscribe to presence +local function default_presence_handler(event) + local origin, stanza = event.origin, event.stanza; + module:log("debug", "Handling %s", tostring(stanza)); + if stanza.attr.type == "probe" then + module:send(st.presence({ to = stanza.attr.from, from = stanza.attr.to.."/default" })); + elseif stanza.attr.type == "subscribe" then + module:send(st.presence({ type = "subscribed", to = stanza.attr.from, from = stanza.attr.to.."/default" })); + module:send(st.presence({ to = stanza.attr.from, from = stanza.attr.to.."/default" })); + elseif stanza.attr.type == "unsubscribe" then + module:send(st.presence({ type = "unavailable", to = stanza.attr.from, from = stanza.attr.to.."/default" })); + end + return true; +end + +module:hook("presence/bare", default_presence_handler, -1);