changeset 6309:f4eba826a308

mod_voipms_carddav: New module to sync contacts from CardDAV in mod_voipms format
author Chaz Kettleson <chaz@pyr3x.com>
date Fri, 01 Aug 2025 18:49:57 -0400
parents 6015477b6dec
children 92e2dcf039d9
files mod_voipms_carddav/README.md mod_voipms_carddav/mod_voipms_carddav.lua
diffstat 2 files changed, 212 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_voipms_carddav/README.md	Fri Aug 01 18:49:57 2025 -0400
@@ -0,0 +1,60 @@
+---
+labels:
+- 'Stage-Alpha'
+- 'Type-Web'
+summary: Sync contacts from CardDAV in mod_voipms format.
+rockspec:
+  build:
+    modules:
+      mod_voipms_carddav: mod_voipms_carddav.lua
+...
+
+Introduction
+============
+
+This is a Prosody module to periodically sync virtual contacts via CardDAV to a roster. It handles the format for contacts produced by the mod_voipms module. It treats contacts with multiple numbers as distinct contacts with a type (e.g. cell, home, work). Further, for each DID, it will create a separate contact (and group) to denote the DID number it is associated with.
+
+Note: Unlike the mod_voip.ms module, which you may run on a VirtualHost like "sms.example.com", this module should be enabled on the host that users login to. This is because it hooks the 'roster-load' function to inject virtual contacts, and these events will not be seen on subdomains. When building contact JIDs for each contact, it will use the module.host by default. If you are using "sms.example.com" for the mod_voip.ms module, you should set 'voipms_carddav_domain' to match.
+
+Configuration
+=============
+
+| option                            | type   | default                | description
+|-----------------------------------|--------|------------------------|------------|
+| voipms\_carddav\_contact\_format  | string | [%alias] %name (%type) | Format to display contacts ([Work] John Smith (cell))
+| voipms\_carddav\_domain           | string | module.host            | Domain part of JID
+| voipms\_carddav\_jid\_map | table | table  | nil                    | JID->DIDs/CardDAV
+| voipms\_carddav\_sync\_interval   | number | 86400                  | Interval (in seconds) to sync CardDAV
+```
+VirtualHost "example.com"
+modules_enabled = {
+    "voipms_carddav";
+}
+voipms_carddav_contact_format = "[%alias] %name (%type)"
+voipms_carddav_domain = "sms.example.com"
+voipms_carddav_jid_map = {
+    ["john@example.com"] = {
+        dids = {
+            ["+10345678901"] = "Home",
+            ["+10473828482"] = "Work",
+        },
+        carddav = {
+            username = "john",
+            password = "some_password",
+            url = "https://cloud.example.com/remote.php/dav/addressbooks/users/john/contacts/"
+        }
+    },
+    ["jane@example.com"] = {
+        dids = {
+            ["+10484828493"] = "Personal",
+            ["+10483829293"] = "Professional",
+        },
+        carddav = {
+            username = "john",
+            password = "some_password",
+            url = "https://cloud.example.com/remote.php/dav/addressbooks/users/john/contacts/"
+        }
+    }
+}
+voipms_carddav_sync_interval = 86400
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_voipms_carddav/mod_voipms_carddav.lua	Fri Aug 01 18:49:57 2025 -0400
@@ -0,0 +1,152 @@
+local http = require "net.http"
+local mime = require "mime"
+
+local carddav_domain = module:get_option("voipms_carddav_domain") or module.host
+local carddav_contact_format = module:get_option("voipms_carddav_contact_format") or "[%alias] %name (%type)"
+local carddav_jid_map = module:get_option("voipms_carddav_jid_map") or {}
+local carddav_sync_interval = module:get_option_number("voipms_carddav_sync_interval") or 86400
+local contact_cache = {}
+
+for jid, config in pairs(carddav_jid_map) do
+    local carddav = config.carddav
+    local dids = config.dids
+
+    local has_dids = dids and type(dids) == "table" and next(dids) ~= nil
+    local has_carddav = carddav and type(carddav) == "table"
+    local has_url = has_carddav and type(carddav.url) == "string" and carddav.url ~= ""
+    local has_username = has_carddav and type(carddav.username) == "string" and carddav.username ~= ""
+    local has_password = has_carddav and type(carddav.password) == "string" and carddav.password ~= ""
+
+    if not has_dids then
+        module:log("error", "Config for %s missing or empty 'dids' table", jid)
+    end
+    if not has_carddav then
+        module:log("error", "Config for %s missing 'carddav' table", jid)
+    else
+        if not has_url then
+            module:log("error", "Config for %s missing CardDAV URL", jid)
+        end
+        if not has_username then
+            module:log("error", "Config for %s missing CardDAV username", jid)
+        end
+        if not has_password then
+            module:log("error", "Config for %s missing CardDAV password", jid)
+        end
+    end
+
+    if not (has_dids and has_carddav and has_url and has_username and has_password) then
+        module:log("warning", "Incomplete config for %s, skipping", jid)
+        carddav_jid_map[jid] = nil
+    end
+end
+
+local function parse_contacts(response_body)
+    local contacts = {}
+
+    local function clean_string(s)
+        if not s then return "" end
+        s = s:gsub("&#13;", "")
+        s = s:gsub("[\r\n]+", "")
+        return s
+    end
+
+    for vcard_block in response_body:gmatch("BEGIN:VCARD(.-)END:VCARD") do
+        local fn = vcard_block:match("FN:(.-)\r?\n") or "Unknown"
+        fn = clean_string(fn)
+
+        for tel_type, tel_number in vcard_block:gmatch("TEL;TYPE=([^:]+):([^\r\n]+)") do
+            tel_type = clean_string(tel_type):lower()
+            tel_number = clean_string(tel_number)
+
+            table.insert(contacts, {
+                name = fn,
+                phone = {
+                    type = tel_type,
+                    number = tel_number,
+                }
+            })
+        end
+    end
+
+    return contacts
+end
+
+local function sync_carddav_contacts()
+    for jid, config in pairs(carddav_jid_map) do
+        local carddav = config.carddav
+        local auth = "Basic " .. mime.b64(carddav.username .. ":" .. carddav.password)
+
+        local headers = {
+            ["Content-Type"] = "application/xml; charset=utf-8",
+            ["Depth"] = "1",
+            ["Authorization"] = auth,
+        }
+
+        local body = [[<?xml version="1.0" encoding="UTF-8"?>
+        <card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+            <d:prop>
+                <d:getetag/>
+                <card:address-data/>
+            </d:prop>
+        </card:addressbook-query>]]
+
+        http.request(carddav.url, {
+            method = "REPORT",
+            headers = headers,
+            body = body,
+        }, function(response_body, code)
+            if code == 207 then
+                contact_cache[jid] = parse_contacts(response_body)
+            else
+                module:log("error", "CardDAV query failed for %s: HTTP %s", jid, tostring(code))
+            end
+        end)
+    end
+end
+
+sync_carddav_contacts()
+module:log("debug", "Scheduling contact sync every %d seconds", carddav_sync_interval)
+module:add_timer(carddav_sync_interval, sync_carddav_contacts)
+
+module:hook("roster-load", function(event)
+    local jid = event.username .. "@" .. event.host
+    local roster = event.roster
+    local config = carddav_jid_map[jid]
+    if not config then return end
+
+    local dids = config.dids
+    local carddav = config.carddav
+    local contacts = contact_cache[jid]
+
+    if not contacts then return end
+
+    local function format_contact_display(template, values)
+        return (template:gsub("%%(%w+)", function(key)
+            return values[key] or ""
+        end))
+    end
+
+    for _, contact in ipairs(contacts) do
+        for to_number, to_number_alias in pairs(dids) do
+            local from_number = contact.phone.number
+            local contact_jid = from_number .. "%" .. to_number .. "@" .. carddav_domain
+--            local contact_name = contact.name .. " (" .. contact.phone.type .. ") [" .. to_number_alias .. "]"
+            local contact_name = format_contact_display(carddav_contact_format, {
+                name = contact.name,
+                type = contact.phone.type,
+                alias = to_number_alias
+            })
+
+            roster[contact_jid] = {
+                name = contact_name,
+                subscription = "none",
+                groups = { [to_number_alias] = true },
+                persist = false
+            }
+        end
+    end
+
+    if roster[false] then
+        roster[false].version = true
+    end
+end)