vodia-teams
Version:
Microsoft Teams Direct Routing configuration tool
1,079 lines (951 loc) • 66 kB
JavaScript
// workflow.js - Main workflow implementation
const fs = require('fs');
const path = require('path');
const axios = require('axios');
const { exec, execSync } = require('child_process');
const { promisify } = require('util');
const readline = require('readline');
const { authenticateWithDeviceCode } = require('./auth');
const execAsync = promisify(exec);
// Create a robust prompt function that creates a new readline interface each time
async function prompt(question) {
return new Promise(resolve => {
// Create a new readline interface each time
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.question(question, (answer) => {
rl.close();
resolve(answer);
});
});
}
// Main workflow class
class M365UnifiedWorkflow {
constructor() {
this.accessToken = null;
this.credentials = null;
this.sbcFqdn = null;
this.domainName = null;
this.tenantId = null;
this.clientId = null;
this.clientSecret = null;
this.baseUrl = 'https://graph.microsoft.com/v1.0';
this.deviceCodeUsed = false;
}
// Initialize and run the workflow
async run() {
try {
console.log('=== M365 Unified Tenant Configuration Workflow ===\n');
// Step 1: Check if credentials exist or need to create app registration
await this.setupCredentials();
// Step 2: Authenticate with Microsoft Graph
await this.authenticate();
// Step 3: Domain management
await this.manageDomain();
// Step 4-5: SBC and Voice routing configuration (Combined to reduce auth prompts)
await this.configureSBCAndVoiceRouting();
// Step 6: Enable Enterprise Voice for users
const users = await this.getUsersWithTeamsPhoneLicenses();
await this.enableBulkEnterpriseVoice(users);
console.log('\n=== Workflow completed successfully! ===');
} catch (error) {
console.error('Error in workflow:', error.message || error);
if (error.response) {
console.error('API Error Details:', error.response.data);
}
process.exit(1);
}
}
// Setup credentials - check existing or create new
async setupCredentials() {
console.log('Step 1: Setting up credentials...');
// Ensure we have domain and SBC parameters first
if (!this.domainName) {
this.domainName = await prompt('Enter the domain name (e.g., contoso.com): ');
}
if (!this.sbcFqdn) {
this.sbcFqdn = await prompt('Enter the SBC FQDN (e.g., sbc.contoso.com): ');
}
console.log(`Domain: ${this.domainName}`);
console.log(`SBC FQDN: ${this.sbcFqdn}`);
// Use SBC FQDN as identifier for credentials file
const credentialsFileName = `${this.sbcFqdn.replace(/\./g, '_')}-credentials.json`;
console.log(`Using credentials file: ${credentialsFileName}`);
// Check if credentials already exist
if (fs.existsSync(`./${credentialsFileName}`)) {
this.credentials = JSON.parse(fs.readFileSync(`./${credentialsFileName}`, 'utf8'));
this.tenantId = this.credentials.tenantId;
this.clientId = this.credentials.clientId;
this.clientSecret = this.credentials.clientSecret;
console.log(`Using existing credentials from ${credentialsFileName}`);
console.log(`Tenant ID: ${this.tenantId}`);
console.log(`Client ID: ${this.clientId}`);
const useExisting = await prompt('Do you want to use these existing credentials? (y/n): ');
if (useExisting.toLowerCase() !== 'y') {
console.log('Creating new app registration...');
await this.runAppRegistrationScript(credentialsFileName);
// Reload credentials
this.credentials = JSON.parse(fs.readFileSync(`./${credentialsFileName}`, 'utf8'));
this.tenantId = this.credentials.tenantId;
this.clientId = this.credentials.clientId;
this.clientSecret = this.credentials.clientSecret;
}
} else {
console.log(`No existing credentials found in ${credentialsFileName}. Creating new app registration...`);
await this.runAppRegistrationScript(credentialsFileName);
// Load credentials
this.credentials = JSON.parse(fs.readFileSync(`./${credentialsFileName}`, 'utf8'));
this.tenantId = this.credentials.tenantId;
this.clientId = this.credentials.clientId;
this.clientSecret = this.credentials.clientSecret;
}
}
// Run app registration script
async runAppRegistrationScript(credentialsFileName) {
console.log('Running app registration process...');
try {
// Use the auth module to create app registration
await authenticateWithDeviceCode(credentialsFileName);
console.log('App registration completed.');
this.deviceCodeUsed = true;
} catch (error) {
console.error('Failed to run app registration process:', error.message);
throw error;
}
}
// Authenticate with Microsoft Graph
async authenticate() {
console.log('\nStep 2: Authenticating with Microsoft Graph...');
let retryCount = 0;
const maxRetries = 3;
while (retryCount < maxRetries) {
try {
// First attempt to get token
const tokenResponse = await axios.post(
`https://login.microsoftonline.com/${this.tenantId}/oauth2/v2.0/token`,
new URLSearchParams({
client_id: this.clientId,
client_secret: this.clientSecret,
scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials'
}).toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
this.accessToken = tokenResponse.data.access_token;
console.log('Authentication successful!');
// Second, verify token by making a Graph API call
try {
const orgResponse = await axios.get(
`${this.baseUrl}/organization`,
{
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
}
);
const tenantName = orgResponse.data.value[0].displayName;
console.log(`Connected to tenant: ${tenantName}`);
// Successfully authenticated and verified the token
return;
} catch (verifyError) {
// Failed during verification step
console.error('Authentication failed:', verifyError.response?.data || verifyError.message);
retryCount++;
if (retryCount >= maxRetries) {
throw new Error(`Authentication failed after ${maxRetries} attempts. Please check your permissions and try again later.`);
}
console.log(`\nAttempt ${retryCount} of ${maxRetries} failed.`);
console.log('You might need to grant admin consent for the application.');
console.log(`Open this URL in your browser and sign in with your admin account:`);
console.log(`https://login.microsoftonline.com/${this.tenantId}/adminconsent?client_id=${this.clientId}&redirect_uri=http://localhost`);
await prompt('Press Enter after granting admin consent...');
console.log('Continuing with next authentication attempt...');
}
} catch (error) {
// Failed during initial token acquisition
console.error('Authentication failed:', error.response?.data || error.message);
retryCount++;
if (retryCount >= maxRetries) {
throw new Error(`Authentication failed after ${maxRetries} attempts. Please check your permissions and try again later.`);
}
console.log(`\nAttempt ${retryCount} of ${maxRetries} failed.`);
console.log('You might need to grant admin consent for the application.');
console.log(`Open this URL in your browser and sign in with your admin account:`);
console.log(`https://login.microsoftonline.com/${this.tenantId}/adminconsent?client_id=${this.clientId}&redirect_uri=http://localhost`);
await prompt('Press Enter after granting admin consent...');
console.log('Continuing with next authentication attempt...');
}
}
}
// Domain management
async manageDomain() {
console.log('\nStep 3: Managing domain...');
try {
// Check if domain exists and its verification status
const domains = await this.getDomains();
const domain = domains.find(d => d.id.toLowerCase() === this.domainName.toLowerCase());
if (!domain) {
// Add the domain
console.log(`Domain ${this.domainName} not found. Adding domain...`);
await this.addDomain(this.domainName);
// Get verification records
const records = await this.getDomainVerificationRecords(this.domainName);
console.log('\nPlease add these DNS records to your domain:');
records.forEach(record => {
if (record.recordType === 'Txt') {
console.log('\nTXT Record:');
console.log('Host/Name:', record.label || '@');
console.log('Value:', record.text);
console.log('TTL:', record.ttl);
} else if (record.recordType === 'Mx') {
console.log('\nMX Record:');
console.log('Host/Name:', record.label || '@');
console.log('Points to:', record.mailExchange);
console.log('Priority:', record.preference);
console.log('TTL:', record.ttl);
}
});
console.log('\nDNS propagation takes time. Please choose how long to wait before verification:');
console.log('1. Wait 1 minute');
console.log('2. Wait 2 minutes');
console.log('3. Wait 5 minutes');
console.log('4. Wait 15 minutes');
console.log('5. Wait 30 minutes');
console.log('6. Skip verification and continue with the workflow');
const verifyOption = await prompt('Select an option (1-6): ');
// Ensure we got a response before proceeding
console.log(`You selected option: ${verifyOption}`);
let waitMinutes = 0;
switch(verifyOption) {
case '1':
waitMinutes = 1;
break;
case '2':
waitMinutes = 2;
break;
case '3':
waitMinutes = 5;
break;
case '4':
waitMinutes = 15;
break;
case '5':
waitMinutes = 30;
break;
case '6':
console.log('Skipping domain verification for now. You can verify the domain later in the Microsoft 365 admin center.');
return;
default:
console.log('Invalid option. Defaulting to 5 minutes wait time.');
waitMinutes = 5;
}
console.log(`Waiting ${waitMinutes} minutes for DNS propagation...`);
// Display countdown
for (let i = waitMinutes; i > 0; i--) {
console.log(`${i} minute(s) remaining...`);
await new Promise(resolve => setTimeout(resolve, 60000)); // Wait for 1 minute
}
console.log('Wait completed. Attempting to verify domain...');
await this.verifyDomain(this.domainName);
} else if (!domain.isVerified) {
console.log(`Domain ${this.domainName} exists but is not verified.`);
// Get verification records
const records = await this.getDomainVerificationRecords(this.domainName);
console.log('\nPlease add these DNS records to your domain:');
records.forEach(record => {
if (record.recordType === 'Txt') {
console.log('\nTXT Record:');
console.log('Host/Name:', record.label || '@');
console.log('Value:', record.text);
console.log('TTL:', record.ttl);
} else if (record.recordType === 'Mx') {
console.log('\nMX Record:');
console.log('Host/Name:', record.label || '@');
console.log('Points to:', record.mailExchange);
console.log('Priority:', record.preference);
console.log('TTL:', record.ttl);
}
});
console.log('\nDNS propagation takes time. Please choose how long to wait before verification:');
console.log('1. Wait 1 minute');
console.log('2. Wait 2 minutes');
console.log('3. Wait 5 minutes');
console.log('4. Wait 15 minutes');
console.log('5. Wait 30 minutes');
console.log('6. Skip verification and continue with the workflow');
const verifyOption = await prompt('Select an option (1-6): ');
// Ensure we got a response before proceeding
console.log(`You selected option: ${verifyOption}`);
let waitMinutes = 0;
switch(verifyOption) {
case '1':
waitMinutes = 1;
break;
case '2':
waitMinutes = 2;
break;
case '3':
waitMinutes = 5;
break;
case '4':
waitMinutes = 15;
break;
case '5':
waitMinutes = 30;
break;
case '6':
console.log('Skipping domain verification for now. You can verify the domain later in the Microsoft 365 admin center.');
return;
default:
console.log('Invalid option. Defaulting to 5 minutes wait time.');
waitMinutes = 5;
}
console.log(`Waiting ${waitMinutes} minutes for DNS propagation...`);
// Display countdown
for (let i = waitMinutes; i > 0; i--) {
console.log(`${i} minute(s) remaining...`);
await new Promise(resolve => setTimeout(resolve, 60000)); // Wait for 1 minute
}
console.log('Wait completed. Attempting to verify domain...');
await this.verifyDomain(this.domainName);
} else {
console.log(`Domain ${this.domainName} already exists and is verified. Skipping domain management.`);
}
} catch (error) {
console.error('Error in domain management:', error.response?.data || error.message);
console.log('Proceeding to next step...');
}
}
// Get domains
async getDomains() {
try {
const response = await axios.get(
`${this.baseUrl}/domains`,
{
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
}
);
return response.data.value;
} catch (error) {
console.error('Error getting domains:', error.response?.data || error.message);
return [];
}
}
// Add domain
async addDomain(domainName) {
try {
const response = await axios.post(
`${this.baseUrl}/domains`,
{ id: domainName },
{
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Domain ${domainName} added successfully!`);
return response.data;
} catch (error) {
if (error.response && error.response.status === 400 &&
error.response.data.error.message.includes('already exists')) {
console.log(`Domain ${domainName} already exists.`);
return { id: domainName };
}
console.error('Error adding domain:', error.response?.data || error.message);
throw error;
}
}
// Get domain verification records
async getDomainVerificationRecords(domainName) {
try {
const response = await axios.get(
`${this.baseUrl}/domains/${domainName}/verificationDnsRecords`,
{
headers: {
'Authorization': `Bearer ${this.accessToken}`
}
}
);
return response.data.value;
} catch (error) {
console.error('Error getting verification records:', error.response?.data || error.message);
throw error;
}
}
// Verify domain
async verifyDomain(domainName) {
try {
await axios.post(
`${this.baseUrl}/domains/${domainName}/verify`,
{},
{
headers: {
'Authorization': `Bearer ${this.accessToken}`,
'Content-Type': 'application/json'
}
}
);
console.log(`Domain ${domainName} verified successfully!`);
return true;
} catch (error) {
console.error('Error verifying domain:', error.response?.data || error.message);
if (error.response && error.response.data && error.response.data.error) {
console.log('Verification error details:', error.response.data.error.message);
}
console.log('Warning: Domain verification failed. This might affect subsequent steps.');
return false;
}
}
// Configure SBC and all voice routing in one script
async configureSBCAndVoiceRouting() {
console.log('\nStep 4-5: Configuring SBC and Voice Routing...');
try {
// Create a combined PowerShell script for SBC and voice routing
const combinedScript = this.createCombinedScript();
fs.writeFileSync('configure-sbc-and-voice-temp.ps1', combinedScript);
console.log(`Running combined SBC and Voice Routing configuration for ${this.sbcFqdn}...`);
// Run PowerShell script with direct output
console.log('Executing PowerShell script with device authentication...');
execSync('pwsh -File configure-sbc-and-voice-temp.ps1', { stdio: 'inherit' });
console.log('SBC and Voice Routing configuration completed successfully.');
// Clean up temporary script
fs.unlinkSync('configure-sbc-and-voice-temp.ps1');
this.deviceCodeUsed = true;
} catch (error) {
console.error('Error in SBC and Voice Routing configuration:', error.message);
console.log('Proceeding to next step...');
}
}
// Create combined script for SBC and voice routing
createCombinedScript() {
const sbcDomain = this.sbcFqdn;
const baseDomain = this.domainName;
const domainPrefix = baseDomain.split('.')[0];
// Use single quotes to avoid backtick issues in JavaScript template literals
return '# Configure-SBC-And-Voice.ps1\n' +
'# This script configures SBC connection and voice routing for Teams Direct Routing\n\n' +
'# Create secure credential storage folder if it doesn\'t exist\n' +
'if (-not (Test-Path -Path "$env:TEMP\\M365Auth")) {\n' +
' New-Item -ItemType Directory -Path "$env:TEMP\\M365Auth" -Force | Out-Null\n' +
'}\n\n' +
'# Connect to Microsoft Teams using device code authentication\n' +
'Write-Host "Connecting to Microsoft Teams using device code authentication..." -ForegroundColor Yellow\n' +
'Connect-MicrosoftTeams -UseDeviceAuthentication\n\n' +
'# Wait for authentication to complete\n' +
'Write-Host "Please complete the authentication process..." -ForegroundColor Cyan\n' +
'Start-Sleep -Seconds 5\n\n' +
'# Check if connected\n' +
'try {\n' +
' $connectionInfo = Get-CsTenant -ErrorAction Stop\n' +
' Write-Host "Successfully connected to Microsoft Teams tenant: $($connectionInfo.DisplayName)" -ForegroundColor Green\n' +
' \n' +
' # Store path for token storage\n' +
' $tokenPath = "$env:TEMP\\M365Auth\\teams_token.xml"\n' +
' \n' +
' # Store credential info for later scripts to potentially reuse\n' +
' Write-Host "Storing connection for subsequent scripts..." -ForegroundColor Yellow\n' +
'}\n' +
'catch {\n' +
' Write-Error "Failed to connect to Microsoft Teams. Please make sure you\'ve authenticated properly."\n' +
' exit 1\n' +
'}\n\n' +
'# PART 1: SBC CONFIGURATION\n' +
'# ------------------------\n' +
'Write-Host "===== CONFIGURING SBC CONNECTION =====" -ForegroundColor Cyan\n\n' +
'# SBC configuration\n' +
'$sbcFqdn = "' + sbcDomain + '"\n' +
'$sipPort = 5061\n' +
'$maxConcurrentSessions = 100\n\n' +
'Write-Host "Configuring SBC connection..." -ForegroundColor Yellow\n' +
'Write-Host "SBC FQDN: $sbcFqdn" -ForegroundColor Yellow\n' +
'Write-Host "SIP Port: $sipPort" -ForegroundColor Yellow\n' +
'Write-Host "Max Concurrent Sessions: $maxConcurrentSessions" -ForegroundColor Yellow\n\n' +
'# Check if SBC already exists\n' +
'$existingSbc = Get-CsOnlinePSTNGateway -Identity $sbcFqdn -ErrorAction SilentlyContinue\n\n' +
'try {\n' +
' if ($existingSbc) {\n' +
' Write-Host "SBC $sbcFqdn already exists. Updating configuration..." -ForegroundColor Yellow\n' +
' \n' +
' # Update existing SBC\n' +
' Set-CsOnlinePSTNGateway -Identity $sbcFqdn -SipSignalingPort $sipPort -MaxConcurrentSessions $maxConcurrentSessions -Enabled $true\n' +
' \n' +
' Write-Host "SBC configuration updated successfully!" -ForegroundColor Green\n' +
' }\n' +
' else {\n' +
' Write-Host "Creating new SBC configuration..." -ForegroundColor Yellow\n' +
' \n' +
' # Create new SBC\n' +
' New-CsOnlinePSTNGateway -Fqdn $sbcFqdn -SipSignalingPort $sipPort -MaxConcurrentSessions $maxConcurrentSessions -Enabled $true\n' +
' \n' +
' Write-Host "SBC created successfully!" -ForegroundColor Green\n' +
' }\n\n' +
' # Verify SBC configuration\n' +
' Write-Host "Verifying SBC configuration..." -ForegroundColor Yellow\n' +
' $sbcConfig = Get-CsOnlinePSTNGateway -Identity $sbcFqdn\n' +
' \n' +
' Write-Host "SBC Configuration Details:" -ForegroundColor Green\n' +
' Write-Host "FQDN: $($sbcConfig.Fqdn)"\n' +
' Write-Host "SIP Port: $($sbcConfig.SipSignalingPort)"\n' +
' Write-Host "Max Concurrent Sessions: $($sbcConfig.MaxConcurrentSessions)"\n' +
' Write-Host "Enabled: $($sbcConfig.Enabled)"\n' +
' \n' +
' Write-Host "SBC configuration completed successfully!" -ForegroundColor Green\n' +
'}\n' +
'catch {\n' +
' Write-Error "Error configuring SBC: $_"\n' +
' exit 1\n' +
'}\n\n' +
'# PART 2: VOICE ROUTING CONFIGURATION\n' +
'# ----------------------------------\n' +
'Write-Host "===== CONFIGURING VOICE ROUTING =====" -ForegroundColor Cyan\n\n' +
'# Voice routing configuration\n' +
'$pstnUsageName = "' + domainPrefix + 'PSTNUsage"\n' +
'$routeName = "' + domainPrefix + 'OutboundRoute"\n' +
'$routePattern = ".*" # Pattern for all calls\n' +
'$sbcFqdn = "' + sbcDomain + '"\n' +
'$policyName = "' + domainPrefix + 'VoicePolicy"\n\n' +
'Write-Host "Configuring Voice Routing for ALL call patterns..." -ForegroundColor Yellow\n' +
'Write-Host "PSTN Usage Name: $pstnUsageName" -ForegroundColor Yellow\n' +
'Write-Host "Route Name: $routeName" -ForegroundColor Yellow\n' +
'Write-Host "Route Pattern: $routePattern (routes all calls)" -ForegroundColor Yellow\n' +
'Write-Host "SBC FQDN: $sbcFqdn" -ForegroundColor Yellow\n' +
'Write-Host "Policy Name: $policyName" -ForegroundColor Yellow\n\n' +
'try {\n' +
' # Step 1: Check and remove existing route with same name if it exists\n' +
' $existingRoute = Get-CsOnlineVoiceRoute -Identity $routeName -ErrorAction SilentlyContinue\n' +
' if ($existingRoute) {\n' +
' Write-Host "Voice Route $routeName already exists. Removing it first..." -ForegroundColor Yellow\n' +
' Remove-CsOnlineVoiceRoute -Identity $routeName -Confirm:$false\n' +
' Write-Host "Existing route removed." -ForegroundColor Yellow\n' +
' }\n' +
' \n' +
' # Step 2: Create/Update PSTN Usage\n' +
' Write-Host "Configuring PSTN Usage..." -ForegroundColor Yellow\n' +
' \n' +
' $currentUsage = Get-CsOnlinePstnUsage\n' +
' if ($currentUsage.Usage -contains $pstnUsageName) {\n' +
' Write-Host "PSTN Usage $pstnUsageName already exists." -ForegroundColor Yellow\n' +
' }\n' +
' else {\n' +
' Write-Host "Creating PSTN Usage $pstnUsageName..." -ForegroundColor Yellow\n' +
' Set-CsOnlinePstnUsage -Identity Global -Usage @{Add=$pstnUsageName}\n' +
' }\n' +
' \n' +
' # Step 3: Create Voice Route\n' +
' Write-Host "Creating new Voice Route $routeName..." -ForegroundColor Yellow\n' +
' New-CsOnlineVoiceRoute -Identity $routeName -NumberPattern $routePattern -OnlinePstnGatewayList $sbcFqdn -OnlinePstnUsages $pstnUsageName -Priority 1\n' +
' Write-Host "Route priority explicitly set to 1" -ForegroundColor Cyan\n' +
' \n' +
' # Step 4: Check and set priority to ensure it\'s 1\n' +
' Write-Host "Verifying route priority..." -ForegroundColor Yellow\n' +
' Start-Sleep -Seconds 2 # Give some time for changes to propagate\n' +
' \n' +
' $verifyRoute = Get-CsOnlineVoiceRoute -Identity $routeName\n' +
' if ($verifyRoute.Priority -ne 1) {\n' +
' Write-Host "WARNING: Route priority is not 1, updating..." -ForegroundColor Red\n' +
' Set-CsOnlineVoiceRoute -Identity $routeName -Priority 1 -Confirm:$false\n' +
' Write-Host "Route priority updated to 1." -ForegroundColor Yellow\n' +
' \n' +
' # Verify one more time\n' +
' $verifyRouteAgain = Get-CsOnlineVoiceRoute -Identity $routeName\n' +
' if ($verifyRouteAgain.Priority -ne 1) {\n' +
' Write-Host "CRITICAL: Unable to set route priority to 1!" -ForegroundColor Red\n' +
' } else {\n' +
' Write-Host "Route priority successfully set to 1." -ForegroundColor Green\n' +
' }\n' +
' } else {\n' +
' Write-Host "Route priority correctly set to 1 √" -ForegroundColor Green\n' +
' }\n' +
' \n' +
' # Step 5: Make sure our route has highest priority among ALL routes\n' +
' Write-Host "Checking priority against other routes..." -ForegroundColor Yellow\n' +
' $allRoutes = Get-CsOnlineVoiceRoute\n' +
' \n' +
' $otherPriority1Routes = $allRoutes | Where-Object { $_.Identity -ne $routeName -and $_.Priority -le 1 }\n' +
' \n' +
' if ($otherPriority1Routes) {\n' +
' Write-Host "Found other routes with priority 1 or lower. Updating their priorities..." -ForegroundColor Yellow\n' +
' $counter = 2\n' +
' foreach ($route in $otherPriority1Routes) {\n' +
' Write-Host "Changing route $($route.Identity) priority from $($route.Priority) to $counter" -ForegroundColor Yellow\n' +
' Set-CsOnlineVoiceRoute -Identity $route.Identity -Priority $counter -Confirm:$false\n' +
' $counter++\n' +
' }\n' +
' Write-Host "All other routes now have lower priority than $routeName" -ForegroundColor Green\n' +
' } else {\n' +
' Write-Host "No other routes with priority 1 or lower. Our route has highest priority √" -ForegroundColor Green\n' +
' }\n' +
' \n' +
' # Step 6: Create/Update Voice Routing Policy\n' +
' Write-Host "Configuring Voice Routing Policy..." -ForegroundColor Yellow\n' +
' \n' +
' $existingPolicy = Get-CsOnlineVoiceRoutingPolicy -Identity $policyName -ErrorAction SilentlyContinue\n' +
' \n' +
' if ($existingPolicy) {\n' +
' Write-Host "Voice Routing Policy $policyName already exists. Updating configuration..." -ForegroundColor Yellow\n' +
' \n' +
' Set-CsOnlineVoiceRoutingPolicy -Identity $policyName -OnlinePstnUsages @{Add=$pstnUsageName}\n' +
' }\n' +
' else {\n' +
' Write-Host "Creating new Voice Routing Policy $policyName..." -ForegroundColor Yellow\n' +
' \n' +
' New-CsOnlineVoiceRoutingPolicy -Identity $policyName -OnlinePstnUsages $pstnUsageName\n' +
' }\n' +
' \n' +
' # Step 7: Verify configuration\n' +
' Write-Host "Verifying configuration..." -ForegroundColor Yellow\n' +
' \n' +
' Write-Host "PSTN Usages:" -ForegroundColor Green\n' +
' Get-CsOnlinePstnUsage | Select-Object -ExpandProperty Usage\n' +
' \n' +
' Write-Host "Voice Route details:" -ForegroundColor Green\n' +
' $route = Get-CsOnlineVoiceRoute -Identity $routeName\n' +
' \n' +
' Write-Host "- Identity: $($route.Identity)"\n' +
' Write-Host "- Number Pattern: $($route.NumberPattern)"\n' +
' Write-Host "- PSTN Gateway: $($route.OnlinePstnGatewayList)"\n' +
' Write-Host "- PSTN Usages: $($route.OnlinePstnUsages)"\n' +
' Write-Host "- Priority: $($route.Priority)" -ForegroundColor Cyan\n' +
' \n' +
' Write-Host "Voice Routing Policy details:" -ForegroundColor Green\n' +
' $policy = Get-CsOnlineVoiceRoutingPolicy -Identity $policyName\n' +
' Write-Host "- Identity: $($policy.Identity)"\n' +
' Write-Host "- PSTN Usages: $($policy.OnlinePstnUsages -join \', \')"\n' +
' \n' +
' Write-Host "Voice Routing configuration completed successfully!" -ForegroundColor Green\n' +
'}\n' +
'catch {\n' +
' Write-Error "Error configuring Voice Routing: $_"\n' +
' exit 1\n' +
'}\n\n' +
'# Store credentials for next script\n' +
'try {\n' +
' # Export the current connection info to a file that next script can use\n' +
' Write-Host "Storing connection for Enterprise Voice configuration..." -ForegroundColor Yellow\n' +
' \n' +
' # To maintain the session for later scripts, we\'ll create a marker file\n' +
' $null = New-Item -Path "$env:TEMP\\M365Auth\\teams_session_active.txt" -ItemType File -Force\n' +
' $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"\n' +
' Set-Content -Path "$env:TEMP\\M365Auth\\teams_session_active.txt" -Value "Session created: $timestamp"\n' +
' \n' +
' Write-Host "Session information stored successfully." -ForegroundColor Green\n' +
'} catch {\n' +
' Write-Warning "Could not store connection information: $_"\n' +
'}\n\n' +
'# We won\'t disconnect so the next script can reuse the session\n' +
'Write-Host "SBC and Voice Routing configuration completed successfully!" -ForegroundColor Green\n' +
'Write-Host "Session remains active for Enterprise Voice configuration." -ForegroundColor Yellow\n';
}
// Enable Enterprise Voice for all users in a single script
async enableBulkEnterpriseVoice(users) {
try {
console.log('\nStep 6: Enabling Enterprise Voice for users with Teams Phone licenses...');
if (users.length === 0) {
console.log('No users to configure for Enterprise Voice.');
return;
}
console.log(`Found ${users.length} users with Teams Phone licenses:`);
users.forEach((user, index) => {
console.log(`${index + 1}. ${user.displayName} (${user.userPrincipalName})`);
});
// Ask which users to configure
console.log('\nWhich users would you like to configure?');
console.log('1. All users');
console.log('2. Select specific users');
const userSelectionOption = await prompt('Enter your choice (1 or 2): ');
console.log(`You selected option: ${userSelectionOption}`);
let selectedUsers = [];
if (userSelectionOption === '1') {
// Configure all users
selectedUsers = [...users];
console.log(`Selected all ${selectedUsers.length} users.`);
} else {
// Select specific users
console.log('\nEnter the numbers of the users you want to configure, separated by commas');
console.log('Example: 1,3,4');
const selectedIndices = await prompt('Select users: ');
console.log(`You selected: ${selectedIndices}`);
// Parse indices and select corresponding users
const indices = selectedIndices.split(',').map(idx => parseInt(idx.trim()) - 1);
indices.forEach(idx => {
if (idx >= 0 && idx < users.length) {
selectedUsers.push(users[idx]);
} else {
console.log(`Warning: Invalid user number ${idx + 1}, skipping.`);
}
});
console.log(`Selected ${selectedUsers.length} users:`);
selectedUsers.forEach((user, index) => {
console.log(`${index + 1}. ${user.displayName} (${user.userPrincipalName})`);
});
if (selectedUsers.length === 0) {
console.log('No valid users selected. Exiting Enterprise Voice configuration.');
return;
}
}
const baseDomain = this.domainName;
const policyName = `${baseDomain.split('.')[0]}VoicePolicy`;
// Ask how to assign phone numbers to users - MODIFIED OPTIONS TEXT
console.log('\nHow would you like to assign phone numbers to users?');
console.log('1. Use a base phone number with sequential digits');
console.log('2. Enter individual phone numbers for each user'); // CHANGED TEXT
console.log('3. Enter extension numbers that will be assigned to each user');
console.log('4. Map Vodia PBX extension numbers to Teams users? You\'ll need the server management address, admin credentials and API enabled for the admin user.'); // ADDED OPTION
const numberingOption = await prompt('Enter your choice (1, 2, 3, or 4): '); // UPDATED PROMPT
console.log(`You selected option: ${numberingOption}`);
let basePhoneNumber = '';
let vodiaExtensions = null;
if (numberingOption === '1') {
// Ask for base phone number
basePhoneNumber = await prompt('Enter a base phone number in E.164 format (e.g., +12065550000). Last digits will be replaced for each user: ');
console.log(`Using base phone number: ${basePhoneNumber}`);
} else if (numberingOption === '3') {
// Set to extension mode - we'll just use the extension numbers
basePhoneNumber = 'extension';
console.log('You will be prompted to enter an extension number for each user.');
} else if (numberingOption === '4') {
// NEW OPTION - Vodia PBX integration
basePhoneNumber = 'vodia';
// Get Vodia PBX credentials
console.log('\nVodia PBX Integration Setup:');
const vodiaServer = await prompt('Enter Vodia PBX server address (e.g., ztfznb.vodia-pbx.com): ');
const vodiaUsername = await prompt('Enter Vodia admin username: ');
const vodiaPassword = await prompt('Enter Vodia admin password: ');
// Store starting time for performance tracking
const startTime = new Date();
// Ensure the SBC FQDN is present in the tenants list
console.log('\nFetching tenants from Vodia PBX...');
try {
// Using node-fetch to make HTTP requests
const { exec } = require('child_process');
const util = require('util');
const execPromise = util.promisify(exec);
let tenantsResponse;
try {
tenantsResponse = await execPromise(`curl --location -g -u "${vodiaUsername}:${vodiaPassword}" -k "https://${vodiaServer}/rest/system/usage"`);
} catch (error) {
console.error('Error fetching Vodia tenants:', error.message);
console.log('Please check your credentials and try again.');
// Offer the options again
console.log('\nHow would you like to assign phone numbers to users?');
console.log('1. Use a base phone number with sequential digits');
console.log('2. Enter individual phone numbers for each user');
console.log('3. Enter extension numbers that will be assigned to each user');
const fallbackOption = await prompt('Enter your choice (1, 2, or 3): ');
if (fallbackOption === '1') {
basePhoneNumber = await prompt('Enter a base phone number in E.164 format (e.g., +12065550000). Last digits will be replaced for each user: ');
} else if (fallbackOption === '3') {
basePhoneNumber = 'extension';
} else {
basePhoneNumber = 'individual';
}
console.log(`Falling back to option: ${fallbackOption}`);
// Continue with the standard processing
vodiaExtensions = null;
// Skip the rest of the Vodia processing
return this.proceedWithEnterpriseVoice(selectedUsers, basePhoneNumber, policyName, vodiaExtensions);
}
// Parse the response to find matching tenant
const tenants = JSON.parse(tenantsResponse.stdout);
console.log('Available Vodia tenants:');
const matchingTenants = [];
tenants.forEach((tenant, index) => {
console.log(`${index + 1}. ${tenant.name}`);
if (tenant.name === this.sbcFqdn) {
matchingTenants.push(tenant.name);
}
});
let selectedTenant;
if (matchingTenants.length === 0) {
console.log(`\nWarning: SBC FQDN ${this.sbcFqdn} not found in tenant list.`);
console.log('Please select a tenant from the list:');
const tenantSelection = await prompt('Enter tenant number: ');
const tenantIndex = parseInt(tenantSelection.trim()) - 1;
if (tenantIndex >= 0 && tenantIndex < tenants.length) {
selectedTenant = tenants[tenantIndex].name;
} else {
console.log('Invalid tenant selection. Falling back to manual extension entry.');
basePhoneNumber = 'extension';
return this.proceedWithEnterpriseVoice(selectedUsers, basePhoneNumber, policyName, null);
}
} else {
selectedTenant = this.sbcFqdn;
console.log(`Found matching tenant: ${selectedTenant}`);
}
// Fetch extensions from the selected tenant
console.log(`\nFetching extensions from tenant ${selectedTenant}...`);
try {
const extensionsResponse = await execPromise(`curl --location -g -u "${vodiaUsername}:${vodiaPassword}" -k "https://${vodiaServer}/rest/domain/${selectedTenant}/extensions"`);
const extensions = JSON.parse(extensionsResponse.stdout);
// Process extensions to extract email and alias information
vodiaExtensions = extensions.map(ext => {
return {
email: ext.email_address,
extension: ext.alias && ext.alias.length > 0 ? ext.alias[0] : '',
name: ext.name || 'Unknown'
};
});
console.log(`Found ${vodiaExtensions.length} extensions in Vodia PBX.`);
// Calculate fetch time
const fetchTime = ((new Date() - startTime) / 1000).toFixed(2);
console.log(`Fetch completed in ${fetchTime} seconds.`);
// Display first few extensions
const sampleSize = Math.min(5, vodiaExtensions.length);
if (sampleSize > 0) {
console.log('\nSample extensions:');
console.log('┌──────────────────────────────────────────────┐');
console.log('│ Extension │ Name │ Email │');
console.log('├──────────────────────────────────────────────┤');
for (let i = 0; i < sampleSize; i++) {
const ext = vodiaExtensions[i];
const extNum = (ext.extension || 'N/A').padEnd(9);
const name = (ext.name || 'Unknown').padEnd(15).substring(0, 15);
const email = (ext.email || 'N/A').padEnd(15).substring(0, 15);
console.log(`│ ${extNum} │ ${name} │ ${email} │`);
}
console.log('└──────────────────────────────────────────────┘');
}
} catch (error) {
console.error('Error fetching Vodia extensions:', error.message);
console.log('Falling back to manual extension entry.');
basePhoneNumber = 'extension';
vodiaExtensions = null;
}
} catch (error) {
console.error('Error in Vodia integration:', error.message);
console.log('Falling back to manual extension entry.');
basePhoneNumber = 'extension';
vodiaExtensions = null;
}
} else {
// Set empty base number to trigger individual entry in PowerShell
basePhoneNumber = 'individual';
console.log('You will be prompted to enter phone numbers for each user individually.');
}
// Proceed with enterprise voice configuration
await this.proceedWithEnterpriseVoice(selectedUsers, basePhoneNumber, policyName, vodiaExtensions);
} catch (error) {
console.error('Error enabling Enterprise Voice:', error.message);
}
}
// New helper method to continue with enterprise voice configuration
async proceedWithEnterpriseVoice(selectedUsers, basePhoneNumber, policyName, vodiaExtensions) {
// If using Vodia, display a mapping summary before proceeding
if (basePhoneNumber === 'vodia' && vodiaExtensions) {
console.log('\n===== VODIA EXTENSION MAPPING SUMMARY =====');
console.log('┌─────────────────────────────────────────────────────────────────────────────┐');
console.log('│ User │ Email │ Vodia Extension │');
console.log('├─────────────────────────────────────────────────────────────────────────────┤');
// Track statistics
let matchedCount = 0;
let unmatchedCount = 0;
// Display mapping info for each user
selectedUsers.forEach(user => {
const matchingExt = vodiaExtensions.find(ext =>
ext.email && user.userPrincipalName &&
ext.email.toLowerCase() === user.userPrincipalName.toLowerCase()
);
const displayName = user.displayName.padEnd(30).substring(0, 30);
const email = user.userPrincipalName.padEnd(25).substring(0, 25);
const extension = matchingExt ? matchingExt.extension.padEnd(15) : 'NOT FOUND'.padEnd(15);
if (matchingExt) {
matchedCount++;
console.log(`│ ${displayName} │ ${email} │ ${extension} │`);
} else {
unmatchedCount++;
console.log(`│ ${displayName} │ ${email} │ ${extension} │`);
}
});
console.log('└─────────────────────────────────────────────────────────────────────────────┘');
console.log(`SUMMARY: ${matchedCount} users matched with Vodia extensions, ${unmatchedCount} users need manual configuration.`);
console.log('You will be prompted to confirm or change each extension in the next step.\n');
// Ask for confirmation before proceeding
const proceed = await prompt('Continue with Enterprise Voice configuration using these mappings? (y/n): ');
if (proceed.toLowerCase() !== 'y') {
console.log('Aborting Enterprise Voice configuration.');
return;
}
}
// Create bulk script for selected users
const bulkScript = this.createBulkEnterpriseVoiceScript(selectedUsers, basePhoneNumber, policyName, vodiaExtensions);
const scriptFilename = 'enable-enterprise-voice-bulk-temp.ps1';
fs.writeFileSync(scriptFilename, bulkScript);
console.log('\nRunning Enterprise Voice configuration for selected users...');
// Run PowerShell script with direct output
console.log('Executing PowerShell script (you\'ll be prompted to configure each user)...');
execSync(`pwsh -File ${scriptFilename}`, { stdio: 'inherit' });
console.log('Enterprise Voice configuration completed for selected users.');
// Clean up temporary script
fs.unlinkSync(scriptFilename);
}
// Create Enterprise Voice script for all users in a single script
createBulkEnterpriseVoiceScript(users, basePhoneNumber, policyName, vodiaExtensions) {
const baseDomain = this.domainName;
const domainPrefix = baseDomain.split('.')[0];
let script = '# Configure-Enterprise-Voice-Bulk.ps1\n' +
'# This script enables Enterprise Voice for multiple users in a single session\n\n' +
'# Create secure credential storage folder if it doesn\'t exist\n' +
'if (-not (Test-Path -Path "$env:TEMP\\M365Auth")) {\n' +
' New-Item -ItemType Directory -Path "$env:TEMP\\M365Auth" -Force | Out-Null\n' +
'}\n\n' +
'# Check if we have an active session from the previous script\n' +
'$activeSessionPath = "$env:TEMP\\M365Auth\\teams_session_active.txt"\n' +
'$connected = $false\n\n' +
'if (Test-Path $activeSessionPath) {\n' +
' try {\n' +
' # Try to use existing session\n' +
' Write-Host "Checking for existing Teams connection..." -ForegroundColor Yellow\n' +
' $connectionInfo = Get-CsTenant -ErrorAction SilentlyContinue\n' +
' \n' +
' if ($connectionInfo) {\n' +
' Write-Host "Using existing Teams connection to tenant: $($connectionInfo.DisplayName)" -ForegroundColor Green\n' +
' $connected = $true\n' +
' }\n' +
' } catch {\n' +
' Write-Host "Existing session not valid or expired." -ForegroundColor Yellow\n' +
' }\n' +
'}\n\n' +
'# Connect if not already connected\n' +
'if (-not $connected) {\n' +
' Write-Host "Connecting to Microsoft Teams using device code authentication..." -ForegroundColor Yellow\n' +
' Connect-MicrosoftTeams -UseDeviceAuthentication\n' +
' Write-Host "Please complete the authentication process..." -ForegroundColor Cyan\n' +
' Start-Sleep -Seconds 5\n' +
'}\n\n' +
'# Verify connection\n' +
'try {\n' +
' $connectionInfo = Get-CsTenant -ErrorAction Stop\n' +
' Write-Host "Connected to Microsoft Teams tenant: $($connectionInfo.DisplayName)" -ForegroundColor Green\n' +
'}\n' +
'catch {\n' +
' Write-Error "Failed to connect to Microsoft Teams. Please make sure you\'ve authenticated properly."\n' +
' exit 1\n' +
'}\n\n' +
'# Voice routing policy to use\n' +
'$policyName = "' + domainPrefix + 'VoicePolicy"\n' +
'Write-Host "Using voice routing policy: $policyName" -ForegroundColor Yellow\n\n' +
'# Process all users in a single session\n' +
'Write-Host "===== ENABLING ENTERPRISE VOICE FOR MULTIPLE USERS =====" -ForegroundColor Cyan\n' +
'Write-Host "Total users to configure: ' + users.length + '" -ForegroundColor Yellow\n\n';
// Add script section for each user
users.forEach((user, index) => {
let userScript = '';
// Check if Vodia extensions are being used and if there's a match
if (basePhoneNumber === 'vodia' && vodiaExtensions) {
// Attempt to find a matching email address in Vodia extensions
const matchingExtension = vodiaExtensions.find(ext =>
ext.email && user.userPrincipalName &&
ext.email.toLowerCase() === user.userPrincipalName.toLowerCase()
);
if (matchingExtension && matchingExtension.extension) {
// Found a matching extension - REMOVED CONFIRMATION PROMPT
userScript = '# User ' + (index + 1) + ': ' + user.displayName + ' (' + user.userPrincipalName + ')\n