domma-cms 0.106.0 → 0.107.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/admin/js/lib/effect-defs.js +1 -1
  4. package/admin/js/lib/effects-builder.js +2 -2
  5. package/admin/js/lib/shortcode-context-menu.js +2 -2
  6. package/admin/js/templates/docs/usage-tools.html +7 -0
  7. package/package.json +2 -2
  8. package/plugins/_lib/call/invite.js +172 -0
  9. package/plugins/_lib/call/rooms.js +19 -5
  10. package/plugins/_lib/call/routes.js +78 -3
  11. package/plugins/_lib/mail/render.js +14 -1
  12. package/plugins/_lib/public/call/call.css +1 -1
  13. package/plugins/_lib/public/call/call.js +287 -23
  14. package/plugins/booking/CLAUDE.md +18 -1
  15. package/plugins/booking/docs/guide.md +1 -0
  16. package/plugins/booking/lib/call.js +18 -2
  17. package/plugins/booking/lib/store.js +2 -0
  18. package/plugins/booking/plugin.js +59 -6
  19. package/plugins/booking/plugin.json +1 -1
  20. package/plugins/booking/plugin.public.js +4 -4
  21. package/plugins/booking/tests/api.test.js +114 -0
  22. package/plugins/free-tier.lock.json +10 -10
  23. package/public/js/celebrations/index.js +1 -1
  24. package/public/js/celebrations/themes/butterfly-drawing.js +1 -0
  25. package/public/js/celebrations/themes/halloween-drawing.js +1 -0
  26. package/public/js/celebrations/themes/halloween.js +1 -1
  27. package/public/js/celebrations/themes/valentines.js +1 -1
  28. package/public/js/celebrations/trait-data.mjs +1 -0
  29. package/public/js/effects.js +1 -1
  30. package/server/routes/api/calls.js +25 -3
  31. package/server/routes/call-public.js +4 -3
  32. package/server/services/callInvites.js +238 -0
  33. package/server/services/hooks.js +55 -0
  34. package/server/services/instantCalls.js +30 -3
  35. package/server/services/plugins.js +6 -1
  36. package/server/templates/page.html +1 -1
