UNPKG

vodia-teams

Version:

Microsoft Teams Direct Routing configuration tool

1,079 lines (951 loc) 66 kB
// 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