Add capabilities negotiation flow

Changes:
- Server sends 'request_capabilities' after handshake
- Client responds with 'capabilities_response' containing supported features
- Server computes intersection of account + client capabilities
- Effective capabilities used for message validation
- Client metadata stored per-connection
- Updated tests for capabilities negotiation
- Updated CLIENT_SPEC.md documentation

Protocol messages added:
- request_capabilities (server → client)
- capabilities_response (client → server)

ConnectedClient fields added:
- clientCapabilities?: ChannelCapabilities
- clientMetadata?: Record<string, unknown>
- capabilitiesReady: boolean

New methods:
- getEffectiveCapabilities(client) - returns intersection
- clientSupportsCapability(client, capability) - check support
This commit is contained in:
root
2026-03-31 21:41:26 +02:00
parent 8dd07e791c
commit 1421f9d645
4 changed files with 355 additions and 21 deletions

View File

@@ -30,6 +30,25 @@ ws.on('message', (data) => {
case 'handshake': case 'handshake':
console.log('Server capabilities:', message.capabilities); console.log('Server capabilities:', message.capabilities);
break; break;
case 'request_capabilities':
// Send client capabilities in response
ws.send(JSON.stringify({
type: 'capabilities_response',
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: true,
typing: true
},
metadata: {
clientName: 'My App',
version: '1.0.0'
}
}));
break;
case 'message': case 'message':
console.log('Bot says:', message.content.text); console.log('Bot says:', message.content.text);
break; break;
@@ -63,7 +82,25 @@ def on_message(ws, message):
print(f"Received: {data}") print(f"Received: {data}")
if data['type'] == 'handshake': if data['type'] == 'handshake':
print(f"Capabilities: {data['capabilities']}") print(f"Server capabilities: {data['capabilities']}")
elif data['type'] == 'request_capabilities':
# Send client capabilities in response
ws.send(json.dumps({
"type": "capabilities_response",
"capabilities": {
"text": True,
"images": True,
"files": False,
"voice": False,
"reactions": False,
"threads": True,
"typing": True
},
"metadata": {
"clientName": "My Python App",
"version": "1.0.0"
}
}))
elif data['type'] == 'message': elif data['type'] == 'message':
print(f"Bot: {data['content']['text']}") print(f"Bot: {data['content']['text']}")
@@ -151,12 +188,12 @@ Sent once when connection is established.
"accountId": "default", "accountId": "default",
"capabilities": { "capabilities": {
"text": true, "text": true,
"images": false, "images": true,
"files": false, "files": true,
"voice": false, "voice": false,
"reactions": false, "reactions": false,
"threads": false, "threads": true,
"typing": false "typing": true
}, },
"metadata": { "metadata": {
"serverName": "Production", "serverName": "Production",
@@ -167,8 +204,10 @@ Sent once when connection is established.
``` ```
**Fields:** **Fields:**
- `capabilities` - What this account supports - `capabilities` - **Maximum capabilities allowed by server** (account config)
- `metadata` - Custom metadata from config - `metadata` - Custom metadata from server config
**Note:** These are the **maximum** capabilities the server allows. The client must still report its own capabilities.
--- ---
@@ -274,8 +313,60 @@ Used for DM pairing flow.
--- ---
#### 8. Capabilities Request
```json
{
"type": "request_capabilities"
}
```
Sent by server **after handshake** to request client's capabilities.
**Client must respond with `capabilities_response`** (see below).
---
### Client → Server ### Client → Server
#### 0. Capabilities Response (required)
**Sent in response to `request_capabilities` from server.**
```json
{
"type": "capabilities_response",
"capabilities": {
"text": true,
"images": true,
"files": false,
"voice": true,
"reactions": false,
"threads": true,
"typing": true
},
"metadata": {
"clientName": "Telegram Bot",
"version": "1.0.0",
"platform": "telegram"
}
}
```
**Important:** Client **must** send this message after receiving `request_capabilities` from server.
**Fields:**
- `capabilities` - What **this client** supports (true = supported, false = not supported)
- `metadata` - Optional client metadata (name, version, platform, etc.)
**Effective Capabilities:**
The server uses the **intersection** of server capabilities and client capabilities:
- Server allows: `{ images: true, voice: false }`
- Client supports: `{ images: true, voice: true }`
- **Effective:** `{ images: true, voice: false }` (most restrictive wins)
---
#### 1. Text Message #### 1. Text Message
```json ```json
@@ -471,6 +562,10 @@ class OpenClawClient {
this.handleMessage(message); this.handleMessage(message);
}); });
this.ws.on('open', () => {
console.log('Connected to OpenClaw');
});
this.ws.on('error', (error) => { this.ws.on('error', (error) => {
reject(error); reject(error);
}); });
@@ -489,6 +584,27 @@ class OpenClawClient {
console.log('Server capabilities:', message.capabilities); console.log('Server capabilities:', message.capabilities);
break; break;
case 'request_capabilities':
// Send client capabilities
this.ws.send(JSON.stringify({
type: 'capabilities_response',
capabilities: {
text: true,
images: this.supportsImages(),
files: this.supportsFiles(),
voice: this.supportsVoice(),
reactions: false,
threads: true,
typing: true
},
metadata: {
clientName: 'OpenClaw WebSocket Client',
version: '1.0.0'
}
}));
console.log('Sent client capabilities');
break;
case 'message': case 'message':
// Bot sent a message // Bot sent a message
this.messageHandlers.forEach(h => h(message)); this.messageHandlers.forEach(h => h(message));
@@ -509,6 +625,11 @@ class OpenClawClient {
} }
} }
// Override these methods based on your client's capabilities
supportsImages() { return true; }
supportsFiles() { return false; }
supportsVoice() { return false; }
onMessage(handler) { onMessage(handler) {
this.messageHandlers.push(handler); this.messageHandlers.push(handler);
} }

