Files
cinny/src/app/features/settings/plugins/PLUGIN_DEVELOPMENT.md

12 KiB

Plugin Development Guide

Plugin Structure

Each plugin must be a directory containing at least:

  • index.js - Main entry point
  • plugin-metadata.json - Plugin metadata

Example Directory Structure

my-plugin/
├── index.js
├── plugin-metadata.json
└── assets/
    └── icon.png

Plugin Metadata

The plugin-metadata.json file contains information about your plugin:

{
  "name": "My Cool Plugin",
  "version": "1.0.0",
  "description": "Does cool things",
  "author": "Your Name",
  "homepage": "https://github.com/yourusername/my-plugin",
  "thumbnail": "https://example.com/thumbnail.png",
  "tags": ["utility", "ui"]
}

Plugin Entry Point (index.js)

Your index.js must export a default plugin object with an onLoad function:

// index.js
module.exports = {
  name: "My Plugin",
  version: "1.0.0",
  dependencies: {
    "paarrot.utils": "^1.0.0"  // Optional: depend on other plugins
  },
  
  /**
   * Called when the plugin is loaded
   * @param {PluginContext} ctx - The plugin context
   */
  onLoad: async (ctx) => {
    ctx.log('Plugin loaded!');
    
    // Your plugin code here
  },
  
  /**
   * Called when the plugin is unloaded (optional)
   */
  onUnload: async () => {
    ctx.log('Plugin unloaded!');
    // Cleanup resources here
  },
  
  /**
   * Exports for other plugins to use (optional)
   */
  exports: {
    myFunction: () => { /* ... */ }
  }
};

Plugin Context API

The ctx object provided to your plugin contains:

🎯 Commands API

Register slash commands with argument parsing:

// Simple command
ctx.commands.register({
  name: "shrug",
  run: () => "¯\\_(ツ)_/¯"
});

// Command with args
ctx.commands.register({
  name: "echo",
  args: ["text"],
  run: ({ text }) => text
});

// Advanced command
ctx.commands.register({
  name: "greet",
  description: "Greet someone",
  args: ["name", "greeting"],
  run: ({ name, greeting }) => {
    return `${greeting || 'Hello'}, ${name || 'stranger'}!`;
  }
});

// Execute commands programmatically
ctx.commands.execute("shrug", {});

// Unregister a command
ctx.commands.unregister("mycommand");

💬 Messages API

Intercept and modify messages:

// Before send interceptor
ctx.messages.onBeforeSend((msg) => {
  if (msg.content === "brb") {
    msg.content = "be right back!";
  }
  
  // Add prefix
  msg.content = `[Bot] ${msg.content}`;
  
  // Access metadata
  msg.metadata = { processed: true };
});

// Receive interceptor
ctx.messages.onReceive((msg) => {
  if (msg.content.includes("@bot")) {
    ctx.log('Bot was mentioned!');
  }
});

MessageContext interface:

interface MessageContext {
  content: string;
  roomId: string;
  eventType: string;
  formatted?: string;
  metadata?: Record<string, any>;
}

🎨 UI API

Plugins also receive ctx.lucide — the full lucide-react icon set for custom renderers and UI.

const { Link, Search, X } = ctx.lucide;

ctx.ui.registerRenderer('message', (msg, defaultRenderer) => {
  return ctx.React.createElement(Link, { size: 16 });
});

Register custom renderers:

ctx.ui.registerRenderer("message", (msg, defaultRenderer) => {
  if (msg.type === "image") {
    return `<img src="${msg.url}" style="border-radius:12px"/>`;
  }
  // Fall back to default
  return defaultRenderer?.();
});

// Unregister renderer
ctx.ui.unregisterRenderer("message");

⚙️ Settings API

Define plugin settings with UI:

ctx.settings.define({
  theme: {
    type: "select",
    label: "Theme",
    description: "Choose your theme",
    options: [
      { value: "dark", label: "Dark" },
      { value: "light", label: "Light" }
    ],
    default: "dark"
  },
  spamFilter: {
    type: "boolean",
    label: "Spam Filter",
    description: "Enable spam filtering",
    default: true
  },
  prefix: {
    type: "string",
    label: "Command Prefix",
    default: "!"
  },
  maxMessages: {
    type: "number",
    label: "Max Messages",
    default: 100
  },
  accentColor: {
    type: "color",
    label: "Accent Color",
    default: "#6366f1"
  }
});

