feat: bind users via private email token verification
This commit is contained in:
@@ -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 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 deleted_at TIMESTAMPTZ NULL;
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS delete_reason VARCHAR(255) 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:
|
except Exception as e:
|
||||||
logger.warning(f"⚠️ 自动补齐数据库字段失败: {type(e).__name__}: {e}")
|
logger.warning(f"⚠️ 自动补齐数据库字段失败: {type(e).__name__}: {e}")
|
||||||
|
|||||||
@@ -83,6 +83,8 @@ class UserBinding(TimestampMixin, Model):
|
|||||||
tg_username = fields.CharField(max_length=255, null=True)
|
tg_username = fields.CharField(max_length=255, null=True)
|
||||||
harvest_uid = 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)
|
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 仍作为统一审计字段
|
# 兼容旧代码/展示语义:绑定时间;created_at 仍作为统一审计字段
|
||||||
bound_at = fields.DatetimeField(auto_now_add=True)
|
bound_at = fields.DatetimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|||||||
+82
-20
@@ -1,42 +1,102 @@
|
|||||||
"""账号绑定 handler"""
|
"""账号绑定 handler"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from telegram import Update
|
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
||||||
from telegram.ext import ContextTypes, CommandHandler, filters
|
from telegram.ext import ContextTypes, CommandHandler, MessageHandler, filters
|
||||||
|
|
||||||
from db.models import Action
|
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")
|
logger = logging.getLogger("spam_guard")
|
||||||
|
|
||||||
|
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
|
||||||
|
|
||||||
|
|
||||||
async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
async def cmd_bind(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||||
"""绑定 harvest 账号"""
|
"""绑定授权账号。群内引导到私聊;私聊进入等待邮箱+Token 状态。"""
|
||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
|
|
||||||
# 检查是否已绑定
|
|
||||||
existing = await get_binding(user.id)
|
existing = await get_binding(user.id)
|
||||||
if existing:
|
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(
|
reply = await update.message.reply_text(
|
||||||
f"🔗 你已绑定 harvest 账号\n"
|
"🔐 为了保护 Token,请点击按钮到私聊完成绑定。",
|
||||||
f"📦 UID: {existing['harvest_uid']}\n"
|
reply_markup=keyboard,
|
||||||
f"📅 绑定时间: {existing['bound_at']}\n\n"
|
|
||||||
f"如需重新绑定,先 /unbind"
|
|
||||||
)
|
)
|
||||||
asyncio.create_task(auto_delete(update.message, seconds=60))
|
asyncio.create_task(auto_delete(update.message, seconds=60))
|
||||||
asyncio.create_task(auto_delete(reply, seconds=60))
|
asyncio.create_task(auto_delete(reply, seconds=60))
|
||||||
return
|
return
|
||||||
|
|
||||||
token, url = generate_bind_token(user.id, user.username or user.first_name)
|
context.user_data["awaiting_bind"] = True
|
||||||
reply = await update.message.reply_text(
|
await update.message.reply_text(
|
||||||
"🔗 账号绑定\n"
|
"🔐 授权绑定\n"
|
||||||
"━━━━━━━━━━━━━━━━━━━━\n\n"
|
"请发送邮箱和 Token,支持空格或换行分隔:\n\n"
|
||||||
f"请点击以下链接完成绑定:\n{url}\n\n"
|
"邮箱 Token\n\n"
|
||||||
"⏳ 链接 5 分钟内有效"
|
"例如: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):
|
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):
|
def register_bind(app):
|
||||||
app.add_handler(CommandHandler("bind", cmd_bind, filters.ChatType.GROUPS))
|
app.add_handler(CommandHandler("start", cmd_start, filters.ChatType.PRIVATE))
|
||||||
app.add_handler(CommandHandler("unbind", cmd_unbind, filters.ChatType.GROUPS))
|
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)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import config
|
|||||||
from db.models import UserBinding
|
from db.models import UserBinding
|
||||||
|
|
||||||
logger = logging.getLogger("spam_guard")
|
logger = logging.getLogger("spam_guard")
|
||||||
|
VERIFY_URL = "https://repeat.ptools.fun/api/user/users/verify"
|
||||||
|
|
||||||
# 临时绑定 token 存储(token -> user_id,5分钟过期)
|
# 临时绑定 token 存储(token -> user_id,5分钟过期)
|
||||||
_pending_bindings: dict[str, dict] = {}
|
_pending_bindings: dict[str, dict] = {}
|
||||||
@@ -64,6 +65,45 @@ async def verify_binding(token: str) -> dict:
|
|||||||
return {"ok": True, "harvest_uid": harvest_uid}
|
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:
|
async def unbind(tg_user_id: int) -> bool:
|
||||||
"""解绑"""
|
"""解绑"""
|
||||||
deleted = await UserBinding.filter(tg_user_id=tg_user_id).delete()
|
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()
|
b = await UserBinding.filter(tg_user_id=tg_user_id).first()
|
||||||
if b:
|
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
|
return None
|
||||||
|
|||||||
Reference in New Issue
Block a user