"use strict"; /** * Main Menu Patch - Injects dynamic skills from menuEntry.json files into Jibo's menu * * This patches: * 1. jibo.loader.load() to intercept menu JSON loading and inject dynamic skills * 2. The main-menu skill's redirectToSkill method to handle custom skill IDs */ const fs = require('fs'); const path = require('path'); const menuEntries = require('./menu-entries'); const skillsRootUtil = require('./skills-root'); function strStartsWith(s, prefix) { return (typeof s === 'string') && (s.indexOf(prefix) === 0); } function strEndsWith(s, suffix) { if (typeof s !== 'string' || typeof suffix !== 'string') return false; if (suffix.length > s.length) return false; return s.indexOf(suffix, s.length - suffix.length) !== -1; } function strIncludes(s, needle) { return (typeof s === 'string') && (s.indexOf(needle) !== -1); } // Optional UDP logger (Python logd). Safe fallback if unavailable. let robotLogger = null; try { robotLogger = require('../be/robot-logger'); } catch (e) { robotLogger = null; } // Skills root directory (configurable) let SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot(); // Cache for scanned skills let _cachedMenuEntries = null; let _cachedSkillsRoot = null; let _cachedProvidersDir = null; let _submenuConfigs = {}; let _dynamicSkillIds = new Set(); // Track dynamic skill IDs for routing let _beInstance = null; let _jibo = null; // Color mappings for skill buttons (name -> hex array) const COLOR_MAP = { 'default': ['0xBBC6CA', '0x434D55'], 'blue': ['0x3765AB', '0x1A2563'], 'green': ['0x8EDD40', '0x31732A'], 'red': ['0xE81853', '0x850F40'], 'purple': ['0x9B59B6', '0x6C3483'], 'orange': ['0xFF892F', '0xAF4123'], 'teal': ['0x2BEFDC', '0x086969'], 'pink': ['0xFF69B4', '0xC71585'], 'cyan': ['0x25F2FB', '0x107799'], 'yellow': ['0xFFD700', '0xB8860B'] }; // Logging helper - writes to both console and file const LOG_FILE = '/tmp/menu-patch.log'; function log(msg, ...args) { const line = '[MENU-PATCH] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); console.log(line); try { if (robotLogger && typeof robotLogger.raw === 'function') { robotLogger.raw(line); } } catch (e) { } try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n'); } catch (e) {} } function warn(msg, ...args) { const line = '[MENU-PATCH WARN] ' + msg + ' ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); console.warn(line); try { if (robotLogger && typeof robotLogger.raw === 'function') { robotLogger.raw(line); } } catch (e) { } try { fs.appendFileSync(LOG_FILE, new Date().toISOString() + ' ' + line + '\n'); } catch (e) {} } /** * Safely read and parse a JSON file */ function safeReadJson(filePath) { try { const txt = fs.readFileSync(filePath, 'utf8'); if (!txt || txt.trim().length === 0) return null; return JSON.parse(txt); } catch (e) { log('safeReadJson error for', filePath, e.message); return null; } } /** * Check if path is a directory */ function isDirectory(p) { try { return fs.statSync(p).isDirectory(); } catch (e) { return false; } } /** * Get colors array from color name or return default */ function getColors(colorName) { if (Array.isArray(colorName)) return colorName; if (typeof colorName === 'string' && colorName.startsWith('0x')) { return [colorName, colorName]; } return COLOR_MAP[colorName] || COLOR_MAP['default']; } /** * Scan a skill directory for menuEntry.json */ function scanSkillEntry(skillDir, folderName) { const menuJsonPath = path.join(skillDir, 'menuEntry.json'); const menuJson = safeReadJson(menuJsonPath); if (!menuJson) { return null; } if (menuJson.hidden === true) { log('Skipping hidden entry:', folderName); return null; } log('Found menuEntry.json in', folderName, '- type:', menuJson.type); const entry = { id: folderName, type: menuJson.type || 'skill', title: menuJson.title || menuJson.label || folderName, icon: menuJson.icon || menuJson.iconSrc || 'resources/icons/settings.png', color: menuJson.color || menuJson.colors || 'default', description: menuJson.description || '', path: skillDir, order: typeof menuJson.order === 'number' ? menuJson.order : 100, skillId: menuJson.skillId || folderName, submenuTitle: menuJson.submenuTitle || menuJson.title || folderName }; // Track this as a dynamic skill if (entry.type === 'skill') { _dynamicSkillIds.add(entry.skillId); _dynamicSkillIds.add(entry.id); } // For submenus, scan child directories if (entry.type === 'submenu') { entry.children = scanSubmenuChildren(skillDir); log('Submenu', folderName, 'has', entry.children.length, 'children'); } return entry; } /** * Scan children of a submenu directory */ function scanSubmenuChildren(submenuDir) { const children = []; let items; try { items = fs.readdirSync(submenuDir); } catch (e) { warn('Failed to read submenu dir:', submenuDir, e.message); return children; } items.forEach(name => { if (name.startsWith('.') || name === '@be' || name === 'node_modules') return; const childPath = path.join(submenuDir, name); if (!isDirectory(childPath)) return; const entry = scanSkillEntry(childPath, name); if (entry && entry.type === 'skill') { children.push(entry); } }); // Sort by order, then alphabetically children.sort((a, b) => { if (a.order !== b.order) return a.order - b.order; return a.title.localeCompare(b.title); }); return children; } /** * Scan the Skills root directory for all menu entries */ function scanAllMenuEntries() { if (_cachedMenuEntries && _cachedSkillsRoot === SKILLS_ROOT) { log('Returning cached entries:', _cachedMenuEntries.length); return _cachedMenuEntries; } log('Scanning Skills directory:', SKILLS_ROOT); _dynamicSkillIds.clear(); const res = menuEntries.getMenuEntries({ skillsRoot: SKILLS_ROOT, log: function () { try { log.apply(null, arguments); } catch (e) { } } }); _cachedMenuEntries = res.entries || []; _cachedSkillsRoot = res.skillsRoot; _cachedProvidersDir = res.providersDir; // update dynamic id set try { if (res.dynamicSkillIds && typeof res.dynamicSkillIds.forEach === 'function') { res.dynamicSkillIds.forEach(function (id) { _dynamicSkillIds.add(id); }); } } catch (e) { } log('Total entries found:', _cachedMenuEntries.length); if (_cachedProvidersDir) { log('Providers dir:', _cachedProvidersDir); } return _cachedMenuEntries; } /** * Convert a menu entry to the main-menu button format */ function entryToMenuButton(entry) { const button = { id: entry.id, label: entry.title, colors: getColors(entry.color), iconSrc: entry.icon }; if (entry.type === 'submenu') { // For submenus, create an action that loads a dynamically generated submenu const submenuId = 'dynamic-submenu-' + entry.id; // Register the submenu configuration _submenuConfigs[submenuId] = createSubmenuConfig(entry); button.action = { type: 'utterance', data: { utterance: { intent: 'loadMenu', entities: { destination: submenuId } } } }; } else { // Regular skill button - use utterance format with dynamic marker button.action = { type: 'utterance', data: { utterance: { intent: 'loadMenu', entities: { destination: 'dynamic:' + entry.skillId } } } }; } return button; } /** * Create a submenu view configuration */ function createSubmenuConfig(entry) { const config = { rule: 'main-menu/execute_main_menu', timeout: 8, viewConfig: { type: 'MenuView', id: 'submenu-' + entry.id, title: entry.submenuTitle || entry.title, listDefault: { menuButtonType: 'SkillButton' }, list: [] } }; // Add back button that closes the menu (returns to main) config.viewConfig.list.push({ id: '__back__', label: '← Back', colors: COLOR_MAP['default'], iconSrc: 'resources/icons/settings.png', action: { type: 'utterance', data: { utterance: { intent: 'loadMenu', entities: { destination: '__goback__' } } } } }); // Add skill buttons from children if (entry.children && entry.children.length > 0) { entry.children.forEach(child => { config.viewConfig.list.push({ id: child.id, label: child.title, colors: getColors(child.color), iconSrc: child.icon, action: { type: 'utterance', data: { utterance: { intent: 'loadMenu', entities: { destination: 'dynamic:' + child.skillId } } } } }); }); } return config; } /** * Inject dynamic skills into a loaded menu config */ function injectDynamicSkills(originalConfig) { log('injectDynamicSkills called'); if (!originalConfig) { warn('originalConfig is null/undefined'); return originalConfig; } if (!originalConfig.viewConfig) { warn('originalConfig.viewConfig is missing'); return originalConfig; } if (!originalConfig.viewConfig.list) { warn('originalConfig.viewConfig.list is missing'); return originalConfig; } log('Original config has', originalConfig.viewConfig.list.length, 'buttons'); const entries = scanAllMenuEntries(); if (entries.length === 0) { warn('No dynamic entries found to inject'); return originalConfig; } // Clone the config to avoid mutating the original const config = JSON.parse(JSON.stringify(originalConfig)); // Add dynamic skill buttons to the list entries.forEach(entry => { const button = entryToMenuButton(entry); log('Adding button:', button.label, '- id:', button.id); config.viewConfig.list.push(button); }); log('Final config has', config.viewConfig.list.length, 'buttons'); log('Injected', entries.length, 'dynamic skills into menu'); return config; } /** * Patch the main-menu skill's redirectToSkill method */ function patchMainMenuSkill(be) { log('patchMainMenuSkill called'); if (!be) { warn('be is null/undefined'); return; } if (!be.skills) { warn('be.skills is null/undefined'); return; } if (!be.skills['@be/main-menu']) { warn('@be/main-menu skill not found in be.skills'); log('Available skills:', Object.keys(be.skills).join(', ')); return; } const mainMenuSkill = be.skills['@be/main-menu']; const originalRedirectToSkill = mainMenuSkill.redirectToSkill.bind(mainMenuSkill); mainMenuSkill.redirectToSkill = function(skill) { log('redirectToSkill called with:', skill); // Handle back navigation if (skill === '__goback__') { log('Going back to main menu'); if (_jibo && _jibo.face && _jibo.face.views) { _jibo.face.views.changeView({ remove: true, leaveEmpty: true, transitionClose: _jibo.face.views.TRANSITION.UP }, () => { // Reload the main menu by launching main-menu skill again this.redirect('@be/main-menu', {}); }); } return; } // Handle dynamic submenus if (skill && strStartsWith(skill, 'dynamic-submenu-')) { log('Showing submenu:', skill); const submenuConfig = _submenuConfigs[skill]; if (submenuConfig && _jibo && _jibo.face && _jibo.face.views) { // Show the submenu view _jibo.face.views.changeView(submenuConfig, (err, result) => { if (err) { warn('Failed to show submenu:', err); return; } log('Submenu displayed successfully'); // Set up selection handler for the submenu if (result && result.on) { result.on('select', (selection) => { log('Submenu item selected:', selection); try { if (selection && selection.action && selection.action.data && selection.action.data.utterance && selection.action.data.utterance.entities) { const destination = selection.action.data.utterance.entities.destination; if (destination) { this.redirectToSkill(destination); } } } catch (e) { warn('Submenu selection handler error:', e && e.message ? e.message : e); } }); } }); return; } warn('Submenu config not found:', skill); return; } // Handle dynamic skills (prefixed with 'dynamic:') if (skill && strStartsWith(skill, 'dynamic:')) { const actualSkillId = skill.substring(8); // Remove 'dynamic:' prefix log('Launching dynamic skill:', actualSkillId); this.skillChosen = true; if (_jibo && _jibo.face && _jibo.face.views) { _jibo.face.views.changeView({ remove: true, leaveEmpty: true, transitionClose: _jibo.face.views.TRANSITION.UP }, () => { // Try to find and launch the skill if (be.skills[actualSkillId]) { log('Found skill as:', actualSkillId); this.redirect(actualSkillId, {}); } else if (be.skills['@be/' + actualSkillId]) { log('Found skill as:', '@be/' + actualSkillId); this.redirect('@be/' + actualSkillId, {}); } else { warn('Skill not found:', actualSkillId); warn('Available skills:', Object.keys(be.skills).join(', ')); this.redirect('@be/chitchat', { NLParse: { domain: 'chitchat', intent: 'scripted', mimId: 'JBO_ImSorryIDidntUnderstandThat' } }); } }); } return; } // For all other skills, use the original method return originalRedirectToSkill(skill); }; log('main-menu skill redirectToSkill patched successfully'); } /** * Apply the patch to the BE instance */ function applyPatch(be) { log('applyPatch called'); _beInstance = be; if (be && be.log) { be.log.info('[MENU-PATCH] initialized'); } // Test scanning immediately log('Testing skill scan on startup...'); const testEntries = scanAllMenuEntries(); log('Startup scan found', testEntries.length, 'entries'); testEntries.forEach(e => log(' -', e.title, '(' + e.type + ')')); // Patch the main-menu skill after a short delay to ensure it's loaded setTimeout(() => { patchMainMenuSkill(be); }, 100); } /** * Patch jibo.loader.load to intercept menu loading */ function patchLoader(jibo, skillsRoot) { log('patchLoader called'); _jibo = jibo; // Allow caller to override skills root. if (skillsRoot && typeof skillsRoot === 'string') { SKILLS_ROOT = skillsRoot; } else { SKILLS_ROOT = skillsRootUtil.resolveSkillsRoot(); } clearCache(); if (!jibo) { warn('jibo is null/undefined'); return; } if (!jibo.loader) { warn('jibo.loader is null/undefined'); return; } if (!jibo.loader.load) { warn('jibo.loader.load is null/undefined'); return; } const originalLoad = jibo.loader.load.bind(jibo.loader); jibo.loader.load = function(resourcePath, callback) { // Log all resource loads to see what's happening log('>>> jibo.loader.load called with:', resourcePath); // Check if this is loading a dynamic submenu if (resourcePath && strStartsWith(resourcePath, 'dynamic-submenu-')) { const submenuConfig = _submenuConfigs[resourcePath]; if (submenuConfig) { log('Loading dynamic submenu:', resourcePath); if (callback) { callback(null, submenuConfig); } return; } } // Check if this is the main menu - match various possible paths const isMainMenu = resourcePath && ( strIncludes(resourcePath, 'main-menu-verbal.json') || strIncludes(resourcePath, 'main-menu.json') || resourcePath === 'resources/views/main-menu-verbal.json' || strEndsWith(resourcePath, 'main-menu-verbal.json') ); if (isMainMenu) { log('*** INTERCEPTING MAIN MENU LOAD ***'); log('Resource path:', resourcePath); originalLoad(resourcePath, function(err, config) { if (err) { warn('Error loading original menu config:', err); if (callback) callback(err); return; } log('Original menu config loaded successfully'); try { const origCount = (config && config.viewConfig && config.viewConfig.list && config.viewConfig.list.length) ? config.viewConfig.list.length : 0; log('Original button count:', origCount); } catch (e) { log('Original button count: (unknown)'); } // Inject dynamic skills const patchedConfig = injectDynamicSkills(config); try { const patchedCount = (patchedConfig && patchedConfig.viewConfig && patchedConfig.viewConfig.list && patchedConfig.viewConfig.list.length) ? patchedConfig.viewConfig.list.length : 0; log('Patched button count:', patchedCount); } catch (e) { log('Patched button count: (unknown)'); } if (callback) callback(null, patchedConfig); }); return; } // For all other resources, use original loader return originalLoad(resourcePath, callback); }; log('jibo.loader.load patched successfully'); } /** * Clear the cached menu entries (useful for rescanning) */ function clearCache() { _cachedMenuEntries = null; _cachedSkillsRoot = null; _cachedProvidersDir = null; _submenuConfigs = {}; _dynamicSkillIds.clear(); } /** * Get the currently scanned menu entries (for debugging) */ function getMenuEntries() { return scanAllMenuEntries(); } /** * Check if a skill ID is a dynamic skill */ function isDynamicSkill(skillId) { return _dynamicSkillIds.has(skillId); } module.exports = { applyPatch: applyPatch, patchLoader: patchLoader, clearCache: clearCache, getMenuEntries: getMenuEntries, isDynamicSkill: isDynamicSkill, SKILLS_ROOT: SKILLS_ROOT };