fix(settings): Telegram 检测/测试闭环与 8 项告警开关恢复

检测与测试前自动持久化 Token/Chat ID;getUpdates 清除 Webhook 并返回具体 API 错误;恢复 NOTIFY 分项开关与免密 reveal。
This commit is contained in:
Nexus Agent
2026-06-07 19:45:07 +08:00
parent 2e911dcca7
commit 540a7fce05
7 changed files with 480 additions and 105 deletions
+20
View File
@@ -376,3 +376,23 @@
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
@@ -0,0 +1,37 @@
# 审计 — 设置页 Telegram 全流程修复
## 范围
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/SettingsPage.vue` | persist/save/test/detect、8 NOTIFY 开关、reveal 免密 |
| `frontend/src/types/api.ts` | `NOTIFY_TOGGLE_ITEMS` |
| `server/api/settings.py` | Webhook 清除、错误详情、reveal 免密 |
| `tests/test_settings_telegram.py` | 6 用例 |
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 测试/检测前持久化 Token+Chat ID | FIXED — `persistTelegramConfig` |
| H2 | getUpdates Webhook 冲突 | FIXED — `deleteWebhook` before poll |
| H3 | Telegram API 错误可见 | FIXED — `ok:false` / sendMessage description |
| H4 | 8 项 NOTIFY 开关 UI | FIXED — Vue v-switch + PUT |
| H5 | reveal 免密(用户确认) | FIXED — JWT + 审计保留 |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1H5 | FIXED | 代码 + pytest 6/6 |
## DoD
- [x] pytest `test_settings_telegram.py` 6 passed
- [x] changelog / audit
- [x] 无不可变 key 变更
## 验证
`.venv/bin/pytest tests/test_settings_telegram.py -q`
@@ -0,0 +1,37 @@
# Changelog — 设置页 Telegram 全流程修复 + 告警开关恢复
**日期**2026-06-07
## 摘要
修复设置页 Telegram 推送相关功能:检测/测试前自动持久化配置、独立保存按钮、getUpdates Webhook 冲突处理、测试 API 返回 Telegram 具体错误;恢复 Vue 迁移遗漏的 8 项告警通知开关;API Key / Bot Token 查看免二次密码(JWT + 审计仍保留)。
## 动机
用户反馈「检测 Chat ID / 发送测试 / 多项功能无法正常使用」:测试按钮不保存草稿配置、告警分项开关在 SPA 中缺失、getUpdates 在 Bot 曾设 Webhook 时恒为空、错误提示过于笼统。
## 涉及文件
- `frontend/src/pages/SettingsPage.vue``persistTelegramConfig`、保存按钮、8 项 NOTIFY 开关
- `frontend/src/types/api.ts``NOTIFY_TOGGLE_ITEMS`
- `server/api/settings.py` — Webhook 清除、Telegram 错误详情、reveal 免密
- `tests/test_settings_telegram.py` — 新增
- `docs/audit/2026-06-07-settings-telegram-fix.md`
## 迁移 / 重启
- 无 DB 迁移
- 需重新构建前端并重启 API(或全量部署)
## 验证
```bash
.venv/bin/pytest tests/test_settings_telegram.py -q
bash scripts/local_verify.sh
bash deploy/pre_deploy_check.sh
# 设置页:填 Token → 检测 Chat ID → 测试发送 → 切换告警开关
```
## 说明
Layer-3 cron`health_monitor.sh`)仍读 `NEXUS_TELEGRAM_*``.env.prod`),与 UI 保存的 MySQL 配置独立;应用内告警走 MySQL 热更新即可。
+149 -91
View File
@@ -63,18 +63,42 @@
class="mb-3"
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
:type="showToken ? 'text' : 'password'"
:append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'"
@click:append-inner="revealTelegramToken"
:append-inner-icon="showToken ? 'mdi-eye-off' : 'mdi-eye'"
@click:append-inner="toggleTelegramTokenVisibility"
/>
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
<p class="text-caption text-medium-emphasis mb-3">
Telegram Bot 发送任意消息后可点击下方按钮自动检测 Chat ID
</p>
<div class="d-flex flex-wrap ga-2">
<div class="d-flex flex-wrap ga-2 mb-4">
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveTelegramConfig" :loading="savingTelegram">保存 Telegram 配置</v-btn>
</div>
<v-divider class="my-3" />
<div class="text-subtitle-2 mb-1">告警通知项目</div>
<p class="text-caption text-medium-emphasis mb-3">
关闭后该类告警仍记录日志但不发送 Telegram 消息Bot Token 未配置时全部静默
</p>
<template v-for="(groupItems, groupName) in notifyToggleGroups" :key="groupName">
<div class="text-caption text-medium-emphasis font-weight-medium mt-2 mb-1">{{ groupName }}</div>
<v-switch
v-for="item in groupItems"
:key="item.key"
:model-value="notifyToggles[item.key] ?? true"
:label="item.label"
:hint="item.desc"
persistent-hint
color="primary"
density="compact"
hide-details="auto"
class="mb-1"
:disabled="notifyToggleSaving === item.key"
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
/>
</template>
</v-card-text>
</v-card>
</v-col>
@@ -154,7 +178,7 @@
<v-card-text>
<div class="d-flex align-center ga-2">
<v-text-field :model-value="showApiKey ? apiKeyValue : '••••••••'" label="API Key" variant="outlined" density="compact" readonly :type="showApiKey ? 'text' : 'password'" style="max-width: 320px" />
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" />
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" :loading="revealingApiKey" />
<v-btn variant="text" size="small" icon="mdi-content-copy" @click="copyApiKey" />
</div>
</v-card-text>
@@ -250,24 +274,6 @@
</v-card>
</v-dialog>
<!-- API Key Reveal Dialog -->
<v-dialog v-model="showRevealDialog" max-width="400">
<v-card border>
<v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
<v-card-text>
<div class="text-body-2 text-medium-emphasis mb-3">
{{ revealTarget === 'telegram' ? '请输入当前密码以查看 Telegram Bot Token' : '请输入当前密码以查看 API Key' }}
</div>
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
<v-btn color="primary" variant="flat" @click="doRevealSecret" :loading="revealingKey">确认</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Telegram Chat ID picker -->
<v-dialog v-model="tgChatDialog" max-width="480">
<v-card border>
@@ -327,6 +333,7 @@ import type {
SubscriptionParseResponse,
IpAllowlistSaveRequest,
} from '@/types/api'
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
const snackbar = useSnackbar()
@@ -348,6 +355,23 @@ const detectingTgChats = ref(false)
const tgChatDialog = ref(false)
const tgChats = ref<TelegramChatItem[]>([])
const showToken = ref(false)
const savingTelegram = ref(false)
const notifyToggles = ref<Record<string, boolean>>({})
const notifyToggleSaving = ref<string | null>(null)
const notifyToggleGroups = computed(() => {
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
for (const item of NOTIFY_TOGGLE_ITEMS) {
if (!groups[item.group]) groups[item.group] = []
groups[item.group].push(item)
}
return groups
})
function parseNotifyEnabled(val: unknown): boolean {
const s = String(val ?? 'true').trim().toLowerCase()
return !['false', '0', 'no', 'off'].includes(s)
}
// ── Password ──
const pwForm = ref({ current: '', new_pw: '', confirm: '', totp_code: '' })
@@ -366,10 +390,8 @@ const disablingTotp = ref(false)
// ── API Key ──
const showApiKey = ref(false)
const apiKeyValue = ref('')
const showRevealDialog = ref(false)
const revealTarget = ref<'api_key' | 'telegram'>('api_key')
const revealPassword = ref('')
const revealingKey = ref(false)
const revealingApiKey = ref(false)
const revealingTelegramToken = ref(false)
// ── IP Allowlist ──
const ipAllowlistEnabled = ref(false)
@@ -399,6 +421,10 @@ async function loadSettings() {
telegramTokenDraft.value = ''
continue
}
if (key && NOTIFY_TOGGLE_ITEMS.some(n => n.key === key)) {
notifyToggles.value[key] = parseNotifyEnabled(item.value)
continue
}
if (key && knownKeys.includes(key)) {
const val = item.value
// Type-cast numeric strings back to numbers for number inputs
@@ -435,32 +461,81 @@ async function loadAllowlist() {
// ── Actions ──
async function saveSettings() {
saving.value = true
const tokenUpdate = telegramTokenDraft.value.trim()
try {
const entries = Object.entries(settings.value).filter(([key]) =>
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_chat_id'].includes(key)
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow'].includes(key)
)
if (tokenUpdate) {
entries.push(['telegram_bot_token', tokenUpdate])
}
for (const [key, value] of entries) {
await http.put(`/settings/${key}`, { value })
}
snackbar('设置已保存')
if (tokenUpdate) {
telegramTokenSet.value = true
telegramTokenDraft.value = ''
showToken.value = false
}
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : '保存失败'
snackbar(msg, 'error')
} finally { saving.value = false }
}
async function persistTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
const tokenUpdate = telegramTokenDraft.value.trim()
const chatId = String(settings.value.telegram_chat_id || '').trim()
if (options?.requireToken && !tokenUpdate && !telegramTokenSet.value) {
snackbar('请先填写 Bot Token', 'error')
return false
}
if (options?.requireChatId && !chatId) {
snackbar('请先填写或检测 Chat ID', 'error')
return false
}
if (tokenUpdate) {
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
telegramTokenSet.value = true
telegramTokenDraft.value = ''
showToken.value = false
}
if (chatId) {
await http.put('/settings/telegram_chat_id', { value: chatId })
}
return true
}
async function saveTelegramConfig() {
savingTelegram.value = true
try {
const tokenUpdate = telegramTokenDraft.value.trim()
const chatId = String(settings.value.telegram_chat_id || '').trim()
if (!tokenUpdate && !chatId && !telegramTokenSet.value) {
snackbar('请先填写 Bot Token 或 Chat ID', 'warning')
return
}
await persistTelegramConfig()
snackbar('Telegram 配置已保存')
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
savingTelegram.value = false
}
}
async function toggleNotify(key: string, enabled: boolean) {
const prev = notifyToggles.value[key]
notifyToggles.value[key] = enabled
notifyToggleSaving.value = key
try {
await http.put(`/settings/${key}`, { value: enabled ? 'true' : 'false' })
} catch (e: unknown) {
notifyToggles.value[key] = prev
snackbar(e instanceof Error ? e.message : '保存失败,已撤销', 'error')
} finally {
notifyToggleSaving.value = null
}
}
async function testTelegram() {
testingTg.value = true
try {
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
await http.post('/settings/telegram/test')
snackbar('测试消息已发送')
} catch (e: unknown) {
@@ -471,16 +546,7 @@ async function testTelegram() {
async function detectTelegramChats() {
detectingTgChats.value = true
try {
const tokenUpdate = telegramTokenDraft.value.trim()
if (tokenUpdate) {
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
telegramTokenSet.value = true
telegramTokenDraft.value = ''
showToken.value = false
} else if (!telegramTokenSet.value) {
snackbar('请先填写 Bot Token', 'error')
return
}
if (!await persistTelegramConfig({ requireToken: true })) return
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
tgChats.value = res.chats || []
if (!tgChats.value.length) {
@@ -586,61 +652,53 @@ async function revealApiKey() {
showApiKey.value = false
return
}
revealTarget.value = 'api_key'
showRevealDialog.value = true
revealPassword.value = ''
}
async function revealTelegramToken() {
if (showToken.value && telegramTokenDraft.value) {
showToken.value = false
telegramTokenDraft.value = ''
return
}
revealTarget.value = 'telegram'
showRevealDialog.value = true
revealPassword.value = ''
}
async function doRevealSecret() {
if (!revealPassword.value) {
snackbar('请输入密码', 'error')
return
}
revealingKey.value = true
revealingApiKey.value = true
try {
if (revealTarget.value === 'telegram') {
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {
current_password: revealPassword.value,
})
telegramTokenDraft.value = res.value
showToken.value = true
} else {
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', {
current_password: revealPassword.value,
})
apiKeyValue.value = res.api_key
showApiKey.value = true
}
showRevealDialog.value = false
revealPassword.value = ''
const res = await http.post<{ value: string }>('/settings/api-key/reveal', {})
apiKeyValue.value = res.value
showApiKey.value = true
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '验证失败', 'error')
snackbar(e instanceof Error ? e.message : '获取 API Key 失败', 'error')
} finally {
revealingKey.value = false
revealingApiKey.value = false
}
}
async function doRevealApiKey() {
revealTarget.value = 'api_key'
await doRevealSecret()
async function toggleTelegramTokenVisibility() {
if (showToken.value) {
showToken.value = false
if (telegramTokenSet.value) {
telegramTokenDraft.value = ''
}
return
}
if (telegramTokenDraft.value.trim()) {
showToken.value = true
return
}
if (!telegramTokenSet.value) {
showToken.value = true
return
}
revealingTelegramToken.value = true
try {
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {})
telegramTokenDraft.value = res.value
showToken.value = true
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '获取 Bot Token 失败', 'error')
} finally {
revealingTelegramToken.value = false
}
}
function copyApiKey() {
if (apiKeyValue.value) {
navigator.clipboard.writeText(apiKeyValue.value)
snackbar('已复制到剪贴板')
if (!showApiKey.value || !apiKeyValue.value) {
snackbar('请先点击眼睛图标显示 API Key', 'warning')
return
}
navigator.clipboard.writeText(apiKeyValue.value)
snackbar('已复制到剪贴板')
}
async function toggleAllowlist() {
+19
View File
@@ -35,6 +35,25 @@ export interface TelegramChatsResponse {
count: number
}
/** Telegram notification toggle (settings key → label) */
export interface NotifyToggleItem {
key: string
label: string
desc: string
group: string
}
export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
{ key: 'notify_alert_cpu', label: 'CPU 超阈值告警', desc: 'CPU 使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘使用率超过设定阈值时推送', group: '服务器资源' },
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘告警解除时推送', group: '服务器资源' },
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
{ key: 'notify_restart', label: 'Nexus 后端重启', desc: 'Python 服务崩溃重启成功/失败时推送', group: '系统' },
]
/** IP Allowlist response */
export interface AllowlistResponse {
enabled: boolean
+52 -14
View File
@@ -291,14 +291,11 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
@router.post("/api-key/reveal", response_model=dict)
async def reveal_api_key(
payload: ApiKeyRevealRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Reveal global API_KEY after re-entering account password."""
await _verify_reauth(db, admin, payload.current_password)
"""Reveal global API_KEY for authenticated admin (JWT required)."""
repo = SettingRepositoryImpl(db)
value = await repo.get("api_key")
if value is None:
@@ -903,13 +900,34 @@ async def alert_stats(
# ── Telegram Test + Chat ID Detection ──
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
"""Remove active webhook so getUpdates can receive messages."""
wh_resp = await client.get(f"{base_url}/getWebhookInfo")
if wh_resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Telegram getWebhookInfo 返回 {wh_resp.status_code}")
wh_data = wh_resp.json()
if not wh_data.get("ok"):
desc = wh_data.get("description") or "getWebhookInfo 失败"
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
if wh_data.get("result", {}).get("url"):
del_resp = await client.post(
f"{base_url}/deleteWebhook",
params={"drop_pending_updates": "false"},
)
del_data = del_resp.json()
if not del_data.get("ok"):
desc = del_data.get("description") or "deleteWebhook 失败"
raise HTTPException(status_code=502, detail=f"无法清除 Webhook: {desc}")
@router.post("/telegram/test", response_model=dict)
async def telegram_test_send(
admin: Admin = Depends(get_current_admin),
):
"""Send a test Telegram message to verify Bot Token + Chat ID configuration."""
from server.infrastructure.telegram import send_telegram
import httpx
from server.config import settings as _settings
from server.infrastructure.telegram import TELEGRAM_API_BASE
if not _settings.TELEGRAM_BOT_TOKEN:
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
@@ -918,26 +936,41 @@ async def telegram_test_send(
from datetime import datetime, timezone
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
ok = await send_telegram(
message = (
f"✅ <b>Nexus 测试消息</b>\n"
f"Bot 配置正确,告警通知已就绪\n"
f"时间: {now}"
)
if ok:
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{TELEGRAM_API_BASE}/bot{_settings.TELEGRAM_BOT_TOKEN}/sendMessage",
json={
"chat_id": _settings.TELEGRAM_CHAT_ID,
"text": message,
"parse_mode": "HTML",
},
)
data = resp.json()
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
if resp.status_code == 200 and data.get("ok"):
return {"success": True}
raise HTTPException(status_code=502, detail="Telegram API 返回错误,请检查 Bot Token 和 Chat ID")
desc = data.get("description") or f"HTTP {resp.status_code}"
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
@router.post("/telegram/reveal-token", response_model=dict)
async def reveal_telegram_token(
payload: ApiKeyRevealRequest,
request: Request,
admin: Admin = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Reveal Telegram Bot Token after re-entering account password."""
await _verify_reauth(db, admin, payload.current_password)
"""Reveal Telegram Bot Token for authenticated admin (JWT required)."""
repo = SettingRepositoryImpl(db)
value = await repo.get("telegram_bot_token")
if value is None:
@@ -973,13 +1006,18 @@ async def telegram_get_chats(
try:
async with httpx.AsyncClient(timeout=10.0) as client:
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
await _telegram_clear_webhook_if_set(client, base_url)
resp = await client.get(
f"{TELEGRAM_API_BASE}/bot{token}/getUpdates",
params={"limit": 50, "allowed_updates": ["message", "channel_post"]},
f"{base_url}/getUpdates",
params={"limit": 50},
)
if resp.status_code != 200:
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
data = resp.json()
if not data.get("ok"):
desc = data.get("description") or "getUpdates 失败"
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
except HTTPException:
raise
except Exception as e:
+166
View File
@@ -0,0 +1,166 @@
"""Tests for Telegram settings endpoints and notify toggle hot-reload."""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from server.config import settings
@pytest.mark.asyncio
async def test_telegram_test_400_when_token_missing():
from server.api.settings import telegram_test_send
settings.TELEGRAM_BOT_TOKEN = ""
settings.TELEGRAM_CHAT_ID = ""
admin = MagicMock()
with pytest.raises(HTTPException) as exc:
await telegram_test_send(admin=admin)
assert exc.value.status_code == 400
assert "TELEGRAM_BOT_TOKEN" in exc.value.detail
@pytest.mark.asyncio
async def test_telegram_test_400_when_chat_id_missing():
from server.api.settings import telegram_test_send
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
settings.TELEGRAM_CHAT_ID = ""
admin = MagicMock()
with pytest.raises(HTTPException) as exc:
await telegram_test_send(admin=admin)
assert exc.value.status_code == 400
assert "TELEGRAM_CHAT_ID" in exc.value.detail
@pytest.mark.asyncio
async def test_telegram_test_surfaces_api_error():
from server.api.settings import telegram_test_send
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
settings.TELEGRAM_CHAT_ID = "999"
admin = MagicMock()
mock_resp = MagicMock()
mock_resp.status_code = 400
mock_resp.json.return_value = {"ok": False, "description": "Bad Request: chat not found"}
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client.post = AsyncMock(return_value=mock_resp)
with patch("httpx.AsyncClient", return_value=mock_client):
with pytest.raises(HTTPException) as exc:
await telegram_test_send(admin=admin)
assert exc.value.status_code == 502
assert "chat not found" in exc.value.detail
@pytest.mark.asyncio
async def test_telegram_chats_surfaces_api_error_when_ok_false():
from server.api.settings import telegram_get_chats
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
admin = MagicMock()
wh_resp = MagicMock()
wh_resp.status_code = 200
wh_resp.json.return_value = {"ok": True, "result": {"url": ""}}
updates_resp = MagicMock()
updates_resp.status_code = 200
updates_resp.json.return_value = {"ok": False, "description": "Unauthorized"}
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
mock_client.post = AsyncMock()
with patch("httpx.AsyncClient", return_value=mock_client):
with pytest.raises(HTTPException) as exc:
await telegram_get_chats(admin=admin)
assert exc.value.status_code == 502
assert "Unauthorized" in exc.value.detail
@pytest.mark.asyncio
async def test_telegram_chats_deletes_webhook_before_get_updates():
from server.api.settings import telegram_get_chats
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
admin = MagicMock()
wh_resp = MagicMock()
wh_resp.status_code = 200
wh_resp.json.return_value = {"ok": True, "result": {"url": "https://example.com/hook"}}
del_resp = MagicMock()
del_resp.json.return_value = {"ok": True}
updates_resp = MagicMock()
updates_resp.status_code = 200
updates_resp.json.return_value = {
"ok": True,
"result": [
{
"message": {
"chat": {"id": 42, "type": "private", "first_name": "Test"},
},
},
],
}
mock_client = AsyncMock()
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
mock_client.post = AsyncMock(return_value=del_resp)
with patch("httpx.AsyncClient", return_value=mock_client):
result = await telegram_get_chats(admin=admin)
assert result["count"] == 1
assert result["chats"][0]["id"] == 42
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
assert "deleteWebhook" in call_args[0][0]
assert call_args[1]["params"]["drop_pending_updates"] == "false"
@pytest.mark.asyncio
async def test_notify_toggle_put_hot_reload():
from server.api.schemas import SettingUpdatePayload
from server.api.settings import set_setting
from server.domain.models import Setting
settings.NOTIFY_ALERT_CPU = "true"
admin = MagicMock(username="admin")
request = MagicMock()
request.client.host = "127.0.0.1"
db = MagicMock()
mock_setting = Setting(key="notify_alert_cpu", value="false")
with patch("server.api.settings.SettingRepositoryImpl") as repo_cls, patch(
"server.api.settings.AuditLogRepositoryImpl"
) as audit_cls:
repo = repo_cls.return_value
repo.set = AsyncMock(return_value=mock_setting)
audit_cls.return_value.create = AsyncMock()
await set_setting(
"notify_alert_cpu",
SettingUpdatePayload(value="false"),
request,
admin,
db,
)
assert settings.NOTIFY_ALERT_CPU == "false"