390 lines
13 KiB
Kotlin
390 lines
13 KiB
Kotlin
package com.inou.clawdnode.service
|
|
|
|
import android.util.Log
|
|
import com.google.gson.Gson
|
|
import com.google.gson.JsonObject
|
|
import com.inou.clawdnode.BuildConfig
|
|
import com.inou.clawdnode.ClawdNodeApp
|
|
import com.inou.clawdnode.protocol.Protocol
|
|
import com.inou.clawdnode.protocol.*
|
|
import com.inou.clawdnode.security.DeviceIdentity
|
|
import kotlinx.coroutines.*
|
|
import okhttp3.*
|
|
import java.util.UUID
|
|
import java.util.concurrent.TimeUnit
|
|
import java.util.concurrent.atomic.AtomicInteger
|
|
|
|
/**
|
|
* WebSocket client for Clawdbot Gateway connection.
|
|
*
|
|
* Implements Gateway Protocol v3:
|
|
* 1. Connect to /ws endpoint
|
|
* 2. Receive connect.challenge event with nonce
|
|
* 3. Send connect request with role, caps, commands, auth
|
|
* 4. Handle hello-ok response
|
|
*
|
|
* After handshake, handles message routing for node commands.
|
|
*/
|
|
class GatewayClient(
|
|
private val onCommand: (NodeCommand) -> Unit,
|
|
private val onConnectionChange: (Boolean) -> Unit,
|
|
var onLog: ((String) -> Unit)? = null
|
|
) {
|
|
|
|
private fun log(message: String) {
|
|
Log.d(tag, message)
|
|
onLog?.invoke(message)
|
|
}
|
|
|
|
private fun logError(message: String, t: Throwable? = null) {
|
|
if (t != null) {
|
|
Log.e(tag, message, t)
|
|
onLog?.invoke("ERROR: $message - ${t.message}")
|
|
} else {
|
|
Log.e(tag, message)
|
|
onLog?.invoke("ERROR: $message")
|
|
}
|
|
}
|
|
private val tag = "GatewayClient"
|
|
private val gson = Gson()
|
|
|
|
private val client = OkHttpClient.Builder()
|
|
.connectTimeout(10, TimeUnit.SECONDS)
|
|
.readTimeout(0, TimeUnit.SECONDS) // No timeout for WebSocket
|
|
.writeTimeout(10, TimeUnit.SECONDS)
|
|
.pingInterval(30, TimeUnit.SECONDS)
|
|
.build()
|
|
|
|
private var webSocket: WebSocket? = null
|
|
private var isConnected = false
|
|
private var isHandshakeComplete = false
|
|
private var shouldReconnect = true
|
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
|
private val requestIdCounter = AtomicInteger(0)
|
|
|
|
private val auditLog get() = ClawdNodeApp.instance.auditLog
|
|
private val tokenStore get() = ClawdNodeApp.instance.tokenStore
|
|
|
|
private val deviceIdentity by lazy { DeviceIdentity(ClawdNodeApp.instance) }
|
|
|
|
// Node capabilities
|
|
private val caps = listOf("notifications", "calls", "voice")
|
|
private val commands = listOf(
|
|
"notification.action",
|
|
"notification.dismiss",
|
|
"call.answer",
|
|
"call.reject",
|
|
"call.speak",
|
|
"call.hangup"
|
|
)
|
|
|
|
fun connect() {
|
|
val url = tokenStore.gatewayUrl ?: run {
|
|
log("No gateway URL configured")
|
|
return
|
|
}
|
|
|
|
if (tokenStore.gatewayToken == null) {
|
|
log("No gateway token configured")
|
|
return
|
|
}
|
|
|
|
shouldReconnect = true
|
|
isHandshakeComplete = false
|
|
|
|
// Build WebSocket URL - connect to /ws (not /ws/node)
|
|
val wsUrl = buildWsUrl(url)
|
|
log("Connecting to $wsUrl")
|
|
|
|
val request = Request.Builder()
|
|
.url(wsUrl)
|
|
.build()
|
|
|
|
webSocket = client.newWebSocket(request, object : WebSocketListener() {
|
|
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
log("WebSocket connected, waiting for challenge...")
|
|
// Don't set isConnected yet - wait for handshake
|
|
}
|
|
|
|
override fun onMessage(webSocket: WebSocket, text: String) {
|
|
log("Received: $text")
|
|
handleMessage(text)
|
|
}
|
|
|
|
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
|
log("Connection closing: $code $reason")
|
|
webSocket.close(1000, null)
|
|
}
|
|
|
|
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
|
|
log("Connection closed: $code $reason")
|
|
handleDisconnect()
|
|
}
|
|
|
|
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
logError("Connection failed", t)
|
|
auditLog.log("GATEWAY_ERROR", "Connection failed: ${t.message}")
|
|
handleDisconnect()
|
|
}
|
|
})
|
|
}
|
|
|
|
fun disconnect() {
|
|
shouldReconnect = false
|
|
webSocket?.close(1000, "Client disconnect")
|
|
webSocket = null
|
|
isConnected = false
|
|
isHandshakeComplete = false
|
|
onConnectionChange(false)
|
|
}
|
|
|
|
fun send(event: NodeEvent) {
|
|
val requestId = generateRequestId()
|
|
val json = event.toProtocolFrame(requestId)
|
|
log("Sending: $json")
|
|
|
|
if (isConnected && isHandshakeComplete) {
|
|
webSocket?.send(json)
|
|
} else {
|
|
log("Not connected or handshake incomplete, cannot send event")
|
|
// TODO: Queue for retry
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send a response to a node.invoke request
|
|
*/
|
|
fun sendResponse(requestId: String, success: Boolean, payload: Any? = null, error: String? = null) {
|
|
val frame = if (success) {
|
|
ResponseFrame(id = requestId, ok = true, payload = payload)
|
|
} else {
|
|
ResponseFrame(id = requestId, ok = false, error = ErrorPayload("ERROR", error ?: "Unknown error"))
|
|
}
|
|
|
|
val json = frame.toJson()
|
|
log("Sending response: $json")
|
|
|
|
if (isConnected && isHandshakeComplete) {
|
|
webSocket?.send(json)
|
|
}
|
|
}
|
|
|
|
private fun buildWsUrl(baseUrl: String): String {
|
|
// Convert http(s) to ws(s)
|
|
val wsBase = baseUrl
|
|
.replace("http://", "ws://")
|
|
.replace("https://", "wss://")
|
|
.trimEnd('/')
|
|
|
|
// Connect to /ws endpoint (no token in URL - sent in handshake)
|
|
return "$wsBase/ws"
|
|
}
|
|
|
|
private fun handleMessage(json: String) {
|
|
val frame = ProtocolFrame.fromJson(json)
|
|
if (frame == null) {
|
|
log("Failed to parse frame: $json")
|
|
return
|
|
}
|
|
|
|
when (frame.type) {
|
|
"event" -> handleEvent(frame)
|
|
"req" -> handleRequest(frame)
|
|
"res" -> handleResponse(frame)
|
|
else -> log("Unknown frame type: ${frame.type}")
|
|
}
|
|
}
|
|
|
|
private fun handleEvent(frame: ProtocolFrame) {
|
|
when (frame.event) {
|
|
"connect.challenge" -> handleConnectChallenge(frame)
|
|
else -> log("Received event: ${frame.event}")
|
|
}
|
|
}
|
|
|
|
private fun handleConnectChallenge(frame: ProtocolFrame) {
|
|
log("Received connect.challenge, sending handshake...")
|
|
|
|
val payload = frame.payload
|
|
val nonce = payload?.get("nonce")?.asString
|
|
|
|
if (nonce == null) {
|
|
logError("No nonce in connect.challenge")
|
|
return
|
|
}
|
|
|
|
val token = tokenStore.gatewayToken ?: run {
|
|
logError("No token available for handshake")
|
|
return
|
|
}
|
|
|
|
// Sign the challenge nonce and get device identity
|
|
// Wrap in try/catch - keystore operations can fail on first run or if hardware unavailable
|
|
val signedChallenge: DeviceIdentity.SignedChallenge?
|
|
val deviceId: String
|
|
val publicKey: String?
|
|
|
|
try {
|
|
deviceId = deviceIdentity.deviceId
|
|
signedChallenge = deviceIdentity.signChallenge(nonce)
|
|
publicKey = deviceIdentity.publicKey
|
|
log("Device identity ready: id=${deviceId.take(8)}..., signed challenge")
|
|
} catch (e: Exception) {
|
|
logError("Failed to initialize device identity or sign challenge", e)
|
|
// Cannot proceed without device identity for non-local connections
|
|
webSocket?.close(1000, "Device identity initialization failed: ${e.message}")
|
|
return
|
|
}
|
|
|
|
val connectRequest = ConnectRequest(
|
|
id = generateRequestId(),
|
|
params = ConnectParams(
|
|
client = ClientInfo(
|
|
id = Protocol.CLIENT_ID,
|
|
version = getAppVersion(),
|
|
platform = Protocol.PLATFORM,
|
|
mode = Protocol.MODE
|
|
),
|
|
role = Protocol.ROLE,
|
|
caps = caps,
|
|
commands = commands,
|
|
permissions = mapOf(
|
|
"notifications.read" to true,
|
|
"notifications.action" to true,
|
|
"calls.answer" to true,
|
|
"calls.reject" to true,
|
|
"calls.speak" to true
|
|
),
|
|
auth = AuthInfo(token = token),
|
|
userAgent = "clawdnode-android/${getAppVersion()}",
|
|
device = DeviceInfo(
|
|
id = deviceId,
|
|
publicKey = publicKey,
|
|
signature = signedChallenge.signature,
|
|
signedAt = signedChallenge.signedAt,
|
|
nonce = signedChallenge.nonce
|
|
)
|
|
)
|
|
)
|
|
|
|
val requestJson = connectRequest.toJson()
|
|
log("Sending connect request: $requestJson")
|
|
webSocket?.send(requestJson)
|
|
}
|
|
|
|
private fun handleRequest(frame: ProtocolFrame) {
|
|
when (frame.method) {
|
|
"node.invoke" -> handleNodeInvoke(frame)
|
|
else -> {
|
|
log("Unknown request method: ${frame.method}")
|
|
// Send error response
|
|
frame.id?.let { id ->
|
|
sendResponse(id, false, error = "Unknown method: ${frame.method}")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun handleNodeInvoke(frame: ProtocolFrame) {
|
|
val requestId = frame.id ?: run {
|
|
logError("node.invoke missing request id")
|
|
return
|
|
}
|
|
|
|
val params = frame.params ?: run {
|
|
logError("node.invoke missing params")
|
|
sendResponse(requestId, false, error = "Missing params")
|
|
return
|
|
}
|
|
|
|
val command = params.get("command")?.asString
|
|
val args = params.get("args")?.asJsonObject
|
|
|
|
if (command == null) {
|
|
logError("node.invoke missing command")
|
|
sendResponse(requestId, false, error = "Missing command")
|
|
return
|
|
}
|
|
|
|
log("Received node.invoke: $command")
|
|
auditLog.logCommand(command, "gateway", true)
|
|
|
|
val invokeParams = NodeInvokeParams(command = command, args = args)
|
|
val nodeCommand = NodeCommand.fromInvoke(requestId, invokeParams)
|
|
|
|
if (nodeCommand != null) {
|
|
onCommand(nodeCommand)
|
|
} else {
|
|
log("Unknown command: $command")
|
|
sendResponse(requestId, false, error = "Unknown command: $command")
|
|
}
|
|
}
|
|
|
|
private fun handleResponse(frame: ProtocolFrame) {
|
|
val ok = frame.ok ?: false
|
|
val payload = frame.payload
|
|
|
|
// Check if this is hello-ok response
|
|
val payloadType = payload?.get("type")?.asString
|
|
if (payloadType == "hello-ok") {
|
|
handleHelloOk(frame)
|
|
return
|
|
}
|
|
|
|
if (!ok) {
|
|
val error = frame.error
|
|
logError("Request failed: $error")
|
|
}
|
|
}
|
|
|
|
private fun handleHelloOk(frame: ProtocolFrame) {
|
|
log("Received hello-ok, handshake complete!")
|
|
|
|
val payload = frame.payload
|
|
val protocol = payload?.get("protocol")?.asInt ?: Protocol.VERSION
|
|
|
|
// Check for device token
|
|
val auth = payload?.get("auth")?.asJsonObject
|
|
val deviceToken = auth?.get("deviceToken")?.asString
|
|
if (deviceToken != null) {
|
|
log("Received device token, storing for future connects")
|
|
// Could store this for future use
|
|
}
|
|
|
|
isConnected = true
|
|
isHandshakeComplete = true
|
|
onConnectionChange(true)
|
|
auditLog.log("GATEWAY_CONNECTED", "Protocol v$protocol handshake complete")
|
|
}
|
|
|
|
private fun handleDisconnect() {
|
|
isConnected = false
|
|
isHandshakeComplete = false
|
|
onConnectionChange(false)
|
|
|
|
if (shouldReconnect) {
|
|
auditLog.log("GATEWAY_RECONNECT", "Scheduling reconnect in 5s")
|
|
scope.launch {
|
|
delay(5000)
|
|
if (shouldReconnect) {
|
|
connect()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private fun generateRequestId(): String {
|
|
return "req_${requestIdCounter.incrementAndGet()}_${UUID.randomUUID().toString().take(8)}"
|
|
}
|
|
|
|
private fun getAppVersion(): String {
|
|
return try {
|
|
BuildConfig.VERSION_NAME
|
|
} catch (e: Exception) {
|
|
"0.1.0"
|
|
}
|
|
}
|
|
|
|
fun isConnected() = isConnected && isHandshakeComplete
|
|
}
|