Skip to content

Latest commit

 

History

History
752 lines (600 loc) · 19.8 KB

File metadata and controls

752 lines (600 loc) · 19.8 KB

Plugin System

The Cascade Chat plugin system lets you extend the client with external processes that communicate over JSON-RPC 2.0. Plugins subscribe to events, provide UI metadata, and interact with the IRC client.

Overview

Plugins are standalone executables that:

  • Communicate via JSON-RPC 2.0 over stdin/stdout
  • Subscribe to IRC and system events
  • Provide UI metadata (colors, badges, icons)
  • Can be written in any language
  • Are discovered automatically from PATH or plugin directory

Architecture

flowchart TB
    subgraph host["Cascade Chat (host process)"]
        bus([EventBus]) --> pm[Plugin Manager]
    end
    pm -->|"IPC · stdin/stdout<br/>JSON-RPC 2.0"| proc["Plugin Process<br/><small>separate executable</small>"]
Loading

Plugin Discovery

Plugins are discovered from two locations:

  1. Plugin Directory: ~/.cascade-chat/plugins/

    • Can be a flat directory with executables
    • Can contain subdirectories with executables
    • Executables should be named cascade-<plugin-name>
  2. System PATH: Any executable in PATH starting with cascade-

Discovery Process

  1. Scan plugin directory for executables
  2. Scan PATH for cascade-* executables
  3. Extract plugin name from filename (remove cascade- prefix)
  4. Validate executable exists and is executable
  5. Load plugin metadata from database (if available)

Plugin Protocol

Plugins communicate using JSON-RPC 2.0 over stdin/stdout. Each message is a single JSON object on one line (newline-delimited).

The exchange over a plugin's lifetime looks like this — requests (solid) expect a response, notifications (dashed) do not:

sequenceDiagram
    participant U as User
    participant C as Cascade (host)
    participant P as Plugin process

    Note over C,P: Startup
    C->>P: initialize (request)
    P-->>C: result: name, events, commands, metadata_types

    Note over C,P: Running
    C--)P: event — message.received, user.joined, …
    P--)C: ui_metadata.set — colors, badges

    U->>C: /remind 5m check the oven
    C->>P: command.invoke (request)
    P-->>C: result {} (or JSON-RPC error)
    P--)C: action — send_message
    C--)U: message delivered to channel

    Note over C,P: Unload
    C->>P: terminate process
Loading

JSON-RPC Messages

Request (from Cascade to Plugin)

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "version": "1.0",
    "capabilities": [],
    "config": {}
  }
}

Response (from Plugin to Cascade)

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "my-plugin",
    "version": "1.0.0",
    "description": "My plugin description",
    "author": "Plugin Author",
    "events": ["message.received", "user.joined"],
    "metadata_types": ["nickname_color"]
  }
}

Notification (no response expected)

{
  "jsonrpc": "2.0",
  "method": "event",
  "params": {
    "type": "message.received",
    "data": {
      "networkId": 1,
      "channel": "#general",
      "user": "Alice",
      "message": "Hello!"
    }
  }
}

Methods

initialize (Request)

Called when plugin is loaded. Plugin must respond with metadata.

Parameters:

  • version (string): Protocol version
  • capabilities ([]string): Server capabilities (typically empty)
  • config (object): User configuration for the plugin

Response:

  • name (string): Plugin name
  • version (string): Plugin version
  • description (string): Plugin description
  • author (string): Plugin author
  • events ([]string): Event types plugin subscribes to (use ["*"] for all)
  • metadata_types ([]string): Types of UI metadata plugin provides
  • config_schema (object): JSON Schema for plugin configuration (optional)

Example:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "nickname-colors",
    "version": "1.0.0",
    "description": "Assigns consistent colors to nicknames",
    "author": "Cascade Chat",
    "events": ["*"],
    "metadata_types": ["nickname_color"]
  }
}

event (Notification)

Sent to plugin when subscribed events occur.

Parameters:

Event notifications are best-effort. Cascade hands them to a bounded writer queue so a plugin that stops reading stdin cannot stall IRC socket processing. If that queue fills, Cascade drops notifications for that plugin and writes a warning to the application log. Plugins should continuously drain stdin and must not treat the event stream as a durable audit log.

Replaceable snapshot events may also be coalesced before plugin delivery. In particular, user.meta contains a complete current value and is last-write-wins per network and nickname during a burst. Process the snapshot as state; do not depend on receiving every intermediate value.

