feat: tune ban duration and rule management layout
This commit is contained in:
+33
-9
@@ -532,17 +532,38 @@ async function contextDeleteMessage(ban = false) {
|
||||
closeChatContextMenu();
|
||||
if (item) await deleteChatMessage(item, ban);
|
||||
}
|
||||
async function pickBanDuration() {
|
||||
const text = window.prompt('选择封禁时间:0=永久,60=1小时,360=6小时,1440=1天,10080=7天', '0');
|
||||
if (text === null) return null;
|
||||
const value = Number(String(text).trim());
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
showToast('封禁时间无效,请输入 0 或正整数分钟');
|
||||
return null;
|
||||
}
|
||||
return Math.floor(value);
|
||||
}
|
||||
function banDurationText(minutes: number) {
|
||||
if (!minutes) return '永久';
|
||||
if (minutes % 1440 === 0) return `${minutes / 1440} 天`;
|
||||
if (minutes % 60 === 0) return `${minutes / 60} 小时`;
|
||||
return `${minutes} 分钟`;
|
||||
}
|
||||
async function contextDeleteAndBanMessage() {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
if (!item) return;
|
||||
if (item.deleted) await banChatMessage(item);
|
||||
else await deleteChatMessage(item, true);
|
||||
const duration = await pickBanDuration();
|
||||
if (duration === null) return;
|
||||
if (item.deleted) await banChatMessage(item, duration);
|
||||
else await deleteChatMessage(item, true, duration);
|
||||
}
|
||||
async function contextBanMessage() {
|
||||
const item = chatContextMenu.item;
|
||||
closeChatContextMenu();
|
||||
if (item) await banChatMessage(item);
|
||||
if (!item) return;
|
||||
const duration = await pickBanDuration();
|
||||
if (duration === null) return;
|
||||
await banChatMessage(item, duration);
|
||||
}
|
||||
async function contextCopyText() {
|
||||
const item = chatContextMenu.item;
|
||||
@@ -565,12 +586,12 @@ function contextReplyMessage() {
|
||||
}
|
||||
function cancelReply() { replyTarget.value = null; }
|
||||
|
||||
async function banChatMessage(item: ChatItem) {
|
||||
async function banChatMessage(item: ChatItem, durationMinutes = 0) {
|
||||
if (!item.user_id) return showToast('当前记录没有用户 ID,无法封禁');
|
||||
if (!await askConfirm('封禁用户', `确认封禁 @${item.username || item.first_name || item.user_id}?`, { confirmText: '封禁', danger: true })) return;
|
||||
if (!await askConfirm('封禁用户', `确认封禁 @${item.username || item.first_name || item.user_id}?时长:${banDurationText(durationMinutes)}`, { confirmText: '封禁', danger: true })) return;
|
||||
item._deleting = true;
|
||||
try {
|
||||
await fetchJson(`/api/messages/${item.id}/ban`, { method: 'POST' });
|
||||
await fetchJson(`/api/messages/${item.id}/ban`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ duration_minutes: durationMinutes }) });
|
||||
await loadBans();
|
||||
await loadStats();
|
||||
} catch (e: any) { showToast(`封禁失败:${e.message}`); }
|
||||
@@ -597,17 +618,20 @@ async function addChatMessageToRules(item: ChatItem) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteChatMessage(item: ChatItem, ban = false) {
|
||||
async function deleteChatMessage(item: ChatItem, ban = false, durationMinutes = 0) {
|
||||
if (item.manually_deleted || item.deleted) return showToast('该消息已删除');
|
||||
const action = ban ? '删除并封禁' : '删除';
|
||||
if (!await askConfirm(action, `确认${action}这条消息?`, { confirmText: action, danger: true })) return;
|
||||
const message = ban ? `确认删除这条消息并封禁用户?时长:${banDurationText(durationMinutes)}` : '确认删除这条消息?';
|
||||
if (!await askConfirm(action, message, { confirmText: action, danger: true })) return;
|
||||
item._deleting = true;
|
||||
try {
|
||||
await fetchJson(`/api/messages/${item.id}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ban_user: ban })
|
||||
body: JSON.stringify({ ban_user: ban, duration_minutes: durationMinutes })
|
||||
});
|
||||
Object.assign(item, { deleted: true, manually_deleted: true });
|
||||
await loadBans();
|
||||
await loadStats();
|
||||
} catch (e: any) {
|
||||
showToast(`${action}失败:${e.message}`);
|
||||
|
||||
@@ -468,3 +468,23 @@ body { background: #f3f6fb; }
|
||||
.rule-manage-page { display: grid; gap: 12px; }
|
||||
.inline-rule-writer { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
|
||||
.chat-context-menu button:disabled { opacity: .45; cursor: not-allowed; }
|
||||
|
||||
|
||||
/* User request polish: scrollable bans and compact rule management */
|
||||
.overview-ban-panel { min-height: 0; }
|
||||
.overview-ban-panel .s-card__content { height: calc(100% - 58px) !important; min-height: 0; overflow: hidden; }
|
||||
.overview-ban-list { height: 100%; max-height: 100%; overflow-y: auto; padding-right: 6px; scrollbar-gutter: stable; }
|
||||
.rule-manage-page { display: grid; gap: 12px; align-content: start; }
|
||||
.rule-manage-page .page-panel { min-height: auto; overflow: visible; }
|
||||
.rule-manage-page .s-card__content { height: auto; min-height: 0; }
|
||||
.rule-test-grid { grid-template-columns: minmax(360px, 560px) minmax(360px, 1fr); align-items: stretch; gap: 16px; }
|
||||
.rule-editor-block label, .rule-write-grid label { display: grid; gap: 6px; font-weight: 700; color: #0f172a; }
|
||||
.rule-textarea { width: 100%; min-height: 150px; max-height: 260px; padding: 12px 14px; border: 1px solid #cbd5e1; border-radius: 14px; font-size: 14px; line-height: 1.55; }
|
||||
.rule-result-card { min-height: 150px; display: flex; flex-direction: column; justify-content: center; }
|
||||
.inline-rule-writer.rule-write-grid { grid-template-columns: 120px 110px minmax(240px, 1fr) auto; align-items: end; }
|
||||
.rule-library-card .s-card__content { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 10px; }
|
||||
.rule-library-grid { min-height: 0; }
|
||||
.rule-list { max-height: 300px; overflow-y: auto; }
|
||||
@media (max-width: 1180px) {
|
||||
.rule-test-grid, .inline-rule-writer.rule-write-grid, .rule-library-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
"""垃圾消息检测 handler"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, MessageHandler, filters
|
||||
@@ -115,9 +115,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
|
||||
# 封禁
|
||||
try:
|
||||
unban_at = datetime.now() + timedelta(minutes=config.BAN_DURATION_MINUTES)
|
||||
until_date = int(unban_at.timestamp())
|
||||
await update.effective_chat.ban_member(user_id=user.id, until_date=until_date)
|
||||
await update.effective_chat.ban_member(user_id=user.id)
|
||||
unban_at = None
|
||||
|
||||
ban_record = {
|
||||
"chat_id": update.effective_chat.id,
|
||||
@@ -126,7 +125,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"first_name": first_name,
|
||||
"reason": reason,
|
||||
"method": "auto",
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"duration_minutes": 0,
|
||||
"auto_unban": False,
|
||||
"unban_at": unban_at,
|
||||
}
|
||||
ban = await Ban.create(**ban_record)
|
||||
@@ -138,12 +138,12 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"first_name": first_name,
|
||||
"reason": reason,
|
||||
"method": "auto",
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"auto_unban": True,
|
||||
"duration_minutes": 0,
|
||||
"auto_unban": False,
|
||||
"unban_at": unban_at,
|
||||
"time": ban.created_at,
|
||||
}))
|
||||
logger.info(f"🔨 已封禁 [{username}] {config.BAN_DURATION_MINUTES} 分钟")
|
||||
logger.info(f"🔨 已永久封禁 [{username}]")
|
||||
except Exception as e:
|
||||
await Action.create(
|
||||
action="BAN_FAILED",
|
||||
|
||||
+52
-26
@@ -10,7 +10,7 @@ import hashlib
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -236,6 +236,10 @@ class SendPayload(BaseModel):
|
||||
reply_to_message_id: int | None = None
|
||||
|
||||
|
||||
class BanPayload(BaseModel):
|
||||
duration_minutes: int = Field(default=0, ge=0, le=525600)
|
||||
|
||||
|
||||
class ShopItemPayload(BaseModel):
|
||||
chat_id: int = 0
|
||||
item_key: str = Field(default='', max_length=64)
|
||||
@@ -721,6 +725,24 @@ async def api_restart():
|
||||
return JSONResponse({"ok": True, "message": "服务正在重启"})
|
||||
|
||||
|
||||
def build_ban_options(duration_minutes: int):
|
||||
"""duration_minutes=0 表示永久封禁;否则按分钟自动解封。"""
|
||||
duration = int(duration_minutes or 0)
|
||||
if duration <= 0:
|
||||
return {"duration_minutes": 0, "auto_unban": False, "unban_at": None, "until_date": None}
|
||||
unban_at = now_tz() + timedelta(minutes=duration)
|
||||
return {"duration_minutes": duration, "auto_unban": True, "unban_at": unban_at, "until_date": int(unban_at.timestamp())}
|
||||
|
||||
def ban_duration_label(duration_minutes: int) -> str:
|
||||
duration = int(duration_minutes or 0)
|
||||
if duration <= 0:
|
||||
return "永久"
|
||||
if duration % 1440 == 0:
|
||||
return f"{duration // 1440} 天"
|
||||
if duration % 60 == 0:
|
||||
return f"{duration // 60} 小时"
|
||||
return f"{duration} 分钟"
|
||||
|
||||
@app.post("/api/send")
|
||||
async def api_send(data: SendPayload):
|
||||
import config
|
||||
@@ -760,29 +782,31 @@ async def api_send(data: SendPayload):
|
||||
|
||||
|
||||
@app.post("/api/messages/{message_id}/ban")
|
||||
async def api_ban_message_user(message_id: int):
|
||||
"""根据聊天记录封禁用户,不删除消息。"""
|
||||
import config
|
||||
from datetime import timedelta
|
||||
async def api_ban_message_user(message_id: int, data: BanPayload | None = None):
|
||||
"""根据聊天记录封禁用户,不删除消息。duration_minutes=0 为永久封禁。"""
|
||||
rec = await Message.get_or_none(id=message_id)
|
||||
if not rec:
|
||||
return JSONResponse({"ok": False, "error": "消息不存在"}, status_code=404)
|
||||
if not rec.chat_id or not rec.user_id:
|
||||
return JSONResponse({"ok": False, "error": "缺少 chat_id 或 user_id,无法封禁"}, status_code=400)
|
||||
opts = build_ban_options((data.duration_minutes if data else 0))
|
||||
try:
|
||||
unban_at = now_tz() + timedelta(minutes=config.BAN_DURATION_MINUTES)
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=int(unban_at.timestamp())), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
if opts["until_date"]:
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=opts["until_date"]), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
else:
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
ban = await Ban.create(
|
||||
user_id=rec.user_id,
|
||||
username=rec.username,
|
||||
first_name=rec.first_name,
|
||||
reason="Web 手动封禁",
|
||||
reason=f"Web 手动封禁({ban_duration_label(opts['duration_minutes'])})",
|
||||
method="web_manual_ban",
|
||||
chat_id=rec.chat_id,
|
||||
duration_minutes=config.BAN_DURATION_MINUTES,
|
||||
unban_at=unban_at,
|
||||
duration_minutes=opts["duration_minutes"],
|
||||
auto_unban=opts["auto_unban"],
|
||||
unban_at=opts["unban_at"],
|
||||
)
|
||||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, chat_id=rec.chat_id, details={"method": "web_manual_ban", "message_id": rec.id})
|
||||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, chat_id=rec.chat_id, details={"method": "web_manual_ban", "message_id": rec.id, "duration_minutes": opts["duration_minutes"]})
|
||||
await broadcast_sse("ban", json_safe({
|
||||
"id": ban.id,
|
||||
"chat_id": rec.chat_id,
|
||||
@@ -791,13 +815,12 @@ async def api_ban_message_user(message_id: int):
|
||||
"first_name": rec.first_name,
|
||||
"reason": ban.reason,
|
||||
"method": ban.method,
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"auto_unban": True,
|
||||
"unban_at": unban_at,
|
||||
"chat_id": rec.chat_id,
|
||||
"duration_minutes": opts["duration_minutes"],
|
||||
"auto_unban": opts["auto_unban"],
|
||||
"unban_at": opts["unban_at"],
|
||||
"time": ban.created_at,
|
||||
}))
|
||||
return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id}))
|
||||
return JSONResponse(json_safe({"ok": True, "banned": True, "ban_id": ban.id, "duration_minutes": opts["duration_minutes"], "auto_unban": opts["auto_unban"]}))
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": f"封禁失败: {type(e).__name__}: {e}"}, status_code=502)
|
||||
|
||||
@@ -811,6 +834,7 @@ async def api_delete_message(message_id: int, request: Request):
|
||||
|
||||
data = await safe_json(request)
|
||||
ban_user = bool(data.get("ban_user") or data.get("ban"))
|
||||
ban_opts = build_ban_options(int(data.get("duration_minutes") or 0))
|
||||
|
||||
rec = await Message.get_or_none(id=message_id)
|
||||
if not rec:
|
||||
@@ -841,18 +865,20 @@ async def api_delete_message(message_id: int, request: Request):
|
||||
ban = None
|
||||
if ban_user and rec.user_id:
|
||||
try:
|
||||
from datetime import timedelta
|
||||
unban_at = now_tz() + timedelta(minutes=config.BAN_DURATION_MINUTES)
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=int(unban_at.timestamp())), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
if ban_opts["until_date"]:
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id, until_date=ban_opts["until_date"]), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
else:
|
||||
await run_tg_request(lambda bot: bot.ban_chat_member(chat_id=rec.chat_id, user_id=rec.user_id), connect_timeout=20.0, read_timeout=20.0, label="ban_chat_member")
|
||||
ban = await Ban.create(
|
||||
user_id=rec.user_id,
|
||||
username=rec.username,
|
||||
first_name=rec.first_name,
|
||||
reason="Web 删除消息时手动封禁",
|
||||
reason=f"Web 删除消息时手动封禁({ban_duration_label(ban_opts['duration_minutes'])})",
|
||||
method="web_delete_ban",
|
||||
chat_id=rec.chat_id,
|
||||
duration_minutes=config.BAN_DURATION_MINUTES,
|
||||
unban_at=unban_at,
|
||||
duration_minutes=ban_opts["duration_minutes"],
|
||||
auto_unban=ban_opts["auto_unban"],
|
||||
unban_at=ban_opts["unban_at"],
|
||||
)
|
||||
except Exception as e:
|
||||
return JSONResponse({"ok": False, "error": f"消息已删除,但封禁失败: {type(e).__name__}: {e}"}, status_code=502)
|
||||
@@ -870,7 +896,7 @@ async def api_delete_message(message_id: int, request: Request):
|
||||
"deleted_at": rec.deleted_at,
|
||||
}))
|
||||
if ban:
|
||||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, details={"method": "web_delete_ban", "message_id": rec.id})
|
||||
await Action.create(action="BAN", user_id=rec.user_id, username=rec.username, details={"method": "web_delete_ban", "message_id": rec.id, "duration_minutes": ban_opts["duration_minutes"]})
|
||||
await broadcast_sse("ban", json_safe({
|
||||
"id": ban.id,
|
||||
"user_id": rec.user_id,
|
||||
@@ -878,9 +904,9 @@ async def api_delete_message(message_id: int, request: Request):
|
||||
"first_name": rec.first_name,
|
||||
"reason": ban.reason,
|
||||
"method": ban.method,
|
||||
"duration_minutes": config.BAN_DURATION_MINUTES,
|
||||
"auto_unban": True,
|
||||
"unban_at": unban_at,
|
||||
"duration_minutes": ban_opts["duration_minutes"],
|
||||
"auto_unban": ban_opts["auto_unban"],
|
||||
"unban_at": ban_opts["unban_at"],
|
||||
"chat_id": rec.chat_id,
|
||||
"time": ban.created_at,
|
||||
}))
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,8 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/static/icon.svg" />
|
||||
<title>TG Spam Guard</title>
|
||||
<script type="module" crossorigin src="/static/assets/index-CLi70OmX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-NLxQkQW1.css">
|
||||
<script type="module" crossorigin src="/static/assets/index-BF4yz--N.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/static/assets/index-DRJmg6LH.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user