diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt index 25a7b94e..c72997bc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt @@ -14,6 +14,15 @@ data class MessagingPlatform( @SerializedName("error_code") val errorCode: String? = null, @SerializedName("error_message") val errorMessage: String? = null, @SerializedName("env_vars") val envVars: List? = null, + @SerializedName("updated_at") val updatedAt: String? = null, + @SerializedName("home_channel") val homeChannel: HomeChannelInfo? = null, +) + +data class HomeChannelInfo( + val platform: String, + @SerializedName("chat_id") val chatId: String, + val name: String, + @SerializedName("thread_id") val threadId: String? = null, ) data class EnvVarField( @@ -27,6 +36,7 @@ data class EnvVarField( @SerializedName("is_password") val isPassword: Boolean, val advanced: Boolean = false, val url: String? = null, + @SerializedName("expires_at") val expiresAt: String? = null, ) data class MessagingPlatformResponse( @@ -47,3 +57,40 @@ data class MessagingPlatformTestResult( val state: String, val message: String, ) + +// ── Telegram onboarding ───────────────────────────────────────────────── + +data class TelegramOnboardingStartRequest( + @SerializedName("bot_name") val botName: String = "Hermes Agent", +) + +data class TelegramOnboardingStartResponse( + @SerializedName("pairing_id") val pairingId: String, + @SerializedName("suggested_username") val suggestedUsername: String, + @SerializedName("deep_link") val deepLink: String, + @SerializedName("qr_payload") val qrPayload: String, + @SerializedName("expires_at") val expiresAt: String, +) + +data class TelegramOnboardingStatusResponse( + val status: String, + @SerializedName("bot_username") val botUsername: String? = null, + @SerializedName("owner_user_id") val ownerUserId: String? = null, + @SerializedName("expires_at") val expiresAt: String? = null, +) + +data class TelegramOnboardingApplyRequest( + @SerializedName("allowed_user_ids") val allowedUserIds: List, + val profile: String? = null, +) + +data class TelegramOnboardingApplyResponse( + val ok: Boolean, + val platform: String, + @SerializedName("bot_username") val botUsername: String? = null, + @SerializedName("needs_restart") val needsRestart: Boolean, + @SerializedName("restart_started") val restartStarted: Boolean? = null, + @SerializedName("restart_action") val restartAction: String? = null, + @SerializedName("restart_pid") val restartPid: Int? = null, + @SerializedName("restart_error") val restartError: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 24446f93..10177622 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -73,6 +73,11 @@ import com.m57.hermescontrol.data.model.SkillHubUninstallRequest import com.m57.hermescontrol.data.model.SkillScanResponse import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.SystemStatsResponse +import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest +import com.m57.hermescontrol.data.model.TelegramOnboardingApplyResponse +import com.m57.hermescontrol.data.model.TelegramOnboardingStartRequest +import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse +import com.m57.hermescontrol.data.model.TelegramOnboardingStatusResponse import com.m57.hermescontrol.data.model.ToggleSkillRequest import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.ToolsetToggleRequest @@ -499,6 +504,32 @@ interface HermesApiService { @Path("platform_id") platformId: String, ): Response + @DELETE("api/messaging/platforms/{platform_id}") + suspend fun removeMessagingPlatform( + @Path("platform_id") platformId: String, + ): Response + + @POST("api/messaging/telegram/onboarding/start") + suspend fun startTelegramOnboarding( + @Body body: TelegramOnboardingStartRequest, + ): Response + + @GET("api/messaging/telegram/onboarding/{pairing_id}") + suspend fun getTelegramOnboardingStatus( + @Path("pairing_id") pairingId: String, + ): Response + + @POST("api/messaging/telegram/onboarding/{pairing_id}/apply") + suspend fun applyTelegramOnboarding( + @Path("pairing_id") pairingId: String, + @Body body: TelegramOnboardingApplyRequest, + ): Response + + @HTTP(method = "DELETE", path = "api/messaging/telegram/onboarding/{pairing_id}", hasBody = false) + suspend fun cancelTelegramOnboarding( + @Path("pairing_id") pairingId: String, + ): Response + @GET("api/env") suspend fun getEnvVars(): Response> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index 0e7cadfd..12f584f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -1,6 +1,7 @@ package com.m57.hermescontrol.ui.channels import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -14,20 +15,29 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -40,13 +50,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.EnvVarField import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.EmptyState import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold @@ -57,6 +70,35 @@ import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +// ── Validation helpers ────────────────────────────────────────────────── + +private val SLACK_MEMBER_ID_RE = Regex("^[UW][A-Z0-9]{2,}$") +private val SLACK_TOKEN_PREFIXES = + mapOf( + "SLACK_BOT_TOKEN" to "xoxb-", + "SLACK_APP_TOKEN" to "xapp-", + ) + +private fun validateEnvField( + field: EnvVarField, + value: String, +): String? { + val trimmed = value.trim() + if (!trimmed.isNotEmpty()) return null + val expectedPrefix = SLACK_TOKEN_PREFIXES[field.key] + if (expectedPrefix != null && !trimmed.startsWith(expectedPrefix)) { + return "${field.prompt ?: field.key} must start with $expectedPrefix" + } + if (field.key == "SLACK_ALLOWED_USERS") { + val parts = trimmed.split(",").map { it.trim() }.filter { it.isNotEmpty() } + val invalid = parts.firstOrNull { it != "*" && !SLACK_MEMBER_ID_RE.matches(it) } + if (invalid != null) { + return "$invalid does not look like a Slack member ID. Use IDs like U01ABC2DEF3." + } + } + return null +} + // ── State → badge colour / label ────────────────────────────────────── private data class StateStyle( @@ -119,6 +161,18 @@ fun ChannelsScreen( ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) + // ── Telegram onboarding dialog ──────────────────────────────── + if (state.onboardingPhase != OnboardingPhase.IDLE) { + TelegramOnboardingDialog( + state = state, + onDismiss = { viewModel.cancelTelegramOnboarding() }, + onAddAllowedId = { viewModel.addOnboardingAllowedId() }, + onRemoveAllowedId = { viewModel.removeOnboardingAllowedId(it) }, + onNewAllowedIdChange = { viewModel.setOnboardingNewAllowedId(it) }, + onApply = { viewModel.applyTelegramOnboarding() }, + ) + } + HermesScaffold( title = { Text(stringResource(R.string.screen_channels)) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, @@ -170,7 +224,7 @@ fun ChannelsScreen( // ── Gateway-not-running hint ── if (!gatewayRunning && !state.restartNeeded) { item(key = "gateway_offline") { - GatewayOfflineBanner() + GatewayOfflineBanner(gatewayCommand = state.gatewayStartCommand) } } @@ -189,9 +243,19 @@ fun ChannelsScreen( platform = platform, isToggling = state.togglingId == platform.id, isTesting = state.testingId == platform.id, + isRemoving = state.removingId == platform.id, onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) }, onTest = { viewModel.testPlatform(platform.id) }, onSave = { env -> viewModel.configurePlatform(platform.id, env) }, + onRemove = { viewModel.removePlatform(platform.id) }, + onStartOnboarding = { viewModel.startTelegramOnboarding() }, + ) + } + + // ── Admin section ── + item(key = "admin_section") { + AdminSection( + envPath = state.envPath, ) } } @@ -249,30 +313,39 @@ private fun RestartBanner( // ── Gateway offline banner ─────────────────────────────────────────── @Composable -private fun GatewayOfflineBanner() { +private fun GatewayOfflineBanner(gatewayCommand: String) { Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(8.dp), ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.fillMaxWidth().padding(12.dp), ) { - Icon( - imageVector = Icons.Filled.Warning, - contentDescription = null, - modifier = Modifier.size(16.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(R.string.channels_gateway_offline), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "The gateway is not running. Configure channels here, then start the gateway with:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Surface( + color = LocalHermesStatusColors.current.warningContainer, + shape = RoundedCornerShape(4.dp), + ) { + Text( + text = " $gatewayCommand", + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + } } } } @@ -284,11 +357,15 @@ private fun PlatformCard( platform: MessagingPlatform, isToggling: Boolean, isTesting: Boolean, + isRemoving: Boolean, onToggle: (Boolean) -> Unit, onTest: () -> Unit, onSave: (MessagingPlatformUpdate) -> Unit, + onRemove: () -> Unit, + onStartOnboarding: () -> Unit, ) { var showConfigureForm by remember { mutableStateOf(false) } + var showRemoveConfirm by remember { mutableStateOf(false) } val style = platformStateStyle(platform.state) Card(modifier = Modifier.fillMaxWidth()) { @@ -318,14 +395,10 @@ private fun PlatformCard( Spacer(modifier = Modifier.height(2.dp)) Row(verticalAlignment = Alignment.CenterVertically) { StateBadge(state = platform.state) - if (platform.id == "telegram") { - // reserved for onboarding badge later - } } } } - // Toggle switch if (isToggling) { CircularProgressIndicator( modifier = Modifier.size(20.dp), @@ -395,14 +468,81 @@ private fun PlatformCard( style = MaterialTheme.typography.labelSmall, ) } + + // Remove button + IconButton( + onClick = { showRemoveConfirm = true }, + enabled = !isRemoving, + ) { + if (isRemoving) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + Icon( + imageVector = Icons.Filled.Delete, + contentDescription = "Remove platform", + tint = MaterialTheme.colorScheme.error, + ) + } + } + } + + // ── Telegram onboarding (only for unconfigured telegram) ── + if (platform.id == "telegram" && !platform.configured) { + Spacer(modifier = Modifier.height(8.dp)) + Button( + onClick = onStartOnboarding, + ) { + Icon( + imageVector = Icons.Filled.QrCode, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Set up with QR", + style = MaterialTheme.typography.labelSmall, + ) + } } // ── Configure form ── if (showConfigureForm) { - ConfigureForm(platform = platform, onSave = onSave, onClose = { showConfigureForm = false }) + ConfigureForm( + platform = platform, + onSave = onSave, + onClose = { showConfigureForm = false }, + ) } } } + + // Remove confirmation + if (showRemoveConfirm) { + AlertDialog( + onDismissRequest = { showRemoveConfirm = false }, + title = { Text("Remove ${platform.name}?") }, + text = { Text("This will permanently remove this platform's configuration.") }, + confirmButton = { + TextButton( + onClick = { + showRemoveConfirm = false + onRemove() + }, + colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("Remove") + } + }, + dismissButton = { + TextButton(onClick = { showRemoveConfirm = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } // ── Configure form ─────────────────────────────────────────────────── @@ -415,6 +555,7 @@ private fun ConfigureForm( ) { val envFields = platform.envVars.orEmpty() val inputValues = remember { mutableStateMapOf() } + val fieldErrors = remember { mutableStateMapOf() } Spacer(modifier = Modifier.height(16.dp)) Text( @@ -426,13 +567,38 @@ private fun ConfigureForm( envFields.forEach { field -> OutlinedTextField( value = inputValues[field.key].orEmpty(), - onValueChange = { inputValues[field.key] = it }, + onValueChange = { + inputValues[field.key] = it + fieldErrors.remove(field.key) + }, label = { Text(field.prompt ?: field.key) }, placeholder = { if (field.isSet) { Text(field.redactedValue ?: "******** (Already Configured)") } }, + supportingText = { + fieldErrors[field.key]?.let { error -> + Text( + text = error, + color = MaterialTheme.colorScheme.error, + ) + } + if (field.help != null && !fieldErrors.containsKey(field.key)) { + Text( + text = field.help, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + ) + } + field.expiresAt?.let { expiry -> + Text( + text = "Expires: ${formatTimestamp(expiry)}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + ) + } + }, singleLine = true, visualTransformation = if (field.isPassword) { @@ -441,6 +607,7 @@ private fun ConfigureForm( androidx.compose.ui.text.input.VisualTransformation.None }, modifier = Modifier.fillMaxWidth(), + isError = fieldErrors.containsKey(field.key), ) Spacer(modifier = Modifier.height(8.dp)) } @@ -461,6 +628,16 @@ private fun ConfigureForm( Spacer(modifier = Modifier.width(8.dp)) Button( onClick = { + val errors = mutableMapOf() + envFields.forEach { field -> + val value = inputValues[field.key].orEmpty() + val error = validateEnvField(field, value) + if (error != null) errors[field.key] = error + } + if (errors.isNotEmpty()) { + fieldErrors.putAll(errors) + return@Button + } val filledEnv = inputValues.filterValues { it.isNotBlank() } val update = MessagingPlatformUpdate( @@ -478,3 +655,226 @@ private fun ConfigureForm( } } } + +// ── Admin section ──────────────────────────────────────────────────── + +@Composable +private fun AdminSection(envPath: String) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text( + text = "Admin", + style = MaterialTheme.typography.titleSmall, + ) + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "Credentials are written to $envPath", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +// ── Telegram Onboarding Dialog ──────────────────────────────────────── + +@Composable +private fun TelegramOnboardingDialog( + state: ChannelsUiState, + onDismiss: () -> Unit, + onAddAllowedId: () -> Unit, + onRemoveAllowedId: (String) -> Unit, + onNewAllowedIdChange: (String) -> Unit, + onApply: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + text = + when (state.onboardingPhase) { + OnboardingPhase.STARTING -> "Starting…" + OnboardingPhase.WAITING -> "Scan QR code" + OnboardingPhase.READY -> "Telegram connected" + OnboardingPhase.APPLYING -> "Saving…" + OnboardingPhase.IDLE -> "Telegram Setup" + }, + ) + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + state.onboardingError?.let { error -> + Surface( + color = LocalHermesStatusColors.current.errorContainer, + shape = RoundedCornerShape(8.dp), + ) { + Text( + text = error, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(12.dp), + ) + } + } + + when (state.onboardingPhase) { + OnboardingPhase.STARTING -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + + OnboardingPhase.WAITING -> { + Text( + text = "Open Telegram and scan the QR code displayed in the app.", + style = MaterialTheme.typography.bodyMedium, + ) + if (state.onboardingExpiresIn.isNotEmpty()) { + Text( + text = "QR code expires in: ${state.onboardingExpiresIn}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + OnboardingPhase.READY -> { + state.onboardingBotUsername?.let { username -> + Text( + text = "Connected as @$username", + style = MaterialTheme.typography.bodyMedium, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Allowed users:", + style = MaterialTheme.typography.titleSmall, + ) + state.onboardingDetectedOwnerId?.let { ownerId -> + if (state.onboardingAllowedIds.contains(ownerId)) { + Text( + text = "$ownerId (owner detected)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + state.onboardingAllowedIds.forEach { id -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = id, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { onRemoveAllowedId(id) }) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = "Remove", + modifier = Modifier.size(18.dp), + ) + } + } + } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = state.onboardingNewAllowedId, + onValueChange = onNewAllowedIdChange, + placeholder = { Text("Telegram user ID") }, + singleLine = true, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = onAddAllowedId) { + Icon(Icons.Filled.Add, contentDescription = "Add user ID") + } + } + } + + OnboardingPhase.APPLYING -> { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = "Saving configuration…", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + OnboardingPhase.IDLE -> { } + } + } + }, + confirmButton = { + when (state.onboardingPhase) { + OnboardingPhase.READY -> { + Button( + onClick = onApply, + enabled = state.onboardingAllowedIds.isNotEmpty(), + ) { + Text("Save and restart") + } + } + else -> {} + } + }, + dismissButton = { + when (state.onboardingPhase) { + OnboardingPhase.READY -> { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + OnboardingPhase.WAITING -> { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + OnboardingPhase.IDLE, OnboardingPhase.STARTING, OnboardingPhase.APPLYING -> {} + } + }, + ) +} + +private val isoTimestampParser = + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + +private val displayTimestampFormatter = + java.text.SimpleDateFormat("MMM d, yyyy HH:mm", java.util.Locale.US) + +private fun formatTimestamp(isoDate: String): String { + return try { + val parsed = isoTimestampParser.parse(isoDate) ?: return "" + displayTimestampFormatter.format(parsed) + } catch (_: Exception) { + "" + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index bf6bbe58..98e85943 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -4,10 +4,14 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.TelegramOnboardingApplyRequest +import com.m57.hermescontrol.data.model.TelegramOnboardingStartRequest +import com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchLoad +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -15,6 +19,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +enum class OnboardingPhase { + IDLE, + STARTING, + WAITING, + READY, + APPLYING, +} + data class ChannelsUiState( val isLoading: Boolean = false, val platforms: List = emptyList(), @@ -24,6 +36,20 @@ data class ChannelsUiState( val isRestarting: Boolean = false, val togglingId: String? = null, val testingId: String? = null, + val removingId: String? = null, + val gatewayStartCommand: String = "hermes gateway start", + // Telegram onboarding + val onboardingPhase: OnboardingPhase = OnboardingPhase.IDLE, + val onboardingSetup: TelegramOnboardingStartResponse? = null, + val onboardingQrDataUrl: String? = null, + val onboardingBotUsername: String? = null, + val onboardingAllowedIds: List = emptyList(), + val onboardingDetectedOwnerId: String? = null, + val onboardingNewAllowedId: String = "", + val onboardingError: String? = null, + val onboardingExpiresIn: String = "", + // Admin section + val envPath: String = "~/.hermes/.env", ) class ChannelsViewModel : @@ -32,6 +58,8 @@ class ChannelsViewModel : private val _uiState = MutableStateFlow(ChannelsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var launchJob: Job? = null + fun loadPlatforms() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } }, @@ -42,7 +70,12 @@ class ChannelsViewModel : }, onSuccess = { data -> _uiState.update { - it.copy(isLoading = false, platforms = data?.platforms.orEmpty()) + it.copy( + isLoading = false, + platforms = data?.platforms.orEmpty(), + gatewayStartCommand = data?.gatewayStartCommand ?: "hermes gateway start", + envPath = data?.envPath ?: "~/.hermes/.env", + ) } }, onError = { errorMsg -> @@ -56,9 +89,7 @@ class ChannelsViewModel : ) } - /** Toggle a platform's enabled state without a full reload. - * On success the local state is updated optimistically and - * restartNeeded is set. */ + /** Toggle a platform's enabled state without a full reload. */ fun togglePlatform( platformId: String, enabled: Boolean, @@ -104,7 +135,37 @@ class ChannelsViewModel : ) } - /** Configure a platform's env vars and/or settings. Reloads on success. */ + /** Remove an entire platform. */ + fun removePlatform(platformId: String) { + _uiState.update { it.copy(removingId = platformId) } + safeLaunchLoad( + apiCall = { + safeApiCall { + ApiClient.hermesApi.removeMessagingPlatform(platformId) + } + }, + onStart = {}, + onSuccess = { + _uiState.update { state -> + state.copy( + platforms = state.platforms.filter { it.id != platformId }, + removingId = null, + toastMessage = "Platform removed", + ) + } + }, + onError = { error -> + _uiState.update { + it.copy( + removingId = null, + toastMessage = "Failed to remove: $error", + ) + } + }, + ) + } + + /** Configure a platform's env vars and/or settings. Reloads on success. */ fun configurePlatform( platformId: String, update: MessagingPlatformUpdate, @@ -170,7 +231,7 @@ class ChannelsViewModel : ) } - /** Restart the whole gateway. Reloads platforms after a 4s delay. */ + /** Restart the whole gateway. Reloads platforms after a 4s delay. */ fun restartGateway() { safeLaunchLoad( apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } }, @@ -203,7 +264,283 @@ class ChannelsViewModel : _uiState.update { it.copy(restartNeeded = false) } } + // ── Telegram onboarding ──────────────────────────────────────────────── + + fun startTelegramOnboarding() { + val state = _uiState.value + if (state.onboardingPhase == OnboardingPhase.STARTING || + state.onboardingPhase == OnboardingPhase.WAITING + ) { + return + } + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.STARTING, + onboardingError = null, + onboardingBotUsername = null, + onboardingAllowedIds = emptyList(), + onboardingDetectedOwnerId = null, + onboardingNewAllowedId = "", + ) + } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.startTelegramOnboarding( + TelegramOnboardingStartRequest(botName = "Hermes Agent"), + ) + } + _uiState.update { + when (result) { + is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + val setup = result.data + it.copy( + onboardingPhase = OnboardingPhase.WAITING, + onboardingSetup = setup, + onboardingQrDataUrl = setup?.qrPayload, + onboardingExpiresIn = formatExpiry(setup?.expiresAt), + ) + } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + it.copy( + onboardingPhase = OnboardingPhase.IDLE, + onboardingError = result.error.message, + ) + } + } + } + if (_uiState.value.onboardingPhase == OnboardingPhase.WAITING) { + pollOnboardingStatus() + } + } + } + + private fun pollOnboardingStatus() { + launchJob?.cancel() + launchJob = + viewModelScope.launch { + while (_uiState.value.onboardingPhase == OnboardingPhase.WAITING) { + delay(2000) + val pairingId = _uiState.value.onboardingSetup?.pairingId ?: break + val result = + safeApiCall { + ApiClient.hermesApi.getTelegramOnboardingStatus(pairingId) + } + when (result) { + is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + val status = result.data + if (status?.status == "ready") { + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.READY, + onboardingBotUsername = status.botUsername, + onboardingError = null, + ) + } + val ownerId = status.ownerUserId + if (ownerId != null && TELEGRAM_USER_ID_RE.matches(ownerId)) { + _uiState.update { + it.copy( + onboardingDetectedOwnerId = ownerId, + onboardingAllowedIds = listOf(ownerId), + ) + } + } + return@launch + } + _uiState.update { it.copy(onboardingError = null) } + } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + val expired = isOnboardingExpired(_uiState.value.onboardingSetup) + if (expired) { + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.IDLE, + onboardingSetup = null, + onboardingQrDataUrl = null, + onboardingError = + "Telegram pairing expired. " + + "Start a new QR setup to try again.", + ) + } + return@launch + } + _uiState.update { + it.copy( + onboardingError = "Still waiting for Telegram. Retrying…", + ) + } + } + } + // Update expiry countdown + _uiState.update { + it.copy( + onboardingExpiresIn = formatExpiry(it.onboardingSetup?.expiresAt), + ) + } + } + } + } + + fun cancelTelegramOnboarding() { + val pairingId = _uiState.value.onboardingSetup?.pairingId + launchJob?.cancel() + if (pairingId != null) { + viewModelScope.launch { + safeApiCall { + ApiClient.hermesApi.cancelTelegramOnboarding(pairingId) + } + } + } + resetOnboarding() + } + + fun setOnboardingNewAllowedId(id: String) { + _uiState.update { it.copy(onboardingNewAllowedId = id) } + } + + fun addOnboardingAllowedId() { + val trimmed = _uiState.value.onboardingNewAllowedId.trim() + if (!TELEGRAM_USER_ID_RE.matches(trimmed)) { + _uiState.update { + it.copy( + onboardingError = "Allowed Telegram user IDs must be numeric.", + ) + } + return + } + _uiState.update { state -> + val ids = state.onboardingAllowedIds + state.copy( + onboardingError = null, + onboardingNewAllowedId = "", + onboardingAllowedIds = + if (ids.contains(trimmed)) ids else ids + trimmed, + ) + } + } + + fun removeOnboardingAllowedId(id: String) { + _uiState.update { state -> + state.copy( + onboardingAllowedIds = state.onboardingAllowedIds.filter { it != id }, + ) + } + } + + fun applyTelegramOnboarding() { + val setup = _uiState.value.onboardingSetup ?: return + val allowedIds = _uiState.value.onboardingAllowedIds + if (allowedIds.isEmpty()) { + _uiState.update { + it.copy( + onboardingError = "Add at least one allowed Telegram user ID.", + ) + } + return + } + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.APPLYING, + onboardingError = null, + ) + } + viewModelScope.launch { + val result = + safeApiCall { + ApiClient.hermesApi.applyTelegramOnboarding( + setup.pairingId, + TelegramOnboardingApplyRequest( + allowedUserIds = allowedIds, + ), + ) + } + when (result) { + is com.m57.hermescontrol.data.remote.NetworkResult.Success -> { + val applyResult = result.data + _uiState.update { + it.copy( + toastMessage = "Telegram saved. Restarting gateway…", + ) + } + resetOnboarding() + if (applyResult?.needsRestart == true) { + restartGateway() + } else { + loadPlatforms() + } + } + is com.m57.hermescontrol.data.remote.NetworkResult.Failure -> { + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.READY, + onboardingError = result.error.message, + ) + } + } + } + } + } + + private fun resetOnboarding() { + launchJob?.cancel() + _uiState.update { + it.copy( + onboardingPhase = OnboardingPhase.IDLE, + onboardingSetup = null, + onboardingQrDataUrl = null, + onboardingBotUsername = null, + onboardingAllowedIds = emptyList(), + onboardingDetectedOwnerId = null, + onboardingNewAllowedId = "", + onboardingError = null, + onboardingExpiresIn = "", + ) + } + } + + fun dismissOnboardingError() { + _uiState.update { it.copy(onboardingError = null) } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } } + +private val TELEGRAM_USER_ID_RE = Regex("^\\d+$") + +private val isoTimestampParser = + java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply { + timeZone = java.util.TimeZone.getTimeZone("UTC") + } + +private fun formatExpiry(expiresAt: String?): String { + if (expiresAt == null) return "" + val ms = + try { + isoTimestampParser + .parse(expiresAt)?.time?.minus(System.currentTimeMillis()) + } catch (_: Exception) { + null + } + ?: return "" + if (ms <= 0) return "expired" + val seconds = (ms / 1000).toInt() + val mins = seconds / 60 + val secs = seconds % 60 + return "$mins:${secs.toString().padStart(2, '0')}" +} + +private fun isOnboardingExpired(setup: com.m57.hermescontrol.data.model.TelegramOnboardingStartResponse?): Boolean { + val expiresAt = setup?.expiresAt ?: return false + val ms = + try { + isoTimestampParser + .parse(expiresAt)?.time + } catch (_: Exception) { + null + } + ?: return false + return System.currentTimeMillis() >= ms +}