feat: rebuild tg spam guard v2 architecture

This commit is contained in:
ngfchl
2026-07-06 19:26:00 +08:00
commit 7cd536e635
45 changed files with 6228 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
.git/
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
.pytest_cache/
.cache/
logs/
botpy.log
.env
.env.*
!.env.example
# 本地/旧知识库缓存,运行时通过 volume 挂载必要目录
knowledge/index/
knowledge/models/
knowledge/chroma_db/
knowledge/qa_cache.json
cache/
chroma_db/
index/
models/
# 开发/备份文件
main.py.bak
.patch_autodelete.py
static/
memory/
tool_results/
+40
View File
@@ -0,0 +1,40 @@
# Telegram
TG_BOT_TOKEN=your_telegram_bot_token
TG_CHAT_ID=-100xxxxxxxxxx
# PostgreSQL
# 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
# 生产环境请在 .env 中设置真实密码,不要提交 .env
POSTGRES_PASSWORD=your_postgres_password
# Ollama
# 宿主机/本地运行:
OLLAMA_URL=http://127.0.0.1:11434
# Docker Compose 中会覆盖为:http://host.docker.internal:11434
OLLAMA_MODEL=qwen3:8b
# Proxy
PROXY_URL=http://192.168.123.10:11055
# Web
WEB_PORT=8766
SITE_URL=https://ptools.fun
# Harvest / account binding
HARVEST_SERVER_URL=https://ptools.fun
# Bot 行为
# 逗号分隔,作为机器人超级管理员补充名单
ADMIN_USER_IDS=5418812283
BAN_DURATION_MINUTES=2
AUTO_DELETE_SECONDS=60
VERIFY_TIMEOUT_SECONDS=300
VERIFY_MAX_ATTEMPTS=3
NEW_MEMBER_RESTRICT_MINUTES=10
RATE_LIMIT_MESSAGES=5
RATE_LIMIT_WINDOW=30
NIGHT_MODE_START=23:00
NIGHT_MODE_END=08:00
+16
View File
@@ -0,0 +1,16 @@
# TG 群管机器人
/logs/
knowledge/index/
knowledge/models/
knowledge/qa_cache.json
wheels/
*.pyc
__pycache__/
.venv/
.cache/
.env
main.py.bak
backups/
cache/
botpy.log
.patch_autodelete.py
+1
View File
@@ -0,0 +1 @@
3.12
+31
View File
@@ -0,0 +1,31 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
KNOWLEDGE_SEARCH_MODE=lite
WORKDIR /app
# 健康检查依赖 curl;不安装 torch/sentence-transformers,保持运行镜像轻量
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt \
--index-url https://mirrors.aliyun.com/pypi/simple/ \
--trusted-host mirrors.aliyun.com
# 应用
COPY config.py .
COPY db/ db/
COPY handlers/ handlers/
COPY services/ services/
COPY web/ web/
COPY knowledge/ knowledge/
COPY main.py .
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD curl -fsS http://127.0.0.1:${WEB_PORT:-8766}/health || exit 1
CMD ["python", "main.py"]
+65
View File
@@ -0,0 +1,65 @@
# 知识库管理说明
## 当前方案
默认运行模式:`KNOWLEDGE_SEARCH_MODE=lite`
- Bot 运行镜像不安装 `torch` / `sentence-transformers` / `numpy`
- 运行时只读取 `knowledge/index/metadata.json`
- 使用轻量关键词 + 领域词表 + BM25-like 加权检索
- 命中后仍调用 Ollama 生成最终中文回答
## 为什么这样设计
原方案运行时需要给用户问题生成 embedding,因此 Docker 镜像必须包含 `sentence-transformers -> torch/CUDA`,镜像约 6GB。
当前轻量方案把运行镜像降到约 175MB,常见问答命中仍然准确,性价比最高。
## 常用命令
### 重建轻量 metadata/向量索引(宿主机)
```bash
cd /vol3/1000/Code/tg-spam-guard
.venv/bin/python3 build_kb.py
```
> `build_kb.py` 仍会生成 `embeddings.npy`,方便以后切回 vector 模式;运行镜像只使用 `metadata.json`。
### 运行时轻量检索测试
```bash
cd /vol3/1000/Code/tg-spam-guard
KNOWLEDGE_SEARCH_MODE=lite .venv/bin/python3 -c "from knowledge import search; print(search('怎么安装收割机', top_k=3))"
```
### 可选:向量检索模式
仅建议宿主机/开发环境使用:
```bash
KNOWLEDGE_SEARCH_MODE=vector .venv/bin/python3 -c "from knowledge import search; print(search('怎么安装收割机', top_k=3))"
```
需要安装离线依赖:
```bash
pip install -r requirements-kb.txt
```
## Docker 注意事项
`docker-compose.yaml` 显式设置:
```yaml
KNOWLEDGE_SEARCH_MODE: lite
```
并挂载:
```yaml
./knowledge/index:/app/knowledge/index
./knowledge/qa_cache.json:/app/knowledge/qa_cache.json
```
只要 `knowledge/index/metadata.json` 存在,轻量检索即可工作。
+293
View File
@@ -0,0 +1,293 @@
# 🤖 TG 群管机器人 v2 — 重构规划
## 📌 项目信息
| 项 | 值 |
|----|-----|
| 项目路径 | `/vol3/1000/Code/tg-spam-guard/` |
| Git 仓库 | GitHub `ngfchl/tg-spam-guard` |
| 运行环境 | Python 3.12 + Docker |
| Web 框架 | FastAPI + uvicorn |
| ORM | Tortoise ORM + aerich |
| 数据库 | PostgreSQL (`192.168.123.5:15432`) |
| Bot | python-telegram-bot ≥ 22 |
| AI | Ollama (`qwen3:8b`) |
| 知识库 | sentence-transformers + BGE-zh + numpy |
| 前端 | Element UI 单页 |
---
## 🏗️ 项目结构
```
tg-spam-guard/
├── main.py # 入口:启动 FastAPI + Bot
├── config.py # 全局配置(从环境变量/配置文件读取)
├── db/ # 数据库层
│ ├── __init__.py # Tortoise 初始化
│ ├── models.py # 所有 ORM 模型
│ └── migrate.py # aerich 迁移配置
├── handlers/ # Telegram 消息处理器
│ ├── __init__.py
│ ├── welcome.py # 入群欢迎/退群告别
│ ├── verify.py # 入群验证
│ ├── spam.py # 垃圾检测+封禁
│ ├── night_mode.py # 夜间模式
│ ├── knowledge.py # /ask 知识库问答
│ ├── fun.py # 气氛组 + 小游戏
│ ├── bind.py # 账号绑定
│ └── admin.py # /ban /unban /warn /mute /kick /stats /purge
├── services/ # 业务逻辑层
│ ├── __init__.py
│ ├── spam_detector.py # 多层垃圾检测引擎
│ ├── knowledge_base.py # RAG 检索(当前 knowledge/ 模块)
│ ├── verify_service.py # 验证逻辑
│ ├── night_scheduler.py # APScheduler 定时任务
│ ├── game_service.py # 小游戏逻辑
│ └── bind_service.py # go-harvest-server verify 对接
├── web/ # Web 管理面板
│ ├── api.py # FastAPI 路由
│ └── static/
│ └── index.html # Element UI 前端
├── knowledge/ # 知识库(保持现有)
│ ├── __init__.py
│ ├── __main__.py
│ ├── index/
│ └── models/
├── templates/ # 消息模板
│ ├── welcome.md
│ ├── verify.md
│ └── night_mode.md
├── wheels/ # 本地 wheel 缓存(torch 等)
├── logs/ # 日志目录(运行时生成)
├── docker-compose.yaml
├── Dockerfile
├── requirements.txt
├── pyproject.toml
├── aerich.toml # aerich 迁移配置
├── .env.example # 环境变量示例
└── .gitignore
```
---
## 📦 数据库设计(PostgreSQL
### 核心表
```sql
-- 消息记录
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
msg_id BIGINT,
chat_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
username VARCHAR(255),
first_name VARCHAR(255),
text TEXT,
is_spam BOOLEAN DEFAULT FALSE,
spam_reason VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_messages_user_id ON messages(user_id);
CREATE INDEX idx_messages_created_at ON messages(created_at);
CREATE INDEX idx_messages_is_spam ON messages(is_spam);
-- 封禁记录
CREATE TABLE bans (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
username VARCHAR(255),
first_name VARCHAR(255),
reason VARCHAR(500),
method VARCHAR(50), -- 'auto' / 'command' / 'verify_fail'
duration_minutes INTEGER,
auto_unban BOOLEAN DEFAULT TRUE,
unban_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_bans_user_id ON bans(user_id);
-- 操作日志(入群/退群/删除/解封/警告等)
CREATE TABLE actions (
id BIGSERIAL PRIMARY KEY,
action VARCHAR(50) NOT NULL, -- JOIN/LEAVE/DELETE/UNBAN/WARN/MUTE/KICK
user_id BIGINT,
username VARCHAR(255),
operator_id BIGINT, -- 执行操作的管理员
details JSONB, -- 扩展字段
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_actions_action ON actions(action);
CREATE INDEX idx_actions_created_at ON actions(created_at);
-- 入队验证
CREATE TABLE verify_queue (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
username VARCHAR(255),
question VARCHAR(500),
answer VARCHAR(500),
attempts INTEGER DEFAULT 0,
status VARCHAR(20) DEFAULT 'pending', -- pending/verified/failed/expired
created_at TIMESTAMPTZ DEFAULT NOW(),
verified_at TIMESTAMPTZ
);
CREATE INDEX idx_verify_user_id ON verify_queue(user_id);
-- 账号绑定(TG ↔ Harvest
CREATE TABLE user_bindings (
id BIGSERIAL PRIMARY KEY,
tg_user_id BIGINT UNIQUE NOT NULL,
tg_username VARCHAR(255),
harvest_uid VARCHAR(255),
harvest_token VARCHAR(500),
bound_at TIMESTAMPTZ DEFAULT NOW()
);
-- 警告记录
CREATE TABLE warnings (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
username VARCHAR(255),
reason VARCHAR(500),
issued_by BIGINT,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_warnings_user_id ON warnings(user_id);
CREATE INDEX idx_warnings_active ON warnings(active);
-- 游戏积分
CREATE TABLE game_scores (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
username VARCHAR(255),
game_type VARCHAR(50),
score INTEGER DEFAULT 0,
wins INTEGER DEFAULT 0,
total INTEGER DEFAULT 0,
streak INTEGER DEFAULT 0,
last_played TIMESTAMPTZ,
UNIQUE(user_id, game_type)
);
-- 群组配置
CREATE TABLE group_config (
key VARCHAR(100) PRIMARY KEY,
value JSONB,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
```
---
## 📋 功能清单(去掉 PT 专属)
| # | 模块 | 优先级 | 说明 |
|---|------|--------|------|
| 1 | 🛡️ 垃圾信息阻挡 | ✅ 已有 | 三级检测:缓存→正则→AI,保留优化 |
| 2 | ⚡ 防刷屏 | 🔴 P0 | 令牌桶限速,超阈值删除多余消息 |
| 3 | 🔒 新人限制 | 🔴 P0 | 入群 N 分钟内禁止发链接/图片/文件/贴纸 |
| 4 | 👋 入群欢迎 | 🔴 P0 | 自定义欢迎卡片 + 群规 + 60s 自动删 |
| 5 | ✅ 入群验证 | 🔴 P0 | 新人答题/点按钮,超时踢出 |
| 6 | 🔗 账号绑定 | 🔴 P0 | TG ↔ Harvest 授权绑定 |
| 7 | 🌙 夜间模式 | 🟡 P1 | 定时全员禁言,管理员不受影响 |
| 8 | 🔨 管理工具 | 🟡 P1 | /warn /mute /kick /purge /slowmode /stats |
| 9 | 📊 操作审计 | 🟡 P1 | 所有管理员操作记录到 DB |
| 10 | 👥 退群告别 | 🟡 P1 | 可选,默认关闭 |
| 11 | 📚 知识库问答 | ✅ 已有 | /ask + RAG,保持 |
| 12 | 🎉 气氛组 | 🟢 P2 | 关键词触发表情、连击、撒花、里程碑 |
| 13 | 🎮 小游戏 | 🟢 P2 | 答题/签到/积分排行/抽奖 |
| 14 | 🎂 生日祝福 | 🟢 P3 | 记录生日,当天自动祝福 |
---
## 🔄 开发阶段
### Phase 1:基建(SQLite→PG + 重构 + Git
- 初始化 Git 仓库
- 项目目录结构搭建
- config.py(环境变量管理)
- db/Tortoise ORM 模型 + aerich 初始化)
- docker-compose.yaml(接入 PostgreSQL 网络)
- requirements.txt / pyproject.toml 更新
- main.py 瘦身:只保留启动逻辑
### Phase 2:核心安全(P0
- handlers/spam.py + services/spam_detector.py(从旧 main.py 提取)
- handlers/admin.py/ban /unban /warn /mute /kick /stats /purge
- 防刷屏中间件
- 新人限制(禁止链接/图片/文件)
### Phase 3:入群流程(P0
- handlers/welcome.py(入群欢迎/退群告别)
- handlers/verify.py + services/verify_service.py(入群验证)
- templates/(消息模板)
### Phase 4:账号绑定(P0
- handlers/bind.py + services/bind_service.py
- 对接 go-harvest-server verify 接口
- /bind /unbind 命令
### Phase 5:夜间模式 + 管理增强(P1)
- handlers/night_mode.py + services/night_scheduler.py
- APScheduler 集成
- 操作审计
### Phase 6:社交 + 游戏(P2-P3
- handlers/fun.py + services/game_service.py
- 气氛组(关键词触发、连击、撒花)
- 小游戏(答题、签到、积分)
- 生日祝福
### Phase 7Web 面板升级
- web/api.py(所有 API 路由)
- web/static/index.html(适配新数据结构)
- SSE 实时推送适配
---
## ⚙️ 环境变量(.env
```env
# Telegram
TG_BOT_TOKEN=your_telegram_bot_token
TG_CHAT_ID=-1001515287526
# PostgreSQL
DATABASE_URL=postgresql://user:password@192.168.123.5:15432/tg_spam_guard
# Ollama
OLLAMA_URL=http://127.0.0.1:11434
OLLAMA_MODEL=qwen3:8b
# Proxy
PROXY_URL=http://192.168.123.10:11055
# Web
WEB_PORT=8766
SITE_URL=https://ptools.fun
# Harvest
HARVEST_SERVER_URL=https://ptools.fun
```
---
## 📝 备忘
- 旧数据迁移:JSON → PostgreSQL(一次性脚本,放在 scripts/migrate_json_to_pg.py
- docker-compose 需要加入 `1panel-network` 网络
- aerich 配置写在 aerich.toml,用 `aerich init` / `aerich migrate` 管理
- wheels/ 目录保留(torch 等大包不走网络)
- 知识库模块(knowledge/)保持独立,不改动
- 旧 main.py 备份为 main.py.bak
+3
View File
@@ -0,0 +1,3 @@
[aerich]
tortoise_orm = "db.__init__.TORTOISE_ORM"
location = "./migrations"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""构建知识库索引 - 在宿主机上运行"""
import sys
sys.path.insert(0, "/vol3/1000/Code/tg-spam-guard")
from knowledge import rebuild_index, get_stats
if __name__ == "__main__":
print("📚 开始构建知识库索引...")
rebuild_index()
print("\n📊 统计信息:")
import json
print(json.dumps(get_stats(), ensure_ascii=False, indent=2))
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""构建轻量知识库 metadata,不依赖 torch/sentence-transformers。"""
import json
import sys
sys.path.insert(0, "/vol3/1000/Code/tg-spam-guard")
from knowledge import INDEX_DIR, load_all_docs, save_qa_cache, get_stats
if __name__ == "__main__":
print("📚 开始构建轻量知识库 metadata...")
INDEX_DIR.mkdir(parents=True, exist_ok=True)
docs = load_all_docs()
if not docs:
print("❌ 没有找到文档")
sys.exit(1)
(INDEX_DIR / "metadata.json").write_text(
json.dumps(docs, ensure_ascii=False, indent=2), encoding="utf-8"
)
save_qa_cache({})
lengths = [len(d.get("text", "")) for d in docs]
print(f"✅ metadata 构建完成:{len(docs)} 个文档块")
print(f"📊 chunk 长度:avg={sum(lengths)//len(lengths)}, min={min(lengths)}, max={max(lengths)}")
print(json.dumps(get_stats(), ensure_ascii=False, indent=2))
+48
View File
@@ -0,0 +1,48 @@
"""全局配置 — 从环境变量读取"""
import os
# Telegram
TG_BOT_TOKEN = os.getenv("TG_BOT_TOKEN", "")
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",
)
# Ollama
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "qwen3:8b")
# Proxy
PROXY_URL = os.getenv("PROXY_URL", "http://192.168.123.10:11055")
# Web
WEB_PORT = int(os.getenv("WEB_PORT", "8766"))
SITE_URL = os.getenv("SITE_URL", "https://ptools.fun")
# Harvest
HARVEST_SERVER_URL = os.getenv("HARVEST_SERVER_URL", "https://ptools.fun")
# Bot 行为
ADMIN_USER_IDS = {
int(x.strip()) for x in os.getenv("ADMIN_USER_IDS", "5418812283").split(",") if x.strip().isdigit()
}
BAN_DURATION_MINUTES = int(os.getenv("BAN_DURATION_MINUTES", "2"))
AUTO_DELETE_SECONDS = int(os.getenv("AUTO_DELETE_SECONDS", "60"))
# 夜间模式
NIGHT_MODE_START = os.getenv("NIGHT_MODE_START", "23:00")
NIGHT_MODE_END = os.getenv("NIGHT_MODE_END", "08:00")
# 入群验证
VERIFY_TIMEOUT_SECONDS = int(os.getenv("VERIFY_TIMEOUT_SECONDS", "300"))
VERIFY_MAX_ATTEMPTS = int(os.getenv("VERIFY_MAX_ATTEMPTS", "3"))
# 新人限制
NEW_MEMBER_RESTRICT_MINUTES = int(os.getenv("NEW_MEMBER_RESTRICT_MINUTES", "10"))
# 防刷屏
RATE_LIMIT_MESSAGES = int(os.getenv("RATE_LIMIT_MESSAGES", "5"))
RATE_LIMIT_WINDOW = int(os.getenv("RATE_LIMIT_WINDOW", "30"))
+38
View File
@@ -0,0 +1,38 @@
"""Tortoise ORM 初始化"""
import logging
from tortoise import Tortoise
import config
logger = logging.getLogger("spam_guard")
MODELS = [
"db.models",
]
# aerich 配置
TORTOISE_ORM = {
"connections": {
"default": config.DATABASE_URL,
},
"apps": {
"models": {
"models": MODELS,
"default_connection": "default",
},
},
}
async def init_db():
await Tortoise.init(
db_url=config.DATABASE_URL,
modules={"models": MODELS},
)
# aerich 用 generate_schemas;正式环境用 aerich migrate
await Tortoise.generate_schemas()
logger.info("✅ 数据库连接成功")
async def close_db():
await Tortoise.close_connections()
logger.info("🛑 数据库连接已关闭")
+129
View File
@@ -0,0 +1,129 @@
"""数据库模型定义"""
from tortoise import fields
from tortoise.models import Model
class TimestampMixin:
"""通用时间戳字段。所有业务表统一包含 created_at / updated_at。"""
created_at = fields.DatetimeField(auto_now_add=True, description="创建时间")
updated_at = fields.DatetimeField(auto_now=True, description="更新时间")
class Message(TimestampMixin, Model):
"""消息记录"""
id = fields.BigIntField(pk=True, generated=True)
msg_id = fields.BigIntField(null=True)
chat_id = fields.BigIntField()
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
text = fields.TextField(null=True)
is_spam = fields.BooleanField(default=False)
spam_reason = fields.CharField(max_length=255, null=True)
class Meta:
table = "messages"
indexes = (("chat_id", "msg_id"), ("user_id", "created_at"), ("is_spam", "created_at"))
class Ban(TimestampMixin, Model):
"""封禁记录"""
id = fields.BigIntField(pk=True, generated=True)
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
first_name = fields.CharField(max_length=255, null=True)
reason = fields.CharField(max_length=500, null=True)
method = fields.CharField(max_length=50, default="auto") # auto/command/verify_fail
duration_minutes = fields.IntField(default=2)
auto_unban = fields.BooleanField(default=True)
unban_at = fields.DatetimeField(null=True)
class Meta:
table = "bans"
indexes = (("user_id", "created_at"), ("auto_unban", "unban_at"))
class Action(TimestampMixin, Model):
"""操作日志(入群/退群/删除/解封/警告等)"""
id = fields.BigIntField(pk=True, generated=True)
action = fields.CharField(max_length=50)
user_id = fields.BigIntField(null=True)
username = fields.CharField(max_length=255, null=True)
operator_id = fields.BigIntField(null=True)
details = fields.JSONField(null=True)
class Meta:
table = "actions"
indexes = (("action", "created_at"), ("user_id", "created_at"), ("operator_id", "created_at"))
class VerifyQueue(TimestampMixin, Model):
"""入群验证队列"""
id = fields.BigIntField(pk=True, generated=True)
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
question = fields.CharField(max_length=500, null=True)
answer = fields.CharField(max_length=500, null=True)
attempts = fields.IntField(default=0)
status = fields.CharField(max_length=20, default="pending")
verified_at = fields.DatetimeField(null=True)
class Meta:
table = "verify_queue"
indexes = (("user_id", "status"), ("status", "created_at"))
class UserBinding(TimestampMixin, Model):
"""TG ↔ Harvest 账号绑定"""
id = fields.BigIntField(pk=True, generated=True)
tg_user_id = fields.BigIntField(unique=True)
tg_username = fields.CharField(max_length=255, null=True)
harvest_uid = fields.CharField(max_length=255, null=True)
harvest_token = fields.CharField(max_length=500, null=True)
# 兼容旧代码/展示语义:绑定时间;created_at 仍作为统一审计字段
bound_at = fields.DatetimeField(auto_now_add=True)
class Meta:
table = "user_bindings"
indexes = (("tg_user_id",), ("harvest_uid",))
class Warning(TimestampMixin, Model):
"""警告记录"""
id = fields.BigIntField(pk=True, generated=True)
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
reason = fields.CharField(max_length=500, null=True)
operator_id = fields.BigIntField(null=True)
active = fields.BooleanField(default=True)
class Meta:
table = "warnings"
indexes = (("user_id", "active"), ("operator_id", "created_at"))
class GameScore(TimestampMixin, Model):
"""游戏积分"""
id = fields.BigIntField(pk=True, generated=True)
user_id = fields.BigIntField()
username = fields.CharField(max_length=255, null=True)
game_type = fields.CharField(max_length=50)
score = fields.IntField(default=0)
wins = fields.IntField(default=0)
total = fields.IntField(default=0)
streak = fields.IntField(default=0)
last_played = fields.DatetimeField(null=True)
class Meta:
table = "game_scores"
unique_together = (("user_id", "game_type"),)
indexes = (("game_type", "score"), ("user_id", "game_type"))
class GroupConfig(TimestampMixin, Model):
"""群组配置"""
key = fields.CharField(max_length=100, pk=True)
value = fields.JSONField(null=True)
class Meta:
table = "group_config"
+28
View File
@@ -0,0 +1,28 @@
services:
tg-spam-guard:
build: .
container_name: tg-spam-guard
restart: unless-stopped
ports:
- "8766:8766"
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
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
+23
View File
@@ -0,0 +1,23 @@
"""注册所有 handlers"""
from telegram.ext import Application
from .welcome import register_welcome
from .verify import register_verify
from .spam import register_spam
from .night_mode import register_night_mode
from .knowledge import register_knowledge
from .fun import register_fun
from .bind import register_bind
from .admin import register_admin
def register_all(app: Application):
"""注册所有消息/命令 handler"""
register_admin(app)
register_knowledge(app)
register_bind(app)
register_fun(app)
register_night_mode(app)
register_verify(app)
register_welcome(app)
register_spam(app) # 最后注册,作为兜底
+235
View File
@@ -0,0 +1,235 @@
"""管理命令 handler"""
import asyncio
import logging
from datetime import datetime, timedelta
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes, filters
import config
from db.models import Action, Ban, Message, Warning
from services.admin_utils import require_admin, resolve_target_user
from services.stats_service import get_daily_stats
logger = logging.getLogger("spam_guard")
async def cmd_stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""今日统计"""
stats = await get_daily_stats()
text = (
f"📊 今日群组统计 ({stats['date']})\n"
f"━━━━━━━━━━━━━━━━━━━━\n"
f"💬 消息: {stats['messages']}\n"
f"🗑️ 删除垃圾: {stats['deleted']}\n"
f"🚨 垃圾判定: {stats['spam_messages']}\n"
f"🆕 入群: {stats['joins']}\n"
f"🚪 退群: {stats['leaves']}\n"
f"🔨 封禁: {stats['today_bans']}\n"
f"🔓 解封: {stats['unbans']}\n"
f"📦 历史封禁: {stats['total_bans']}\n"
)
reply = await update.message.reply_text(text)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_ban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动封禁。支持回复消息、用户ID、历史 username。"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await update.message.reply_text("用法: 回复用户消息 /ban,或 /ban 用户ID/@历史用户名")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
try:
unban_at = datetime.now() + timedelta(minutes=config.BAN_DURATION_MINUTES)
await update.effective_chat.ban_member(user_id=user_id, until_date=int(unban_at.timestamp()))
await Ban.create(
user_id=user_id,
username=target,
reason=f"手动封禁 by {update.effective_user.username or update.effective_user.id}",
method="command",
duration_minutes=config.BAN_DURATION_MINUTES,
unban_at=unban_at,
)
await Action.create(action="BAN", user_id=user_id, username=target, operator_id=update.effective_user.id)
reply = await update.message.reply_text(f"🔨 已封禁 {target} ({user_id}) {config.BAN_DURATION_MINUTES} 分钟")
except Exception as e:
logger.exception("手动封禁失败")
reply = await update.message.reply_text(f"❌ 封禁失败: {e}")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_unban(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动解封"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await update.message.reply_text("用法: /unban 用户ID/@历史用户名")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
try:
await context.bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id)
await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now())
await Action.create(action="UNBAN", user_id=user_id, username=target, operator_id=update.effective_user.id)
reply = await update.message.reply_text(f"🔓 已解封用户 {target} ({user_id})")
except Exception as e:
logger.exception("手动解封失败")
reply = await update.message.reply_text(f"❌ 解封失败: {e}")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_warn(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""警告用户"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await update.message.reply_text("用法: 回复用户消息 /warn [原因],或 /warn 用户ID/@历史用户名 [原因]")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
# 回复模式下 args 全部是原因;非回复模式下 args[1:] 是原因
if update.message.reply_to_message:
reason = " ".join(context.args) if context.args else "未说明原因"
else:
reason = " ".join(context.args[1:]) if len(context.args) > 1 else "未说明原因"
warn_count = await Warning.filter(user_id=user_id).count() + 1
await Warning.create(user_id=user_id, username=target, reason=reason, operator_id=update.effective_user.id)
await Action.create(action="WARN", user_id=user_id, username=target, operator_id=update.effective_user.id, details={"reason": reason})
reply = await update.message.reply_text(f"⚠️ 已警告 {target} ({user_id})\n原因: {reason}\n累计警告: {warn_count}")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_mute(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""禁言用户"""
if not await require_admin(update, context):
return
user_id, target = await resolve_target_user(update, context.args)
if not user_id:
reply = await update.message.reply_text("用法: 回复用户消息 /mute [分钟],或 /mute 用户ID/@历史用户名 [分钟]")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
minutes = 10
minute_arg = context.args[0] if update.message.reply_to_message and context.args else (context.args[1] if len(context.args) > 1 else "")
if minute_arg.isdigit():
minutes = max(1, min(int(minute_arg), 1440))
try:
from telegram import ChatPermissions
until_date = int((datetime.now() + timedelta(minutes=minutes)).timestamp())
await update.effective_chat.restrict_member(
user_id=user_id,
permissions=ChatPermissions(can_send_messages=False),
until_date=until_date,
)
await Action.create(action="MUTE", user_id=user_id, username=target, operator_id=update.effective_user.id, details={"minutes": minutes})
reply = await update.message.reply_text(f"🔇 已禁言 {target} ({user_id}) {minutes} 分钟")
except Exception as e:
logger.exception("禁言失败")
reply = await update.message.reply_text(f"❌ 禁言失败: {e}")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_purge(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""批量删除消息。Telegram Bot API 无法拉历史,只支持回复某条消息后向前按已知 message_id 尝试删除。"""
if not await require_admin(update, context):
return
if not context.args or not context.args[0].isdigit():
reply = await update.message.reply_text("用法: 回复一条消息后 /purge 数量(最多50)")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
if not update.message.reply_to_message:
reply = await update.message.reply_text("❌ /purge 需要回复一条消息作为删除起点")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
count = min(max(int(context.args[0]), 1), 50)
start_id = update.message.reply_to_message.message_id
deleted = 0
for mid in range(start_id, start_id - count, -1):
try:
await context.bot.delete_message(chat_id=update.effective_chat.id, message_id=mid)
deleted += 1
except Exception:
pass
try:
await update.message.delete()
except Exception:
pass
reply = await update.effective_chat.send_message(f"🧹 已尝试清理 {count} 条,成功删除 {deleted}")
await Action.create(action="PURGE", operator_id=update.effective_user.id, details={"count": count, "deleted": deleted})
asyncio.create_task(auto_delete(reply))
async def cmd_help(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""帮助"""
text = (
"📖 收割机群管机器人帮助\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"🤖 /ask <问题> - 向知识库提问\n"
"📊 /stats - 今日统计\n"
"✅ /checkin - 每日签到\n"
"🎮 /trivia - 知识答题\n"
"🏆 /rank - 积分排行\n"
"🔗 /bind - 绑定账号\n"
"🌙 /night on/off - 夜间模式(管理员)\n\n"
"管理命令(管理员):\n"
"🔨 回复消息 /ban 或 /ban 用户ID\n"
"🔓 /unban 用户ID\n"
"⚠️ 回复消息 /warn [原因]\n"
"🔇 回复消息 /mute [分钟]\n"
"🧹 回复消息 /purge 数量\n"
)
reply = await update.message.reply_text(text)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply, seconds=120))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_admin(app):
app.add_handler(CommandHandler("help", cmd_help, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("stats", cmd_stats, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("ban", cmd_ban, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("unban", cmd_unban, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("warn", cmd_warn, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("mute", cmd_mute, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("purge", cmd_purge, filters.ChatType.GROUPS))
+65
View File
@@ -0,0 +1,65 @@
"""账号绑定 handler"""
import asyncio
import logging
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler, filters
from db.models import Action
from services.bind_service import generate_bind_token, unbind, get_binding
logger = logging.getLogger("spam_guard")
async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""绑定 harvest 账号"""
user = update.effective_user
# 检查是否已绑定
existing = await get_binding(user.id)
if existing:
reply = await update.message.reply_text(
f"🔗 你已绑定 harvest 账号\n"
f"📦 UID: {existing['harvest_uid']}\n"
f"📅 绑定时间: {existing['bound_at']}\n\n"
f"如需重新绑定,先 /unbind"
)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
token, url = generate_bind_token(user.id, user.username or user.first_name)
reply = await update.message.reply_text(
"🔗 账号绑定\n"
"━━━━━━━━━━━━━━━━━━━━\n\n"
f"请点击以下链接完成绑定:\n{url}\n\n"
"⏳ 链接 5 分钟内有效"
)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply, seconds=120))
async def cmd_unbind(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""解绑 harvest 账号"""
user = update.effective_user
deleted = await unbind(user.id)
if deleted:
reply = await update.message.reply_text("🔓 已解除账号绑定")
await Action.create(action="UNBIND", user_id=user.id, username=user.username)
else:
reply = await update.message.reply_text("❌ 你没有绑定的账号")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_bind(app):
app.add_handler(CommandHandler("bind", cmd_bind, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("unbind", cmd_unbind, filters.ChatType.GROUPS))
+120
View File
@@ -0,0 +1,120 @@
"""气氛组 + 小游戏 handler"""
import asyncio
import logging
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters
from db.models import Action
from services.game_service import (
get_trivia_question, check_trivia_answer,
get_leaderboard, daily_checkin,
)
logger = logging.getLogger("spam_guard")
# 关键词触发
TRIGGER_REACTIONS = {
"谢谢": "👍❤️",
"感谢": "👍❤️🎉",
"哈哈": "😂",
"笑死": "😂💀",
"牛逼": "🔥💪",
"nb": "🔥💪",
"666": "🔥",
"晚安": "🌙✨",
}
async def handle_trigger(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""关键词触发气氛组"""
msg = update.effective_message
if not msg or not msg.text:
return
text = msg.text.strip()
for keyword, emoji in TRIGGER_REACTIONS.items():
if text == keyword or text.startswith(keyword):
try:
await msg.react(emoji)
except Exception:
pass
break
async def cmd_checkin(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""每日签到"""
user = update.effective_user
result = await daily_checkin(user.id, user.username or user.first_name)
if result["ok"]:
reply = await update.message.reply_text(
f"✅ 签到成功!\n"
f"📊 积分: {result['score']}\n"
f"🔥 连续签到: {result['streak']}\n"
f"🎁 签到奖励: +{result['bonus']}"
)
else:
reply = await update.message.reply_text("❌ 你今天已经签到过了")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_trivia(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""收割机知识答题"""
q = await get_trivia_question()
text = f"{q['q']}\n\n"
for i, opt in enumerate(q["opts"], 1):
text += f"{i}. {opt}\n"
text += f"\n💡 请回复序号或答案文字"
context.user_data["trivia_answer"] = q["a"]
reply = await update.message.reply_text(text)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_score(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""查看积分"""
from db.models import GameScore
user = update.effective_user
scores = await GameScore.filter(user_id=user.id).all()
if not scores:
reply = await update.message.reply_text("📊 你还没有积分记录\n💡 签到 /checkin 或答题 /trivia 开始赚积分")
else:
text = "📊 你的积分\n━━━━━━━━━━━━\n"
for s in scores:
text += f"🎮 {s.game_type}: {s.score} 分 (胜率 {s.wins}/{s.total})\n"
reply = await update.message.reply_text(text)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def cmd_rank(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""排行榜"""
top = await get_leaderboard("trivia", 10)
if not top:
reply = await update.message.reply_text("🏆 暂无排行数据")
else:
text = "🏆 知识答题排行榜\n━━━━━━━━━━━━━━━━━━\n"
medals = ["🥇", "🥈", "🥉"]
for i, s in enumerate(top):
medal = medals[i] if i < 3 else f" {i+1}."
text += f"{medal} @{s.username or s.user_id}{s.score}\n"
reply = await update.message.reply_text(text)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_fun(app):
app.add_handler(CommandHandler("checkin", cmd_checkin, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("trivia", cmd_trivia, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("score", cmd_score, filters.ChatType.GROUPS))
app.add_handler(CommandHandler("rank", cmd_rank, filters.ChatType.GROUPS))
app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.GROUPS, handle_trigger), group=2)
+82
View File
@@ -0,0 +1,82 @@
"""知识库问答 handler"""
import asyncio
import logging
from telegram import Update
from telegram.ext import CommandHandler, ContextTypes, filters
import config
logger = logging.getLogger("spam_guard")
def split_answer(text: str, max_len: int = 3500, max_parts: int = 3) -> list[str]:
"""Telegram 单条消息长度有限,长回答按段发送,不再硬截断。"""
if len(text) <= max_len:
return [text]
parts = []
rest = text
while rest and len(parts) < max_parts:
if len(rest) <= max_len:
parts.append(rest)
break
cut = rest.rfind("\n\n", 0, max_len)
if cut < max_len // 2:
cut = rest.rfind("\n", 0, max_len)
if cut < max_len // 2:
cut = max_len
parts.append(rest[:cut].strip())
rest = rest[cut:].strip()
if rest and parts:
parts[-1] += "\n\n...(回答较长,后续内容已省略;可以追问具体部分)"
return parts
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(
"❓ 用法: /ask <你的问题>\n"
"例如: /ask 怎么安装收割机\n"
f"文档地址: {config.SITE_URL}"
)
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
return
logger.info(f"📩 用户提问: [{update.message.from_user.username}] {query}")
searching_msg = await update.message.reply_text("🔍 正在搜索知识库并整理详细答案...")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(searching_msg, seconds=8))
try:
from services.knowledge_base import ask_knowledge_base
answer = await ask_knowledge_base(query)
replies = []
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)
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}")
asyncio.create_task(auto_delete(reply))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_knowledge(app):
app.add_handler(CommandHandler("ask", cmd_ask, filters.ChatType.GROUPS))
+54
View File
@@ -0,0 +1,54 @@
"""夜间模式 handler"""
import asyncio
import logging
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler, filters
import config
from services.admin_utils import require_admin
from services.night_scheduler import enable_night_mode, disable_night_mode
logger = logging.getLogger("spam_guard")
async def cmd_night(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""手动控制夜间模式"""
if not await require_admin(update, context):
return
args = context.args
if not args:
await update.message.reply_text(
"🌙 夜间模式\n\n"
"用法:\n"
"/night on 开启\n"
"/night off 关闭"
)
asyncio.create_task(auto_delete(update.message))
return
cmd = args[0].lower()
if cmd == "on":
await enable_night_mode(update.effective_chat)
reply = await update.message.reply_text("🌙 夜间模式已开启,全员禁言")
elif cmd == "off":
await disable_night_mode(update.effective_chat)
reply = await update.message.reply_text("☀️ 夜间模式已关闭,恢复发言")
else:
reply = await update.message.reply_text("❓ 用法: /night on|off")
asyncio.create_task(auto_delete(update.message))
asyncio.create_task(auto_delete(reply))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_night_mode(app):
app.add_handler(CommandHandler("night", cmd_night, filters.ChatType.GROUPS))
+100
View File
@@ -0,0 +1,100 @@
"""垃圾消息检测 handler"""
import asyncio
import logging
from datetime import datetime, timedelta
from telegram import Update
from telegram.ext import ContextTypes, MessageHandler, filters
import config
from db.models import Message, Ban, Action
from services.spam_detector import detect
logger = logging.getLogger("spam_guard")
# 简单的防刷屏缓存:user_id -> [(timestamp, msg_id)]
_flood_cache: dict[int, list] = {}
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""处理群消息:防刷屏 + 垃圾检测"""
msg = update.effective_message
if not msg or not msg.text:
return
user = update.effective_user
if not user or user.is_bot:
return
username = user.username or ""
first_name = user.first_name or ""
text = msg.text
# === 防刷屏 ===
now = datetime.now().timestamp()
if user.id not in _flood_cache:
_flood_cache[user.id] = []
_flood_cache[user.id].append((now, msg.message_id))
# 清理过期
_flood_cache[user.id] = [
(t, mid) for t, mid in _flood_cache[user.id]
if now - t < config.RATE_LIMIT_WINDOW
]
if len(_flood_cache[user.id]) > config.RATE_LIMIT_MESSAGES:
try:
await msg.delete()
except Exception:
pass
logger.warning(f"⚡ 防刷屏: @{username} ({user.id}) {len(_flood_cache[user.id])}条/{config.RATE_LIMIT_WINDOW}s")
return
# 记录消息
await Message.create(
msg_id=msg.message_id,
chat_id=update.effective_chat.id,
user_id=user.id,
username=username,
first_name=first_name,
text=text[:500],
)
# === 垃圾检测 ===
is_spam, reason = await detect(text, username, first_name)
if is_spam:
logger.warning(f"🚨 垃圾消息 [{username}]: {reason}")
# 删消息
try:
await msg.delete()
await Action.create(action="DELETE", user_id=user.id, username=username, details={"reason": reason, "text": text[:120]})
except Exception as e:
logger.error(f"❌ 删除消息失败: {e}")
# 封禁
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)
ban_record = {
"user_id": user.id,
"username": username,
"first_name": first_name,
"reason": reason,
"method": "auto",
"duration_minutes": config.BAN_DURATION_MINUTES,
"unban_at": unban_at,
}
await Ban.create(**ban_record)
await Action.create(action="BAN", user_id=user.id, username=username, details={"reason": reason})
logger.info(f"🔨 已封禁 [{username}] {config.BAN_DURATION_MINUTES} 分钟")
except Exception as e:
logger.error(f"❌ 封禁失败: {e}")
# 标记为垃圾
await Message.filter(msg_id=msg.message_id).update(is_spam=True, spam_reason=reason)
def register_spam(app):
app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.GROUPS & ~filters.COMMAND, handle_message))
+109
View File
@@ -0,0 +1,109 @@
"""入群验证 handler"""
import asyncio
import logging
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes, CallbackQueryHandler, MessageHandler, filters
import config
from db.models import VerifyQueue, Action
from services.verify_service import create_verify, check_answer, get_pending_verify
logger = logging.getLogger("spam_guard")
async def handle_verify_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""处理验证按钮回调"""
query = update.callback_query
await query.answer()
user = query.from_user
data = query.data
if data == "start_verify":
result = await get_pending_verify(user.id)
if not result:
result = await create_verify(user.id, user.username)
await query.edit_message_text(
f"✅ 验证问题:\n\n{result['question']}\n\n"
f"请直接回复你的答案(文字消息)"
)
# 设置状态等待回答
context.user_data["verifying"] = True
context.user_data["verify_user_id"] = user.id
elif data == "verify_help":
await query.edit_message_text(
"💡 验证帮助\n\n"
"请直接在群内发送你的答案文字消息。\n"
f"你有 {config.VERIFY_MAX_ATTEMPTS} 次机会"
)
async def handle_verify_answer(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""处理验证回答"""
if not context.user_data.get("verifying"):
return
user_id = context.user_data.get("verify_user_id", update.effective_user.id)
answer = update.message.text.strip()
result = await check_answer(user_id, answer)
if result["ok"]:
context.user_data["verifying"] = False
# 恢复发言权限
try:
from telegram import ChatPermissions
await update.effective_chat.restrict_member(
user_id=user_id,
permissions=ChatPermissions(
can_send_messages=True,
can_send_audios=True,
can_send_documents=True,
can_send_photos=True,
can_send_videos=True,
can_send_video_notes=True,
can_send_voice_notes=True,
can_send_polls=True,
can_send_other_messages=True,
can_add_web_page_previews=True,
can_invite_users=True,
),
)
except Exception as e:
logger.error(f"⚠️ 恢复权限失败: {e}")
await update.message.reply_text("🎉 验证通过!欢迎加入群组!")
await Action.create(action="VERIFY_PASS", user_id=user_id, username=update.effective_user.username)
logger.info(f"✅ 验证通过: {user_id}")
# 60s 后删除
asyncio.create_task(auto_delete(update.message))
else:
if result["reason"] == "max_attempts":
context.user_data["verifying"] = False
await update.message.reply_text("❌ 验证失败次数过多,你将被移出群组")
try:
await update.effective_chat.ban_member(user_id=user_id)
await asyncio.sleep(5)
await update.effective_chat.unban_member(user_id=user_id)
except Exception:
pass
await Action.create(action="VERIFY_FAIL", user_id=user_id, username=update.effective_user.username)
else:
await update.message.reply_text(f"❌ 答案错误,剩余 {config.VERIFY_MAX_ATTEMPTS - result.get('attempts', 0)} 次机会")
asyncio.create_task(auto_delete(update.message))
async def auto_delete(message, seconds=60):
try:
await asyncio.sleep(seconds)
await message.delete()
except Exception:
pass
def register_verify(app):
app.add_handler(CallbackQueryHandler(handle_verify_callback, pattern=r"^(start_verify|verify_help)$"))
app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.GROUPS & ~filters.COMMAND, handle_verify_answer), group=1)
+103
View File
@@ -0,0 +1,103 @@
"""入群欢迎 / 退群告别"""
import logging
from telegram import ChatMember, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.ext import ChatMemberHandler, ContextTypes
import config
from db.models import Action
from services.message_utils import schedule_delete
from services.verify_service import create_verify
logger = logging.getLogger("spam_guard")
WELCOME_TEXT = (
"👋 欢迎新成员 **{username}** 加入收割机群组!\n"
"━━━━━━━━━━━━━━━━━━━━\n"
"📖 请先阅读群规,文明交流\n"
"❓ 有问题用 /ask 向知识库提问\n"
"🛡️ 自动垃圾检测已开启\n\n"
"⚠️ 请在 {timeout} 分钟内点击按钮完成验证,否则可能被移出"
)
GOODBYE_TEXT = "👋 {username} 离开了群组"
def verify_keyboard() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("✅ 开始验证", callback_data="start_verify")],
[InlineKeyboardButton("💡 验证帮助", callback_data="verify_help")],
])
async def handle_chat_member(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""入群欢迎 / 退群告别"""
cmu = update.chat_member
if not cmu or not cmu.new_chat_member:
return
user = cmu.new_chat_member.user
if user.is_bot:
return
old_status = cmu.old_chat_member.status if cmu.old_chat_member else "unknown"
new_status = cmu.new_chat_member.status
# 入群
if old_status in (ChatMember.LEFT, ChatMember.BANNED) and new_status in (ChatMember.MEMBER, ChatMember.RESTRICTED):
username = user.username or user.first_name or str(user.id)
await Action.create(action="JOIN", user_id=user.id, username=username, details={"chat_id": update.effective_chat.id})
logger.info(f"🆕 入群: @{username} ({user.id})")
# 先创建验证题,避免按钮点下去无 pending 记录
try:
await create_verify(user.id, username)
except Exception as e:
logger.error(f"⚠️ 创建验证失败: {e}")
# 限制新人(允许文本,禁止媒体/链接预览/拉人)
try:
from telegram import ChatPermissions
await update.effective_chat.restrict_member(
user_id=user.id,
permissions=ChatPermissions(
can_send_messages=True,
can_send_audios=False,
can_send_documents=False,
can_send_photos=False,
can_send_videos=False,
can_send_video_notes=False,
can_send_voice_notes=False,
can_send_polls=False,
can_send_other_messages=False,
can_add_web_page_previews=False,
can_invite_users=False,
),
)
except Exception as e:
logger.error(f"⚠️ 限制新人失败: {e}")
# 发欢迎+验证按钮
try:
timeout_min = max(1, config.VERIFY_TIMEOUT_SECONDS // 60)
msg = await update.effective_chat.send_message(
WELCOME_TEXT.format(username=f"@{username}", timeout=timeout_min),
parse_mode="Markdown",
reply_markup=verify_keyboard(),
)
schedule_delete(msg, seconds=config.VERIFY_TIMEOUT_SECONDS)
except Exception as e:
logger.error(f"❌ 欢迎消息发送失败: {e}")
# 退群
elif new_status in (ChatMember.LEFT, ChatMember.BANNED):
username = user.username or user.first_name or str(user.id)
await Action.create(action="LEAVE", user_id=user.id, username=username)
logger.info(f"🚪 离群: @{username} ({user.id})")
def register_welcome(app):
"""注册入群欢迎/退群告别"""
app.add_handler(ChatMemberHandler(handle_chat_member, ChatMemberHandler.CHAT_MEMBER))
+719
View File
@@ -0,0 +1,719 @@
"""知识库管理模块 - RAG 检索增强生成
架构:文档 → 精细分块(代码块独立/标题层级切分) → 中文embedding(BGE) + numpy余弦相似度 → 关键词+向量混合检索 → LLM生成回答
"""
import hashlib
import json
import os
import re
from pathlib import Path
from urllib.parse import quote
# ============ 配置 ============
KNOWLEDGE_DIR = Path(__file__).parent
INDEX_DIR = KNOWLEDGE_DIR / "index"
CACHE_FILE = KNOWLEDGE_DIR / "qa_cache.json"
CACHE_VERSION = "v3-detail-grounded"
DOCS_SOURCE = Path("/vol3/1000/Code/ngfchl.github.io/docs")
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "qwen3:8b")
SITE_URL = "https://ptools.fun"
# 切分参数
CHUNK_SIZE = 900
CHUNK_OVERLAP = 120
TOP_K = 10
# 中文 embedding 模型
EMBED_MODEL = "BAAI/bge-small-zh-v1.5"
# 需要跳过的文件
SKIP_FILES = {"agreement.md", "index.md", "password.md", "termnal.md", "ssh.md"}
SKIP_SECTION_KEYWORDS = ["油猴", "tampermonkey", "greasemonkey", "userscript"]
# ============ Embedding ============
_model = None
def get_model():
global _model
if _model is None:
from sentence_transformers import SentenceTransformer
cache_dir = str(KNOWLEDGE_DIR / "models")
os.environ["CUDA_VISIBLE_DEVICES"] = ""
print(f"📦 加载中文 embedding 模型: {EMBED_MODEL}")
_model = SentenceTransformer(EMBED_MODEL, cache_folder=cache_dir)
print("✅ 模型加载完成")
return _model
def embed_texts(texts: list[str]):
model = get_model()
return model.encode(texts, normalize_embeddings=True, show_progress_bar=True,
batch_size=32, convert_to_numpy=True)
# ============ URL 生成 ============
def doc_path_to_url(rel_path: str) -> str:
html_path = rel_path.replace(".md", ".html")
parts = html_path.split("/")
encoded = "/".join(quote(p, safe="") for p in parts)
return f"{SITE_URL}/{encoded}"
# ============ 文档清洗(保留结构) ============
def clean_markdown(text: str) -> str:
"""清洗但保留代码块标记和链接"""
text = re.sub(r'<[^>]+>', '', text)
# 图片替换为 alt 文本
text = re.sub(r'!\[([^\]]*)\]\([^\)]+\)', r'[图片:\1]', text)
# 链接保留文本+URL
text = re.sub(r'\[([^\]]+)\]\(([^\)]+)\)', r'\1 (\2)', text)
# frontmatter
text = re.sub(r'^---\n.*?---\n', '', text, flags=re.DOTALL)
text = re.sub(r'\n{3,}', '\n\n', text)
return text.strip()
# ============ 代码块检测 ============
def find_code_blocks(text: str) -> list[tuple[int, int]]:
"""找到所有代码块的起止位置"""
blocks = []
lines = text.split('\n')
in_block = False
start = -1
for i, line in enumerate(lines):
stripped = line.strip()
if re.match(r'^```\w*$', stripped):
if not in_block:
in_block = True
start = i
else:
blocks.append((start, i))
in_block = False
return blocks
# ============ 精细分块 ============
def split_document(text: str, rel_path: str, doc_title: str, doc_url: str) -> list[dict]:
"""
精细分块策略:
1. 代码块作为独立 chunk(不拆分)
2. 每个标题层级下的内容独立成 chunk
3. 大段落按 CHUNK_SIZE 拆分
4. 每个 chunk 带上标题路径上下文
"""
chunks = []
lines = text.split('\n')
# 先找代码块位置
code_blocks = find_code_blocks(text)
code_block_lines = set()
for start, end in code_blocks:
for i in range(start, end + 1):
code_block_lines.add(i)
# 按标题切分
sections = [] # [(level, heading, line_start, line_end)]
current_heading_stack = [] # [(level, heading)]
current_start = 0
for i, line in enumerate(lines):
if i in code_block_lines:
continue
m = re.match(r'^(#{1,6})\s+(.+)', line.strip())
if m:
level = len(m.group(1))
heading = m.group(2).strip()
# 保存之前的 section
if i > current_start:
sections.append({
"level": current_heading_stack[-1][0] if current_heading_stack else 0,
"heading": current_heading_stack[-1][1] if current_heading_stack else "",
"path": [h for _, h in current_heading_stack],
"line_start": current_start,
"line_end": i - 1,
})
# 更新 heading stack
while current_heading_stack and current_heading_stack[-1][0] >= level:
current_heading_stack.pop()
current_heading_stack.append((level, heading))
current_start = i
# 最后一个 section
sections.append({
"level": current_heading_stack[-1][0] if current_heading_stack else 0,
"heading": current_heading_stack[-1][1] if current_heading_stack else "",
"path": [h for _, h in current_heading_stack],
"line_start": current_start,
"line_end": len(lines) - 1,
})
chunk_id = 0
for sec in sections:
sec_lines = lines[sec["line_start"]:sec["line_end"] + 1]
sec_text = '\n'.join(sec_lines).strip()
if len(sec_text) < 15:
continue
# 检查是否包含油猴关键词
if any(kw in sec_text.lower() for kw in SKIP_SECTION_KEYWORDS):
continue
# 构建标题路径
title_path = " > ".join(sec["path"]) if sec["path"] else doc_title
if title_path and title_path != doc_title:
title_path = f"{doc_title} > {title_path}"
else:
title_path = doc_title
# 代码块独立成 chunk
code_text_parts = []
non_code_parts = []
in_code = False
current_block = []
for line in sec_lines:
stripped = line.strip()
if re.match(r'^```\w*$', stripped):
if not in_code:
in_code = True
current_block = [line]
else:
current_block.append(line)
code_text = '\n'.join(current_block)
if len(code_text.strip()) > 20:
code_text_parts.append(code_text)
current_block = []
in_code = False
continue
if in_code:
current_block.append(line)
continue
non_code_parts.append(line)
# 代码块 chunk
for code_text in code_text_parts:
chunks.append({
"id": f"{rel_path}#{chunk_id}",
"text": f"[代码块] {title_path}\n{code_text}",
"title": title_path,
"doc_title": doc_title,
"source": rel_path,
"url": doc_url,
"heading": sec["heading"],
})
chunk_id += 1
# 非代码部分 chunk
non_code_text = '\n'.join(non_code_parts).strip()
if not non_code_text or len(non_code_text) < 15:
continue
if len(non_code_text) <= CHUNK_SIZE:
chunks.append({
"id": f"{rel_path}#{chunk_id}",
"text": f"{title_path}\n{non_code_text}",
"title": title_path,
"doc_title": doc_title,
"source": rel_path,
"url": doc_url,
"heading": sec["heading"],
})
chunk_id += 1
else:
# 按段落拆分大文本
paragraphs = non_code_text.split('\n\n')
current = ""
for para in paragraphs:
para = para.strip()
if not para:
continue
if len(current) + len(para) + 2 <= CHUNK_SIZE:
current += ("\n\n" if current else "") + para
else:
if current:
chunks.append({
"id": f"{rel_path}#{chunk_id}",
"text": f"{title_path}\n{current}",
"title": title_path,
"doc_title": doc_title,
"source": rel_path,
"url": doc_url,
"heading": sec["heading"],
})
chunk_id += 1
if len(para) > CHUNK_SIZE:
# 按行拆
sub_lines = para.split('\n')
current = ""
for sl in sub_lines:
if len(current) + len(sl) + 1 <= CHUNK_SIZE:
current += ("\n" if current else "") + sl
else:
if current:
chunks.append({
"id": f"{rel_path}#{chunk_id}",
"text": f"{title_path}\n{current}",
"title": title_path,
"doc_title": doc_title,
"source": rel_path,
"url": doc_url,
"heading": sec["heading"],
})
chunk_id += 1
current = sl
else:
current = para
if current.strip():
chunks.append({
"id": f"{rel_path}#{chunk_id}",
"text": f"{title_path}\n{current}",
"title": title_path,
"doc_title": doc_title,
"source": rel_path,
"url": doc_url,
"heading": sec["heading"],
})
chunk_id += 1
return chunks
# ============ 文档加载 ============
def load_all_docs() -> list[dict]:
docs = []
target_dirs = ("收割机", "通用教程")
for md_file in sorted(DOCS_SOURCE.rglob("*.md")):
rel_path = str(md_file.relative_to(DOCS_SOURCE))
parts = md_file.relative_to(DOCS_SOURCE).parts
if parts[0] not in target_dirs:
continue
if md_file.name in SKIP_FILES:
continue
try:
content = md_file.read_text(encoding="utf-8")
doc_title = ""
title_match = re.search(r'^title:\s*(.+)$', content, re.MULTILINE)
if title_match:
doc_title = title_match.group(1).strip().strip('"').strip("'")
doc_title = re.sub(r'^\d+\.\s*', '', doc_title)
body = clean_markdown(content)
if len(body) < 30:
continue
if not doc_title:
doc_title = md_file.stem
doc_url = doc_path_to_url(rel_path)
chunks = split_document(body, rel_path, doc_title, doc_url)
docs.extend(chunks)
code_count = sum(1 for c in chunks if "[代码块]" in c["text"])
print(f" 📄 {doc_title}: {len(chunks)} chunks ({code_count} 代码块)")
except Exception as e:
print(f"⚠️ 跳过 {md_file}: {e}")
return docs
# ============ 索引管理 ============
def rebuild_index():
INDEX_DIR.mkdir(parents=True, exist_ok=True)
docs = load_all_docs()
if not docs:
print("❌ 没有找到文档")
return
print(f"\n📚 加载了 {len(docs)} 个文档块")
texts = [d["text"] for d in docs]
print("🔄 正在生成中文向量...")
embeddings = embed_texts(texts)
import numpy as np
np.save(INDEX_DIR / "embeddings.npy", embeddings)
(INDEX_DIR / "metadata.json").write_text(
json.dumps(docs, ensure_ascii=False, indent=2), encoding="utf-8"
)
save_qa_cache({})
print(f"\n🎉 知识库索引完成,共 {len(docs)} 个文档块")
def load_metadata() -> list[dict]:
"""只加载轻量 metadata,运行时 lite 检索使用。"""
meta_path = INDEX_DIR / "metadata.json"
if not meta_path.exists():
return []
return json.loads(meta_path.read_text(encoding="utf-8"))
def load_index():
"""加载向量索引。仅 vector 模式/离线构建环境需要 numpy。"""
import numpy as np
emb_path = INDEX_DIR / "embeddings.npy"
meta_path = INDEX_DIR / "metadata.json"
if not emb_path.exists() or not meta_path.exists():
return np.array([]), []
embeddings = np.load(emb_path)
metadata = json.loads(meta_path.read_text(encoding="utf-8"))
return embeddings, metadata
# ============ 问答缓存 ============
def _cache_key(question: str) -> str:
normalized = re.sub(r'\s+', '', question.strip().lower())
return hashlib.md5(f"{CACHE_VERSION}:{normalized}".encode()).hexdigest()
def load_qa_cache() -> dict:
if CACHE_FILE.exists():
try:
content = CACHE_FILE.read_text(encoding="utf-8")
if content.strip():
return json.loads(content)
except Exception:
return {}
return {}
def save_qa_cache(cache: dict):
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(json.dumps(cache, ensure_ascii=False, indent=2), encoding="utf-8")
def get_cached_answer(question: str) -> str | None:
cache = load_qa_cache()
entry = cache.get(_cache_key(question))
if entry:
return entry.get("answer")
return None
def set_cached_answer(question: str, answer: str):
cache = load_qa_cache()
cache[_cache_key(question)] = {
"question": question,
"answer": answer,
}
if len(cache) > 500:
oldest = min(cache.keys(), key=lambda k: cache[k].get("question", ""))
del cache[oldest]
save_qa_cache(cache)
# ============ 关键词→文档映射 ============
KEYWORD_DOC_MAP = {
"安装": ["收割机/install.md"],
"部署": ["收割机/install.md", "收割机/使用指南.md"],
"docker": ["收割机/install.md", "收割机/使用指南.md"],
"compose": ["收割机/install.md", "收割机/compose.md"],
"群晖": ["收割机/install.md"],
"飞牛": ["收割机/install.md"],
"cookie": ["收割机/import.md", "通用教程/CookieCloud.md"],
"导入": ["收割机/import.md"],
"站点导入": ["收割机/import.md"],
"签到": ["收割机/common.md"],
"通知": ["收割机/common.md", "收割机/home.md"],
"下载器": ["收割机/common.md", "收割机/home.md"],
"app": ["收割机/app.md"],
"辅种": ["收割机/common.md"],
"主题": ["收割机/custom-theme.md"],
"自定义": ["收割机/custom-add.md", "收割机/custom-theme.md"],
"常见问题": ["收割机/common.md"],
"微信": ["收割机/wechat.md"],
"baidu": ["通用教程/baidu-ocr.md"],
"ocr": ["通用教程/baidu-ocr.md"],
"frp": ["通用教程/tunnel.md"],
"内网穿透": ["通用教程/tunnel.md"],
"tailscale": ["通用教程/tailscale-nat.md"],
"telegram": ["通用教程/telegram-bot.md"],
"阿里云": ["通用教程/aliyun.md"],
"emby": ["收割机/common.md"],
"做种": ["收割机/common.md"],
"日志": ["收割机/common.md"],
"代理": ["收割机/common.md"],
"ua": ["收割机/common.md"],
"登录": ["收割机/使用指南.md"],
"添加站点": ["收割机/import.md", "收割机/custom-add.md"],
"计划任务": ["收割机/home.md", "收割机/使用指南.md"],
"辅种助手": ["收割机/import.md"],
"种子迁移": ["收割机/使用指南.md"],
"数据库": ["收割机/install.md"],
"postgresql": ["收割机/install.md"],
"环境变量": ["收割机/install.md"],
"webhook": ["收割机/common.md"],
"授权": ["收割机/common.md", "收割机/install.md"],
"密码": ["收割机/使用指南.md"],
"token": ["收割机/使用指南.md"],
}
def keyword_boost(query: str) -> list[str]:
query_lower = query.lower()
matched = []
for keyword, docs in KEYWORD_DOC_MAP.items():
if keyword in query_lower:
matched.extend(docs)
return list(dict.fromkeys(matched))
# ============ 混合检索 ============
def tokenize_query(query: str) -> list[str]:
"""轻量中文/英文分词:无需 jieba,运行镜像零重依赖。"""
q = query.lower().strip()
tokens: list[str] = []
# 英文、数字、版本号等
tokens.extend(re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}", q))
# 连续中文词 + 2~4 字滑窗,兼顾“怎么安装/反代/环境变量”等短问法
for block in re.findall(r"[\u4e00-\u9fff]+", q):
if len(block) <= 4:
tokens.append(block)
else:
tokens.append(block)
for n in (2, 3, 4):
tokens.extend(block[i:i + n] for i in range(0, len(block) - n + 1))
# 去重并过滤太泛的单字
return list(dict.fromkeys(t for t in tokens if len(t) >= 2))
def lite_search(query: str, top_k: int = TOP_K) -> list[dict]:
"""轻量关键词/BM25-like 检索。
运行时不加载 sentence-transformers/torch,大幅降低 Docker 镜像体积。
对当前 200~500 个文档块规模,关键词+领域词表加权的性价比最高。
"""
metadata = load_metadata()
if not metadata:
return []
query_lower = query.lower().strip()
tokens = tokenize_query(query)
boosted_sources = set(keyword_boost(query))
scored: list[tuple[float, int]] = []
for i, doc in enumerate(metadata):
title = (doc.get("title") or "").lower()
source = (doc.get("source") or "").lower()
text = (doc.get("text") or "").lower()
hay = f"{title}\n{source}\n{text}"
score = 0.0
# 领域词表强加权
if doc.get("source") in boosted_sources:
score += 20.0
# 完整问题/短语命中
if query_lower and query_lower in hay:
score += 6.0
if query_lower and query_lower in title:
score += 5.0
# token 频次 + 字段权重
for token in tokens:
if token in title:
score += 4.0
if token in source:
score += 3.0
count = text.count(token)
if count:
# 类 BM25:频次递减,避免长文刷分
score += min(3.0, 1.0 + count ** 0.5)
# 配置/安装类问题:代码块和关键配置变量优先,避免模型拿不到原文示例后自行编造
config_query = any(k in query_lower for k in ["compose", "docker", "postgres", "postgresql", "安装", "部署", "配置", "环境变量"])
if config_query and "[代码块]" in text:
score += 10.0
config_hits = sum(1 for k in [
"image:", "services:", "environment:", "volumes:", "ports:",
"postgres_db", "postgres_user", "postgres_password",
"email", "token", "go_web_port", "database", "db_",
] if k in text)
score += min(20.0, config_hits * 3.0)
# 查询中英文/数字 token 对安装、docker、postgresql 等问题很关键
exact_tokens = re.findall(r"[a-z0-9][a-z0-9_.:-]{1,}", query_lower)
for token in exact_tokens:
if token in hay:
score += 2.5
if score > 0:
scored.append((score, i))
scored.sort(reverse=True, key=lambda x: x[0])
docs = []
for score, idx in scored:
doc = metadata[idx].copy()
doc["score"] = float(score)
src_count = sum(1 for d in docs if d["source"] == doc["source"])
if src_count < 2:
docs.append(doc)
if len(docs) >= top_k:
break
return docs
def vector_search(query: str, top_k: int = TOP_K) -> list[dict]:
"""向量检索:仅开发/宿主机模式使用,需要 sentence-transformers。"""
embeddings, metadata = load_index()
if len(embeddings) == 0:
return []
query_vec = get_model().encode([query], normalize_embeddings=True)
scores = (embeddings @ query_vec.T).flatten()
boosted_sources = keyword_boost(query)
if boosted_sources:
for i, doc in enumerate(metadata):
if doc["source"] in boosted_sources:
scores[i] += 0.3
query_words = set(re.findall(r'[\u4e00-\u9fff]+', query))
for i, doc in enumerate(metadata):
text_lower = doc["text"].lower()
matches = sum(1 for w in query_words if w in text_lower)
if matches >= 2:
scores[i] += 0.15 * matches
top_indices = np.argsort(scores)[::-1][:top_k]
docs = []
for idx in top_indices:
doc = metadata[idx].copy()
doc["score"] = float(scores[idx])
src_count = sum(1 for d in docs if d["source"] == doc["source"])
if src_count < 2:
docs.append(doc)
return docs
def expand_context(docs: list[dict], window: int = 1, max_chars: int = 12000, max_sources: int = 5) -> list[dict]:
"""为命中的 chunk 补充同源相邻上下文,减少回答过短/断章取义。"""
if not docs:
return []
metadata = load_metadata()
by_id = {d.get("id"): d for d in metadata}
expanded: list[dict] = []
seen: set[str] = set()
total_chars = 0
def parse_id(doc_id: str) -> tuple[str, int] | None:
if not doc_id or "#" not in doc_id:
return None
src, idx = doc_id.rsplit("#", 1)
if not idx.isdigit():
return None
return src, int(idx)
allowed_sources = list(dict.fromkeys(d.get("source") for d in docs if d.get("source")))[:max_sources]
allowed_sources_set = set(allowed_sources)
for doc in docs:
if doc.get("source") not in allowed_sources_set:
continue
parsed = parse_id(doc.get("id", ""))
candidates = [doc]
if parsed:
src, idx = parsed
neighbors = []
for offset in range(-window, window + 1):
item = by_id.get(f"{src}#{idx + offset}")
if item:
neighbors.append(item)
candidates = neighbors or [doc]
for item in candidates:
doc_id = item.get("id", "")
if doc_id in seen:
continue
text_len = len(item.get("text", ""))
if total_chars + text_len > max_chars and expanded:
continue
copy = item.copy()
copy.setdefault("score", doc.get("score", 0))
expanded.append(copy)
seen.add(doc_id)
total_chars += text_len
return expanded
def search(query: str, top_k: int = TOP_K, expand: bool = True, context_window: int = 1) -> list[dict]:
mode = os.environ.get("KNOWLEDGE_SEARCH_MODE", "lite").lower()
if mode in {"vector", "embedding", "semantic"}:
docs = vector_search(query, top_k=top_k)
else:
docs = lite_search(query, top_k=top_k)
return expand_context(docs, window=context_window) if expand else docs
def get_stats() -> dict:
metadata = load_metadata()
cache = load_qa_cache()
return {
"total_chunks": len(metadata),
"cached_answers": len(cache),
"site_url": SITE_URL,
"search_mode": os.environ.get("KNOWLEDGE_SEARCH_MODE", "lite"),
"embedding_model": EMBED_MODEL,
"cache_version": CACHE_VERSION,
}
# ============ CLI ============
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "rebuild":
rebuild_index()
elif len(sys.argv) > 1 and sys.argv[1] == "search":
query = " ".join(sys.argv[2:])
results = search(query)
for r in results:
print(f"\n📄 {r['title']} ({r['source']}) [分数:{r.get('score', 0):.3f}]")
print(f" URL: {r['url']}")
print(f" {r['text'][:200]}...")
elif len(sys.argv) > 1 and sys.argv[1] == "stats":
print(json.dumps(get_stats(), ensure_ascii=False, indent=2))
elif len(sys.argv) > 1 and sys.argv[1] == "cache":
cache = load_qa_cache()
for k, v in cache.items():
print(f"\n🔑 {k[:8]}... Q: {v['question'][:50]}")
else:
print("用法:")
print(" python -m knowledge rebuild - 重建索引")
print(" python -m knowledge search <查询> - 搜索")
print(" python -m knowledge stats - 统计信息")
print(" python -m knowledge cache - 查看缓存")
+20
View File
@@ -0,0 +1,20 @@
"""知识库命令行入口"""
import sys
import json
from knowledge import rebuild_index, search, get_stats
if len(sys.argv) > 1 and sys.argv[1] == "rebuild":
rebuild_index()
elif len(sys.argv) > 1 and sys.argv[1] == "search":
query = " ".join(sys.argv[2:])
results = search(query)
for r in results:
print(f"\n📄 {r['title']} ({r['source']}) [分数:{r.get('score', 0):.3f}]")
print(f" {r['text'][:200]}...")
elif len(sys.argv) > 1 and sys.argv[1] == "stats":
print(json.dumps(get_stats(), ensure_ascii=False, indent=2))
else:
print("用法:")
print(" python -m knowledge rebuild - 重建索引")
print(" python -m knowledge search <查询> - 搜索")
print(" python -m knowledge stats - 统计信息")
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""TG 群管机器人 v2 — FastAPI + Tortoise ORM + python-telegram-bot"""
import os
import asyncio
import logging
# 代理(必须在其他 import 之前)
PROXY_URL = os.getenv("PROXY_URL", "http://192.168.123.10:11055")
os.environ["HTTPS_PROXY"] = PROXY_URL
os.environ["HTTP_PROXY"] = PROXY_URL
os.environ["NO_PROXY"] = os.getenv("NO_PROXY", "127.0.0.1,localhost")
import uvicorn
from fastapi import FastAPI
from telegram import BotCommand, BotCommandScopeChat
from telegram.ext import Application
import config
from db import init_db, close_db
from handlers import register_all
# ============ 日志 ============
log_fmt = "%(asctime)s [%(levelname)s] %(message)s"
log_datefmt = "%Y-%m-%d %H:%M:%S"
logging.basicConfig(level=logging.INFO, format=log_fmt, datefmt=log_datefmt)
logger = logging.getLogger("spam_guard")
# 阻止 httpx 刷屏
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
# ============ Telegram Bot ============
tg_app: Application = None
async def start_tg_bot():
global tg_app
from telegram.request import HTTPXRequest
proxy_request = HTTPXRequest(proxy=PROXY_URL)
tg_app = (
Application.builder()
.token(config.TG_BOT_TOKEN)
.request(proxy_request)
.build()
)
register_all(tg_app)
await tg_app.initialize()
await tg_app.start()
await tg_app.updater.start_polling(drop_pending_updates=False)
# 菜单
commands = [
BotCommand("ask", "🤖 向知识库提问"),
BotCommand("stats", "📊 今日统计"),
BotCommand("checkin", "✅ 每日签到"),
BotCommand("trivia", "🎮 知识答题"),
BotCommand("bind", "🔗 绑定账号"),
BotCommand("help", "📖 帮助"),
]
await tg_app.bot.delete_my_commands()
await tg_app.bot.set_my_commands(commands, scope=BotCommandScopeChat(chat_id=config.TG_CHAT_ID))
logger.info(f"✅ TG Bot 已启动,菜单命令已设置 (群组: {config.TG_CHAT_ID})")
async def stop_tg_bot():
global tg_app
if tg_app:
await tg_app.updater.stop()
await tg_app.stop()
await tg_app.shutdown()
logger.info("🛑 TG Bot 已停止")
# ============ FastAPI ============
from web.api import app as web_app
async def start_tg_bot_safe():
"""启动 TG Bot,并确保异常能进日志。"""
try:
await start_tg_bot()
except Exception:
logger.exception("❌ TG Bot 启动失败")
raise
@web_app.on_event("startup")
async def on_startup():
# 初始化数据库
await init_db()
# 启动 TG Bot
task = asyncio.create_task(start_tg_bot_safe())
def log_task_result(t: asyncio.Task):
if t.cancelled():
return
exc = t.exception()
if exc:
logger.error("❌ TG Bot 任务异常结束", exc_info=exc)
task.add_done_callback(log_task_result)
logger.info("🚀 服务启动完成")
@web_app.on_event("shutdown")
async def on_shutdown():
await stop_tg_bot()
await close_db()
logger.info("🛑 服务已关闭")
def run():
uvicorn.run(web_app, host="0.0.0.0", port=config.WEB_PORT, log_level="info")
if __name__ == "__main__":
run()
@@ -0,0 +1,45 @@
-- 手工迁移兜底:为所有业务表补 created_at / updated_at
-- 执行前建议先备份数据库。
BEGIN;
-- 1) 大多数表已有 created_at,只补 updated_at
ALTER TABLE messages ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE bans ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE actions ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE verify_queue ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE warnings ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE game_scores ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE game_scores ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
-- 2) user_bindings 原来有 bound_at,这里补统一审计字段
ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
UPDATE user_bindings SET created_at = COALESCE(bound_at, created_at) WHERE bound_at IS NOT NULL;
-- 3) group_config 原来只有 updated_at,这里补 created_at,并修正 updated_at 默认值
ALTER TABLE group_config ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
ALTER TABLE group_config ALTER COLUMN updated_at SET DEFAULT NOW();
-- 4) warnings 字段统一:issued_by -> operator_id
ALTER TABLE warnings ADD COLUMN IF NOT EXISTS operator_id BIGINT;
UPDATE warnings SET operator_id = issued_by WHERE operator_id IS NULL AND issued_by IS NOT NULL;
-- 5) 常用索引
CREATE INDEX IF NOT EXISTS idx_messages_chat_msg ON messages (chat_id, msg_id);
CREATE INDEX IF NOT EXISTS idx_messages_user_created ON messages (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_spam_created ON messages (is_spam, created_at);
CREATE INDEX IF NOT EXISTS idx_bans_user_created ON bans (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_bans_auto_unban ON bans (auto_unban, unban_at);
CREATE INDEX IF NOT EXISTS idx_actions_action_created ON actions (action, created_at);
CREATE INDEX IF NOT EXISTS idx_actions_user_created ON actions (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_actions_operator_created ON actions (operator_id, created_at);
CREATE INDEX IF NOT EXISTS idx_verify_user_status ON verify_queue (user_id, status);
CREATE INDEX IF NOT EXISTS idx_verify_status_created ON verify_queue (status, created_at);
CREATE INDEX IF NOT EXISTS idx_warnings_user_active ON warnings (user_id, active);
CREATE INDEX IF NOT EXISTS idx_warnings_operator_created ON warnings (operator_id, created_at);
CREATE INDEX IF NOT EXISTS idx_game_scores_type_score ON game_scores (game_type, score);
CREATE INDEX IF NOT EXISTS idx_game_scores_user_type ON game_scores (user_id, game_type);
CREATE INDEX IF NOT EXISTS idx_user_bindings_harvest_uid ON user_bindings (harvest_uid);
COMMIT;
+20
View File
@@ -0,0 +1,20 @@
[project]
name = "tg-spam-guard"
version = "2.0.0"
description = "TG 群管机器人 — FastAPI + Tortoise ORM + 知识库"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.138.0",
"uvicorn>=0.30.0",
"python-telegram-bot>=22.8",
"httpx>=0.28.1",
"tortoise-orm>=0.24.0",
"aerich>=0.8.0",
"asyncpg>=0.30.0",
"sse-starlette>=3.4.5",
"sentence-transformers>=3.0.0",
"numpy>=1.26.0",
]
[tool.aerich]
tortoise_orm = "db.__init__.TORTOISE_ORM"
+3
View File
@@ -0,0 +1,3 @@
# 仅用于宿主机/构建机重建知识库向量索引,不进入 Bot 运行镜像
sentence-transformers>=3.0.0
numpy>=1.26.0
+8
View File
@@ -0,0 +1,8 @@
fastapi>=0.138.0
uvicorn>=0.30.0
python-telegram-bot>=22.8
httpx>=0.28.1
tortoise-orm>=0.24.0
aerich>=0.8.0
asyncpg>=0.30.0
sse-starlette>=3.4.5
View File
+79
View File
@@ -0,0 +1,79 @@
"""群管理工具:权限校验、目标用户解析"""
from __future__ import annotations
import logging
from telegram import Update
from telegram.constants import ChatMemberStatus
from telegram.ext import ContextTypes
import config
from db.models import Action, Ban, Message
from services.message_utils import schedule_delete
logger = logging.getLogger("spam_guard")
ADMIN_STATUSES = {ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER}
async def is_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
"""判断命令发送者是否为群管理员或配置中的超级管理员。"""
user = update.effective_user
chat = update.effective_chat
if not user or not chat:
return False
if user.id in config.ADMIN_USER_IDS:
return True
try:
member = await context.bot.get_chat_member(chat.id, user.id)
return member.status in ADMIN_STATUSES
except Exception as e:
logger.warning(f"管理员校验失败: user={user.id}, chat={chat.id}, err={e}")
return False
async def require_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
"""命令权限守卫。返回 True 表示允许继续。"""
if await is_admin(update, context):
return True
reply = await update.message.reply_text("⛔ 只有群管理员可以使用这个命令")
schedule_delete(update.message)
schedule_delete(reply)
return False
async def resolve_target_user(update: Update, args: list[str]) -> tuple[int | None, str]:
"""解析管理命令目标用户。
支持:
1. 回复某条消息后执行命令
2. /cmd 用户ID
3. /cmd @username,从历史消息/操作/封禁记录中反查 user_id
"""
# 回复消息优先,最准确
if update.message and update.message.reply_to_message and update.message.reply_to_message.from_user:
u = update.message.reply_to_message.from_user
username = u.username or u.first_name or str(u.id)
return u.id, username
if not args:
return None, ""
target = args[0].strip().lstrip("@")
if target.isdigit():
return int(target), target
# Telegram Bot API 不能直接用 username 查群成员,只能从历史记录里反查
msg = await Message.filter(username=target).order_by("-created_at").first()
if msg:
return msg.user_id, target
action = await Action.filter(username=target, user_id__not_isnull=True).order_by("-created_at").first()
if action:
return action.user_id, target
ban = await Ban.filter(username=target).order_by("-created_at").first()
if ban:
return ban.user_id, target
return None, target
+80
View File
@@ -0,0 +1,80 @@
"""账号绑定服务 — TG ↔ Harvest"""
import secrets
import logging
import httpx
import config
from db.models import UserBinding
logger = logging.getLogger("spam_guard")
# 临时绑定 token 存储(token -> user_id5分钟过期)
_pending_bindings: dict[str, dict] = {}
def generate_bind_token(tg_user_id: int, tg_username: str) -> tuple[str, str]:
"""生成绑定链接,返回 (token, url)"""
token = secrets.token_urlsafe(16)
_pending_bindings[token] = {
"tg_user_id": tg_user_id,
"tg_username": tg_username,
}
url = f"{config.HARVEST_SERVER_URL}/bind?token={token}&tg_user_id={tg_user_id}"
return token, url
async def verify_binding(token: str) -> dict:
"""验证绑定(harvest-server 回调时调用)"""
info = _pending_bindings.pop(token, None)
if not info:
return {"ok": False, "reason": "invalid_or_expired_token"}
tg_user_id = info["tg_user_id"]
tg_username = info["tg_username"]
# 尝试调用 harvest-server verify
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{config.HARVEST_SERVER_URL}/api/verify",
json={"token": token, "tg_user_id": tg_user_id},
)
if resp.status_code == 200:
data = resp.json()
harvest_uid = data.get("uid", "")
harvest_token = data.get("token", "")
else:
harvest_uid = str(tg_user_id)
harvest_token = token
except Exception:
harvest_uid = str(tg_user_id)
harvest_token = token
# 写入绑定
binding, _ = await UserBinding.get_or_create(
tg_user_id=tg_user_id,
defaults={
"tg_username": tg_username,
"harvest_uid": harvest_uid,
"harvest_token": harvest_token,
},
)
logger.info(f"🔗 账号绑定成功: TG={tg_user_id} Harvest={harvest_uid}")
return {"ok": True, "harvest_uid": harvest_uid}
async def unbind(tg_user_id: int) -> bool:
"""解绑"""
deleted = await UserBinding.filter(tg_user_id=tg_user_id).delete()
if deleted:
logger.info(f"🔗 账号解绑: TG={tg_user_id}")
return deleted > 0
async def get_binding(tg_user_id: int) -> dict | None:
"""查询绑定"""
b = await UserBinding.filter(tg_user_id=tg_user_id).first()
if b:
return {"harvest_uid": b.harvest_uid, "bound_at": b.bound_at}
return None
+63
View File
@@ -0,0 +1,63 @@
"""小游戏服务"""
import random
import logging
from datetime import datetime
from db.models import GameScore
logger = logging.getLogger("spam_guard")
# 简单题库
TRIVIA_QUESTIONS = [
{"q": "收割机项目的 GitHub 地址是?", "a": "github.com/ngfchl/go-harvest", "opts": ["github.com/ngfchl/go-harvest", "github.com/ngfchl/harvest", "github.com/harvest/go-harvest"]},
{"q": "以下哪个不是 PT 站的常见等级?", "a": "Diamond", "opts": ["User", "Power User", "Diamond", "Elite"]},
{"q": "Docker 默认的日志驱动是?", "a": "json-file", "opts": ["json-file", "syslog", "journald", "fluentd"]},
]
async def get_or_create_score(user_id: int, username: str, game_type: str) -> GameScore:
score, _ = await GameScore.get_or_create(
user_id=user_id, game_type=game_type,
defaults={"username": username},
)
return score
async def get_trivia_question() -> dict:
return random.choice(TRIVIA_QUESTIONS)
async def check_trivia_answer(user_id: int, username: str, answer: str, correct_answer: str) -> dict:
score = await get_or_create_score(user_id, username, "trivia")
score.total += 1
is_correct = answer.strip().lower() in correct_answer.strip().lower()
if is_correct:
score.score += 10
score.wins += 1
score.streak += 1
if score.streak > 1:
score.score += 5 # 连击奖励
else:
score.streak = 0
score.last_played = datetime.now()
await score.save()
return {"correct": is_correct, "score": score.score, "streak": score.streak}
async def get_leaderboard(game_type: str = "trivia", limit: int = 10) -> list:
return await GameScore.filter(game_type=game_type).order_by("-score").limit(limit).all()
async def daily_checkin(user_id: int, username: str) -> dict:
score = await get_or_create_score(user_id, username, "checkin")
today = datetime.now().strftime("%Y-%m-%d")
if score.last_played and score.last_played.strftime("%Y-%m-%d") == today:
return {"ok": False, "reason": "already_checked"}
score.total += 1
score.score += 5
score.streak += 1
bonus = min(score.streak, 7) # 连续签到最多 +7
score.score += bonus
score.last_played = datetime.now()
await score.save()
return {"ok": True, "score": score.score, "streak": score.streak, "bonus": bonus}
+170
View File
@@ -0,0 +1,170 @@
"""知识库问答服务 — 调用 knowledge/ 轻量检索 + Ollama 生成答案"""
import logging
import httpx
from knowledge import (
OLLAMA_MODEL,
OLLAMA_URL,
SITE_URL,
get_cached_answer,
search as kb_search,
set_cached_answer,
)
logger = logging.getLogger("spam_guard")
KNOWLEDGE_PROMPT = """你是收割机(go-harvest)文档助手。请根据知识库内容,给用户一个完整、可执行、细节充分的中文回答。
相关文档内容:
{context_text}
用户问题:{query}
硬性要求(非常重要):
1. 只能使用上方知识库内容回答。不要编造镜像名、环境变量、端口、命令、配置文件或链接。
2. 如果知识库里有代码块/YAML/env/bash 示例,涉及配置时必须原样引用或忠实摘录,不要自行重写完整配置,不要改写变量名。
3. 如果资料没有给出完整示例,不要自己补全完整示例;应说明“文档未给出完整示例”,再列出文档中已有的片段。
4. 对 Go Harvest 安装配置,若资料中出现 `newptools/go-harvest:latest`、`EMAIL`、`TOKEN`、`GO_WEB_PORT`、`POSTGRES_DB` 等,请保持这些原名,不要改成其他名字。
5. 不要把常识当成文档内容。可以说“文档没有说明”,不能猜。
回答质量要求:
1. 不要过度简略。优先给出完整操作步骤、配置位置、示例、注意事项和排错建议。
2. 如果问题是“怎么做/如何配置/怎么安装/报错怎么办”,请按以下结构回答:
✅ 简短结论
🧭 操作步骤
⚙️ 配置示例/关键参数(仅限资料中出现的内容)
⚠️ 注意事项
🧪 验证方式/排错方向
📚 相关文档
3. 如果知识库信息不足,要明确说“文档里没有直接说明”,再基于已有内容给出可确认的部分。
4. 回答长度建议 800~2500 字;复杂问题可以更长,但避免废话。
5. 使用清晰排版,可以使用 emoji、小标题、编号、代码块。
6. 最后列出相关文档链接,最多 5 个。"""
def build_context(docs: list[dict], max_chars: int = 12000) -> str:
"""构建给 LLM 的上下文,保留来源、标题、分数。"""
parts = []
used = 0
for i, d in enumerate(docs, 1):
text = d.get("text", "").strip()
if not text:
continue
block = (
f"【资料 {i}\n"
f"标题:{d.get('title', '')}\n"
f"来源:{d.get('source', '')}\n"
f"链接:{d.get('url', '')}\n"
f"相关度:{d.get('score', 0):.2f}\n"
f"内容:\n{text}\n"
)
if used + len(block) > max_chars and parts:
break
parts.append(block)
used += len(block)
return "\n---\n".join(parts)
def extract_verbatim_snippets(query: str, docs: list[dict], limit: int = 2, max_chars: int = 4200) -> str:
"""配置/安装类问题优先展示知识库原文代码块,避免 LLM 重写配置时幻觉。"""
q = query.lower()
if not any(k in q for k in ["compose", "docker", "postgres", "postgresql", "配置", "安装", "部署", "环境变量"]):
return ""
snippets = []
used = 0
need_postgres = any(k in q for k in ["postgres", "postgresql", "pgsql"])
for d in docs:
text = d.get("text", "")
text_lower = text.lower()
if "[代码块]" not in text:
continue
# PostgreSQL 问题优先只展示 PostgreSQL 相关代码块,避免混入 SQLite 示例
if need_postgres and "postgres" not in text_lower:
continue
# 优先 yaml/env/bash/text 配置块
if not any(mark in text_lower for mark in ["```yaml", "```env", "```bash", "```text"]):
continue
block = text.replace("[代码块]", "").strip()
rendered = (
f"📌 文档原文配置片段({d.get('title', '')}\n"
f"来源:{d.get('url', '')}\n\n"
f"{block}"
)
if used + len(rendered) > max_chars and snippets:
continue
snippets.append(rendered)
used += len(rendered)
if len(snippets) >= limit:
break
if not snippets:
return ""
return "\n\n".join(snippets)
def build_source_text(docs: list[dict], limit: int = 5) -> str:
seen = []
for d in docs:
url = d.get("url")
title = d.get("doc_title") or d.get("title") or url
if not url or url in [x[1] for x in seen]:
continue
seen.append((title, url))
if len(seen) >= limit:
break
return "\n".join([f"📎 {title}: {url}" for title, url in seen])
async def ask_knowledge_base(query: str) -> str:
"""知识库问答"""
cached = get_cached_answer(query)
if cached:
logger.info("📦 命中问答缓存")
return cached
# top_k 提高 + search 内部扩展相邻 chunk,让回答不再只基于碎片
relevant = kb_search(query, top_k=8, expand=True, context_window=1)
if not relevant:
return f"❌ 未找到相关内容,请换个关键词试试\n\n文档: {SITE_URL}"
context_text = build_context(relevant, max_chars=12000)
prompt = KNOWLEDGE_PROMPT.format(context_text=context_text, query=query)
try:
import time as _time
t0 = _time.time()
logger.info(f"🤖 调用 LLM 生成回答... (模型: {OLLAMA_MODEL}, 上下文: {len(context_text)}字, 文档块: {len(relevant)})")
async with httpx.AsyncClient(timeout=120, proxy=None) as client:
resp = await client.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {
"num_predict": 3000,
"temperature": 0.25,
"top_p": 0.9,
},
"think": False,
},
)
resp.raise_for_status()
answer = resp.json()["message"]["content"].strip()
elapsed = _time.time() - t0
logger.info(f"✅ LLM 回答完成 (耗时: {elapsed:.1f}s, 长度: {len(answer)}字)")
source_text = build_source_text(relevant, limit=5)
verbatim = extract_verbatim_snippets(query, relevant, limit=2)
body = f"{verbatim}\n\n---\n\n{answer}" if verbatim else answer
full_answer = f"{body}\n\n📚 相关文档:\n{source_text}" if source_text else body
set_cached_answer(query, full_answer)
return full_answer
except Exception as e:
logger.error(f"❌ 知识库问答失败: {e}")
return f"❌ 回答失败: {e}"
+25
View File
@@ -0,0 +1,25 @@
"""消息工具函数"""
from __future__ import annotations
import asyncio
import logging
import config
logger = logging.getLogger("spam_guard")
async def auto_delete(message, seconds: int | None = None) -> None:
"""延迟删除消息。删除失败不影响主流程。"""
if not message:
return
try:
await asyncio.sleep(seconds or config.AUTO_DELETE_SECONDS)
await message.delete()
except Exception as e:
logger.debug(f"自动删除消息失败: {e}")
def schedule_delete(message, seconds: int | None = None) -> None:
"""创建自动删除任务。"""
asyncio.create_task(auto_delete(message, seconds=seconds))
+53
View File
@@ -0,0 +1,53 @@
"""夜间模式调度服务"""
import logging
from datetime import datetime
logger = logging.getLogger("spam_guard")
async def enable_night_mode(chat) -> None:
"""开启夜间模式:全员禁言"""
from telegram import ChatPermissions
await chat.set_permissions(
ChatPermissions(
can_send_messages=False,
can_send_audios=False,
can_send_documents=False,
can_send_photos=False,
can_send_videos=False,
can_send_video_notes=False,
can_send_voice_notes=False,
can_send_polls=False,
can_send_other_messages=False,
can_add_web_page_previews=False,
can_invite_users=False,
can_change_info=False,
can_pin_messages=False,
can_manage_topics=False,
)
)
logger.info("🌙 夜间模式已开启")
async def disable_night_mode(chat) -> None:
"""关闭夜间模式:恢复发言"""
from telegram import ChatPermissions
await chat.set_permissions(
ChatPermissions(
can_send_messages=True,
can_send_audios=True,
can_send_documents=True,
can_send_photos=True,
can_send_videos=True,
can_send_video_notes=True,
can_send_voice_notes=True,
can_send_polls=True,
can_send_other_messages=True,
can_add_web_page_previews=True,
can_invite_users=True,
can_change_info=False,
can_pin_messages=False,
can_manage_topics=False,
)
)
logger.info("☀️ 夜间模式已关闭")
+123
View File
@@ -0,0 +1,123 @@
"""多层垃圾检测引擎 — 缓存 → 硬性正则 → 正常特征 → AI 兜底"""
import re
import logging
import httpx
import config
from knowledge import OLLAMA_URL, OLLAMA_MODEL
logger = logging.getLogger("spam_guard")
# ============ 硬性垃圾正则 ============
HARD_SPAM_PATTERNS = [
r"菠菜|赌博|博彩|赌场|棋牌|彩票|开奖|中奖|赔率|下注",
r"色情|约炮|外围|楼凤|一夜情|裸聊|成人视频",
r"刷单|传销|杀猪盘|资金盘|庞氏|传销盘",
r"(做USDT|U ?S ?D ?T|搬砖).{0,15}(看主页|看签名|进群|加我|找我)",
r"(看主页|看签名|加我|找我|进群).{0,15}(做USDT|U ?S ?D ?T|搬砖|赚钱|日入|月入)",
r"(一天|日入|月入|赚).{0,10}(万|w|W).{0,8}(项目|路子|渠道|带|做)",
r"(换|开|提).{0,6}(迈巴赫|宝马|奔驰|保时捷|法拉利|兰博基尼)",
r"(小白可带|手把手带|带你赚|带你做)",
r"(t\.me|telegram\.me).{0,30}(join|group|channel)",
]
# 软性信号(需 AI 二次判定)
SOFT_SPAM_SIGNALS = [
r"(免费领|免费送|限时领|点击领取|点击链接|复制链接)",
r"(加微信|wx|加我|私聊|私我|dm|PM)",
r"(赚钱|赚快钱|躺赚|副业|兼职日结)",
r"(开户|入金|出金|充值返|提现)",
r"(代理|加盟|推广返|分润|佣金)",
]
# 正常消息特征
NORMAL_SIGNALS = [
r"^.{0,8}$",
r"[?]",
r"(怎么|如何|为什么|能不能|请问|求|帮忙|谢谢|好的|收到|OK|ok|嗯|是的|不是|可以|不行|对|错|嗯嗯)",
r"(收割机|harvest|签到|站点|cookie|docker|安装|配置|更新|升级|bug|报错|失败|成功)",
r"^\d+$",
r"^[a-zA-Z0-9\s]{1,20}$",
]
SPAM_PROMPT = """判断以下消息是否为垃圾广告。同时检查消息内容和发送者信息。
消息内容: {text}
发送者用户名: {username}
发送者昵称: {first_name}
判断标准(满足任一即为垃圾):
1. 包含色情、赌博、诈骗、菠菜等违法推广内容
2. 包含明显广告推销(加群、加微信、免费领、点击链接等)
3. 纯转发的营销内容
请先给出结论,包含"垃圾""正常",再简短说明原因。"""
def normalize(text: str) -> str:
"""去除零宽字符、规范空白"""
t = re.sub(r'[\u200b\u200c\u200d\u2060\u200e\u200f\ufeff\u00a0]', '', text)
t = re.sub(r'\s+', ' ', t).strip()
return t
def match_patterns(text: str, patterns: list[str]) -> bool:
for pat in patterns:
if re.search(pat, text, re.IGNORECASE):
return True
return False
async def detect(text: str, username: str = "", first_name: str = "") -> tuple[bool, str]:
"""
多层垃圾检测。
返回 (is_spam, reason)
"""
norm = normalize(text)
# 第一层:硬性正则
if match_patterns(norm, HARD_SPAM_PATTERNS):
return True, "规则命中(硬性垃圾)"
# 短消息放行
if len(norm) < 20 and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "短消息跳过"
# 正常特征
if match_patterns(norm, NORMAL_SIGNALS) and not match_patterns(norm, SOFT_SPAM_SIGNALS):
return False, "正常消息特征匹配"
# AI 兜底
prompt = SPAM_PROMPT.format(text=text, username=username, first_name=first_name)
try:
async with httpx.AsyncClient(timeout=30, proxy=None) as client:
resp = await client.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_predict": 50, "temperature": 0.1},
"think": False,
},
)
resp.raise_for_status()
reply = resp.json()["message"]["content"]
logger.info(f"🤖 AI回复: {reply[:100]}")
reply_upper = reply.upper()
spam_words = ["垃圾", "广告", "诈骗", "菠菜", "赌博", "色情", "YES"]
normal_words = ["正常", "非垃圾", "不是垃圾", "NO"]
if any(w in reply for w in normal_words) or "NO" in reply_upper:
return False, f"AI判定: {reply}"
if any(w in reply for w in spam_words) or "YES" in reply_upper:
return True, f"AI判定: {reply}"
return False, f"AI判定不明确: {reply}"
except Exception as e:
logger.error(f"❌ Ollama 调用失败: {e}")
if match_patterns(norm, SOFT_SPAM_SIGNALS):
return True, "关键词兜底(AI不可用)"
return False, f"检测异常: {e}"
+45
View File
@@ -0,0 +1,45 @@
"""统计服务:统一 TG 命令和 Web API 的统计口径"""
from __future__ import annotations
from datetime import datetime, time, timedelta
from db.models import Action, Ban, Message
def day_range(day: str | None = None) -> tuple[str, datetime, datetime]:
"""返回 (YYYY-MM-DD, start, end)。day 为空则取今天。"""
if day:
target = datetime.strptime(day, "%Y-%m-%d").date()
else:
target = datetime.now().date()
start = datetime.combine(target, time.min)
end = start + timedelta(days=1)
return target.strftime("%Y-%m-%d"), start, end
async def get_daily_stats(day: str | None = None) -> dict:
"""获取单日统计。"""
day_str, start, end = day_range(day)
msg_count = await Message.filter(created_at__gte=start, created_at__lt=end).count()
spam_count = await Message.filter(created_at__gte=start, created_at__lt=end, is_spam=True).count()
ban_count = await Ban.filter(created_at__gte=start, created_at__lt=end).count()
total_bans = await Ban.all().count()
join_count = await Action.filter(action="JOIN", created_at__gte=start, created_at__lt=end).count()
leave_count = await Action.filter(action="LEAVE", created_at__gte=start, created_at__lt=end).count()
delete_count = await Action.filter(action="DELETE", created_at__gte=start, created_at__lt=end).count()
unban_count = await Action.filter(action="UNBAN", created_at__gte=start, created_at__lt=end).count()
return {
"date": day_str,
"messages": msg_count,
"spam_messages": spam_count,
"bans": ban_count,
"today_bans": ban_count,
"total_bans": total_bans,
"joins": join_count,
"leaves": leave_count,
"deleted": delete_count,
"unbans": unban_count,
"chat_count": msg_count,
}
+65
View File
@@ -0,0 +1,65 @@
"""入群验证服务"""
import random
import logging
from datetime import datetime
from db.models import VerifyQueue
logger = logging.getLogger("spam_guard")
# 验证题库
VERIFY_QUESTIONS = [
{"q": "收割机的英文名是什么?", "a": "harvest"},
{"q": "CookieCloud 的默认端口是?", "a": "80"},
{"q": "Docker 停止容器的命令是?", "a": "stop"},
{"q": "PT 站点的全称是?", "a": "private tracker"},
{"q": "收割机项目的作者是?", "a": "ngfchl"},
{"q": "收割机文档站在哪个域名?", "a": "ptools.fun"},
]
async def get_pending_verify(user_id: int) -> dict | None:
"""获取用户待验证题目。"""
vq = await VerifyQueue.filter(user_id=user_id, status="pending").order_by("-id").first()
if not vq:
return None
return {"id": vq.id, "question": vq.question}
async def create_verify(user_id: int, username: str = None) -> dict:
"""为新用户创建验证"""
existing = await get_pending_verify(user_id)
if existing:
return existing
q = random.choice(VERIFY_QUESTIONS)
vq = await VerifyQueue.create(
user_id=user_id,
username=username,
question=q["q"],
answer=q["a"],
status="pending",
)
logger.info(f"📝 验证已创建: user={user_id} q={q['q']}")
return {"id": vq.id, "question": q["q"]}
async def check_answer(user_id: int, answer: str) -> dict:
"""检查答案"""
vq = await VerifyQueue.filter(user_id=user_id, status="pending").order_by("-id").first()
if not vq:
return {"ok": False, "reason": "no_pending"}
vq.attempts += 1
if answer.strip().lower() == vq.answer.strip().lower():
vq.status = "verified"
vq.verified_at = datetime.now()
await vq.save()
return {"ok": True, "reason": "verified"}
else:
if vq.attempts >= 3:
vq.status = "failed"
await vq.save()
return {"ok": False, "reason": "max_attempts"}
await vq.save()
return {"ok": False, "reason": "wrong", "attempts": vq.attempts}
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🛡️ TG Spam Guard - 收割机</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
body { margin: 0; background: #f5f7fa; }
#app { padding: 20px; }
.header-card {
background: linear-gradient(135deg, #409eff, #6366f1);
border-radius: 12px;
padding: 24px 32px;
margin-bottom: 20px;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-card h1 { margin: 0; font-size: 22px; }
.header-card .status { display: flex; align-items: center; gap: 8px; font-size: 14px; opacity: 0.9; }
.stats-row { margin-bottom: 20px; }
.stat-card { text-align: center; padding: 20px; }
.stat-card .num { font-size: 32px; font-weight: bold; color: #409eff; }
.stat-card .label { font-size: 13px; color: #909399; margin-top: 6px; }
.stat-card.danger .num { color: #f56c6c; }
.stat-card.success .num { color: #67c23a; }
.stat-card.warning .num { color: #e6a23c; }
.chat-panel, .ban-panel { height: 520px; }
.chat-item {
padding: 10px 0;
border-bottom: 1px solid #ebeef5;
transition: background 0.2s;
}
.chat-item:hover { background: #f5f7fa; }
.chat-item .meta { display: flex; justify-content: space-between; margin-bottom: 4px; }
.chat-item .user { font-weight: 600; color: #409eff; font-size: 14px; }
.chat-item .time { font-size: 12px; color: #c0c4cc; }
.chat-item .text { font-size: 14px; color: #606266; line-height: 1.6; }
.ban-item {
padding: 12px 0;
border-bottom: 1px solid #ebeef5;
display: flex;
justify-content: space-between;
align-items: center;
}
.ban-info { flex: 1; }
.ban-info .user { font-weight: 600; color: #f56c6c; font-size: 14px; }
.ban-info .reason { font-size: 13px; color: #909399; margin-top: 3px; }
.ban-info .time { font-size: 12px; color: #c0c4cc; margin-top: 2px; }
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.panel-header h3 { margin: 0; font-size: 16px; color: #303133; }
.empty-text { text-align: center; padding: 60px 0; color: #c0c4cc; }
.empty-text .icon { font-size: 48px; margin-bottom: 10px; display: block; }
.sse-dot {
display: inline-block;
width: 8px; height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.sse-dot.on { background: #67c23a; }
.sse-dot.off { background: #f56c6c; }
</style>
</head>
<body>
<div id="app">
<!-- 头部 -->
<div class="header-card">
<h1>🛡️ TG Spam Guard</h1>
<div class="status">
<span class="sse-dot" :class="sseConnected ? 'on' : 'off'"></span>
{{ sseConnected ? '已连接' : '连接中...' }}
</div>
</div>
<!-- 统计 -->
<el-row class="stats-row" :gutter="20">
<el-col :span="6">
<el-card shadow="hover" class="stat-card">
<div class="num">{{ stats.today_bans }}</div>
<div class="label">今日封禁</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card danger">
<div class="num">{{ stats.total_bans }}</div>
<div class="label">累计封禁</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card warning">
<div class="num">{{ stats.active_bans }}</div>
<div class="label">当前封禁中</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="stat-card success">
<div class="num">{{ chatLogs.length }}</div>
<div class="label">聊天记录</div>
</el-card>
</el-col>
</el-row>
<!-- 主内容 -->
<el-row :gutter="20">
<!-- 聊天记录 -->
<el-col :span="12">
<el-card shadow="hover" class="chat-panel">
<div class="panel-header">
<h3>💬 实时聊天</h3>
<el-tag size="small" type="info">{{ chatLogs.length }} 条</el-tag>
</div>
<div style="overflow-y:auto; height:440px;" ref="chatScroll">
<div v-if="chatLogs.length === 0" class="empty-text">
<span class="icon">💬</span>等待消息...
</div>
<div v-for="(item, i) in chatLogs" :key="i" class="chat-item">
<div class="meta">
<span class="user">@{{ item.username }} ({{ item.user_id }})</span>
<span class="time">{{ formatTime(item.time) }}</span>
</div>
<div class="text">{{ item.text }}</div>
</div>
</div>
</el-card>
</el-col>
<!-- 封禁列表 -->
<el-col :span="12">
<el-card shadow="hover" class="ban-panel">
<div class="panel-header">
<h3>🚫 封禁列表</h3>
<el-button size="mini" icon="el-icon-refresh" @click="loadBans">刷新</el-button>
</div>
<div style="overflow-y:auto; height:440px;">
<div v-if="bans.length === 0" class="empty-text">
<span class="icon"></span>暂无封禁记录
</div>
<div v-for="(item, i) in bans" :key="i" class="ban-item">
<div class="ban-info">
<div class="user">@{{ item.username }} ({{ item.user_id }})</div>
<div class="reason">📝 {{ item.reason }}</div>
<div class="time">⏰ {{ formatTime(item.time) }}</div>
</div>
<el-button
v-if="item.auto_unban !== false"
size="small"
type="danger"
:loading="item._loading"
@click="unbanUser(item)"
>解封</el-button>
<el-tag v-else size="small" type="success">已解封</el-tag>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script>
new Vue({
el: '#app',
data() {
return {
sseConnected: false,
chatLogs: [],
bans: [],
stats: { today_bans: 0, total_bans: 0, active_bans: 0 },
}
},
mounted() {
this.loadChatHistory()
this.loadBans()
this.loadStats()
this.connectSSE()
setInterval(() => this.loadStats(), 30000)
},
methods: {
// SSE
connectSSE() {
if (this._es) this._es.close()
const es = new EventSource('/api/stream')
this._es = es
es.addEventListener('connected', () => {
this.sseConnected = true
})
es.addEventListener('chat', (e) => {
const data = JSON.parse(e.data)
this.chatLogs.unshift(data)
if (this.chatLogs.length > 100) this.chatLogs.pop()
})
es.addEventListener('ban', (e) => {
const data = JSON.parse(e.data)
data._loading = false
this.bans.unshift(data)
if (this.bans.length > 50) this.bans.pop()
this.loadStats()
this.$message({ type: 'error', message: `🚫 封禁 @${data.username} - ${data.reason}`, duration: 5000 })
})
es.addEventListener('unban', (e) => {
const data = JSON.parse(e.data)
const ban = this.bans.find(b => b.user_id === data.user_id)
if (ban) ban.auto_unban = false
this.loadStats()
this.$message({ type: 'success', message: `✅ 已解封 ${data.username || data.user_id}`, duration: 3000 })
})
es.addEventListener('heartbeat', () => {})
es.onerror = () => {
this.sseConnected = false
setTimeout(() => this.connectSSE(), 3000)
}
},
// API
async loadChatHistory() {
try {
const r = await fetch('/api/chat')
this.chatLogs = await r.json()
} catch (e) {}
},
async loadBans() {
try {
const r = await fetch('/api/bans')
const data = await r.json()
this.bans = data.map(b => ({ ...b, _loading: false }))
} catch (e) {}
},
async loadStats() {
try {
const r = await fetch('/api/stats')
this.stats = await r.json()
} catch (e) {}
},
async unbanUser(item) {
this.$set(item, '_loading', true)
try {
const r = await fetch(`/api/unban/${item.user_id}`, { method: 'POST' })
const data = await r.json()
if (data.ok) {
item.auto_unban = false
this.$message.success(`✅ 已解封 @${item.username}`)
this.loadStats()
} else {
this.$message.error(`❌ 解封失败: ${data.error}`)
}
} catch (e) {
this.$message.error('❌ 网络错误')
}
this.$set(item, '_loading', false)
},
formatTime(iso) {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
},
}
})
</script>
</body>
</html>
Generated
+2485
View File
File diff suppressed because it is too large Load Diff
View File
+126
View File
@@ -0,0 +1,126 @@
"""Web 管理面板 API"""
from __future__ import annotations
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import Any
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from db.models import Action, Ban, GameScore, Message
from services.stats_service import get_daily_stats
BASE_DIR = Path(__file__).parent
app = FastAPI(title="TG Spam Guard Manager")
app.mount("/static", StaticFiles(directory=str(BASE_DIR / "static")), name="static")
# SSE
_sse_queues: list[asyncio.Queue] = []
def json_safe(value: Any) -> Any:
"""将 ORM values() 结果转成 JSONResponse 可序列化对象。"""
if isinstance(value, datetime):
return value.isoformat(sep=" ", timespec="seconds")
if isinstance(value, list):
return [json_safe(v) for v in value]
if isinstance(value, dict):
return {k: json_safe(v) for k, v in value.items()}
return value
async def broadcast_sse(event: str, data: dict):
dead = []
payload = {"event": event, "data": json.dumps(data, ensure_ascii=False)}
for q in _sse_queues:
try:
await q.put(payload)
except Exception:
dead.append(q)
for q in dead:
if q in _sse_queues:
_sse_queues.remove(q)
@app.get("/", response_class=HTMLResponse)
async def index():
return HTMLResponse((BASE_DIR / "static" / "index.html").read_text(encoding="utf-8"))
@app.get("/health")
async def health():
return {"ok": True, "service": "tg-spam-guard", "time": datetime.now().isoformat(timespec="seconds")}
@app.get("/api/stats")
async def api_stats(day: str = Query(default=None, pattern=r"^\d{4}-\d{2}-\d{2}$")):
return JSONResponse(await get_daily_stats(day))
@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)))
@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)))
@app.get("/api/actions")
async def api_actions(limit: int = Query(default=100, ge=1, le=500)):
actions = await Action.all().order_by("-created_at").limit(limit).values()
return JSONResponse(json_safe(list(actions)))
@app.get("/api/scores")
async def api_scores(limit: int = Query(default=50, ge=1, le=200)):
scores = await GameScore.all().order_by("-score").limit(limit).values()
return JSONResponse(json_safe(list(scores)))
@app.post("/api/unban/{user_id}")
async def api_unban(user_id: int):
import config
try:
from telegram import Bot
bot = Bot(token=config.TG_BOT_TOKEN)
await bot.unban_chat_member(chat_id=config.TG_CHAT_ID, user_id=user_id)
await Ban.filter(user_id=user_id).update(auto_unban=False, unban_at=datetime.now())
await Action.create(action="UNBAN", user_id=user_id, details={"method": "web"})
await broadcast_sse("unban", {"user_id": user_id})
return JSONResponse({"ok": True, "user_id": user_id})
except Exception as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=500)
@app.get("/api/stream")
async def api_stream(request: Request):
from sse_starlette.sse import EventSourceResponse
queue: asyncio.Queue = asyncio.Queue()
_sse_queues.append(queue)
async def event_generator():
try:
while True:
if await request.is_disconnected():
break
try:
data = await asyncio.wait_for(queue.get(), timeout=30)
yield {"event": data["event"], "data": data["data"]}
except asyncio.TimeoutError:
yield {"event": "ping", "data": "ok"}
finally:
if queue in _sse_queues:
_sse_queues.remove(queue)
return EventSourceResponse(event_generator())
+45
View File
@@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🛡️ TG Spam Guard</title>
<link rel="stylesheet" href="https://unpkg.com/element-ui@2.15.14/lib/theme-chalk/index.css">
<style>
body { margin: 0; background: #f5f7fa; }
#app { padding: 20px; max-width: 1200px; margin: 0 auto; }
.header-card { background: linear-gradient(135deg, #409eff, #6366f1); border-radius: 12px; padding: 24px 32px; margin-bottom: 20px; color: white; }
.header-card h1 { margin: 0; font-size: 22px; }
.stat-card { text-align: center; padding: 20px; }
.stat-card .num { font-size: 32px; font-weight: bold; color: #409eff; }
.stat-card .label { font-size: 13px; color: #909399; margin-top: 6px; }
.stat-card.danger .num { color: #f56c6c; }
.stat-card.success .num { color: #67c23a; }
</style>
</head>
<body>
<div id="app">
<div class="header-card">
<h1>🛡️ TG Spam Guard v2</h1>
</div>
<el-row :gutter="20">
<el-col :span="6"><el-card shadow="hover" class="stat-card"><div class="num">{{ stats.messages || 0 }}</div><div class="label">💬 消息</div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover" class="stat-card"><div class="num">{{ stats.joins || 0 }}</div><div class="label">🆕 入群</div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover" class="stat-card"><div class="num">{{ stats.today_bans || 0 }}</div><div class="label">🔨 封禁</div></el-card></el-col>
<el-col :span="6"><el-card shadow="hover" class="stat-card danger"><div class="num">{{ stats.deleted || 0 }}</div><div class="label">🗑️ 删除</div></el-card></el-col>
</el-row>
</div>
<script src="https://unpkg.com/vue@2.7.16/dist/vue.min.js"></script>
<script src="https://unpkg.com/element-ui@2.15.14/lib/index.js"></script>
<script>
new Vue({
el: '#app',
data() { return { stats: {} } },
async mounted() {
const r = await fetch('/api/stats');
this.stats = await r.json();
}
});
</script>
</body>
</html>