Mercurial > prosody-modules
view mod_rest/README.md @ 6299:5cf5ee23b361
mod_voipms: New Module to send and receive SMS/MMS via VoIP.ms APIs.
diff --git a/mod_voipms/README.md b/mod_voipms/README.md
new file mode 100644
--- /dev/null
+++ b/mod_voipms/README.md
@@ -0,0 +1,51 @@
+---
+labels:
+- 'Stage-Alpha'
+- 'Type-Web'
+summary: Send and receive SMS/MMS via VoIP.ms APIs.
+rockspec:
+ build:
+ modules:
+ mod_voipms: mod_voipms.lua
+...
+
+Introduction
+============
+
+This is a Prosody module to map JIDs to DIDs on VoIP.ms and support sending/receiving SMS/MMS.
+
+Configuration
+=============
+
+| option | type | default |
+|-----------------------|--------|---------|
+| voipms\_api\_username | string | nil |
+| voipms\_api\_password | string | nil |
+| voipms\_query\_key | string | nil |
+| voipms\_jid\_map | table | nil |
+```
+VirtualHost "sms.example.com"
+modules_enabled = {
+ "voipms";
+}
+voipms_api_username = john@example.com -- E-mail registered with VoIP.ms
+voipms_api_password = abcd1234 -- API password configured in VoIP.ms
+voipms_query_key = some_query_key -- query param 'key' part of your URL callback
+voipms_jid_map = {
+ ["your_jid@your_domain.com"] = "+1234567890"
+}
+```
+
+HTTP
+====
+
+The module is served on Prosody's default HTTP ports at the path /voipms. More details on configuring HTTP modules in Prosody can be found in the HTTP documentation.
+
+VoIP.ms Webhook URL
+===================
+
+This module receives the VoIP.ms Webhook URL (POST) at the /voipms endpoint. It uses the sendSMS/sendMMS GET methods against the VoIP.ms APIs. This is an example webhook to use in VoIP.ms:
+
+```
+https://sms.example.com/voipms?key=some_query_key
+```
diff --git a/mod_voipms/mod_voipms.lua b/mod_voipms/mod_voipms.lua
new file mode 100644
--- /dev/null
+++ b/mod_voipms/mod_voipms.lua
@@ -0,0 +1,166 @@
+local http = require "net.http"
+local json = require "util.json"
+local st = require "util.stanza"
+
+local api_username = module:get_option_string("voipms_api_username")
+local api_password = module:get_option_string("voipms_api_password")
+local query_key = module:get_option("voipms_query_key")
+local jid_map = module:get_option("voipms_jid_map") or {}
+local rest_endpoint = "https://voip.ms/api/v1/rest.php"
+
+if not api_username or not api_password or not query_key then
+ module:log("error", "Missing required config values (voipms_api_username, voipms_api_password, voipms_query_key)")
+ return
+end
+
+module:depends("http")
+
+local function normalize_number(num)
+ if not num then return nil end
+ if num:sub(1, 1) ~= "+" then
+ return "+1" .. num
+ end
+ return num
+end
+
+local function extract_query_key(event)
+ return (event.request.url.query or ""):match("key=([^&]+)")
+end
+
+module:provides("http", {
+ route = {
+ ["POST"] = function(event)
+ local req = event.request
+ local body = req.body or ""
+
+ if extract_query_key(event) ~= query_key then
+ module:log("warn", "Unauthorized webhook: missing or invalid key")
+ return { status_code = 403 }
+ end
+
+ local json_payload, err = json.decode(body)
+ if not json_payload then
+ module:log("warn", "Invalid JSON: %s", err or "unknown error")
+ return { status_code = 400 }
+ end
+
+ local payload = json_payload.data and json_payload.data.payload
+ if not payload then
+ module:log("warn", "Missing payload in JSON")
+ return { status_code = 400 }
+ end
+
+ local from = payload.from and payload.from.phone_number
+ local to_list = payload.to or {}
+ local to = #to_list > 0 and to_list[1].phone_number or nil
+
+ if not from or not to then
+ module:log("warn", "Missing phone numbers (from: %s, to: %s)", tostring(from), tostring(to))
+ return { status_code = 400 }
+ end
+
+ local normalized_to = normalize_number(to)
+ local target_jid = nil
+
+ for jid, did in pairs(jid_map) do
+ if normalize_number(did) == normalized_to then
+ target_jid = jid
+ break
+ end
+ end
+
+ if not target_jid then
+ module:log("warn", "No JID mapping for DID %s", normalized_to)
+ return { status_code = 404 }
+ end
+
+ local normalized_from = normalize_number(from)
+ local message_text = payload.text or ""
+
+ local message = st.message({
+ from = normalized_from .. "@" .. module.host,
+ to = target_jid,
+ type = "chat"
+ }):tag("body"):text(message_text):up()
+
+ if payload.media and #payload.media > 0 then
+ for _, media_item in ipairs(payload.media) do
+ if media_item.url then
+ message_text = message_text .. "\n" .. media_item.url
+ message:tag("x", { xmlns = "jabber:x:oob" })
+ :tag("url"):text(media_item.url):up():up()
+ end
+ end
+ end
+
+ module:send(message)
+ module:log("info", "Delivered SMS from %s to %s", normalized_from, target_jid)
+
+ return { status_code = 204 }
+ end
+ }
+})
+
+module:hook("message/bare", function(event)
+ local stanza = event.stanza
+ if stanza.attr.type ~= "chat" then return end
+
+ local from_jid = stanza.attr.from
+ local to_jid = stanza.attr.to
+ local body = stanza:get_child_text("body")
+ if not body or body == "" then return end
+
+ local from_number = jid_map[from_jid]
+ if not from_number then
+ module:log("warn", "No DID mapping for JID %s", from_jid)
+ return
+ end
+
+ local to_number = to_jid:match("^([^@]+)")
+ if not to_number then
+ module:log("warn", "Malformed JID in message.to: %s", to_jid)
+ return
+ end
+
+ to_number = normalize_number(to_number)
+
+ local media_urls = {}
+ for line in body:gmatch("[^\r\n]+") do
+ if line:match("^https?://") then
+ table.insert(media_urls, line)
+ end
+ end
+
+ local method = (#media_urls > 0) and "sendMMS" or "sendSMS"
+ local query = {
+ api_username = api_username,
+ api_password = api_password,
+ method = method,
+ did = from_number,
+ dst = to_number,
+ message = body
+ }
+
+ if method == "sendMMS" then
+ for i, url in ipairs(media_urls) do
+ query["media_url[" .. (i - 1) .. "]"] = url
+ end
+ end
+
+ local query_str = http.formencode(query)
+
+ http.request(rest_endpoint .. "?" .. query_str, {
+ method = "GET";
+ }, function(response_body, code)
+ if code == 200 then
+ local resp, err = json.decode(response_body)
+ if not resp or resp.status ~= "success" then
+ module:log("error", "Failed to send %s: %s", method, err or (resp and resp.status) or "unknown")
+ else
+ module:log("info", "Sent %s from %s to %s", method, from_number, to_number)
+ end
+ else
+ module:log("error", "HTTP error sending %s: code %s", method, tostring(code))
+ end
+ end)
+end)
| author | Chaz <menel@snikket.de> |
|---|---|
| date | Mon, 21 Jul 2025 23:57:37 +0200 |
| parents | ed6fa901cf94 |
| children | cf5f0a20e16a |
line wrap: on
line source
--- labels: - 'Stage-Alpha' summary: RESTful XMPP API rockspec: build: modules: mod_rest.jsonmap: jsonmap.lib.lua copy_directories: - example - res --- # Introduction This is yet another RESTful API for sending and receiving stanzas via Prosody. It can be used to build bots and components implemented as HTTP services. It is the spiritual successor to [mod_post_msg] and absorbs use cases from [mod_http_rest] and [mod_component_http] and other such modules floating around the Internet. # Usage You make a choice: install via VirtualHosts or as a Component. User authentication can be used when installed via VirtualHost, and OAuth2 can be used for either. ## On VirtualHosts This enables rest on the VirtualHost domain, enabling user authentication to secure the endpoint. Make sure that the modules_enabled section is immediately below the VirtualHost entry so that it's not under any Component sections. EG: ```lua VirtualHost "chat.example.com" modules_enabled = {"rest"} ``` ## As a Component If you install this as a component, the HTTP Basic credentials are the components base JID along with its secret. ``` {.lua} Component "chat.example.com" "rest" component_secret = "dmVyeSBzZWNyZXQgdG9rZW4K" ``` ## User authentication To enable user authentication, edit the "admins = { }" section in prosody.cfg.lua, EG: ```lua admins = { "admin@chat.example.com" } ``` To set up the admin user account: ```lua prosodyctl adduser admin@chat.example.com ``` and lastly, drop the "@host" from the username in your http queries, EG: ```lua curl -sf \ -H 'Accept: application/json' \ --user admin \ https://chat.example.com:5281/rest/version/chat.example.com ``` ## OAuth2 [mod_http_oauth2] can be used to grant bearer tokens which are accepted by mod_rest. Tokens can be passed to `curl` like `--oauth2-bearer dmVyeSBzZWNyZXQgdG9rZW4K` instead of using `--user`. ## Sending stanzas The API endpoint becomes available at the path `/rest`, so the full URL will be something like `https://conference.chat.example.com:5281/rest`. To try it, simply `curl` an XML stanza payload: ``` {.sh} curl https://prosody.example:5281/rest \ --user username \ -H 'Content-Type: application/xmpp+xml' \ --data-binary '<message type="chat" to="user@example.org"> <body>Hello!</body> </message>' ``` or a JSON payload: ``` {.sh} curl https://prosody.example:5281/rest \ --user username \ -H 'Content-Type: application/json' \ --data-binary '{ "body" : "Hello!", "kind" : "message", "to" : "user@example.org", "type" : "chat" }' ``` The `Content-Type` header is important! ### Parameters in path New alternative format with the parameters `kind`, `type`, and `to` embedded in the path: ``` curl https://prosody.example:5281/rest/message/chat/john@example.com \ --user username \ -H 'Content-Type: text/plain' \ --data-binary 'Hello John!' ``` ### Replies A POST containing an `<iq>` stanza automatically wait for the reply, long-polling style. ``` {.sh} curl https://prosody.example:5281/rest \ --user username \ -H 'Content-Type: application/xmpp+xml' \ --data-binary '<iq type="get" to="example.net"> <ping xmlns="urn:xmpp:ping"/> </iq>' ``` Replies to other kinds of stanzas that are generated by the same Prosody instance *MAY* be returned in the HTTP response. Replies from other entities (connected clients or remote servers) will not be returned, but can be forwarded via the callback API described in the next section. ### Simple info queries A subset of IQ stanzas can be sent as simple GET requests ``` curl https://prosody.example:5281/rest/version/example.com \ --user username \ -H 'Accept: application/json' ``` The supported queries are - `archive` - `disco` - `extdisco` - `items` - `lastactivity` - `oob` - `payload` - `ping` - `stats` - `version` ## Receiving stanzas TL;DR: Set this webhook callback URL, get XML `POST`-ed there. ``` {.lua} Component "rest.example.net" "rest" rest_callback_url = "http://my-api.example:9999/stanzas" ``` The callback URL supports a few variables from the stanza being sent, namely `{kind}` (e.g. message, presence, iq or meta) and ones corresponding to stanza attributes: `{type}`, `{to}` and `{from}`. The preferred format can be indicated via the Accept header in response to an OPTIONS probe that mod_rest does on startup, or by configuring: ``` {.lua} rest_callback_content_type = "application/json" ``` Example callback looks like: ``` {.xml} POST /stanzas HTTP/1.1 Content-Type: application/xmpp+xml Content-Length: 102 <message to="bot@rest.example.net" from="user@example.com" type="chat"> <body>Hello</body> </message> ``` or as JSON: ``` {.json} POST /stanzas HTTP/1.1 Content-Type: application/json Content-Length: 133 { "body" : "Hello", "from" : "user@example.com", "kind" : "message", "to" : "bot@rest.example.net", "type" : "chat" } ``` ### Which stanzas The set of stanzas routed to the callback is determined by these two settings: `rest_callback_stanzas` : The stanza kinds to handle, defaults to `{ "message", "presence", "iq" }` `rest_callback_events` : For the selected stanza kinds, which events to handle. When loaded on a Component, this defaults to `{ "bare", "full", "host" }`, while on a VirtualHost the default is `{ "host" }`. Events correspond to which form of address was used in the `to` attribute of the stanza. bare : `localpart@hostpart` full : `localpart@hostpart/resourcepart` host : `hostpart` The following example would handle only stanzas like `<message to="anything@hello.example"/>` ```lua Component "hello.example" "rest" rest_callback_url = "http://hello.internal.example:9003/api" rest_callback_stanzas = { "message" } rest_callback_events = { "bare" } ``` ### Replying To accept the stanza without returning a reply, respond with HTTP status code `202` or `204`. HTTP status codes in the `4xx` and `5xx` range are mapped to an appropriate stanza error. For full control over the response, set the `Content-Type` header to `application/xmpp+xml` and return an XMPP stanza as an XML snippet. ``` {.xml} HTTP/1.1 200 Ok Content-Type: application/xmpp+xml <message type="chat"> <body>Yes, this is bot</body> </message> ``` ## Payload format ### JSON ``` {.json} { "body" : "Hello!", "kind" : "message", "type" : "chat" } ``` Further JSON object keys as follows: #### Messages `kind` : `"message"` `type` : Commonly `"chat"` for 1-to-1 messages and `"groupchat"` for group chat messages. Others include `"normal"`, `"headline"` and `"error"`. `body` : Human-readable message text. `subject` : Message subject or MUC topic. `html` : HTML. `oob_url` : URL of an out-of-band resource, often used for images. #### Presence `kind` : `"presence"` `type` : Empty for online or `"unavailable"` for offline. `show` : [Online status](https://xmpp.org/rfcs/rfc6121.html#presence-syntax-children-show), `away`, `dnd` etc. `status` : Human-readable status message. #### Info-Queries Only one type of payload can be included in an `iq`. `kind` : `"iq"` `type` : `"get"` or `"set"` for queries, `"response"` or `"error"` for replies. `ping` : Send a ping. Get a pong. Maybe. `disco` : Retrieve service discovery information about an entity. `items` : Discover list of items (other services, groupchats etc). ### XML ``` {.xml} <message type="" id="" to="" from="" xml:lang=""> ... </message> ``` An XML declaration (`<?xml?>`) **MUST NOT** be included. The payload MUST contain one (1) `message`, `presence` or `iq` stanza. The stanzas MUST NOT have an `xmlns` attribute, and the default/empty namespace is treated as `jabber:client`. # Examples ## Python / Flask Simple echo bot that responds to messages as XML: ``` {.python} from flask import Flask, Response, request import xml.etree.ElementTree as ET app = Flask("echobot") @app.before_request def parse(): request.stanza = ET.fromstring(request.data) @app.route("/", methods=["POST"]) def hello(): if request.stanza.tag == "message": return Response( "<message><body>Yes this is bot</body></message>", content_type="application/xmpp+xml", ) return Response(status=501) if __name__ == "__main__": app.run() ``` And a JSON variant: ``` {.python} from flask import Flask, Response, request, jsonify app = Flask("echobot") @app.route("/", methods=["POST"]) def hello(): print(request.data) if request.is_json: data = request.get_json() if data["kind"] == "message": return jsonify({"body": "hello"}) return Response(status=501) if __name__ == "__main__": app.run() ``` Remember to set `rest_callback_content_type = "application/json"` for this to work. # JSON mapping This section describes the JSON mapping. It can't represent any possible stanza, for full flexibility use the XML mode. ## Stanza basics `kind` : String representing the kind of stanza, one of `"message"`, `"presence"` or `"iq"`. `type` : String with the type of stanza, appropriate values vary depending on `kind`, see [RFC 6121]. E.g.`"chat"` for *message* stanzas etc. `to` : String containing the XMPP Address of the destination / recipient of the stanza. `from` : String containing the XMPP Address of the sender the stanza. `id` : String with a reasonably unique identifier for the stanza. ## Basic Payloads ### Messages `body` : String, human readable text message. `subject` : String, human readable summary equivalent to an email subject or the chat room topic in a `type:groupchat` message. ### Presence `show` : String representing availability, e.g. `"away"`, `"dnd"`. No value means a normal online status. See [RFC 6121] for the full list. `status` : String with a human readable text message describing availability. ## More payloads ### Messages `state` : String with current chat state, e.g. `"active"` (default) and `"composing"` (typing). `html` : String with HTML allowing rich formatting. **MUST** be contained in a `<body>` element. `oob_url` : String with an URL of an external resource. ### Presence `muc` : Object with [MUC][XEP-0045] related properties. ### IQ `ping` : Boolean, a simple ping query. "Pongs" have only basic fields presents. `version` : Map with `name`, `version` fields, and optionally an `os` field, to describe the software. #### Service Discovery `disco` : Boolean `true` in a `kind:iq` `type:get` for a service discovery query. Responses have a map containing an array of available features in the `features` key and an array of "identities" in the `identities` key. Each identity has a `category` and `type` field as well as an optional `name` field. See [XEP-0030] for further details. `items` : Boolean `true` in a `kind:iq` `type:get` for a service discovery items list query. The response contain an array of items like `{"jid":"xmpp.address.here","name":"Description of item"}`. `extensions` : Map of extended feature discovery (see [XEP-0128]) data with `FORM_DATA` fields as the keys pointing at maps with the rest of the data. #### Ad-Hoc Commands Used to execute arbitrary commands on supporting entities. `command` : String representing the command `node` or Map with the following possible fields: `node` : Required string with node from disco\#items query for the command to execute. `action` : Optional enum string defaulting to `"execute"`. Multi-step commands may involve `"next"`, `"prev"`, `"complete"` or `"cancel"`. `actions` : Set (map of strings to `true`) with available actions to proceed with in multi-step commands. `status` : String describing the status of the command, normally `"executing"`. `sessionid` : Random session ID issued by the responder to identify the session in multi-step commands. `note` : Map with `"type"` and `"text"` fields that carry simple result information. `form` : Data form with description of expected input and data types in the next step of multi-step commands. **TODO** document format. `data` : Map with only the data for result dataforms. Fields may be strings or arrays of strings. ##### Example Discovering commands: ``` {.json} { "items" : { "node" : "http://jabber.org/protocol/commands" }, "id" : "8iN9hwdAAcfTBchm", "kind" : "iq", "to" : "example.com", "type" : "get" } ``` Response: ``` {.json} { "from" : "example.com", "id" : "8iN9hwdAAcfTBchm", "items" : [ { "jid" : "example.com", "name" : "Get uptime", "node" : "uptime" } ], "kind" : "iq", "type" : "result" } ``` Execute the command: ``` {.json} { "command" : { "node" : "uptime" }, "id" : "Jv-87nRaP6Mnrp8l", "kind" : "iq", "to" : "example.com", "type" : "set" } ``` Executed: ``` {.json} { "command" : { "node" : "uptime", "note" : { "text" : "This server has been running for 0 days, 20 hours and 54 minutes (since Fri Feb 7 18:05:30 2020)", "type" : "info" }, "sessionid" : "6380880a-93e9-4f13-8ee2-171927a40e67", "status" : "completed" }, "from" : "example.com", "id" : "Jv-87nRaP6Mnrp8l", "kind" : "iq", "type" : "result" } ``` # TODO - Describe multi-step commands with dataforms. - Versioned API, i.e. /v1/stanzas - Bind resource to webhook/callback # Compatibility Requires Prosody trunk / 0.12
