Files
gongyichuren b979e44ac0 feat: 图片验证码人机验证 + 私聊自动回复 + 静默拉黑
- 新用户首条私聊消息需先通过 4 位图片验证码(PIL 本地生成,无新依赖)
- 防爆破:答错一次旧码立即作废并自动发新图,验证阶段同样受限流约束
- 验证码 10 分钟过期(bot.verification.expires_minutes 可配)
- 自动回复:管理员忙碌时自动答复私聊用户,按用户去重(bot.auto_reply)
- 拉黑用户完全静默:不回复、不通知管理员,仅面板/收件箱留痕
- Web 面板 /rules 三卡片管理 + /users 页人工验证/撤销按钮
- 管理命令:/verify /unverify /verifylist
- 离线 E2E 测试 19 项全过(tests/test_captcha_flow.py)
2026-09-21 08:06:57 +00:00

93 lines
3.2 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Text CAPTCHA generator: PIL-drawn distorted text captcha -> JPEG bytes.
Used by tg-watchbot human verification for new users before relaying messages.
"""
from __future__ import annotations
import io
import random
import string
from PIL import Image, ImageDraw, ImageFilter, ImageFont
ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" # no I/1/O/0 ambiguities
FONT_CANDIDATES = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
]
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont | None:
for path in FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except Exception:
continue
try:
return ImageFont.load_default()
except Exception:
return None
def generate_captcha_text(length: int = 4) -> str:
return "".join(random.SystemRandom().choice(ALPHABET) for _ in range(length))
def render_captcha_image(text: str, width: int = 220, height: int = 90) -> bytes:
"""Render distorted text captcha; returns JPEG bytes (~small, fine to send in TG)."""
bg = (245, 245, 245)
img = Image.new("RGB", (width, height), bg)
draw = ImageDraw.Draw(img)
# background noise curves
for _ in range(6):
x0 = random.randint(0, width // 4)
y0 = random.randint(0, height)
x1 = random.randint(width // 2, width)
y1 = random.randint(0, height)
if y1 < y0:
y0, y1 = y1, y0
color = tuple(random.randint(120, 200) for _ in range(3))
draw.arc([x0, y0, x1, y1], start=random.randint(0, 180), end=random.randint(180, 360), fill=color, width=2)
font = _load_font(48)
char_count = len(text)
slot = width // max(char_count, 1)
for i, ch in enumerate(text):
fsize = random.randint(40, 54)
f = _load_font(fsize)
f = f or font
color = tuple(random.randint(20, 110) for _ in range(3))
# per-char vertical wobble
x = i * slot + random.randint(2, max(4, slot // 6))
y = (height - fsize) // 2 + random.randint(-8, 8)
# per-char rotation
rotation = random.randint(-30, 30)
try:
char_img = Image.new("RGBA", (fsize + 16, fsize + 16), (0, 0, 0, 0))
cdraw = ImageDraw.Draw(char_img)
cdraw.text((8, 4), ch, font=f, fill=color)
char_img = char_img.rotate(rotation, resample=Image.Resampling.BICUBIC, expand=False)
img.paste(char_img, (x, max(0, y - 8)), char_img)
except Exception:
draw.text((x, max(0, y)), ch, font=f, fill=color)
# slight blur + noise dots
img = img.filter(ImageFilter.GaussianBlur(0.6))
for _ in range(60):
xy = (random.randint(0, width - 1), random.randint(0, height - 1))
draw.point(xy, fill=tuple(random.randint(80, 200) for _ in range(3)))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=82)
return buf.getvalue()
if __name__ == "__main__":
t = generate_captcha_text()
data = render_captcha_image(t)
print(f"captcha text={t} bytes={len(data)}")
with open("/tmp/captcha-sample.jpg", "wb") as f:
f.write(data)