UNPKG

matterbridge

Version:
975 lines (974 loc) • 53.9 kB
/** * This file contains the Plugins class. * * @file plugins.ts * @author Luca Liguori * @date 2024-07-14 * @version 1.1.1 * * Copyright 2024, 2025, 2026 Luca Liguori. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ // AnsiLogger module import { AnsiLogger, UNDERLINE, UNDERLINEOFF, BLUE, db, er, nf, nt, rs, wr } from './logger/export.js'; import { plg, typ } from './matterbridgeTypes.js'; export class PluginManager { _plugins = new Map(); nodeContext; matterbridge; log; constructor(matterbridge) { this.matterbridge = matterbridge; // eslint-disable-next-line @typescript-eslint/no-explicit-any this.nodeContext = matterbridge.nodeContext; this.log = new AnsiLogger({ logName: 'PluginManager', logTimestampFormat: 4 /* TimestampFormat.TIME_MILLIS */, logLevel: matterbridge.log.logLevel }); this.log.debug('Matterbridge plugin manager starting...'); } get length() { return this._plugins.size; } get size() { return this._plugins.size; } has(name) { return this._plugins.has(name); } get(name) { return this._plugins.get(name); } set(plugin) { this._plugins.set(plugin.name, plugin); return plugin; } clear() { this._plugins.clear(); } array() { return Array.from(this._plugins.values()); } [Symbol.iterator]() { return this._plugins.values(); } async forEach(callback) { if (this.size === 0) return; const tasks = Array.from(this._plugins.values()).map(async (plugin) => { try { await callback(plugin); } catch (error) { this.log.error(`Error processing forEach plugin ${plg}${plugin.name}${er}:`, error); // throw error; } }); await Promise.all(tasks); } set logLevel(logLevel) { this.log.logLevel = logLevel; } /** * Loads registered plugins from storage. * * This method retrieves an array of registered plugins from the storage and converts it * into a map where the plugin names are the keys and the plugin objects are the values. * * @returns {Promise<RegisteredPlugin[]>} A promise that resolves to an array of registered plugins. */ async loadFromStorage() { // Load the array from storage and convert it to a map const pluginsArray = await this.nodeContext.get('plugins', []); for (const plugin of pluginsArray) this._plugins.set(plugin.name, plugin); return pluginsArray; } /** * Loads registered plugins from storage. * * This method retrieves an array of registered plugins from the storage and converts it * into a map where the plugin names are the keys and the plugin objects are the values. * * @returns {Promise<RegisteredPlugin[]>} A promise that resolves to an array of registered plugins. */ async saveToStorage() { // Convert the map to an array const plugins = []; const pluginArrayFromMap = Array.from(this._plugins.values()); for (const plugin of pluginArrayFromMap) { plugins.push({ name: plugin.name, path: plugin.path, type: plugin.type, version: plugin.version, description: plugin.description, author: plugin.author, enabled: plugin.enabled, qrPairingCode: plugin.qrPairingCode, manualPairingCode: plugin.manualPairingCode, }); } await this.nodeContext.set('plugins', plugins); this.log.debug(`Saved ${BLUE}${plugins.length}${db} plugins to storage`); return plugins.length; } /** * Resolves the name of a plugin by loading and parsing its package.json file. * @param pluginPath - The path to the plugin or the path to the plugin's package.json file. * @returns The path to the resolved package.json file, or null if the package.json file is not found or does not contain a name. */ async resolve(pluginPath) { const { default: path } = await import('node:path'); const { promises } = await import('node:fs'); if (!pluginPath.endsWith('package.json')) pluginPath = path.join(pluginPath, 'package.json'); // Resolve the package.json of the plugin let packageJsonPath = path.resolve(pluginPath); this.log.debug(`Resolving plugin path ${plg}${packageJsonPath}${db}`); // Check if the package.json file exists try { await promises.access(packageJsonPath); } catch { this.log.debug(`Package.json not found at ${plg}${packageJsonPath}${db}`); packageJsonPath = path.join(this.matterbridge.globalModulesDirectory, pluginPath); this.log.debug(`Trying at ${plg}${packageJsonPath}${db}`); } try { // Load the package.json of the plugin const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8')); // Check for main issues if (!packageJson.name) { this.log.error(`Package.json name not found at ${packageJsonPath}`); return null; } if (!packageJson.type || packageJson.type !== 'module') { this.log.error(`Plugin at ${packageJsonPath} is not a module`); return null; } if (!packageJson.main) { this.log.error(`Plugin at ${packageJsonPath} has no main entrypoint in package.json`); return null; } // Check for @project-chip and @matter packages in dependencies and devDependencies const checkForProjectChipPackages = (dependencies) => { return Object.keys(dependencies).filter((pkg) => pkg.startsWith('@project-chip') || pkg.startsWith('@matter')); }; const projectChipDependencies = checkForProjectChipPackages(packageJson.dependencies || {}); if (projectChipDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipDependencies.join(', ')}" in plugin dependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const projectChipDevDependencies = checkForProjectChipPackages(packageJson.devDependencies || {}); if (projectChipDevDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipDevDependencies.join(', ')}" in plugin devDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const projectChipPeerDependencies = checkForProjectChipPackages(packageJson.peerDependencies || {}); if (projectChipPeerDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipPeerDependencies.join(', ')}" in plugin peerDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } // Check for matterbridge package in dependencies and devDependencies const checkForMatterbridgePackage = (dependencies) => { return Object.keys(dependencies).filter((pkg) => pkg === 'matterbridge'); }; const matterbridgeDependencies = checkForMatterbridgePackage(packageJson.dependencies || {}); if (matterbridgeDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin dependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const matterbridgeDevDependencies = checkForMatterbridgePackage(packageJson.devDependencies || {}); if (matterbridgeDevDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin devDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const matterbridgePeerDependencies = checkForMatterbridgePackage(packageJson.peerDependencies || {}); if (matterbridgePeerDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin peerDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } this.log.debug(`Resolved plugin path ${plg}${pluginPath}${db}: ${packageJsonPath}`); return packageJsonPath; } catch (err) { this.log.error(`Failed to resolve plugin path ${plg}${pluginPath}${er}: ${err}`); return null; } } /** * Get the author of a plugin from its package.json. * * @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin. * @returns {string} The author of the plugin, or 'Unknown author' if not found. */ getAuthor(packageJson) { if (packageJson.author && typeof packageJson.author === 'string') return packageJson.author; if (packageJson.author && typeof packageJson.author === 'object' && packageJson.author.name && typeof packageJson.author.name === 'string') return packageJson.author.name; return 'Unknown author'; } /** * Get the homepage of a plugin from its package.json. * * @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin. * @returns {string | undefined} The homepage of the plugin, or undefined if not found. */ getHomepage(packageJson) { if (packageJson.homepage && typeof packageJson.homepage === 'string') { return packageJson.homepage.replace('git+', '').replace('.git', ''); } if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') { return packageJson.repository.url.replace('git+', '').replace('.git', ''); } } /** * Get the help URL of a plugin from its package.json. * * @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin. * @returns {string | undefined} The URL to the help page or to the README file, or undefined if not found. */ getHelp(packageJson) { // If there's a help field that looks like a URL, return it. if (packageJson.help && typeof packageJson.help === 'string' && packageJson.help.startsWith('http')) { return packageJson.help; } // Derive a base URL from homepage or repository. let baseUrl; if (packageJson.homepage && typeof packageJson.homepage === 'string') { // Remove a trailing "/README.md" if present. baseUrl = packageJson.homepage .replace(/\/README\.md$/i, '') .replace('git+', '') .replace('.git', ''); } else if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') { baseUrl = packageJson.repository.url.replace('git+', '').replace('.git', ''); } return baseUrl ? `${baseUrl}/blob/main/README.md` : undefined; } /** * Get the changelog URL of a plugin from its package.json. * * @param {Record<string, string | number | Record<string, string | number | object>>} packageJson - The package.json object of the plugin. * @returns {string | undefined} The URL to the CHANGELOG file, or undefined if not found. */ getChangelog(packageJson) { // If there's a changelog field that looks like a URL, return it. if (packageJson.changelog && typeof packageJson.changelog === 'string' && packageJson.changelog.startsWith('http')) { return packageJson.changelog; } // Derive a base URL from homepage or repository. let baseUrl; if (packageJson.homepage && typeof packageJson.homepage === 'string') { baseUrl = packageJson.homepage .replace(/\/README\.md$/i, '') .replace('git+', '') .replace('.git', ''); } else if (packageJson.repository && typeof packageJson.repository === 'object' && packageJson.repository.url && typeof packageJson.repository.url === 'string') { baseUrl = packageJson.repository.url.replace('git+', '').replace('.git', ''); } return baseUrl ? `${baseUrl}/blob/main/CHANGELOG.md` : undefined; } /** * Get the first funding URL(s) of a plugin from its package.json. * * @param {Record<string, any>} packageJson - The package.json object of the plugin. * @returns {string | undefined} The first funding URLs, or undefined if not found. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any getFunding(packageJson) { const funding = packageJson.funding; if (!funding) return undefined; if (typeof funding === 'string' && !funding.startsWith('http')) return; if (typeof funding === 'string' && funding.startsWith('http')) return funding; // Normalize funding into an array. const fundingEntries = Array.isArray(funding) ? funding : [funding]; for (const entry of fundingEntries) { if (entry && typeof entry === 'string' && entry.startsWith('http')) { // If the funding entry is a string, assume it is a URL. return entry; } else if (entry && typeof entry === 'object' && typeof entry.url === 'string' && entry.url.startsWith('http')) { // If it's an object with a 'url' property, use that. return entry.url; } } } /** * Loads and parse the plugin package.json and returns it. * @param plugin - The plugin to load the package from. * @returns A Promise that resolves to the package.json object or undefined if the package.json could not be loaded. */ async parse(plugin) { const { promises } = await import('node:fs'); try { this.log.debug(`Parsing package.json of plugin ${plg}${plugin.name}${db}`); const packageJson = JSON.parse(await promises.readFile(plugin.path, 'utf8')); if (!packageJson.name) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no name in package.json`); if (!packageJson.version) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no version in package.json`); if (!packageJson.description) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no description in package.json`); if (!packageJson.author) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no author in package.json`); if (!packageJson.homepage) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no homepage in package.json`); if (!packageJson.type || packageJson.type !== 'module') this.log.error(`Plugin ${plg}${plugin.name}${er} is not a module`); if (!packageJson.main) this.log.error(`Plugin ${plg}${plugin.name}${er} has no main entrypoint in package.json`); plugin.name = packageJson.name || 'Unknown name'; plugin.version = packageJson.version || '1.0.0'; plugin.description = packageJson.description || 'Unknown description'; plugin.author = this.getAuthor(packageJson); plugin.homepage = this.getHomepage(packageJson); plugin.help = this.getHelp(packageJson); plugin.changelog = this.getChangelog(packageJson); plugin.funding = this.getFunding(packageJson); if (!plugin.path) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no path`); if (!plugin.type) this.log.warn(`Plugin ${plg}${plugin.name}${wr} has no type`); // Check for @project-chip and @matter packages in dependencies and devDependencies const checkForProjectChipPackages = (dependencies) => { return Object.keys(dependencies).filter((pkg) => pkg.startsWith('@project-chip') || pkg.startsWith('@matter')); }; const projectChipDependencies = checkForProjectChipPackages(packageJson.dependencies || {}); if (projectChipDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} dependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const projectChipDevDependencies = checkForProjectChipPackages(packageJson.devDependencies || {}); if (projectChipDevDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipDevDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} devDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const projectChipPeerDependencies = checkForProjectChipPackages(packageJson.peerDependencies || {}); if (projectChipPeerDependencies.length > 0) { this.log.error(`Found @project-chip packages "${projectChipPeerDependencies.join(', ')}" in plugin ${plg}${plugin.name}${er} peerDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } // Check for matterbridge package in dependencies and devDependencies const checkForMatterbridgePackage = (dependencies) => { return Object.keys(dependencies).filter((pkg) => pkg === 'matterbridge'); }; const matterbridgeDependencies = checkForMatterbridgePackage(packageJson.dependencies || {}); if (matterbridgeDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} dependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const matterbridgeDevDependencies = checkForMatterbridgePackage(packageJson.devDependencies || {}); if (matterbridgeDevDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} devDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } const matterbridgePeerDependencies = checkForMatterbridgePackage(packageJson.peerDependencies || {}); if (matterbridgePeerDependencies.length > 0) { this.log.error(`Found matterbridge package in the plugin ${plg}${plugin.name}${er} peerDependencies.`); this.log.error(`Please open an issue on the plugin repository to remove them.`); return null; } // await this.saveToStorage(); // No need to save the plugin to storage return packageJson; } catch (err) { this.log.error(`Failed to parse package.json of plugin ${plg}${plugin.name}${er}: ${err}`); plugin.error = true; return null; } } /** * Enables a plugin by its name or path. * * This method enables a plugin by setting its `enabled` property to `true` and saving the updated * plugin information to storage. It first checks if the plugin is already registered in the `_plugins` map. * If not, it attempts to resolve the plugin's `package.json` file to retrieve its name and enable it. * * @param {string} nameOrPath - The name or path of the plugin to enable. * @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the enabled plugin object, or null if the plugin could not be enabled. */ async enable(nameOrPath) { const { promises } = await import('node:fs'); if (!nameOrPath || nameOrPath === '') return null; if (this._plugins.has(nameOrPath)) { const plugin = this._plugins.get(nameOrPath); plugin.enabled = true; this.log.info(`Enabled plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } const packageJsonPath = await this.resolve(nameOrPath); if (!packageJsonPath) { this.log.error(`Failed to enable plugin ${plg}${nameOrPath}${er}: package.json not found`); return null; } try { const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8')); const plugin = this._plugins.get(packageJson.name); if (!plugin) { this.log.error(`Failed to enable plugin ${plg}${nameOrPath}${er}: plugin not registered`); return null; } plugin.enabled = true; this.log.info(`Enabled plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } catch (err) { this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`); return null; } } /** * Enables a plugin by its name or path. * * This method enables a plugin by setting its `enabled` property to `true` and saving the updated * plugin information to storage. It first checks if the plugin is already registered in the `_plugins` map. * If not, it attempts to resolve the plugin's `package.json` file to retrieve its name and enable it. * * @param {string} nameOrPath - The name or path of the plugin to enable. * @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the enabled plugin object, or null if the plugin could not be enabled. */ async disable(nameOrPath) { const { promises } = await import('node:fs'); if (!nameOrPath || nameOrPath === '') return null; if (this._plugins.has(nameOrPath)) { const plugin = this._plugins.get(nameOrPath); plugin.enabled = false; this.log.info(`Disabled plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } const packageJsonPath = await this.resolve(nameOrPath); if (!packageJsonPath) { this.log.error(`Failed to disable plugin ${plg}${nameOrPath}${er}: package.json not found`); return null; } try { const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8')); const plugin = this._plugins.get(packageJson.name); if (!plugin) { this.log.error(`Failed to disable plugin ${plg}${nameOrPath}${er}: plugin not registered`); return null; } plugin.enabled = false; this.log.info(`Disabled plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } catch (err) { this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`); return null; } } /** * Removes a plugin by its name or path. * * This method removes a plugin from the `_plugins` map and saves the updated plugin information to storage. * It first checks if the plugin is already registered in the `_plugins` map. If not, it attempts to resolve * the plugin's `package.json` file to retrieve its name and remove it. * * @param {string} nameOrPath - The name or path of the plugin to remove. * @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the removed plugin object, or null if the plugin could not be removed. */ async remove(nameOrPath) { const { promises } = await import('node:fs'); if (!nameOrPath || nameOrPath === '') return null; if (this._plugins.has(nameOrPath)) { const plugin = this._plugins.get(nameOrPath); this._plugins.delete(nameOrPath); this.log.info(`Removed plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } const packageJsonPath = await this.resolve(nameOrPath); if (!packageJsonPath) { this.log.error(`Failed to remove plugin ${plg}${nameOrPath}${er}: package.json not found`); return null; } try { const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8')); const plugin = this._plugins.get(packageJson.name); if (!plugin) { this.log.error(`Failed to remove plugin ${plg}${nameOrPath}${er}: plugin not registered`); return null; } this._plugins.delete(packageJson.name); this.log.info(`Removed plugin ${plg}${plugin.name}${nf}`); await this.saveToStorage(); return plugin; } catch (err) { this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err}`); return null; } } /** * Adds a plugin by its name or path. * * This method adds a plugin to the `_plugins` map and saves the updated plugin information to storage. * It first resolves the plugin's `package.json` file to retrieve its details. If the plugin is already * registered, it logs an info message and returns null. Otherwise, it registers the plugin, enables it, * and saves the updated plugin information to storage. * * @param {string} nameOrPath - The name or path of the plugin to add. * @returns {Promise<RegisteredPlugin | null>} A promise that resolves to the added plugin object, or null if the plugin could not be added. */ async add(nameOrPath) { const { promises } = await import('node:fs'); if (!nameOrPath || nameOrPath === '') return null; const packageJsonPath = await this.resolve(nameOrPath); if (!packageJsonPath) { this.log.error(`Failed to add plugin ${plg}${nameOrPath}${er}: package.json not found`); return null; } try { const packageJson = JSON.parse(await promises.readFile(packageJsonPath, 'utf8')); if (this._plugins.get(packageJson.name)) { this.log.info(`Plugin ${plg}${nameOrPath}${nf} already registered`); return null; } this._plugins.set(packageJson.name, { name: packageJson.name, enabled: true, path: packageJsonPath, type: 'AnyPlatform', version: packageJson.version, description: packageJson.description, author: this.getAuthor(packageJson), }); this.log.info(`Added plugin ${plg}${packageJson.name}${nf}`); await this.saveToStorage(); const plugin = this._plugins.get(packageJson.name); return plugin || null; } catch (err) { this.log.error(`Failed to parse package.json of plugin ${plg}${nameOrPath}${er}: ${err instanceof Error ? err.message : err}`); return null; } } /** * Installs a plugin by its name. * * This method first uninstalls any existing version of the plugin, then installs the plugin globally using npm. * It logs the installation process and retrieves the installed version of the plugin. * * @param {string} name - The name of the plugin to install. * @returns {Promise<string | undefined>} A promise that resolves to the installed version of the plugin, or undefined if the installation failed. */ async install(name) { const { exec } = await import('node:child_process'); await this.uninstall(name); this.log.info(`Installing plugin ${plg}${name}${nf}`); return new Promise((resolve) => { exec(`npm install -g ${name} --omit=dev`, (error, stdout, stderr) => { if (error) { this.log.error(`Failed to install plugin ${plg}${name}${er}: ${error}`); this.log.debug(`Failed to install plugin ${plg}${name}${db}: ${stderr}`); resolve(undefined); } else { this.log.info(`Installed plugin ${plg}${name}${nf}`); this.log.debug(`Installed plugin ${plg}${name}${db}: ${stdout}`); // Get the installed version // eslint-disable-next-line @typescript-eslint/no-unused-vars exec(`npm list -g ${name} --depth=0`, (listError, listStdout, listStderr) => { if (listError) { this.log.error(`List error: ${listError}`); resolve(undefined); } // Clean the output to get only the package name and version const lines = listStdout.split('\n'); const versionLine = lines.find((line) => line.includes(`${name}@`)); if (versionLine) { const version = versionLine.split('@')[1].trim(); this.log.info(`Installed plugin ${plg}${name}@${version}${nf}`); resolve(version); } else { resolve(undefined); } }); } }); }); } /** * Uninstalls a plugin by its name. * * This method uninstalls a globally installed plugin using npm. It logs the uninstallation process * and returns the name of the uninstalled plugin if successful, or undefined if the uninstallation failed. * * @param {string} name - The name of the plugin to uninstall. * @returns {Promise<string | undefined>} A promise that resolves to the name of the uninstalled plugin, or undefined if the uninstallation failed. */ async uninstall(name) { const { exec } = await import('node:child_process'); this.log.info(`Uninstalling plugin ${plg}${name}${nf}`); return new Promise((resolve) => { exec(`npm uninstall -g ${name}`, (error, stdout, stderr) => { if (error) { this.log.error(`Failed to uninstall plugin ${plg}${name}${er}: ${error}`); this.log.debug(`Failed to uninstall plugin ${plg}${name}${db}: ${stderr}`); resolve(undefined); } else { this.log.info(`Uninstalled plugin ${plg}${name}${nf}`); this.log.debug(`Uninstalled plugin ${plg}${name}${db}: ${stdout}`); resolve(name); } }); }); } /** * Loads a plugin and returns the corresponding MatterbridgePlatform instance. * @param plugin - The plugin to load. * @param start - Optional flag indicating whether to start the plugin after loading. Default is false. * @param message - Optional message to pass to the plugin when starting. * @returns A Promise that resolves to the loaded MatterbridgePlatform instance. * @throws An error if the plugin is not enabled, already loaded, or fails to load. */ async load(plugin, start = false, message = '', configure = false) { const { promises } = await import('node:fs'); const { default: path } = await import('node:path'); if (!plugin.enabled) { this.log.error(`Plugin ${plg}${plugin.name}${er} not enabled`); return undefined; } if (plugin.platform) { this.log.error(`Plugin ${plg}${plugin.name}${er} already loaded`); return plugin.platform; } this.log.info(`Loading plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`); try { // Load the package.json of the plugin const packageJson = JSON.parse(await promises.readFile(plugin.path, 'utf8')); // Resolve the main module path relative to package.json const pluginEntry = path.resolve(path.dirname(plugin.path), packageJson.main); // Dynamically import the plugin const { pathToFileURL } = await import('node:url'); const pluginUrl = pathToFileURL(pluginEntry); this.log.debug(`Importing plugin ${plg}${plugin.name}${db} from ${pluginUrl.href}`); const pluginInstance = await import(pluginUrl.href); this.log.debug(`Imported plugin ${plg}${plugin.name}${db} from ${pluginUrl.href}`); // Call the default export function of the plugin, passing this MatterBridge instance, the log and the config if (pluginInstance.default) { const config = await this.loadConfig(plugin); // Preset the plugin properties here in case the plugin throws an error during loading. In this case the user can change the config and restart the plugin. plugin.name = packageJson.name; plugin.description = packageJson.description ?? 'No description'; plugin.version = packageJson.version; plugin.author = this.getAuthor(packageJson); plugin.configJson = config; plugin.schemaJson = await this.loadSchema(plugin); config.name = plugin.name; config.version = packageJson.version; const log = new AnsiLogger({ logName: plugin.description ?? 'No description', logTimestampFormat: 4 /* TimestampFormat.TIME_MILLIS */, logLevel: config.debug ? "debug" /* LogLevel.DEBUG */ : this.matterbridge.log.logLevel }); const platform = pluginInstance.default(this.matterbridge, log, config); config.type = platform.type; platform.name = packageJson.name; platform.config = config; platform.version = packageJson.version; plugin.name = packageJson.name; plugin.description = packageJson.description ?? 'No description'; plugin.version = packageJson.version; plugin.author = this.getAuthor(packageJson); plugin.type = platform.type; plugin.platform = platform; plugin.loaded = true; plugin.registeredDevices = 0; plugin.addedDevices = 0; await this.saveToStorage(); // Save the plugin to storage this.log.notice(`Loaded plugin ${plg}${plugin.name}${nt} type ${typ}${platform.type}${nt} (entrypoint ${UNDERLINE}${pluginEntry}${UNDERLINEOFF})`); if (start) await this.start(plugin, message, false); if (configure) await this.configure(plugin); return platform; } else { this.log.error(`Plugin ${plg}${plugin.name}${er} does not provide a default export`); plugin.error = true; } } catch (err) { this.log.error(`Failed to load plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`); plugin.error = true; } return undefined; } /** * Starts a plugin. * * @param {RegisteredPlugin} plugin - The plugin to start. * @param {string} [message] - Optional message to pass to the plugin's onStart method. * @param {boolean} [configure] - Indicates whether to configure the plugin after starting (default false). * @returns {Promise<RegisteredPlugin | undefined>} A promise that resolves when the plugin is started successfully, or rejects with an error if starting the plugin fails. */ async start(plugin, message, configure = false) { if (!plugin.loaded) { this.log.error(`Plugin ${plg}${plugin.name}${er} not loaded`); return undefined; } if (!plugin.platform) { this.log.error(`Plugin ${plg}${plugin.name}${er} no platform found`); return undefined; } if (plugin.started) { this.log.error(`Plugin ${plg}${plugin.name}${er} already started`); return undefined; } this.log.info(`Starting plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`); try { await plugin.platform.onStart(message); this.log.notice(`Started plugin ${plg}${plugin.name}${nt} type ${typ}${plugin.type}${nt}`); plugin.started = true; await this.saveConfigFromPlugin(plugin); if (configure) await this.configure(plugin); return plugin; } catch (err) { plugin.error = true; this.log.error(`Failed to start plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`); } return undefined; } /** * Configures a plugin. * * @param {RegisteredPlugin} plugin - The plugin to configure. * @returns {Promise<void>} A promise that resolves when the plugin is configured successfully, or rejects with an error if configuration fails. */ async configure(plugin) { if (!plugin.loaded) { this.log.error(`Plugin ${plg}${plugin.name}${er} not loaded`); return undefined; } if (!plugin.started) { this.log.error(`Plugin ${plg}${plugin.name}${er} not started`); return undefined; } if (!plugin.platform) { this.log.error(`Plugin ${plg}${plugin.name}${er} no platform found`); return undefined; } if (plugin.configured) { this.log.debug(`Plugin ${plg}${plugin.name}${db} already configured`); return undefined; } this.log.info(`Configuring plugin ${plg}${plugin.name}${nf} type ${typ}${plugin.type}${nf}`); try { await plugin.platform.onConfigure(); this.log.notice(`Configured plugin ${plg}${plugin.name}${nt} type ${typ}${plugin.type}${nt}`); plugin.configured = true; return plugin; } catch (err) { plugin.error = true; this.log.error(`Failed to configure plugin ${plg}${plugin.name}${er}: ${err}`); } return undefined; } /** * Shuts down a plugin. * * This method shuts down a plugin by calling its `onShutdown` method and resetting its state. * It logs the shutdown process and optionally removes all devices associated with the plugin. * * @param {RegisteredPlugin} plugin - The plugin to shut down. * @param {string} [reason] - The reason for shutting down the plugin. * @param {boolean} [removeAllDevices=false] - Whether to remove all devices associated with the plugin. * @param {boolean} [force=false] - Whether to force the shutdown even if the plugin is not loaded or started. * @returns {Promise<RegisteredPlugin | undefined>} A promise that resolves to the shut down plugin object, or undefined if the shutdown failed. */ async shutdown(plugin, reason, removeAllDevices = false, force = false) { this.log.debug(`Shutting down plugin ${plg}${plugin.name}${db}`); if (!plugin.loaded) { this.log.debug(`Plugin ${plg}${plugin.name}${db} not loaded`); if (!force) return undefined; } if (!plugin.started) { this.log.debug(`Plugin ${plg}${plugin.name}${db} not started`); if (!force) return undefined; } if (!plugin.configured) { this.log.debug(`Plugin ${plg}${plugin.name}${db} not configured`); } if (!plugin.platform) { this.log.debug(`Plugin ${plg}${plugin.name}${db} no platform found`); return undefined; } this.log.info(`Shutting down plugin ${plg}${plugin.name}${nf}: ${reason}...`); try { await plugin.platform.onShutdown(reason); plugin.locked = undefined; plugin.error = undefined; plugin.loaded = undefined; plugin.started = undefined; plugin.configured = undefined; plugin.platform = undefined; if (removeAllDevices) { this.log.info(`Removing all endpoints for plugin ${plg}${plugin.name}${nf}: ${reason}...`); await this.matterbridge.removeAllBridgedEndpoints(plugin.name); } plugin.registeredDevices = undefined; plugin.addedDevices = undefined; this.log.notice(`Shutdown of plugin ${plg}${plugin.name}${nt} completed`); return plugin; } catch (err) { this.log.error(`Failed to shut down plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`); } return undefined; } /** * Loads the configuration for a plugin. * If the configuration file exists, it reads the file and returns the parsed JSON data. * If the configuration file does not exist, it creates a new file with default configuration and returns it. * If any error occurs during file access or creation, it logs an error and return un empty config. * * @param plugin - The plugin for which to load the configuration. * @returns A promise that resolves to the loaded or created configuration. */ async loadConfig(plugin) { const { default: path } = await import('node:path'); const { promises } = await import('node:fs'); const { shelly_config, somfytahoma_config, zigbee2mqtt_config } = await import('./defaultConfigSchema.js'); const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`); try { await promises.access(configFile); const data = await promises.readFile(configFile, 'utf8'); const config = JSON.parse(data); this.log.debug(`Loaded config file ${configFile} for plugin ${plg}${plugin.name}${db}.`); // this.log.debug(`Loaded config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, config); // The first time a plugin is added to the system, the config file is created with the plugin name and type "AnyPlatform". config.name = plugin.name; config.type = plugin.type; if (config.debug === undefined) config.debug = false; if (config.unregisterOnShutdown === undefined) config.unregisterOnShutdown = false; return config; } catch (err) { const nodeErr = err; if (nodeErr.code === 'ENOENT') { let config; if (plugin.name === 'matterbridge-zigbee2mqtt') config = zigbee2mqtt_config; else if (plugin.name === 'matterbridge-somfy-tahoma') config = somfytahoma_config; else if (plugin.name === 'matterbridge-shelly') config = shelly_config; else config = { name: plugin.name, type: plugin.type, debug: false, unregisterOnShutdown: false }; try { await promises.writeFile(configFile, JSON.stringify(config, null, 2), 'utf8'); this.log.debug(`Created config file ${configFile} for plugin ${plg}${plugin.name}${db}.`); // this.log.debug(`Created config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, config); return config; } catch (err) { this.log.error(`Error creating config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`); return config; } } else { this.log.error(`Error accessing config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err instanceof Error ? err.message : err}`); return { name: plugin.name, type: plugin.type, debug: false, unregisterOnShutdown: false }; } } } /** * Saves the configuration of a plugin to a file. * * This method saves the configuration of the specified plugin to a JSON file in the matterbridge directory. * If the plugin's configuration is not found, it logs an error and rejects the promise. If the configuration * is successfully saved, it logs a debug message. If an error occurs during the file write operation, it logs * the error and rejects the promise. * * @param {RegisteredPlugin} plugin - The plugin whose configuration is to be saved. * @returns {Promise<void>} A promise that resolves when the configuration is successfully saved, or rejects if an error occurs. * @throws {Error} If the plugin's configuration is not found. */ async saveConfigFromPlugin(plugin) { const { default: path } = await import('node:path'); const { promises } = await import('node:fs'); if (!plugin.platform?.config) { this.log.error(`Error saving config file for plugin ${plg}${plugin.name}${er}: config not found`); return Promise.reject(new Error(`Error saving config file for plugin ${plg}${plugin.name}${er}: config not found`)); } const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`); try { await promises.writeFile(configFile, JSON.stringify(plugin.platform.config, null, 2), 'utf8'); plugin.configJson = plugin.platform.config; this.log.debug(`Saved config file ${configFile} for plugin ${plg}${plugin.name}${db}`); // this.log.debug(`Saved config file ${configFile} for plugin ${plg}${plugin.name}${db}.\nConfig:${rs}\n`, plugin.platform.config); return Promise.resolve(); } catch (err) { this.log.error(`Error saving config file ${configFile} for plugin ${plg}${plugin.name}${er}: ${err}`); return Promise.reject(err); } } /** * Saves the configuration of a plugin from a JSON object to a file. * * This method saves the provided configuration of the specified plugin to a JSON file in the matterbridge directory. * It first checks if the configuration data is valid by ensuring it contains the correct name and type, and matches * the plugin's name. If the configuration data is invalid, it logs an error and returns. If the configuration is * successfully saved, it updates the plugin's `configJson` property and logs a debug message. If an error occurs * during the file write operation, it logs the error and returns. * * @param {RegisteredPlugin} plugin - The plugin whose configuration is to be saved. * @param {PlatformConfig} config - The configuration data to be saved. * @returns {Promise<void>} A promise that resolves when the configuration is successfully saved, or returns if an error occurs. */ async saveConfigFromJson(plugin, config) { const { default: path } = await import('node:path'); const { promises } = await import('node:fs'); if (!config.name || !config.type || config.name !== plugin.name) { this.log.error(`Error saving config file for plugin ${plg}${plugin.name}${er}. Wrong config data content:${rs}\n`, config); return; } const configFile = path.join(this.matterbridge.matterbridgeDirectory, `${plugin.name}.config.json`); try { await promises.writeFile(configFile, JSON.stringify(config, null, 2), 'utf8'); plugin.configJson = config; plugin.restartRequired = true; if (plugin.platform) { plugin.platform.config = config;