feat: bind users via private email token verification

This commit is contained in:
ngfchl
2026-07-07 09:35:15 +08:00
parent afb01ee7b8
commit a538628728
4 changed files with 127 additions and 21 deletions
+2
View File
@@ -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}")
+2
View File
@@ -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)
+82 -20
View File
@@ -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)
+41 -1
View File
@@ -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_id5分钟过期)
_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