Event notifications use newline-delimited JSON and are bounded to 48 KiB per frame. When a large batched event contains a top-level updates array, Cascade splits plugin delivery into multiple frames. Each frame includes updates, batch_index (1-based), and batch_total; process every chunk independently. The frontend still receives the event as one logical batch.

Example:

{
  "jsonrpc": "2.0",
  "method": "event",
  "params": {
    "type": "message.received",
    "data": {
      "networkId": 1,
      "channel": "#general",
      "user": "Alice",
      "message": "Hello!",
      "timestamp": 1234567890
    }
  }
}

Slash Commands

Plugins can register slash commands that users invoke directly in the chat input (e.g. /remind, /weather).

Declaring commands in initialize

Return a commands array in the initialize result. Each entry is a CommandSpecWire object:

Field Type Description
name string Primary command name (without the / prefix)
aliases []string Optional alternative names for the same command
usage string Short usage hint shown in help (e.g. "remind <duration> <text>")
description string One-line description shown in the command list and help dialog

Example initialize result with commands:

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "name": "remind-plugin",
    "version": "1.0.0",
    "description": "Reminder plugin",
    "author": "Me",
    "events": [],
    "metadata_types": [],
    "commands": [
      {
        "name": "remind",
        "aliases": ["reminder"],
        "usage": "remind <duration> <text>",
        "description": "Set a reminder that fires after a duration"
      }
    ]
  }
}

command.invoke (Request from host to plugin)

When a user runs a plugin command, Cascade sends a command.invoke request to the owning plugin:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "command.invoke",
  "params": {
    "command": "remind",
    "args": ["5m", "check", "the", "oven"],
    "networkId": 1,
    "channel": "#general"
  }
}

Params:

  • command (string): The command name as typed (canonical name, not an alias).
  • args ([]string): Tokenised arguments after the command name.
  • networkId (int64): ID of the network the command was sent from.
  • channel (string): Channel or query window where the command was typed.

The plugin must send a standard JSON-RPC result to signal success. To report a failure, return a JSON-RPC error object. The error message is surfaced to the user.

{
  "jsonrpc": "2.0",
  "id": 2,
  "result": {}
}

Conflict policy

  • Built-in commands take precedence. A plugin command whose name or alias matches a built-in is silently ignored; the collision is logged as a warning.
  • First-registration-wins between plugins. If two plugins register the same name or alias, the second registration is ignored and the collision is logged.
  • Commands are unregistered automatically when a plugin is unloaded or disabled.

Notifications from Plugin

ui_metadata.set (Notification)

Plugin sends this to set UI metadata (colors, badges, etc.).

Parameters:

  • type (string): Metadata type (e.g., "nickname_color")
  • key (string): Metadata key (e.g., "nickname:Alice")
  • value (interface{}): Metadata value (e.g., "#FF6B6B")
  • network_id (int64, optional): Network-specific metadata
  • channel (string, optional): Channel-specific metadata
  • priority (int, optional): Priority (higher = more important, default: 0)

Example:

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set",
  "params": {
    "type": "nickname_color",
    "key": "nickname:Alice",
    "value": "#FF6B6B",
    "network_id": 1,
    "priority": 0
  }
}

ui_metadata.set_batch (Notification)

Stores multiple metadata values in one registry transaction and publishes one coalesced update to the frontend. Use this for snapshots such as a completed channel roster. The manager also coalesces bursts of legacy ui_metadata.set notifications, so one-at-a-time plugins cannot saturate the shared event bus. The flush occurs after the burst has been quiet for the debounce interval, and repeated writes with the same network, channel, type, and key use the latest value. Consumers of metadata.updated receive either one update directly or a top-level updates array, which may be split into bounded plugin event frames.

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set_batch",
  "params": {
    "updates": [
      {
        "type": "nickname_color",
        "key": "nickname:alice",
        "value": "#4ECDC4",
        "network_id": 1
      },
      {
        "type": "nickname_color",
        "key": "nickname:bob",
        "value": "#F8B739",
        "network_id": 1
      }
    ]
  }
}

action (Notification)

Plugins send action notifications to ask Cascade to perform side-effects on their behalf, such as sending an IRC message.

{
  "jsonrpc": "2.0",
  "method": "action",
  "params": {
    "type": "send_message",
    "data": {
      "server": "irc.libera.chat:6667",
      "target": "#general",
      "message": "Reminder: check the oven"
    }
  }
}

Params:

  • type (string): Action type. The only currently supported value is "send_message".
  • data (object): Action-specific payload.

