Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

- Add the data layer for outside calls: the contract copy brings call requests (`request_call`), `callee` on call events, and `callsOthers` on voice lines. The app reads call requests, approves or declines them, follows them every five seconds while they are pending or calling, and reads a call's transcript every two seconds while it goes on.
- Show call requests in the bot chat: a pending one reads "Wants to call for you" with the callee, number, goal and opening and has Decline and Approve; a declined one reads "Declined"; a placed one is a row with its state that opens the call, with its goal and a transcript that follows the call live. An outside call's end notice becomes the same row.
- Show voice gateway calls in bot chats, as on the web: a spoken message reads "Spoken on a call" under its bubble, and a call's start and end show as centred phone markers. A call the bot placed reads "{bot} called you" with the greeting it spoke, and the "Call ended" line that wakes the bot shows the bot's reaction instead of appearing as a typed message.
- Copy the bot contract with the voice gateway. It adds the `call` field on transcript messages, the voice gateway and voice line operations, and the `partial` query on chat events, which the app does not use yet.
- Bump the contract generator to `1.2.0`: an object that declares properties and allows others keeps the others in `additionalProperties`, so `getOpenAPI()` returns the whole document instead of only `openapi`.
- CI caches Gradle and runs tests and lint as parallel jobs. Lint skips test sources.
- `scripts/contract.py update --from DIRECTORY` copies the contract files from a directory such as `bot/openapi/`, replacing `--local` and `--release`. `contract/` no longer carries `pin.json`, `SHA256SUMS`, `CHANGELOG.md`, or `source-commit.txt`; the `verify` and `update-latest` commands and the weekly contract update workflow are gone.
- Publish signed release APKs: a `vX.Y.Z` tag builds one with FCM and attaches it to a GitHub release. Release builds are signed when `CRUMBLE_KEYSTORE_PATH` and `CRUMBLE_KEYSTORE_PASSWORD` are set, and take their version from `crumble.versionName` and `crumble.versionCode`.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Generated by scripts/generate-contract.py 1.1.0. Do not edit.
// Generated by scripts/generate-contract.py 1.2.0. Do not edit.
package dev.crumble.android.generated

import dev.crumble.android.network.HttpRequestData
Expand Down
736 changes: 723 additions & 13 deletions app/src/main/java/dev/crumble/android/generated/ContractModels.kt

Large diffs are not rendered by default.

117 changes: 115 additions & 2 deletions app/src/main/java/dev/crumble/android/generated/ContractOperations.kt
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Generated by scripts/generate-contract.py 1.1.0. Do not edit.
// Generated by scripts/generate-contract.py 1.2.0. Do not edit.
package dev.crumble.android.generated

import kotlinx.serialization.*
Expand All @@ -17,6 +17,13 @@ class ContractOperations(private val boundary: ContractBoundary) {
return boundary.json.decodeFromString<Health>(bytes.toString(Charsets.UTF_8))
}

suspend fun getOpenAPI(timeoutMs: Int = 20_000): GetOpenAPIResponse {
val query = mutableListOf<String>()
val path = "/openapi.json" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("GET", path, null, timeoutMs)
return boundary.json.decodeFromString<GetOpenAPIResponse>(bytes.toString(Charsets.UTF_8))
}

suspend fun getCapabilities(timeoutMs: Int = 20_000): Capabilities {
val query = mutableListOf<String>()
val path = "/v1/capabilities" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
Expand Down Expand Up @@ -139,6 +146,81 @@ class ContractOperations(private val boundary: ContractBoundary) {
return boundary.json.decodeFromString<UpdateEventRelayResponse>(bytes.toString(Charsets.UTF_8))
}

suspend fun getVoiceGateway(timeoutMs: Int = 20_000): GetVoiceGatewayResponse {
val query = mutableListOf<String>()
val path = "/v1/voice-gateway" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("GET", path, null, timeoutMs)
return boundary.json.decodeFromString<GetVoiceGatewayResponse>(bytes.toString(Charsets.UTF_8))
}

suspend fun updateVoiceGateway(body: UpdateVoiceGatewayRequest, timeoutMs: Int = 20_000): UpdateVoiceGatewayResponse {
val query = mutableListOf<String>()
val path = "/v1/voice-gateway" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs)
return boundary.json.decodeFromString<UpdateVoiceGatewayResponse>(bytes.toString(Charsets.UTF_8))
}

suspend fun issueVoiceGatewayToken(timeoutMs: Int = 20_000): IssueVoiceGatewayTokenResponse {
val query = mutableListOf<String>()
val path = "/v1/voice-gateway/token" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("POST", path, null, timeoutMs)
return boundary.json.decodeFromString<IssueVoiceGatewayTokenResponse>(bytes.toString(Charsets.UTF_8))
}

