You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

306 lines
10 KiB

11 months ago
  1. const fs = require("fs");
  2. const path = require("path");
  3. const { safeJsonParse } = require("../http");
  4. const { isWithin, normalizePath } = require("../files");
  5. const { CollectorApi } = require("../collectorApi");
  6. const pluginsPath =
  7. process.env.NODE_ENV === "development"
  8. ? path.resolve(__dirname, "../../storage/plugins/agent-skills")
  9. : path.resolve(process.env.STORAGE_DIR, "plugins", "agent-skills");
  10. const sharedWebScraper = new CollectorApi();
  11. class ImportedPlugin {
  12. constructor(config) {
  13. this.config = config;
  14. this.handlerLocation = path.resolve(
  15. pluginsPath,
  16. this.config.hubId,
  17. "handler.js"
  18. );
  19. delete require.cache[require.resolve(this.handlerLocation)];
  20. this.handler = require(this.handlerLocation);
  21. this.name = config.hubId;
  22. this.startupConfig = {
  23. params: {},
  24. };
  25. }
  26. /**
  27. * Gets the imported plugin handler.
  28. * @param {string} hubId - The hub ID of the plugin.
  29. * @returns {ImportedPlugin} - The plugin handler.
  30. */
  31. static loadPluginByHubId(hubId) {
  32. const configLocation = path.resolve(
  33. pluginsPath,
  34. normalizePath(hubId),
  35. "plugin.json"
  36. );
  37. if (!this.isValidLocation(configLocation)) return;
  38. const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
  39. return new ImportedPlugin(config);
  40. }
  41. static isValidLocation(pathToValidate) {
  42. if (!isWithin(pluginsPath, pathToValidate)) return false;
  43. if (!fs.existsSync(pathToValidate)) return false;
  44. return true;
  45. }
  46. /**
  47. * Checks if the plugin folder exists and if it does not, creates the folder.
  48. */
  49. static checkPluginFolderExists() {
  50. const dir = path.resolve(pluginsPath);
  51. if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  52. return;
  53. }
  54. /**
  55. * Loads plugins from `plugins` folder in storage that are custom loaded and defined.
  56. * only loads plugins that are active: true.
  57. * @returns {string[]} - array of plugin names to be loaded later.
  58. */
  59. static activeImportedPlugins() {
  60. const plugins = [];
  61. this.checkPluginFolderExists();
  62. const folders = fs.readdirSync(path.resolve(pluginsPath));
  63. for (const folder of folders) {
  64. const configLocation = path.resolve(
  65. pluginsPath,
  66. normalizePath(folder),
  67. "plugin.json"
  68. );
  69. if (!this.isValidLocation(configLocation)) continue;
  70. const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
  71. if (config.active) plugins.push(`@@${config.hubId}`);
  72. }
  73. return plugins;
  74. }
  75. /**
  76. * Lists all imported plugins.
  77. * @returns {Array} - array of plugin configurations (JSON).
  78. */
  79. static listImportedPlugins() {
  80. const plugins = [];
  81. this.checkPluginFolderExists();
  82. if (!fs.existsSync(pluginsPath)) return plugins;
  83. const folders = fs.readdirSync(path.resolve(pluginsPath));
  84. for (const folder of folders) {
  85. const configLocation = path.resolve(
  86. pluginsPath,
  87. normalizePath(folder),
  88. "plugin.json"
  89. );
  90. if (!this.isValidLocation(configLocation)) continue;
  91. const config = safeJsonParse(fs.readFileSync(configLocation, "utf8"));
  92. plugins.push(config);
  93. }
  94. return plugins;
  95. }
  96. /**
  97. * Updates a plugin configuration.
  98. * @param {string} hubId - The hub ID of the plugin.
  99. * @param {object} config - The configuration to update.
  100. * @returns {object} - The updated configuration.
  101. */
  102. static updateImportedPlugin(hubId, config) {
  103. const configLocation = path.resolve(
  104. pluginsPath,
  105. normalizePath(hubId),
  106. "plugin.json"
  107. );
  108. if (!this.isValidLocation(configLocation)) return;
  109. const currentConfig = safeJsonParse(
  110. fs.readFileSync(configLocation, "utf8"),
  111. null
  112. );
  113. if (!currentConfig) return;
  114. const updatedConfig = { ...currentConfig, ...config };
  115. fs.writeFileSync(configLocation, JSON.stringify(updatedConfig, null, 2));
  116. return updatedConfig;
  117. }
  118. /**
  119. * Deletes a plugin. Removes the entire folder of the object.
  120. * @param {string} hubId - The hub ID of the plugin.
  121. * @returns {boolean} - True if the plugin was deleted, false otherwise.
  122. */
  123. static deletePlugin(hubId) {
  124. if (!hubId) throw new Error("No plugin hubID passed.");
  125. const pluginFolder = path.resolve(pluginsPath, normalizePath(hubId));
  126. if (!this.isValidLocation(pluginFolder)) return;
  127. fs.rmSync(pluginFolder, { recursive: true });
  128. return true;
  129. }
  130. /**
  131. /**
  132. * Validates if the handler.js file exists for the given plugin.
  133. * @param {string} hubId - The hub ID of the plugin.
  134. * @returns {boolean} - True if the handler.js file exists, false otherwise.
  135. */
  136. static validateImportedPluginHandler(hubId) {
  137. const handlerLocation = path.resolve(
  138. pluginsPath,
  139. normalizePath(hubId),
  140. "handler.js"
  141. );
  142. return this.isValidLocation(handlerLocation);
  143. }
  144. parseCallOptions() {
  145. const callOpts = {};
  146. if (!this.config.setup_args || typeof this.config.setup_args !== "object") {
  147. return callOpts;
  148. }
  149. for (const [param, definition] of Object.entries(this.config.setup_args)) {
  150. if (definition.required && !definition?.value) {
  151. console.log(
  152. `'${param}' required value for '${this.name}' plugin is missing. Plugin may not function or crash agent.`
  153. );
  154. continue;
  155. }
  156. callOpts[param] = definition.value || definition.default || null;
  157. }
  158. return callOpts;
  159. }
  160. plugin(runtimeArgs = {}) {
  161. const customFunctions = this.handler.runtime;
  162. return {
  163. runtimeArgs,
  164. name: this.name,
  165. config: this.config,
  166. setup(aibitat) {
  167. aibitat.function({
  168. super: aibitat,
  169. name: this.name,
  170. config: this.config,
  171. runtimeArgs: this.runtimeArgs,
  172. description: this.config.description,
  173. logger: aibitat?.handlerProps?.log || console.log, // Allows plugin to log to the console.
  174. introspect: aibitat?.introspect || console.log, // Allows plugin to display a "thought" the chat window UI.
  175. runtime: "docker",
  176. webScraper: sharedWebScraper,
  177. examples: this.config.examples ?? [],
  178. parameters: {
  179. $schema: "http://json-schema.org/draft-07/schema#",
  180. type: "object",
  181. properties: this.config.entrypoint.params ?? {},
  182. additionalProperties: false,
  183. },
  184. ...customFunctions,
  185. });
  186. },
  187. };
  188. }
  189. /**
  190. * Imports a community item from a URL.
  191. * The community item is a zip file that contains a plugin.json file and handler.js file.
  192. * This function will unzip the file and import the plugin into the agent-skills folder
  193. * based on the hubId found in the plugin.json file.
  194. * The zip file will be downloaded to the pluginsPath folder and then unzipped and finally deleted.
  195. * @param {string} url - The signed URL of the community item zip file.
  196. * @param {object} item - The community item.
  197. * @returns {Promise<object>} - The result of the import.
  198. */
  199. static async importCommunityItemFromUrl(url, item) {
  200. this.checkPluginFolderExists();
  201. const hubId = item.id;
  202. if (!hubId) return { success: false, error: "No hubId passed to import." };
  203. const zipFilePath = path.resolve(pluginsPath, `${item.id}.zip`);
  204. const pluginFile = item.manifest.files.find(
  205. (file) => file.name === "plugin.json"
  206. );
  207. if (!pluginFile)
  208. return {
  209. success: false,
  210. error: "No plugin.json file found in manifest.",
  211. };
  212. const pluginFolder = path.resolve(pluginsPath, normalizePath(hubId));
  213. if (fs.existsSync(pluginFolder))
  214. console.log(
  215. "ImportedPlugin.importCommunityItemFromUrl - plugin folder already exists - will overwrite"
  216. );
  217. try {
  218. const protocol = new URL(url).protocol.replace(":", "");
  219. const httpLib = protocol === "https" ? require("https") : require("http");
  220. const downloadZipFile = new Promise(async (resolve) => {
  221. try {
  222. console.log(
  223. "ImportedPlugin.importCommunityItemFromUrl - downloading asset from ",
  224. new URL(url).origin
  225. );
  226. const zipFile = fs.createWriteStream(zipFilePath);
  227. const request = httpLib.get(url, function (response) {
  228. response.pipe(zipFile);
  229. zipFile.on("finish", () => {
  230. console.log(
  231. "ImportedPlugin.importCommunityItemFromUrl - downloaded zip file"
  232. );
  233. resolve(true);
  234. });
  235. });
  236. request.on("error", (error) => {
  237. console.error(
  238. "ImportedPlugin.importCommunityItemFromUrl - error downloading zip file: ",
  239. error
  240. );
  241. resolve(false);
  242. });
  243. } catch (error) {
  244. console.error(
  245. "ImportedPlugin.importCommunityItemFromUrl - error downloading zip file: ",
  246. error
  247. );
  248. resolve(false);
  249. }
  250. });
  251. const success = await downloadZipFile;
  252. if (!success)
  253. return { success: false, error: "Failed to download zip file." };
  254. // Unzip the file to the plugin folder
  255. // Note: https://github.com/cthackers/adm-zip?tab=readme-ov-file#electron-original-fs
  256. const AdmZip = require("adm-zip");
  257. const zip = new AdmZip(zipFilePath);
  258. zip.extractAllTo(pluginFolder);
  259. // We want to make sure specific keys are set to the proper values for
  260. // plugin.json so we read and overwrite the file with the proper values.
  261. const pluginJsonPath = path.resolve(pluginFolder, "plugin.json");
  262. const pluginJson = safeJsonParse(fs.readFileSync(pluginJsonPath, "utf8"));
  263. pluginJson.active = false;
  264. pluginJson.hubId = hubId;
  265. fs.writeFileSync(pluginJsonPath, JSON.stringify(pluginJson, null, 2));
  266. console.log(
  267. `ImportedPlugin.importCommunityItemFromUrl - successfully imported plugin to agent-skills/${hubId}`
  268. );
  269. return { success: true, error: null };
  270. } catch (error) {
  271. console.error(
  272. "ImportedPlugin.importCommunityItemFromUrl - error: ",
  273. error
  274. );
  275. return { success: false, error: error.message };
  276. } finally {
  277. if (fs.existsSync(zipFilePath)) fs.unlinkSync(zipFilePath);
  278. }
  279. }
  280. }
  281. module.exports = ImportedPlugin;