// Get/set settings
const theme = ctx.settings.get('theme');
ctx.settings.set('theme', 'light');

🪝 Matrix Events API

Listen to raw Matrix events:

ctx.matrix.on("m.room.message", (event) => {
  const content = event.getContent();
  ctx.log('Message received:', content.body);
});

ctx.matrix.on("Room.timeline", (event) => {
  if (event.getType() === 'm.room.member') {
    ctx.log('Member event:', event.getSender());
  }
});

// Remove listener
ctx.matrix.off("m.room.message", handler);

Common events:

  • "m.room.message" - Room messages
  • "m.room.member" - Membership changes
  • "Room.timeline" - Timeline events
  • "sync" - Sync state changes
  • "RoomState.events" - State events

⏱️ Timers API

Run background tasks:

// Set interval
const intervalId = ctx.timers.setInterval(() => {
  ctx.log('Running every 10 seconds');
  // Auto-cleanup bots, sync data, etc.
}, 10000);

// Set timeout
const timeoutId = ctx.timers.setTimeout(() => {
  ctx.log('Running once after 5 seconds');
}, 5000);

// Clear timers
ctx.timers.clearInterval(intervalId);
ctx.timers.clearTimeout(timeoutId);

// Timers are auto-cleaned on plugin unload

📣 Notifications API

Show notifications to users:

// Simple notification
ctx.notify("Plugin loaded!");

// Advanced notification
ctx.notify({
  title: "New Message",
  body: "You have a new message from Steve",
  type: "info",  // info, success, error, warning
  duration: 5000,
  actions: [
    {
      label: "View",
      action: () => {
        // Handle action
      }
    }
  ]
});

📝 Logging API

Per-plugin logging:

ctx.log('Info message', { data: 123 });
ctx.warn('Warning message');
ctx.error('Error message', error);

// Logs are visible in plugin logs panel
// Format: [Plugin pluginId] message

🔗 Require API

Use other plugins:

// Require another plugin's exports
const utils = ctx.require("paarrot.utils");
utils.formatDate(new Date());

// Check if plugin exists
try {
  const plugin = ctx.require("optional.plugin");
} catch (err) {
  ctx.warn('Optional plugin not available');
}

🔌 Matrix Client

Direct access to Matrix.js SDK:

const mx = ctx.matrixClient;

// Get user info
const userId = mx.getUserId();
const user = mx.getUser(userId);

// Get rooms
const rooms = mx.getRooms();
const room = mx.getRoom(roomId);

// Send messages
await mx.sendTextMessage(roomId, "Hello!");

// Full Matrix.js SDK available

⚛️ React

Access to React:

const { useState, useEffect } = ctx.React;

// Create custom UI components
const MyComponent = () => {
  const [count, setCount] = useState(0);
  return ctx.React.createElement('button', {
    onClick: () => setCount(count + 1)
  }, `Count: ${count}`);
};

Complete Example

See example-plugin/index.js for a full-featured example.

module.exports = {
  name: "Awesome Plugin",
  version: "1.0.0",
  
  onLoad: async (ctx) => {
    // Register commands
    ctx.commands.register({
      name: "shrug",
      run: () => "¯\\_(ツ)_/¯"
    });
    
    // Message interceptors
    ctx.messages.onBeforeSend((msg) => {
      if (msg.content === "brb") {
        msg.content = "be right back!";
      }
    });
    
    // Settings
    ctx.settings.define({
      enabled: { type: "boolean", default: true }
    });
    
    // Matrix events
    ctx.matrix.on("m.room.message", (event) => {
      ctx.log('Message:', event.getContent().body);
    });
    
    // Background task
    ctx.timers.setInterval(() => {
      ctx.log('Checking for updates...');
    }, 60000);
    
    ctx.notify("Plugin loaded!");
  },
  
  onUnload: async () => {
    // Cleanup happens automatically
  },
  
  exports: {
    version: "1.0.0"
  }
};

Best Practices

1. Error Handling

