<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <link>http://hg.omfa.de/prosody-modules/</link>
    <language>en-us</language>

    <title>prosody-modules: mod_voipms/mod_voipms.lua history</title>
    <description>mod_voipms/mod_voipms.lua revision history</description>
    <item>
    <title>mod_voipms: Chunk messages larger than 160 characters as per the API</title>
    <link>http://hg.omfa.de/prosody-modules/log/fa976338928c/mod_voipms/mod_voipms.lua</link>
    <description><![CDATA[mod_voipms: Chunk messages larger than 160 characters as per the API]]></description>
    <author>&#67;&#104;&#97;&#122;&#32;&#75;&#101;&#116;&#116;&#108;&#101;&#115;&#111;&#110;&#32;&#60;&#99;&#104;&#97;&#122;&#64;&#112;&#121;&#114;&#51;&#120;&#46;&#99;&#111;&#109;&#62;</author>
    <pubDate>Sun, 12 Oct 2025 17:47:24 -0400</pubDate>
</item>
<item>
    <title>mod_voipms: Send client error messages in additon to logging</title>
    <link>http://hg.omfa.de/prosody-modules/log/8f2de6652930/mod_voipms/mod_voipms.lua</link>
    <description><![CDATA[mod_voipms: Send client error messages in additon to logging]]></description>
    <author>&#67;&#104;&#97;&#122;&#32;&#75;&#101;&#116;&#116;&#108;&#101;&#115;&#111;&#110;&#32;&#60;&#99;&#104;&#97;&#122;&#64;&#112;&#121;&#114;&#51;&#120;&#46;&#99;&#111;&#109;&#62;</author>
    <pubDate>Mon, 18 Aug 2025 08:42:34 -0400</pubDate>
</item>
<item>
    <title>mod_voipms: Fix bug from not returning in message/bare hook</title>
    <link>http://hg.omfa.de/prosody-modules/log/c194bec4b56d/mod_voipms/mod_voipms.lua</link>
    <description><![CDATA[mod_voipms: Fix bug from not returning in message/bare hook<br/>
<br/>
Since SMS/MMS are sent/received on JIDs that are not real accounts, we<br/>
need to `return true` from the 'message/bare' hook, otherwise routing<br/>
will occur and result in failed account lookups.]]></description>
    <author>&#67;&#104;&#97;&#122;&#32;&#75;&#101;&#116;&#116;&#108;&#101;&#115;&#111;&#110;&#32;&#60;&#99;&#104;&#97;&#122;&#64;&#112;&#121;&#114;&#51;&#120;&#46;&#99;&#111;&#109;&#62;</author>
    <pubDate>Fri, 01 Aug 2025 18:36:47 -0400</pubDate>
</item>
<item>
    <title>mod_voipms: Updates</title>
    <link>http://hg.omfa.de/prosody-modules/log/8689f49d21ef/mod_voipms/mod_voipms.lua</link>
    <description><![CDATA[mod_voipms: Updates]]></description>
    <author>&#77;&#101;&#110;&#101;&#108;&#32;&#60;&#109;&#101;&#110;&#101;&#108;&#64;&#115;&#110;&#105;&#107;&#107;&#101;&#116;&#46;&#100;&#101;&#62;</author>
    <pubDate>Tue, 22 Jul 2025 06:24:32 +0200</pubDate>
