package farm.hobbs.clippyauto.matrix import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.* import java.util.UUID /** * Lightweight Matrix REST API client. * No E2EE — designed for private homeserver use. * * Handles: login, message send, long-poll sync (fallback to FCM). */ class MatrixRestClient( private val homeserverUrl: String ) { private val httpClient = HttpClient(OkHttp) { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true }) } } var accessToken: String? = null private set private var syncToken: String? = null /** * Login with username/password, store access token. */ suspend fun login(user: String, password: String): Boolean { return try { val response = httpClient.post("$homeserverUrl/_matrix/client/v3/login") { contentType(ContentType.Application.Json) setBody(buildJsonObject { put("type", "m.login.password") put("user", user) put("password", password) putJsonObject("identifier") { put("type", "m.id.user") put("user", user) } }) } val body = response.body() accessToken = body["access_token"]?.jsonPrimitive?.content accessToken != null } catch (e: Exception) { false } } /** * Login with existing access token (from Keystore). */ fun loginWithToken(token: String) { accessToken = token } /** * Send a text message to a room with optional custom fields for session tracking. */ suspend fun sendMessage( roomId: String, body: String, source: String = "voice", sessionId: String? = null ): Boolean { val token = accessToken ?: return false val txnId = UUID.randomUUID().toString() return try { httpClient.put("$homeserverUrl/_matrix/client/v3/rooms/$roomId/send/m.room.message/$txnId") { bearerAuth(token) contentType(ContentType.Application.Json) setBody(buildJsonObject { put("msgtype", "m.text") put("body", body) put("farm.hobbs.clippy.source", source) sessionId?.let { put("farm.hobbs.clippy.session_id", it) } }) } true } catch (e: Exception) { false } } /** * Send a session signal (start, end, resume). */ suspend fun sendSessionSignal(roomId: String, signal: String, sessionId: String): Boolean { val token = accessToken ?: return false val txnId = UUID.randomUUID().toString() val signalBody = when (signal) { "session_start" -> "[ClippyAuto session started — commute mode, voice-first]" "session_resume" -> "[ClippyAuto session resumed]" "session_end" -> "[ClippyAuto session ended]" else -> "[ClippyAuto signal: $signal]" } return try { httpClient.put("$homeserverUrl/_matrix/client/v3/rooms/$roomId/send/m.room.message/$txnId") { bearerAuth(token) contentType(ContentType.Application.Json) setBody(buildJsonObject { put("msgtype", "m.text") put("body", signalBody) put("farm.hobbs.clippy.signal", signal) put("farm.hobbs.clippy.session_id", sessionId) }) } true } catch (e: Exception) { false } } /** * Long-poll sync — fallback when FCM doesn't deliver. * Returns list of messages from the target room since last sync. */ suspend fun sync( roomId: String, timeoutMs: Int = 30000 ): List { val token = accessToken ?: return emptyList() return try { val url = buildString { append("$homeserverUrl/_matrix/client/v3/sync?timeout=$timeoutMs") syncToken?.let { append("&since=$it") } } val response = httpClient.get(url) { bearerAuth(token) } val body = response.body() syncToken = body["next_batch"]?.jsonPrimitive?.content val rooms = body["rooms"]?.jsonObject ?.get("join")?.jsonObject ?.get(roomId)?.jsonObject val timeline = rooms?.get("timeline")?.jsonObject ?.get("events")?.jsonArray ?: return emptyList() timeline.mapNotNull { event -> val obj = event.jsonObject val type = obj["type"]?.jsonPrimitive?.content if (type != "m.room.message") return@mapNotNull null val content = obj["content"]?.jsonObject ?: return@mapNotNull null val sender = obj["sender"]?.jsonPrimitive?.content ?: return@mapNotNull null val msgBody = content["body"]?.jsonPrimitive?.content ?: return@mapNotNull null MatrixMessage( sender = sender, body = msgBody, source = content["farm.hobbs.clippy.source"]?.jsonPrimitive?.content, sessionId = content["farm.hobbs.clippy.session_id"]?.jsonPrimitive?.content, signal = content["farm.hobbs.clippy.signal"]?.jsonPrimitive?.content ) } } catch (e: Exception) { emptyList() } } fun shutdown() { httpClient.close() } } data class MatrixMessage( val sender: String, val body: String, val source: String? = null, val sessionId: String? = null, val signal: String? = null )