From a538628728a702126fd1c0b5699dc6c666777336 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Tue, 7 Jul 2026 09:35:15 +0800 Subject: [PATCH] feat: bind users via private email token verification --- db/__init__.py | 2 + db/models.py | 2 + handlers/bind.py | 102 +++++++++++++++++++++++++++++++-------- services/bind_service.py | 42 +++++++++++++++- 4 files changed, 127 insertions(+), 21 deletions(-) diff --git a/db/__init__.py b/db/__init__.py index 60abff7..7668df9 100644 --- a/db/__init__.py +++ b/db/__init__.py @@ -31,6 +31,8 @@ async def ensure_runtime_schema(): ALTER TABLE messages ADD COLUMN IF NOT EXISTS manually_deleted BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ NULL; ALTER TABLE messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255) NULL; + ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS auth_email VARCHAR(255) NULL; + ALTER TABLE user_bindings ADD COLUMN IF NOT EXISTS time_expire VARCHAR(100) NULL; """) except Exception as e: logger.warning(f"⚠️ 自动补齐数据库字段失败: {type(e).__name__}: {e}") diff --git a/db/models.py b/db/models.py index dea0f6b..ec8ca8b 100644 --- a/db/models.py +++ b/db/models.py @@ -83,6 +83,8 @@ class UserBinding(TimestampMixin, Model): 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) + auth_email = fields.CharField(max_length=255, null=True) + time_expire = fields.CharField(max_length=100, null=True) # 兼容旧代码/展示语义:绑定时间;created_at 仍作为统一审计字段 bound_at = fields.DatetimeField(auto_now_add=True) diff --git a/handlers/bind.py b/handlers/bind.py index 44c9738..e049278 100644 --- a/handlers/bind.py +++ b/handlers/bind.py @@ -1,42 +1,102 @@ """账号绑定 handler""" import asyncio import logging +import re -from telegram import Update -from telegram.ext import ContextTypes, CommandHandler, filters +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters from db.models import Action -from services.bind_service import generate_bind_token, unbind, get_binding +from services.bind_service import bind_with_email_token, unbind, get_binding logger = logging.getLogger("spam_guard") +EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE): - """绑定 harvest 账号""" + """绑定授权账号。群内引导到私聊;私聊进入等待邮箱+Token 状态。""" user = update.effective_user - - # 检查是否已绑定 existing = await get_binding(user.id) if existing: + text = ( + "🔗 你已绑定授权账号\n" + f"📧 邮箱: {existing.get('email') or existing.get('harvest_uid') or '-'}\n" + f"⏳ 到期: {existing.get('time_expire') or '-'}\n" + f"📅 绑定时间: {existing.get('bound_at')}\n\n" + "如需重新绑定,先 /unbind" + ) + reply = await update.message.reply_text(text) + asyncio.create_task(auto_delete(update.message, seconds=60)) + asyncio.create_task(auto_delete(reply, seconds=60)) + return + + if update.effective_chat.type != "private": + bot_username = context.bot.username or (await context.bot.get_me()).username + url = f"https://t.me/{bot_username}?start=bind" + keyboard = InlineKeyboardMarkup([[InlineKeyboardButton("去私聊绑定", url=url)]]) reply = await update.message.reply_text( - f"🔗 你已绑定 harvest 账号\n" - f"📦 UID: {existing['harvest_uid']}\n" - f"📅 绑定时间: {existing['bound_at']}\n\n" - f"如需重新绑定,先 /unbind" + "🔐 为了保护 Token,请点击按钮到私聊完成绑定。", + reply_markup=keyboard, ) asyncio.create_task(auto_delete(update.message, seconds=60)) asyncio.create_task(auto_delete(reply, seconds=60)) 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 分钟内有效" + context.user_data["awaiting_bind"] = True + await update.message.reply_text( + "🔐 授权绑定\n" + "请发送邮箱和 Token,支持空格或换行分隔:\n\n" + "邮箱 Token\n\n" + "例如:user@example.com xxxxx" ) - asyncio.create_task(auto_delete(update.message, seconds=60)) - asyncio.create_task(auto_delete(reply, seconds=60)) + + +async def cmd_start(update: Update, context: ContextTypes.DEFAULT_TYPE): + if update.effective_chat.type == "private" and context.args and context.args[0] == "bind": + await cmd_bind(update, context) + + +async def handle_private_bind_text(update: Update, context: ContextTypes.DEFAULT_TYPE): + if update.effective_chat.type != "private" or not context.user_data.get("awaiting_bind"): + return + + text = (update.message.text or "").strip() + parts = text.replace("\n", " ").split() + if len(parts) < 2: + await update.message.reply_text("❌ 格式不对,请发送:邮箱 Token") + return + + email, token = parts[0].strip(), parts[1].strip() + if not EMAIL_RE.match(email): + await update.message.reply_text("❌ 邮箱格式不正确,请重新发送:邮箱 Token") + return + + processing = await update.message.reply_text("⏳ 正在验证授权信息...") + ok, result = await bind_with_email_token( + tg_user_id=update.effective_user.id, + tg_username=update.effective_user.username or update.effective_user.first_name, + email=email, + token=token, + ) + context.user_data["awaiting_bind"] = False + + try: + await processing.delete() + except Exception: + pass + + if ok: + data = result.get("data") or {} + await Action.create(action="BIND", user_id=update.effective_user.id, username=update.effective_user.username, details={"email": data.get("email")}) + await update.message.reply_text( + "✅ 绑定成功\n" + f"📧 邮箱: {data.get('email') or email}\n" + f"⏳ 到期: {data.get('time_expire') or '-'}" + ) + else: + context.user_data["awaiting_bind"] = True + await update.message.reply_text(f"❌ 验证失败:{result.get('msg') or result.get('reason') or '授权信息无效'}\n请重新发送:邮箱 Token") async def cmd_unbind(update: Update, context: ContextTypes.DEFAULT_TYPE): @@ -61,5 +121,7 @@ async def auto_delete(message, seconds=60): def register_bind(app): - app.add_handler(CommandHandler("bind", cmd_bind, filters.ChatType.GROUPS)) - app.add_handler(CommandHandler("unbind", cmd_unbind, filters.ChatType.GROUPS)) + app.add_handler(CommandHandler("start", cmd_start, filters.ChatType.PRIVATE)) + app.add_handler(CommandHandler("bind", cmd_bind)) + app.add_handler(CommandHandler("unbind", cmd_unbind)) + app.add_handler(MessageHandler(filters.TEXT & filters.ChatType.PRIVATE & ~filters.COMMAND, handle_private_bind_text), group=0) diff --git a/services/bind_service.py b/services/bind_service.py index 28c6dac..b4e06c6 100644 --- a/services/bind_service.py +++ b/services/bind_service.py @@ -8,6 +8,7 @@ import config from db.models import UserBinding logger = logging.getLogger("spam_guard") +VERIFY_URL = "https://repeat.ptools.fun/api/user/users/verify" # 临时绑定 token 存储(token -> user_id,5分钟过期) _pending_bindings: dict[str, dict] = {} @@ -64,6 +65,45 @@ async def verify_binding(token: str) -> dict: return {"ok": True, "harvest_uid": harvest_uid} +async def bind_with_email_token(tg_user_id: int, tg_username: str, email: str, token: str) -> tuple[bool, dict]: + """调用授权接口验证邮箱+Token,成功后绑定 TG 用户。""" + try: + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post(VERIFY_URL, json={"email": email, "token": token}) + data = resp.json() + except Exception as e: + logger.warning(f"🔗 授权验证请求失败: {type(e).__name__}: {e}") + return False, {"reason": "验证服务暂时不可用"} + + if not data.get("succeed"): + return False, data + + auth = data.get("data") or {} + auth_email = auth.get("email") or email + time_expire = auth.get("time_expire") + + binding, created = await UserBinding.get_or_create( + tg_user_id=tg_user_id, + defaults={ + "tg_username": tg_username, + "harvest_uid": auth_email, + "harvest_token": token, + "auth_email": auth_email, + "time_expire": time_expire, + }, + ) + if not created: + binding.tg_username = tg_username + binding.harvest_uid = auth_email + binding.harvest_token = token + binding.auth_email = auth_email + binding.time_expire = time_expire + await binding.save() + + logger.info(f"🔗 授权绑定成功: TG={tg_user_id} email={auth_email}") + return True, data + + async def unbind(tg_user_id: int) -> bool: """解绑""" deleted = await UserBinding.filter(tg_user_id=tg_user_id).delete() @@ -76,5 +116,5 @@ 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 {"harvest_uid": b.harvest_uid, "email": b.auth_email or b.harvest_uid, "time_expire": b.time_expire, "bound_at": b.bound_at} return None