suspend fun revokeVoiceGatewayToken(timeoutMs: Int = 20_000): Unit {
val query = mutableListOf<String>()
val path = "/v1/voice-gateway/token" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
boundary.send("DELETE", path, null, timeoutMs)
}

suspend fun streamVoiceGatewayEvents(`calls`: String? = null, timeoutMs: Int = 20_000): HttpRequestData {
val query = mutableListOf<String>()
`calls`?.let { query.add("calls=" + boundary.escape(it)) }
val path = "/v1/voice/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
return boundary.request("GET", path, null, timeoutMs)
}

suspend fun sendSpokenMessage(body: SendSpokenMessageRequest, `username`: String, timeoutMs: Int = 20_000): Execution {
val query = mutableListOf<String>()
val path = "/v1/voice/lines/${boundary.escape(`username`)}/messages" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("POST", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs)
return boundary.json.decodeFromString<Execution>(bytes.toString(Charsets.UTF_8))
}

suspend fun streamVoiceLineEvents(`username`: String, `limit`: Long? = null, `partial`: String? = null, `callId`: String? = null, timeoutMs: Int = 20_000): HttpRequestData {
val query = mutableListOf<String>()
`limit`?.let { query.add("limit=" + boundary.escape(it.toString())) }
`partial`?.let { query.add("partial=" + boundary.escape(it)) }
`callId`?.let { query.add("callId=" + boundary.escape(it)) }
val path = "/v1/voice/lines/${boundary.escape(`username`)}/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
return boundary.request("GET", path, null, timeoutMs)
}

suspend fun getSpokenExecution(`username`: String, `executionId`: String, timeoutMs: Int = 20_000): SpokenExecution {
val query = mutableListOf<String>()
val path = "/v1/voice/lines/${boundary.escape(`username`)}/executions/${boundary.escape(`executionId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("GET", path, null, timeoutMs)
return boundary.json.decodeFromString<SpokenExecution>(bytes.toString(Charsets.UTF_8))
}

suspend fun startVoiceCall(body: StartVoiceCallRequest, `username`: String, `callId`: String, timeoutMs: Int = 20_000): Unit {
val query = mutableListOf<String>()
val path = "/v1/voice/lines/${boundary.escape(`username`)}/calls/${boundary.escape(`callId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs)
}

suspend fun reportVoiceCall(body: RequestVoiceCallReport, `callId`: String, timeoutMs: Int = 20_000): Unit {
val query = mutableListOf<String>()
val path = "/v1/voice/calls/${boundary.escape(`callId`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
boundary.send("POST", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs)
}

suspend fun reportVoiceLines(body: ReportVoiceLinesRequest, timeoutMs: Int = 20_000): Unit {
val query = mutableListOf<String>()
val path = "/v1/voice/lines" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
boundary.send("PUT", path, boundary.json.encodeToString(body).toByteArray(Charsets.UTF_8), timeoutMs)
}

