* 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:
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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user