*/
private $realtime_providers;
/** @var Tools\ToolRegistry */
private $tool_registry;
/** @var string[] Settings screens, excluding Freemius account/contact pages. */
private $admin_page_hooks = array();
/**
* @return Plugin
*/
public static function instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
private function __construct() {
$this->config = new ConfigRepository();
$this->rate_limiter = new RateLimiter($this->config);
$this->session_store = new SessionGrantStore();
$this->audit = new AuditLogger();
$this->credentials = new RealtimeCredentialResolver($this->config);
$this->realtime_providers = array(
'openai' => new OpenAIRealtimeProvider($this, $this->credentials),
'azure' => new AzureRealtimeProvider($this, $this->credentials),
);
(new AdmissionBootstrap($this))->register();
add_action('rest_api_init', array($this, 'register_rest_routes'));
add_action('admin_menu', array($this, 'register_admin_menu'));
add_action('admin_init', array($this, 'register_settings'));
add_action('admin_enqueue_scripts', array($this, 'register_admin_assets'));
add_action('wp_enqueue_scripts', array($this, 'register_public_assets'));
add_action('wp_footer', array($this, 'render_sitewide_widget'), 5);
add_action('init', array($this, 'register_shortcode'));
add_action('init', array($this, 'register_block'));
add_action('wp_abilities_api_categories_init', array($this, 'register_ability_categories'));
add_action('wp_abilities_api_init', array($this, 'register_abilities'));
add_action('wp_connectors_init', array($this->credentials, 'register_azure_connector'));
$contracts = new Tools\AbilityRegistrar($this, $this->tool_registry());
add_filter('wp_ability_validate_input', array($contracts, 'validate_input'), 10, 3);
add_filter('wp_ability_validate_output', array($contracts, 'validate_output'), 10, 3);
add_action('wp_ability_invoked', array($contracts, 'invoked'), 10, 1);
add_action('admin_init', array($this, 'register_privacy_policy'));
add_action(SessionGrantStore::CLEANUP_HOOK, array($this->session_store, 'cleanup'));
add_action('admin_init', array($this, 'ensure_session_schema'));
add_filter('plugin_action_links_' . plugin_basename(EMBODIED_ASSISTANT_FILE), array($this, 'plugin_action_links'));
register_activation_hook(EMBODIED_ASSISTANT_FILE, array($this, 'activate'));
register_deactivation_hook(EMBODIED_ASSISTANT_FILE, array($this, 'deactivate'));
}
/**
* @return ConfigRepository
*/
public function operation_policy() {
return new OperationPolicy($this->config);
}
public function config() {
return $this->config;
}
/**
* @return RateLimiter
*/
public function rate_limiter() {
return $this->rate_limiter;
}
/**
* @return SessionGrantStore
*/
public function session_grants() {
return $this->session_store;
}
/**
* @return AuditLogger
*/
public function audit() {
return $this->audit;
}
/**
* @return RealtimeCredentialResolver
*/
public function realtime_credentials() {
return $this->credentials;
}
/**
* @param string $provider
* @return RealtimeProviderAdapter|null
*/
public function realtime_provider($provider) {
return isset($this->realtime_providers[$provider]) ? $this->realtime_providers[$provider] : null;
}
/**
* Resolve provider readiness without exposing credential values.
*
* @param string|null $provider
* @return array{ready: bool, provider: string, code: string, source: string, label: string}
*/
public function realtime_readiness($provider = null) {
$settings = $this->config->get();
$provider = is_string($provider) && '' !== $provider ? $provider : ($settings['realtime']['provider'] ?? 'openai');
$adapter = $this->realtime_provider($provider);
if (!$adapter) {
return array('ready' => false, 'provider' => $provider, 'code' => 'provider_unsupported', 'source' => 'none', 'label' => 'Provider is not supported.');
}
$result = $adapter->readiness($settings);
if (true === $result) {
$credential_status = $this->credentials->readiness($provider);
return array(
'ready' => true,
'provider' => $provider,
'code' => 'ready',
'source' => $credential_status['source'],
'label' => $credential_status['label'],
);
}
return array(
'ready' => false,
'provider' => $provider,
'code' => is_wp_error($result) ? $result->get_error_code() : 'provider_not_ready',
'source' => $this->credentials->source($provider),
'label' => is_wp_error($result) ? $result->get_error_message() : 'Provider is not ready.',
);
}
/**
* @return Tools\ToolRegistry
*/
public function tool_registry() {
if (!$this->tool_registry) {
$this->tool_registry = new Tools\ToolRegistry($this->config);
}
return $this->tool_registry;
}
/**
* Activation: create defaults; do not make remote calls.
*/
public function activate($network_wide = false) {
if ($network_wide) { wp_die('Embodied Assistant requires activation separately on each site; network activation is not supported.'); }
$this->session_store->install();
$this->config->ensure_defaults();
}
public function ensure_session_schema() {
if (current_user_can('activate_plugins') && SessionGrantStore::SCHEMA_VERSION !== (int) get_option('embodied_assistant_session_schema')) {
try { $this->session_store->install(); }
catch (\Throwable $e) { add_action('admin_notices', static function () { echo '
Embodied Assistant session storage is unavailable. Check database CREATE/ALTER permissions and InnoDB support.
'; }); }
}
}
/**
* Deactivation: close sessions and unschedule cleanup; retain configuration.
*/
public function deactivate() {
$this->session_store->deactivate();
}
/**
* Register the REST controllers.
*/
public function register_rest_routes() {
$namespace = 'embodied-assistant/v1';
$controllers = array(
new Rest\ConfigController($this),
new Rest\HealthController($this),
new Rest\RealtimeController($this),
);
$this->tool_registry = new Tools\ToolRegistry($this->config);
$executor = new Tools\SessionToolService($this, $this->tool_registry, array(
new Tools\Providers\WordPressTools($this),
new Tools\Providers\WooCommerceTools($this),
));
$controllers[] = new Rest\ToolsController($this, $executor);
foreach ($controllers as $controller) {
$controller->register_routes($namespace);
}
}
/**
* Admin menu + settings page.
*/
public function register_admin_menu() {
require_once __DIR__ . '/class-admin.php';
$this->admin_page_hooks[] = add_menu_page(
__('Sentify Embodied Assistant', 'sentify-embodied-assistant'),
__('Sentify Embodied Assistant', 'sentify-embodied-assistant'),
'manage_options',
'embodied-assistant',
array($this, 'render_admin_page'),
'dashicons-format-chat'
);
foreach (Admin::sections() as $section => $label) {
$this->admin_page_hooks[] = add_submenu_page(
Admin::PAGE,
$label . ' — ' . __('Sentify Embodied Assistant', 'sentify-embodied-assistant'),
$label,
'manage_options',
Admin::page_slug($section),
array($this, 'render_admin_page')
);
}
add_filter('submenu_file', array(Admin::class, 'highlight_submenu'), 10, 2);
}
/**
* Native Settings API registrations (sanitize + capability checks).
*/
public function register_settings() {
require_once __DIR__ . '/class-admin.php';
$admin = new Admin($this);
$admin->register();
}
/**
* Admin assets (settings page + block editor style).
*/
public function register_admin_assets($hook) {
if (!in_array($hook, $this->admin_page_hooks, true)) {
return;
}
$admin_css_path = EMBODIED_ASSISTANT_DIR . 'assets/admin/admin.css';
$admin_js_path = EMBODIED_ASSISTANT_DIR . 'assets/admin/admin.js';
$admin_css_ver = file_exists($admin_css_path) ? (string) filemtime($admin_css_path) : EMBODIED_ASSISTANT_VERSION;
$admin_js_ver = file_exists($admin_js_path) ? (string) filemtime($admin_js_path) : EMBODIED_ASSISTANT_VERSION;
wp_enqueue_style('wp-components');
wp_enqueue_style('embodied-assistant-admin', EMBODIED_ASSISTANT_URL . 'assets/admin/admin.css', array('wp-components'), $admin_css_ver);
wp_enqueue_script('embodied-assistant-admin', EMBODIED_ASSISTANT_URL . 'assets/admin/admin.js', array('wp-components', 'wp-element'), $admin_js_ver, true);
wp_localize_script(
'embodied-assistant-admin',
'eaAdmin',
array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('embodied_assistant_avatar_catalog'),
)
);
}
/**
* Enqueue globally only for the two automatic toggler display modes.
* Embedded modes enqueue lazily when their shortcode or block renders.
*/
public function register_public_assets() {
$settings = $this->config->get();
$mode = $settings['ui']['mode'] ?? 'toggler';
if (empty($settings['enabled']) || !in_array($mode, array('toggler', 'overlay_toggler'), true)) {
return;
}
Assets::register(array('mode' => 'default'));
}
/**
* Add one automatic mount for site-wide toggler modes.
*/
public function render_sitewide_widget() {
$settings = $this->config->get();
$mode = $settings['ui']['mode'] ?? 'toggler';
if (empty($settings['enabled']) || !in_array($mode, array('toggler', 'overlay_toggler'), true)) {
return;
}
echo Embed::sitewide_mount(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
}
/**
* Shortcode: [embodied_assistant label="Store Assistant"]
*/
public function register_shortcode() {
add_shortcode('embodied_assistant', array(__NAMESPACE__ . '\Embed', 'shortcode'));
}
/**
* Block registration (server-side render; see blocks/avatar/block.json).
*/
public function register_block() {
Embed::register_block();
}
/**
* WordPress 7.1 Abilities category registration.
*/
public function register_ability_categories() {
if (!$this->tool_registry) {
$this->tool_registry = new Tools\ToolRegistry($this->config);
}
$registrar = new Tools\AbilityRegistrar($this, $this->tool_registry);
$registrar->register_categories();
}
/**
* WordPress 7.1 Abilities registration from the canonical catalog.
*/
public function register_abilities() {
if (!$this->tool_registry) {
$this->tool_registry = new Tools\ToolRegistry($this->config);
}
$registrar = new Tools\AbilityRegistrar($this, $this->tool_registry);
$registrar->register();
}
/**
* Privacy policy text.
*/
public function register_privacy_policy() {
$content = sprintf(
/* translators: %s: plugin name. */
__('%1$s: when a visitor starts a voice conversation, their microphone audio and speech are transmitted to the configured voice AI provider for realtime transcription and response generation. Usage statistics store only daily session counts and elapsed seconds for up to 365 days, with no user identifiers, audio, page URLs or conversation content. Separate temporary records support session authorization and tool execution. See the privacy policy of the voice AI provider for how it processes speech.', 'sentify-embodied-assistant'),
'Embodied Assistant'
);
wp_add_privacy_policy_content('Embodied Assistant', wp_kses_post($content));
}
/**
* Settings link on the Plugins list row.
*
* @param string[] $links
* @return string[]
*/
public function plugin_action_links($links) {
$settings_url = esc_url(admin_url('admin.php?page=embodied-assistant'));
array_unshift($links, sprintf(
'%s',
$settings_url,
esc_html__('Settings', 'sentify-embodied-assistant')
));
return $links;
}
/**
* Settings page render (delegates to Admin).
*/
public function render_admin_page() {
$admin = new Admin($this);
$admin->render_page();
}
}