mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-06-09 22:12:07 +00:00
bdb60de7ae
- Update go.mod and Dockerfile to Go 1.26 - Apply `go fix ./...` stdlib modernizations across 170+ files - Add `go fix` to post-implementation checklist in CLAUDE.md - Fix go fix misapplied rewrite in loop_history.go
269 lines
6.5 KiB
Go
269 lines
6.5 KiB
Go
package cmd
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/config"
|
|
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
|
)
|
|
|
|
func pairingCmd() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "pairing",
|
|
Short: "Manage device pairing (approve, list, revoke)",
|
|
}
|
|
|
|
cmd.AddCommand(pairingApproveCmd())
|
|
cmd.AddCommand(pairingListCmd())
|
|
cmd.AddCommand(pairingRevokeCmd())
|
|
|
|
return cmd
|
|
}
|
|
|
|
func pairingApproveCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "approve [code]",
|
|
Short: "Approve a pairing code (interactive if no code given)",
|
|
Args: cobra.MaximumNArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
var code string
|
|
if len(args) == 1 {
|
|
code = args[0]
|
|
} else {
|
|
code = pairingInteractiveSelect()
|
|
if code == "" {
|
|
return
|
|
}
|
|
}
|
|
|
|
pairingApproveByCode(code)
|
|
},
|
|
}
|
|
}
|
|
|
|
// pairingInteractiveSelect fetches pending pairings from the gateway and lets the user pick one.
|
|
func pairingInteractiveSelect() string {
|
|
fmt.Println("Fetching pending pairings...")
|
|
fmt.Println()
|
|
|
|
resp, err := gatewayRPC(protocol.MethodPairingList, nil)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if !resp.OK {
|
|
fmt.Printf("Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Parse the payload into pending list
|
|
raw, err := json.Marshal(resp.Payload)
|
|
if err != nil {
|
|
fmt.Printf("Error parsing response: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var listResult struct {
|
|
Pending []struct {
|
|
Code string `json:"code"`
|
|
SenderID string `json:"sender_id"`
|
|
Channel string `json:"channel"`
|
|
ChatID string `json:"chat_id"`
|
|
AccountID string `json:"account_id"`
|
|
CreatedAt int64 `json:"created_at"`
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
} `json:"pending"`
|
|
}
|
|
if err := json.Unmarshal(raw, &listResult); err != nil {
|
|
fmt.Printf("Error parsing pairing list: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if len(listResult.Pending) == 0 {
|
|
fmt.Println("No pending pairing requests.")
|
|
return ""
|
|
}
|
|
|
|
fmt.Printf("Found %d pending request(s).\n\n", len(listResult.Pending))
|
|
|
|
// Build select options
|
|
options := make([]SelectOption[string], 0, len(listResult.Pending))
|
|
for _, p := range listResult.Pending {
|
|
createdAt := time.UnixMilli(p.CreatedAt)
|
|
ago := time.Since(createdAt).Truncate(time.Second)
|
|
label := fmt.Sprintf("[%s] %s / %s (%s ago)", p.Code, p.Channel, p.SenderID, ago)
|
|
options = append(options, SelectOption[string]{Label: label, Value: p.Code})
|
|
}
|
|
|
|
selected, err := promptSelect("Select a pairing request to approve", options, 0)
|
|
if err != nil {
|
|
fmt.Println("Cancelled.")
|
|
return ""
|
|
}
|
|
|
|
return selected
|
|
}
|
|
|
|
func pairingApproveByCode(code string) {
|
|
params, _ := json.Marshal(map[string]string{
|
|
"code": code,
|
|
"approvedBy": "cli-operator",
|
|
})
|
|
|
|
resp, err := gatewayRPC(protocol.MethodPairingApprove, params)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !resp.OK {
|
|
fmt.Printf("Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Pairing approved! Code: %s\n", code)
|
|
|
|
if resp.Payload != nil {
|
|
data, _ := json.MarshalIndent(resp.Payload, "", " ")
|
|
fmt.Println(string(data))
|
|
}
|
|
}
|
|
|
|
func pairingListCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "list",
|
|
Short: "List pending and paired devices",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
resp, err := gatewayRPC(protocol.MethodPairingList, nil)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !resp.OK {
|
|
fmt.Printf("Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
data, _ := json.MarshalIndent(resp.Payload, "", " ")
|
|
fmt.Println(string(data))
|
|
},
|
|
}
|
|
}
|
|
|
|
func pairingRevokeCmd() *cobra.Command {
|
|
return &cobra.Command{
|
|
Use: "revoke <channel> <senderId>",
|
|
Short: "Revoke a paired device",
|
|
Args: cobra.ExactArgs(2),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
params, _ := json.Marshal(map[string]string{
|
|
"channel": args[0],
|
|
"senderId": args[1],
|
|
})
|
|
|
|
resp, err := gatewayRPC(protocol.MethodPairingRevoke, params)
|
|
if err != nil {
|
|
fmt.Printf("Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if !resp.OK {
|
|
fmt.Printf("Failed: %s\n", resp.Error.Message)
|
|
os.Exit(1)
|
|
}
|
|
|
|
fmt.Printf("Revoked pairing for %s/%s\n", args[0], args[1])
|
|
},
|
|
}
|
|
}
|
|
|
|
// gatewayRPC connects to the running gateway, authenticates, sends an RPC call, and returns the response.
|
|
func gatewayRPC(method string, params json.RawMessage) (*protocol.ResponseFrame, error) {
|
|
cfg, err := config.Load(resolveConfigPath())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load config: %w", err)
|
|
}
|
|
|
|
host := cfg.Gateway.Host
|
|
if host == "0.0.0.0" {
|
|
host = "127.0.0.1"
|
|
}
|
|
|
|
u := url.URL{Scheme: "ws", Host: fmt.Sprintf("%s:%d", host, cfg.Gateway.Port), Path: "/ws"}
|
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("connect to gateway at %s: %w", u.String(), err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
// Step 1: Send connect handshake
|
|
connectParams, _ := json.Marshal(map[string]any{
|
|
"token": cfg.Gateway.Token,
|
|
"protocol": protocol.ProtocolVersion,
|
|
})
|
|
connectReq := protocol.RequestFrame{
|
|
Type: protocol.FrameTypeRequest,
|
|
ID: "cli-connect",
|
|
Method: protocol.MethodConnect,
|
|
Params: connectParams,
|
|
}
|
|
if err := conn.WriteJSON(connectReq); err != nil {
|
|
return nil, fmt.Errorf("send connect: %w", err)
|
|
}
|
|
|
|
// Read connect response
|
|
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
var connectResp protocol.ResponseFrame
|
|
if err := conn.ReadJSON(&connectResp); err != nil {
|
|
return nil, fmt.Errorf("read connect response: %w", err)
|
|
}
|
|
if !connectResp.OK {
|
|
msg := "unknown error"
|
|
if connectResp.Error != nil {
|
|
msg = connectResp.Error.Message
|
|
}
|
|
return nil, fmt.Errorf("connect failed: %s", msg)
|
|
}
|
|
|
|
// Step 2: Send the RPC call
|
|
rpcReq := protocol.RequestFrame{
|
|
Type: protocol.FrameTypeRequest,
|
|
ID: "cli-rpc",
|
|
Method: method,
|
|
Params: params,
|
|
}
|
|
if err := conn.WriteJSON(rpcReq); err != nil {
|
|
return nil, fmt.Errorf("send RPC: %w", err)
|
|
}
|
|
|
|
// Read response (skip events, find response with matching ID)
|
|
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
for {
|
|
_, msg, err := conn.ReadMessage()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read response: %w", err)
|
|
}
|
|
|
|
frameType, _ := protocol.ParseFrameType(msg)
|
|
if frameType == protocol.FrameTypeEvent {
|
|
continue // skip events
|
|
}
|
|
|
|
var resp protocol.ResponseFrame
|
|
if err := json.Unmarshal(msg, &resp); err != nil {
|
|
return nil, fmt.Errorf("parse response: %w", err)
|
|
}
|
|
if resp.ID == "cli-rpc" {
|
|
return &resp, nil
|
|
}
|
|
}
|
|
}
|