Mercurial > prosody-modules
view mod_pastebin/mod_pastebin.lua @ 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 | 8aec430ba205 |
| children | ad9026b6a714 |
line wrap: on
line source
local st = require "util.stanza"; module:depends("http"); local uuid_new = require "util.uuid".generate; local os_time = os.time; local t_remove = table.remove; local add_task = require "util.timer".add_task; local jid_bare = require "util.jid".bare; local function get_room_from_jid() end; local is_component = module:get_host_type() == "component"; if is_component then local mod_muc = module:depends "muc"; local muc_rooms = rawget(mod_muc, "rooms"); get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or function (jid) return muc_rooms[jid]; end end local utf8_pattern = "[\194-\244][\128-\191]*$"; local function drop_invalid_utf8(seq) local start = seq:byte(); module:log("debug", "utf8: %d, %d", start, #seq); if (start <= 223 and #seq < 2) or (start >= 224 and start <= 239 and #seq < 3) or (start >= 240 and start <= 244 and #seq < 4) or (start > 244) then return ""; end return seq; end local function utf8_length(str) local _, count = string.gsub(str, "[^\128-\193]", ""); return count; end local pastebin_private_messages = module:get_option_boolean("pastebin_private_messages", not is_component); local length_threshold = module:get_option_number("pastebin_threshold", 500); local line_threshold = module:get_option_number("pastebin_line_threshold", 4); local max_summary_length = module:get_option_number("pastebin_summary_length", 150); local html_preview = module:get_option_boolean("pastebin_html_preview", true); local base_url = module:get_option_string("pastebin_url", module:http_url()):gsub("/$", "").."/"; -- Seconds a paste should live for in seconds (config is in hours), default 24 hours local expire_after = math.floor(module:get_option_number("pastebin_expire_after", 24) * 3600); local trigger_string = module:get_option_string("pastebin_trigger"); trigger_string = (trigger_string and trigger_string .. " "); local pastes = {}; local xmlns_xhtmlim = "http://jabber.org/protocol/xhtml-im"; local xmlns_xhtml = "http://www.w3.org/1999/xhtml"; function pastebin_text(text) local uuid = uuid_new(); pastes[uuid] = { body = text, time = os_time(), }; pastes[#pastes+1] = uuid; if not pastes[2] then -- No other pastes, give the timer a kick add_task(expire_after, expire_pastes); end return base_url..uuid; end function handle_request(event, pasteid) event.response.headers.content_type = "text/plain; charset=utf-8"; if not pasteid then return "Invalid paste id, perhaps it expired?"; end --module:log("debug", "Received request, replying: %s", pastes[pasteid].text); local paste = pastes[pasteid]; if not paste then return "Invalid paste id, perhaps it expired?"; end return paste.body; end local function replace_tag(s, replacement) local once = false; s:maptags(function (tag) if tag.name == replacement.name and tag.attr.xmlns == replacement.attr.xmlns then if not once then once = true; return replacement; else return nil; end end return tag; end); if not once then s:add_child(replacement); end end function check_message(data) local stanza = data.stanza; -- Only check for MUC presence when loaded on a component. if is_component then local room = get_room_from_jid(jid_bare(stanza.attr.to)); if not room then return; end local nick = room._jid_nick[stanza.attr.from]; if not nick then return; end end local body = stanza:get_child_text("body"); if not body then return; end --module:log("debug", "Body(%s) length: %d", type(body), #(body or "")); if ( #body > length_threshold and utf8_length(body) > length_threshold ) or (trigger_string and body:find(trigger_string, 1, true) == 1) or (select(2, body:gsub("\n", "%0")) >= line_threshold) then if trigger_string and body:sub(1, #trigger_string) == trigger_string then body = body:sub(#trigger_string+1); end local url = pastebin_text(body); module:log("debug", "Pasted message as %s", url); --module:log("debug", " stanza[bodyindex] = %q", tostring( stanza[bodyindex])); local summary = (body:sub(1, max_summary_length):gsub(utf8_pattern, drop_invalid_utf8) or ""):match("[^\n]+") or ""; summary = summary:match("^%s*(.-)%s*$"); local summary_prefixed = summary:match("[,:]$"); replace_tag(stanza, st.stanza("body"):text(summary .. "\n" .. url)); stanza:add_child(st.stanza("query", { xmlns = "jabber:iq:oob" }):tag("url"):text(url)); if html_preview then local line_count = select(2, body:gsub("\n", "%0")) + 1; local link_text = ("[view %spaste (%d line%s)]"):format(summary_prefixed and "" or "rest of ", line_count, line_count == 1 and "" or "s"); local html = st.stanza("html", { xmlns = xmlns_xhtmlim }):tag("body", { xmlns = xmlns_xhtml }); html:tag("p"):text(summary.." "):up(); html:tag("a", { href = url }):text(link_text):up(); replace_tag(stanza, html); end end end module:hook("message/bare", check_message); if pastebin_private_messages then module:hook("message/full", check_message); end module:hook("muc-disco#info", function (event) local reply, form, formdata = event.reply, event.form, event.formdata; reply:tag("feature", { var = "https://modules.prosody.im/mod_pastebin" }):up(); table.insert(form, { name = "{https://modules.prosody.im/mod_pastebin}max_lines", datatype = "xs:integer" }); table.insert(form, { name = "{https://modules.prosody.im/mod_pastebin}max_characters", datatype = "xs:integer" }); formdata["{https://modules.prosody.im/mod_pastebin}max_lines"] = tostring(line_threshold); formdata["{https://modules.prosody.im/mod_pastebin}max_characters"] = tostring(length_threshold); end); function expire_pastes(time) time = time or os_time(); -- COMPAT with 0.5 if pastes[1] then pastes[pastes[1]] = nil; t_remove(pastes, 1); if pastes[1] then return (expire_after - (time - pastes[pastes[1]].time)) + 1; end end end module:provides("http", { route = { ["GET /*"] = handle_request; }; }); local function set_pastes_metatable() -- luacheck: ignore 212/pastes 431/pastes if expire_after == 0 then local dm = require "util.datamanager"; setmetatable(pastes, { __index = function (pastes, id) if type(id) == "string" then return dm.load(id, module.host, "pastebin"); end end; __newindex = function (pastes, id, data) if type(id) == "string" then dm.store(id, module.host, "pastebin", data); end end; }); else setmetatable(pastes, nil); end end module.load = set_pastes_metatable; function module.save() return { pastes = pastes }; end function module.restore(data) pastes = data.pastes or pastes; set_pastes_metatable(); end