</item>
<item>
    <title>mod_voipms: New Module to send and receive SMS/MMS via VoIP.ms APIs.</title>
    <link>http://hg.omfa.de/prosody-modules/log/5cf5ee23b361/mod_voipms/mod_voipms.lua</link>
    <description><![CDATA[mod_voipms: New Module to send and receive SMS/MMS via VoIP.ms APIs.<br/>
<br/>
<br/>
<br/>
diff --git a/mod_voipms/README.md b/mod_voipms/README.md<br/>
new file mode 100644<br/>
--- /dev/null<br/>
+++ b/mod_voipms/README.md<br/>
@@ -0,0 +1,51 @@<br/>
+---<br/>
+labels:<br/>
+- 'Stage-Alpha'<br/>
+- 'Type-Web'<br/>
+summary: Send and receive SMS/MMS via VoIP.ms APIs.<br/>
+rockspec:<br/>
+  build:<br/>
+    modules:<br/>
+      mod_voipms: mod_voipms.lua<br/>
+...<br/>
+<br/>
+Introduction<br/>
+============<br/>
+<br/>
+This is a Prosody module to map JIDs to DIDs on VoIP.ms and support sending/receiving SMS/MMS.<br/>
+<br/>
+Configuration<br/>
+=============<br/>
+<br/>
+| option                | type   | default |<br/>
+|-----------------------|--------|---------|<br/>
+| voipms\_api\_username | string | nil     |<br/>
+| voipms\_api\_password | string | nil     |<br/>
+| voipms\_query\_key    | string | nil     |<br/>
+| voipms\_jid\_map      | table  | nil     |<br/>
+```<br/>
+VirtualHost &quot;sms.example.com&quot;<br/>
+modules_enabled = {<br/>
+    &quot;voipms&quot;;<br/>
+}<br/>
+voipms_api_username = john@example.com -- E-mail registered with VoIP.ms<br/>
+voipms_api_password = abcd1234 -- API password configured in VoIP.ms<br/>
+voipms_query_key = some_query_key -- query param 'key' part of your URL callback<br/>
+voipms_jid_map = {<br/>
+        [&quot;your_jid@your_domain.com&quot;] = &quot;+1234567890&quot;<br/>
+}<br/>
+```<br/>
+<br/>
+HTTP<br/>
+====<br/>
+<br/>
+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.<br/>
+<br/>
+VoIP.ms Webhook URL<br/>
+===================<br/>
+<br/>
+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:<br/>
+<br/>
+```<br/>
+https://sms.example.com/voipms?key=some_query_key<br/>
+```<br/>
diff --git a/mod_voipms/mod_voipms.lua b/mod_voipms/mod_voipms.lua<br/>
new file mode 100644<br/>
--- /dev/null<br/>
+++ b/mod_voipms/mod_voipms.lua<br/>
@@ -0,0 +1,166 @@<br/>
+local http = require &quot;net.http&quot;<br/>
+local json = require &quot;util.json&quot;<br/>
+local st = require &quot;util.stanza&quot;<br/>
+<br/>
+local api_username = module:get_option_string(&quot;voipms_api_username&quot;)<br/>
+local api_password = module:get_option_string(&quot;voipms_api_password&quot;)<br/>
+local query_key = module:get_option(&quot;voipms_query_key&quot;)<br/>
+local jid_map = module:get_option(&quot;voipms_jid_map&quot;) or {}<br/>
+local rest_endpoint = &quot;https://voip.ms/api/v1/rest.php&quot;<br/>
+<br/>
+if not api_username or not api_password or not query_key then<br/>
+    module:log(&quot;error&quot;, &quot;Missing required config values (voipms_api_username, voipms_api_password, voipms_query_key)&quot;)<br/>
+    return<br/>
+end<br/>
+<br/>
+module:depends(&quot;http&quot;)<br/>
+<br/>
+local function normalize_number(num)<br/>
+    if not num then return nil end<br/>
+    if num:sub(1, 1) ~= &quot;+&quot; then<br/>
+        return &quot;+1&quot; .. num<br/>
+    end<br/>
+    return num<br/>
+end<br/>
+<br/>
+local function extract_query_key(event)<br/>
+    return (event.request.url.query or &quot;&quot;):match(&quot;key=([^&amp;]+)&quot;)<br/>
+end<br/>
+<br/>
+module:provides(&quot;http&quot;, {<br/>
+    route = {<br/>
+        [&quot;POST&quot;] = function(event)<br/>
+            local req = event.request<br/>
+            local body = req.body or &quot;&quot;<br/>
+<br/>
+            if extract_query_key(event) ~= query_key then<br/>
+                module:log(&quot;warn&quot;, &quot;Unauthorized webhook: missing or invalid key&quot;)<br/>
+                return { status_code = 403 }<br/>
+            end<br/>
+<br/>
+            local json_payload, err = json.decode(body)<br/>
+            if not json_payload then<br/>
+                module:log(&quot;warn&quot;, &quot;Invalid JSON: %s&quot;, err or &quot;unknown error&quot;)<br/>
+                return { status_code = 400 }<br/>
+            end<br/>
+<br/>
+            local payload = json_payload.data and json_payload.data.payload<br/>
+            if not payload then<br/>
+                module:log(&quot;warn&quot;, &quot;Missing payload in JSON&quot;)<br/>
+                return { status_code = 400 }<br/>
+            end<br/>
+<br/>
+            local from = payload.from and payload.from.phone_number<br/>
+            local to_list = payload.to or {}<br/>
+            local to = #to_list &gt; 0 and to_list[1].phone_number or nil<br/>
+<br/>
+            if not from or not to then<br/>
+                module:log(&quot;warn&quot;, &quot;Missing phone numbers (from: %s, to: %s)&quot;, tostring(from), tostring(to))<br/>
+                return { status_code = 400 }<br/>
+            end<br/>
+<br/>
+            local normalized_to = normalize_number(to)<br/>
+            local target_jid = nil<br/>
+<br/>
+            for jid, did in pairs(jid_map) do<br/>
+                if normalize_number(did) == normalized_to then<br/>
+                    target_jid = jid<br/>
+                    break<br/>
+                end<br/>
+            end<br/>
+<br/>
+            if not target_jid then<br/>
+                module:log(&quot;warn&quot;, &quot;No JID mapping for DID %s&quot;, normalized_to)<br/>
+                return { status_code = 404 }<br/>
+            end<br/>
+<br/>
+            local normalized_from = normalize_number(from)<br/>
+            local message_text = payload.text or &quot;&quot;<br/>
+<br/>
+            local message = st.message({<br/>
+                from = normalized_from .. &quot;@&quot; .. module.host,<br/>
+                to = target_jid,<br/>
+                type = &quot;chat&quot;<br/>
+            }):tag(&quot;body&quot;):text(message_text):up()<br/>
+<br/>
+            if payload.media and #payload.media &gt; 0 then<br/>
+                for _, media_item in ipairs(payload.media) do<br/>
+                    if media_item.url then<br/>
+                        message_text = message_text .. &quot;\n&quot; .. media_item.url<br/>
+                        message:tag(&quot;x&quot;, { xmlns = &quot;jabber:x:oob&quot; })<br/>
+                               :tag(&quot;url&quot;):text(media_item.url):up():up()<br/>
+                    end<br/>
+                end<br/>
+            end<br/>
+<br/>
+            module:send(message)<br/>
+            module:log(&quot;info&quot;, &quot;Delivered SMS from %s to %s&quot;, normalized_from, target_jid)<br/>
+<br/>
+            return { status_code = 204 }<br/>
+        end<br/>
+    }<br/>
+})<br/>
+<br/>
+module:hook(&quot;message/bare&quot;, function(event)<br/>
+    local stanza = event.stanza<br/>
+    if stanza.attr.type ~= &quot;chat&quot; then return end<br/>
+<br/>
+    local from_jid = stanza.attr.from<br/>
+    local to_jid = stanza.attr.to<br/>
+    local body = stanza:get_child_text(&quot;body&quot;)<br/>
+    if not body or body == &quot;&quot; then return end<br/>
+<br/>
+    local from_number = jid_map[from_jid]<br/>
+    if not from_number then<br/>
+        module:log(&quot;warn&quot;, &quot;No DID mapping for JID %s&quot;, from_jid)<br/>
+        return<br/>
+    end<br/>
+<br/>
+    local to_number = to_jid:match(&quot;^([^@]+)&quot;)<br/>
+    if not to_number then<br/>
+        module:log(&quot;warn&quot;, &quot;Malformed JID in message.to: %s&quot;, to_jid)<br/>
+        return<br/>
+    end<br/>
+<br/>
+    to_number = normalize_number(to_number)<br/>
+<br/>
+    local media_urls = {}<br/>
+    for line in body:gmatch(&quot;[^\r\n]+&quot;) do<br/>
+        if line:match(&quot;^https?://&quot;) then<br/>
+            table.insert(media_urls, line)<br/>
+        end<br/>
+    end<br/>
+<br/>
+    local method = (#media_urls &gt; 0) and &quot;sendMMS&quot; or &quot;sendSMS&quot;<br/>
+    local query = {<br/>
+        api_username = api_username,<br/>
+        api_password = api_password,<br/>
+        method = method,<br/>
+        did = from_number,<br/>
+        dst = to_number,<br/>
+        message = body<br/>
+    }<br/>
+<br/>
+    if method == &quot;sendMMS&quot; then<br/>
+        for i, url in ipairs(media_urls) do<br/>
+            query[&quot;media_url[&quot; .. (i - 1) .. &quot;]&quot;] = url<br/>
+        end<br/>
+    end<br/>
+<br/>
+    local query_str = http.formencode(query)<br/>
+<br/>
+    http.request(rest_endpoint .. &quot;?&quot; .. query_str, {<br/>
+        method = &quot;GET&quot;;<br/>
+    }, function(response_body, code)<br/>
+        if code == 200 then<br/>
+            local resp, err = json.decode(response_body)<br/>
+            if not resp or resp.status ~= &quot;success&quot; then<br/>
+                module:log(&quot;error&quot;, &quot;Failed to send %s: %s&quot;, method, err or (resp and resp.status) or &quot;unknown&quot;)<br/>
+            else<br/>
+                module:log(&quot;info&quot;, &quot;Sent %s from %s to %s&quot;, method, from_number, to_number)<br/>
+            end<br/>
+        else<br/>
+            module:log(&quot;error&quot;, &quot;HTTP error sending %s: code %s&quot;, method, tostring(code))<br/>
+        end<br/>
+    end)<br/>
+end)]]></description>
    <author>&#67;&#104;&#97;&#122;&#32;&#60;&#109;&#101;&#110;&#101;&#108;&#64;&#115;&#110;&#105;&#107;&#107;&#101;&#116;&#46;&#100;&#101;&#62;</author>
    <pubDate>Mon, 21 Jul 2025 23:57:37 +0200</pubDate>
</item>

  </channel>
</rss>
