@elizaos/plugin-intiface
Version:
ElizaOS plugin for controlling intimate hardware devices via Intiface/Buttplug protocol
637 lines (633 loc) • 18.5 kB
JavaScript
// 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