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:
@@ -30,6 +30,25 @@ ws.on('message', (data) => {
|
||||
case 'handshake':
|
||||
console.log('Server capabilities:', message.capabilities);
|
||||
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':
|
||||
console.log('Bot says:', message.content.text);
|
||||
break;
|
||||
@@ -63,7 +82,25 @@ def on_message(ws, message):
|
||||
print(f"Received: {data}")
|
||||
|
||||
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':
|
||||
print(f"Bot: {data['content']['text']}")
|
||||
|
||||
@@ -151,12 +188,12 @@ Sent once when connection is established.
|
||||
"accountId": "default",
|
||||
"capabilities": {
|
||||
"text": true,
|
||||
"images": false,
|
||||
"files": false,
|
||||
"images": true,
|
||||
"files": true,
|
||||
"voice": false,
|
||||
"reactions": false,
|
||||
"threads": false,
|
||||
"typing": false
|
||||
"threads": true,
|
||||
"typing": true
|
||||
},
|
||||
"metadata": {
|
||||
"serverName": "Production",
|
||||
@@ -167,8 +204,10 @@ Sent once when connection is established.
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
- `capabilities` - What this account supports
|
||||
- `metadata` - Custom metadata from config
|
||||
- `capabilities` - **Maximum capabilities allowed by server** (account 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
|
||||
|
||||
#### 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
|
||||
|
||||
```json
|
||||
@@ -471,6 +562,10 @@ class OpenClawClient {
|
||||
this.handleMessage(message);
|
||||
});
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('Connected to OpenClaw');
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
reject(error);
|
||||
});
|
||||
@@ -489,6 +584,27 @@ class OpenClawClient {
|
||||
console.log('Server capabilities:', message.capabilities);
|
||||
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':
|
||||
// Bot sent a 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) {
|
||||
this.messageHandlers.push(handler);
|
||||
}
|
||||
|
||||
@@ -188,18 +188,22 @@ describe("WebSocketServerManager", () => {
|
||||
});
|
||||
|
||||
describe("addClient", () => {
|
||||
it("adds a client and sends handshake", () => {
|
||||
it("adds a client and sends handshake + capabilities request", () => {
|
||||
const sentMessages: string[] = [];
|
||||
mockWs.send = (data: string) => sentMessages.push(data);
|
||||
|
||||
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]);
|
||||
expect(handshake.type).toBe("handshake");
|
||||
expect(handshake.accountId).toBe("test-account");
|
||||
expect(handshake.capabilities.text).toBe(true);
|
||||
expect(handshake.metadata.serverName).toBe("Test Server");
|
||||
|
||||
const capsRequest = JSON.parse(sentMessages[1]);
|
||||
expect(capsRequest.type).toBe("request_capabilities");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("sends text message to all connections for account", async () => {
|
||||
const sentMessages: string[] = [];
|
||||
|
||||
@@ -61,18 +61,20 @@ async function handleInboundMessage(
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate capabilities
|
||||
// Validate capabilities - check both account and client capabilities
|
||||
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;
|
||||
}
|
||||
if (content.type === "file" && !account.capabilities.files) {
|
||||
api.logger?.warn(`Account ${account.accountId} tried to send file but capability is disabled`);
|
||||
if (content.type === "file" && !wsManager.clientSupportsCapability(client, "files")) {
|
||||
api.logger?.warn(`Client ${client.account.accountId} tried to send file but capability is not supported by client`);
|
||||
return;
|
||||
}
|
||||
if (content.type === "voice" && !account.capabilities.voice) {
|
||||
api.logger?.warn(`Account ${account.accountId} tried to send voice but capability is disabled`);
|
||||
if (content.type === "voice" && !wsManager.clientSupportsCapability(client, "voice")) {
|
||||
api.logger?.warn(`Client ${client.account.accountId} tried to send voice but capability is not supported by client`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,24 @@ export type PairingCodeMessage = {
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type CapabilitiesRequest = {
|
||||
type: "request_capabilities";
|
||||
};
|
||||
|
||||
export type CapabilitiesResponse = {
|
||||
type: "capabilities_response";
|
||||
capabilities: ChannelCapabilities;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WebSocketMessage =
|
||||
| InboundMessage
|
||||
| OutboundMessage
|
||||
| HandshakeMessage
|
||||
| TypingIndicator
|
||||
| PairingCodeMessage;
|
||||
| PairingCodeMessage
|
||||
| CapabilitiesRequest
|
||||
| CapabilitiesResponse;
|
||||
|
||||
export type MessageContent =
|
||||
| { type: "text"; text: string }
|
||||
@@ -65,6 +77,9 @@ export type ConnectedClient = {
|
||||
userId?: string;
|
||||
connectedAt: 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;
|
||||
@@ -89,15 +104,28 @@ export class WebSocketServerManager {
|
||||
account,
|
||||
connectedAt: new Date(),
|
||||
lastPingAt: new Date(),
|
||||
clientCapabilities: undefined,
|
||||
clientMetadata: undefined,
|
||||
capabilitiesReady: false,
|
||||
};
|
||||
this.clients.set(ws, client);
|
||||
|
||||
// Set up message handler
|
||||
ws.on("message", async (data) => {
|
||||
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) {
|
||||
await this.messageHandler(message, client);
|
||||
await this.messageHandler(message as InboundMessage, client);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to parse WebSocket message:", err);
|
||||
@@ -121,9 +149,52 @@ export class WebSocketServerManager {
|
||||
// Send handshake
|
||||
this.sendHandshake(client);
|
||||
|
||||
// Request client capabilities after handshake
|
||||
this.requestCapabilities(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
|
||||
*/
|
||||
@@ -131,13 +202,34 @@ export class WebSocketServerManager {
|
||||
const handshake: HandshakeMessage = {
|
||||
type: "handshake",
|
||||
accountId: client.account.accountId,
|
||||
capabilities: client.account.capabilities,
|
||||
capabilities: client.account.capabilities, // Server capabilities (max allowed)
|
||||
metadata: client.account.metadata || {},
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user