Always wrap your code in try-catch blocks:

onLoad: async (ctx) => {
  try {
    // Your code
  } catch (error) {
    ctx.error('Failed to load:', error);
  }
}

2. Cleanup Resources

Timers and Matrix handlers are auto-cleaned, but clean up other resources:

onUnload: async () => {
  // Close connections, save state, etc.
}

3. Performance

Don't block the main thread:

// Bad
for (let i = 0; i < 1000000; i++) { /* ... */ }

// Good
ctx.timers.setTimeout(() => {
  // Heavy work in background
}, 0);

4. Settings Best Practices

Provide defaults and descriptions:

ctx.settings.define({
  maxRetries: {
    type: "number",
    label: "Max Retries",
    description: "Number of retry attempts",
    default: 3
  }
});

5. Logging

Use ctx.log() instead of console.log():

ctx.log('Info');     // Shows in plugin logs panel
ctx.warn('Warning'); // Highlighted as warning
ctx.error('Error');  // Highlighted as error

Advanced Patterns

Plugin Dependencies

Require and use other plugins:

onLoad: async (ctx) => {
  try {
    const utils = ctx.require("paarrot.utils");
    const formatted = utils.formatDate(new Date());
    ctx.log('Using utils:', formatted);
  } catch (err) {
    ctx.warn('Utils plugin not available, using fallback');
  }
}

Conditional Features

Enable features based on settings:

const autoReply = ctx.settings.get('autoReply');
if (autoReply) {
  ctx.matrix.on("m.room.message", (event) => {
    // Auto-reply logic
  });
}

State Management

Store plugin state in settings:

// Save state
ctx.settings.set('lastRun', Date.now());
ctx.settings.set('messageCount', count);

// Load state
const lastRun = ctx.settings.get('lastRun');

Command Aliases

Create multiple commands for the same function:

const shrugFn = () => "¯\\_(ツ)_/¯";

ctx.commands.register({ name: "shrug", run: shrugFn });
ctx.commands.register({ name: "dunno", run: shrugFn });

Hot Reload Support

Make your plugin hot-reload friendly:

let messageHandler = null;

onLoad: async (ctx) => {
  messageHandler = (event) => { /* ... */ };
  ctx.matrix.on("m.room.message", messageHandler);
},

onUnload: async () => {
  if (messageHandler) {
    // Cleanup happens automatically but explicit is good
  }
}

Security Considerations

  1. Never store passwords or tokens in plaintext
  2. Validate all user input
  3. Don't execute arbitrary code from messages
  4. Use HTTPS for external requests
  5. Respect user privacy - don't log sensitive data

Packaging

To package your plugin:

  1. Create a ZIP file containing your plugin directory
  2. The ZIP should contain the plugin folder at the root:
    my-plugin.zip
    └── my-plugin/
        ├── index.js
        ├── plugin-metadata.json
        └── assets/
    
  3. Upload to a URL accessible by users
  4. Add your plugin to the Plugin Directory repository

Plugin Directory

To list your plugin in the marketplace:

  1. Fork the Paarrot Plugin Directory
  2. Add your plugin metadata to plugins/index.json
  3. Create a plugin JSON file in plugins/your-plugin.json
  4. Submit a pull request

Debugging

Browser Console

Check the browser console (F12) for plugin logs:

  • [PluginLoader] - Plugin loading messages
  • [Plugin yourplugin] - Your plugin's logs

Plugin Logs Panel

View per-plugin logs in Settings → Plugins → Logs (coming soon)

Common Issues

Plugin won't load:

  • Check index.js exists
  • Verify module.exports is correct
  • Look for JavaScript syntax errors

Command not working:

  • Ensure command is registered in onLoad
  • Check command name doesn't conflict
  • Verify run function returns a value

Settings not saving:

  • Use ctx.settings.set() not localStorage
  • Check setting key matches defined schema

Testing

Test your plugin locally:

  1. Copy plugin folder to %APPDATA%/paarrot/plugins/ (Windows) or ~/.config/Paarrot/plugins/ (Linux)
  2. Restart Paarrot
  3. Enable plugin in Settings → Plugins
  4. Check console for errors
  5. Test all features

Resources