diff --git a/.env.example b/.env.example index 0235db3..ef8d274 100644 --- a/.env.example +++ b/.env.example @@ -6,14 +6,14 @@ TG_CHAT_ID=-100xxxxxxxxxx # Tortoise ORM 使用 postgres:// scheme,不是 postgresql:// # 宿主机/本地运行用外部地址: DATABASE_URL=postgres://user:password@192.168.123.5:15432/tg-spam-guard -# Docker Compose 中会覆盖为:postgres://user_eNPcQS:${POSTGRES_PASSWORD}@1Panel-postgresql-KS6e:5432/tg-spam-guard +# Docker Compose 使用 host 网络,会覆盖为:postgres://user_eNPcQS:${POSTGRES_PASSWORD}@127.0.0.1:15432/tg-spam-guard # 生产环境请在 .env 中设置真实密码,不要提交 .env POSTGRES_PASSWORD=your_postgres_password # Ollama # 宿主机/本地运行: OLLAMA_URL=http://127.0.0.1:11434 -# Docker Compose 中会覆盖为:http://host.docker.internal:11434 +# Docker Compose 使用 host 网络,会覆盖为:http://127.0.0.1:11434 OLLAMA_MODEL=qwen3:8b # Proxy diff --git a/config.py b/config.py index 80928b6..9390f3f 100644 --- a/config.py +++ b/config.py @@ -8,7 +8,7 @@ TG_CHAT_ID = int(os.getenv("TG_CHAT_ID", "0")) # PostgreSQL DATABASE_URL = os.getenv( "DATABASE_URL", - "postgresql://postgres:postgres@192.168.123.5:15432/tg_spam_guard", + "postgres://user:password@127.0.0.1:15432/tg-spam-guard", ) # Ollama diff --git a/docker-compose.yaml b/docker-compose.yaml index 4d971ea..f99c3e2 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -3,26 +3,16 @@ services: build: . container_name: tg-spam-guard restart: unless-stopped - ports: - - "8766:8766" + network_mode: host env_file: .env environment: - # 容器内走 1panel-network 服务名,比外部 IP 更稳定 - DATABASE_URL: postgres://user_eNPcQS:${POSTGRES_PASSWORD}@1Panel-postgresql-KS6e:5432/tg-spam-guard - # Linux Docker 访问宿主机 Ollama,避免 127.0.0.1 指向容器自身 - OLLAMA_URL: http://host.docker.internal:11434 - NO_PROXY: 127.0.0.1,localhost,host.docker.internal,1Panel-postgresql-KS6e + # host 网络模式:容器内 127.0.0.1 即宿主机 + DATABASE_URL: postgres://user_eNPcQS:${POSTGRES_PASSWORD}@127.0.0.1:15432/tg-spam-guard + OLLAMA_URL: http://127.0.0.1:11434 + NO_PROXY: 127.0.0.1,localhost KNOWLEDGE_SEARCH_MODE: lite - extra_hosts: - - "host.docker.internal:host-gateway" volumes: - ./logs:/app/logs - ./knowledge/index:/app/knowledge/index - ./knowledge/models:/app/knowledge/models - ./knowledge/qa_cache.json:/app/knowledge/qa_cache.json - networks: - - 1panel-network - -networks: - 1panel-network: - external: true diff --git a/handlers/knowledge.py b/handlers/knowledge.py index c2a6492..6e77d82 100644 --- a/handlers/knowledge.py +++ b/handlers/knowledge.py @@ -34,11 +34,22 @@ def split_answer(text: str, max_len: int = 3500, max_parts: int = 3) -> list[str return parts +async def safe_send(update: Update, context: ContextTypes.DEFAULT_TYPE, text: str): + """优先回复原消息;若原消息已被自动删除,退回普通 send_message。""" + chat_id = update.effective_chat.id + try: + return await update.message.reply_text(text) + except Exception as e: + if "Message to be replied not found" not in str(e): + logger.warning(f"reply_text 失败,改用 send_message: {e}") + return await context.bot.send_message(chat_id=chat_id, text=text) + + async def cmd_ask(update: Update, context: ContextTypes.DEFAULT_TYPE): """向知识库提问""" query = " ".join(context.args) if context.args else "" if not query: - reply = await update.message.reply_text( + reply = await safe_send(update, context, "❓ 用法: /ask <你的问题>\n" "例如: /ask 怎么安装收割机\n" f"文档地址: {config.SITE_URL}" @@ -48,7 +59,7 @@ async def cmd_ask(update: Update, context: ContextTypes.DEFAULT_TYPE): return logger.info(f"📩 用户提问: [{update.message.from_user.username}] {query}") - searching_msg = await update.message.reply_text("🔍 正在搜索知识库并整理详细答案...") + searching_msg = await safe_send(update, context, "🔍 正在搜索知识库并整理详细答案...") asyncio.create_task(auto_delete(update.message)) asyncio.create_task(auto_delete(searching_msg, seconds=8)) @@ -60,13 +71,13 @@ async def cmd_ask(update: Update, context: ContextTypes.DEFAULT_TYPE): parts = split_answer(answer) for i, part in enumerate(parts, 1): prefix = f"📚 回答 {i}/{len(parts)}\n\n" if len(parts) > 1 else "" - reply = await update.message.reply_text(prefix + part) + reply = await safe_send(update, context, prefix + part) replies.append(reply) for reply in replies: asyncio.create_task(auto_delete(reply, seconds=180)) except Exception as e: logger.error(f"❌ 回答失败: {e}") - reply = await update.message.reply_text(f"❌ 回答失败: {e}") + reply = await safe_send(update, context, f"❌ 回答失败: {e}") asyncio.create_task(auto_delete(reply)) diff --git a/handlers/spam.py b/handlers/spam.py index a4da2b6..26bc7cc 100644 --- a/handlers/spam.py +++ b/handlers/spam.py @@ -9,6 +9,7 @@ from telegram.ext import ContextTypes, MessageHandler, filters import config from db.models import Message, Ban, Action from services.spam_detector import detect +from web.api import broadcast_sse, json_safe logger = logging.getLogger("spam_guard") @@ -48,8 +49,8 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): logger.warning(f"⚡ 防刷屏: @{username} ({user.id}) {len(_flood_cache[user.id])}条/{config.RATE_LIMIT_WINDOW}s") return - # 记录消息 - await Message.create( + # 记录消息并推送到 Web 管理页 + message_record = await Message.create( msg_id=msg.message_id, chat_id=update.effective_chat.id, user_id=user.id, @@ -57,12 +58,36 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): first_name=first_name, text=text[:500], ) + await broadcast_sse("chat", json_safe({ + "id": message_record.id, + "msg_id": msg.message_id, + "chat_id": update.effective_chat.id, + "user_id": user.id, + "username": username, + "first_name": first_name, + "text": text[:500], + "time": message_record.created_at, + "spam": False, + })) # === 垃圾检测 === is_spam, reason = await detect(text, username, first_name) if is_spam: logger.warning(f"🚨 垃圾消息 [{username}]: {reason}") + await message_record.update_from_dict({"is_spam": True, "spam_reason": reason}).save() + await broadcast_sse("spam", json_safe({ + "id": message_record.id, + "msg_id": msg.message_id, + "chat_id": update.effective_chat.id, + "user_id": user.id, + "username": username, + "first_name": first_name, + "text": text[:500], + "time": message_record.created_at, + "spam": True, + "reason": reason, + })) # 删消息 try: @@ -86,8 +111,20 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): "duration_minutes": config.BAN_DURATION_MINUTES, "unban_at": unban_at, } - await Ban.create(**ban_record) + ban = await Ban.create(**ban_record) await Action.create(action="BAN", user_id=user.id, username=username, details={"reason": reason}) + await broadcast_sse("ban", json_safe({ + "id": ban.id, + "user_id": user.id, + "username": username, + "first_name": first_name, + "reason": reason, + "method": "auto", + "duration_minutes": config.BAN_DURATION_MINUTES, + "auto_unban": True, + "unban_at": unban_at, + "time": ban.created_at, + })) logger.info(f"🔨 已封禁 [{username}] {config.BAN_DURATION_MINUTES} 分钟") except Exception as e: logger.error(f"❌ 封禁失败: {e}") diff --git a/web/api.py b/web/api.py index 81fa324..ceb34cf 100644 --- a/web/api.py +++ b/web/api.py @@ -62,16 +62,36 @@ async def api_stats(day: str = Query(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$ return JSONResponse(await get_daily_stats(day)) +def normalize_message(row: dict) -> dict: + row = dict(row) + row["time"] = row.get("created_at") + row["spam"] = row.get("is_spam", False) + return row + + +def normalize_ban(row: dict) -> dict: + row = dict(row) + row["time"] = row.get("created_at") + return row + + @app.get("/api/messages") async def api_messages(limit: int = Query(default=50, ge=1, le=500)): msgs = await Message.all().order_by("-created_at").limit(limit).values() - return JSONResponse(json_safe(list(msgs))) + return JSONResponse(json_safe([normalize_message(m) for m in msgs])) + + +@app.get("/api/chat") +async def api_chat(limit: int = Query(default=100, ge=1, le=500)): + """兼容旧前端:返回聊天记录。""" + msgs = await Message.all().order_by("-created_at").limit(limit).values() + return JSONResponse(json_safe([normalize_message(m) for m in msgs])) @app.get("/api/bans") async def api_bans(limit: int = Query(default=100, ge=1, le=500)): bans = await Ban.all().order_by("-created_at").limit(limit).values() - return JSONResponse(json_safe(list(bans))) + return JSONResponse(json_safe([normalize_ban(b) for b in bans])) @app.get("/api/actions") @@ -111,6 +131,7 @@ async def api_stream(request: Request): async def event_generator(): try: + yield {"event": "connected", "data": "ok"} while True: if await request.is_disconnected(): break diff --git a/web/static/index.html b/web/static/index.html index 2aa0e1a..94a9224 100644 --- a/web/static/index.html +++ b/web/static/index.html @@ -3,43 +3,273 @@ -🛡️ TG Spam Guard +🛡️ TG Spam Guard - 收割机
+
-

