view mod_unified_push/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 04d0ab97a678
children
line wrap: on
line source

---
labels:
- Stage-Alpha
summary: Unified Push provider
---

This module implements a [Unified Push](https://unifiedpush.org/) Provider
that uses XMPP to talk to a Push Distributor (e.g. [Conversations](http://codeberg.org/iNPUTmice/Conversations)).

It allows push notifications to be delivered to apps on your device over XMPP.
This means notifications can be delivered quickly and efficiently (apps don't
need to repeatedly poll for new notifications).

For a list of compatible apps, see the [UnifiedPush apps list](https://unifiedpush.org/users/apps/).

A server-independent external component is also available - see [the 'up'
project](https://codeberg.org/inputmice/up). That project also contains a
description of the protocol between the XMPP server and the client.

This module and the protocol it implements is at an experimental prototype
stage.

Note that this module is **not related** to XEP-0357 push notifications for
XMPP. It does not send push notifications to disconnected XMPP clients. For
that, see [mod_cloud_notify](https://modules.prosody.im/mod_cloud_notify).

## Configuration

| Name                          | Description                                             | Default                                     |
|-------------------------------|---------------------------------------------------------|---------------------------------------------|
| unified_push_acl              | A list of domains or users permitted to use the service | current host, or parent host if a component |
| unified_push_backend          | Backend to use: "paseto", "storage" or "jwt"            | "paseto" (trunk), "storage" (0.12)          |
| unified_push_registration_ttl | Maximum lifetime of a push registration (seconds)       | `86400` (1 day)                             |

### Backends

The module needs to track registrations, and be able to associate tokens with
users. There are multiple ways to do this, but not every method is supported
on every Prosody version.

By default the module will automatically select the best backend that is
supported on the current Prosody version you are using.

#### storage backend

This is the default backend on Prosody 0.12 and earlier. It stores tokens and
their associated data in Prosody's configured data store.

Supported by all Prosody versions.

#### paseto backend

This is a stateless (i.e. no storage required) backend that uses encrypted
[PASETO tokens](https://paseto.io/) to store registration info. It is the
default backend on Prosody trunk, as PASETO support is not available in
Prosody 0.12 and earlier.

#### jwt backend

This is a stateless backend that uses [JWT tokens](https://jwt.io/) to store
registration info. It is supported in Prosody 0.12 and higher.

**Note:** The JWT tokens are **not encrypted**, which means the JID
associated with a registration is visible to apps and services that send you
push notifications. This can have privacy implications. If in doubt, do not
use this backend.

This backend requires you to set a secure random string in the config file,
using the `unified_push_secret` option.

A random push secret can be generated with the command
`openssl rand -base64 32`. Changing the secret will invalidate all existing
push registrations.

## HTTP configuration

This module exposes a HTTP endpoint, by default at the path `/push` (to receive push notifications from app
servers). **If you use a reverse proxy, make sure you proxy this path too.**
For more information on configuring HTTP services and reverse proxying in Prosody, see
[Prosody HTTP documentation](https://prosody.im/doc/http).

## Example configuration

### Recommended: load on Virtualhost(s)

Just add just add `"unified_push"` to your `modules_enabled` option.
This is the easiest and **recommended** configuration.

``` lua
  modules_enabled = {
    -- ...
    "unified_push";
    -- ...
  }
```

#### Component method

This is an example of how to configure the module as an internal component,
e.g. on a subdomain or other non-user domain.

This example creates a push notification component called
'notify.example.com'.

The 'http_host' line instructs Prosody to expose this module's HTTP services
on the 'example.com' host, which avoids needing to create/update DNS records
and HTTPS certificates if example.com is already set up.

``` lua
Component "notify.example.com" "unified_push"
    unified_push_secret = "<secret string here>"
    http_host = "example.com"
```

## Compatibility

  Prosody-Version   Status
  ----------------- ----------------------
  trunk             Works as of 24-12-08
  0.12              Works