{ lib, cfg, models, allPhones, intercomEntries, mohDirs, greetingDirs }: let hasTrunk = cfg.sipTrunks != {}; # Produces either "prefix" (static) or a #exec that reads the file at runtime. # Use this wherever a config value can be supplied either inline or from a key file. runtimeLine = prefix: val: file: if file != null then ''#exec echo "${prefix}$(cat ${file})"'' else "${prefix}${val}"; hasSharedMailbox = cfg.sharedMailbox != null; hasPersonalMailboxes = lib.any (p: p.mailboxExt != null) (lib.attrValues allPhones); hasAnyMailbox = hasPersonalMailboxes || hasSharedMailbox; # Helper to produce a literal Asterisk variable reference like ${EXTEN} # when used inside a Nix string interpolation: ${av "EXTEN"} → ${EXTEN} av = name: "\${${name}}"; # Group phone keys by extension number (multiple phones can share one extension) phonesByExtension = lib.foldlAttrs (acc: key: phone: acc // { ${phone.extension} = (acc.${phone.extension} or []) ++ [ key ]; } ) {} allPhones; # Per-extension metadata derived from allPhones (all phones of the same extension # share the same personKey and mailboxExt by construction) extensionInfo = lib.mapAttrs (_ext: keys: let sample = allPhones.${lib.head keys}; in { inherit keys; personKey = sample.personKey; mailboxExt = sample.mailboxExt; ringTimeout = if sample.personKey != null then cfg.persons.${sample.personKey}.ringTimeout else 30; } ) phonesByExtension; # L2 only makes sense for person phones: they need separate lines for # personal vs family calls and have distinct mailbox MWI per line. # Shared phones (no personal mailbox) and sip-clients use L1 for everything. phoneHasL2 = key: let phone = allPhones.${key}; in phone.personKey != null && phone.sharedLine && models.${phone.model}.hasMultiLine; # Dial target: L2 for provisioned phones, L1 for sip-clients (can only register once) dialTarget = key: if phoneHasL2 key then "PJSIP/${key}-l2" else "PJSIP/${key}"; # Dial target strings # L2 endpoints for "all" DID routing: shared phones (always) + persons with sharedLine = true allL2Endpoints = lib.concatStringsSep "&" (map dialTarget (lib.attrNames (lib.filterAttrs (_: phone: phone.personKey == null || phone.sharedLine ) allPhones))); personL1Endpoints = personKey: lib.concatStringsSep "&" (map (key: "PJSIP/${key}") (lib.attrNames cfg.persons.${personKey}.phones)); personsL2Endpoints = personKeys: lib.concatStringsSep "&" (lib.concatMap (personKey: map dialTarget (lib.attrNames cfg.persons.${personKey}.phones) ) personKeys); # --- Outbound trunk resolution --- # Find the first DID matching a predicate, returning { id, trunk } or null. findDid = pred: lib.foldlAttrs (acc: id: didCfg: if acc != null then acc else if pred didCfg then { inherit id; inherit (didCfg) trunk; } else null ) null cfg.dids; # The shared/family DID: routing.type = "all" allDid = findDid (d: d.routing.type == "all"); # Personal DID for a given person key personDid = personKey: findDid (d: d.routing.type == "person" && d.routing.person == personKey); # Outbound assignment for an L1 endpoint: personal DID if person, else allDid l1OutboundFor = phone: if phone.personKey != null then let pd = personDid phone.personKey; in if pd != null then pd else allDid else allDid; # Render set_var lines for outbound trunk + caller ID, or empty string if no DID found outboundVars = did: if did == null then "" else let t = cfg.sipTrunks.${did.trunk}; in "set_var=OUTBOUND_TRUNK=trunk-${did.trunk}-endpoint\n " + lib.optionalString (t.callerId != "" || t.callerIdFile != null) (runtimeLine "set_var=OUTBOUND_DID=" t.callerId t.callerIdFile + "\n "); # Page: only intercom-capable phones with an intercom line (auto-answer speakerphone). allPageEndpoints = lib.concatStringsSep "&" (lib.mapAttrsToList (key: _: "PJSIP/${key}-intercom") (lib.filterAttrs (key: phone: models.${phone.model}.hasIntercom && cfg.intercomPrefix != null ) allPhones)); # --- PJSIP endpoint generators --- genL1Endpoint = key: phone: let m = models.${phone.model}; # Personal mailbox takes priority; shared phones watch the shared mailbox effectiveMailbox = if phone.mailboxExt != null then phone.mailboxExt else if hasSharedMailbox then cfg.sharedMailbox.mailboxId else null; mailboxLine = lib.optionalString (effectiveMailbox != null) "mailboxes = ${effectiveMailbox}@voicemail\n "; vmVar = lib.optionalString (effectiveMailbox != null) "set_var=VOICEMAIL_MAILBOX=${effectiveMailbox}\n "; obVars = lib.optionalString hasTrunk (outboundVars (l1OutboundFor phone)); in '' [${key}](${m.endpointTemplate}) auth = auth-${key} aors = ${key} callerid = ${phone.displayName} <${phone.extension}> ${mailboxLine}${vmVar}${obVars} [auth-${key}](auth-userpass) username = ${key} password = ${phone.password} [${key}] type = aor max_contacts = ${toString m.maxContacts} remove_existing = yes ''; genL2Endpoint = key: phone: let m = models.${phone.model}; k = "${key}-l2"; mailboxLine = lib.optionalString hasSharedMailbox "mailboxes = ${cfg.sharedMailbox.mailboxId}@voicemail\n "; vmVar = lib.optionalString hasSharedMailbox "set_var=VOICEMAIL_MAILBOX=${cfg.sharedMailbox.mailboxId}\n "; obVars = outboundVars allDid; in '' [${k}](${m.endpointTemplate}) auth = auth-${k} aors = ${k} ${mailboxLine}${vmVar}${obVars} [auth-${k}](auth-userpass) username = ${k} password = ${phone.password} [${k}] type = aor max_contacts = ${toString m.maxContacts} remove_existing = yes ''; pjsip = '' [transport-tcp] type = transport protocol = tcp bind = 0.0.0.0:${toString cfg.sipPort} [transport-udp] type = transport protocol = udp bind = 0.0.0.0:${toString cfg.sipPort} ; --- endpoint templates --- ${lib.concatStringsSep "\n" (lib.mapAttrsToList (_: m: '' [${m.endpointTemplate}](!) type = endpoint context = internal transport = transport-tcp disallow = all ${lib.concatMapStrings (c: "allow = ${c}\n ") m.codecs} direct_media = no send_pai = yes '') models)} [auth-userpass](!) type = auth auth_type = userpass ; --- phones (L1) --- '' + lib.concatStringsSep "\n" (lib.mapAttrsToList genL1Endpoint allPhones) + lib.optionalString hasTrunk ( let provisionedPhones = lib.filterAttrs (key: _: phoneHasL2 key) allPhones; in lib.optionalString (provisionedPhones != {}) ( "\n ; --- family line endpoints (L2, provisioned phones only) ---\n\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList genL2Endpoint provisionedPhones) ) ) + lib.concatMapStringsSep "\n" (ic: '' ; --- intercom --- [${ic.endpoint}](${ic.endpointTemplate}) auth = auth-${ic.endpoint} aors = ${ic.endpoint} [auth-${ic.endpoint}](auth-userpass) username = ${ic.endpoint} password = ${ic.password} [${ic.endpoint}] type = aor max_contacts = ${toString ic.maxContacts} remove_existing = yes '') intercomEntries + lib.optionalString hasTrunk ( "\n ; --- SIP trunks ---\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: t: '' [trunk-${name}-registration] type = registration outbound_auth = trunk-${name}-auth ${runtimeLine "server_uri = sip:" t.host t.hostFile} ${let u = if t.usernameFile != null then "$(cat ${t.usernameFile})" else t.username; h = if t.hostFile != null then "$(cat ${t.hostFile})" else t.host; in if t.usernameFile != null || t.hostFile != null then ''#exec echo "client_uri = sip:${u}@${h}"'' else "client_uri = sip:${u}@${h}"} retry_interval = 60 forbidden_retry_interval = 600 expiration = 3600 [trunk-${name}-auth] type = auth auth_type = userpass ${runtimeLine "username = " t.username t.usernameFile} ${runtimeLine "password = " t.password t.passwordFile} [trunk-${name}-endpoint] type = endpoint context = from-external transport = transport-${t.transport} outbound_auth = trunk-${name}-auth aors = trunk-${name}-aor trust_id_inbound = yes disallow = all ${lib.concatMapStrings (c: "allow = ${c}\n ") t.codecs} [trunk-${name}-aor] type = aor ${runtimeLine "contact = sip:" t.host t.hostFile} [trunk-${name}-identify] type = identify endpoint = trunk-${name}-endpoint ${runtimeLine "match = " t.host t.hostFile} '') cfg.sipTrunks)); # --- Dialplan --- internalContext = "[internal]\n" # Line extensions (auto-generated from allPhones, grouped by extension) + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info: let dialStr = lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys); in if info.mailboxExt != null then ''exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout}) same => n,VoiceMail(${info.mailboxExt}@voicemail,u)'' else "exten => ${ext},1,Dial(${dialStr},${toString info.ringTimeout})" ) extensionInfo) # BLF hints for person extensions + "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: info: lib.optionalString (info.personKey != null) "exten => ${ext},hint,${lib.concatStringsSep "&" (map (k: "PJSIP/${k}") info.keys)}" ) extensionInfo) # Page and app extensions from cfg.extensions + "\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (ext: extCfg: if extCfg.mode == "page" then "exten => ${ext},1,Page(${allPageEndpoints},i,120)" else if extCfg.mode == "all" then "exten => ${ext},1,Dial(${allL2Endpoints},30)" else "exten => ${ext},1,${extCfg.app}" ) cfg.extensions) # Auto-generated intercom extensions + "\n" + lib.concatMapStringsSep "\n" (ic: "exten => ${ic.extension},1,Dial(PJSIP/${ic.endpoint},30)" ) intercomEntries # Voicemail check (*97 — uses VOICEMAIL_MAILBOX set on the endpoint) + lib.optionalString hasAnyMailbox "\nexten => *97,1,VoiceMailMain(${av "VOICEMAIL_MAILBOX"}@voicemail,sa(0))" # Shared mailbox direct check extension + lib.optionalString hasSharedMailbox "\nexten => ${cfg.sharedMailbox.checkExtension},1,VoiceMailMain(${cfg.sharedMailbox.mailboxId}@voicemail,sa(0))" # Outbound — trunk and caller ID determined by set_var on the originating endpoint + lib.optionalString hasTrunk '' exten => _0.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) exten => _00.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) exten => _+.,1,Set(CALLERID(num)=${av "OUTBOUND_DID"}) same => n,Dial(PJSIP/${av "EXTEN"}@${av "OUTBOUND_TRUNK"}) ''; externalContext = lib.optionalString hasTrunk ( '' [from-external] ; Provider sent INVITE without DID in Request-URI — extract from To header. ; To: ; CUT by ':' field 2 → +49123456789@provider.example.com ; CUT by '@' field 1 → +49123456789 exten => s,1,Set(DID=${av "PJSIP_HEADER(read,To)"}) same => n,Set(DID=${av "CUT(DID,:,2)"}) same => n,Set(DID=${av "CUT(DID,@,1)"}) same => n,Goto(from-external,${av "DID"},1) same => n,Hangup(21) '' + lib.concatStringsSep "\n" (lib.mapAttrsToList (id: didCfg: let r = didCfg.routing; dialStr = if r.type == "all" then allL2Endpoints else if r.type == "person" then personL1Endpoints r.person else personsL2Endpoints r.persons; mohOpt = lib.optionalString (didCfg.musicOnHold != null) "m(${didCfg.musicOnHold})"; dialArgs = "${dialStr},${toString r.timeout},${mohOpt}"; noAnsLine = if didCfg.mailbox == "shared" then let gdir = greetingDirs.shared; in lib.optionalString (gdir != null) "\n same => n,Playback(${gdir}/greeting)" + "\n same => n,VoiceMail(${cfg.sharedMailbox.mailboxId}@voicemail,${if gdir != null then "s" else "u"})" else if didCfg.mailbox == "person" then let person = cfg.persons.${r.person}; gdir = greetingDirs.persons.${r.person}; in lib.optionalString (gdir != null) "\n same => n,Playback(${gdir}/greeting)" + "\n same => n,VoiceMail(${person.extension}@voicemail,${if gdir != null then "s" else "u"})" else ""; # Static number: inline it. Runtime number: #exec reads the file and outputs the exten line. # noAnsLine follows as static text — Asterisk processes #exec output inline with the file, # so "same =>" on the next line correctly refers to the exten => emitted by #exec. extenLine = if didCfg.numberFile != null then "#exec echo 'exten => '$(cat ${didCfg.numberFile})',1,Dial(${dialArgs})'" else "exten => ${didCfg.number},1,Dial(${dialArgs})"; in "; ${didCfg.displayName} (${id}, trunk: ${didCfg.trunk})\n${extenLine}${noAnsLine}" ) cfg.dids) ); extensions = internalContext + externalContext; rtp = '' [general] rtpstart = ${toString cfg.rtpStart} rtpend = ${toString cfg.rtpEnd} ''; voicemail = '' [general] format = ulaw [voicemail] '' + lib.concatStringsSep "\n" ( lib.mapAttrsToList (_key: person: lib.optionalString person.mailbox " ${person.extension} => ,${person.displayName},,attach=no" ) cfg.persons ++ lib.optional hasSharedMailbox " ${cfg.sharedMailbox.mailboxId} => ,${cfg.sharedMailbox.displayName},,attach=no" ); musiconhold = lib.optionalString (cfg.mohClasses != {}) ( "[general]\n" + lib.concatStringsSep "\n" (lib.mapAttrsToList (name: cls: '' [${name}] mode = files directory = ${mohDirs.${name}} sort = ${cls.sort} '') cfg.mohClasses) ); in { "pjsip.conf" = pjsip; "extensions.conf" = extensions; "rtp.conf" = rtp; } // lib.optionalAttrs hasAnyMailbox { "voicemail.conf" = voicemail; } // lib.optionalAttrs (cfg.mohClasses != {}) { "musiconhold.conf" = musiconhold; }