clawdnode-android/app/src/main/java/com/inou/clawdnode/protocol/Messages.kt

420 lines
12 KiB
Kotlin

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<String> = emptyList(),
@SerializedName("caps") val caps: List<String>,
@SerializedName("commands") val commands: List<String>,
@SerializedName("permissions") val permissions: Map<String, Boolean> = 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<String>? = 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<String> = 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<String, Boolean>
) : 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()