adp-openclaw 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +733 -0
- package/README.md +70 -0
- package/USAGE.md +910 -0
- package/WEBSOCKET_PROTOCOL.md +506 -0
- package/index.ts +17 -0
- package/openclaw.plugin.json +22 -0
- package/package.json +26 -0
- package/server/.claude/settings.local.json +16 -0
- package/server/go.mod +5 -0
- package/server/main.go +786 -0
- package/src/channel.ts +245 -0
- package/src/config-schema.ts +8 -0
- package/src/monitor.ts +325 -0
- package/src/runtime.ts +15 -0
package/server/main.go
ADDED
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
// Simple Go channel server for OpenClaw (WebSocket version)
|
|
2
|
+
// Supports: API Token auth, multiple clients, multi-turn conversations, real-time WebSocket communication
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"crypto/rand"
|
|
7
|
+
"crypto/sha256"
|
|
8
|
+
"encoding/hex"
|
|
9
|
+
"encoding/json"
|
|
10
|
+
"flag"
|
|
11
|
+
"fmt"
|
|
12
|
+
"log"
|
|
13
|
+
"net/http"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
"time"
|
|
17
|
+
|
|
18
|
+
"github.com/gorilla/websocket"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// WebSocket message types
|
|
22
|
+
const (
|
|
23
|
+
MsgTypeAuth = "auth" // Client authentication
|
|
24
|
+
MsgTypeAuthResult = "auth_result" // Authentication result
|
|
25
|
+
MsgTypePing = "ping" // Heartbeat ping
|
|
26
|
+
MsgTypePong = "pong" // Heartbeat pong
|
|
27
|
+
MsgTypeInbound = "inbound" // User message to bot
|
|
28
|
+
MsgTypeOutbound = "outbound" // Bot reply to user
|
|
29
|
+
MsgTypeAck = "ack" // Message acknowledgment
|
|
30
|
+
MsgTypeError = "error" // Error message
|
|
31
|
+
MsgTypeConvHistory = "conv_history" // Request conversation history
|
|
32
|
+
MsgTypeConvResponse = "conv_response" // Conversation history response
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
// WSMessage is the base WebSocket message structure
|
|
36
|
+
type WSMessage struct {
|
|
37
|
+
Type string `json:"type"` // Message type
|
|
38
|
+
RequestID string `json:"requestId,omitempty"` // Optional request ID for correlation
|
|
39
|
+
Payload json.RawMessage `json:"payload,omitempty"` // Message payload
|
|
40
|
+
Timestamp int64 `json:"timestamp"` // Unix milliseconds
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// AuthPayload for authentication request
|
|
44
|
+
type AuthPayload struct {
|
|
45
|
+
Token string `json:"token"` // API token
|
|
46
|
+
ClientID string `json:"clientId,omitempty"` // Optional client ID for reconnection
|
|
47
|
+
Signature string `json:"signature,omitempty"` // HMAC signature for extra security
|
|
48
|
+
Nonce string `json:"nonce,omitempty"` // Random nonce for signature
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// AuthResultPayload for authentication response
|
|
52
|
+
type AuthResultPayload struct {
|
|
53
|
+
Success bool `json:"success"`
|
|
54
|
+
ClientID string `json:"clientId,omitempty"`
|
|
55
|
+
Message string `json:"message,omitempty"`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// UserInfo represents user identity information (like Feishu multi-user support)
|
|
59
|
+
type UserInfo struct {
|
|
60
|
+
UserID string `json:"userId"` // Unique user identifier
|
|
61
|
+
Username string `json:"username,omitempty"` // Display name
|
|
62
|
+
Avatar string `json:"avatar,omitempty"` // Avatar URL
|
|
63
|
+
Email string `json:"email,omitempty"` // User email
|
|
64
|
+
TenantID string `json:"tenantId,omitempty"` // Tenant/Organization ID for multi-tenant
|
|
65
|
+
Source string `json:"source,omitempty"` // Source platform (e.g., "web", "app", "api")
|
|
66
|
+
Extra map[string]string `json:"extra,omitempty"` // Additional user metadata
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Message represents a chat message
|
|
70
|
+
type Message struct {
|
|
71
|
+
ID string `json:"id"`
|
|
72
|
+
ConversationID string `json:"conversationId"` // Groups messages into conversations
|
|
73
|
+
ClientID string `json:"clientId"` // Which OpenClaw client handles this
|
|
74
|
+
From string `json:"from"`
|
|
75
|
+
To string `json:"to"`
|
|
76
|
+
Text string `json:"text"`
|
|
77
|
+
Timestamp int64 `json:"timestamp"`
|
|
78
|
+
User *UserInfo `json:"user,omitempty"` // Full user identity information
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Conversation tracks multi-turn dialogue state
|
|
82
|
+
type Conversation struct {
|
|
83
|
+
ID string `json:"id"`
|
|
84
|
+
UserID string `json:"userId"`
|
|
85
|
+
ClientID string `json:"clientId"` // Assigned OpenClaw client
|
|
86
|
+
Messages []Message `json:"messages"` // Conversation history
|
|
87
|
+
CreatedAt int64 `json:"createdAt"`
|
|
88
|
+
UpdatedAt int64 `json:"updatedAt"`
|
|
89
|
+
User *UserInfo `json:"user,omitempty"` // Associated user information
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Client represents a connected OpenClaw instance
|
|
93
|
+
type Client struct {
|
|
94
|
+
ID string `json:"id"`
|
|
95
|
+
Token string `json:"-"` // API token (not exposed in JSON)
|
|
96
|
+
Name string `json:"name"`
|
|
97
|
+
LastSeenAt int64 `json:"lastSeenAt"`
|
|
98
|
+
Conn *websocket.Conn `json:"-"` // WebSocket connection
|
|
99
|
+
ConnMu sync.Mutex `json:"-"` // Mutex for connection writes
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Server holds all state
|
|
103
|
+
type Server struct {
|
|
104
|
+
mu sync.RWMutex
|
|
105
|
+
clients map[string]*Client // clientID -> Client
|
|
106
|
+
tokens map[string]string // token -> clientID
|
|
107
|
+
conversations map[string]*Conversation // conversationID -> Conversation
|
|
108
|
+
outbound []Message // All outbound messages (for debug)
|
|
109
|
+
lastID int
|
|
110
|
+
adminToken string // Admin token for client registration
|
|
111
|
+
upgrader websocket.Upgrader
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
func NewServer(adminToken string) *Server {
|
|
115
|
+
return &Server{
|
|
116
|
+
clients: make(map[string]*Client),
|
|
117
|
+
tokens: make(map[string]string),
|
|
118
|
+
conversations: make(map[string]*Conversation),
|
|
119
|
+
outbound: make([]Message, 0),
|
|
120
|
+
adminToken: adminToken,
|
|
121
|
+
upgrader: websocket.Upgrader{
|
|
122
|
+
CheckOrigin: func(r *http.Request) bool {
|
|
123
|
+
return true // Allow all origins for demo
|
|
124
|
+
},
|
|
125
|
+
ReadBufferSize: 1024,
|
|
126
|
+
WriteBufferSize: 1024,
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
func generateID(prefix string) string {
|
|
132
|
+
bytes := make([]byte, 8)
|
|
133
|
+
rand.Read(bytes)
|
|
134
|
+
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(bytes))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func generateToken() string {
|
|
138
|
+
bytes := make([]byte, 32)
|
|
139
|
+
rand.Read(bytes)
|
|
140
|
+
return hex.EncodeToString(bytes)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// verifySignature verifies HMAC-SHA256 signature
|
|
144
|
+
func verifySignature(token, nonce, signature string) bool {
|
|
145
|
+
if nonce == "" || signature == "" {
|
|
146
|
+
return true // Signature is optional
|
|
147
|
+
}
|
|
148
|
+
h := sha256.New()
|
|
149
|
+
h.Write([]byte(token + ":" + nonce))
|
|
150
|
+
expected := hex.EncodeToString(h.Sum(nil))
|
|
151
|
+
return expected == signature
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// sendWSMessage sends a WebSocket message to a client
|
|
155
|
+
func (c *Client) sendWSMessage(msg WSMessage) error {
|
|
156
|
+
c.ConnMu.Lock()
|
|
157
|
+
defer c.ConnMu.Unlock()
|
|
158
|
+
if c.Conn == nil {
|
|
159
|
+
return fmt.Errorf("connection closed")
|
|
160
|
+
}
|
|
161
|
+
msg.Timestamp = time.Now().UnixMilli()
|
|
162
|
+
return c.Conn.WriteJSON(msg)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// WebSocket handler for client connections
|
|
166
|
+
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
|
|
167
|
+
conn, err := s.upgrader.Upgrade(w, r, nil)
|
|
168
|
+
if err != nil {
|
|
169
|
+
log.Printf("[WS] Upgrade error: %v", err)
|
|
170
|
+
return
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
log.Printf("[WS] New connection from %s", conn.RemoteAddr())
|
|
174
|
+
|
|
175
|
+
// Set up ping/pong handlers
|
|
176
|
+
conn.SetPongHandler(func(string) error {
|
|
177
|
+
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
178
|
+
return nil
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Wait for authentication message
|
|
182
|
+
conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
|
183
|
+
|
|
184
|
+
var authMsg WSMessage
|
|
185
|
+
if err := conn.ReadJSON(&authMsg); err != nil {
|
|
186
|
+
log.Printf("[WS] Auth read error: %v", err)
|
|
187
|
+
s.sendError(conn, "", "auth_timeout", "Authentication timeout")
|
|
188
|
+
conn.Close()
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if authMsg.Type != MsgTypeAuth {
|
|
193
|
+
s.sendError(conn, authMsg.RequestID, "invalid_auth", "First message must be auth")
|
|
194
|
+
conn.Close()
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var authPayload AuthPayload
|
|
199
|
+
if err := json.Unmarshal(authMsg.Payload, &authPayload); err != nil {
|
|
200
|
+
s.sendError(conn, authMsg.RequestID, "invalid_payload", "Invalid auth payload")
|
|
201
|
+
conn.Close()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Verify token
|
|
206
|
+
s.mu.RLock()
|
|
207
|
+
clientID, ok := s.tokens[authPayload.Token]
|
|
208
|
+
s.mu.RUnlock()
|
|
209
|
+
|
|
210
|
+
if !ok {
|
|
211
|
+
log.Printf("[WS] Invalid token from %s", conn.RemoteAddr())
|
|
212
|
+
s.sendAuthResult(conn, authMsg.RequestID, false, "", "Invalid token")
|
|
213
|
+
conn.Close()
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Verify signature if provided
|
|
218
|
+
if !verifySignature(authPayload.Token, authPayload.Nonce, authPayload.Signature) {
|
|
219
|
+
log.Printf("[WS] Invalid signature from %s", conn.RemoteAddr())
|
|
220
|
+
s.sendAuthResult(conn, authMsg.RequestID, false, "", "Invalid signature")
|
|
221
|
+
conn.Close()
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Update client connection
|
|
226
|
+
s.mu.Lock()
|
|
227
|
+
client := s.clients[clientID]
|
|
228
|
+
if client.Conn != nil {
|
|
229
|
+
// Close old connection
|
|
230
|
+
client.Conn.Close()
|
|
231
|
+
}
|
|
232
|
+
client.Conn = conn
|
|
233
|
+
client.LastSeenAt = time.Now().UnixMilli()
|
|
234
|
+
s.mu.Unlock()
|
|
235
|
+
|
|
236
|
+
log.Printf("[WS] Client %s authenticated (%s)", clientID, client.Name)
|
|
237
|
+
s.sendAuthResult(conn, authMsg.RequestID, true, clientID, "Authentication successful")
|
|
238
|
+
|
|
239
|
+
// Reset read deadline and start message loop
|
|
240
|
+
conn.SetReadDeadline(time.Time{})
|
|
241
|
+
|
|
242
|
+
// Start ping ticker
|
|
243
|
+
go s.pingClient(client)
|
|
244
|
+
|
|
245
|
+
// Message handling loop
|
|
246
|
+
s.handleClientMessages(client)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// pingClient sends periodic pings to keep connection alive
|
|
250
|
+
func (s *Server) pingClient(client *Client) {
|
|
251
|
+
ticker := time.NewTicker(30 * time.Second)
|
|
252
|
+
defer ticker.Stop()
|
|
253
|
+
|
|
254
|
+
for range ticker.C {
|
|
255
|
+
client.ConnMu.Lock()
|
|
256
|
+
if client.Conn == nil {
|
|
257
|
+
client.ConnMu.Unlock()
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
err := client.Conn.WriteMessage(websocket.PingMessage, nil)
|
|
261
|
+
client.ConnMu.Unlock()
|
|
262
|
+
|
|
263
|
+
if err != nil {
|
|
264
|
+
log.Printf("[WS] Ping error for client %s: %v", client.ID, err)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// handleClientMessages processes incoming messages from a client
|
|
271
|
+
func (s *Server) handleClientMessages(client *Client) {
|
|
272
|
+
defer func() {
|
|
273
|
+
client.ConnMu.Lock()
|
|
274
|
+
if client.Conn != nil {
|
|
275
|
+
client.Conn.Close()
|
|
276
|
+
client.Conn = nil
|
|
277
|
+
}
|
|
278
|
+
client.ConnMu.Unlock()
|
|
279
|
+
log.Printf("[WS] Client %s disconnected", client.ID)
|
|
280
|
+
}()
|
|
281
|
+
|
|
282
|
+
for {
|
|
283
|
+
var msg WSMessage
|
|
284
|
+
if err := client.Conn.ReadJSON(&msg); err != nil {
|
|
285
|
+
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
286
|
+
log.Printf("[WS] Read error for client %s: %v", client.ID, err)
|
|
287
|
+
}
|
|
288
|
+
return
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
s.mu.Lock()
|
|
292
|
+
client.LastSeenAt = time.Now().UnixMilli()
|
|
293
|
+
s.mu.Unlock()
|
|
294
|
+
|
|
295
|
+
switch msg.Type {
|
|
296
|
+
case MsgTypePing:
|
|
297
|
+
client.sendWSMessage(WSMessage{Type: MsgTypePong, RequestID: msg.RequestID})
|
|
298
|
+
|
|
299
|
+
case MsgTypeOutbound:
|
|
300
|
+
s.handleOutboundMessage(client, msg)
|
|
301
|
+
|
|
302
|
+
case MsgTypeConvHistory:
|
|
303
|
+
s.handleConvHistoryRequest(client, msg)
|
|
304
|
+
|
|
305
|
+
default:
|
|
306
|
+
log.Printf("[WS] Unknown message type from client %s: %s", client.ID, msg.Type)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// handleOutboundMessage processes outbound messages from bot to user
|
|
312
|
+
func (s *Server) handleOutboundMessage(client *Client, wsMsg WSMessage) {
|
|
313
|
+
var payload struct {
|
|
314
|
+
To string `json:"to"`
|
|
315
|
+
Text string `json:"text"`
|
|
316
|
+
ConversationID string `json:"conversationId"`
|
|
317
|
+
}
|
|
318
|
+
if err := json.Unmarshal(wsMsg.Payload, &payload); err != nil {
|
|
319
|
+
client.sendWSMessage(WSMessage{
|
|
320
|
+
Type: MsgTypeError,
|
|
321
|
+
RequestID: wsMsg.RequestID,
|
|
322
|
+
Payload: mustMarshal(map[string]string{"error": "invalid_payload", "message": err.Error()}),
|
|
323
|
+
})
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
s.mu.Lock()
|
|
328
|
+
s.lastID++
|
|
329
|
+
msg := Message{
|
|
330
|
+
ID: fmt.Sprintf("msg-%d", s.lastID),
|
|
331
|
+
ConversationID: payload.ConversationID,
|
|
332
|
+
ClientID: client.ID,
|
|
333
|
+
From: "bot",
|
|
334
|
+
To: payload.To,
|
|
335
|
+
Text: payload.Text,
|
|
336
|
+
Timestamp: time.Now().UnixMilli(),
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Add to conversation history
|
|
340
|
+
if conv, ok := s.conversations[payload.ConversationID]; ok {
|
|
341
|
+
conv.Messages = append(conv.Messages, msg)
|
|
342
|
+
conv.UpdatedAt = msg.Timestamp
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
s.outbound = append(s.outbound, msg)
|
|
346
|
+
s.mu.Unlock()
|
|
347
|
+
|
|
348
|
+
log.Printf("[OUTBOUND] %s → %s: %s (conv=%s)", client.ID, msg.To, msg.Text, payload.ConversationID)
|
|
349
|
+
|
|
350
|
+
// Send acknowledgment
|
|
351
|
+
client.sendWSMessage(WSMessage{
|
|
352
|
+
Type: MsgTypeAck,
|
|
353
|
+
RequestID: wsMsg.RequestID,
|
|
354
|
+
Payload: mustMarshal(map[string]any{"ok": true, "message": msg}),
|
|
355
|
+
})
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// handleConvHistoryRequest returns conversation history
|
|
359
|
+
func (s *Server) handleConvHistoryRequest(client *Client, wsMsg WSMessage) {
|
|
360
|
+
var payload struct {
|
|
361
|
+
ConversationID string `json:"conversationId"`
|
|
362
|
+
}
|
|
363
|
+
if err := json.Unmarshal(wsMsg.Payload, &payload); err != nil {
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
s.mu.RLock()
|
|
368
|
+
conv, ok := s.conversations[payload.ConversationID]
|
|
369
|
+
s.mu.RUnlock()
|
|
370
|
+
|
|
371
|
+
if !ok {
|
|
372
|
+
client.sendWSMessage(WSMessage{
|
|
373
|
+
Type: MsgTypeError,
|
|
374
|
+
RequestID: wsMsg.RequestID,
|
|
375
|
+
Payload: mustMarshal(map[string]string{"error": "not_found", "message": "Conversation not found"}),
|
|
376
|
+
})
|
|
377
|
+
return
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
client.sendWSMessage(WSMessage{
|
|
381
|
+
Type: MsgTypeConvResponse,
|
|
382
|
+
RequestID: wsMsg.RequestID,
|
|
383
|
+
Payload: mustMarshal(conv),
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// sendError sends an error message over WebSocket
|
|
388
|
+
func (s *Server) sendError(conn *websocket.Conn, requestID, code, message string) {
|
|
389
|
+
msg := WSMessage{
|
|
390
|
+
Type: MsgTypeError,
|
|
391
|
+
RequestID: requestID,
|
|
392
|
+
Payload: mustMarshal(map[string]string{"error": code, "message": message}),
|
|
393
|
+
Timestamp: time.Now().UnixMilli(),
|
|
394
|
+
}
|
|
395
|
+
conn.WriteJSON(msg)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// sendAuthResult sends authentication result
|
|
399
|
+
func (s *Server) sendAuthResult(conn *websocket.Conn, requestID string, success bool, clientID, message string) {
|
|
400
|
+
payload := AuthResultPayload{
|
|
401
|
+
Success: success,
|
|
402
|
+
ClientID: clientID,
|
|
403
|
+
Message: message,
|
|
404
|
+
}
|
|
405
|
+
msg := WSMessage{
|
|
406
|
+
Type: MsgTypeAuthResult,
|
|
407
|
+
RequestID: requestID,
|
|
408
|
+
Payload: mustMarshal(payload),
|
|
409
|
+
Timestamp: time.Now().UnixMilli(),
|
|
410
|
+
}
|
|
411
|
+
conn.WriteJSON(msg)
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// pushMessageToClient sends an inbound message to the assigned client via WebSocket
|
|
415
|
+
func (s *Server) pushMessageToClient(msg Message) bool {
|
|
416
|
+
s.mu.RLock()
|
|
417
|
+
client, ok := s.clients[msg.ClientID]
|
|
418
|
+
s.mu.RUnlock()
|
|
419
|
+
|
|
420
|
+
if !ok || client.Conn == nil {
|
|
421
|
+
log.Printf("[WS] Client %s not connected, message queued", msg.ClientID)
|
|
422
|
+
return false
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
err := client.sendWSMessage(WSMessage{
|
|
426
|
+
Type: MsgTypeInbound,
|
|
427
|
+
Payload: mustMarshal(msg),
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
if err != nil {
|
|
431
|
+
log.Printf("[WS] Failed to push message to client %s: %v", msg.ClientID, err)
|
|
432
|
+
return false
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return true
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
func mustMarshal(v any) json.RawMessage {
|
|
439
|
+
data, _ := json.Marshal(v)
|
|
440
|
+
return data
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Admin auth middleware
|
|
444
|
+
func (s *Server) adminAuth(next http.HandlerFunc) http.HandlerFunc {
|
|
445
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
446
|
+
token := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
447
|
+
if token != s.adminToken {
|
|
448
|
+
http.Error(w, "Invalid admin token", http.StatusUnauthorized)
|
|
449
|
+
return
|
|
450
|
+
}
|
|
451
|
+
next(w, r)
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// POST /clients - Register a new OpenClaw client (admin only)
|
|
456
|
+
func (s *Server) handleRegisterClient(w http.ResponseWriter, r *http.Request) {
|
|
457
|
+
if r.Method != http.MethodPost {
|
|
458
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
459
|
+
return
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
var req struct {
|
|
463
|
+
Name string `json:"name"`
|
|
464
|
+
}
|
|
465
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
466
|
+
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
467
|
+
return
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
clientID := generateID("client")
|
|
471
|
+
token := generateToken()
|
|
472
|
+
|
|
473
|
+
client := &Client{
|
|
474
|
+
ID: clientID,
|
|
475
|
+
Token: token,
|
|
476
|
+
Name: req.Name,
|
|
477
|
+
LastSeenAt: time.Now().UnixMilli(),
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
s.mu.Lock()
|
|
481
|
+
s.clients[clientID] = client
|
|
482
|
+
s.tokens[token] = clientID
|
|
483
|
+
s.mu.Unlock()
|
|
484
|
+
|
|
485
|
+
log.Printf("[CLIENT] Registered: %s (%s)", client.Name, clientID)
|
|
486
|
+
|
|
487
|
+
w.Header().Set("Content-Type", "application/json")
|
|
488
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
489
|
+
"clientId": clientID,
|
|
490
|
+
"token": token,
|
|
491
|
+
"name": req.Name,
|
|
492
|
+
})
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// GET /clients - List all clients (admin only)
|
|
496
|
+
func (s *Server) handleListClients(w http.ResponseWriter, r *http.Request) {
|
|
497
|
+
s.mu.RLock()
|
|
498
|
+
clients := make([]map[string]any, 0, len(s.clients))
|
|
499
|
+
for _, c := range s.clients {
|
|
500
|
+
clients = append(clients, map[string]any{
|
|
501
|
+
"id": c.ID,
|
|
502
|
+
"name": c.Name,
|
|
503
|
+
"lastSeenAt": c.LastSeenAt,
|
|
504
|
+
"connected": c.Conn != nil,
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
s.mu.RUnlock()
|
|
508
|
+
|
|
509
|
+
w.Header().Set("Content-Type", "application/json")
|
|
510
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
511
|
+
"clients": clients,
|
|
512
|
+
})
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Find or create conversation for a user, assign to least-busy client
|
|
516
|
+
func (s *Server) getOrCreateConversation(userID string, userInfo *UserInfo) *Conversation {
|
|
517
|
+
s.mu.Lock()
|
|
518
|
+
defer s.mu.Unlock()
|
|
519
|
+
|
|
520
|
+
// Find existing active conversation for this user
|
|
521
|
+
for _, conv := range s.conversations {
|
|
522
|
+
if conv.UserID == userID {
|
|
523
|
+
conv.UpdatedAt = time.Now().UnixMilli()
|
|
524
|
+
// Update user info if provided
|
|
525
|
+
if userInfo != nil {
|
|
526
|
+
conv.User = userInfo
|
|
527
|
+
}
|
|
528
|
+
return conv
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Assign to client with active connection (prefer connected clients)
|
|
533
|
+
var assignedClientID string
|
|
534
|
+
|
|
535
|
+
for clientID, client := range s.clients {
|
|
536
|
+
if client.Conn != nil {
|
|
537
|
+
assignedClientID = clientID
|
|
538
|
+
break
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if assignedClientID == "" {
|
|
543
|
+
// No clients connected, use first available or unassigned
|
|
544
|
+
for clientID := range s.clients {
|
|
545
|
+
assignedClientID = clientID
|
|
546
|
+
break
|
|
547
|
+
}
|
|
548
|
+
if assignedClientID == "" {
|
|
549
|
+
assignedClientID = "unassigned"
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
conv := &Conversation{
|
|
554
|
+
ID: generateID("conv"),
|
|
555
|
+
UserID: userID,
|
|
556
|
+
ClientID: assignedClientID,
|
|
557
|
+
Messages: make([]Message, 0),
|
|
558
|
+
CreatedAt: time.Now().UnixMilli(),
|
|
559
|
+
UpdatedAt: time.Now().UnixMilli(),
|
|
560
|
+
User: userInfo,
|
|
561
|
+
}
|
|
562
|
+
s.conversations[conv.ID] = conv
|
|
563
|
+
|
|
564
|
+
log.Printf("[CONV] Created %s for user %s → client %s", conv.ID, userID, assignedClientID)
|
|
565
|
+
return conv
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// POST /inbound - User sends a message (pushed via WebSocket to client)
|
|
569
|
+
// Supports full user identity for multi-user scenarios (like Feishu)
|
|
570
|
+
func (s *Server) handleInbound(w http.ResponseWriter, r *http.Request) {
|
|
571
|
+
if r.Method != http.MethodPost {
|
|
572
|
+
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
var req struct {
|
|
577
|
+
From string `json:"from"` // User ID (required)
|
|
578
|
+
Text string `json:"text"` // Query text (required)
|
|
579
|
+
Query string `json:"query"` // Alias for text
|
|
580
|
+
UserID string `json:"userId"` // Alias for from
|
|
581
|
+
User *UserInfo `json:"user"` // Full user info (optional)
|
|
582
|
+
Username string `json:"username,omitempty"` // Quick username field
|
|
583
|
+
TenantID string `json:"tenantId,omitempty"` // Multi-tenant support
|
|
584
|
+
Source string `json:"source,omitempty"` // Request source
|
|
585
|
+
}
|
|
586
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
587
|
+
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Resolve user ID from various fields
|
|
592
|
+
userID := req.From
|
|
593
|
+
if userID == "" {
|
|
594
|
+
userID = req.UserID
|
|
595
|
+
}
|
|
596
|
+
if userID == "" && req.User != nil {
|
|
597
|
+
userID = req.User.UserID
|
|
598
|
+
}
|
|
599
|
+
if userID == "" {
|
|
600
|
+
http.Error(w, "Missing user identifier (from, userId, or user.userId required)", http.StatusBadRequest)
|
|
601
|
+
return
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// Resolve query text
|
|
605
|
+
queryText := req.Text
|
|
606
|
+
if queryText == "" {
|
|
607
|
+
queryText = req.Query
|
|
608
|
+
}
|
|
609
|
+
if queryText == "" {
|
|
610
|
+
http.Error(w, "Missing query text (text or query required)", http.StatusBadRequest)
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Build UserInfo if not provided but quick fields are available
|
|
615
|
+
userInfo := req.User
|
|
616
|
+
if userInfo == nil && (req.Username != "" || req.TenantID != "" || req.Source != "") {
|
|
617
|
+
userInfo = &UserInfo{
|
|
618
|
+
UserID: userID,
|
|
619
|
+
Username: req.Username,
|
|
620
|
+
TenantID: req.TenantID,
|
|
621
|
+
Source: req.Source,
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
conv := s.getOrCreateConversation(userID, userInfo)
|
|
626
|
+
|
|
627
|
+
s.mu.Lock()
|
|
628
|
+
s.lastID++
|
|
629
|
+
msg := Message{
|
|
630
|
+
ID: fmt.Sprintf("msg-%d", s.lastID),
|
|
631
|
+
ConversationID: conv.ID,
|
|
632
|
+
ClientID: conv.ClientID,
|
|
633
|
+
From: userID,
|
|
634
|
+
To: "bot",
|
|
635
|
+
Text: queryText,
|
|
636
|
+
Timestamp: time.Now().UnixMilli(),
|
|
637
|
+
User: userInfo,
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Add to conversation history
|
|
641
|
+
conv.Messages = append(conv.Messages, msg)
|
|
642
|
+
conv.UpdatedAt = msg.Timestamp
|
|
643
|
+
s.mu.Unlock()
|
|
644
|
+
|
|
645
|
+
log.Printf("[INBOUND] %s → %s: %s (conv=%s, user=%+v)", msg.From, conv.ClientID, msg.Text, conv.ID, userInfo)
|
|
646
|
+
|
|
647
|
+
// Push to client via WebSocket
|
|
648
|
+
pushed := s.pushMessageToClient(msg)
|
|
649
|
+
|
|
650
|
+
w.Header().Set("Content-Type", "application/json")
|
|
651
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
652
|
+
"message": msg,
|
|
653
|
+
"delivered": pushed,
|
|
654
|
+
"websocket": true,
|
|
655
|
+
"conversationId": conv.ID,
|
|
656
|
+
"userId": userID,
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// GET /conversations/:id - Get conversation history
|
|
661
|
+
func (s *Server) handleGetConversation(w http.ResponseWriter, r *http.Request) {
|
|
662
|
+
convID := strings.TrimPrefix(r.URL.Path, "/conversations/")
|
|
663
|
+
if convID == "" {
|
|
664
|
+
http.Error(w, "Missing conversation ID", http.StatusBadRequest)
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
s.mu.RLock()
|
|
669
|
+
conv, ok := s.conversations[convID]
|
|
670
|
+
s.mu.RUnlock()
|
|
671
|
+
|
|
672
|
+
if !ok {
|
|
673
|
+
http.Error(w, "Conversation not found", http.StatusNotFound)
|
|
674
|
+
return
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
w.Header().Set("Content-Type", "application/json")
|
|
678
|
+
json.NewEncoder(w).Encode(conv)
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// GET /conversations - List all conversations (admin)
|
|
682
|
+
func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) {
|
|
683
|
+
s.mu.RLock()
|
|
684
|
+
convs := make([]*Conversation, 0, len(s.conversations))
|
|
685
|
+
for _, c := range s.conversations {
|
|
686
|
+
convs = append(convs, c)
|
|
687
|
+
}
|
|
688
|
+
s.mu.RUnlock()
|
|
689
|
+
|
|
690
|
+
w.Header().Set("Content-Type", "application/json")
|
|
691
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
692
|
+
"conversations": convs,
|
|
693
|
+
})
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// GET /outbox - View sent messages (debug)
|
|
697
|
+
func (s *Server) handleOutbox(w http.ResponseWriter, r *http.Request) {
|
|
698
|
+
s.mu.RLock()
|
|
699
|
+
messages := s.outbound
|
|
700
|
+
s.mu.RUnlock()
|
|
701
|
+
|
|
702
|
+
w.Header().Set("Content-Type", "application/json")
|
|
703
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
704
|
+
"messages": messages,
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// GET /health - Health check (no auth required)
|
|
709
|
+
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|
710
|
+
s.mu.RLock()
|
|
711
|
+
clientCount := len(s.clients)
|
|
712
|
+
connectedCount := 0
|
|
713
|
+
for _, c := range s.clients {
|
|
714
|
+
if c.Conn != nil {
|
|
715
|
+
connectedCount++
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
convCount := len(s.conversations)
|
|
719
|
+
s.mu.RUnlock()
|
|
720
|
+
|
|
721
|
+
w.Header().Set("Content-Type", "application/json")
|
|
722
|
+
json.NewEncoder(w).Encode(map[string]any{
|
|
723
|
+
"ok": true,
|
|
724
|
+
"server": "simplego-ws",
|
|
725
|
+
"clients": clientCount,
|
|
726
|
+
"connectedClients": connectedCount,
|
|
727
|
+
"conversations": convCount,
|
|
728
|
+
})
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
func main() {
|
|
732
|
+
port := flag.Int("port", 9876, "Server port")
|
|
733
|
+
adminToken := flag.String("admin-token", "", "Admin token (auto-generated if empty)")
|
|
734
|
+
flag.Parse()
|
|
735
|
+
|
|
736
|
+
if *adminToken == "" {
|
|
737
|
+
*adminToken = generateToken()
|
|
738
|
+
log.Printf("Generated admin token: %s", *adminToken)
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
server := NewServer(*adminToken)
|
|
742
|
+
|
|
743
|
+
// Public endpoints
|
|
744
|
+
http.HandleFunc("/health", server.handleHealth)
|
|
745
|
+
|
|
746
|
+
// WebSocket endpoint
|
|
747
|
+
http.HandleFunc("/ws", server.handleWebSocket)
|
|
748
|
+
|
|
749
|
+
// Admin endpoints
|
|
750
|
+
http.HandleFunc("/clients", func(w http.ResponseWriter, r *http.Request) {
|
|
751
|
+
if r.Method == http.MethodPost {
|
|
752
|
+
server.adminAuth(server.handleRegisterClient)(w, r)
|
|
753
|
+
} else {
|
|
754
|
+
server.adminAuth(server.handleListClients)(w, r)
|
|
755
|
+
}
|
|
756
|
+
})
|
|
757
|
+
http.HandleFunc("/conversations", server.adminAuth(server.handleListConversations))
|
|
758
|
+
|
|
759
|
+
// User endpoints (no auth - simulating external users)
|
|
760
|
+
http.HandleFunc("/inbound", server.handleInbound)
|
|
761
|
+
http.HandleFunc("/outbox", server.handleOutbox)
|
|
762
|
+
|
|
763
|
+
// Conversation lookup
|
|
764
|
+
http.HandleFunc("/conversations/", server.handleGetConversation)
|
|
765
|
+
|
|
766
|
+
addr := fmt.Sprintf(":%d", *port)
|
|
767
|
+
log.Printf("Simple Go WebSocket channel server starting on http://localhost%s", addr)
|
|
768
|
+
log.Printf("")
|
|
769
|
+
log.Printf("WebSocket endpoint:")
|
|
770
|
+
log.Printf(" ws://localhost%s/ws - Client WebSocket connection", addr)
|
|
771
|
+
log.Printf("")
|
|
772
|
+
log.Printf("Admin endpoints (use admin token):")
|
|
773
|
+
log.Printf(" POST /clients - Register OpenClaw client")
|
|
774
|
+
log.Printf(" GET /clients - List clients")
|
|
775
|
+
log.Printf(" GET /conversations - List all conversations")
|
|
776
|
+
log.Printf("")
|
|
777
|
+
log.Printf("Public endpoints:")
|
|
778
|
+
log.Printf(" POST /inbound - User message with identity (pushed via WebSocket)")
|
|
779
|
+
log.Printf(" Body: {from/userId, text/query, user?: {userId, username, tenantId, ...}}")
|
|
780
|
+
log.Printf(" GET /outbox - View sent messages")
|
|
781
|
+
log.Printf(" GET /health - Health check")
|
|
782
|
+
|
|
783
|
+
if err := http.ListenAndServe(addr, nil); err != nil {
|
|
784
|
+
log.Fatal(err)
|
|
785
|
+
}
|
|
786
|
+
}
|