@@ -0,0 +1 @@
1
+ export const CELEBRATION_THEMES={christmas:{label:"Christmas",traits:{snowflake:{label:"Snowflakes",types:["snowflake"],kind:"particle"},tree:{label:"Christmas trees",types:["tree"],count:"trees"},wreath:{label:"Wreaths",types:["wreath"],count:"wreaths"},snowman:{label:"Snowmen",types:["snowman"],count:"snowmen"},northStar:{label:"North star",types:["north-star"],count:"northStars"},sleigh:{label:"Santa's sleigh",types:["sleigh"]},robin:{label:"Robins",types:["robin"]},train:{label:"Steam train",types:["train"]},elf:{label:"Elves",types:["elf"]},peeingElf:{label:"Peeing elf",types:["peeing-elf"],chance:"peeingElfChance",enabled:!1},thievingElf:{label:"Thieving elf",types:["thieving-elf"],chance:"thievingElfChance",enabled:!1},firework:{label:"Fireworks",types:["firework","spark"]}}},halloween:{label:"Halloween",traits:{pumpkin:{label:"Falling pumpkins",types:["pumpkin"],kind:"particle"},bat:{label:"Bats",types:["bat"],kind:"particle"},ghost:{label:"Ghosts",types:["ghost"],kind:"particle"},spider:{label:"Spiders",types:["spider","spider-small"],count:"spiders"},gravestone:{label:"Gravestones",types:["gravestone"],count:"gravestones"},jackOLantern:{label:"Jack-o-lanterns",types:["jack-o-lantern"],count:"jackOLanterns"},scarecrow:{label:"Scarecrows",types:["scarecrow"],count:"scarecrows"},twinklingStar:{label:"Twinkling stars",types:["twinkling-star"],count:"twinklingStars"},hauntedHouse:{label:"Haunted house",types:["haunted-house"]},moon:{label:"Moon",types:["moon"]},witch:{label:"Witches",types:["witch"]},cauldron:{label:"Cauldron",types:["cauldron"]},floatingPumpkin:{label:"Floating pumpkins",types:["floating-pumpkin"]},lightning:{label:"Lightning",global:!0}}},valentines:{label:"Valentine's Day",traits:{heart:{label:"Hearts",types:["heart"],kind:"particle"},rosePetal:{label:"Rose petals",types:["rose-petal"],kind:"particle"},sparkle:{label:"Sparkles",types:["sparkle"],kind:"particle"},lips:{label:"Kisses",types:["lips"],kind:"particle"},heartGarland:{label:"Heart garlands",types:["heart-garland"],count:"garlands"},butterfly:{label:"Butterflies",types:["butterfly"],count:"butterflies"},envelope:{label:"Envelopes",types:["envelope"],count:"envelopes"},heartMoon:{label:"Heart moon",types:["heart-moon"]},cupid:{label:"Cupid",types:["cupid"],chance:"cupidChance"},loveLetter:{label:"Love letters",types:["love-letter"]},neonSign:{label:"Neon sign",types:["neon-sign"]}}},"guy-fawkes":{label:"Guy Fawkes Night",traits:{ember:{label:"Embers",types:["ember"],kind:"particle"},spark:{label:"Sparks",types:["spark","trail"],kind:"particle"},bonfire:{label:"Bonfires",types:["bonfire"],count:"bonfires"},guyEffigy:{label:"Guy effigies",types:["guy-effigy"]},catherineWheel:{label:"Catherine wheels",types:["catherine-wheel"]},romanCandle:{label:"Roman candles",types:["roman-candle"]},sparklerBundle:{label:"Sparklers",types:["sparkler-bundle"]},firework:{label:"Fireworks",types:["firework"],chance:"fireworkChance"},rocket:{label:"Rockets",types:["rocket"],chance:"rocketChance"},burst:{label:"Bursts",types:["burst"],chance:"burstChance"},moon:{label:"Moon",types:["moon"]},lightning:{label:"Lightning",global:!0}}},"st-patricks":{label:"St Patrick's Day",traits:{cloverPetal:{label:"Clover petals",types:["clover-petal"],kind:"particle"},shamrock:{label:"Shamrocks",types:["shamrock"],kind:"particle"},goldCoin:{label:"Gold coins",types:["gold-coin"],kind:"particle"},sparkle:{label:"Sparkles",types:["sparkle"],kind:"particle"},potOfGold:{label:"Pots of gold",types:["pot-of-gold"],count:"pots"},twinklingStar:{label:"Twinkling stars",types:["twinkling-star"],count:"twinklingStars"},leprechaun:{label:"Leprechauns",types:["leprechaun","static-leprechaun"],chance:"leprechaunChance"},rainbow:{label:"Rainbow",types:["rainbow"]},banshee:{label:"Banshee",types:["banshee"]},moon:{label:"Moon",types:["moon"]}}},"st-davids":{label:"St David's Day",traits:{daffodilPetal:{label:"Daffodil petals",types:["daffodil-petal"],kind:"particle"},daffodil:{label:"Daffodils",types:["daffodil"],kind:"particle"},springSparkle:{label:"Spring sparkles",types:["spring-sparkle"],kind:"particle"},leek:{label:"Leeks",types:["leek"],kind:"particle"},daffodilField:{label:"Daffodil fields",types:["daffodil-field"],count:"daffodilFields"},twinklingStar:{label:"Twinkling stars",types:["twinkling-star"],count:"twinklingStars"},welshDragon:{label:"Welsh dragon",types:["welsh-dragon"],chance:"dragonChance"},flag:{label:"Welsh flag",types:["flag"]},harp:{label:"Harp",types:["harp"]},leekBundle:{label:"Leek bundles",types:["leek-bundle"]}}},"st-georges":{label:"St George's Day",traits:{rosePetal:{label:"Rose petals",types:["rose-petal"],kind:"particle"},tudorRose:{label:"Tudor roses",types:["tudor-rose"],kind:"particle"},oakLeaf:{label:"Oak leaves",types:["oak-leaf"],kind:"particle"},sparkle:{label:"Sparkles",types:["sparkle"],kind:"particle"},englishRose:{label:"English roses",types:["english-rose"],count:"roses"},twinklingStar:{label:"Twinkling stars",types:["twinkling-star"],count:"twinklingStars"},knight:{label:"Knight",types:["knight"],chance:"knightChance"},dragon:{label:"Dragon",types:["dragon"]},castle:{label:"Castle",types:["castle"]},shield:{label:"Shields",types:["shield"]},stGeorgesCross:{label:"St George's cross",types:["st-georges-cross"]}}},"st-andrews":{label:"St Andrew's Day",traits:{heatherPetal:{label:"Heather petals",types:["heather-petal"],kind:"particle"},heather:{label:"Heather",types:["heather"],kind:"particle"},saltireSparkle:{label:"Saltire sparkles",types:["saltire-sparkle"],kind:"particle"},thistle:{label:"Thistles",types:["thistle"],kind:"particle"},thistlePlant:{label:"Thistle plants",types:["thistle-plant"],count:"thistles"},twinklingStar:{label:"Twinkling stars",types:["twinkling-star"],count:"twinklingStars"},bagpiper:{label:"Bagpiper",types:["bagpiper"],chance:"bagpiperChance"},saltireFlag:{label:"Saltire flag",types:["saltire-flag"]},tartanPattern:{label:"Tartan",types:["tartan-pattern"]},highlandScene:{label:"Highland scene",types:["highland-scene"]}}}};const c=a=>String(a).toLowerCase().replace(/[^a-z0-9]/g,"");export const traitAttribute=a=>a.replace(/[A-Z]/g,s=>"-"+s.toLowerCase());export function resolveCelebrationTraits(a,s={}){const n=new Set,r=new Set,i={},o=Object.entries(s).map(([l,e])=>[c(l),String(e).trim().toLowerCase()]),b=o.some(([l,e])=>["fallingonly","snowonly"].includes(l)&&["true","1","yes","on"].includes(e));for(const[l,e]of Object.entries(CELEBRATION_THEMES[a]?.traits||{})){const p=o.find(([t])=>[l,e.count,e.chance].filter(Boolean).some(d=>c(d)===t))?.[1];if(["off","false","0","no"].includes(p)||e.enabled===!1&&!["true","1","yes","on"].includes(p)||b&&e.kind!=="particle"){for(const t of e.types||[])n.add(t);e.global&&r.add(l);for(const t of[e.count,e.chance].filter(Boolean))i[t]=0}}return{disabledTypes:n,disabledGlobals:r,config:i}}
@@ -1 +1 @@
1
- (function(){"use strict";fetch("/api/effects/settings").then(function(y){return y.json()}).then(function(y){var z=y.respectMotion!==!1,A=y.defaultDuration||600,S=y.defaultAnimation||"fade",E=y.defaultThreshold!=null?y.defaultThreshold:.1,p=z&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,d=document.querySelector(".page-body");if(!d)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function i(t,e,n){var a=r(t,e);return a!==null?parseFloat(a):n}function v(t,e,n){var a=r(t,e);return a===null?n:a==="true"||a==="1"}function b(t,e,n){var a=new IntersectionObserver(function(o){o[0].isIntersecting&&(a.unobserve(t),n())},{threshold:e});a.observe(t)}d.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||S,n=i(t,"duration",A),a=i(t,"delay",0),o=i(t,"threshold",E),f=v(t,"once",!0);p||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+a+"ms, transform "+n+"ms ease "+a+"ms";var s=new IntersectionObserver(function(u){u.forEach(function(h){h.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:o});s.observe(t)})}))}),d.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=i(t,"to",0),n=i(t,"from",0),a=i(t,"duration",2e3),o=r(t,"prefix")||"",f=r(t,"suffix")||"",s=i(t,"decimals",0),u=r(t,"separator")||"";function h(m){var l=m.toFixed(s);if(u){var x=l.split(".");x[0]=x[0].replace(/\B(?=(\d{3})+(?!\d))/g,u),l=x.join(".")}return o+l+f}if(p){t.textContent=h(e);return}b(t,.5,function(){var m=null;requestAnimationFrame(function l(x){m||(m=x);var w=Math.min((x-m)/a,1),q=1-Math.pow(1-w,3);t.textContent=h(n+(e-n)*q),w<1&&requestAnimationFrame(l)})})}),p||d.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=i(t,"speed",50),n=t.textContent,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",o=0;b(t,.3,function(){var f=setInterval(function(){for(var s="",u=0;u<n.length;u++)u<o||n[u]===" "?s+=n[u]:s+=a[Math.floor(Math.random()*a.length)];t.textContent=s,o<n.length?o++:clearInterval(f)},e)})}),p||d.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=i(t,"intensity",5),n=i(t,"duration",500),a=r(t,"direction")||"horizontal",o=a==="vertical"?"translateY":"translateX";t.animate([{transform:o+"(0)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(0)"}],{duration:n,easing:"ease-in-out"})}),d.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",a=i(t,"duration",600),o=t.getBoundingClientRect(),f=Math.max(o.width,o.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-o.left-f/2)+"px;top:"+(e.clientY-o.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+a+"ms ease,opacity "+a+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},a)})});var c=window.Domma&&window.Domma.effects;c&&(d.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!p){var e={};r(t,"amplitude")&&(e.amplitude=i(t,"amplitude",6)),r(t,"duration")&&(e.duration=i(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=i(t,"stagger",0)),c.breathe(t,e)}}),d.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!p){var e={};r(t,"scale")&&(e.scale=i(t,"scale",1.05)),r(t,"duration")&&(e.duration=i(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),c.pulse(t,e)}}),d.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=i(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=i(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=v(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=v(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=i(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=v(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var a=t.textContent.trim();if(!a)return;t.textContent="",e.actions=[{render:a}]}c.scribe(t,e)}),d.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=i(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=i(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",3)),c.twinkle(t,e)}),typeof c.tickerTape=="function"&&d.querySelectorAll(".dm-fx-ticker-tape").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",50)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"sway")&&(e.sway=i(t,"sway",60)),r(t,"rotation-speed")&&(e.rotationSpeed=i(t,"rotation-speed",6)),r(t,"min-width")&&(e.minWidth=i(t,"min-width",5)),r(t,"max-width")&&(e.maxWidth=i(t,"max-width",9)),r(t,"min-height")&&(e.minHeight=i(t,"min-height",12)),r(t,"max-height")&&(e.maxHeight=i(t,"max-height",22)),r(t,"fade-start")&&(e.fadeStart=i(t,"fade-start",.55)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",150)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.tickerTape(null,e):c.tickerTape(t,e)}}),typeof c.butterflies=="function"&&d.querySelectorAll(".dm-fx-butterflies").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",18)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"wander")&&(e.wander=i(t,"wander",1)),r(t,"rise-speed")&&(e.riseSpeed=i(t,"rise-speed",.4)),r(t,"flap-speed")&&(e.flapSpeed=i(t,"flap-speed",1)),r(t,"min-size")&&(e.minSize=i(t,"min-size",14)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",26)),r(t,"two-tone")&&(e.twoTone=v(t,"two-tone",!0)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",40)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.butterflies(null,e):c.butterflies(t,e)}}),typeof c.strobe=="function"&&d.querySelectorAll(".dm-fx-strobe").forEach(function(t){if(p)return;function e(s){var u=r(t,s);if(!u)return null;var h=u.split(",").map(function(m){return m.trim()}).filter(Boolean);return h.length?h:null}var n={};r(t,"preset")&&(n.preset=r(t,"preset")),r(t,"motion")&&(n.motion=r(t,"motion")),r(t,"speed")&&(n.speed=i(t,"speed",1)),r(t,"sweep-arc")&&(n.sweepArc=i(t,"sweep-arc",100)),r(t,"beam-width")&&(n.beamWidth=i(t,"beam-width",16)),r(t,"intensity")&&(n.intensity=i(t,"intensity",.7)),r(t,"flicker")&&(n.flicker=v(t,"flicker",!0)),r(t,"hz")&&(n.hz=i(t,"hz",10));var a=e("origins");a&&(n.origins=a);var o=e("colours");o&&(n.colours=o),r(t,"duration")&&(n.duration=i(t,"duration",null)),r(t,"z-index")&&(n.zIndex=i(t,"z-index",9999));var f=r(t,"mode");f==="page"?c.strobe(null,n):c.strobe(t,n)}));var g=d.querySelectorAll(".dm-fx-celebrate");g.length&&!p&&import("/public/js/celebrations/index.js?v=20260930-snowman").then(function(t){var e=t.CelebrationsEffect;g.forEach(function(n){var a=n.getAttribute("data-fx-theme")||"auto",o=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(a==="auto"&&(a=e.getCurrentTheme(),!a))){var s={},u=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(m){m.name.startsWith("data-fx-")&&!u.includes(m.name)&&(s[m.name.slice(8)]=m.value)});var h=new e({theme:a,intensity:o,enabled:!0,zIndex:f,overrides:s});h.init().then(function(){h.start()})}})}).catch(function(){})}).catch(function(){})})();
1
+ (function(){"use strict";fetch("/api/effects/settings").then(function(y){return y.json()}).then(function(y){var z=y.respectMotion!==!1,A=y.defaultDuration||600,S=y.defaultAnimation||"fade",E=y.defaultThreshold!=null?y.defaultThreshold:.1,p=z&&window.matchMedia&&window.matchMedia("(prefers-reduced-motion: reduce)").matches,d=document.querySelector(".page-body");if(!d)return;function r(t,e){return t.getAttribute("data-fx-"+e)}function i(t,e,n){var a=r(t,e);return a!==null?parseFloat(a):n}function v(t,e,n){var a=r(t,e);return a===null?n:a==="true"||a==="1"}function b(t,e,n){var a=new IntersectionObserver(function(o){o[0].isIntersecting&&(a.unobserve(t),n())},{threshold:e});a.observe(t)}d.querySelectorAll(".dm-fx-reveal").forEach(function(t){var e=r(t,"animation")||S,n=i(t,"duration",A),a=i(t,"delay",0),o=i(t,"threshold",E),f=v(t,"once",!0);p||(t.style.opacity="0",t.style.transition="none",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"&&(t.style.transform="rotateY(90deg)"),requestAnimationFrame(function(){requestAnimationFrame(function(){t.style.transition="opacity "+n+"ms ease "+a+"ms, transform "+n+"ms ease "+a+"ms";var s=new IntersectionObserver(function(u){u.forEach(function(h){h.isIntersecting?(t.style.opacity="1",t.style.transform="",f&&s.unobserve(t)):f||(t.style.opacity="0",e==="slide-up"?t.style.transform="translateY(30px)":e==="slide-down"?t.style.transform="translateY(-30px)":e==="zoom"?t.style.transform="scale(0.85)":e==="flip"?t.style.transform="rotateY(90deg)":t.style.transform="")})},{threshold:o});s.observe(t)})}))}),d.querySelectorAll(".dm-fx-counter").forEach(function(t){var e=i(t,"to",0),n=i(t,"from",0),a=i(t,"duration",2e3),o=r(t,"prefix")||"",f=r(t,"suffix")||"",s=i(t,"decimals",0),u=r(t,"separator")||"";function h(m){var l=m.toFixed(s);if(u){var x=l.split(".");x[0]=x[0].replace(/\B(?=(\d{3})+(?!\d))/g,u),l=x.join(".")}return o+l+f}if(p){t.textContent=h(e);return}b(t,.5,function(){var m=null;requestAnimationFrame(function l(x){m||(m=x);var w=Math.min((x-m)/a,1),q=1-Math.pow(1-w,3);t.textContent=h(n+(e-n)*q),w<1&&requestAnimationFrame(l)})})}),p||d.querySelectorAll(".dm-fx-scramble").forEach(function(t){var e=i(t,"speed",50),n=t.textContent,a="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%&",o=0;b(t,.3,function(){var f=setInterval(function(){for(var s="",u=0;u<n.length;u++)u<o||n[u]===" "?s+=n[u]:s+=a[Math.floor(Math.random()*a.length)];t.textContent=s,o<n.length?o++:clearInterval(f)},e)})}),p||d.querySelectorAll(".dm-fx-shake").forEach(function(t){var e=i(t,"intensity",5),n=i(t,"duration",500),a=r(t,"direction")||"horizontal",o=a==="vertical"?"translateY":"translateX";t.animate([{transform:o+"(0)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(-"+e+"px)"},{transform:o+"("+e+"px)"},{transform:o+"(0)"}],{duration:n,easing:"ease-in-out"})}),d.querySelectorAll(".dm-fx-ripple").forEach(function(t){t.style.position="relative",t.style.overflow="hidden",t.addEventListener("click",function(e){var n=r(t,"colour")||"rgba(255,255,255,0.3)",a=i(t,"duration",600),o=t.getBoundingClientRect(),f=Math.max(o.width,o.height)*2,s=document.createElement("span");s.style.cssText="position:absolute;border-radius:50%;pointer-events:none;width:"+f+"px;height:"+f+"px;left:"+(e.clientX-o.left-f/2)+"px;top:"+(e.clientY-o.top-f/2)+"px;background:"+n+";transform:scale(0);opacity:1;transition:transform "+a+"ms ease,opacity "+a+"ms ease;",t.appendChild(s),requestAnimationFrame(function(){s.style.transform="scale(1)",s.style.opacity="0"}),setTimeout(function(){s.remove()},a)})});var c=window.Domma&&window.Domma.effects;c&&(d.querySelectorAll(".dm-fx-breathe").forEach(function(t){if(!p){var e={};r(t,"amplitude")&&(e.amplitude=i(t,"amplitude",6)),r(t,"duration")&&(e.duration=i(t,"duration",3e3)),r(t,"easing")&&(e.easing=r(t,"easing")),r(t,"stagger")&&(e.stagger=i(t,"stagger",0)),c.breathe(t,e)}}),d.querySelectorAll(".dm-fx-pulse").forEach(function(t){if(!p){var e={};r(t,"scale")&&(e.scale=i(t,"scale",1.05)),r(t,"duration")&&(e.duration=i(t,"duration",2e3)),r(t,"easing")&&(e.easing=r(t,"easing")),c.pulse(t,e)}}),d.querySelectorAll(".dm-fx-scribe").forEach(function(t){var e={};r(t,"speed")&&(e.speed=i(t,"speed",50)),r(t,"delete-speed")&&(e.deleteSpeed=i(t,"delete-speed",30)),r(t,"cursor")&&(e.cursor=v(t,"cursor",!0)),r(t,"cursor-char")&&(e.cursorChar=r(t,"cursor-char")),r(t,"cursor-type")&&(e.cursorType=r(t,"cursor-type")),r(t,"mode")&&(e.mode=r(t,"mode")),r(t,"loop")&&(e.loop=v(t,"loop",!1)),r(t,"loop-delay")&&(e.loopDelay=i(t,"loop-delay",1e3)),r(t,"pause-on-hover")&&(e.pauseOnHover=v(t,"pause-on-hover",!1));var n=r(t,"actions");if(n)try{e.actions=JSON.parse(n)}catch{return}else{var a=t.textContent.trim();if(!a)return;t.textContent="",e.actions=[{render:a}]}c.scribe(t,e)}),d.querySelectorAll(".dm-fx-twinkle").forEach(function(t){var e={};r(t,"count")&&(e.count=i(t,"count",100)),r(t,"shape")&&(e.shape=r(t,"shape")),r(t,"colour")&&(e.colour=r(t,"colour")),r(t,"min-size")&&(e.minSize=i(t,"min-size",1)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",3)),c.twinkle(t,e)}),typeof c.tickerTape=="function"&&d.querySelectorAll(".dm-fx-ticker-tape").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",50)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"sway")&&(e.sway=i(t,"sway",60)),r(t,"rotation-speed")&&(e.rotationSpeed=i(t,"rotation-speed",6)),r(t,"min-width")&&(e.minWidth=i(t,"min-width",5)),r(t,"max-width")&&(e.maxWidth=i(t,"max-width",9)),r(t,"min-height")&&(e.minHeight=i(t,"min-height",12)),r(t,"max-height")&&(e.maxHeight=i(t,"max-height",22)),r(t,"fade-start")&&(e.fadeStart=i(t,"fade-start",.55)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",150)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.tickerTape(null,e):c.tickerTape(t,e)}}),typeof c.butterflies=="function"&&d.querySelectorAll(".dm-fx-butterflies").forEach(function(t){if(!p){var e={};r(t,"palette")&&(e.palette=r(t,"palette")),r(t,"density")&&(e.density=i(t,"density",18)),r(t,"speed")&&(e.speed=i(t,"speed",1)),r(t,"wander")&&(e.wander=i(t,"wander",1)),r(t,"rise-speed")&&(e.riseSpeed=i(t,"rise-speed",.4)),r(t,"flap-speed")&&(e.flapSpeed=i(t,"flap-speed",1)),r(t,"min-size")&&(e.minSize=i(t,"min-size",14)),r(t,"max-size")&&(e.maxSize=i(t,"max-size",26)),r(t,"two-tone")&&(e.twoTone=v(t,"two-tone",!0)),r(t,"burst")&&(e.burst=v(t,"burst",!1)),r(t,"burst-count")&&(e.burstCount=i(t,"burst-count",40)),r(t,"z-index")&&(e.zIndex=i(t,"z-index",1));var n=r(t,"mode");n==="page"?c.butterflies(null,e):c.butterflies(t,e)}}),typeof c.strobe=="function"&&d.querySelectorAll(".dm-fx-strobe").forEach(function(t){if(p)return;function e(s){var u=r(t,s);if(!u)return null;var h=u.split(",").map(function(m){return m.trim()}).filter(Boolean);return h.length?h:null}var n={};r(t,"preset")&&(n.preset=r(t,"preset")),r(t,"motion")&&(n.motion=r(t,"motion")),r(t,"speed")&&(n.speed=i(t,"speed",1)),r(t,"sweep-arc")&&(n.sweepArc=i(t,"sweep-arc",100)),r(t,"beam-width")&&(n.beamWidth=i(t,"beam-width",16)),r(t,"intensity")&&(n.intensity=i(t,"intensity",.7)),r(t,"flicker")&&(n.flicker=v(t,"flicker",!0)),r(t,"hz")&&(n.hz=i(t,"hz",10));var a=e("origins");a&&(n.origins=a);var o=e("colours");o&&(n.colours=o),r(t,"duration")&&(n.duration=i(t,"duration",null)),r(t,"z-index")&&(n.zIndex=i(t,"z-index",9999));var f=r(t,"mode");f==="page"?c.strobe(null,n):c.strobe(t,n)}));var g=d.querySelectorAll(".dm-fx-celebrate");g.length&&!p&&import("/public/js/celebrations/index.js?v=20261001-options").then(function(t){var e=t.CelebrationsEffect;g.forEach(function(n){var a=n.getAttribute("data-fx-theme")||"auto",o=n.getAttribute("data-fx-intensity")||"medium",f=n.getAttribute("data-fx-z-index")?parseInt(n.getAttribute("data-fx-z-index"),10):999;if(!(a==="auto"&&(a=e.getCurrentTheme(),!a))){var s={},u=["data-fx-theme","data-fx-intensity","data-fx-z-index"];Array.from(n.attributes).forEach(function(m){m.name.startsWith("data-fx-")&&!u.includes(m.name)&&(s[m.name.slice(8)]=m.value)});var h=new e({theme:a,intensity:o,enabled:!0,zIndex:f,overrides:s});h.init().then(function(){h.start()})}})}).catch(function(){})}).catch(function(){})})();
@@ -25,6 +25,9 @@
25
25
  * The call itself (no sign-in - the link is the credential):
