package com.inou.clawdnode.protocol import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonParser import com.google.gson.annotations.SerializedName /** * Protocol messages between ClawdNode and Gateway. * * Gateway Protocol v3: * - Connect to /ws endpoint * - Receive connect.challenge event with nonce * - Send connect request with role, caps, commands, auth * - Receive hello-ok response * * Phone → Gateway: Events (notifications, calls, etc.) * Gateway → Phone: Commands (answer call, screenshot, etc.) */ // ============================================ // PROTOCOL CONSTANTS // ============================================ object Protocol { const val VERSION = 3 const val CLIENT_ID = "android-node" const val PLATFORM = "android" const val MODE = "node" const val ROLE = "node" } // ============================================ // HANDSHAKE MESSAGES // ============================================ /** * Protocol frame types */ enum class FrameType { @SerializedName("req") REQUEST, @SerializedName("res") RESPONSE, @SerializedName("event") EVENT } /** * Base protocol frame */ data class ProtocolFrame( @SerializedName("type") val type: String, @SerializedName("id") val id: String? = null, @SerializedName("method") val method: String? = null, @SerializedName("event") val event: String? = null, @SerializedName("params") val params: JsonObject? = null, @SerializedName("payload") val payload: JsonObject? = null, @SerializedName("ok") val ok: Boolean? = null, @SerializedName("error") val error: JsonObject? = null ) { companion object { fun fromJson(json: String): ProtocolFrame? { return try { Gson().fromJson(json, ProtocolFrame::class.java) } catch (e: Exception) { null } } } } /** * Connect challenge event payload * Gateway → Client (first message after connect) */ data class ConnectChallengePayload( @SerializedName("nonce") val nonce: String, @SerializedName("ts") val timestamp: Long ) /** * Client info for connect request */ data class ClientInfo( @SerializedName("id") val id: String = Protocol.CLIENT_ID, @SerializedName("version") val version: String, @SerializedName("platform") val platform: String = Protocol.PLATFORM, @SerializedName("mode") val mode: String = Protocol.MODE ) /** * Device identity for connect request */ data class DeviceInfo( @SerializedName("id") val id: String, @SerializedName("publicKey") val publicKey: String? = null, @SerializedName("signature") val signature: String? = null, @SerializedName("signedAt") val signedAt: Long? = null, @SerializedName("nonce") val nonce: String? = null ) /** * Auth info for connect request */ data class AuthInfo( @SerializedName("token") val token: String ) /** * Connect request parameters */ data class ConnectParams( @SerializedName("minProtocol") val minProtocol: Int = Protocol.VERSION, @SerializedName("maxProtocol") val maxProtocol: Int = Protocol.VERSION, @SerializedName("client") val client: ClientInfo, @SerializedName("role") val role: String = Protocol.ROLE, @SerializedName("scopes") val scopes: List = emptyList(), @SerializedName("caps") val caps: List, @SerializedName("commands") val commands: List, @SerializedName("permissions") val permissions: Map = emptyMap(), @SerializedName("auth") val auth: AuthInfo, @SerializedName("locale") val locale: String = "en-US", @SerializedName("userAgent") val userAgent: String, @SerializedName("device") val device: DeviceInfo ) /** * Connect request frame */ data class ConnectRequest( @SerializedName("type") val type: String = "req", @SerializedName("id") val id: String, @SerializedName("method") val method: String = "connect", @SerializedName("params") val params: ConnectParams ) { fun toJson(): String = Gson().toJson(this) } /** * Hello-ok response payload */ data class HelloOkPayload( @SerializedName("type") val type: String, // "hello-ok" @SerializedName("protocol") val protocol: Int, @SerializedName("policy") val policy: PolicyInfo? = null, @SerializedName("auth") val auth: AuthResponse? = null ) data class PolicyInfo( @SerializedName("tickIntervalMs") val tickIntervalMs: Long? = null ) data class AuthResponse( @SerializedName("deviceToken") val deviceToken: String? = null, @SerializedName("role") val role: String? = null, @SerializedName("scopes") val scopes: List? = null ) // ============================================ // NODE INVOKE (Gateway → Phone) // ============================================ /** * Node invoke request from gateway * This is how the gateway sends commands to nodes */ data class NodeInvokeParams( @SerializedName("command") val command: String, @SerializedName("args") val args: JsonObject? = null ) // ============================================ // EVENTS (Phone → Gateway) // ============================================ sealed class NodeEvent { abstract fun toProtocolFrame(requestId: String): String } /** * Generic event wrapper for sending events to gateway */ data class EventFrame( @SerializedName("type") val type: String = "event", @SerializedName("event") val event: String, @SerializedName("payload") val payload: Any ) { fun toJson(): String = Gson().toJson(this) } /** * Response frame for node.invoke requests */ data class ResponseFrame( @SerializedName("type") val type: String = "res", @SerializedName("id") val id: String, @SerializedName("ok") val ok: Boolean, @SerializedName("payload") val payload: Any? = null, @SerializedName("error") val error: ErrorPayload? = null ) { fun toJson(): String = Gson().toJson(this) } data class ErrorPayload( @SerializedName("code") val code: String, @SerializedName("message") val message: String ) data class NotificationEvent( val id: String, val app: String, val packageName: String, val title: String?, val text: String?, val actions: List = emptyList(), val timestamp: Long = System.currentTimeMillis() ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.notification", payload = mapOf( "id" to id, "app" to app, "package" to packageName, "title" to title, "text" to text, "actions" to actions, "timestamp" to timestamp ) ).toJson() } } data class CallIncomingEvent( val callId: String, val number: String?, val contact: String?, val timestamp: Long = System.currentTimeMillis() ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.call.incoming", payload = mapOf( "callId" to callId, "number" to number, "contact" to contact, "timestamp" to timestamp ) ).toJson() } } data class CallEndedEvent( val callId: String, val durationSeconds: Int, val outcome: String, // answered, rejected, missed, voicemail val transcript: String? = null ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.call.ended", payload = mapOf( "callId" to callId, "duration" to durationSeconds, "outcome" to outcome, "transcript" to transcript ) ).toJson() } } data class CallAudioEvent( val callId: String, val transcript: String, val isFinal: Boolean = false ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.call.audio", payload = mapOf( "callId" to callId, "transcript" to transcript, "isFinal" to isFinal ) ).toJson() } } data class ScreenshotEvent( val width: Int, val height: Int, val base64: String ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.screenshot", payload = mapOf( "width" to width, "height" to height, "base64" to base64 ) ).toJson() } } data class StatusEvent( val connected: Boolean, val batteryPercent: Int, val permissions: Map ) : NodeEvent() { override fun toProtocolFrame(requestId: String): String { return EventFrame( event = "node.status", payload = mapOf( "connected" to connected, "battery" to batteryPercent, "permissions" to permissions ) ).toJson() } } // ============================================ // COMMANDS (Gateway → Phone via node.invoke) // ============================================ sealed class NodeCommand { abstract val requestId: String? companion object { /** * Parse a node.invoke request from the gateway */ fun fromInvoke(requestId: String, params: NodeInvokeParams): NodeCommand? { val args = params.args return when (params.command) { "screenshot" -> ScreenshotCommand(requestId) "notification.action" -> NotificationActionCommand( requestId = requestId, notificationId = args?.get("id")?.asString ?: return null, action = args.get("action")?.asString ?: return null, text = args.get("text")?.asString ) "notification.dismiss" -> NotificationDismissCommand( requestId = requestId, notificationId = args?.get("id")?.asString ?: return null ) "call.answer" -> CallAnswerCommand( requestId = requestId, callId = args?.get("callId")?.asString ?: return null, greeting = args.get("greeting")?.asString ) "call.reject" -> CallRejectCommand( requestId = requestId, callId = args?.get("callId")?.asString ?: return null, reason = args.get("reason")?.asString ) "call.silence" -> CallSilenceCommand( requestId = requestId, callId = args?.get("callId")?.asString ?: return null ) "call.speak" -> CallSpeakCommand( requestId = requestId, callId = args?.get("callId")?.asString ?: return null, text = args.get("text")?.asString ?: return null, voice = args.get("voice")?.asString ) "call.hangup" -> CallHangupCommand( requestId = requestId, callId = args?.get("callId")?.asString ?: return null ) else -> null } } } } data class ScreenshotCommand( override val requestId: String? = null ) : NodeCommand() data class NotificationActionCommand( override val requestId: String? = null, val notificationId: String, val action: String, val text: String? = null // For reply actions ) : NodeCommand() data class NotificationDismissCommand( override val requestId: String? = null, val notificationId: String ) : NodeCommand() data class CallAnswerCommand( override val requestId: String? = null, val callId: String, val greeting: String? = null // TTS greeting to play ) : NodeCommand() data class CallRejectCommand( override val requestId: String? = null, val callId: String, val reason: String? = null ) : NodeCommand() data class CallSilenceCommand( override val requestId: String? = null, val callId: String ) : NodeCommand() data class CallSpeakCommand( override val requestId: String? = null, val callId: String, val text: String, // Text to speak via TTS val voice: String? = null // TTS voice preference ) : NodeCommand() data class CallHangupCommand( override val requestId: String? = null, val callId: String ) : NodeCommand()