🛡️ TG Spam Guard v2

+

🛡️ TG Spam Guard

+
+ + {{ sseConnected ? '已连接' : '连接中...' }} +
+ + + + + +
{{ stats.today_bans }}
+
今日封禁
+
+
+ + +
{{ stats.total_bans }}
+
累计封禁
+
+
+ + +
{{ stats.active_bans }}
+
当前封禁中
+
+
+ + +
{{ chatLogs.length }}
+
聊天记录
+
+
+
+ + -
{{ stats.messages || 0 }}
💬 消息
-
{{ stats.joins || 0 }}
🆕 入群
-
{{ stats.today_bans || 0 }}
🔨 封禁
-
{{ stats.deleted || 0 }}
🗑️ 删除
+ + + +
+

💬 实时聊天

+ {{ chatLogs.length }} 条 +
+
+
+ 💬等待消息... +
+
+
+ @{{ item.username }} ({{ item.user_id }}) + {{ formatTime(item.time) }} +
+
{{ item.text }}
+
+
+
+
+ + + + +
+

🚫 封禁列表

+ 刷新 +
+
+
+ 暂无封禁记录 +
+
+
+
@{{ item.username }} ({{ item.user_id }})
+
📝 {{ item.reason }}
+
⏰ {{ formatTime(item.time) }}
+
+ 解封 + 已解封 +
+
+
+
+