26
26
  * /api/calls/room/:id/{join,poll,signal,chat,answer,recording,leave} (plugins/_lib/call/routes.js)
27
27
  * join takes {t} (the guest) or {h} (the host); guests knock and the host lets them in.
28
+ * "Add people" (the host, in the call): GET /api/calls/room/:id/people?q= - the host's readable contacts and the
29
+ * team; POST /api/calls/room/:id/invite - each person gets their own Join link `?g=<pass>` (never the guest's
30
+ * token), by email, notification and Messages as chosen (services/callInvites.js). They knock like any guest.
28
31
  */
29
32
  import {authenticate as defaultAuthenticate, requirePermission as defaultRequirePermission} from '../../middleware/auth.js';
30
33
  import {getEntry, listEntries} from '../../services/collections.js';
@@ -35,9 +38,10 @@ import {isToolEnabled} from '../../services/tools.js';
35
38
  import {mountCallRoutes} from '../../../plugins/_lib/call/routes.js';
36
39
  import {whoOf} from '../../../plugins/_lib/call/rooms.js';
37
40
  import {getBaseUrl} from '../public.js';
41
+ import {deliverInvites, resolveInvitees, searchInvitees} from '../../services/callInvites.js';
38
42
  import {
39
43
  EMAIL_RE, callChat, callMailReady, callPaths, callsOfContact, chatCountOf, createCall, getCall, instantCallsEnabled, isOpen,
40
- mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
44
+ invitePath, mayEmailAddress, mayStartCall, saveCallChat, sendCallEmail, whoIsCalling
41
45
  } from '../../services/instantCalls.js';
