Files
Zos/Skills/@be/be/menu/main-menu-patch.js

676 lines
21 KiB
JavaScript

"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
};