# Macro for Ad-Hoc Zoom Meeting Join

**URL:** <https://community.pexip.com/t/macro-for-ad-hoc-zoom-meeting-join/1098>\
**Category:** Endpoint Macros\
**Created:** [October 9, 2026, 9:35pm UTC](https://community.pexip.com/t/macro-for-ad-hoc-zoom-meeting-join/1098 "2026-10-09T21:35:33Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![Chatty.Catty](https://avatars.discourse-cdn.com/v4/letter/c/a8b319/32.png) [@Chatty.Catty](https://community.pexip.com/u/Chatty.Catty)\
**Post date:** [October 9, 2026, 9:35pm UTC](https://community.pexip.com/t/macro-for-ad-hoc-zoom-meeting-join/1098/1 "2026-10-09T21:35:33Z")

</div>

Active Macro. Adds a “Zoom” button to the home screen.

1. Prompts for meeting ID (9–13 digits) and optional numeric passcode

2. Calls detectDualScreen() — queries all video output connectors

3. Calls resolveLayoutSuffix() — returns “608” if dual screen, “” if single

4. Builds dial string: ..@zoomcrc.com

5. Dials via xapi.Command.Dial with CallType: Video

```auto
/*!

 *

 * Adds a "Zoom" button to the home screen. Tapping it prompts for a Zoom

 * meeting ID, then optionally a passcode, and dials the Zoom Cloud Room

 * Connector.

 *

 * The SIP dial string uses dot-separated fields, any of which may be empty,

 * per Zoom's documentation:

 *

 * <meetingId>.<passcode>.<layout>@zoomcrc.com

 *

 * 812345678 ID only

 * 812345678.12345 ID + passcode

 * 812345678..608 ID + dual-screen layout, no passcode

 *

 *

 * Dial string reference:

 * Starting a meeting from a SIP/H.323 endpoint

 *   

 *

 * Companion file: zoom-macro-settings.js (must be loaded as its own macro,

 * named exactly "zoom-macro-settings", and may be left deactivated).

 */

import xapi from 'xapi';

const VERSION = 'v1.0.0';

const PANEL_ID = 'zoom_panel';

const SETTINGS_MACRO_NAME = 'zoom-macro-settings';

const SETTINGS_CONST_NAME = 'zoom_macro_settings';

// Max characters RoomOS will render in a TextInput body.

const MAX_TEXT_LENGTH = 128;

const DEFAULT_SETTINGS = {

  service: {

    domain: 'zoomcrc.com',

    layoutSuffix: '',

    dualScreen: 'Auto',

  },

  zoom: {

    meetingId: {

      regex: '^([0-9]{9,13})$',

      regexDescription: 'Numeric Meeting ID. Example: 812 3456 7890',

      invalidRegexMaxTries: 99,

    },

    passcode: {

      regex: '^[0-9]{1,16}$',

      regexDescription: 'Numeric passcode, or leave blank to skip',

      invalidRegexMaxTries: 99,

      required: false,

    },

  },

  button: {

    icon: 'Camera',

    name: 'Zoom',

  },

  custom: {

    name: '',

  },

};

/* ------------------------------------------------------------------ *

 * Logging

 * ------------------------------------------------------------------ */

function log(level, method, msg, event) {

  const detail = event ? ` ${JSON.stringify(event)}` : '';

  console[level](`${VERSION} ${level.toUpperCase()} method="${method}": "${msg}"${detail}`);

}

const logger = {

  info: (method, msg, event) => log('info', method, msg, event),

  warn: (method, msg, event) => log('warn', method, msg, event),

  error: (method, msg, event) => log('error', method, msg, event),

  debug: (method, msg, event) => log('debug', method, msg, event),

};

// Never write a passcode to the console in clear text.

// Fields are <meetingId>.<passcode>.<layout> - mask field 2.

function maskSecrets(number) {

  const at = String(number).indexOf('@');

  const local = at === -1 ? String(number) : String(number).slice(0, at);

  const domain = at === -1 ? '' : String(number).slice(at);

  const masked = local

    .split('.')

    .map((field, index) => (index === 1 && field ? '*'.repeat(field.length) : field));

  return masked.join('.') + domain;

}

/* ------------------------------------------------------------------ *

 * Settings

 * ------------------------------------------------------------------ */

function mergeDefaults(target, defaults) {

  const out = Array.isArray(defaults) ? [] : {};

  Object.keys(defaults).forEach((key) => {

    const defaultValue = defaults[key];

    const value = target ? target[key] : undefined;

    if (defaultValue && typeof defaultValue === 'object' && !Array.isArray(defaultValue)) {

      out[key] = mergeDefaults(value, defaultValue);

    } else {

      out[key] = value === undefined || value === null || value === '' ? defaultValue : value;

    }

  });

  return out;

}

async function readSettings() {

  try {

    const result = await xapi.Command.Macros.Macro.Get({ Content: true, Name: SETTINGS_MACRO_NAME });

    const content = result.Macro[0].Content;

    const start = content.indexOf(SETTINGS_CONST_NAME);

    if (start === -1) throw new Error(`Could not find "${SETTINGS_CONST_NAME}" in ${SETTINGS_MACRO_NAME}`);

    const braceStart = content.indexOf('{', start);

    if (braceStart === -1) throw new Error('Settings object is malformed (no opening brace)');

    // Walk the braces so trailing content in the file does not break parsing.

    let depth = 0;

    let braceEnd = -1;

    for (let i = braceStart; i < content.length; i += 1) {

      if (content[i] === '{') depth += 1;

      if (content[i] === '}') {

        depth -= 1;

        if (depth === 0) {

          braceEnd = i;

          break;

        }

      }

    }

    if (braceEnd === -1) throw new Error('Settings object is malformed (no closing brace)');

    // Strip // comments so the settings file can stay documented.

    const json = content

      .slice(braceStart, braceEnd + 1)

      .replace(/^\s*\/\/.*$/gm, '');

    return mergeDefaults(JSON.parse(json), DEFAULT_SETTINGS);

  } catch (error) {

    logger.warn('readSettings', 'Could not read settings macro, using defaults', { error: error.message });

    return mergeDefaults({}, DEFAULT_SETTINGS);

  }

}

/* ------------------------------------------------------------------ *

 * UI panel

 * ------------------------------------------------------------------ */

function escapeXml(value) {

  return String(value)

    .replace(/&/g, '&amp;')

    .replace(/</g, '&lt;')

    .replace(/>/g, '&gt;');

}

function buildPanelXml(button) {

  return `<Extensions>

  <Version>1.7</Version>

  <Panel>

    <PanelId>${PANEL_ID}</PanelId>

    <Origin>local</Origin>

    <Type>Home</Type>

    <Icon>${escapeXml(button.icon)}</Icon>

    <Color>#2D8CFF</Color>

    <Name>${escapeXml(button.name)}</Name>

    <ActivityType>Custom</ActivityType>

  </Panel>

</Extensions>`;

}

async function savePanel(button) {

  try {

    await xapi.Command.UserInterface.Extensions.Panel.Save({ PanelId: PANEL_ID }, buildPanelXml(button));

    logger.info('savePanel', 'Zoom panel saved', { panelId: PANEL_ID, name: button.name });

    return true;

  } catch (error) {

    logger.error('savePanel', 'Failed to save Zoom panel', { error: error.message });

    return false;

  }

}

/* ------------------------------------------------------------------ *

 * Text input prompts

 * ------------------------------------------------------------------ */

function uuid() {

  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {

    const r = (Math.random() * 16) | 0;

    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);

  });

}

// Resolves with the entered string, or null if the user dismissed the dialog.

function promptText({ title, text, placeholder, submitText, inputType = 'Numeric', keyboardState = 'Open' }) {

  return new Promise((resolve, reject) => {

    const feedbackId = uuid();

    let offResponse;

    let offClear;

    const finish = (value) => {

      if (offResponse) offResponse();

      if (offClear) offClear();

      resolve(value);

    };

    offResponse = xapi.Event.UserInterface.Message.TextInput.Response.on((event) => {

      if (event.FeedbackId === feedbackId) finish(event.Text === undefined ? '' : event.Text);

    });

    offClear = xapi.Event.UserInterface.Message.TextInput.Clear.on((event) => {

      if (event.FeedbackId === feedbackId) finish(null);

    });

    xapi.Command.UserInterface.Message.TextInput.Display({

      FeedbackId: feedbackId,

      InputType: inputType,

      KeyboardState: keyboardState,

      Title: title,

      Text: text,

      Placeholder: placeholder,

      SubmitText: submitText,

    }).catch((error) => {

      if (offResponse) offResponse();

      if (offClear) offClear();

      reject(error);

    });

  });

}

async function showError(text) {

  await xapi.Command.UserInterface.Message.Alert.Display({

    Title: 'Zoom',

    Text: text,

    Duration: 8,

  }).catch(() => undefined);

}

/* ------------------------------------------------------------------ *

 * Prompt flow

 * ------------------------------------------------------------------ */

const MEETING_ID_FIRST = 'Enter the Zoom meeting ID:';

const MEETING_ID_RETRY = 'That meeting ID is not valid. Please try again.';

const PASSCODE_FIRST = 'Enter the meeting passcode:';

const PASSCODE_RETRY = 'That passcode is not valid. Please try again.';

// Keep the prompt body inside the RoomOS character limit.

function fitDescription(prompts, description) {

  const longest = prompts.reduce((a, b) => (a.length > b.length ? a : b));

  const budget = MAX_TEXT_LENGTH - longest.length - '<br>()'.length;

  if (description.length <= budget) return description;

  logger.warn('fitDescription', 'Regex description truncated to fit the dialog', {

    length: description.length,

    max: budget,

  });

  return `${description.slice(0, Math.max(budget - 1, 0))}…`;

}

// Returns the validated string, '' if skipped, or null if the user cancelled.

async function askValidated({

  title,

  prompts,

  description,

  placeholder,

  submitText,

  regex,

  maxTries,

  allowEmpty,

  inputType,

}) {

  const safeDescription = fitDescription(prompts, description);

  const tries = Math.max(1, Number(maxTries) || 1);

  for (let attempt = 0; attempt < tries; attempt += 1) {

    const prompt = attempt === 0 ? prompts[0] : prompts[1];

    const value = await promptText({

      title,

      text: `${prompt}<br>(${safeDescription})`,

      placeholder,

      submitText,

      inputType,

    });

    if (value === null) return null;

    const trimmed = value.trim().replace(/\s/g, '');

    if (trimmed === '' && allowEmpty) return '';

    if (regex.test(trimmed)) return trimmed;

  }

  throw new Error(`Maximum number of attempts reached (${tries})`);

}

/* ------------------------------------------------------------------ *

 * Dialling

 * ------------------------------------------------------------------ */

// <meetingId>.<passcode>.<layout>@domain

// Zoom tolerates empty fields between the dots, so any field can be omitted

// as long as the ones after it keep their position. Trailing empties are

// dropped so a plain join stays a plain "<meetingId>@domain".

function buildDialString({ meetingId, passcode, layoutSuffix, domain }) {

  const fields = [meetingId, passcode || '', layoutSuffix || ''];

  while (fields.length > 1 && fields[fields.length - 1] === '') fields.pop();

  return `${fields.join('.')}@${String(domain).replace(/^@/, '')}`;

}

async function detectDualScreen() {

  try {

    const connectors = await xapi.Status.Video.Output.Connector.get();

    const connected = connectors.filter((c) => c.Connected === 'True');

    return connected.length >= 2;

  } catch (e) {

    logger.warn('detectDualScreen', 'Could not check video outputs, defaulting to single screen', { error: e.message });

    return false;

  }

}

async function resolveLayoutSuffix(settings) {

  const mode = settings.service.dualScreen;

  if (mode === 'On') return '608';

  if (mode === 'Off') return settings.service.layoutSuffix;

  const isDual = await detectDualScreen();

  logger.info('resolveLayoutSuffix', 'Dual screen detection result', { isDual });

  return isDual ? '608' : settings.service.layoutSuffix;

}

async function joinZoomCall(settings) {

  try {

    const meetingIdRules = settings.zoom.meetingId;

    const passcodeRules = settings.zoom.passcode;

    let meetingIdRegex;

    try {

      meetingIdRegex = new RegExp(meetingIdRules.regex);

    } catch (e) {

      logger.warn('joinZoomCall', 'Invalid meetingId regex in settings, using default', { error: e.message });

      meetingIdRegex = new RegExp(DEFAULT_SETTINGS.zoom.meetingId.regex);

    }

    const meetingId = await askValidated({

      title: 'Join a Zoom meeting',

      prompts: [MEETING_ID_FIRST, MEETING_ID_RETRY],

      description: meetingIdRules.regexDescription,

      placeholder: 'Meeting ID',

      submitText: 'Next',

      regex: meetingIdRegex,

      maxTries: meetingIdRules.invalidRegexMaxTries,

      allowEmpty: false,

    });

    if (meetingId === null) {

      logger.info('joinZoomCall', 'User cancelled at the meeting ID prompt');

      return;

    }

    let passcodeRegex;

    try {

      passcodeRegex = new RegExp(passcodeRules.regex);

    } catch (e) {

      logger.warn('joinZoomCall', 'Invalid passcode regex in settings, using default', { error: e.message });

      passcodeRegex = new RegExp(DEFAULT_SETTINGS.zoom.passcode.regex);

    }

    const passcode = await askValidated({

      title: 'Join a Zoom meeting',

      prompts: [PASSCODE_FIRST, PASSCODE_RETRY],

      description: passcodeRules.regexDescription,

      placeholder: 'Passcode',

      submitText: 'Call',

      regex: passcodeRegex,

      maxTries: passcodeRules.invalidRegexMaxTries,

      allowEmpty: passcodeRules.required !== true,

    });

    if (passcode === null) {

      logger.info('joinZoomCall', 'User cancelled at the passcode prompt');

      return;

    }

    const layoutSuffix = await resolveLayoutSuffix(settings);

    const number = buildDialString({

      meetingId,

      passcode,

      layoutSuffix,

      domain: settings.service.domain,

    });

    const call = await xapi.Command.Dial({ Number: number, CallType: 'Video' });

    logger.info('joinZoomCall', 'Call has been initiated', {

      number: maskSecrets(number),

      call,

    });

  } catch (error) {

    logger.error('joinZoomCall', 'Failed to join Zoom call', { error: error.message });

    await showError(`Could not start the Zoom call: ${error.message}`);

  }

}

/* ------------------------------------------------------------------ *

 * Main

 * ------------------------------------------------------------------ */

async function main() {

  logger.info('main', 'Starting macro', { name: 'Zoom-Macro', version: VERSION });

  const settings = await readSettings();

  const button = {

    icon: settings.button.icon,

    name: settings.custom.name || settings.button.name,

  };

  const saved = await savePanel(button);

  if (!saved) return;

  xapi.Event.UserInterface.Extensions.Panel.Clicked.on((event) => {

    if (event.PanelId !== PANEL_ID) return;

    logger.info('panelListener', 'Zoom panel clicked');

    joinZoomCall(settings);

  });

}

main();

```

Inactive companion config file. Read by zoom-macro.js at startup. Keeps tuneable values out of the macro logic so the macro doesn’t need to be edited for configuration changes.

Current settings:

- service.domain: [zoomcrc.com](http://zoomcrc.com)

- service.layoutSuffix: “” (fallback when single screen)

- service.dualScreen: “Auto” (detect at dial time)

- zoom.meetingId.regex: ^([0-9]{9,13})$

- zoom.passcode.required: false

- button.icon: Camera, button.name: Zoom

```auto
const zoom_macro_settings = {

  "service": {

    // Zoom Cloud Room Connector domain. Leave as zoomcrc.com for direct SIP

    // dialling, or replace for another gateway domain.

    "domain": "zoomcrc.com",

    // Used when dualScreen is "Off". Set "608" for fixed dual-screen, "" for single.

    "layoutSuffix": "",

    // "Auto" = detect HDMI outputs at dial time; use 608 only if both connectors are connected.

    // "On" = always dial with dual-screen layout (608).

    // "Off" = always use the layoutSuffix value above.

    "dualScreen": "Auto"

  },

  "zoom": {

    "meetingId": {

      "regex": "^([0-9]{9,13})$",

      "regexDescription": 'Numeric Meeting ID. Example: 812 3456 7890',

      "invalidRegexMaxTries": 99

    },

    "passcode": {

      // Numeric passcodes only - the Zoom CRC dial string cannot carry letters.

      "regex": "^[0-9]{1,16}$",

      "regexDescription": 'Numeric passcode, or leave blank to skip',

      "invalidRegexMaxTries": 99,

      // false = user may submit an empty passcode and the macro dials without one

      "required": false

    }

  },

  "button": {

    // Any built-in RoomOS panel icon: Camera, Handset, Home, Media, Tv,

    // Webex, Proximity, Concierge, Info, General ...

    "icon": "Camera",

    "name": "Zoom"

  },

  "custom": {

    "name": ""

  }

}

```