send_message data fields:

  • server (string): Server address of the network (as host:port).
  • target (string): Channel or nickname to send the message to.
  • message (string): Text to send.

Queue and overflow: Actions are delivered through a bounded in-memory queue (capacity 100). If the queue is full when an action arrives, for example because the app is processing a burst of actions, the action is dropped and a warning is logged. Plugins should not rely on delivery guarantees during high load.

Plugin Lifecycle

  1. Discovery: Plugin discovered during startup or when enabled
  2. Validation: Executable validated (exists, is executable)
  3. Initialization: Plugin process started, initialize request sent
  4. Active: Plugin receives events and can send metadata
  5. Unload: Plugin process terminated, metadata cleared

plugin-lifecycle frontend event

Whenever a plugin is loaded or unloaded, Cascade emits a plugin-lifecycle event to the frontend. The frontend uses it to refetch the current command list (e.g. to update autocomplete). No action is required from the plugin itself.

Loading a Plugin

// Plugin manager automatically loads enabled plugins on startup
pm.DiscoverAndLoad()

// Or manually load a plugin
pm.LoadPlugin(pluginInfo)

Unloading a Plugin

// Unload and disable
pm.SetPluginEnabled("plugin-name", false, storage)

// Or just unload (keeps enabled state)
pm.UnloadPlugin("plugin-name")

UI Metadata System

Plugins can provide UI metadata that enhances the display of IRC information.

Metadata Types

  • nickname_color: Color for nickname display (hex color string)
  • nickname_badge: Badge/icon for nickname (future)
  • nickname_icon: Icon for nickname (future)

Metadata Scoping

Metadata can be scoped at three levels:

  1. Global: Applies everywhere

    • No network_id or channel specified
  2. Network: Applies to a specific network

    • network_id specified, no channel
  3. Channel: Applies to a specific channel on a network

    • Both network_id and channel specified

Metadata Priority

When multiple plugins set metadata for the same key:

  • Higher priority wins
  • On tie, newer metadata wins

Setting Metadata

From a plugin:

{
  "jsonrpc": "2.0",
  "method": "ui_metadata.set",
  "params": {
    "type": "nickname_color",
    "key": "nickname:Alice",
    "value": "#FF6B6B",
    "network_id": 1,
    "priority": 0
  }
}

Retrieving Metadata

From the application:

// Get single metadata value
color := pm.GetNicknameColor(networkID, "Alice")

// Get batch of metadata values
colors := pm.GetNicknameColorsBatch(networkID, []string{"Alice", "Bob"})

Writing a Plugin

Basic Structure

A plugin must:

  1. Read JSON-RPC messages from stdin (line-delimited)
  2. Write JSON-RPC messages to stdout (line-delimited)
  3. Handle initialize request
  4. Handle event notifications
  5. Send ui_metadata.set notifications as needed

Example Plugin (Go)

package main

import (
    "bufio"
    "encoding/json"
    "fmt"
    "os"
)

type Request struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id,omitempty"`
    Method  string      `json:"method"`
    Params  interface{} `json:"params,omitempty"`
}

type Response struct {
    JSONRPC string      `json:"jsonrpc"`
    ID      interface{} `json:"id,omitempty"`
    Result  interface{} `json:"result,omitempty"`
    Error   *RPCError   `json:"error,omitempty"`
}

type RPCError struct {
    Code    int    `json:"code"`
    Message string `json:"message"`
}

type EventParams struct {
    Type string                 `json:"type"`
    Data map[string]interface{} `json:"data"`
}

func sendResponse(id interface{}, result interface{}) {
    resp := Response{
        JSONRPC: "2.0",
        ID:      id,
        Result:  result,
    }
    data, _ := json.Marshal(resp)
    os.Stdout.Write(append(data, '\n'))
}

func sendNotification(method string, params interface{}) {
    req := Request{
        JSONRPC: "2.0",
        Method:  method,
        Params:  params,
    }
    data, _ := json.Marshal(req)
    os.Stdout.Write(append(data, '\n'))
}

