fix(telegram): remove restart button, validate token on setup (#434)

* fix(web): remove gateway restart button from channel activation failure cards

When a WASM channel (e.g. Telegram) fails to hot-activate after setup,
the extension card showed a "Restart" button that calls POST /api/gateway/restart.
This triggers a process exit and relies on an external supervisor to relaunch,
which doesn't work reliably when running inside Docker.

Remove the Restart button entirely from the failed-activation card for all
channels — Reconfigure is the correct recovery action (re-enter credentials).

Also fix two bugs found during review:
- setServerLogLevel/loadServerLogLevel called .json() on the already-parsed
  object returned by apiFetch, causing a silent TypeError that prevented the
  log level selector from updating
- buildBreadcrumb embedded paths in inline onclick JS strings using escapeHtml,
  which doesn't escape single quotes; switched to data-path attribute pattern
  to avoid JS string injection from paths containing quotes

And simplify: collapse the dead Telegram-specific branch in submitConfigureModal
toast messaging — all channels now show "Configured and activated X" on success.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(telegram): propagate token validation errors from on_start

Both webhook and polling mode in on_start() swallowed activation errors
from register_webhook/delete_webhook — using `if let Err(e)` to log
but then returning Ok regardless. This caused a bad bot token to show
as "configured and active" instead of failing activation.

Telegram returns {"ok": true} when deleteWebhook is called with no
existing webhook (idempotent), so any error (e.g. 401 Unauthorized)
genuinely means an invalid token.

The WASM is rebuilt automatically via build.rs on cargo build.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(telegram): validate bot token before storing, fix misleading toast

Add upfront GET /getMe validation in save_setup_secrets() before writing
the bot token to the secrets store. This catches bad tokens immediately
for both fresh installs and reconfigures — the reconfigure path
(refresh_active_channel) skips on_start entirely and would never catch
an invalid token without this check. URL-encode the token before
interpolating into the getMe URL path.

Also update the activation-failure toast from "Restart required to
activate" (misleading now that the Restart button is gone) to
"Use Reconfigure to re-enter credentials and activate".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

* fix(telegram): collapse nested if, fix formatting (clippy + fmt)

Collapse `if name == "telegram" { if let Some(...) }` into a single
let-chain condition as suggested by clippy's collapsible_if lint.
Also apply rustfmt line-length fixes in the same block.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>

---------

Co-authored-by: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
Henry Park
2026-02-28 19:58:45 -08:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 8751a5a9bc
commit 98467a553e
3 changed files with 44 additions and 29 deletions
+8 -15
View File
@@ -373,19 +373,16 @@ impl Guest for TelegramChannel {
"Webhook mode enabled (tunnel configured)",
);
// Register webhook with Telegram API
// Register webhook with Telegram API — propagate errors so a bad token
// causes activation to fail rather than silently succeeding.
if let Some(ref tunnel_url) = config.tunnel_url {
channel_host::log(
channel_host::LogLevel::Info,
&format!("Registering webhook: {}/webhook/telegram", tunnel_url),
);
if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) {
channel_host::log(
channel_host::LogLevel::Error,
&format!("Failed to register webhook: {}", e),
);
}
register_webhook(tunnel_url, config.webhook_secret.as_deref())
.map_err(|e| format!("Failed to register webhook: {}", e))?;
}
} else {
channel_host::log(
@@ -393,14 +390,10 @@ impl Guest for TelegramChannel {
"Polling mode enabled (no tunnel configured)",
);
// Delete any existing webhook before polling
// Telegram doesn't allow getUpdates while a webhook is active
if let Err(e) = delete_webhook() {
channel_host::log(
channel_host::LogLevel::Warn,
&format!("Failed to delete webhook (may not exist): {}", e),
);
}
// Delete any existing webhook before polling. Telegram returns success
// when no webhook exists, so any error here (e.g. 401) means a bad token.
delete_webhook()
.map_err(|e| format!("Bot token validation failed: {}", e))?;
}
// Configure polling only if not in webhook mode
+6 -14
View File
@@ -1453,7 +1453,9 @@ function buildBreadcrumb(path) {
let current = '';
for (const part of parts) {
current += (current ? '/' : '') + part;
html += ' / <a onclick="readMemoryFile(\'' + escapeHtml(current) + '\')">' + escapeHtml(part) + '</a>';
// Store the path in data-path (HTML-escaped) and read it back via this.dataset.path
// to avoid single-quote injection in inline JS string literals.
html += ' / <a onclick="readMemoryFile(this.dataset.path)" data-path="' + escapeHtml(current) + '">' + escapeHtml(part) + '</a>';
}
return html;
}
@@ -1628,10 +1630,8 @@ function applyLogFilters() {
function setServerLogLevel(level) {
apiFetch('/api/logs/level', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ level: level }),
body: { level },
})
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
@@ -1640,7 +1640,6 @@ function setServerLogLevel(level) {
function loadServerLogLevel() {
apiFetch('/api/logs/level')
.then(r => r.json())
.then(data => {
document.getElementById('logs-server-level').value = data.level;
})
@@ -1957,11 +1956,6 @@ function renderExtensionCard(ext) {
actions.appendChild(pairingLabel);
actions.appendChild(createReconfigureButton(ext.name));
} else if (status === 'failed') {
var restartBtn = document.createElement('button');
restartBtn.className = 'btn-ext activate';
restartBtn.textContent = 'Restart';
restartBtn.addEventListener('click', restartGateway);
actions.appendChild(restartBtn);
actions.appendChild(createReconfigureButton(ext.name));
} else {
// installed or configured: show Setup button
@@ -2169,12 +2163,10 @@ function submitConfigureModal(name, fields) {
.then((res) => {
closeConfigureModal();
if (res.success) {
if (res.activated && name === 'telegram') {
if (res.activated) {
showToast('Configured and activated ' + name, 'success');
} else if (res.activated) {
showToast('Configured ' + name + ' successfully', 'success');
} else if (res.needs_restart) {
showToast('Configured ' + name + '. Restart required to activate.', 'info');
showToast('Configured ' + name + '. Use Reconfigure to re-enter credentials and activate.', 'info');
} else {
showToast(res.message, 'success');
}
+30
View File
@@ -2329,6 +2329,36 @@ impl ExtensionManager {
}
};
// For Telegram, validate the bot token against the API before storing it.
// This catches bad tokens immediately (both on first setup and reconfigure),
// before the channel activates and potentially shows as active with a bad token.
if name == "telegram"
&& let Some(token_value) = secrets.get("telegram_bot_token")
{
let token = token_value.trim();
if !token.is_empty() {
let encoded_token =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
let url = format!("https://api.telegram.org/bot{}/getMe", encoded_token);
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ExtensionError::Other(e.to_string()))?
.get(&url)
.send()
.await
.map_err(|e| {
ExtensionError::Other(format!("Failed to validate bot token: {}", e))
})?;
if !resp.status().is_success() {
return Err(ExtensionError::Other(format!(
"Invalid bot token (Telegram API returned {})",
resp.status()
)));
}
}
}
// Validate and store each submitted secret
for (secret_name, secret_value) in secrets {
if !allowed.contains(secret_name.as_str()) {