42
46
 
43
47
  const CONTACTS_SLUG = 'contacts-contacts';
@@ -147,8 +151,26 @@ export async function callsRoutes(fastify, opts = {}) {
147
151
  // The call engine: a room per call, guests knock, the host lets them in, open createdAt..expiresAt.
148
152
  mountCallRoutes(fastify, {
149
153
  prefix: '/room', ns: 'instant', ...(opts.rooms ? {rooms: opts.rooms} : {}),
150
- resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h}),
154
+ resolve: async (request, room, body) => whoIsCalling(await getCall(String(room)), {t: body?.t, h: body?.h, g: body?.g}),
151
155
  // The chat is saved against the call record, so a call reopened (or after a restart) shows it again.
152
- chat: {load: (room) => callChat(String(room)), save: (room, item) => saveCallChat(String(room), item)}
156
+ chat: {load: (room) => callChat(String(room)), save: (room, item) => saveCallChat(String(room), item)},
157
+ // Add people (the host): the host user's contacts and the team; each invitee gets their own `?g=` link.
158
+ invite: {
159
+ search: async (room, q) => {
160
+ const rec = await getCall(String(room));
161
+ if (!rec || !isOpen(rec)) return [];
162
+ return searchInvitees({hostUserId: rec.hostId, q});
163
+ },
164
+ send: async (room, people, {via, request}) => {
165
+ const rec = await getCall(String(room));
166
+ if (!rec || !isOpen(rec)) return {error: 'This call has ended.'};
167
+ const base = getBaseUrl(request);
168
+ const newLink = async () => `${base}${await invitePath(rec.id)}`;
169
+ const targets = await resolveInvitees(people, {hostUserId: rec.hostId});
170
+ const results = await deliverInvites(targets, {linkFor: newLink, hostName: rec.hostName, hostUserId: rec.hostId,
171
+ title: `Video call: ${rec.hostName} and ${rec.name || 'a guest'}`, via, key: `instant:${rec.id}`, expiresAt: rec.expiresAt, log: request.log});
172
+ return {link: await newLink(), results};
173
+ }
174
+ }
153
175
  });