View File

@@ -188,18 +188,22 @@ describe("WebSocketServerManager", () => {
}); });
describe("addClient", () => { describe("addClient", () => {
it("adds a client and sends handshake", () => { it("adds a client and sends handshake + capabilities request", () => {
const sentMessages: string[] = []; const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data); mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount); manager.addClient(mockWs as any, mockAccount);
expect(sentMessages.length).toBe(1); expect(sentMessages.length).toBe(2); // handshake + request_capabilities
const handshake = JSON.parse(sentMessages[0]); const handshake = JSON.parse(sentMessages[0]);
expect(handshake.type).toBe("handshake"); expect(handshake.type).toBe("handshake");
expect(handshake.accountId).toBe("test-account"); expect(handshake.accountId).toBe("test-account");
expect(handshake.capabilities.text).toBe(true); expect(handshake.capabilities.text).toBe(true);
expect(handshake.metadata.serverName).toBe("Test Server"); expect(handshake.metadata.serverName).toBe("Test Server");
const capsRequest = JSON.parse(sentMessages[1]);
expect(capsRequest.type).toBe("request_capabilities");
}); });
it("tracks connection count", () => { it("tracks connection count", () => {
@@ -215,6 +219,121 @@ describe("WebSocketServerManager", () => {
}); });
}); });
describe("capabilities negotiation", () => {
it("receives and processes capabilities response", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Simulate client capabilities response
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: true,
typing: true
},
metadata: { clientName: "Test Client" }
})));
const connections = manager.getConnections();
expect(connections[0].capabilitiesReady).toBe(true);
expect(connections[0].clientCapabilities?.images).toBe(true);
expect(connections[0].clientCapabilities?.files).toBe(false);
expect(connections[0].clientMetadata?.clientName).toBe("Test Client");
});
it("uses intersection of account and client capabilities", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
// Account allows everything
const fullCapAccount = {
...mockAccount,
capabilities: {
text: true,
images: true,
files: true,
voice: true,
reactions: true,
threads: true,
typing: true
}
};
manager.addClient(mockWs as any, fullCapAccount);
// Client only supports text and images
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: false,
typing: false
}
})));
const effective = manager.getEffectiveCapabilities(manager.getConnections()[0]);
expect(effective.text).toBe(true);
expect(effective.images).toBe(true);
expect(effective.files).toBe(false); // Client doesn't support
expect(effective.voice).toBe(false); // Client doesn't support
});
it("falls back to account capabilities if client doesn't report", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Don't send capabilities_response
const effective = manager.getEffectiveCapabilities(manager.getConnections()[0]);
// Should use account capabilities
expect(effective.text).toBe(true);
expect(effective.images).toBe(false); // Account default
});
it("clientSupportsCapability checks effective capabilities", () => {
const sentMessages: string[] = [];
mockWs.send = (data: string) => sentMessages.push(data);
manager.addClient(mockWs as any, mockAccount);
// Before capabilities response - uses account defaults
expect(manager.clientSupportsCapability(manager.getConnections()[0], "text")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "images")).toBe(false);
// Send capabilities response
mockWs.emit("message", Buffer.from(JSON.stringify({
type: "capabilities_response",
capabilities: {
text: true,
images: true,
files: false,
voice: false,
reactions: false,
threads: false,
typing: false
}
})));
// After response - uses intersection
expect(manager.clientSupportsCapability(manager.getConnections()[0], "text")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "images")).toBe(true);
expect(manager.clientSupportsCapability(manager.getConnections()[0], "files")).toBe(false);
});
});
describe("sendText", () => { describe("sendText", () => {
it("sends text message to all connections for account", async () => { it("sends text message to all connections for account", async () => {
const sentMessages: string[] = []; const sentMessages: string[] = [];

View File

@@ -61,18 +61,20 @@ async function handleInboundMessage(
return; return;
} }
// Validate capabilities // Validate capabilities - check both account and client capabilities
const content = message.content; const content = message.content;
if (content.type === "image" && !account.capabilities.images) {
api.logger?.warn(`Account ${account.accountId} tried to send image but capability is disabled`); // Check if CLIENT supports this capability (client-reported)
if (content.type === "image" && !wsManager.clientSupportsCapability(client, "images")) {
api.logger?.warn(`Client ${client.account.accountId} tried to send image but capability is not supported by client`);
return; return;
} }
if (content.type === "file" && !account.capabilities.files) { if (content.type === "file" && !wsManager.clientSupportsCapability(client, "files")) {
api.logger?.warn(`Account ${account.accountId} tried to send file but capability is disabled`); api.logger?.warn(`Client ${client.account.accountId} tried to send file but capability is not supported by client`);
return; return;
} }
if (content.type === "voice" && !account.capabilities.voice) { if (content.type === "voice" && !wsManager.clientSupportsCapability(client, "voice")) {
api.logger?.warn(`Account ${account.accountId} tried to send voice but capability is disabled`); api.logger?.warn(`Client ${client.account.accountId} tried to send voice but capability is not supported by client`);
return; return;
} }

View File

@@ -45,12 +45,24 @@ export type PairingCodeMessage = {
message: string; message: string;
}; };
export type CapabilitiesRequest = {
type: "request_capabilities";
};
export type CapabilitiesResponse = {
type: "capabilities_response";
capabilities: ChannelCapabilities;
metadata?: Record<string, unknown>;
};
export type WebSocketMessage = export type WebSocketMessage =
| InboundMessage | InboundMessage
| OutboundMessage | OutboundMessage
| HandshakeMessage | HandshakeMessage
| TypingIndicator | TypingIndicator
| PairingCodeMessage; | PairingCodeMessage
| CapabilitiesRequest
| CapabilitiesResponse;
export type MessageContent = export type MessageContent =
| { type: "text"; text: string } | { type: "text"; text: string }
@@ -65,6 +77,9 @@ export type ConnectedClient = {
userId?: string; userId?: string;
connectedAt: Date; connectedAt: Date;
lastPingAt: Date; lastPingAt: Date;
clientCapabilities?: ChannelCapabilities; // Capabilities reported by client
clientMetadata?: Record<string, unknown>; // Metadata reported by client
capabilitiesReady: boolean; // Whether client has reported capabilities
}; };
export type MessageHandler = (message: InboundMessage, client: ConnectedClient) => Promise<void> | void; export type MessageHandler = (message: InboundMessage, client: ConnectedClient) => Promise<void> | void;
@@ -89,15 +104,28 @@ export class WebSocketServerManager {
account, account,
connectedAt: new Date(), connectedAt: new Date(),
lastPingAt: new Date(), lastPingAt: new Date(),
clientCapabilities: undefined,
clientMetadata: undefined,
capabilitiesReady: false,
}; };
this.clients.set(ws, client); this.clients.set(ws, client);
// Set up message handler // Set up message handler
ws.on("message", async (data) => { ws.on("message", async (data) => {
try { try {
const message = JSON.parse(data.toString()) as InboundMessage; const message = JSON.parse(data.toString()) as WebSocketMessage;
// Handle capabilities response from client
if (message.type === "capabilities_response") {
this.handleCapabilitiesResponse(client, message);
return;
}
// Handle regular inbound messages
if (message.type === "message") {
if (this.messageHandler) { if (this.messageHandler) {
await this.messageHandler(message, client); await this.messageHandler(message as InboundMessage, client);
}
} }
} catch (err) { } catch (err) {
console.error("Failed to parse WebSocket message:", err); console.error("Failed to parse WebSocket message:", err);
@@ -121,9 +149,52 @@ export class WebSocketServerManager {
// Send handshake // Send handshake
this.sendHandshake(client); this.sendHandshake(client);
// Request client capabilities after handshake
this.requestCapabilities(client);
return client; return client;
} }
/**
* Request capabilities from client
*/
private requestCapabilities(client: ConnectedClient): void {
const request: CapabilitiesRequest = {
type: "request_capabilities",
};
this.sendToClient(client.ws, request);
}
/**
* Handle capabilities response from client
*/
private handleCapabilitiesResponse(
client: ConnectedClient,
response: CapabilitiesResponse
): void {
// Merge client capabilities with account capabilities (intersection)
const accountCaps = client.account.capabilities;
const clientCaps = response.capabilities;
client.clientCapabilities = {
text: accountCaps.text && clientCaps.text,
images: accountCaps.images && clientCaps.images,
files: accountCaps.files && clientCaps.files,
voice: accountCaps.voice && clientCaps.voice,
reactions: accountCaps.reactions && clientCaps.reactions,
threads: accountCaps.threads && clientCaps.threads,
typing: accountCaps.typing && clientCaps.typing,
};
client.clientMetadata = response.metadata;
client.capabilitiesReady = true;
console.log(
`Client capabilities received for account ${client.account.accountId}:`,
client.clientCapabilities
);
}
/** /**
* Send handshake message to client * Send handshake message to client
*/ */
@@ -131,13 +202,34 @@ export class WebSocketServerManager {
const handshake: HandshakeMessage = { const handshake: HandshakeMessage = {
type: "handshake", type: "handshake",
accountId: client.account.accountId, accountId: client.account.accountId,
capabilities: client.account.capabilities, capabilities: client.account.capabilities, // Server capabilities (max allowed)
metadata: client.account.metadata || {}, metadata: client.account.metadata || {},
timestamp: Date.now(), timestamp: Date.now(),
}; };
this.sendToClient(client.ws, handshake); this.sendToClient(client.ws, handshake);
} }
/**
* Get effective capabilities for a client (intersection of account and client capabilities)
*/
getEffectiveCapabilities(client: ConnectedClient): ChannelCapabilities {
// If client hasn't reported capabilities yet, use account capabilities
if (!client.capabilitiesReady || !client.clientCapabilities) {
return client.account.capabilities;
}
// Return intersection (client can only use what both support)
return client.clientCapabilities;
}
/**
* Check if a client supports a specific capability
*/
clientSupportsCapability(client: ConnectedClient, capability: keyof ChannelCapabilities): boolean {
const caps = this.getEffectiveCapabilities(client);
return caps[capability] === true;
}
/** /**
* Send a message to a specific client * Send a message to a specific client
*/ */