Mercurial > prosody-modules
view mod_pubsub_forgejo/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 | 131b8bfbefb4 |
| children |
line wrap: on
line source
--- labels: - "Stage-Beta" summary: "Turn forgejo/github/gitlab webhooks into atom-in-pubsub" rockspec: build: modules: mod_pubsub_forgejo.templates: templates.lib.lua mod_pubsub_forgejo.format: format.lib.lua --- # Introduction This module accepts Forgejo webhooks and publishes them to a local pubsub component as Atom entries for XMPP clients to subscribe to. Such entries can be viewed with a pubsub-compatible XMPP client such as [movim](https://movim.eu/) or [libervia](https://libervia.org/), or turned into chat messages with a bot (cf last section of this document). It is a more customisable `mod_pubsub_github`. It should also work with other forges such as github and gitlab (to be tested). # Configuration ## Basic setup Load the module on a pubsub component: ```{.lua} Component "pubsub.example.com" "pubsub" modules_enabled = { "pubsub_forgejo" } forgejo_secret = "something-very-secret" -- copy this in the Forgejo web UI ``` The "Target URL" to configure in the Forgejo web UI should be either: - `http://pubsub.example.com:5280/pubsub_forgejo` - `https://pubsub.example.com:5281/pubsub_forgejo` If your HTTP host doesn't match the pubsub component's address, you will need to inform Prosody. For more info see Prosody's [HTTP server documentation](https://prosody.im/doc/http#virtual_hosts). ## Advanced setup ### Publishing settings #### Pubsub actor By default, `forgejo_actor` is unset; this results in nodes being created by the prosody superuser. Change this if you set up access control and you know what you are doing. #### Pubsub node By default, all events are published in the same pubsub node named "forgejo". This can be changed by setting `forgejo_node` to a different value. Another option is to used different nodes based on which repository emitted the webhook. This is useful if you configured the webhook at the user (or organisation) level instead of repository-level. To set this up, define `forgejo_node_prefix` and `forgejo_node_mapping`. `forgejo_node_mapping` must be a key in the the webhook "repository" payload, e.g., "full*name". Example: with `forge_node_prefix = "forgejo---"` and `forgejo_node_mapping = "full_name"`, webhooks emitted by the repository \_repo-name* in the _org-name_ organisation will be published in the node _forgejo---org-name/repo-name_. ### Customizing the atom entry #### Pushes with no commits By default, pushes without commits (i.e., pushing tags) are ignored, because it leads to weird entries like "romeo pushed 0 commit(s) to repo". This behaviour can be changed by setting `forgejo_skip_commitless_push = false`. #### Atom entry templates By default, 3 webhooks events are handled (push, pull_request and release), and the payload is turned into a atom entry by using [util.interpolation](https://prosody.im/doc/developers/util/interpolation) templates. The default templates can be viewed in the source of this module, in the `templates.lib.lua` file. You can customise them using by setting `forgejo_templates`, which is merged with the default templates. In this table, keys are forgejo event names (`x-forgejo-template` request header). Values of this table are tables too, where keys are atom elements and values are the templates passed to [util.interpolation](https://prosody.im/doc/developers/util/interpolation). A few filters are provided: - `|shorten` strips the last 32 characters: useful to get a short commit hash - `|firstline` only keeps the first line: useful to get a commit "title" - `|branch` strips the first 12 characters: useful to get a branch name from `data.ref` - `|tag` strips the first 11 characters: useful to get a tag name from `data.ref` Example: ```{.lua} forgejo_templates = { pull_request = nil, -- suppress handling of `pull_request` events release = { -- data is the JSON payload of the webhook title = "{data.sender.username} {data.action} a release for {data.repository.name}", content = "{data.release.name}", id = "release-{data.release.tag_name}", link = "{data.release.html_url}" } } ``` Examples payloads are provided in the `webhook-examples` # Publishing in a MUC You can use a bot that listen to pubsub events and converts them to MUC messages. MattJ's [riddim](https://matthewwild.co.uk/projects/riddim/) is well suited for that. Example config, single pubsub node: ```{.lua} jid = "forgejo-bot@example.com" password = "top-secret-stuff" room = "room@rooms.example.com" autojoin = room pubsub2room = { "pubsub.example.com#forgejo" = { room = room, template = "${title}\n${content}\n${link@href}" } } ``` Example with several nodes: ```{.lua} local nodes = {"forgejo---org/repo1", "forgejo---org/repo2"} pubsub2room = {} for _, node in ipairs(slidge_repos) do pubsub2room = ["pubsub.example.com#" .. node] = { room = room, template = "${title}\n${content}\n${link@href}" } end ``` # TODO - Default templates for all event types - (x)html content # Compatibility Works with prosody 0.12
