Mercurial > prosody-modules
view mod_firewall/conditions.lib.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 | cc665f343690 |
| children | b15601203d0d |
line wrap: on
line source
--luacheck: globals meta idsafe local condition_handlers = {}; local jid = require "util.jid"; local unpack = table.unpack or unpack; -- Helper to convert user-input strings (yes/true//no/false) to a bool local function string_to_boolean(s) s = s:lower(); return s == "yes" or s == "true"; end -- Return a code string for a condition that checks whether the contents -- of variable with the name 'name' matches any of the values in the -- comma/space/pipe delimited list 'values'. local function compile_comparison_list(name, values) local conditions = {}; for value in values:gmatch("[^%s,|]+") do table.insert(conditions, ("%s == %q"):format(name, value)); end return table.concat(conditions, " or "); end function condition_handlers.KIND(kind) assert(kind, "Expected stanza kind to match against"); return compile_comparison_list("name", kind), { "name" }; end local wildcard_equivs = { ["*"] = ".*", ["?"] = "." }; local function compile_jid_match_part(part, match) if not match then return part.." == nil"; end local pattern = match:match("^<(.*)>$"); if pattern then if pattern == "*" then return part; end if pattern:find("^<.*>$") then pattern = pattern:match("^<(.*)>$"); else pattern = pattern:gsub("%p", "%%%0"):gsub("%%(%p)", wildcard_equivs); end return ("(%s and %s:find(%q))"):format(part, part, "^"..pattern.."$"); else return ("%s == %q"):format(part, match); end end local function compile_jid_match(which, match_jid) local match_node, match_host, match_resource = jid.split(match_jid); local conditions = {}; conditions[#conditions+1] = compile_jid_match_part(which.."_node", match_node); conditions[#conditions+1] = compile_jid_match_part(which.."_host", match_host); if match_resource then conditions[#conditions+1] = compile_jid_match_part(which.."_resource", match_resource); end return table.concat(conditions, " and "); end function condition_handlers.TO(to) return compile_jid_match("to", to), { "split_to" }; end function condition_handlers.FROM(from) return compile_jid_match("from", from), { "split_from" }; end function condition_handlers.FROM_FULL_JID() return "not "..compile_jid_match_part("from_resource", nil), { "split_from" }; end function condition_handlers.FROM_EXACTLY(from) local metadeps = {}; return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) }; end function condition_handlers.TO_EXACTLY(to) local metadeps = {}; return ("to == %s"):format(metaq(to, metadeps)), { "to", unpack(metadeps) }; end function condition_handlers.TO_SELF() -- Intentionally not using 'to' here, as that defaults to bare JID when nil return ("stanza.attr.to == nil"); end function condition_handlers.TYPE(type) assert(type, "Expected 'type' value to match against"); return compile_comparison_list("(type or (name == 'message' and 'normal') or (name == 'presence' and 'available'))", type), { "type", "name" }; end local function zone_check(zone, which) local zone_var = zone; if zone == "$local" then zone_var = "_local" end local which_not = which == "from" and "to" or "from"; return ("(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s]) " .."and not(zone_%s[%s_host] or zone_%s[%s] or zone_%s[bare_%s])" ) :format(zone_var, which, zone_var, which, zone_var, which, zone_var, which_not, zone_var, which_not, zone_var, which_not), { "split_to", "split_from", "bare_to", "bare_from", "zone:"..zone }; end function condition_handlers.ENTERING(zone) return zone_check(zone, "to"); end function condition_handlers.LEAVING(zone) return zone_check(zone, "from"); end -- IN ROSTER? (parameter is deprecated) function condition_handlers.IN_ROSTER(yes_no) local in_roster_requirement = string_to_boolean(yes_no or "yes"); -- COMPAT w/ older scripts return "not "..(in_roster_requirement and "not" or "").." roster_entry", { "roster_entry" }; end function condition_handlers.IN_ROSTER_GROUP(group) return ("not not (roster_entry and roster_entry.groups[%q])"):format(group), { "roster_entry" }; end function condition_handlers.SUBSCRIBED() return "(bare_to == bare_from or to_node and rostermanager.is_user_subscribed(to_node, to_host, bare_from))", { "rostermanager", "split_to", "bare_to", "bare_from" }; end function condition_handlers.PENDING_SUBSCRIPTION_FROM_SENDER() return "(bare_to == bare_from or to_node and rostermanager.is_contact_pending_in(to_node, to_host, bare_from))", { "rostermanager", "split_to", "bare_to", "bare_from" }; end function condition_handlers.PAYLOAD(payload_ns) return ("stanza:get_child(nil, %q)"):format(payload_ns); end function condition_handlers.INSPECT(path) if path:find("=") then local query, match_type, value = path:match("(.-)([~/$]*)=(.*)"); if not(query:match("#$") or query:match("@[^/]+")) then error("Stanza path does not return a string (append # for text content or @name for value of named attribute)", 0); end local meta_deps = {}; local quoted_value = ("%q"):format(value); if match_type:find("$", 1, true) then match_type = match_type:gsub("%$", ""); quoted_value = meta(quoted_value, meta_deps); end if match_type == "~" then -- Lua pattern match return ("(stanza:find(%q) or ''):match(%s)"):format(query, quoted_value), meta_deps; elseif match_type == "/" then -- find literal substring return ("(stanza:find(%q) or ''):find(%s, 1, true)"):format(query, quoted_value), meta_deps; elseif match_type == "" then -- exact match return ("stanza:find(%q) == %s"):format(query, quoted_value), meta_deps; else error("Unrecognised comparison '"..match_type.."='", 0); end end return ("stanza:find(%q)"):format(path); end function condition_handlers.FROM_GROUP(group_name) return ("group_contains(%q, bare_from)"):format(group_name), { "group_contains", "bare_from" }; end function condition_handlers.TO_GROUP(group_name) return ("group_contains(%q, bare_to)"):format(group_name), { "group_contains", "bare_to" }; end function condition_handlers.CROSSING_GROUPS(group_names) local code = {}; for group_name in group_names:gmatch("([^, ][^,]+)") do group_name = group_name:match("^%s*(.-)%s*$"); -- Trim leading/trailing whitespace -- Just check that's it is crossing from outside group to inside group table.insert(code, ("(group_contains(%q, bare_to) and group_contains(%q, bare_from))"):format(group_name, group_name)) end return "not "..table.concat(code, " or "), { "group_contains", "bare_to", "bare_from" }; end -- COMPAT w/0.12: Deprecated function condition_handlers.FROM_ADMIN_OF(host) return ("is_admin(bare_from, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_from" }; end -- COMPAT w/0.12: Deprecated function condition_handlers.TO_ADMIN_OF(host) return ("is_admin(bare_to, %s)"):format(host ~= "*" and metaq(host) or nil), { "is_admin", "bare_to" }; end -- COMPAT w/0.12: Deprecated function condition_handlers.FROM_ADMIN() return ("is_admin(bare_from, current_host)"), { "is_admin", "bare_from", "current_host" }; end -- COMPAT w/0.12: Deprecated function condition_handlers.TO_ADMIN() return ("is_admin(bare_to, current_host)"), { "is_admin", "bare_to", "current_host" }; end -- MAY: permission_to_check function condition_handlers.MAY(permission_to_check) return ("module:may(%q, event)"):format(permission_to_check); end function condition_handlers.TO_ROLE(role_name) return ("recipient_role and recipient_role.name == %q"):format(role_name), { "recipient_role" }; end function condition_handlers.FROM_ROLE(role_name) return ("sender_role and sender_role.name == %q"):format(role_name), { "sender_role" }; end local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 }; local function current_time_check(op, hour, minute) hour, minute = tonumber(hour), tonumber(minute); local adj_op = op == "<" and "<" or ">="; -- Start time inclusive, end time exclusive if minute == 0 then return "(current_hour"..adj_op..hour..")"; else return "((current_hour"..op..hour..") or (current_hour == "..hour.." and current_minute"..adj_op..minute.."))"; end end local function resolve_day_number(day_name) return assert(day_numbers[day_name:sub(1,3):lower()], "Unknown day name: "..day_name); end function condition_handlers.DAY(days) local conditions = {}; for day_range in days:gmatch("[^,]+") do local day_start, day_end = day_range:match("(%a+)%s*%-%s*(%a+)"); if day_start and day_end then local day_start_num, day_end_num = resolve_day_number(day_start), resolve_day_number(day_end); local op = "and"; if day_end_num < day_start_num then op = "or"; end table.insert(conditions, ("current_day >= %d %s current_day <= %d"):format(day_start_num, op, day_end_num)); elseif day_range:find("%a") then local day = resolve_day_number(day_range:match("%a+")); table.insert(conditions, "current_day == "..day); else error("Unable to parse day/day range: "..day_range); end end assert(#conditions>0, "Expected a list of days or day ranges"); return "("..table.concat(conditions, ") or (")..")", { "time:day" }; end function condition_handlers.TIME(ranges) local conditions = {}; for range in ranges:gmatch("([^,]+)") do local clause = {}; range = range:lower() :gsub("(%d+):?(%d*) *am", function (h, m) return tostring(tonumber(h)%12)..":"..(tonumber(m) or "00"); end) :gsub("(%d+):?(%d*) *pm", function (h, m) return tostring(tonumber(h)%12+12)..":"..(tonumber(m) or "00"); end); local start_hour, start_minute = range:match("(%d+):(%d+) *%-"); local end_hour, end_minute = range:match("%- *(%d+):(%d+)"); local op = tonumber(start_hour) > tonumber(end_hour) and " or " or " and "; if start_hour and end_hour then table.insert(clause, current_time_check(">", start_hour, start_minute)); table.insert(clause, current_time_check("<", end_hour, end_minute)); end if #clause == 0 then error("Unable to parse time range: "..range); end table.insert(conditions, "("..table.concat(clause, " "..op.." ")..")"); end return table.concat(conditions, " or "), { "time:hour,min" }; end function condition_handlers.LIMIT(spec) local name, param = spec:match("^(%w+) on (.+)$"); local meta_deps = {}; if not name then name = spec:match("^%w+$"); if not name then error("Unable to parse LIMIT specification"); end else param = meta(("%q"):format(param), meta_deps); end if not param then return ("not global_throttle_%s:poll(1)"):format(name), { "globalthrottle:"..name, unpack(meta_deps) }; end return ("not multi_throttle_%s:poll_on(%s, 1)"):format(name, param), { "multithrottle:"..name, unpack(meta_deps) }; end function condition_handlers.ORIGIN_MARKED(name_and_time) local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$"); if not name then name = name_and_time:match("^%s*([%w_]+)%s*$"); end if not name then error("Error parsing mark name, see documentation for usage examples"); end if time then return ("(current_timestamp - (session.firewall_marked_%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" }; end return ("not not session.firewall_marked_"..idsafe(name)); end function condition_handlers.USER_MARKED(name_and_time) local name, time = name_and_time:match("^%s*([%w_]+)%s+%(([^)]+)s%)%s*$"); if not name then name = name_and_time:match("^%s*([%w_]+)%s*$"); end if not name then error("Error parsing mark name, see documentation for usage examples"); end if time then return ([[( current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0) ) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" }; end return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")"); end function condition_handlers.SENT_DIRECTED_PRESENCE_TO_SENDER() return "not not (session.directed and session.directed[from])", { "from" }; end -- TO FULL JID? function condition_handlers.TO_FULL_JID() return "not not full_sessions[to]", { "to", "full_sessions" }; end -- CHECK LIST: spammers contains $<@from> function condition_handlers.CHECK_LIST(list_condition) local list_name, expr = list_condition:match("(%S+) contains (.+)$"); if not (list_name and expr) then error("Error parsing list check, syntax: LISTNAME contains EXPRESSION"); end local meta_deps = {}; expr = meta(("%q"):format(expr), meta_deps); return ("list_%s:contains(%s) == true"):format(list_name, expr), { "list:"..list_name, unpack(meta_deps) }; end -- SCAN: body for word in badwords function condition_handlers.SCAN(scan_expression) local search_name, pattern_name, list_name = scan_expression:match("(%S+) for (%S+) in (%S+)$"); if not (search_name) then error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST"); end return ("scan_list(list_%s, %s)"):format( list_name, "tokens_"..search_name.."_"..pattern_name ), { "scan_list", "tokens:"..search_name.."-"..pattern_name, "list:"..list_name }; end -- COUNT: lines in body < 10 local valid_comp_ops = { [">"] = ">", ["<"] = "<", ["="] = "==", ["=="] = "==", ["<="] = "<=", [">="] = ">=" }; function condition_handlers.COUNT(count_expression) local pattern_name, search_name, comparator_expression = count_expression:match("(%S+) in (%S+) (.+)$"); if not (pattern_name) then error("Error parsing COUNT expression, syntax: PATTERN in SEARCH COMPARATOR"); end local value; comparator_expression = comparator_expression:gsub("%d+", function (value_string) value = tonumber(value_string); return ""; end); if not value then error("Error parsing COUNT expression, expected value"); end local comp_op = comparator_expression:gsub("%s+", ""); assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op); return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format( search_name, pattern_name, comp_op, value ), { "it_count", "search:"..search_name, "pattern:"..pattern_name }; end -- FROM COUNTRY: SE -- FROM COUNTRY: code=SE -- FROM COUNTRY: SWE -- FROM COUNTRY: code3=SWE -- FROM COUNTRY: continent=EU -- FROM COUNTRY? --> NOT FROM COUNTRY: -- (for unknown/invalid) -- TODO list support? function condition_handlers.FROM_COUNTRY(geoip_spec) local condition = "=="; if not geoip_spec then geoip_spec = "--"; condition = "~="; end local field, country = geoip_spec:match("(%w+)=(%w+)"); if not field then if #geoip_spec == 3 then field, country = "code3", geoip_spec; elseif #geoip_spec == 2 then field, country = "code", geoip_spec; else error("Unknown country code type"); end end return ("get_geoip(session.ip, %q) %s %q"):format(field:lower(), condition, country:upper()), { "geoip_country" }; end return condition_handlers;
