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':
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[] = [];
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
if (this.messageHandler) {
|
|
||||||
await this.messageHandler(message, client);
|
// 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 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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user