view mod_email_pass/vcard.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 5608dd296d5f
children
line wrap: on
line source

-- Copyright (C) 2011-2012 Kim Alvefur
-- 
-- This project is MIT/X11 licensed. Please see the
-- COPYING file in the source package for more information.
--

-- TODO
-- Fix folding.

local st = require "util.stanza";
local t_insert, t_concat = table.insert, table.concat;
local type = type;
local next, pairs, ipairs = next, pairs, ipairs;

local from_text, to_text, from_xep54, to_xep54;

local line_sep = "\n";

local vCard_dtd; -- See end of file

local function fold_line()
	error "Not implemented" --TODO
end
local function unfold_line()
	error "Not implemented"
	-- gsub("\r?\n[ \t]([^\r\n])", "%1");
end

local function vCard_esc(s)
	return s:gsub("[,:;\\]", "\\%1"):gsub("\n","\\n");
end

local function vCard_unesc(s)
	return s:gsub("\\?[\\nt:;,]", {
		["\\\\"] = "\\",
		["\\n"] = "\n",
		["\\r"] = "\r",
		["\\t"] = "\t",
		["\\:"] = ":", -- FIXME Shouldn't need to espace : in values, just params
		["\\;"] = ";",
		["\\,"] = ",",
		[":"] = "\29",
		[";"] = "\30",
		[","] = "\31",
	});
end

