feat: per-chat command toggle, listener name + toggle in bot tab
- Add commands_enabled field to TelegramChat (default off) with migration, gating command dispatch in both poller and webhook - Show toggle switch per chat in bot tab for enabling/disabling commands - Fix listener response to include bot name instead of just type - Replace listener "Enabled" label + "Edit" link with toggle switch and crosslink to command-trackers page
This commit is contained in:
@@ -338,6 +338,8 @@
|
||||
"chatName": "Name",
|
||||
"chatType": "Type",
|
||||
"chatLang": "Lang",
|
||||
"cmds": "Cmds",
|
||||
"commandsToggle": "Toggle command listening for this chat",
|
||||
"chatId": "Chat ID",
|
||||
"languageUpdated": "Chat language updated",
|
||||
"cmdLocale": "Bot language",
|
||||
|
||||
@@ -338,6 +338,8 @@
|
||||
"chatName": "Имя",
|
||||
"chatType": "Тип",
|
||||
"chatLang": "Язык",
|
||||
"cmds": "Команды",
|
||||
"commandsToggle": "Включить/выключить команды для этого чата",
|
||||
"chatId": "ID чата",
|
||||
"languageUpdated": "Язык чата обновлён",
|
||||
"cmdLocale": "Язык бота",
|
||||
|
||||
@@ -52,6 +52,7 @@ export interface TelegramChat {
|
||||
type: string;
|
||||
username: string;
|
||||
language_code?: string;
|
||||
commands_enabled: boolean;
|
||||
discovered_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ language_code: lang }),
|
||||
});
|
||||
// Update local state immutably
|
||||
chats[botId] = (chats[botId] || []).map(c =>
|
||||
c.id === chat.id ? { ...c, language_code: lang } : c
|
||||
);
|
||||
@@ -139,6 +138,19 @@
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function toggleChatCommands(botId: number, chat: TelegramChat) {
|
||||
const newVal = !chat.commands_enabled;
|
||||
try {
|
||||
await api(`/telegram-bots/${botId}/chats/${chat.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ commands_enabled: newVal }),
|
||||
});
|
||||
chats[botId] = (chats[botId] || []).map(c =>
|
||||
c.id === chat.id ? { ...c, commands_enabled: newVal } : c
|
||||
);
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function loadListenerStatus(botId: number) {
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: true };
|
||||
try {
|
||||
@@ -156,6 +168,19 @@
|
||||
botListenerLoading = { ...botListenerLoading, [botId]: false };
|
||||
}
|
||||
|
||||
async function toggleListenerEnabled(botId: number, trk: CommandTrackerSummary) {
|
||||
const endpoint = trk.enabled ? 'disable' : 'enable';
|
||||
try {
|
||||
await api(`/command-trackers/${trk.id}/${endpoint}`, { method: 'POST' });
|
||||
botListenerStatus = {
|
||||
...botListenerStatus,
|
||||
[botId]: (botListenerStatus[botId] || []).map(t =>
|
||||
t.id === trk.id ? { ...t, enabled: !t.enabled } : t
|
||||
),
|
||||
};
|
||||
} catch (err: any) { snackError(err.message); }
|
||||
}
|
||||
|
||||
async function syncCommands(botId: number) {
|
||||
modeChanging = { ...modeChanging, [botId]: true };
|
||||
try {
|
||||
@@ -337,13 +362,14 @@
|
||||
{:else if (chats[bot.id] || []).length === 0}
|
||||
<p class="text-xs text-[var(--color-muted-foreground)]">{t('telegramBot.noChats')}</p>
|
||||
{:else}
|
||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||
{@const gridStyle = "display:grid; grid-template-columns:1fr 80px 100px 50px 130px 60px; align-items:center; gap:0.5rem;"}
|
||||
<!-- Header -->
|
||||
<div style="{gridStyle} padding:0.25rem 0.5rem; border-bottom:1px solid var(--color-border);"
|
||||
class="text-[0.65rem] font-semibold uppercase tracking-wide text-[var(--color-muted-foreground)]">
|
||||
<span>{t('telegramBot.chatName')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatType')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatLang')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.cmds')}</span>
|
||||
<span style="text-align:center">{t('telegramBot.chatId')}</span>
|
||||
<span></span>
|
||||
</div>
|
||||
@@ -364,6 +390,14 @@
|
||||
onselect={(val) => updateChatLanguage(bot.id, chat, String(val ?? ''))}
|
||||
/>
|
||||
</div>
|
||||
<div style="justify-self:center" onclick={(e: MouseEvent) => e.stopPropagation()}>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{chat.commands_enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={t('telegramBot.commandsToggle')}
|
||||
onclick={() => toggleChatCommands(bot.id, chat)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{chat.commands_enabled ? '14px' : '2px'}; background:{chat.commands_enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
<span style="text-align:center" class="text-xs text-[var(--color-muted-foreground)] font-mono">{chat.chat_id}</span>
|
||||
<div style="justify-self:end" class="flex items-center gap-1">
|
||||
<IconButton icon="mdiSend" title="Test message" size={14}
|
||||
@@ -395,16 +429,14 @@
|
||||
<div class="flex items-center justify-between text-sm px-2 py-1 rounded hover:bg-[var(--color-muted)]">
|
||||
<div class="flex items-center gap-2">
|
||||
<MdiIcon name={trk.icon || 'mdiConsoleLine'} size={14} />
|
||||
<span class="font-medium">{trk.name}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded font-mono {trk.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500'
|
||||
: 'bg-red-500/10 text-red-500'}">
|
||||
{trk.enabled ? t('commandTracker.enabled') : t('commandTracker.disabled')}
|
||||
</span>
|
||||
<a href="/command-trackers" class="font-medium text-[var(--color-primary)] hover:underline">{trk.name}</a>
|
||||
</div>
|
||||
<a href="/command-trackers" class="text-xs text-[var(--color-primary)] hover:underline">
|
||||
{t('common.edit')}
|
||||
</a>
|
||||
<button
|
||||
style="width:28px; height:16px; border-radius:8px; position:relative; transition:background-color 0.2s; background-color:{trk.enabled ? 'var(--color-primary)' : 'var(--color-border)'};"
|
||||
title={trk.enabled ? t('notificationTracker.pause') : t('notificationTracker.resume')}
|
||||
onclick={() => toggleListenerEnabled(bot.id, trk)}>
|
||||
<span style="position:absolute; top:2px; width:12px; height:12px; border-radius:50%; transition:left 0.2s; left:{trk.enabled ? '14px' : '2px'}; background:{trk.enabled ? 'white' : 'var(--color-muted-foreground)'};" ></span>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -256,7 +256,7 @@ async def list_listeners(
|
||||
CommandTrackerListener.command_tracker_id == tracker_id
|
||||
)
|
||||
)
|
||||
return [_listener_response(l) for l in result.all()]
|
||||
return [await _listener_response(session, l) for l in result.all()]
|
||||
|
||||
|
||||
@router.post("/{tracker_id}/listeners", status_code=status.HTTP_201_CREATED)
|
||||
@@ -312,7 +312,7 @@ async def add_listener(
|
||||
from ..services.command_sync import mark_bot_dirty
|
||||
mark_bot_dirty(body.listener_id)
|
||||
|
||||
return _listener_response(listener)
|
||||
return await _listener_response(session, listener)
|
||||
|
||||
|
||||
@router.delete("/{tracker_id}/listeners/{listener_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -377,17 +377,23 @@ async def _tracker_response(
|
||||
CommandTrackerListener.command_tracker_id == t.id
|
||||
)
|
||||
)
|
||||
resp["listeners"] = [_listener_response(l) for l in lr.all()]
|
||||
resp["listeners"] = [await _listener_response(session, l) for l in lr.all()]
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
def _listener_response(l: CommandTrackerListener) -> dict:
|
||||
async def _listener_response(session: AsyncSession, l: CommandTrackerListener) -> dict:
|
||||
name = ""
|
||||
if l.listener_type == "telegram_bot":
|
||||
bot = await session.get(TelegramBot, l.listener_id)
|
||||
if bot:
|
||||
name = bot.name
|
||||
return {
|
||||
"id": l.id,
|
||||
"command_tracker_id": l.command_tracker_id,
|
||||
"listener_type": l.listener_type,
|
||||
"listener_id": l.listener_id,
|
||||
"name": name,
|
||||
"created_at": l.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -299,6 +299,7 @@ async def test_chat(
|
||||
class ChatUpdate(BaseModel):
|
||||
language_code: str | None = None
|
||||
title: str | None = None
|
||||
commands_enabled: bool | None = None
|
||||
|
||||
|
||||
@router.put("/{bot_id}/chats/{chat_db_id}")
|
||||
@@ -320,11 +321,7 @@ async def update_chat(
|
||||
session.add(chat)
|
||||
await session.commit()
|
||||
await session.refresh(chat)
|
||||
return {
|
||||
"id": chat.id, "bot_id": chat.bot_id, "chat_id": chat.chat_id,
|
||||
"title": chat.title, "type": chat.chat_type, "username": chat.username,
|
||||
"language_code": chat.language_code, "discovered_at": chat.discovered_at.isoformat() if chat.discovered_at else None,
|
||||
}
|
||||
return _chat_response(chat)
|
||||
|
||||
|
||||
@router.delete("/{bot_id}/chats/{chat_db_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@@ -392,6 +389,7 @@ def _chat_response(c: TelegramChat) -> dict:
|
||||
"type": c.chat_type,
|
||||
"username": c.username,
|
||||
"language_code": getattr(c, 'language_code', '') or '',
|
||||
"commands_enabled": getattr(c, 'commands_enabled', False),
|
||||
"discovered_at": c.discovered_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
from ..database.engine import get_session
|
||||
from ..database.models import TelegramBot
|
||||
from ..database.models import TelegramBot, TelegramChat
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .handler import handle_command, send_media_group, send_reply
|
||||
|
||||
@@ -76,8 +76,16 @@ async def telegram_webhook(
|
||||
except Exception:
|
||||
_LOGGER.warning("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
# Handle commands
|
||||
# Handle commands (only if chat has commands enabled)
|
||||
if text.startswith("/"):
|
||||
chat_row = (await session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot.id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)).first()
|
||||
if not chat_row or not chat_row.commands_enabled:
|
||||
return {"ok": True, "skipped": "commands_disabled"}
|
||||
message_id = message.get("message_id")
|
||||
cmd_response = await handle_command(bot, chat_id, text, language_code=msg_language)
|
||||
if cmd_response is not None:
|
||||
|
||||
@@ -207,6 +207,13 @@ async def migrate_schema(engine: AsyncEngine) -> None:
|
||||
)
|
||||
logger.info("Added language_code column to telegram_chat table")
|
||||
|
||||
# Add commands_enabled to telegram_chat if missing (default disabled)
|
||||
if not await _has_column(conn, "telegram_chat", "commands_enabled"):
|
||||
await conn.execute(
|
||||
text("ALTER TABLE telegram_chat ADD COLUMN commands_enabled INTEGER DEFAULT 0")
|
||||
)
|
||||
logger.info("Added commands_enabled column to telegram_chat table")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Legacy tracker_target migration (pre-Phase 1)
|
||||
|
||||
@@ -96,6 +96,7 @@ class TelegramChat(SQLModel, table=True):
|
||||
chat_type: str = Field(default="private")
|
||||
username: str = Field(default="")
|
||||
language_code: str = Field(default="")
|
||||
commands_enabled: bool = Field(default=False)
|
||||
discovered_at: datetime = Field(default_factory=_utcnow)
|
||||
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from sqlmodel.ext.asyncio.session import AsyncSession
|
||||
from notify_bridge_core.notifications.telegram.client import TelegramClient
|
||||
|
||||
from ..database.engine import get_engine
|
||||
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot
|
||||
from ..database.models import CommandTracker, CommandTrackerListener, TelegramBot, TelegramChat
|
||||
from ..services.telegram import save_chat_from_webhook
|
||||
from .scheduler import get_scheduler
|
||||
|
||||
@@ -195,9 +195,18 @@ async def _poll_bot(bot_id: int) -> None:
|
||||
except Exception:
|
||||
_LOGGER.debug("Failed to auto-save chat %s", chat_id, exc_info=True)
|
||||
|
||||
# Dispatch commands
|
||||
# Dispatch commands (only if chat has commands enabled)
|
||||
if text and text.startswith("/"):
|
||||
try:
|
||||
async with AsyncSession(engine) as cmd_session:
|
||||
chat_row = (await cmd_session.exec(
|
||||
select(TelegramChat).where(
|
||||
TelegramChat.bot_id == bot_obj.id,
|
||||
TelegramChat.chat_id == chat_id,
|
||||
)
|
||||
)).first()
|
||||
if not chat_row or not chat_row.commands_enabled:
|
||||
continue
|
||||
message_id = message.get("message_id")
|
||||
cmd_response = await handle_command(bot_obj, chat_id, text, language_code=msg_language)
|
||||
if cmd_response is not None:
|
||||
|
||||
Reference in New Issue
Block a user