func main() {
    reader := bufio.NewReader(os.Stdin)
    
    for {
        line, err := reader.ReadString('\n')
        if err != nil {
            break
        }
        
        var req Request
        if err := json.Unmarshal([]byte(line), &req); err != nil {
            continue
        }
        
        switch req.Method {
        case "initialize":
            sendResponse(req.ID, map[string]interface{}{
                "name":          "my-plugin",
                "version":       "1.0.0",
                "description":   "My plugin",
                "author":        "Me",
                "events":        []string{"message.received"},
                "metadata_types": []string{"nickname_color"},
            })
            
        case "event":
            paramsBytes, _ := json.Marshal(req.Params)
            var eventParams EventParams
            json.Unmarshal(paramsBytes, &eventParams)
            
            if eventParams.Type == "message.received" {
                // Process message and set metadata
                if user, ok := eventParams.Data["user"].(string); ok {
                    sendNotification("ui_metadata.set", map[string]interface{}{
                        "type":  "nickname_color",
                        "key":   "nickname:" + user,
                        "value": "#FF6B6B",
                    })
                }
            }
        }
    }
}

Example Plugin (Python)

import json
import sys

def send_response(id, result):
    resp = {
        "jsonrpc": "2.0",
        "id": id,
        "result": result
    }
    print(json.dumps(resp), flush=True)

def send_notification(method, params):
    req = {
        "jsonrpc": "2.0",
        "method": method,
        "params": params
    }
    print(json.dumps(req), flush=True)

for line in sys.stdin:
    line = line.strip()
    if not line:
        continue
    
    req = json.loads(line)
    
    if req.get("method") == "initialize":
        send_response(req.get("id"), {
            "name": "my-plugin",
            "version": "1.0.0",
            "description": "My plugin",
            "author": "Me",
            "events": ["message.received"],
            "metadata_types": ["nickname_color"]
        })
    
    elif req.get("method") == "event":
        params = req.get("params", {})
        if params.get("type") == "message.received":
            data = params.get("data", {})
            user = data.get("user")
            if user:
                send_notification("ui_metadata.set", {
                    "type": "nickname_color",
                    "key": f"nickname:{user}",
                    "value": "#FF6B6B"
                })

Plugin Configuration

Plugins can define a configuration schema using JSON Schema. The schema is stored in the database and used to generate configuration UI.

Config Schema Example

{
  "type": "object",
  "properties": {
    "color_palette": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "default": ["#FF6B6B", "#4ECDC4", "#45B7D1"]
    },
    "enable_badges": {
      "type": "boolean",
      "default": true
    }
  }
}

Accessing Configuration

Configuration is passed to the plugin during initialization:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "initialize",
  "params": {
    "version": "1.0",
    "capabilities": [],
    "config": {
      "color_palette": ["#FF6B6B", "#4ECDC4"],
      "enable_badges": true
    }
  }
}

Plugin Management

Enabling/Disabling Plugins

Plugins can be enabled or disabled through the UI or API. The state is persisted in the database.

// Enable a plugin
pm.SetPluginEnabled("plugin-name", true, storage)

// Disable a plugin
pm.SetPluginEnabled("plugin-name", false, storage)

Listing Plugins

plugins := pm.ListPlugins()
// Returns PluginInfo for all discovered plugins (loaded and unloaded)

Best Practices

  1. Error Handling: Always handle errors gracefully, don't crash
  2. Logging: Use stderr for debug logs (they're captured by Cascade)
  3. Buffering: Flush stdout after each message (or use line buffering)
  4. Input Draining: Continuously read stdin; event notifications are dropped if the bounded writer queue fills
  5. Event Filtering: Subscribe only to events you need (avoid ["*"] if possible)
  6. Input Limits: Configure line readers for at least 48 KiB JSON-RPC frames
  7. Metadata Keys: Use consistent key formats (e.g., "nickname:Alice")
  8. Configuration: Provide sensible defaults in config schema
  9. Versioning: Include version in plugin metadata
  10. Graceful Shutdown: Exit cleanly when stdin closes (EOF)

Troubleshooting

Plugin Not Loading

  • Check executable permissions: chmod +x cascade-my-plugin
  • Verify plugin is in PATH or plugin directory
  • Check plugin implements initialize correctly
  • Review stderr output for errors

Plugin Not Receiving Events

  • Verify plugin subscribes to event type in initialize response
  • Check event type spelling matches exactly
  • Review plugin manager logs

Metadata Not Appearing

  • Verify ui_metadata.set notification format
  • Check metadata key format matches retrieval pattern
  • Verify network_id/channel scope matches query
  • Check metadata priority isn't being overridden

Plugin Crashes

  • Review stderr output
  • Ensure plugin handles all expected event types
  • Check for JSON parsing errors
  • Verify plugin exits cleanly on EOF

Future Enhancements

  • Plugin actions (send message, join channel, etc.)
  • Plugin UI components
  • Plugin-to-plugin communication
  • Plugin sandboxing/security
  • Plugin marketplace
  • Hot reloading
  • Plugin dependencies