local function item_to_xep54(item)
	local t = st.stanza(item.name, { xmlns = "vcard-temp" });

	local prop_def = vCard_dtd[item.name];
	if prop_def == "text" then
		t:text(item[1]);
	elseif type(prop_def) == "table" then
		if prop_def.types and item.TYPE then
			if type(item.TYPE) == "table" then
				for _,v in pairs(prop_def.types) do
					for _,typ in pairs(item.TYPE) do
						if typ:upper() == v then
							t:tag(v):up();
							break;
						end
					end
				end
			else
				t:tag(item.TYPE:upper()):up();
			end
		end

		if prop_def.props then
			for _,v in pairs(prop_def.props) do
				if item[v] then
					t:tag(v):up();
				end
			end
		end

		if prop_def.value then
			t:tag(prop_def.value):text(item[1]):up();
		elseif prop_def.values then
			local prop_def_values = prop_def.values;
			local repeat_last = prop_def_values.behaviour == "repeat-last" and prop_def_values[#prop_def_values];
			for i=1,#item do
				t:tag(prop_def.values[i] or repeat_last):text(item[i]):up();
			end
		end
	end

	return t;
end

local function vcard_to_xep54(vCard)
	local t = st.stanza("vCard", { xmlns = "vcard-temp" });
	for i=1,#vCard do
		t:add_child(item_to_xep54(vCard[i]));
	end
	return t;
end

function to_xep54(vCards)
	if not vCards[1] or vCards[1].name then
		return vcard_to_xep54(vCards)
	else
		local t = st.stanza("xCard", { xmlns = "vcard-temp" });
		for i=1,#vCards do
			t:add_child(vcard_to_xep54(vCards[i]));
		end
		return t;
	end
end

function from_text(data)
	data = data -- unfold and remove empty lines
		:gsub("\r\n","\n")
		:gsub("\n ", "")
		:gsub("\n\n+","\n");
	local vCards = {};
	local c; -- current item
	for line in data:gmatch("[^\n]+") do
		local line = vCard_unesc(line);
		local name, params, value = line:match("^([-%a]+)(\30?[^\29]*)\29(.*)$");
		value = value:gsub("\29",":");
		if #params > 0 then
			local _params = {};
			for k,isval,v in params:gmatch("\30([^=]+)(=?)([^\30]*)") do
				k = k:upper();
				local _vt = {};
				for _p in v:gmatch("[^\31]+") do
					_vt[#_vt+1]=_p
					_vt[_p]=true;
				end
				if isval == "=" then
					_params[k]=_vt;
				else
					_params[k]=true;
				end
			end
			params = _params;
		end
		if name == "BEGIN" and value == "VCARD" then
			c = {};
			vCards[#vCards+1] = c;
		elseif name == "END" and value == "VCARD" then
			c = nil;
		elseif vCard_dtd[name] then
			local dtd = vCard_dtd[name];
			local p = { name = name };
			c[#c+1]=p;
			--c[name]=p;
			local up = c;
			c = p;
			if dtd.types then
				for _, t in ipairs(dtd.types) do
					local t = t:lower();
					if ( params.TYPE and params.TYPE[t] == true)
							or params[t] == true then
						c.TYPE=t;
					end
				end
			end
			if dtd.props then
				for _, p in ipairs(dtd.props) do
					if params[p] then
						if params[p] == true then
							c[p]=true;
						else
							for _, prop in ipairs(params[p]) do
								c[p]=prop;
							end
						end
					end
				end
			end
			if dtd == "text" or dtd.value then
				t_insert(c, value);
			elseif dtd.values then
				local value = "\30"..value;
				for p in value:gmatch("\30([^\30]*)") do
					t_insert(c, p);
				end
			end
			c = up;
		end
	end
	return vCards;
end

local function item_to_text(item)
	local value = {};
	for i=1,#item do
		value[i] = vCard_esc(item[i]);
	end
	value = t_concat(value, ";");

	local params = "";
	for k,v in pairs(item) do
		if type(k) == "string" and k ~= "name" then
			params = params .. (";%s=%s"):format(k, type(v) == "table" and t_concat(v,",") or v);
		end
	end

	return ("%s%s:%s"):format(item.name, params, value)
end

local function vcard_to_text(vcard)
	local t={};
	t_insert(t, "BEGIN:VCARD")
	for i=1,#vcard do
		t_insert(t, item_to_text(vcard[i]));
	end
	t_insert(t, "END:VCARD")
	return t_concat(t, line_sep);
end

function to_text(vCards)
	if vCards[1] and vCards[1].name then
		return vcard_to_text(vCards)
	else
		local t = {};
		for i=1,#vCards do
			t[i]=vcard_to_text(vCards[i]);
		end
		return t_concat(t, line_sep);
	end
end

local function from_xep54_item(item)
	local prop_name = item.name;
	local prop_def = vCard_dtd[prop_name];

	local prop = { name = prop_name };

	if prop_def == "text" then
		prop[1] = item:get_text();
	elseif type(prop_def) == "table" then
		if prop_def.value then --single item
			prop[1] = item:get_child_text(prop_def.value) or "";
		elseif prop_def.values then --array
			local value_names = prop_def.values;
			if value_names.behaviour == "repeat-last" then
				for i=1,#item.tags do
					t_insert(prop, item.tags[i]:get_text() or "");
				end
			else
				for i=1,#value_names do
					t_insert(prop, item:get_child_text(value_names[i]) or "");
				end
			end
		elseif prop_def.names then
			local names = prop_def.names;
			for i=1,#names do
				if item:get_child(names[i]) then
					prop[1] = names[i];
					break;
				end
			end
		end
		
		if prop_def.props_verbatim then
			for k,v in pairs(prop_def.props_verbatim) do
				prop[k] = v;
			end
		end

		if prop_def.types then
			local types = prop_def.types;
			prop.TYPE = {};
			for i=1,#types do
				if item:get_child(types[i]) then
					t_insert(prop.TYPE, types[i]:lower());
				end
			end
			if #prop.TYPE == 0 then
				prop.TYPE = nil;
			end
		end

		-- A key-value pair, within a key-value pair?
		if prop_def.props then
			local params = prop_def.props;
			for i=1,#params do
				local name = params[i]
				local data = item:get_child_text(name);
				if data then
					prop[name] = prop[name] or {};
					t_insert(prop[name], data);
				end
			end
		end
	else
		return nil
	end

	return prop;
end

local function from_xep54_vCard(vCard)
	local tags = vCard.tags;
	local t = {};
	for i=1,#tags do
		t_insert(t, from_xep54_item(tags[i]));
	end
	return t
end

function from_xep54(vCard)
	if vCard.attr.xmlns ~= "vcard-temp" then
		return nil, "wrong-xmlns";
	end
	if vCard.name == "xCard" then -- A collection of vCards
		local t = {};
		local vCards = vCard.tags;
		for i=1,#vCards do
			t[i] = from_xep54_vCard(vCards[i]);
		end
		return t
	elseif vCard.name == "vCard" then -- A single vCard
		return from_xep54_vCard(vCard)
	end
end

-- This was adapted from http://xmpp.org/extensions/xep-0054.html#dtd
vCard_dtd = {
	VERSION = "text", --MUST be 3.0, so parsing is redundant
	FN = "text",
	N = {
		values = {
			"FAMILY",
			"GIVEN",
			"MIDDLE",
			"PREFIX",
			"SUFFIX",
		},
	},
	NICKNAME = "text",
	PHOTO = {
		props_verbatim = { ENCODING = { "b" } },
		props = { "TYPE" },
		value = "BINVAL", --{ "EXTVAL", },
	},
	BDAY = "text",
	ADR = {
		types = {
			"HOME",
			"WORK", 
			"POSTAL", 
			"PARCEL", 
			"DOM",
			"INTL",
			"PREF", 
		},
		values = {
			"POBOX",
			"EXTADD",
			"STREET",
			"LOCALITY",
			"REGION",
			"PCODE",
			"CTRY",
		}
	},
	LABEL = {
		types = {
			"HOME", 
			"WORK", 
			"POSTAL", 
			"PARCEL", 
			"DOM",
			"INTL", 
			"PREF", 
		},
		value = "LINE",
	},
	TEL = {
		types = {
			"HOME", 
			"WORK", 
			"VOICE", 
			"FAX", 
			"PAGER", 
			"MSG", 
			"CELL", 
			"VIDEO", 
			"BBS", 
			"MODEM", 
			"ISDN", 
			"PCS", 
			"PREF", 
		},
		value = "NUMBER",
	},
	EMAIL = {
		types = {
			"HOME", 
			"WORK", 
			"INTERNET", 
			"PREF", 
			"X400", 
		},
		value = "USERID",
	},
	JABBERID = "text",
	MAILER = "text",
	TZ = "text",
	GEO = {
		values = {
			"LAT",
			"LON",
		},
	},
	TITLE = "text",
	ROLE = "text",
	LOGO = "copy of PHOTO",
	AGENT = "text",
	ORG = {
		values = {
			behaviour = "repeat-last",
			"ORGNAME",
			"ORGUNIT",
		}
	},
	CATEGORIES = {
		values = "KEYWORD",
	},
	NOTE = "text",
	PRODID = "text",
	REV = "text",
	SORTSTRING = "text",
	SOUND = "copy of PHOTO",
	UID = "text",
	URL = "text",
	CLASS = {
		names = { -- The item.name is the value if it's one of these.
			"PUBLIC",
			"PRIVATE",
			"CONFIDENTIAL",
		},
	},
	KEY = {
		props = { "TYPE" },
		value = "CRED",
	},
	DESC = "text",
};
vCard_dtd.LOGO = vCard_dtd.PHOTO;
vCard_dtd.SOUND = vCard_dtd.PHOTO;

return {
	from_text = from_text;
	to_text = to_text;

	from_xep54 = from_xep54;
	to_xep54 = to_xep54;

	-- COMPAT:
	lua_to_text = to_text;
	lua_to_xep54 = to_xep54;

	text_to_lua = from_text;
	text_to_xep54 = function (...) return to_xep54(from_text(...)); end;

	xep54_to_lua = from_xep54;
	xep54_to_text = function (...) return to_text(from_xep54(...)) end;
};