676 lines
21 KiB
JavaScript
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
|
|
};
|