Mercurial > prosody-modules
view mod_groups_internal/mod_groups_internal.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 | 8ff8121ff603 |
| children | 4fd1ad56176b |
line wrap: on
line source
local rostermanager = require"core.rostermanager"; local modulemanager = require"core.modulemanager"; local array = require "util.array"; local id = require "util.id"; local jid = require "util.jid"; local st = require "util.stanza"; local jid_join = jid.join; local host = module.host; local group_info_store = module:open_store("group_info", "keyval+"); local group_members_store = module:open_store("groups"); local group_memberships = module:open_store("groups", "map"); local muc_host_name = module:get_option("groups_muc_host"); local muc_host = nil; local is_contact_subscribed = rostermanager.is_contact_subscribed; -- Make a *one-way* subscription. User will see when contact is online, -- contact will not see when user is online. local function subscribe(user, user_jid, contact, contact_jid, group_name) -- Update user's roster to say subscription request is pending... rostermanager.set_contact_pending_out(user, host, contact_jid); -- Update contact's roster to say subscription request is pending... rostermanager.set_contact_pending_in(contact, host, user_jid); -- Update contact's roster to say subscription request approved... rostermanager.subscribed(contact, host, user_jid); -- Update user's roster to say subscription request approved... rostermanager.process_inbound_subscription_approval(user, host, contact_jid); if group_name then local user_roster = rostermanager.load_roster(user, host); user_roster[contact_jid].groups[group_name] = true; rostermanager.save_roster(user, host, user_roster, contact_jid); end -- Push updates to both rosters rostermanager.roster_push(user, host, contact_jid); rostermanager.roster_push(contact, host, user_jid); end local function user_groups(username) return pairs(group_memberships:get_all(username) or {}); end local function do_single_group_subscriptions(username, group_id) local members = group_members_store:get(group_id); if not members then return; end local group_name = group_info_store:get_key(group_id, "name"); local user_jid = jid_join(username, host); for membername in pairs(members) do if membername ~= username then local member_jid = jid_join(membername, host); if not is_contact_subscribed(username, host, member_jid) then module:log("debug", "[group %s] Subscribing %s to %s", member_jid, user_jid); subscribe(membername, member_jid, username, user_jid, group_name); end if not is_contact_subscribed(membername, host, user_jid) then module:log("debug", "[group %s] Subscribing %s to %s", user_jid, member_jid); subscribe(username, user_jid, membername, member_jid, group_name); end end end end local function do_all_group_subscriptions_by_user(username) for group_id in user_groups(username) do do_single_group_subscriptions(username, group_id); end end local function do_all_group_subscriptions_by_group(group_id) local members = get_members(group_id) if not members then return end for membername in pairs(members) do do_single_group_subscriptions(membername, group_id); end end module:hook("resource-bind", function(event) module:log("debug", "Updating group subscriptions..."); do_all_group_subscriptions_by_user(event.session.username); end); local function _create_muc_room(name) if not muc_host_name then module:log("error", "cannot create group MUC: no MUC host configured") return nil, "service-unavailable" end if not muc_host then module:log("error", "cannot create group MUC: MUC host %s not configured properly", muc_host_name) return nil, "internal-server-error" end local muc_jid = jid.prep(id.short() .. "@" .. muc_host_name); local room = muc_host.create_room(muc_jid) if not room then return nil, "internal-server-error" end local ok = pcall(function () room:set_public(false); room:set_persistent(true); room:set_members_only(true); room:set_allow_member_invites(false); room:set_moderated(false); room:set_whois("anyone"); room:set_name(name); end); if not ok then module:log("error", "Failed to configure group MUC %s", muc_jid); room:destroy(); return nil, "internal-server-error"; end return muc_jid, room; end --luacheck: ignore 131 function create(group_info, create_default_muc, group_id) if not group_info.name then return nil, "group-name-required"; end if group_id then if exists(group_id) then return nil, "conflict" end else group_id = id.short(); end local muc_jid = nil local room = nil if create_default_muc then muc_jid, room = _create_muc_room(group_info.name); if not muc_jid then -- MUC creation failed, fail to create group delete(group_id) return nil, room; end end local ok = group_info_store:set(group_id, { name = group_info.name; muc_jid = muc_jid; }); if not ok then if room then room:destroy() end return nil, "internal-server-error"; end return group_id; end function get_info(group_id) return group_info_store:get(group_id); end function set_info(group_id, info) if not info then return nil, "bad-request" end if not info.name or #info.name == 0 then return nil, "bad-request" end -- TODO: we should probably prohibit changing/removing the MUC JID of -- an existing group. if info.muc_jid then local room = muc_host.get_room_from_jid(info.muc_jid); room:set_name(info.name); end local ok = group_info_store:set(group_id, info); if not ok then return nil, "internal-server-error"; end return true end function get_members(group_id) return group_members_store:get(group_id) or {}; end function exists(group_id) return not not get_info(group_id); end function get_user_groups(username) local groups = {}; do local group_set = group_memberships:get_all(username); if group_set then for group_id in pairs(group_set) do table.insert(groups, group_id); end end end return groups; end function delete(group_id) if group_members_store:set(group_id, nil) then local group_info = get_info(group_id); if group_info then if group_info.muc_jid then local room = muc_host.get_room_from_jid(group_info.muc_jid) if room then room:destroy() end end for _, muc_jid in ipairs(group_info.mucs) do local room = muc_host.get_room_from_jid(muc_jid) if room then room:destroy() end end end return group_info_store:set(group_id, nil); end return nil, "internal-server-error"; end function add_member(group_id, username, delay_update) local group_info = group_info_store:get(group_id); if not group_info then return nil, "group-not-found"; end if not group_memberships:set(group_id, username, {}) then return nil, "internal-server-error"; end if group_info.muc_jid then local room = muc_host.get_room_from_jid(group_info.muc_jid); if room then local user_jid = username .. "@" .. host; room:set_affiliation(true, user_jid, "member"); module:send(st.message( { from = group_info.muc_jid, to = user_jid } ):tag("x", { xmlns = "jabber:x:conference", jid = group_info.muc_jid }):up()); module:log("debug", "set user %s to be member in %s and sent invite", username, group_info.muc_jid); else module:log("warn", "failed to update affiliation for %s in %s", username, group_info.muc_jid); end elseif group_info.mucs then local user_jid = username .. "@" .. host; for i = #group_info.mucs, 1, -1 do local muc_jid = group_info.mucs[i]; local room = muc_host.get_room_from_jid(muc_jid); if not room or room._data.destroyed then -- MUC no longer available, for some reason -- Let's remove it from the circle metadata... table.remove(group_info.mucs, i); group_info_store:set_key(group_id, "mucs", group_info.mucs); else room:set_affiliation(true, user_jid, "member"); module:send(st.message( { from = muc_jid, to = user_jid } ):tag("x", { xmlns = "jabber:x:conference", jid = muc_jid }):up()); module:log("debug", "set user %s to be member in %s and sent invite", username, muc_jid); end end end module:fire_event( "group-user-added", { id = group_id, user = username, host = host, group_info = group_info, } ) if not delay_update then do_all_group_subscriptions_by_group(group_id); end return true; end function remove_member(group_id, username) local group_info = group_info_store:get(group_id); if not group_info then return nil, "group-not-found"; end if not group_memberships:set(group_id, username, nil) then return nil, "internal-server-error"; end if group_info.muc_jid then local room = muc_host.get_room_from_jid(group_info.muc_jid); if room then local user_jid = username .. "@" .. host; room:set_affiliation(true, user_jid, nil); else module:log("warn", "failed to update affiliation for %s in %s", username, group_info.muc_jid); end elseif group_info.mucs then local user_jid = username .. "@" .. host; for _, muc_jid in ipairs(group_info.mucs) do local room = muc_host.get_room_from_jid(muc_jid); if room then room:set_affiliation(true, user_jid, nil); else module:log("warn", "failed to update affiliation for %s in %s", username, muc_jid); end end end module:fire_event( "group-user-removed", { id = group_id, user = username, host = host, group_info = group_info, } ) return true; end function sync(group_id) do_all_group_subscriptions_by_group(group_id); end function add_group_chat(group_id, name) local group_info = group_info_store:get(group_id); local mucs = group_info.mucs or {}; -- Create the MUC local muc_jid, room = _create_muc_room(name); if not muc_jid then return nil, room; end room:save(); -- This ensures the room is committed to storage table.insert(mucs, muc_jid); if group_info.muc_jid then -- COMPAT include old muc_jid into array table.insert(mucs, group_info.muc_jid); end local store_ok, store_err = group_info_store:set_key(group_id, "mucs", mucs); if not store_ok then module:log("error", "Failed to store new MUC association: %s", store_err); room:destroy(); return nil, "internal-server-error"; end -- COMPAT: clear old muc_jid (it's now in mucs array) if group_info.muc_jid then module:log("debug", "Clearing old single-MUC JID"); group_info.muc_jid = nil; group_info_store:set_key(group_id, "muc_jid", nil); end -- Make existing group members, members of the MUC for username in pairs(get_members(group_id)) do local user_jid = username .. "@" ..module.host; room:set_affiliation(true, user_jid, "member"); module:send(st.message( { from = muc_jid, to = user_jid } ):tag("x", { xmlns = "jabber:x:conference", jid = muc_jid }):up()); module:log("debug", "set user %s to be member in %s and sent invite", user_jid, muc_jid); end -- Notify other modules (such as mod_groups_muc_bookmarks) local muc = { jid = muc_jid; name = name; }; module:fire_event("group-chat-added", { group_id = group_id; group_info = group_info; muc = muc; }); return muc; end function remove_group_chat(group_id, muc_id) local group_info = group_info_store:get(group_id); if not group_info then return nil, "group-not-found"; end local mucs = group_info.mucs; if not mucs then if not group_info.muc_jid then return true; end -- COMPAT with old single-MUC groups - upgrade to new format mucs = {}; end if group_info.muc_jid then table.insert(mucs, group_info.muc_jid); end local removed; for i, muc_jid in ipairs(mucs) do if muc_id == jid.node(muc_jid) then removed = table.remove(mucs, i); break; end end if removed then if not group_info_store:set_key(group_id, "mucs", mucs) then return nil, "internal-server-error"; end if group_info.muc_jid then -- COMPAT: Now we've set the array, clean up muc_jid group_info.muc_jid = nil; group_info_store:set_key(group_id, "muc_jid", nil); end module:log("debug", "Updated group MUC list"); local room = muc_host.get_room_from_jid(removed); if room then room:destroy(); else module:log("warn", "Removing a group chat, but associated MUC not found (%s)", removed); end module:fire_event( "group-chat-removed", { group_id = group_id; group_info = group_info; muc = { id = muc_id; jid = removed; }; } ); else module:log("warn", "Removal of a group chat that can't be found - %s", muc_id); end return true; end function get_group_chats(group_id) local group_info, err = group_info_store:get(group_id); if not group_info then module:log("debug", "Unable to load group info: %s - %s", group_id, err); return nil; end local mucs = group_info.mucs or {}; -- COMPAT with single-MUC groups if group_info.muc_jid then table.insert(mucs, group_info.muc_jid); end return array.map(mucs, function (muc_jid) local room = muc_host.get_room_from_jid(muc_jid); return { id = jid.node(muc_jid); jid = muc_jid; name = room and room:get_name() or group_info.name; deleted = not room or room._data.destroyed; }; end); end function emit_member_events(group_id) local group_info, err = get_info(group_id) if group_info == nil then return false, err end for username in pairs(get_members(group_id)) do module:fire_event( "group-user-added", { id = group_id, user = username, host = host, group_info = group_info, } ) end return true end -- Returns iterator over group ids function groups() return group_info_store:items(); end local function setup() if not muc_host_name then module:log("info", "MUC management disabled (groups_muc_host set to nil)"); return; end local target_module = modulemanager.get_module(muc_host_name, "muc"); if not target_module then module:log("error", "host %s is not a MUC host -- group management will not work correctly; check your groups_muc_host setting!", muc_host_name); else module:log("debug", "found MUC host at %s", muc_host_name); muc_host = target_module; end end module:hook_global("user-deleted", function(event) if event.host ~= module.host then return end local username = event.username; for group_id in user_groups(username) do remove_member(group_id, username); end end); if prosody.start_time then -- server already started setup(); else module:hook_global("server-started", setup); end
