feat(telegram): add 'none' listener mode for bots

Introduce a third update_mode option alongside polling/webhook. 'none'
disables both polling and webhook delivery — useful when another instance
owns the listener or when the bot is send-only. Switching into 'none' now
unschedules polling and unregisters any active webhook so Telegram stops
delivering updates.

New bots default to 'none' (safer when multiple bridges share a token).
Existing bots upgraded from a pre-update_mode schema keep 'polling' so
their behavior is unchanged.
This commit is contained in:
2026-04-24 15:15:25 +03:00
parent 461fb495d7
commit be15463fd2
7 changed files with 42 additions and 7 deletions
+2
View File
@@ -433,6 +433,8 @@
"webhookRegistered": "Webhook registered",
"webhookUnregistered": "Webhook unregistered",
"updateMode": "Update mode",
"none": "None",
"noneActive": "Listener disabled",
"polling": "Polling",
"webhook": "Webhook",
"webhookStatus": "Webhook status",
+2
View File
@@ -433,6 +433,8 @@
"webhookRegistered": "Вебхук зарегистрирован",
"webhookUnregistered": "Вебхук удалён",
"updateMode": "Режим обновлений",
"none": "Откл.",
"noneActive": "Приём обновлений отключён",
"polling": "Опрос",
"webhook": "Вебхук",
"webhookStatus": "Статус вебхука",
+20 -3
View File
@@ -334,10 +334,12 @@
<span class="text-xs text-[var(--color-muted-foreground)]">@{bot.bot_username}</span>
{/if}
<!-- Mode badge -->
<span class="text-xs px-1.5 py-0.5 rounded font-mono {bot.update_mode === 'webhook'
<span class="text-xs px-1.5 py-0.5 rounded font-mono {(bot.update_mode || 'none') === 'webhook'
? 'bg-[var(--color-primary)]/10 text-[var(--color-primary)]'
: 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'}">
{bot.update_mode === 'webhook' ? t('telegramBot.webhook') : t('telegramBot.polling')}
: (bot.update_mode || 'none') === 'polling'
? 'bg-[var(--color-success-bg)] text-[var(--color-success-fg)]'
: 'bg-[var(--color-muted)] text-[var(--color-muted-foreground)]'}">
{(bot.update_mode || 'none') === 'webhook' ? t('telegramBot.webhook') : (bot.update_mode || 'none') === 'polling' ? t('telegramBot.polling') : t('telegramBot.none')}
</span>
</div>
<p class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.token_preview}</p>
@@ -456,6 +458,14 @@
<p class="text-xs font-medium mb-2">{t('telegramBot.updateMode')}</p>
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center rounded-md border border-[var(--color-border)] overflow-hidden">
<button onclick={() => switchMode(bot.id, 'none')}
disabled={modeChanging[bot.id] || (bot.update_mode || 'none') === 'none'}
class="px-3 py-1 text-xs transition-colors {(bot.update_mode || 'none') === 'none'
? 'bg-[var(--color-primary)] text-[var(--color-primary-foreground)]'
: 'hover:bg-[var(--color-muted)]'} disabled:opacity-70">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.none')}
</button>
<button onclick={() => switchMode(bot.id, 'polling')}
disabled={modeChanging[bot.id] || bot.update_mode === 'polling'}
class="px-3 py-1 text-xs transition-colors {bot.update_mode === 'polling'
@@ -474,6 +484,13 @@
</button>
</div>
{#if (bot.update_mode || 'none') === 'none'}
<span class="text-xs text-[var(--color-muted-foreground)] flex items-center gap-1">
<MdiIcon name="mdiBellOff" size={14} />
{t('telegramBot.noneActive')}
</span>
{/if}
{#if bot.update_mode === 'polling'}
<span class="text-xs text-[var(--color-success-fg)] flex items-center gap-1">
<MdiIcon name="mdiCheckCircle" size={14} />
@@ -86,6 +86,11 @@ async def update_bot(
bot.icon = body.icon
# Handle mode switching
if body.update_mode is not None and body.update_mode != bot.update_mode:
if body.update_mode not in ("none", "polling", "webhook"):
raise HTTPException(
status_code=400,
detail=f"Invalid update_mode: {body.update_mode!r}. Must be 'none', 'polling', or 'webhook'.",
)
if body.update_mode == "webhook":
# Validate and register webhook BEFORE stopping polling
base_url = await get_setting(session, "external_url")
@@ -108,6 +113,12 @@ async def update_bot(
# Switching to polling: unregister webhook, start polling
await unregister_webhook(bot.token)
schedule_bot_polling(bot.id)
elif body.update_mode == "none":
# Disable listener: stop polling and clear any webhook so Telegram
# stops delivering updates. This makes the bot send-only, which is
# safe when another instance owns the listener.
unschedule_bot_polling(bot.id)
await unregister_webhook(bot.token)
bot.update_mode = body.update_mode
session.add(bot)
@@ -406,7 +417,7 @@ def _bot_response(b: TelegramBot) -> dict:
"bot_username": b.bot_username,
"bot_id": b.bot_id,
"webhook_path_id": b.webhook_path_id,
"update_mode": b.update_mode or "polling",
"update_mode": b.update_mode or "none",
"token_preview": f"{b.token[:8]}...{b.token[-4:]}" if len(b.token) > 12 else "***",
"created_at": b.created_at.isoformat(),
}
@@ -144,7 +144,10 @@ async def migrate_schema(engine: AsyncEngine) -> None:
if bots:
logger.info("Backfilled webhook_path_id for %d existing bots", len(bots))
# Add update_mode to telegram_bot if missing
# Add update_mode to telegram_bot if missing.
# Existing bots pre-date this feature and were implicitly polling;
# preserve that behavior. New bots default to "none" via the
# SQLModel field default on fresh schemas.
if not await _has_column(conn, "telegram_bot", "update_mode"):
await conn.execute(
text("ALTER TABLE telegram_bot ADD COLUMN update_mode TEXT DEFAULT 'polling'")
@@ -49,7 +49,7 @@ class TelegramBot(SQLModel, table=True):
bot_username: str = Field(default="")
bot_id: int = Field(default=0)
webhook_path_id: str = Field(default_factory=lambda: uuid4().hex)
update_mode: str = Field(default="polling") # "polling" or "webhook"
update_mode: str = Field(default="none") # "none", "polling", or "webhook"
# NOTE: commands_config column remains in the DB for backward compat,
# but is no longer part of the SQLModel class. Data migrated to CommandConfig.
created_at: datetime = Field(default_factory=_utcnow)
@@ -173,7 +173,7 @@ class TelegramBotData(BaseModel):
token: str = ""
icon: str = ""
bot_username: str = ""
update_mode: str = "polling"
update_mode: str = "none"
class MatrixBotData(BaseModel):