feat: rebuild tg spam guard v2 architecture
This commit is contained in:
@@ -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/
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
+31
@@ -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"]
|
||||
@@ -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` 存在,轻量检索即可工作。
|
||||
@@ -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 7:Web 面板升级
|
||||
- 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
|
||||
@@ -0,0 +1,3 @@
|
||||
[aerich]
|
||||
tortoise_orm = "db.__init__.TORTOISE_ORM"
|
||||
location = "./migrations"
|
||||
+14
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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"))
|
||||
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
@@ -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) # 最后注册,作为兜底
|
||||
@@ -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))
|
||||
@@ -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
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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 - 查看缓存")
|
||||
@@ -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 - 统计信息")
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
# 仅用于宿主机/构建机重建知识库向量索引,不进入 Bot 运行镜像
|
||||
sentence-transformers>=3.0.0
|
||||
numpy>=1.26.0
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_id,5分钟过期)
|
||||
_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
|
||||
@@ -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}
|
||||
@@ -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}"
|
||||
@@ -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))
|
||||
@@ -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("☀️ 夜间模式已关闭")
|
||||
@@ -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}"
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
+126
@@ -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())
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user