154
176
  }
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * /_call/<id>?t=<guest token> the person called
5
5
  * /_call/<id>?h=<host key> the host
6
+ * /_call/<id>?g=<invite pass> someone the host added ("Add people") - names themselves, knocks
6
7
  *
7
8
  * Under `/_call`, like `/_preview`. Answered from an onRequest hook, and ONLY
8
9
  * while instant calls are on: with no Pro Tool with calls the request goes on
@@ -53,14 +54,14 @@ export const recoverScript = '<script>(function(){try{if(location.search)return;
53
54
 
54
55
  export function callPage(rec, who, cred, now = Date.now()) {
55
56
  if (!rec || !who) {
56
- const bare = !cred?.t && !cred?.h;
57
+ const bare = !cred?.t && !cred?.h && !cred?.g;
57
58
  return {status: 404, title: 'Call', body: '<h1>This call link is not valid</h1><p>Ask the person who sent it for a new one.</p>'
58
59
  + (bare ? recoverScript : '')};
59
60
  }
60
61
  if (!(Date.parse(rec.expiresAt) > now)) {
61
62
  return {status: 410, title: 'Call', body: '<h1>This call has ended</h1><p>Ask the person who sent the link for a new one.</p>'};
62
63
  }
63
- const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && !rec.name, recordingAllowed: false, closesAt: rec.expiresAt};
64
+ const cfg = {api: ROOM_API, room: rec.id, cred, title: who.title, askName: !who.host && (!rec.name || who.invited === true), recordingAllowed: false, closesAt: rec.expiresAt};
64
65
  return {
65
66
  status: 200,
66
67
  title: who.title,
@@ -89,7 +90,7 @@ export async function callPublicRoutes(fastify, opts = {}) {
89
90
  reply.header('cache-control', 'no-store, private');
90
91
  reply.header('x-robots-tag', 'noindex, nofollow');
91
92
  const q = new URLSearchParams(search);
92
- const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
+ const cred = q.get('h') ? {h: q.get('h').slice(0, 128)} : q.get('g') ? {g: q.get('g').slice(0, 128)} : {t: String(q.get('t') || '').slice(0, 128)};
93
94
  const rec = await getCall(id);
94
95
  const page = callPage(rec, whoIsCalling(rec, cred), cred);
95
96
  const html = await renderBlogPage(TEMPLATE, {body: page.body}, {
@@ -0,0 +1,238 @@
1
+ /**
2
+ * "Add people" to a call - what core does for every Tool with calls (instant
3
+ * calls, Booking, Calendar, Messages). The pure parts (the request's shape, the
4
+ * invite pass, matching, limits) are `plugins/_lib/call/invite.js`; the routes
5
+ * are the engine's (`plugins/_lib/call/routes.js`). This file finds people and
6
+ * sends what the host chose:
7
+ *
8
+ * searchInvitees({hostUserId, q, excludeUserIds, contacts, team})
9
+ * the host's readable contacts (their own plus what a sharing Tool lets
10
+ * them read - `readableContactIds`) and the team, matched to `q`
11
+ * resolveInvitees(people, {hostUserId, contacts, team, emails})
12
+ * the request's {kind, id} / {kind: 'email'} list as real people - a
13
+ * contact the host may read, an active team member - else an error row
14
+ * deliverInvites(targets, {linkFor, hostName, hostUserId, title, via, key})
15
+ * per person: the Join email (via.email, any address), a notification
16
+ * (via.notify, team members: bell + desktop, `core:call-invites`,
17
+ * dedupeKey per call + person) and a direct message from the host
18
+ * (via.message, team members, when a Tool registered a messenger -
19
+ * Messages; hooks.sendDirectMessage). Never throws.
20
+ *
21
+ * "The team": active users whose most senior role is at the base `user` level
22
+ * or above - roles a plugin makes for people from outside (Job Board's
23
+ * candidates) are not the team.
24
+ *
25
+ * @module services/callInvites
26
+ */
27
+ import {getConfig} from '../config.js';
28
+ import {getEntry, listEntries} from './collections.js';
29
+ import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
30
+ import {hasDirectMessenger, readableContactIds, sendDirectMessage} from './hooks.js';
31
+ import {notify, registerNotificationSource} from './notify.js';
32
+ import {getPermissionsFor, getRoleLevel} from './roles.js';
33
+ import {isToolEnabled} from './tools.js';
34
+ import {getUserById, listUsers} from './users.js';
35
+ import {getEffectiveLevel, getEffectiveRoles} from './userRoles.js';
36
+ import {cleanText, createLimiter, inviteMessage, inviteTitle, matchPeople} from '../../plugins/_lib/call/invite.js';
37
+
38
+ export const SOURCE = 'core:call-invites';
39
+ const CONTACTS_SLUG = 'contacts-contacts';
40
+ /** Join emails one address may be sent in an hour, whoever sends them. */
41
+ export const EMAILS_PER_ADDRESS_HOUR = 3;
42
+ /** Join emails the whole site may send in an hour (every call, every Tool) - a leaked host link cannot spam. */
43
+ export const EMAILS_PER_SITE_HOUR = 30;
44
+
45
+ try {
46
+ registerNotificationSource({id: SOURCE, owner: 'core', label: 'Call invitations', severity: 'info', defaultOn: true,
47
+ description: 'Someone in a video call adds a team member to it ("Add people") - told to that person, with the Join link.'});
48
+ } catch { /* never stops the calls */ }
49
+
50
+ const isActive = (u) => Boolean(u) && u.disabled !== true && u.active !== false && u.isActive !== false;
51
+
52
+ /** Is this user one of the team (active, at the base user level or more senior)? */
53
+ export function isTeamMember(u) {
54
+ if (!isActive(u)) return false;
55
+ const base = getRoleLevel('user');
56
+ try { return getEffectiveLevel(u) <= (Number.isFinite(base) ? base : 2); } catch { return false; }
57
+ }
58
+
59
+ /** The team, by name. */
60
+ export async function teamMembers() {
61
+ return (await listUsers().catch(() => [])).filter(isTeamMember);
62
+ }
63
+
64
+ /** May this user read contacts at all (Contacts on, a role with contacts.read)? */
65
+ function mayReadContacts(user) {
66
+ try {
67
+ const roles = getEffectiveRoles(user || {});
68
+ return isToolEnabled('contacts') && roles.some(r => getPermissionsFor('contacts', 'read').includes(r));
69
+ } catch { return false; }
70
+ }
71
+
72
+ /** The contacts this user may read, as {id, name, email} (with an email only). */
73
+ export async function contactsOf(hostUserId) {
74
+ const user = hostUserId ? await getUserById(String(hostUserId)).catch(() => null) : null;
75
+ if (!user || !mayReadContacts(user)) return [];
76
+ const {entries} = await listEntries(CONTACTS_SLUG, {limit: 100000}).catch(() => ({entries: []}));
77
+ const withEmail = (entries || []).filter(e => String(e?.data?.email || '').includes('@'));
78
+ const ok = await readableContactIds(withEmail, {id: user.id, roles: getEffectiveRoles(user)});
79
+ return withEmail.filter(e => ok.has(e.id)).map(e => ({id: e.id, name: String(e.data?.name || '').trim(), email: String(e.data.email).trim()}));
80
+ }
81
+
82
+ /**
83
+ * Search: the team (not `excludeUserIds`, not the host) and the host's contacts.
84
+ *
85
+ * @param {{hostUserId?: string, q?: string, excludeUserIds?: string[], contacts?: boolean, team?: boolean, teamFilter?: (u: object) => boolean}} opts
86
+ */
87
+ export async function searchInvitees({hostUserId = '', q = '', excludeUserIds = [], contacts = true, team = true, teamFilter = null} = {}) {
88
+ const users = team ? (await teamMembers()).filter(u => !teamFilter || teamFilter(u)) : [];
89
+ const known = contacts && String(q || '').trim() ? await contactsOf(hostUserId) : [];
90
+ return matchPeople({q, users, contacts: known, excludeUserIds: [...excludeUserIds, hostUserId].filter(Boolean),
91
+ canMessage: Boolean(hostUserId) && hasDirectMessenger()});
92
+ }
93
+
94
+ /**
95
+ * The request's people as real people: `{kind, id?, name, email, userId?}`, or `{name, error}` for someone who
96
+ * cannot be invited (a contact the host may not read, not a team member, an email not allowed here).
97
+ *
98
+ * @param {object[]} people - cleanInviteRequest()'s list
99
+ * @param {{hostUserId?: string, contacts?: boolean, team?: boolean, emails?: boolean, teamFilter?: (u: object) => boolean}} opts
100
+ */
101
+ export async function resolveInvitees(people = [], {hostUserId = '', contacts = true, team = true, emails = true, teamFilter = null} = {}) {
102
+ const out = [];
103
+ let readable = null;
104
+ for (const p of people) {
105
+ if (p.kind === 'email') {
106
+ out.push(emails ? {kind: 'email', name: p.name, email: p.email} : {name: p.name, error: 'Only team members can be added to this call.'});
107
+ } else if (p.kind === 'user') {
108
+ const u = team ? await getUserById(p.id).catch(() => null) : null;
109
+ if (!u || !isTeamMember(u) || (teamFilter && !teamFilter(u)) || u.id === hostUserId) out.push({name: 'Someone', error: 'That person cannot be added.'});
110
+ else out.push({kind: 'user', id: u.id, name: String(u.name || '').trim() || 'Team member', email: String(u.email || '').trim()});
111
+ } else if (p.kind === 'contact') {
112
+ readable ||= contacts ? await contactsOf(hostUserId) : [];
113
+ const c = readable.find(x => x.id === p.id);
114
+ out.push(c ? {kind: 'contact', id: c.id, name: c.name || c.email.split('@')[0], email: c.email.toLowerCase()} : {name: 'Someone', error: 'That contact could not be found.'});
115
+ }
116
+ }
117
+ return out;
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The Join email
122
+ // ---------------------------------------------------------------------------
123
+
124
+ let mailer = null;
125
+ let transport = null;
126
+ let transportFor = '';
127
+ const emailsTo = createLimiter({max: EMAILS_PER_ADDRESS_HOUR, windowMs: 3600000});
128
+ const emailsFromSite = createLimiter({max: EMAILS_PER_SITE_HOUR, windowMs: 3600000});
129
+
130
+ /** Tests only: `fn(msg)` receives every message instead of SMTP. null restores. */
131
+ export function setInviteMailer(fn) { mailer = fn; }
132
+ /** Tests only. */
133
+ export function resetInviteLimits() { emailsTo.reset(); emailsFromSite.reset(); }
134
+
135
+ const site = () => { try { return getConfig('site') || {}; } catch { return {}; } };
136
+
137
+ /** Can mail really leave this site? */
138
+ export const inviteMailReady = () => Boolean(mailer) || isSmtpConfigured(site().smtp || {});
139
+
140
+ /** The message, escaped. */
141
+ export function composeInviteEmail({name = '', hostName = '', title = '', link = ''}) {
142
+ const host = cleanText(hostName, 80) || 'Someone';
143
+ name = cleanText(name, 120);
144
+ title = cleanText(title, 120);
145
+ const subject = `${host} is inviting you to a video call now`;
146
+ const lines = [`Hello${name ? ` ${name}` : ''}, ${host} is in a video call${title ? ` (${title})` : ''} and would like you to join now.`,
147
+ 'Use the button below. You will wait a moment until you are let in. You need a camera and microphone, or you can join with just one.'];
148
+ const html = [...lines.map(l => `<p>${escapeHtml(l)}</p>`),
149
+ `<p><a href="${escapeHtml(link)}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;border-radius:6px;text-decoration:none">Join the call</a></p>`,
150
+ `<p style="color:#666;font-size:13px">Or paste this into your browser: ${escapeHtml(link)}</p>`].join('\n');
151
+ return {subject, html, text: [...lines, `Join the call: ${link}`].join('\n\n')};
152
+ }
153
+
154
+ /** Send it. Never throws; true only when it left. */
155
+ export async function sendInviteEmail({to, name, hostName, title, link}, {log} = {}) {
156
+ try {
157
+ if (!to || !link) return false;
158
+ const msg = {to, ...composeInviteEmail({name, hostName, title, link})};
159
+ if (mailer) { await mailer(msg); return true; }
160
+ const s = site();
161
+ const smtp = s.smtp || {};
162
+ if (!isSmtpConfigured(smtp)) return false;
163
+ const k = JSON.stringify([smtp.host, smtp.port, smtp.user, smtp.secure]);
164
+ if (!transport || transportFor !== k) { transport = await createTransport(smtp); transportFor = k; }
165
+ await sendEmail(transport, {from: smtp.fromAddress || 'noreply@example.com', fromName: smtp.fromName || s.title || 'Calls', ...msg});
166
+ return true;
167
+ } catch (err) {
168
+ try { (log?.error || console.error).call(log || console, `[calls] Invitation to ${to} not sent: ${err?.message || err}`); } catch { /* never */ }
169
+ return false;
170
+ }
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Sending
175
+ // ---------------------------------------------------------------------------
176
+
177
+ /**
178
+ * Tell each person. `linkFor(target)` is their Join link (absolute). Resolves to one result per target:
179
+ * `{name, emailed, notified, messaged, error?}` - `error` says why nothing reached them.
180
+ *
181
+ * @param {object[]} targets - resolveInvitees()'s list
182
+ * @param {{linkFor: (t: object) => string|Promise<string>, hostName?: string, hostUserId?: string, title?: string,
183
+ * via?: {email?: boolean, notify?: boolean, message?: boolean}, key?: string, expiresAt?: string, log?: object,
184
+ * deps?: {notify?: Function, sendDirectMessage?: Function}}} opts - `key`: the call, for the notification's dedupeKey
185
+ */
186
+ export async function deliverInvites(targets = [], {linkFor, hostName = '', hostUserId = '', title = '', via = {}, key = '', expiresAt, log, deps = {}} = {}) {
187
+ const raise = deps.notify || notify;
188
+ const dm = deps.sendDirectMessage || sendDirectMessage;
189
+ const results = [];
190
+ for (const t of targets) {
191
+ if (t.error) { results.push({name: t.name, emailed: false, notified: false, messaged: false, error: t.error}); continue; }
192
+ const r = {name: t.name, emailed: false, notified: false, messaged: false};
193
+ let link = '';
194
+ try { link = await linkFor(t); } catch { link = ''; }
195
+ if (!link) { results.push({...r, error: 'No link could be made for them.'}); continue; }
196
+ const why = [];
197
+ if (via.email && t.email) {
198
+ if (!inviteMailReady()) why.push('email is not set up on this site');
199
+ else if (!emailsTo.take(t.email)) why.push('they have been emailed several times this hour');
200
+ else if (!emailsFromSite.take('site')) why.push('this site has sent a lot of call invitations this hour');
201
+ else if (!(r.emailed = await sendInviteEmail({to: t.email, name: t.name, hostName, title, link}, {log}))) why.push('the email could not be sent');
202
+ }
203
+ if (t.kind === 'user') {
204
+ if (via.notify) {
205
+ try {
206
+ r.notified = Boolean(await raise({title: inviteTitle(hostName), body: title ? `Video call: ${title}` : 'A video call, now.', severity: 'info', link,
207
+ source: SOURCE, dedupeKey: `call-invite:${key}:${t.id}`.slice(0, 200), audience: {userIds: [t.id]},
208
+ expiresAt: expiresAt || new Date(Date.now() + 3 * 3600e3).toISOString()}));
209
+ } catch { r.notified = false; }
210
+ if (!r.notified) why.push('the notification was not raised');
211
+ }
212
+ if (via.message) {
213
+ if (!hostUserId) why.push('a message needs a host who is a team member');
214
+ else if (!hasDirectMessenger() && !deps.sendDirectMessage) why.push('Messages is not on');
215
+ else {
216
+ let out = null;
217
+ try { out = await dm({fromUserId: hostUserId, toUserId: t.id, text: inviteMessage(hostName, title, link)}); } catch { out = null; }
218
+ r.messaged = out === true || out?.sent === true;
219
+ if (!r.messaged) why.push(`no message: ${out?.reason || 'it could not be sent'}`);
220
+ }
221
+ }
222
+ }
223
+ if (!r.emailed && !r.notified && !r.messaged) r.error = why.length ? `Not reached: ${why.join('; ')}.` : 'Nothing was sent - copy the link and pass it on.';
224
+ results.push(r);
225
+ }
226
+ return results;
227
+ }
228
+
229
+ /**
230
+ * The origin Join links are built on: the site's configured `baseUrl` whenever there is one (links in emails
231
+ * and notices must never follow a Host header), else the request's own origin.
232
+ */
233
+ export function inviteOrigin(request) {
234
+ const base = String(site().baseUrl || '').trim();
235
+ if (/^https?:\/\/[^/\s]+/i.test(base)) return base.replace(/\/+$/, '');
236
+ if (!request) return '';
237
+ return `${request.protocol}://${request.host || request.headers?.host || request.hostname}`;
238
+ }
@@ -404,6 +404,61 @@ export async function readableContactIds(entries, viewer) {
404
404
  return out;
405
405
  }
406
406
 
407
+ // ---------------------------------------------------------------------------
408
+ // 7c. Direct messages - one team member writing to another (Messages)
409
+ // ---------------------------------------------------------------------------
410
+ //
411
+ // Core has no messaging of its own. A Tool that has it (Messages) registers a
412
+ // messenger, and core code - a call's "Add people" (services/callInvites.js) -
413
+ // sends a direct message from one user to another through it, without knowing
414
+ // the Tool. No messenger (Messages off or absent): sendDirectMessage answers
415
+ // false and the caller carries on without it.
416
+
417
+ const _messengers = new Map(); // owner -> fn({fromUserId, toUserId, text}) => Promise<boolean | {sent, reason}>
418
+
419
+ /**
420
+ * @param {string} owner - the plugin
421
+ * @param {(msg: {fromUserId: string, toUserId: string, text: string}) => Promise<boolean|{sent: boolean, reason?: string}>} fn
422
+ * post `text` from `fromUserId` to `toUserId` in their direct conversation (made when there is none), by the
423
+ * Tool's OWN rules (who may message whom): true / {sent: true} when posted, else false or {sent: false, reason}
424
+ */
425
+ export function registerDirectMessenger(owner, fn) {
426
+ if (typeof owner !== 'string' || !owner || typeof fn !== 'function') return false;
427
+ _messengers.set(owner, fn);
428
+ return true;
429
+ }
430
+
431
+ /** @param {string} owner */
432
+ export function unregisterDirectMessenger(owner) {
433
+ _messengers.delete(owner);
434
+ }
435
+
436
+ /** Is there a Tool that can send a direct message? */
437
+ export const hasDirectMessenger = () => _messengers.size > 0;
438
+
439
+ /**
440
+ * Send a direct message through the registered messenger. Never throws.
441
+ *
442
+ * @param {{fromUserId: string, toUserId: string, text: string}} msg
443
+ * @returns {Promise<{sent: boolean, reason?: string}>} sent when a messenger posted it; else why not
444
+ */
445
+ export async function sendDirectMessage({fromUserId, toUserId, text} = {}) {
446
+ const from = String(fromUserId || '');
447
+ const to = String(toUserId || '');
448
+ const body = String(text || '').trim().slice(0, 4000);
449
+ if (!from || !to || from === to || !body) return {sent: false, reason: 'nobody to send it to'};
450
+ if (!_messengers.size) return {sent: false, reason: 'Messages is not on'};
451
+ let reason = 'the message could not be sent';
452
+ for (const fn of _messengers.values()) {
453
+ try {
454
+ const out = await fn({fromUserId: from, toUserId: to, text: body});
455
+ if (out === true || out?.sent === true) return {sent: true};
456
+ if (out?.reason) reason = String(out.reason).slice(0, 200);
457
+ } catch { /* a failing messenger sends nothing */ }
458
+ }
459
+ return {sent: false, reason};
460
+ }
461
+
407
462
  // ---------------------------------------------------------------------------
408
463
  // 8. Sitemap sources (0.89) - URLs a plugin serves, in /sitemap.xml
409
464
  // ---------------------------------------------------------------------------
@@ -19,7 +19,10 @@
19
19
  * actions and list stand aside (checked per request), so nobody sees two.
20
20
  *
21
21
  * A record: {id, name, email, contactId, hostId, hostName, token, hostKey,
22
- * createdAt, expiresAt, chat?}. `chat` is the call's chat, saved against the
22
+ * createdAt, expiresAt, chat?, inviteKey?}. `inviteKey` (made on the first "Add
23
+ * people", `inviteKeyOf`) signs the passes of people added to the call
24
+ * (`?g=<pass>`, _lib/call/invite.js) - never the guest's token, which carries
25
+ * the guest's name and place. `chat` is the call's chat, saved against the
23
26
  * call (the engine's items - typed messages plus joined/left lines - the last
24
27
  * CHAT_MAX); the engine seeds a reopened room from it, and the host (or an
25
28
  * admin) reads it afterwards through GET /api/calls/:id/chat. Kept in memory and in one JSON file with the sign-in
@@ -43,6 +46,7 @@ import {hasRelatedProvider, registerRelatedItems, unregisterRelatedItems} from '
43
46
  import {getPermissionsFor} from './roles.js';
44
47
  import {getEffectiveRoles} from './userRoles.js';
45
48
  import {createTransport, escapeHtml, isSmtpConfigured, sendEmail} from './email.js';
49
+ import {checkInvitePass, invitePass, newInviteKey} from '../../plugins/_lib/call/invite.js';
46
50
 
47
51
  export const INSTANT_HOURS = 3;
48
52
  export const KEEP_DAYS = 30;
@@ -244,6 +248,25 @@ export async function getCall(id) {
244
248
  return (await load()).get(id) || null;
245
249
  }
246
250
 
251
+ /** The call's invite key, made (and stored) the first time someone is added to it. Null for no such call. */
252
+ export async function inviteKeyOf(id) {
253
+ if (typeof id !== 'string' || !ID_RE.test(id)) return null;
254
+ const all = await load();
255
+ const rec = all.get(id);
256
+ if (!rec) return null;
257
+ if (!rec.inviteKey) {
258
+ rec.inviteKey = newInviteKey();
259
+ await save(all);
260
+ }
261
+ return rec.inviteKey;
262
+ }
263
+
264
+ /** A new Join link (relative) for someone added to the call: `?g=<pass>`. */
265
+ export async function invitePath(id) {
266
+ const k = await inviteKeyOf(id);
267
+ return k ? `${CALL_PATH}/${encodeURIComponent(id)}?g=${encodeURIComponent(invitePass(k, id))}` : '';
268
+ }
269
+
247
270
  /** The chat item as it is kept on a record; null for junk. System lines other than joined/left are not kept. */
248
271
  function chatItem(raw) {
249
272
  if (!raw || typeof raw !== 'object' || typeof raw.text !== 'string' || !raw.id) return null;
@@ -299,8 +322,10 @@ const same = (a, b) => {
299
322
 
300
323
  /**
301
324
  * Who the caller is in an instant call, or null (the engine answers 404).
302
- * `cred` is what the link carried: {t} for the guest, {h} for the host. The
303
- * engine answers 410 once `closesAt` has passed.
325
+ * `cred` is what the link carried: {t} for the guest, {h} for the host, {g}
326
+ * for someone added to the call (a pass under `inviteKey`: they give their own
327
+ * name and knock; each pass is its own identity). The engine answers 410 once
328
+ * `closesAt` has passed.
304
329
  */
305
330
  export function whoIsCalling(rec, cred = {}) {
306
331
  if (!rec) return null;
@@ -314,6 +339,8 @@ export function whoIsCalling(rec, cred = {}) {
314
339
  if (cred.t && same(cred.t, rec.token)) {
315
340
  return {...base, host: false, name: rec.name || '', identity: `${rec.id}:guest`};
316
341
  }
342
+ const nonce = cred.g && rec.inviteKey ? checkInvitePass(rec.inviteKey, rec.id, cred.g) : null;
343
+ if (nonce) return {...base, host: false, name: '', identity: `${rec.id}:inv:${nonce}`, invited: true};
317
344
  return null;
318
345
  }
319
346
 
@@ -28,6 +28,8 @@ import {
28
28
  registerComponent,
29
29
  registerContactAccess,
30
30
  unregisterContactAccess,
31
+ registerDirectMessenger,
32
+ unregisterDirectMessenger,
31
33
  registerSidebarItem,
32
34
  registerTransform
33
35
  } from './hooks.js';
@@ -559,7 +561,9 @@ export async function registerPlugins(fastify) {
559
561
  // loads under a licence may.
560
562
  enableInstantCalls: () => enableInstantCalls({owner: manifest.name, tier: manifest.tier || 'free', closedSource: manifest.closedSource === true}),
561
563
  // Who may read a contact that is not their own (Contacts Pro's sharing) - hooks.js 7b.
562
- registerContactAccess: (fn) => registerContactAccess(manifest.name, fn)},
564
+ registerContactAccess: (fn) => registerContactAccess(manifest.name, fn),
565
+ // Direct messages between team members (Messages) - hooks.js 7c; a call's "Add people" uses it.
566
+ registerDirectMessenger: (fn) => registerDirectMessenger(manifest.name, fn)},
563
567
  settings,
564
568
  config: {}
565
569
  });
@@ -1120,6 +1124,7 @@ export async function runLifecycleHook(name, hook, fastify) {
1120
1124
  if (hook === 'onDisable') {
1121
1125
  disableInstantCalls(name);
1122
1126
  unregisterContactAccess(name);
1127
+ unregisterDirectMessenger(name);
1123
1128
  }
1124
1129
 
1125
1130
  const pluginJsPath = path.join(PLUGINS_DIR, name, 'plugin.js');
@@ -154,7 +154,7 @@
154
154
  <script src="/public/js/forms.js?v=20260927-submit-btn" type="module"></script>
155
155
 
156
156
  <!-- Core effects runtime -->
157
- <script src="/public/js/effects.js?v=20260930-snowman"></script>
157
+ <script src="/public/js/effects.js?v=20261001-options"></script>
158
158
 
159
159
  <!-- Plugin body-end injection -->
160
160
  {{bodyEndInject}}