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/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
+ }