UNPKG

@elizaos/plugin-intiface

Version:

ElizaOS plugin for controlling intimate hardware devices via Intiface/Buttplug protocol

637 lines (633 loc) 18.5 kB
// src/index.ts import { ButtplugClient, ButtplugNodeWebsocketClientConnector } from "buttplug"; // src/environment.ts import { z } from "zod"; var intifaceEnvSchema = z.object({ INTIFACE_URL: z.string().default("ws://localhost:12345"), INTIFACE_NAME: z.string().default("Eliza Intiface Client"), DEVICE_NAME: z.string().default("Lovense Nora") }).refine( (data) => { return data.INTIFACE_URL.startsWith("ws://") || data.INTIFACE_URL.startsWith("wss://"); }, { message: "INTIFACE_URL must be a valid WebSocket URL (ws:// or wss://)" } ); async function validateIntifaceConfig(runtime) { try { const config = { INTIFACE_URL: runtime.getSetting("INTIFACE_URL") || process.env.INTIFACE_URL, INTIFACE_NAME: runtime.getSetting("INTIFACE_NAME") || process.env.INTIFACE_NAME, DEVICE_NAME: runtime.getSetting("DEVICE_NAME") || process.env.DEVICE_NAME }; return intifaceEnvSchema.parse(config); } catch (error) { if (error instanceof z.ZodError) { const errorMessages = error.errors.map((err) => `${err.path.join(".")}: ${err.message}`).join("\n"); throw new Error( `Intiface configuration validation failed: ${errorMessages}` ); } throw error; } } // src/index.ts import { Service, ServiceType } from "@elizaos/core"; // src/utils.ts import { spawn } from "child_process"; import net from "net"; import path from "path"; import { fileURLToPath } from "url"; var __filename = fileURLToPath(import.meta.url); var __dirname = path.dirname(__filename); var intifaceProcess = null; async function isPortAvailable(port) { return new Promise((resolve) => { const server = net.createServer().once("error", () => resolve(false)).once("listening", () => { server.close(); resolve(true); }).listen(port); }); } async function startIntifaceEngine() { const configPath = path.join( __dirname, "../src/intiface-user-device-config.json" ); try { const child = spawn( path.join(__dirname, "../intiface-engine/intiface-engine"), [ "--websocket-port", "12345", "--use-bluetooth-le", "--server-name", "Eliza Intiface Server", "--use-device-websocket-server", "--user-device-config-file", configPath ], { detached: false, stdio: "ignore", windowsHide: true } ); child.unref(); intifaceProcess = child; await new Promise((resolve) => setTimeout(resolve, 5e3)); console.log("[utils] Intiface Engine started"); } catch (error) { throw new Error(`Failed to start Intiface Engine: ${error}`); } } async function cleanup() { if (intifaceProcess) { console.log("[utils] Shutting down Intiface Engine..."); try { intifaceProcess.kill("SIGTERM"); await new Promise((r) => setTimeout(r, 1e3)); if (intifaceProcess.killed === false) { try { const killCommand = process.platform === "win32" ? spawn("taskkill", ["/F", "/IM", "intiface-engine.exe"]) : spawn("pkill", ["intiface-engine"]); await new Promise((resolve) => { killCommand.on("close", resolve); }); } catch (killErr) { console.error( "[utils] Error force killing Intiface Engine:", killErr ); } } } catch (err) { console.error("[utils] Error during Intiface Engine shutdown:", err); } finally { intifaceProcess = null; } } } // src/index.ts var ServiceTypeExtended = { ...ServiceType, INTIFACE: "INTIFACE" }; var IntifaceService = class extends Service { static serviceType = ServiceTypeExtended.INTIFACE; client; connected = false; devices = /* @__PURE__ */ new Map(); vibrateQueue = []; isProcessingQueue = false; intifaceConfig = null; maxVibrationIntensity = 1; rampUpAndDown = false; rampSteps = 20; preferredDeviceName; get serviceType() { return ServiceTypeExtended.INTIFACE; } get capabilityDescription() { return "Provides control interface for Intiface/Buttplug compatible devices"; } // Required by abstract Service class but not used async start() { } async stop() { await this.disconnect(); await this.cleanup(); } constructor() { super(); this.client = new ButtplugClient("Temporary Name"); this.client.addListener("deviceadded", this.handleDeviceAdded.bind(this)); this.client.addListener( "deviceremoved", this.handleDeviceRemoved.bind(this) ); process.on("SIGINT", this.cleanup.bind(this)); process.on("SIGTERM", this.cleanup.bind(this)); process.on("exit", this.cleanup.bind(this)); } async cleanup() { try { if (this.connected) { await this.client.disconnect(); } await cleanup(); } catch (error) { console.error("[IntifaceService] Cleanup error:", error); } } getInstance() { return this; } async initialize(runtime) { this.intifaceConfig = await validateIntifaceConfig(runtime); this.preferredDeviceName = this.intifaceConfig.DEVICE_NAME; this.client = new ButtplugClient(this.intifaceConfig.INTIFACE_NAME); if (this.intifaceConfig.INTIFACE_URL) { await this.connect(); } } async connect() { if (this.connected || !this.intifaceConfig) return; const portAvailable = await isPortAvailable(12345); if (portAvailable) { try { await startIntifaceEngine(); } catch (error) { console.error("Failed to start Intiface Engine:", error); throw error; } } else { console.log("Port 12345 is in use, assuming Intiface is already running"); } let retries = 5; while (retries > 0) { try { const connector = new ButtplugNodeWebsocketClientConnector( this.intifaceConfig.INTIFACE_URL ); await this.client.connect(connector); this.connected = true; await this.scanAndGrabDevices(); return; } catch (error) { retries--; if (retries > 0) { console.log( `Connection attempt failed, retrying... (${retries} attempts left)` ); await new Promise((r) => setTimeout(r, 2e3)); } else { console.error( "Failed to connect to Intiface server after all retries:", error ); throw error; } } } } async scanAndGrabDevices() { await this.client.startScanning(); console.log("Scanning for devices..."); await new Promise((r) => setTimeout(r, 2e3)); for (const device of this.client.devices) { this.devices.set(device.name, device); console.log(`- ${device.name} (${device.index})`); } if (this.devices.size === 0) { console.log("No devices found"); } } async ensureDeviceAvailable() { if (!this.connected) { throw new Error("Not connected to Intiface server"); } if (this.devices.size === 0) { await this.scanAndGrabDevices(); } const devices = this.getDevices(); if (devices.length === 0) { throw new Error("No devices available"); } let targetDevice; if (this.preferredDeviceName) { targetDevice = this.devices.get(this.preferredDeviceName); if (!targetDevice) { console.warn( `Preferred device ${this.preferredDeviceName} not found, using first available device` ); targetDevice = devices[0]; } } else { targetDevice = devices[0]; } return targetDevice; } async disconnect() { if (!this.connected) return; await this.client.disconnect(); this.connected = false; this.devices.clear(); } handleDeviceAdded(device) { this.devices.set(device.name, device); console.log(`Device connected: ${device.name}`); } handleDeviceRemoved(device) { this.devices.delete(device.name); console.log(`Device disconnected: ${device.name}`); } getDevices() { return Array.from(this.devices.values()); } isConnected() { return this.connected; } async addToVibrateQueue(event) { this.vibrateQueue.push(event); if (!this.isProcessingQueue) { this.isProcessingQueue = true; await this.processVibrateQueue(); } } async processVibrateQueue() { while (this.vibrateQueue.length > 0) { const event = this.vibrateQueue[0]; await this.handleVibrate(event); this.vibrateQueue.shift(); } this.isProcessingQueue = false; } async handleVibrate(event) { const targetDevice = await this.ensureDeviceAvailable(); if (this.rampUpAndDown) { const steps = this.rampSteps; const rampLength = event.duration * 0.2 / steps; let startIntensity = 0; let endIntensity = event.strength; let stepIntensity = (endIntensity - startIntensity) / steps; for (let i = 0; i <= steps; i++) { if (targetDevice.vibrate) { await targetDevice.vibrate(startIntensity + stepIntensity * i); } await new Promise((r) => setTimeout(r, rampLength)); } await new Promise((r) => setTimeout(r, event.duration * 0.54)); startIntensity = event.strength; endIntensity = 0; stepIntensity = (endIntensity - startIntensity) / steps; for (let i = 0; i <= steps; i++) { if (targetDevice.vibrate) { await targetDevice.vibrate(startIntensity + stepIntensity * i); } await new Promise((r) => setTimeout(r, rampLength)); } } else { if (targetDevice.vibrate) { await targetDevice.vibrate(event.strength); } await new Promise((r) => setTimeout(r, event.duration)); } if (targetDevice.stop) { await targetDevice.stop(); } } async vibrate(strength, duration) { const targetDevice = await this.ensureDeviceAvailable(); await this.addToVibrateQueue({ strength, duration, deviceId: targetDevice.id }); } async getBatteryLevel() { const targetDevice = await this.ensureDeviceAvailable(); try { if (!targetDevice.battery) { throw new Error("Device does not support battery level"); } const battery = await targetDevice.battery(); console.log(`Battery level for ${targetDevice.name}: ${battery * 100}%`); return battery; } catch (err) { console.error("Error getting battery level:", err); throw err; } } async rotate(strength, duration) { const targetDevice = await this.ensureDeviceAvailable(); if (!targetDevice.rotateCmd) { throw new Error("Device does not support rotation"); } if (this.rampUpAndDown) { await this.rampedRotate(targetDevice, strength, duration); } else { if (targetDevice.rotate) { await targetDevice.rotate(strength); } await new Promise((r) => setTimeout(r, duration)); if (targetDevice.stop) { await targetDevice.stop(); } } } async rampedRotate(device, targetStrength, duration) { if (!device.rotate || !device.stop) { throw new Error("Device does not support rotation"); } const stepTime = duration * 0.2 / this.rampSteps; for (let i = 0; i <= this.rampSteps; i++) { const intensity = targetStrength / this.rampSteps * i; await device.rotate(intensity); await new Promise((r) => setTimeout(r, stepTime)); } await new Promise((r) => setTimeout(r, duration * 0.6)); for (let i = this.rampSteps; i >= 0; i--) { const intensity = targetStrength / this.rampSteps * i; await device.rotate(intensity); await new Promise((r) => setTimeout(r, stepTime)); } await device.stop(); } }; var vibrateAction = { name: "VIBRATE", similes: ["VIBRATE_TOY", "VIBRATE_DEVICE", "START_VIBRATION", "BUZZ"], description: "Control vibration intensity of connected devices", validate: async (runtime, _message) => { try { await validateIntifaceConfig(runtime); return true; } catch { return false; } }, handler: async (runtime, _message, _state, _options, callback) => { const service = runtime.getService( ServiceTypeExtended.INTIFACE ); if (!service) { throw new Error("Intiface service not available"); } const messageText = _message.content?.text || ""; const intensityMatch = messageText.match(/(\d+)\s*%/); const durationMatch = messageText.match( /(\d+)\s*(seconds?|ms|milliseconds?)/ ); const intensity = intensityMatch ? parseInt(intensityMatch[1]) / 100 : 0.5; const duration = durationMatch ? durationMatch[2].includes("ms") ? parseInt(durationMatch[1]) : parseInt(durationMatch[1]) * 1e3 : 2e3; await service.vibrate(intensity, duration); const response = { text: `Vibrating at ${intensity * 100}% intensity for ${duration}ms` }; if (callback) { callback(response); } return response; }, examples: [ [ { name: "{{user1}}", content: { text: "Vibrate the toy at 70% for 3 seconds" } }, { name: "{{agentName}}", content: { text: "Vibrating at 70% intensity for 3000ms", action: "VIBRATE", options: { intensity: 0.7, duration: 3e3 } } } ], [ { name: "{{user1}}", content: { text: "Start vibrating" } }, { name: "{{agentName}}", content: { text: "Vibrating at 50% intensity for 2000ms", action: "VIBRATE" } } ], [ { name: "{{user1}}", content: { text: "Make it buzz at max power for 5 seconds" } }, { name: "{{agentName}}", content: { text: "Vibrating at 100% intensity for 5000ms", action: "VIBRATE", options: { intensity: 1, duration: 5e3 } } } ], [ { name: "{{user1}}", content: { text: "Give me a gentle buzz" } }, { name: "{{agentName}}", content: { text: "Vibrating at 25% intensity for 2000ms", action: "VIBRATE", options: { intensity: 0.25, duration: 2e3 } } } ], [ { name: "{{user1}}", content: { text: "Vibrate for 10 seconds" } }, { name: "{{agentName}}", content: { text: "Vibrating at 50% intensity for 10000ms", action: "VIBRATE", options: { intensity: 0.5, duration: 1e4 } } } ] ] }; var rotateAction = { name: "ROTATE", similes: ["ROTATE_TOY", "ROTATE_DEVICE", "START_ROTATION", "SPIN"], description: "Control rotation intensity of connected devices", validate: async (runtime, _message) => { try { await validateIntifaceConfig(runtime); return true; } catch { return false; } }, handler: async (runtime, _message, _state, _options, callback) => { const service = runtime.getService( ServiceTypeExtended.INTIFACE ); if (!service || !service.rotate) { throw new Error("Rotation not supported"); } const messageText = _message.content?.text || ""; const intensityMatch = messageText.match(/(\d+)\s*%/); const durationMatch = messageText.match( /(\d+)\s*(seconds?|ms|milliseconds?)/ ); const intensity = intensityMatch ? parseInt(intensityMatch[1]) / 100 : 0.5; const duration = durationMatch ? durationMatch[2].includes("ms") ? parseInt(durationMatch[1]) : parseInt(durationMatch[1]) * 1e3 : 2e3; await service.rotate(intensity, duration); const response = { text: `Rotating at ${intensity * 100}% intensity for ${duration}ms` }; if (callback) { callback(response); } return response; }, examples: [ [ { name: "{{user1}}", content: { text: "Rotate the toy at 70% for 3 seconds" } }, { name: "{{agentName}}", content: { text: "Rotating at 70% intensity for 3000ms", action: "ROTATE", options: { intensity: 0.7, duration: 3e3 } } } ] ] }; var batteryAction = { name: "BATTERY", similes: ["CHECK_BATTERY", "BATTERY_LEVEL", "TOY_BATTERY", "DEVICE_BATTERY"], description: "Check battery level of connected devices", validate: async (runtime, _message) => { try { await validateIntifaceConfig(runtime); return true; } catch { return false; } }, handler: async (runtime, _message, _state, _options, callback) => { const service = runtime.getService( ServiceTypeExtended.INTIFACE ); if (!service || !service.getBatteryLevel) { throw new Error("Battery level check not supported"); } try { const batteryLevel = await service.getBatteryLevel(); const response = { text: `Device battery level is at ${Math.round(batteryLevel * 100)}%` }; if (callback) { callback(response); } return response; } catch { const response = { text: "Unable to get battery level. Device might not support this feature." }; if (callback) { callback(response); } return response; } }, examples: [ [ { name: "{{user1}}", content: { text: "What's the battery level?" } }, { name: "{{agentName}}", content: { text: "Device battery level is at 90%", action: "BATTERY" } } ], [ { name: "{{user1}}", content: { text: "Check toy battery" } }, { name: "{{agentName}}", content: { text: "Device battery level is at 75%", action: "BATTERY" } } ], [ { name: "{{user1}}", content: { text: "How much battery is left?" } }, { name: "{{agentName}}", content: { text: "Device battery level is at 45%", action: "BATTERY" } } ] ] }; var intifaceServiceInstance = new IntifaceService(); var intifacePlugin = { name: "intiface", description: "Controls intimate hardware devices", actions: [vibrateAction, rotateAction, batteryAction], evaluators: [], providers: [], services: [intifaceServiceInstance] }; var index_default = intifacePlugin; export { IntifaceService, index_default as default, intifacePlugin }; //# sourceMappingURL=index.js.map