feat(#443): Telegram onboarding, remove platform, credential expiry, field validation (#467)

* feat(#443): Telegram onboarding, remove platform, credential expiry, gateway command, field validation

- Add Telegram onboarding wizard: start (QR), poll status, cancel, apply
  with allowed-user-IDs flow. Full state machine in ChannelsViewModel.
- Add remove platform button with confirmation dialog (per card).
- Add credential expiry display in env var config fields.
- Add gateway_start_command to the offline banner with monospace hint.
- Add field validation for Slack token prefixes and member IDs.
- Add admin section showing env path and gateway command.
- Add DELETE, POST, GET API endpoints for onboarding + remove platform
  + action status polling.
- Extend MessagingPlatform model with envVar expiresAt, homeChannel,
  updatedAt fields.

* fix(#443): address review feedback + fix CI compilation & ktlint

- Remove duplicate getActionStatus overload (compilation blocker)
- Add missing Box import in ChannelsScreen (compilation blocker)
- Remove unused gatewayCommand param from AdminSection
- Centralize ISO timestamp parsing with UTC timezone
- Clean up WAITING phase dialog (remove redundant confirmButton)
- Use LocalHermesStatusColors for semantic surfaces
- Fix all ktlint violations in changed files

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-07-04 13:22:49 +03:00
1 parent b46e0ef46d
commit 9689d17be0
4 files changed
+847 -32

No files matched your search

@@ -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<EnvVarField>? = 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<String>,
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,
)
@@ -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<MessagingPlatformTestResult>
@DELETE("api/messaging/platforms/{platform_id}")
suspend fun removeMessagingPlatform(
@Path("platform_id") platformId: String,
): Response<Unit>
@POST("api/messaging/telegram/onboarding/start")
suspend fun startTelegramOnboarding(
@Body body: TelegramOnboardingStartRequest,
): Response<TelegramOnboardingStartResponse>
@GET("api/messaging/telegram/onboarding/{pairing_id}")
suspend fun getTelegramOnboardingStatus(
@Path("pairing_id") pairingId: String,
): Response<TelegramOnboardingStatusResponse>
@POST("api/messaging/telegram/onboarding/{pairing_id}/apply")
suspend fun applyTelegramOnboarding(
@Path("pairing_id") pairingId: String,
@Body body: TelegramOnboardingApplyRequest,
): Response<TelegramOnboardingApplyResponse>
@HTTP(method = "DELETE", path = "api/messaging/telegram/onboarding/{pairing_id}", hasBody = false)
suspend fun cancelTelegramOnboarding(
@Path("pairing_id") pairingId: String,
): Response<Unit>
@GET("api/env")
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
@@ -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<String, String>() }
val fieldErrors = remember { mutableStateMapOf<String, String>() }
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<String, String>()
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) {
""
}
}
@@ -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<MessagingPlatform> = 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<String> = 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<ChannelsUiState> = _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
}