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_code") val errorCode: String? = null,
@SerializedName("error_message") val errorMessage: String? = null, @SerializedName("error_message") val errorMessage: String? = null,
@SerializedName("env_vars") val envVars: List<EnvVarField>? = 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( data class EnvVarField(
@@ -27,6 +36,7 @@ data class EnvVarField(
@SerializedName("is_password") val isPassword: Boolean, @SerializedName("is_password") val isPassword: Boolean,
val advanced: Boolean = false, val advanced: Boolean = false,
val url: String? = null, val url: String? = null,
@SerializedName("expires_at") val expiresAt: String? = null,
) )
data class MessagingPlatformResponse( data class MessagingPlatformResponse(
@@ -47,3 +57,40 @@ data class MessagingPlatformTestResult(
val state: String, val state: String,
val message: 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.SkillScanResponse
import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.model.StatusResponse
import com.m57.hermescontrol.data.model.SystemStatsResponse 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.ToggleSkillRequest
import com.m57.hermescontrol.data.model.Toolset import com.m57.hermescontrol.data.model.Toolset
import com.m57.hermescontrol.data.model.ToolsetToggleRequest import com.m57.hermescontrol.data.model.ToolsetToggleRequest
@@ -499,6 +504,32 @@ interface HermesApiService {
@Path("platform_id") platformId: String, @Path("platform_id") platformId: String,
): Response<MessagingPlatformTestResult> ): 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") @GET("api/env")
suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>> suspend fun getEnvVars(): Response<Map<String, EnvVarConfig>>
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.channels package com.m57.hermescontrol.ui.channels
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer 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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.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.RadioButtonChecked
import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.R 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.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate 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.EmptyState
import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.ErrorState
import com.m57.hermescontrol.ui.common.HermesScaffold 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.listContentPadding
import com.m57.hermescontrol.ui.common.listItemSpacing 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 ────────────────────────────────────── // ── State → badge colour / label ──────────────────────────────────────
private data class StateStyle( private data class StateStyle(
@@ -119,6 +161,18 @@ fun ChannelsScreen(
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast) 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( HermesScaffold(
title = { Text(stringResource(R.string.screen_channels)) }, title = { Text(stringResource(R.string.screen_channels)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) }, navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
@@ -170,7 +224,7 @@ fun ChannelsScreen(
// ── Gateway-not-running hint ── // ── Gateway-not-running hint ──
if (!gatewayRunning && !state.restartNeeded) { if (!gatewayRunning && !state.restartNeeded) {
item(key = "gateway_offline") { item(key = "gateway_offline") {
GatewayOfflineBanner() GatewayOfflineBanner(gatewayCommand = state.gatewayStartCommand)
} }
} }
@@ -189,9 +243,19 @@ fun ChannelsScreen(
platform = platform, platform = platform,
isToggling = state.togglingId == platform.id, isToggling = state.togglingId == platform.id,
isTesting = state.testingId == platform.id, isTesting = state.testingId == platform.id,
isRemoving = state.removingId == platform.id,
onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) }, onToggle = { enabled -> viewModel.togglePlatform(platform.id, enabled) },
onTest = { viewModel.testPlatform(platform.id) }, onTest = { viewModel.testPlatform(platform.id) },
onSave = { env -> viewModel.configurePlatform(platform.id, env) }, 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 ─────────────────────────────────────────── // ── Gateway offline banner ───────────────────────────────────────────
@Composable @Composable
private fun GatewayOfflineBanner() { private fun GatewayOfflineBanner(gatewayCommand: String) {
Card( Card(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
) { ) {
Row( Column(
modifier = modifier = Modifier.fillMaxWidth().padding(12.dp),
Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Row(verticalAlignment = Alignment.CenterVertically) {
imageVector = Icons.Filled.Warning, Icon(
contentDescription = null, imageVector = Icons.Filled.Warning,
modifier = Modifier.size(16.dp), contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(16.dp),
) tint = MaterialTheme.colorScheme.onSurfaceVariant,
Spacer(modifier = Modifier.width(8.dp)) )
Text( Spacer(modifier = Modifier.width(8.dp))
text = stringResource(R.string.channels_gateway_offline), Text(
style = MaterialTheme.typography.bodySmall, text = "The gateway is not running. Configure channels here, then start the gateway with:",
color = MaterialTheme.colorScheme.onSurfaceVariant, 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, platform: MessagingPlatform,
isToggling: Boolean, isToggling: Boolean,
isTesting: Boolean, isTesting: Boolean,
isRemoving: Boolean,
onToggle: (Boolean) -> Unit, onToggle: (Boolean) -> Unit,
onTest: () -> Unit, onTest: () -> Unit,
onSave: (MessagingPlatformUpdate) -> Unit, onSave: (MessagingPlatformUpdate) -> Unit,
onRemove: () -> Unit,
onStartOnboarding: () -> Unit,
) { ) {
var showConfigureForm by remember { mutableStateOf(false) } var showConfigureForm by remember { mutableStateOf(false) }
var showRemoveConfirm by remember { mutableStateOf(false) }
val style = platformStateStyle(platform.state) val style = platformStateStyle(platform.state)
Card(modifier = Modifier.fillMaxWidth()) { Card(modifier = Modifier.fillMaxWidth()) {
@@ -318,14 +395,10 @@ private fun PlatformCard(
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
StateBadge(state = platform.state) StateBadge(state = platform.state)
if (platform.id == "telegram") {
// reserved for onboarding badge later
}
} }
} }
} }
// Toggle switch
if (isToggling) { if (isToggling) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
@@ -395,14 +468,81 @@ private fun PlatformCard(
style = MaterialTheme.typography.labelSmall, 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 ── // ── Configure form ──
if (showConfigureForm) { 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 ─────────────────────────────────────────────────── // ── Configure form ───────────────────────────────────────────────────
@@ -415,6 +555,7 @@ private fun ConfigureForm(
) { ) {
val envFields = platform.envVars.orEmpty() val envFields = platform.envVars.orEmpty()
val inputValues = remember { mutableStateMapOf<String, String>() } val inputValues = remember { mutableStateMapOf<String, String>() }
val fieldErrors = remember { mutableStateMapOf<String, String>() }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
@@ -426,13 +567,38 @@ private fun ConfigureForm(
envFields.forEach { field -> envFields.forEach { field ->
OutlinedTextField( OutlinedTextField(
value = inputValues[field.key].orEmpty(), 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) }, label = { Text(field.prompt ?: field.key) },
placeholder = { placeholder = {
if (field.isSet) { if (field.isSet) {
Text(field.redactedValue ?: "******** (Already Configured)") 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, singleLine = true,
visualTransformation = visualTransformation =
if (field.isPassword) { if (field.isPassword) {
@@ -441,6 +607,7 @@ private fun ConfigureForm(
androidx.compose.ui.text.input.VisualTransformation.None androidx.compose.ui.text.input.VisualTransformation.None
}, },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
isError = fieldErrors.containsKey(field.key),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
@@ -461,6 +628,16 @@ private fun ConfigureForm(
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Button( Button(
onClick = { 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 filledEnv = inputValues.filterValues { it.isNotBlank() }
val update = val update =
MessagingPlatformUpdate( 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 androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatform
import com.m57.hermescontrol.data.model.MessagingPlatformUpdate 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.ApiClient
import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -15,6 +19,14 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
enum class OnboardingPhase {
IDLE,
STARTING,
WAITING,
READY,
APPLYING,
}
data class ChannelsUiState( data class ChannelsUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val platforms: List<MessagingPlatform> = emptyList(), val platforms: List<MessagingPlatform> = emptyList(),
@@ -24,6 +36,20 @@ data class ChannelsUiState(
val isRestarting: Boolean = false, val isRestarting: Boolean = false,
val togglingId: String? = null, val togglingId: String? = null,
val testingId: 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 : class ChannelsViewModel :
@@ -32,6 +58,8 @@ class ChannelsViewModel :
private val _uiState = MutableStateFlow(ChannelsUiState()) private val _uiState = MutableStateFlow(ChannelsUiState())
val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow() val uiState: StateFlow<ChannelsUiState> = _uiState.asStateFlow()
private var launchJob: Job? = null
fun loadPlatforms() { fun loadPlatforms() {
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } }, apiCall = { safeApiCall { ApiClient.hermesApi.getMessagingPlatforms() } },
@@ -42,7 +70,12 @@ class ChannelsViewModel :
}, },
onSuccess = { data -> onSuccess = { data ->
_uiState.update { _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 -> onError = { errorMsg ->
@@ -56,9 +89,7 @@ class ChannelsViewModel :
) )
} }
/** Toggle a platform's enabled state without a full reload. /** Toggle a platform's enabled state without a full reload. */
* On success the local state is updated optimistically and
* restartNeeded is set. */
fun togglePlatform( fun togglePlatform(
platformId: String, platformId: String,
enabled: Boolean, 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( fun configurePlatform(
platformId: String, platformId: String,
update: MessagingPlatformUpdate, 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() { fun restartGateway() {
safeLaunchLoad( safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } }, apiCall = { safeApiCall { ApiClient.hermesApi.restartGateway() } },
@@ -203,7 +264,283 @@ class ChannelsViewModel :
_uiState.update { it.copy(restartNeeded = false) } _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() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _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
}