suspend fun listGithubApps(timeoutMs: Int = 20_000): ListGithubAppsResponse {
val query = mutableListOf<String>()
val path = "/v1/github-apps" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
Expand Down Expand Up @@ -461,9 +543,10 @@ class ContractOperations(private val boundary: ContractBoundary) {
return boundary.json.decodeFromString<Compaction>(bytes.toString(Charsets.UTF_8))
}

suspend fun streamChatEvents(`id`: String, `limit`: Long? = null, timeoutMs: Int = 20_000): HttpRequestData {
suspend fun streamChatEvents(`id`: String, `limit`: Long? = null, `partial`: String? = null, timeoutMs: Int = 20_000): HttpRequestData {
val query = mutableListOf<String>()
`limit`?.let { query.add("limit=" + boundary.escape(it.toString())) }
`partial`?.let { query.add("partial=" + boundary.escape(it)) }
val path = "/v1/agents/${boundary.escape(`id`)}/events" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
return boundary.request("GET", path, null, timeoutMs)
}
Expand Down Expand Up @@ -579,6 +662,36 @@ class ContractOperations(private val boundary: ContractBoundary) {
return boundary.json.decodeFromString<BotRequest>(bytes.toString(Charsets.UTF_8))
}

suspend fun listCallRequests(`id`: String, timeoutMs: Int = 20_000): List<CallRequest> {
val query = mutableListOf<String>()
val path = "/v1/agents/${boundary.escape(`id`)}/call-requests" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("GET", path, null, timeoutMs)
return boundary.json.decodeFromString<List<CallRequest>>(bytes.toString(Charsets.UTF_8))
}

suspend fun approveCallRequest(`id`: String, `requestId`: String, timeoutMs: Int = 20_000): CallRequest {
val query = mutableListOf<String>()
val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/approve" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("POST", path, null, timeoutMs)
return boundary.json.decodeFromString<CallRequest>(bytes.toString(Charsets.UTF_8))
}

suspend fun declineCallRequest(`id`: String, `requestId`: String, timeoutMs: Int = 20_000): CallRequest {
val query = mutableListOf<String>()
val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/decline" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("POST", path, null, timeoutMs)
return boundary.json.decodeFromString<CallRequest>(bytes.toString(Charsets.UTF_8))
}

suspend fun getCallRequestTranscript(`id`: String, `requestId`: String, `limit`: Long? = null, `before`: String? = null, timeoutMs: Int = 20_000): TranscriptPage {
val query = mutableListOf<String>()
`limit`?.let { query.add("limit=" + boundary.escape(it.toString())) }
`before`?.let { query.add("before=" + boundary.escape(it)) }
val path = "/v1/agents/${boundary.escape(`id`)}/call-requests/${boundary.escape(`requestId`)}/transcript" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
val bytes = boundary.send("GET", path, null, timeoutMs)
return boundary.json.decodeFromString<TranscriptPage>(bytes.toString(Charsets.UTF_8))
}

suspend fun saveAgentCredential(body: SaveAgentCredentialRequest, `id`: String, `name`: String, timeoutMs: Int = 20_000): Credential {
val query = mutableListOf<String>()
val path = "/v1/agents/${boundary.escape(`id`)}/credentials/${boundary.escape(`name`)}" + if (query.isEmpty()) "" else "?" + query.joinToString("&")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@
],
"additionalProperties": true
},
"call": {
"type": "object",
"properties": {
"callId": {
"type": "string"
},
"direction": {
"type": "string",
"enum": [
"inbound",
"outbound"
]
},
"callee": {
"type": "string"
}
},
"required": [
"callId",
"direction"
],
"additionalProperties": true
},
"attachments": {
"type": "array",
"items": {
Expand Down
59 changes: 58 additions & 1 deletion app/src/main/java/dev/crumble/android/model/AppModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import dev.crumble.android.generated.MemorySource
import dev.crumble.android.generated.ScriptRun
import dev.crumble.android.generated.SkillPackage
import dev.crumble.android.generated.BotRequest
import dev.crumble.android.generated.CallRequest
import dev.crumble.android.generated.Credential
import dev.crumble.android.generated.CredentialRequest
import dev.crumble.android.generated.Credentials
Expand Down Expand Up @@ -268,6 +269,8 @@ private fun defaultAppScope(): CoroutineScope {
}

private const val DECISION_POLL_MS = 5_000L
private const val CALL_TRANSCRIPT_POLL_MS = 2_000L
private const val CALL_TRANSCRIPT_PAGE_SIZE = 200
private const val BOT_STATUS_POLL_MS = 5_000L
private const val PROVIDER_LOGIN_POLL_MS = 1_000L
private const val JOB_REFRESH_MS = 3_000L
Expand Down Expand Up @@ -314,6 +317,9 @@ class AppModel(
var heldActions by mutableStateOf<List<HeldAction>>(emptyList())
var codingJobs by mutableStateOf<List<CodingJob>>(emptyList())
var botRequests by mutableStateOf<List<BotRequest>>(emptyList())
var callRequests by mutableStateOf<List<CallRequest>>(emptyList())
var callTranscript by mutableStateOf<CallTranscript?>(null)
private var callTranscriptJob: Job? = null
var decisionsLoaded by mutableStateOf(false)
var decisionsFailed by mutableStateOf(false)
var decidingIDs by mutableStateOf<Set<String>>(emptySet())
Expand Down Expand Up @@ -916,6 +922,7 @@ class AppModel(
heldActions = emptyList()
codingJobs = emptyList()
botRequests = emptyList()
callRequests = emptyList()
decisionsLoaded = false
decisionsFailed = false
decisionErrors = emptyMap()
Expand Down Expand Up @@ -997,6 +1004,7 @@ class AppModel(
streamStatus = ""
decisionsJob?.cancel()
decisionsKey = ""
closeCallTranscript()
closeJobs()
}

Expand All @@ -1005,10 +1013,16 @@ class AppModel(
message.heldAction?.let { ref -> heldActions.any { it.id == ref.id && it.status == "pending" } } == true ||
message.jobQuestion?.let { ref -> codingJobs.any { it.question?.id == ref.id } } == true ||
message.botRequest?.let { ref -> botRequests.any { it.id == ref.id && it.status == "pending" } } == true ||
message.callRequest?.let { ref -> callRequests.any { it.id == ref.id && (it.status == "pending" || it.status == "calling") } } == true ||
message.credentialRequest?.let { ref -> botCredentials[selectedAgentID]?.requests?.any { ref.answers(it.name, it.requestedAt) } } == true ||
message.loginRequest?.let { ref -> botLogins[selectedAgentID]?.requests?.any { ref.answers(it.name, it.requestedAt) } } == true
}

private fun isCallNotice(message: Message) =
message.role == "user" && (message.origin?.kind == "call" || message.origin?.kind == "call_request")

private fun concernsCalls(message: Message) = message.callRequest != null || isCallNotice(message)

private fun secretRefs() = messages.flatMap {
listOfNotNull(it.credentialRequest?.let(::credentialReadKey), it.loginRequest?.let(::loginReadKey))
}
Expand All @@ -1018,6 +1032,7 @@ class AppModel(
val held = if (messages.any { it.heldAction != null }) api.heldActions(agentID) else emptyList()
val jobs = if (messages.any { it.jobQuestion != null }) api.codingJobs(agentID) else emptyList()
val requests = if (messages.any { it.botRequest != null }) api.botRequests(agentID) else emptyList()
val calls = if (messages.any(::concernsCalls)) api.callRequests(agentID) else emptyList()
val asked = secretRefs().toSet()
val credentialMark = secretMark("credential", agentID)
val loginMark = secretMark("login", agentID)
Expand All @@ -1027,6 +1042,7 @@ class AppModel(
heldActions = held
codingJobs = jobs
botRequests = requests
callRequests = calls
if (credentialList != null && secretSettled("credential", agentID, credentialMark)) botCredentials = botCredentials + (agentID to credentialList)
if (loginList != null && secretSettled("login", agentID, loginMark)) botLogins = botLogins + (agentID to loginList)
freshSecretRequests = asked
Expand All @@ -1043,7 +1059,9 @@ class AppModel(

// Another client, a stopped job, or a restart can settle a decision, so keep reading while one waits.
private fun followDecisions() {
val refs = messages.flatMap { listOfNotNull(it.heldAction?.id, it.jobQuestion?.id, it.botRequest?.id) } + secretRefs()
val refs = messages.flatMap {
listOfNotNull(it.heldAction?.id, it.jobQuestion?.id, it.botRequest?.id, it.callRequest?.id, it.id.takeIf { _ -> isCallNotice(it) })
} + secretRefs()
val key = selectedAgentID + "\n" + refs.joinToString("\n")
val api = client
if (refs.isEmpty() || key == decisionsKey || api == null) return
Expand Down Expand Up @@ -1103,6 +1121,45 @@ class AppModel(
null
}

fun decideCallRequest(id: String, approve: Boolean) = decide(id) { api, agentID ->
val decided = if (approve) api.approveCallRequest(agentID, id) else api.declineCallRequest(agentID, id)
return@decide {
if (selectedAgentID == agentID) callRequests = callRequests.map { if (it.id == decided.id) decided else it }
}
}

/** Reads the call's transcript, then follows it every two seconds while the call goes on. */
fun openCallTranscript(requestID: String) {
val api = client ?: return
val agentID = selectedAgentID
val gen = generation
callTranscriptJob?.cancel()
callTranscript = CallTranscript(agentID, requestID)
callTranscriptJob = scope.launch {
do {
// Checked before the read, so a call that ends meanwhile still gets one last read.
val calling = callRequests.any { it.id == requestID && it.status == "calling" }
try {
val page = api.callRequestTranscript(agentID, requestID, CALL_TRANSCRIPT_PAGE_SIZE)
if (generation != gen || selectedAgentID != agentID) return@launch
callTranscript = CallTranscript(agentID, requestID, callLines(page.messages.orEmpty()), loaded = true)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
if (generation != gen || selectedAgentID != agentID) return@launch
callTranscript = callTranscript?.copy(loaded = true, failed = true)
}
if (calling) delay(CALL_TRANSCRIPT_POLL_MS)
} while (isActive && calling)
}
}

fun closeCallTranscript() {
callTranscriptJob?.cancel()
callTranscriptJob = null
callTranscript = null
}

private fun addCreatedBot(created: Agent) {
if (agents.none { it.id == created.id }) agents = agents + created
}
Expand Down
Loading
Loading