diff --git a/README.md b/README.md index 23c8122..8f8c44a 100644 --- a/README.md +++ b/README.md @@ -38,8 +38,9 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 - 支持关键词过滤:只转发包含特定关键词的消息,留空则转发全部。 - 支持媒体类型过滤:可选视频、文档、图片、音频。 - 支持 SOCKS5/HTTP 代理,适合国内服务器。 +- 新增 Telegram 二维码登录:设置页填写 `TG_API_ID` / `TG_API_HASH` 后,可扫码生成并保存用户会话。 - 内置下载到服务器、断点续传、并发下载等功能,后续可通过配置开启。 -- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`、`TG_API_SESSION` 后使用。 +- 仍兼容手动填写 `TG_API_SESSION`。 ### 2026-05-22 更新 @@ -126,7 +127,7 @@ tg-watchbot 是一个轻量级 Python 服务,把 **Telegram 双向客服机器 - 支持关键词过滤、媒体类型选择(视频/文档/图片/音频)、文件大小限制。 - 支持实时转发模式:群消息匹配后直接转发到你的 Telegram(含视频/文档原文),无需下载到服务器。 - 下载完成可自动推送 Telegram 通知给管理员。 -- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`、`TG_API_SESSION`。 +- 需要在设置页填写 `TG_API_ID`、`TG_API_HASH`,然后扫码登录;也兼容手动填写 `TG_API_SESSION`。 ### Web 管理面板 diff --git a/app.py b/app.py index 0e1cb2c..80d35cc 100644 --- a/app.py +++ b/app.py @@ -10,8 +10,10 @@ from __future__ import annotations import argparse import asyncio +import base64 import hashlib import html +import io import logging import os import re @@ -32,6 +34,7 @@ import os.path as ospath import feedparser import httpx import yaml +import qrcode from apscheduler.schedulers.asyncio import AsyncIOScheduler from bs4 import BeautifulSoup from dotenv import load_dotenv @@ -81,6 +84,7 @@ scheduler_ref: AsyncIOScheduler | None = None user_session_listener_task: asyncio.Task | None = None user_session_client: Any = None channel_media_clients: dict[str, Any] = {} +telegram_qr_logins: dict[str, dict[str, Any]] = {} GROUP_SUMMARY_MAX_CHARS = 800 @@ -225,6 +229,19 @@ def init_db() -> None: meta_value TEXT, updated_at TEXT NOT NULL ); + CREATE TABLE IF NOT EXISTS telegram_login_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_name TEXT NOT NULL DEFAULT 'default', + api_id TEXT NOT NULL DEFAULT '', + api_hash TEXT NOT NULL DEFAULT '', + tg_session TEXT NOT NULL DEFAULT '', + phone TEXT DEFAULT '', + username TEXT DEFAULT '', + user_id TEXT DEFAULT '', + status TEXT DEFAULT 'empty', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS discovered_group_chats ( chat_id INTEGER PRIMARY KEY, title TEXT, @@ -281,6 +298,10 @@ def init_db() -> None: "ALTER TABLE channel_media_monitors ADD COLUMN max_concurrent INTEGER DEFAULT 3", "ALTER TABLE channel_media_monitors ADD COLUMN forward_mode INTEGER DEFAULT 0", "ALTER TABLE channel_media_monitors ADD COLUMN forward_to TEXT DEFAULT 'admin'", + "ALTER TABLE telegram_login_sessions ADD COLUMN username TEXT DEFAULT ''", + "ALTER TABLE telegram_login_sessions ADD COLUMN user_id TEXT DEFAULT ''", + "ALTER TABLE telegram_login_sessions ADD COLUMN phone TEXT DEFAULT ''", + "ALTER TABLE telegram_login_sessions ADD COLUMN status TEXT DEFAULT 'empty'", ]: try: conn.execute(sql) @@ -568,6 +589,14 @@ def user_session_config() -> tuple[str, str, str]: api_id = os.getenv("TG_API_ID", "").strip() api_hash = os.getenv("TG_API_HASH", "").strip() session = os.getenv("TG_API_SESSION", "").strip() + with closing(db()) as conn: + row = conn.execute( + "SELECT api_id, api_hash, tg_session FROM telegram_login_sessions WHERE session_name='default' ORDER BY id DESC LIMIT 1" + ).fetchone() + if row: + api_id = str(row["api_id"] or api_id).strip() + api_hash = str(row["api_hash"] or api_hash).strip() + session = str(row["tg_session"] or session).strip() return api_id, api_hash, session @@ -584,6 +613,91 @@ def user_session_ready() -> bool: return True +def telegram_login_status_row() -> dict[str, str]: + with closing(db()) as conn: + row = conn.execute( + "SELECT * FROM telegram_login_sessions WHERE session_name='default' ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return {"status": "empty", "username": "", "phone": "", "user_id": ""} + return {k: str(row[k] or "") for k in row.keys()} + + +def save_telegram_login_session(api_id: str, api_hash: str, tg_session: str, phone: str = "", username: str = "", user_id: str = "") -> None: + ts = now_iso() + with closing(db()) as conn: + conn.execute( + "DELETE FROM telegram_login_sessions WHERE session_name='default'" + ) + conn.execute( + """INSERT INTO telegram_login_sessions(session_name, api_id, api_hash, tg_session, phone, username, user_id, status, created_at, updated_at) + VALUES('default',?,?,?,?,?,?, 'authorized', ?, ?)""", + (api_id, api_hash, tg_session, phone, username, user_id, ts, ts), + ) + conn.commit() + + +def clear_telegram_login_session() -> None: + with closing(db()) as conn: + conn.execute("DELETE FROM telegram_login_sessions WHERE session_name='default'") + conn.commit() + + +async def telegram_login_prepare_qr() -> dict[str, Any]: + if TelegramClient is None or StringSession is None: + return {"ok": False, "error": "telethon not installed"} + api_id = os.getenv("TG_API_ID", "").strip() + api_hash = os.getenv("TG_API_HASH", "").strip() + if not api_id or not api_hash: + return {"ok": False, "error": "missing TG_API_ID / TG_API_HASH"} + try: + api_id_int = int(api_id) + except Exception: + return {"ok": False, "error": "TG_API_ID must be int"} + client = TelegramClient(StringSession(), api_id_int, api_hash) + try: + await client.connect() + qr_login = await client.qr_login() + qr_svg = qrcode.make(qr_login.url) + buffer = io.BytesIO() + qr_svg.save(buffer, format="PNG") + qr_b64 = base64.b64encode(buffer.getvalue()).decode("ascii") + return { + "ok": True, + "url": qr_login.url, + "qr_png": f"data:image/png;base64,{qr_b64}", + "client": client, + "login": qr_login, + } + except Exception as e: + try: + await client.disconnect() + except Exception: + pass + return {"ok": False, "error": str(e)} + + +async def telegram_login_complete(client: Any, login: Any) -> dict[str, Any]: + await login.wait() + session_str = client.session.save() if hasattr(client.session, "save") else "" + me = await client.get_me() + save_telegram_login_session( + os.getenv("TG_API_ID", "").strip(), + os.getenv("TG_API_HASH", "").strip(), + session_str, + phone=str(getattr(me, "phone", "") or ""), + username=str(getattr(me, "username", "") or ""), + user_id=str(getattr(me, "id", "") or ""), + ) + await client.disconnect() + return { + "ok": True, + "username": str(getattr(me, "username", "") or ""), + "phone": str(getattr(me, "phone", "") or ""), + "user_id": str(getattr(me, "id", "") or ""), + } + + # ---- Channel Media Download (Telethon user session) ---- def get_or_create_channel_media_client(client_type: str = "channel_media", proxy: str = "") -> Any: @@ -2824,6 +2938,9 @@ th{{color:var(--ink);font-size:12px;background:var(--yellow);text-transform:uppe tr:nth-child(even) td{{background:#fafafa}} .badge{{padding:4px 8px;border:3px solid var(--ink);border-radius:999px;background:var(--blue);color:white;font-size:12px;font-weight:900;text-transform:uppercase}} .msg{{padding:11px 12px;border:3px solid var(--ink);background:var(--yellow);color:var(--ink);margin:10px 0;font-weight:900;box-shadow:4px 4px 0 var(--ink)}} +.step{{border:3px solid var(--ink);background:#fff;padding:14px;margin:14px 0;box-shadow:4px 4px 0 var(--ink)}} +.step-title{{display:flex;align-items:center;gap:10px;margin:0 0 10px;font-size:18px;font-weight:900}} +.step-no{{display:inline-grid;place-items:center;width:30px;height:30px;border:3px solid var(--ink);background:var(--yellow);font-weight:900}} pre{{white-space:pre-wrap;background:#121212;color:#fff;padding:13px;border:4px solid var(--ink);max-height:420px;overflow:auto;box-shadow:5px 5px 0 var(--yellow)}} .friend-links{{margin-top:18px;padding-top:12px;border-top:3px solid var(--ink);display:flex;gap:8px;align-items:center;flex-wrap:wrap}} .friend-links b{{font-size:12px;font-weight:900;text-transform:uppercase}} @@ -2842,7 +2959,7 @@ pre{{white-space:pre-wrap;background:#121212;color:#fff;padding:13px;border:4px @media (prefers-reduced-motion: reduce){{ *,*::before,*::after{{animation:none!important;transition:none!important}} }} -