Skip to content

Signal: Add REST API support alongside JSON-RPC for containerized deployments #10240

Description

@Hua688

Summary

The current Signal channel implementation works well with directly-installed signal-cli using JSON-RPC and SSE endpoints (/api/v1/rpc and /api/v1/events). However, these endpoints do not exist in the official bbernhard/signal-cli-rest-api Docker image, which only provides REST + WebSocket endpoints.

We should add support for the REST API mode while keeping the existing JSON-RPC mode working, giving users flexibility to choose based on their deployment scenario.

Current situation:

  1. JSON-RPC mode works - For users who install signal-cli directly on the host
  2. REST API mode doesn't work - For users who want containerized deployments with bbernhard/signal-cli-rest-api
  3. No configuration option - Users cannot choose which API mode to use

Current problems with containerized deployments:

  1. Non-existent endpoints: Gateway tries to connect to /api/v1/events (SSE) and /api/v1/rpc (JSON-RPC), but the REST API image only exposes /v1/receive/{account} (WebSocket) and /v2/send (REST)

  2. Connection failures: Logs show repeated 404 errors:

    [signal] SSE stream error: Error: Signal SSE failed (404 Not Found)
    [signal] SSE connection lost, reconnecting in 10s...
    
  3. Workarounds required: Users must choose between:

    • Running signal-cli directly on the host (requires Java, complex setup)
    • Using a custom wrapper/bridge service (maintenance burden)
    • Modifying the REST API Docker image (non-standard deployment)
  4. Missing "Note to Self" support: The current implementation doesn't properly handle syncMessage.sentMessage (self-messages), which is needed for the "Note to Self" workflow where users talk to the bot via their own Signal account

Proposed solution

Add REST API support alongside existing JSON-RPC, with automatic detection and manual configuration:

Configuration Option

Add apiMode setting to Signal channel config:

{
  "channels": {
    "signal": {
      "httpUrl": "http://localhost:8080",
      "account": "+1234567890",
      "apiMode": "auto"  // "auto" (default), "jsonrpc", or "rest"
    }
  }
}

API Mode Options:

  • "auto" (default) - Auto-detect by probing available endpoints (try REST first, fall back to JSON-RPC)
  • "jsonrpc" - Force legacy JSON-RPC + SSE mode (for direct signal-cli installations)
  • "rest" - Force REST + WebSocket mode (for bbernhard/signal-cli-rest-api containers)

Dual Implementation

Support both API modes in parallel:

JSON-RPC Mode (existing, keep as-is)

Receiving messages:

  • GET /api/v1/events (SSE)

Sending messages:

  • POST /api/v1/rpc with method send

Typing indicators:

  • POST /api/v1/rpc with method sendTyping

Read receipts:

  • POST /api/v1/rpc with method sendReceipt

Health checks:

  • GET /api/v1/check

REST Mode (new)

Receiving messages:

  • GET /v1/receive/{account} (WebSocket)

Sending messages:

  • POST /v2/send (REST)

Typing indicators:

  • PUT /v1/typing-indicator/{phone} (REST)

Read receipts:

  • POST /v1/receipts/{phone} (REST)

Health checks:

  • GET /v1/about

Event Format Handling

The event handler should support both formats:

SSE format (JSON-RPC mode):

{
  "event": "receive",
  "data": "{\"envelope\":{\"sourceNumber\":\"+1234567890\",\"dataMessage\":{\"message\":\"Hello\"}}}"
}

WebSocket format (REST mode):

{
  "envelope": {
    "sourceNumber": "+1234567890",
    "dataMessage": {
      "message": "Hello"
    }
  }
}

Critical syncMessage Handling (both modes)

Both modes should properly handle syncMessage to support "Note to Self":

// Extract dataMessage from syncMessage.sentMessage if present
let dataMessage = envelope.dataMessage ?? envelope.editMessage?.dataMessage;
let isFromSync = false;

if (envelope.syncMessage && !dataMessage) {
  const sentMessage = envelope.syncMessage.sentMessage;
  if (sentMessage) {
    dataMessage = sentMessage;
    isFromSync = true;
  }
}

// Self-message filter: block bot echoes, allow Note to Self
if (sender.e164 === normalizeE164(account)) {
  if (!isFromSync) {
    return; // dataMessage from self = bot echo (block it)
  }
  // isFromSync = Note to Self (allow it)
}

Benefits

  1. Backward compatibility: Existing users with direct signal-cli installations continue to work
  2. Containerized deployments: New users can use official bbernhard/signal-cli-rest-api:latest Docker image
  3. Flexible deployment: Users choose the mode that fits their infrastructure
  4. Automatic detection: Zero-config setup for most users (auto-detect works)
  5. Better reliability: WebSocket (REST mode) provides persistent connection vs. SSE polling
  6. Note to Self support: Users can talk to their bot via self-messages (both modes)
  7. Easier onboarding: docker-compose up just works for containerized setups

Use Cases

Use Case 1: Existing host-based deployment (JSON-RPC)

{
  "channels": {
    "signal": {
      "httpUrl": "http://localhost:8080",
      "account": "+1234567890",
      "apiMode": "jsonrpc"  // or "auto" (will detect JSON-RPC)
    }
  }
}

Use Case 2: New containerized deployment (REST)

# docker-compose.yml
services:
  signal-api:
    image: bbernhard/signal-cli-rest-api:latest
    ports:
      - "8080:8080"
  
  openclaw-gateway:
    image: openclaw:latest
    environment:
      OPENCLAW_SIGNAL_HTTP_URL: http://signal-api:8080
      OPENCLAW_SIGNAL_ACCOUNT: "+1234567890"
      # apiMode: "auto" will detect REST API

Use Case 3: Mixed environment

  • Some accounts use JSON-RPC (direct signal-cli on host)
  • Other accounts use REST (containerized signal-cli-rest-api)
{
  "channels": {
    "signal": {
      "accounts": {
        "personal": {
          "httpUrl": "http://localhost:8080",
          "account": "+1234567890",
          "apiMode": "jsonrpc"
        },
        "work": {
          "httpUrl": "http://signal-container:8080",
          "account": "+9876543210",
          "apiMode": "rest"
        }
      }
    }
  }
}

Alternatives considered

  1. Replace JSON-RPC entirely: Would break existing users ❌
  2. Only support one mode: Users have to adapt their infrastructure to OpenClaw ❌
  3. Separate channels (signal-jsonrpc vs signal-rest): Code duplication, confusing UX ❌
  4. Custom bridge service: External dependency, maintenance burden ❌

Implementation plan

  1. ✅ Add apiMode configuration option to SignalAccountConfig
  2. ✅ Implement detectSignalApiMode() to probe available endpoints
  3. ✅ Add REST API client functions (signalRestRequest, streamSignalEvents)
  4. ✅ Update event handler to support both SSE and WebSocket formats
  5. ✅ Add syncMessage extraction for "Note to Self" support
  6. ✅ Add self-message filtering (block bot echoes, allow Note to Self)
  7. ✅ Update send operations to support both JSON-RPC and REST
  8. ✅ Add unit tests for both modes
  9. 🔲 Update documentation with configuration examples
  10. 🔲 Add migration guide for users switching between modes

Additional context

Note: The implementation should maintain full backward compatibility. Existing configurations without apiMode specified should continue to work via auto-detection.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions