Skip to content

Commit 825d792

Browse files
committed
feat(watch): add voice command to post messages to Ant Farm rooms
1 parent 6df080a commit 825d792

File tree

2 files changed

+146
-2
lines changed

2 files changed

+146
-2
lines changed

app/src/main/java/com/thinkoff/clawwatch/ClawRunner.kt

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,13 +95,22 @@ class ClawRunner(private val context: Context) {
9595
fun saveMaxTokens(n: Int) = prefs.edit().putInt(PREF_MAX_TOKENS, n).apply()
9696
fun saveRagMode(mode: String) = prefs.edit().putString(PREF_RAG_MODE, mode).apply()
9797

98+
private fun getStringSetting(key: String, default: String? = null): String? {
99+
val secure = prefs.getString(key, null)
100+
if (!secure.isNullOrBlank()) return secure
101+
val legacy = context
102+
.getSharedPreferences("clawwatch_prefs", Context.MODE_PRIVATE)
103+
.getString(key, null)
104+
return legacy ?: default
105+
}
106+
98107
fun hasApiKey(): Boolean = prefs.getString(PREF_API_KEY, null)?.isNotBlank() == true
99108
private fun getApiKey(): String? = prefs.getString(PREF_API_KEY, null)
100109
private fun getBraveKey(): String? = prefs.getString(PREF_BRAVE_KEY, null)
101110
private fun getTavilyKey(): String? = prefs.getString(PREF_TAVILY_KEY, null)
102-
private fun getAntFarmKey(): String? = prefs.getString(PREF_ANTFARM_KEY, null)
111+
private fun getAntFarmKey(): String? = getStringSetting(PREF_ANTFARM_KEY)
103112
private fun getAntFarmRooms(): List<String> =
104-
(prefs.getString(PREF_ANTFARM_ROOMS, DEFAULT_FAMILY_ROOMS) ?: DEFAULT_FAMILY_ROOMS)
113+
(getStringSetting(PREF_ANTFARM_ROOMS, DEFAULT_FAMILY_ROOMS) ?: DEFAULT_FAMILY_ROOMS)
105114
.split(',')
106115
.map { it.trim() }
107116
.filter { it.isNotBlank() }
@@ -427,6 +436,60 @@ class ClawRunner(private val context: Context) {
427436
)
428437
}
429438

439+
suspend fun postMessageToRoom(room: String, message: String): Result<String> = withContext(Dispatchers.IO) {
440+
val antFarmKey = getAntFarmKey()
441+
?: return@withContext Result.failure(
442+
RuntimeException("room access key is missing")
443+
)
444+
445+
val targetRoom = room.trim()
446+
.ifBlank { getAntFarmRooms().firstOrNull().orEmpty() }
447+
.ifBlank {
448+
return@withContext Result.failure(RuntimeException("room name is missing"))
449+
}
450+
451+
val cleanBody = message
452+
.trim()
453+
.replace(Regex("\\s+"), " ")
454+
.take(500)
455+
if (cleanBody.isBlank()) {
456+
return@withContext Result.failure(RuntimeException("message was empty"))
457+
}
458+
459+
return@withContext try {
460+
val encodedRoom = URLEncoder.encode(targetRoom, "UTF-8")
461+
val url = URL("https://antfarm.world/api/v1/rooms/$encodedRoom/messages")
462+
val conn = url.openConnection() as HttpURLConnection
463+
conn.requestMethod = "POST"
464+
conn.setRequestProperty("Accept", "application/json")
465+
conn.setRequestProperty("Authorization", "Bearer $antFarmKey")
466+
conn.setRequestProperty("Content-Type", "application/json")
467+
conn.connectTimeout = 10_000
468+
conn.readTimeout = 10_000
469+
conn.doOutput = true
470+
val payload = JSONObject().apply { put("body", cleanBody) }.toString()
471+
OutputStreamWriter(conn.outputStream).use { it.write(payload) }
472+
473+
val code = conn.responseCode
474+
val responseText = if (code in 200..299) {
475+
conn.inputStream.bufferedReader().readText()
476+
} else {
477+
conn.errorStream?.bufferedReader()?.readText() ?: "HTTP $code"
478+
}
479+
480+
if (code !in 200..299) {
481+
Log.w(TAG, "Ant Farm room post failed for $targetRoom: $code $responseText")
482+
Result.failure(RuntimeException("room post failed ($code)"))
483+
} else {
484+
Log.i(TAG, "Posted room message to $targetRoom")
485+
Result.success("Posted to $targetRoom.")
486+
}
487+
} catch (e: Exception) {
488+
Log.w(TAG, "Ant Farm room post failed for $targetRoom: ${e.message}")
489+
Result.failure(RuntimeException("network error while posting to room"))
490+
}
491+
}
492+
430493
// ── Mode 1: Direct (no RAG) ───────────────────────────────────────────────
431494

432495
private suspend fun queryDirect(prompt: String, apiKey: String): Result<String> =

app/src/main/java/com/thinkoff/clawwatch/MainActivity.kt

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ class MainActivity : AppCompatActivity() {
7575
val totalSeconds: Int,
7676
val spokenDuration: String
7777
)
78+
private data class RoomMessageCommand(
79+
val room: String,
80+
val body: String
81+
)
7882
private data class PendingVitalsCommand(
7983
val type: LocalCommandType,
8084
val token: Int
@@ -479,6 +483,10 @@ class MainActivity : AppCompatActivity() {
479483
}
480484

481485
private fun handleLocalCommand(prompt: String, token: Int): Boolean {
486+
parseRoomMessageCommand(prompt)?.let { command ->
487+
launchRoomMessageCommand(command, token)
488+
return true
489+
}
482490
parseVitalsCommand(prompt)?.let { command ->
483491
launchVitalsCommand(command, token)
484492
return true
@@ -491,6 +499,54 @@ class MainActivity : AppCompatActivity() {
491499
return launchSystemTimer(timer, token)
492500
}
493501

502+
private fun parseRoomMessageCommand(prompt: String): RoomMessageCommand? {
503+
val raw = prompt.trim()
504+
if (raw.isBlank()) return null
505+
val normalized = raw.lowercase().replace(Regex("\\s+"), " ")
506+
val likelyPostIntent =
507+
normalized.contains("send") ||
508+
normalized.contains("post") ||
509+
normalized.contains("write") ||
510+
normalized.contains("tell")
511+
if (!likelyPostIntent || !normalized.contains("room")) return null
512+
513+
val toRoomPattern = Regex(
514+
"""\b(?:send|post|write|tell)\b\s+(.+?)\s+\bto\s+(?:the\s+)?room\s+([a-zA-Z0-9._-]+)\b\s*$""",
515+
RegexOption.IGNORE_CASE
516+
)
517+
toRoomPattern.find(raw)?.let { match ->
518+
val body = cleanRoomMessageBody(match.groupValues[1])
519+
val room = match.groupValues[2].trim()
520+
if (body.isNotBlank() && room.isNotBlank()) {
521+
return RoomMessageCommand(room = room, body = body)
522+
}
523+
}
524+
525+
val inRoomPattern = Regex(
526+
"""\b(?:send|post|write|tell)\b\s+(?:a\s+)?(?:message\s+)?(?:to|in)\s+(?:the\s+)?room\s+([a-zA-Z0-9._-]+)\b(?:\s*(?:saying|that|:)\s*|\s+)(.+)$""",
527+
RegexOption.IGNORE_CASE
528+
)
529+
inRoomPattern.find(raw)?.let { match ->
530+
val room = match.groupValues[1].trim()
531+
val body = cleanRoomMessageBody(match.groupValues[2])
532+
if (body.isNotBlank() && room.isNotBlank()) {
533+
return RoomMessageCommand(room = room, body = body)
534+
}
535+
}
536+
537+
return null
538+
}
539+
540+
private fun cleanRoomMessageBody(raw: String): String {
541+
return raw
542+
.trim()
543+
.trim('"', '\'', '', '')
544+
.replace(Regex("^message\\s+", RegexOption.IGNORE_CASE), "")
545+
.replace(Regex("\\s+"), " ")
546+
.trim()
547+
.take(500)
548+
}
549+
494550
private fun parseVitalsCommand(prompt: String): LocalCommandType? {
495551
val normalized = prompt.lowercase()
496552
if (
@@ -687,6 +743,31 @@ class MainActivity : AppCompatActivity() {
687743
}
688744
}
689745

746+
private fun launchRoomMessageCommand(command: RoomMessageCommand, token: Int) {
747+
queryJob?.cancel()
748+
setState(State.THINKING)
749+
setStatus("Sending message…")
750+
queryJob = lifecycleScope.launch {
751+
val result = clawRunner.postMessageToRoom(
752+
room = command.room,
753+
message = command.body
754+
)
755+
if (token != interactionToken) return@launch
756+
result.fold(
757+
onSuccess = { response ->
758+
binding.responseText.text = response
759+
speakLocalResponse(response, token)
760+
},
761+
onFailure = { err ->
762+
val reason = err.message?.take(120) ?: "unknown error"
763+
val response = "I couldn't send that room message. $reason"
764+
binding.responseText.text = response
765+
speakLocalResponse(response, token)
766+
}
767+
)
768+
}
769+
}
770+
690771
private fun buildHeartRateSummary(snapshot: VitalsReader.Snapshot): String {
691772
val bpm = snapshot.heartRateBpm
692773
?: return "I couldn't get a clean pulse reading just now. Keep the watch snug and hold still for a moment, then ask again."

0 commit comments

Comments
 (0)