420 lines
12 KiB
Kotlin
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()
|