Upload 2 files
Browse files- app.py +608 -113
- webapp.html +82 -17
app.py
CHANGED
|
@@ -20,6 +20,7 @@ import requests
|
|
| 20 |
from collections import defaultdict
|
| 21 |
from datetime import datetime, timezone, timedelta
|
| 22 |
from threading import Thread, Lock, Timer
|
|
|
|
| 23 |
from urllib.parse import parse_qs, unquote
|
| 24 |
from functools import wraps
|
| 25 |
|
|
@@ -34,7 +35,7 @@ from telebot import types, apihelper
|
|
| 34 |
from telebot.apihelper import ApiTelegramException
|
| 35 |
from flask import Flask, request, jsonify, send_file, Response
|
| 36 |
from webdav4.client import Client as WebDAVClient
|
| 37 |
-
from telethon import TelegramClient
|
| 38 |
from telethon.sessions import StringSession
|
| 39 |
from telethon.extensions import html as tl_html
|
| 40 |
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
@@ -52,7 +53,7 @@ DAV_SUB_PATH = os.environ.get("WEBDAV_PATH", "").strip("/")
|
|
| 52 |
FULL_WEBDAV_URL = f"{DAV_URL_BASE}/{DAV_SUB_PATH}/" if DAV_SUB_PATH else f"{DAV_URL_BASE}/"
|
| 53 |
REMOTE_FILENAME = "tg_bot_data_v5.json"
|
| 54 |
|
| 55 |
-
DATA = {"users": {}, "msg_map": {}, "backup_log": {}, "list_cache": {}, "admin_ids": [], "events": {}, "profiles": {}, "ch_meta": {}, "userbot": {}}
|
| 56 |
data_lock = Lock()
|
| 57 |
|
| 58 |
HTML_CACHE = {}
|
|
@@ -142,7 +143,10 @@ def load_data():
|
|
| 142 |
DATA["events"] = loaded_data.get("events", {}) # 🆕 持久化的事件流(每人最多 50 条)
|
| 143 |
DATA["profiles"] = loaded_data.get("profiles", {}) # 🆕 白名单成员的名字/头像缓存
|
| 144 |
DATA["ch_meta"] = loaded_data.get("ch_meta", {}) # 🆕 频道名称/头像缓存
|
|
|
|
|
|
|
| 145 |
DATA["userbot"] = loaded_data.get("userbot", {}) # 🆕 /login 登录得到的 Session
|
|
|
|
| 146 |
print(f"✅ 成功下载恢复云端数据!映射条数: {len(DATA['msg_map'])}")
|
| 147 |
else:
|
| 148 |
save_data(force=True)
|
|
@@ -564,6 +568,84 @@ def _avatar_key(raw):
|
|
| 564 |
def _avatar_path(key):
|
| 565 |
return os.path.join(AVATAR_DIR, f"{_avatar_key(key)}.jpg")
|
| 566 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
def _tl_run(coro, timeout=30):
|
| 568 |
"""在 Telethon 的事件循环里跑一个协程(同步线程调用用)"""
|
| 569 |
if not TL_LOOP or not TL_CLIENT: return None
|
|
@@ -596,14 +678,55 @@ def _download_tg_file(file_path, tries=3, timeout=25):
|
|
| 596 |
time.sleep(0.6 * (i + 1))
|
| 597 |
raise RuntimeError(last)
|
| 598 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
def _save_avatar_from_bot(key, photo):
|
| 600 |
"""用 Bot API 下载头像;头像换了 file_unique_id 会变,据此判断要不要重新下载"""
|
| 601 |
-
fid = getattr(photo, '
|
| 602 |
-
uniq =
|
| 603 |
if not fid: return ""
|
| 604 |
f = bot.get_file(fid)
|
| 605 |
raw = _download_tg_file(f.file_path, tries=2, timeout=15)
|
| 606 |
with open(_avatar_path(key), 'wb') as fh: fh.write(raw)
|
|
|
|
| 607 |
# 版本号必须只跟「图片内容」有关:用时间戳会让 URL 每次都变,浏览器缓存直接失效
|
| 608 |
return uniq or hashlib.md5(raw).hexdigest()[:12]
|
| 609 |
|
|
@@ -614,14 +737,19 @@ def _save_avatar_from_tl(key, entity):
|
|
| 614 |
except Exception: return ""
|
| 615 |
if not data: return ""
|
| 616 |
with open(_avatar_path(key), 'wb') as fh: fh.write(data)
|
|
|
|
| 617 |
return hashlib.md5(data).hexdigest()[:12]
|
| 618 |
|
| 619 |
-
def refresh_profile(uid, force=False, ttl=
|
| 620 |
"""🆕 缓存白名单成员的名字与头像,换头像后自动跟着变"""
|
| 621 |
uid = str(uid)
|
| 622 |
prof = DATA.setdefault("profiles", {}).setdefault(uid, {})
|
| 623 |
fresh = time.time() - float(prof.get("checked", 0) or 0) < ttl
|
| 624 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
prof["checked"] = int(time.time())
|
| 626 |
chat = None
|
| 627 |
try: chat = bot.get_chat(int(uid))
|
|
@@ -632,21 +760,23 @@ def refresh_profile(uid, force=False, ttl=1800):
|
|
| 632 |
prof[k] = getattr(chat, attr, "") or ""
|
| 633 |
photo = getattr(chat, "photo", None)
|
| 634 |
if photo:
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
|
|
|
|
|
|
|
|
|
| 638 |
got = _save_avatar_from_bot(uid, photo)
|
| 639 |
-
if got: prof["photo_uid"] = got
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
else:
|
| 646 |
prof["photo_uid"] = ""
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
elif not os.path.exists(_avatar_path(uid)):
|
| 650 |
got = _save_avatar_from_tl(uid, int(uid)) if TL_CLIENT else ""
|
| 651 |
if got: prof["photo_uid"] = got
|
| 652 |
prof["has_avatar"] = os.path.exists(_avatar_path(uid))
|
|
@@ -659,7 +789,8 @@ def refresh_profile_bg(uid, force=False):
|
|
| 659 |
"""🆕 头像/名字一律后台刷:接口不能卡在 Telegram 的往返上(慢就慢在这里)"""
|
| 660 |
uid = str(uid)
|
| 661 |
prof = DATA.get("profiles", {}).get(uid) or {}
|
| 662 |
-
|
|
|
|
| 663 |
if uid in _prof_busy: return
|
| 664 |
_prof_busy.add(uid)
|
| 665 |
def work():
|
|
@@ -677,12 +808,14 @@ def profile_view(uid):
|
|
| 677 |
if not p.get(k) and u.get(k): p[k] = u[k]
|
| 678 |
name = " ".join([x for x in (p.get("first_name"), p.get("last_name")) if x]).strip()
|
| 679 |
return {"id": uid, "name": name, "username": p.get("username", ""),
|
|
|
|
| 680 |
"avatar": (f"/api/avatar/{uid}?v={p.get('photo_uid') or '0'}"
|
| 681 |
-
if os.path.exists(_avatar_path(uid)) else ""),
|
| 682 |
"unknown": bool(p.get("err")) and not name}
|
| 683 |
|
| 684 |
-
def resolve_chat_info(raw):
|
| 685 |
-
"""🆕 认频道:给 ID / @用户名 / t.me 链接,返回真实名称、用户名、成员数和头像
|
|
|
|
| 686 |
ident = normalize_chat(raw)
|
| 687 |
if not ident: return {"ok": False, "msg": "请输入频道 ID、@用户名 或 t.me 链接"}
|
| 688 |
target = peer(ident)
|
|
@@ -701,13 +834,24 @@ def resolve_chat_info(raw):
|
|
| 701 |
except Exception: members = 0
|
| 702 |
except Exception: members = 0
|
| 703 |
photo = getattr(chat, "photo", None)
|
|
|
|
|
|
|
| 704 |
if photo:
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
try: photo_uid = (
|
| 709 |
-
except Exception
|
| 710 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
else:
|
| 712 |
# Bot 不在这个频道里,换 userbot 认
|
| 713 |
try:
|
|
@@ -721,11 +865,23 @@ def resolve_chat_info(raw):
|
|
| 721 |
raw_id = str(getattr(ent, "id", "") or "")
|
| 722 |
cid = raw_id if raw_id.startswith("-") else f"-100{raw_id}"
|
| 723 |
members = int(getattr(ent, "participants_count", 0) or 0)
|
| 724 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
key = "c" + (cid or ident)
|
| 726 |
return {"ok": True, "id": cid or ident, "title": title or ident, "username": username,
|
| 727 |
-
"members": members,
|
| 728 |
-
"avatar": f"/api/avatar/{_avatar_key(key)}?v={photo_uid or '0'}"
|
|
|
|
| 729 |
|
| 730 |
def refresh_channel_meta(cid, ttl=21600, force=False):
|
| 731 |
"""🆕 地址簿频道的名称/头像缓存,默认 6 小时对一次(频道换头像会跟着变)"""
|
|
@@ -733,10 +889,11 @@ def refresh_channel_meta(cid, ttl=21600, force=False):
|
|
| 733 |
meta = DATA.setdefault("ch_meta", {}).setdefault(cid, {})
|
| 734 |
if not force and time.time() - float(meta.get("checked", 0) or 0) < ttl: return meta
|
| 735 |
meta["checked"] = int(time.time())
|
| 736 |
-
info = resolve_chat_info(cid)
|
| 737 |
if info.get("ok"):
|
| 738 |
meta.update({"title": info.get("title", ""), "username": info.get("username", ""),
|
| 739 |
-
"members": info.get("members", 0), "avatar": info.get("avatar", "")
|
|
|
|
| 740 |
meta.pop("err", None)
|
| 741 |
else:
|
| 742 |
meta["err"] = info.get("msg", "")[:80]
|
|
@@ -893,6 +1050,7 @@ try:
|
|
| 893 |
types.BotCommand("session","🧩 Userbot 状态"),
|
| 894 |
types.BotCommand("login","🔑 登录 Userbot(拿 Session)"),
|
| 895 |
types.BotCommand("follow","🤝 让 Userbot 跟进频道"),
|
|
|
|
| 896 |
types.BotCommand("cleanlinks","🧹 清理邀请链接")
|
| 897 |
])
|
| 898 |
except: pass
|
|
@@ -1100,6 +1258,49 @@ def cmd_follow(message):
|
|
| 1100 |
except Exception: bot.send_message(message.chat.id, "\n".join(lines), parse_mode="Markdown")
|
| 1101 |
Thread(target=work, daemon=True).start()
|
| 1102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1103 |
# 🆕 清掉机器人开过的邀请链接(跟进失败时留下的那些)
|
| 1104 |
@bot.message_handler(commands=['cleanlinks'])
|
| 1105 |
def cmd_cleanlinks(message):
|
|
@@ -1110,8 +1311,12 @@ def cmd_cleanlinks(message):
|
|
| 1110 |
m = bot.reply_to(message, "🧹 正在清理邀请链接…")
|
| 1111 |
def work():
|
| 1112 |
out = []
|
| 1113 |
-
done = revoke_tracked_links(cid or None)
|
| 1114 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1115 |
# 机器人自己没法列出邀请链接(Bot API 没这个方法),借 userbot 扫一遍
|
| 1116 |
bot_id = BOT_ID
|
| 1117 |
targets = [cid] if cid else list((DATA.get("users", {}).get(uid, {}).get("address_book") or {}).keys())
|
|
@@ -1125,7 +1330,8 @@ def cmd_cleanlinks(message):
|
|
| 1125 |
if swept: out.append(f"深度清扫(userbot 是创建者的频道):又清掉 {swept} 条")
|
| 1126 |
if skipped:
|
| 1127 |
out.append(f"这 {len(skipped)} 个频道没权限列出链接(userbot 不是创建者):\n" + "、".join(skipped[:10]))
|
| 1128 |
-
out.append("这些只能手动删:频道 → 管理 → 邀请链接 →
|
|
|
|
| 1129 |
if not done and not swept and not skipped:
|
| 1130 |
out.append("没有需要清理的链接。")
|
| 1131 |
txt = "🧹 " + "\n".join(out)
|
|
@@ -1710,42 +1916,97 @@ def _member_backup_paths(uid, ch_id):
|
|
| 1710 |
remote_dir = f"members/{uid}"
|
| 1711 |
return remote_dir, f"{remote_dir}/{safe_channel}.csv", f"members_{safe_channel}.csv"
|
| 1712 |
|
| 1713 |
-
# ===== 🆕 成员
|
|
|
|
|
|
|
|
|
|
| 1714 |
BJ_TZ = timezone(timedelta(hours=8))
|
| 1715 |
-
|
| 1716 |
-
|
| 1717 |
-
|
| 1718 |
-
|
| 1719 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1720 |
|
| 1721 |
def _member_display(row):
|
| 1722 |
nm = " ".join([x for x in (row[1], row[2]) if x]).strip()
|
| 1723 |
return nm or (f"@{row[0]}" if row[0] else "")
|
| 1724 |
|
| 1725 |
-
def
|
| 1726 |
-
"""
|
| 1727 |
-
|
| 1728 |
-
|
| 1729 |
-
|
| 1730 |
-
|
| 1731 |
-
|
| 1732 |
-
|
| 1733 |
-
|
| 1734 |
-
|
| 1735 |
-
|
| 1736 |
-
|
| 1737 |
-
|
| 1738 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1739 |
|
| 1740 |
def _write_member_csv(members_index, path):
|
| 1741 |
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
| 1742 |
writer = csv.writer(f)
|
| 1743 |
-
writer.writerow(['UserID','Username','FirstName','LastName','IsBot','FirstSeen','BackupTime'])
|
| 1744 |
backup_time = datetime.now(BJ_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
| 1745 |
for uid_str, row in sorted(members_index.items(), key=lambda kv: kv[1][4]):
|
| 1746 |
-
|
| 1747 |
writer.writerow([uid_str, f"@{row[0]}" if row[0] else '', row[1], row[2],
|
| 1748 |
-
'是' if row[3] else '否',
|
|
|
|
| 1749 |
|
| 1750 |
def _upload_member_backup(uid, ch_id, members_index):
|
| 1751 |
remote_dir, remote_path, filename = _member_backup_paths(uid, ch_id)
|
|
@@ -1779,6 +2040,79 @@ def send_member_backup(uid, ch_id):
|
|
| 1779 |
try: os.remove(temp_path)
|
| 1780 |
except OSError: pass
|
| 1781 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1782 |
|
| 1783 |
def run_smart_backup_v2(latest_id, uid, src, tgt, wash=False):
|
| 1784 |
global TL_LOOP, TL_CLIENT
|
|
@@ -2183,35 +2517,50 @@ def start_telethon_worker():
|
|
| 2183 |
TL_CLIENT = TelegramClient(StringSession(user_session), int(api_id_str), api_hash)
|
| 2184 |
|
| 2185 |
async def update_member_backups():
|
| 2186 |
-
"""成员监控:
|
|
|
|
|
|
|
| 2187 |
current_time = int(time.time()); data_changed = False
|
| 2188 |
for uid, u_data in DATA.get("users", {}).items():
|
| 2189 |
for monitor in u_data.get("member_monitors", []):
|
| 2190 |
-
interval_sec = max(1, int(monitor.get("interval", 60))) * 60
|
| 2191 |
-
members_index = monitor.get("members_index") or {}
|
| 2192 |
-
is_first_run = not bool(members_index)
|
| 2193 |
-
# 首次(新建后)立刻扫一次,之后按检测间隔增量扫描
|
| 2194 |
-
if not is_first_run and current_time - int(monitor.get("last_run", 0)) < interval_sec: continue
|
| 2195 |
ch_id = str(monitor.get("channel_id", "")).strip()
|
| 2196 |
if not ch_id: continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2197 |
try:
|
| 2198 |
members = [member async for member in TL_CLIENT.iter_participants(peer(ch_id))]
|
| 2199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2200 |
changed = bool(joined or left) or is_first_run
|
| 2201 |
remote_path = monitor.get("remote_path", "")
|
| 2202 |
if changed: # 只有名单发生变化才重新上传,避免无意义写入
|
| 2203 |
-
remote_path = await asyncio.to_thread(_upload_member_backup, uid, ch_id,
|
| 2204 |
monitor.pop("members", None)
|
| 2205 |
-
monitor.update({"last_run":current_time, "member_count":
|
| 2206 |
-
"
|
| 2207 |
"joined_count":len(joined), "left_count":len(left),
|
| 2208 |
"first_scan_at":monitor.get("first_scan_at") or current_time})
|
| 2209 |
if is_first_run:
|
| 2210 |
-
|
|
|
|
| 2211 |
elif changed:
|
| 2212 |
-
names = "、".join([_member_display(
|
| 2213 |
detail = f"({names}{'…' if len(joined) > 5 else ''})" if names else ""
|
| 2214 |
-
push_event(uid, "members_done",
|
|
|
|
| 2215 |
except Exception as e:
|
| 2216 |
monitor.update({"last_run":current_time, "last_error":str(e)})
|
| 2217 |
print(f"❌ [成员监控] {ch_id}: {e}")
|
|
@@ -2485,6 +2834,25 @@ def start_telethon_worker():
|
|
| 2485 |
return
|
| 2486 |
try: print(f"👤 Userbot: {TL_LOOP.run_until_complete(TL_CLIENT.get_me()).first_name}")
|
| 2487 |
except Exception: pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2488 |
scheduler = AsyncIOScheduler(event_loop=TL_LOOP)
|
| 2489 |
scheduler.add_job(update_channel_msg, 'interval', seconds=10)
|
| 2490 |
scheduler.add_job(update_member_backups, 'interval', seconds=10)
|
|
@@ -2733,6 +3101,25 @@ def _tl_linked_chat(target):
|
|
| 2733 |
try: return _tl_run(_go(), timeout=60)
|
| 2734 |
except Exception: return None
|
| 2735 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2736 |
def _track_link(cid, url):
|
| 2737 |
"""🆕 记下我们开过的每条邀请链接:万一没当场收回,之后还能一键清掉"""
|
| 2738 |
store = DATA.setdefault("userbot", {}).setdefault("links", {}).setdefault(str(cid), [])
|
|
@@ -2746,15 +3133,18 @@ def _untrack_link(cid, url):
|
|
| 2746 |
save_data_soon()
|
| 2747 |
|
| 2748 |
def revoke_tracked_links(cid=None):
|
| 2749 |
-
"""把记录在案的邀请链接全部撤销 + 删除(cid 为空 = 所有频道)
|
|
|
|
| 2750 |
store = DATA.setdefault("userbot", {}).setdefault("links", {})
|
| 2751 |
-
n = 0
|
| 2752 |
for key in ([str(cid)] if cid else list(store.keys())):
|
| 2753 |
urls = list(store.get(key) or [])
|
| 2754 |
if not urls: continue
|
| 2755 |
-
|
|
|
|
|
|
|
| 2756 |
save_data()
|
| 2757 |
-
return n
|
| 2758 |
|
| 2759 |
def tl_sweep_bot_links(cid, bot_id):
|
| 2760 |
"""userbot 是频道创建者时,能把机器人建过的邀请链接列出来,逐条撤销并彻底删除
|
|
@@ -2768,6 +3158,12 @@ def tl_sweep_bot_links(cid, bot_id):
|
|
| 2768 |
for inv in (getattr(res, "invites", []) or []):
|
| 2769 |
link = getattr(inv, "link", "")
|
| 2770 |
if not link: continue
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2771 |
try: await TL_CLIENT(EditExportedChatInviteRequest(peer=peer(cid), link=link, revoked=True))
|
| 2772 |
except Exception: pass
|
| 2773 |
try: await TL_CLIENT(DeleteExportedChatInviteRequest(peer=peer(cid), link=link))
|
|
@@ -2779,46 +3175,77 @@ def tl_sweep_bot_links(cid, bot_id):
|
|
| 2779 |
try: return _tl_run(_go(), timeout=90), ""
|
| 2780 |
except Exception as e: return 0, _err_txt(e)
|
| 2781 |
|
| 2782 |
-
def _purge_links(cid, urls):
|
| 2783 |
-
"""🆕 撤销 + 彻底删除我们开过的邀请链接
|
| 2784 |
-
|
|
|
|
|
|
|
|
|
|
| 2785 |
urls = [u for u in (urls or []) if u]
|
| 2786 |
-
if not urls: return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2787 |
for u in urls:
|
| 2788 |
try: bot.revoke_chat_invite_link(peer(cid), u)
|
| 2789 |
except Exception as e: print(f"⚠️ 撤销链接失败 {cid}: {str(e)[:70]}")
|
| 2790 |
-
|
| 2791 |
-
|
| 2792 |
-
|
| 2793 |
-
|
| 2794 |
-
|
| 2795 |
-
|
| 2796 |
-
|
| 2797 |
-
|
| 2798 |
-
|
| 2799 |
-
|
| 2800 |
-
|
| 2801 |
-
|
| 2802 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2803 |
|
| 2804 |
def _bot_invite_links(cid, chat=None, fresh=False):
|
| 2805 |
"""返回 (候选链接, 其中我们新建的, 失败原因)
|
| 2806 |
-
|
| 2807 |
-
|
| 2808 |
-
|
| 2809 |
-
|
|
|
|
|
|
|
| 2810 |
links, created, why = [], [], ""
|
| 2811 |
if not fresh:
|
| 2812 |
-
try:
|
| 2813 |
u = getattr(chat if chat is not None else bot.get_chat(cid), "invite_link", "") or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2814 |
if u: links.append(u)
|
| 2815 |
except Exception as e: why = str(e)[:140]
|
| 2816 |
if not links:
|
| 2817 |
-
for kw in (dict(
|
| 2818 |
try:
|
| 2819 |
u = getattr(bot.create_chat_invite_link(cid, **kw), "invite_link", "") or ""
|
| 2820 |
if u and u not in links:
|
| 2821 |
links.append(u); created.append(u); _track_link(cid, u)
|
|
|
|
| 2822 |
except Exception as e:
|
| 2823 |
why = why or str(e)[:140]
|
| 2824 |
if links: return links, created, ""
|
|
@@ -2891,9 +3318,10 @@ def userbot_follow_chat(cid, title="", with_linked=True):
|
|
| 2891 |
joined = False; how = ""
|
| 2892 |
if not uname and not invites:
|
| 2893 |
lines.append(f"❌ 拿不到 **{label}** 的邀请链接:{invite_err or '未知原因'}\n"
|
| 2894 |
-
f"(私密频道只能靠邀请链接把 Userbot 拉进去。
|
| 2895 |
-
f"
|
| 2896 |
-
f"或者
|
|
|
|
| 2897 |
return False, lines
|
| 2898 |
entity = ("@" + uname) if uname else target
|
| 2899 |
try:
|
|
@@ -2933,13 +3361,16 @@ def userbot_follow_chat(cid, title="", with_linked=True):
|
|
| 2933 |
except Exception: pass
|
| 2934 |
tail = ("\n" + "\n".join(diag)) if diag else ""
|
| 2935 |
lines.append(f"❌ Userbot 加入 **{label}** 失败:{_err_txt(e)}{hint}{tail}\n"
|
| 2936 |
-
f"(
|
|
|
|
| 2937 |
# 超时不代表没进去,再确认一次
|
| 2938 |
if not joined and _tl_joined(target):
|
| 2939 |
lines[-1] = f"✅ Userbot 已在 **{label}** 里(前一步只是响应超时)"; joined = True
|
| 2940 |
finally:
|
| 2941 |
# 只清我们自己开的(现成的主链接绝不能动);等审批的先留着,撤销会连带取消申请
|
| 2942 |
-
if created and "申请" not in how:
|
|
|
|
|
|
|
| 2943 |
if not joined: return False, lines
|
| 2944 |
# 关联讨论组:榜单统计要读评论,私密讨论组不进就读不到
|
| 2945 |
if with_linked:
|
|
@@ -3029,6 +3460,27 @@ if hasattr(bot, "my_chat_member_handler"):
|
|
| 3029 |
else:
|
| 3030 |
print("⚠️ 当前 pyTelegramBotAPI 版本不支持 my_chat_member,userbot 自动跟进只能用 /follow 手动触发")
|
| 3031 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3032 |
# 🆕 给所有「私聊指令 / 内联按钮」处理器套一层白名单检查(频道消息处理器不在此列)
|
| 3033 |
def guard_bot_handlers():
|
| 3034 |
def wrap(fn, kind):
|
|
@@ -3061,10 +3513,12 @@ def guard_bot_handlers():
|
|
| 3061 |
@app.route('/api/avatar/<key>')
|
| 3062 |
def api_avatar(key):
|
| 3063 |
path = _avatar_path(key)
|
| 3064 |
-
if not os.path.exists(path)
|
|
|
|
| 3065 |
st = os.stat(path)
|
| 3066 |
# 🆕 URL 自带头像指纹,同一个地址的内容永远不变 ➜ immutable + ETag,浏览器第二次起直接用本地缓存
|
| 3067 |
-
|
|
|
|
| 3068 |
if request.headers.get("If-None-Match") == etag:
|
| 3069 |
resp = Response(status=304)
|
| 3070 |
else:
|
|
@@ -3084,7 +3538,8 @@ def api_resolve(uid):
|
|
| 3084 |
meta = DATA.setdefault("ch_meta", {}).setdefault(str(info["id"]), {})
|
| 3085 |
meta.update({"checked": int(time.time()), "title": info.get("title", ""),
|
| 3086 |
"username": info.get("username", ""), "members": info.get("members", 0),
|
| 3087 |
-
"avatar": info.get("avatar", "")
|
|
|
|
| 3088 |
save_data_soon()
|
| 3089 |
return jsonify(info)
|
| 3090 |
|
|
@@ -3130,11 +3585,16 @@ def api_admins(uid):
|
|
| 3130 |
@need_auth
|
| 3131 |
def api_get_data(uid):
|
| 3132 |
u = dict(DATA["users"].get(uid,{}))
|
| 3133 |
-
# 成员
|
| 3134 |
monitors = []
|
| 3135 |
for mon in u.get("member_monitors", []):
|
| 3136 |
slim = {k:v for k,v in mon.items() if k != "members_index"}
|
| 3137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3138 |
monitors.append(slim)
|
| 3139 |
if "member_monitors" in u: u["member_monitors"] = monitors
|
| 3140 |
return jsonify({"ok":True,"user":u,"msg_count":len(DATA.get("msg_map",{})),
|
|
@@ -3547,10 +4007,10 @@ def api_add_member_monitor(uid):
|
|
| 3547 |
if any(str(item.get("channel_id")) == ch_id for item in monitors): return jsonify({"ok":False,"msg":"该频道已在监控中"})
|
| 3548 |
try: interval = max(1, int(d.get('interval', 60)))
|
| 3549 |
except (TypeError, ValueError): return jsonify({"ok":False,"msg":"检测间隔必须是整数分钟"})
|
| 3550 |
-
#
|
| 3551 |
monitors.append({"channel_id":ch_id,"started_at":int(time.time()),"interval":interval,
|
| 3552 |
"last_run":0,"member_count":0,"remote_path":"","last_error":"",
|
| 3553 |
-
"
|
| 3554 |
save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
|
| 3555 |
|
| 3556 |
@app.route('/api/member_monitors/<path:cid>', methods=['PUT'])
|
|
@@ -3569,9 +4029,42 @@ def api_edit_member_monitor(uid, cid):
|
|
| 3569 |
def api_download_member_monitor(uid, cid):
|
| 3570 |
monitor = next((m for m in DATA["users"][uid].setdefault("member_monitors", []) if str(m.get("channel_id")) == cid), None)
|
| 3571 |
if not monitor: return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
|
| 3572 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3573 |
return jsonify({"ok":True,"msg":"备份文件将由机器人发送给你"})
|
| 3574 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3575 |
@app.route('/api/member_monitors/<path:cid>', methods=['DELETE'])
|
| 3576 |
@need_auth
|
| 3577 |
def api_del_member_monitor(uid, cid):
|
|
@@ -3664,6 +4157,7 @@ def api_health():
|
|
| 3664 |
if __name__ == "__main__":
|
| 3665 |
Thread(target=lambda: app.run(host="0.0.0.0", port=7860), daemon=True).start()
|
| 3666 |
Thread(target=start_telethon_worker, daemon=True).start()
|
|
|
|
| 3667 |
print("🔄 正在清除旧连接...")
|
| 3668 |
for attempt in range(5):
|
| 3669 |
try: bot.remove_webhook(); bot.get_updates(offset=-1, timeout=1); break
|
|
@@ -3672,7 +4166,8 @@ if __name__ == "__main__":
|
|
| 3672 |
while True:
|
| 3673 |
guard_bot_handlers() # 🆕 上锁:非白名单用户的指令与按钮一律拒绝
|
| 3674 |
try: bot.infinity_polling(timeout=60, long_polling_timeout=60,
|
| 3675 |
-
allowed_updates=["message","callback_query","channel_post","edited_channel_post",
|
|
|
|
| 3676 |
except Exception as e:
|
| 3677 |
print(f"❌ Polling 异常: {e}"); time.sleep(10)
|
| 3678 |
try: bot.remove_webhook(); bot.get_updates(offset=-1, timeout=1)
|
|
|
|
| 20 |
from collections import defaultdict
|
| 21 |
from datetime import datetime, timezone, timedelta
|
| 22 |
from threading import Thread, Lock, Timer
|
| 23 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 24 |
from urllib.parse import parse_qs, unquote
|
| 25 |
from functools import wraps
|
| 26 |
|
|
|
|
| 35 |
from telebot.apihelper import ApiTelegramException
|
| 36 |
from flask import Flask, request, jsonify, send_file, Response
|
| 37 |
from webdav4.client import Client as WebDAVClient
|
| 38 |
+
from telethon import TelegramClient, events
|
| 39 |
from telethon.sessions import StringSession
|
| 40 |
from telethon.extensions import html as tl_html
|
| 41 |
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
|
|
|
| 53 |
FULL_WEBDAV_URL = f"{DAV_URL_BASE}/{DAV_SUB_PATH}/" if DAV_SUB_PATH else f"{DAV_URL_BASE}/"
|
| 54 |
REMOTE_FILENAME = "tg_bot_data_v5.json"
|
| 55 |
|
| 56 |
+
DATA = {"users": {}, "msg_map": {}, "backup_log": {}, "list_cache": {}, "admin_ids": [], "events": {}, "profiles": {}, "ch_meta": {}, "userbot": {}, "ava_fp": {}, "ch_members": {}}
|
| 57 |
data_lock = Lock()
|
| 58 |
|
| 59 |
HTML_CACHE = {}
|
|
|
|
| 143 |
DATA["events"] = loaded_data.get("events", {}) # 🆕 持久化的事件流(每人最多 50 条)
|
| 144 |
DATA["profiles"] = loaded_data.get("profiles", {}) # 🆕 白名单成员的名字/头像缓存
|
| 145 |
DATA["ch_meta"] = loaded_data.get("ch_meta", {}) # 🆕 频道名称/头像缓存
|
| 146 |
+
DATA["ch_members"] = loaded_data.get("ch_members", {}) # 🆕 每个频道一份成员台账(实时累加)
|
| 147 |
+
DATA["ava_fp"] = loaded_data.get("ava_fp", {}) # 🆕 头像指纹台账(按来源分开记)
|
| 148 |
DATA["userbot"] = loaded_data.get("userbot", {}) # 🆕 /login 登录得到的 Session
|
| 149 |
+
DATA["userbot"].pop("joinlinks", None) # 常驻邀请链接已下线:只用机器人自己那条主链接
|
| 150 |
print(f"✅ 成功下载恢复云端数据!映射条数: {len(DATA['msg_map'])}")
|
| 151 |
else:
|
| 152 |
save_data(force=True)
|
|
|
|
| 568 |
def _avatar_path(key):
|
| 569 |
return os.path.join(AVATAR_DIR, f"{_avatar_key(key)}.jpg")
|
| 570 |
|
| 571 |
+
# ===== 🆕 头像持久化:本地 avatar_cache 只是缓存,容器一重启就空了 ➜ 同一份镜像到 WebDAV =====
|
| 572 |
+
# 以前重启后所有头像都要重新问 Telegram(get_file + 下文件,每个几百 ms 起,还常被掐 SSL),
|
| 573 |
+
# 所以面板每次打开都是一排首字母。现在重启只从 WebDAV 拉回来,Telegram 只在换头像时才碰。
|
| 574 |
+
DAV_AVATAR_DIR = "avatars"
|
| 575 |
+
_dav_ava = {"dir": False}
|
| 576 |
+
_dav_ava_miss = {} # key ➜ 上次去云上找过但没有的时间(别反复问)
|
| 577 |
+
|
| 578 |
+
def _dav_ava_remote(key):
|
| 579 |
+
return f"{DAV_AVATAR_DIR}/{_avatar_key(key)}.jpg"
|
| 580 |
+
|
| 581 |
+
def _dav_ava_mkdir():
|
| 582 |
+
if _dav_ava["dir"]: return
|
| 583 |
+
try:
|
| 584 |
+
c = get_dav_client()
|
| 585 |
+
if not c.exists(DAV_AVATAR_DIR): c.mkdir(DAV_AVATAR_DIR)
|
| 586 |
+
_dav_ava["dir"] = True
|
| 587 |
+
except Exception as e: print(f"⚠️ WebDAV 头像目录: {str(e)[:60]}")
|
| 588 |
+
|
| 589 |
+
def dav_ava_put(key):
|
| 590 |
+
"""本地头像上云(后台跑,慢也挡不着任何请求)"""
|
| 591 |
+
p = _avatar_path(key)
|
| 592 |
+
if not os.path.exists(p): return
|
| 593 |
+
def work():
|
| 594 |
+
try:
|
| 595 |
+
_dav_ava_mkdir()
|
| 596 |
+
get_dav_client().upload_file(p, _dav_ava_remote(key), overwrite=True)
|
| 597 |
+
_dav_ava_miss.pop(_avatar_key(key), None)
|
| 598 |
+
except Exception as e: print(f"⚠️ 头像上云失败 {key}: {str(e)[:60]}")
|
| 599 |
+
Thread(target=work, daemon=True).start()
|
| 600 |
+
|
| 601 |
+
def dav_ava_get(key):
|
| 602 |
+
"""本地没有就从云上拉回来(重启后走这条,不必再问 Telegram);返回本地是否已就位"""
|
| 603 |
+
k = _avatar_key(key); p = _avatar_path(k)
|
| 604 |
+
if os.path.exists(p): return True
|
| 605 |
+
if time.time() - float(_dav_ava_miss.get(k, 0) or 0) < 600: return False
|
| 606 |
+
try:
|
| 607 |
+
c = get_dav_client()
|
| 608 |
+
remote = _dav_ava_remote(k)
|
| 609 |
+
if not c.exists(remote):
|
| 610 |
+
_dav_ava_miss[k] = time.time(); return False
|
| 611 |
+
tmp = p + ".part"
|
| 612 |
+
c.download_file(remote, tmp)
|
| 613 |
+
os.replace(tmp, p)
|
| 614 |
+
return True
|
| 615 |
+
except Exception as e:
|
| 616 |
+
_dav_ava_miss[k] = time.time()
|
| 617 |
+
print(f"⚠️ 头像回源失败 {k}: {str(e)[:60]}")
|
| 618 |
+
return False
|
| 619 |
+
|
| 620 |
+
def dav_ava_del(key):
|
| 621 |
+
"""头像被本人删掉了:云上那份也一起清掉,别下次又拉回来"""
|
| 622 |
+
def work():
|
| 623 |
+
try: get_dav_client().remove(_dav_ava_remote(key))
|
| 624 |
+
except Exception: pass
|
| 625 |
+
_dav_ava_miss[_avatar_key(key)] = time.time()
|
| 626 |
+
Thread(target=work, daemon=True).start()
|
| 627 |
+
|
| 628 |
+
def dav_ava_warm():
|
| 629 |
+
"""启动预热:云上的头像一次性并行拉回本地,再把白名单成员的资料刷一遍
|
| 630 |
+
➜ 用户打开面板时头像已经在本地,接口不用等 Telegram"""
|
| 631 |
+
got = 0
|
| 632 |
+
try:
|
| 633 |
+
c = get_dav_client()
|
| 634 |
+
if c.exists(DAV_AVATAR_DIR):
|
| 635 |
+
names = []
|
| 636 |
+
for it in (c.ls(DAV_AVATAR_DIR, detail=False) or []):
|
| 637 |
+
n = os.path.basename(str(it).rstrip("/"))
|
| 638 |
+
if n.endswith(".jpg"): names.append(n[:-4])
|
| 639 |
+
todo = [n for n in names if not os.path.exists(_avatar_path(n))][:300]
|
| 640 |
+
with ThreadPoolExecutor(max_workers=6) as ex:
|
| 641 |
+
got = sum(1 for r in ex.map(dav_ava_get, todo) if r)
|
| 642 |
+
print(f"🖼 头像预热:云上 {len(names)} 张,本地补齐 {got} 张")
|
| 643 |
+
except Exception as e: print(f"⚠️ 头像预热失败: {str(e)[:80]}")
|
| 644 |
+
for x in (sorted(ADMIN_IDS) + [str(y) for y in DATA.get("admin_ids", [])]):
|
| 645 |
+
try: refresh_profile_bg(x)
|
| 646 |
+
except Exception: pass
|
| 647 |
+
return got
|
| 648 |
+
|
| 649 |
def _tl_run(coro, timeout=30):
|
| 650 |
"""在 Telethon 的事件循环里跑一个协程(同步线程调用用)"""
|
| 651 |
if not TL_LOOP or not TL_CLIENT: return None
|
|
|
|
| 678 |
time.sleep(0.6 * (i + 1))
|
| 679 |
raise RuntimeError(last)
|
| 680 |
|
| 681 |
+
def _photo_uniq(photo):
|
| 682 |
+
"""头像指纹:跟着下载用的那一档走(小图 160px,够 40px 的圆头像用,下载快得多)"""
|
| 683 |
+
return (getattr(photo, 'small_file_unique_id', None)
|
| 684 |
+
or getattr(photo, 'big_file_unique_id', None) or '')
|
| 685 |
+
|
| 686 |
+
# ===== 🆕 头像指纹台账:换头像才下载,没换连 get_file 都不发 =====
|
| 687 |
+
# 两个来源的指纹天生不一样(Bot API 是 file_unique_id,userbot 是 photo_id),混在一个字段里比
|
| 688 |
+
# 就会「每次都像换了头像」➜ 白下一遍。所以按来源分开记,另外单独记 URL 版本号 v(只跟图片内容有关)。
|
| 689 |
+
def _ava_led(key):
|
| 690 |
+
return DATA.setdefault("ava_fp", {}).setdefault(_avatar_key(key), {})
|
| 691 |
+
|
| 692 |
+
def ava_have(key):
|
| 693 |
+
"""图还在吗(本地没有会顺手从 WebDAV 拉回来)"""
|
| 694 |
+
return os.path.exists(_avatar_path(key)) or dav_ava_get(key)
|
| 695 |
+
|
| 696 |
+
def ava_unchanged(key, src, fp, known=""):
|
| 697 |
+
"""指纹对得上、图也还在 ➜ 返回沿用的 URL 版本号;返回空串代表要重新下"""
|
| 698 |
+
if not fp: return ""
|
| 699 |
+
led = _ava_led(key)
|
| 700 |
+
hit = str(fp) == str(led.get(src) or "")
|
| 701 |
+
if not hit and not (known and str(fp) == str(known)): return ""
|
| 702 |
+
if not ava_have(key): return ""
|
| 703 |
+
if not hit: return ava_mark(key, src, fp, known) # 老数据只有一个 photo_uid:顺手补进台账
|
| 704 |
+
return str(led.get("v") or known or fp)
|
| 705 |
+
|
| 706 |
+
def ava_mark(key, src, fp, ver=""):
|
| 707 |
+
"""记下这次的来源指纹 + URL 版本号"""
|
| 708 |
+
led = _ava_led(key)
|
| 709 |
+
if fp: led[src] = str(fp)
|
| 710 |
+
led["v"] = str(ver or fp or "")
|
| 711 |
+
save_data_soon()
|
| 712 |
+
return led["v"]
|
| 713 |
+
|
| 714 |
+
def ava_drop(key):
|
| 715 |
+
"""头像被删了:本地、云上、台账一起清"""
|
| 716 |
+
try: os.remove(_avatar_path(key))
|
| 717 |
+
except Exception: pass
|
| 718 |
+
dav_ava_del(key)
|
| 719 |
+
DATA.setdefault("ava_fp", {}).pop(_avatar_key(key), None)
|
| 720 |
+
|
| 721 |
def _save_avatar_from_bot(key, photo):
|
| 722 |
"""用 Bot API 下载头像;头像换了 file_unique_id 会变,据此判断要不要重新下载"""
|
| 723 |
+
fid = getattr(photo, 'small_file_id', None) or getattr(photo, 'big_file_id', None)
|
| 724 |
+
uniq = _photo_uniq(photo)
|
| 725 |
if not fid: return ""
|
| 726 |
f = bot.get_file(fid)
|
| 727 |
raw = _download_tg_file(f.file_path, tries=2, timeout=15)
|
| 728 |
with open(_avatar_path(key), 'wb') as fh: fh.write(raw)
|
| 729 |
+
dav_ava_put(key) # 🆕 顺手镜像到 WebDAV,重启后就不用再下一遍
|
| 730 |
# 版本号必须只跟「图片内容」有关:用时间戳会让 URL 每次都变,浏览器缓存直接失效
|
| 731 |
return uniq or hashlib.md5(raw).hexdigest()[:12]
|
| 732 |
|
|
|
|
| 737 |
except Exception: return ""
|
| 738 |
if not data: return ""
|
| 739 |
with open(_avatar_path(key), 'wb') as fh: fh.write(data)
|
| 740 |
+
dav_ava_put(key)
|
| 741 |
return hashlib.md5(data).hexdigest()[:12]
|
| 742 |
|
| 743 |
+
def refresh_profile(uid, force=False, ttl=21600):
|
| 744 |
"""🆕 缓存白名单成员的名字与头像,换头像后自动跟着变"""
|
| 745 |
uid = str(uid)
|
| 746 |
prof = DATA.setdefault("profiles", {}).setdefault(uid, {})
|
| 747 |
fresh = time.time() - float(prof.get("checked", 0) or 0) < ttl
|
| 748 |
+
# 本地图丢了(容器重启)就算还「新鲜」也得补:先从 WebDAV 回源,回得到就完事,不惊动 Telegram
|
| 749 |
+
if fresh and not force and prof.get("photo_uid") and not os.path.exists(_avatar_path(uid)):
|
| 750 |
+
if dav_ava_get(uid):
|
| 751 |
+
prof["has_avatar"] = True; return prof
|
| 752 |
+
elif fresh and not force: return prof
|
| 753 |
prof["checked"] = int(time.time())
|
| 754 |
chat = None
|
| 755 |
try: chat = bot.get_chat(int(uid))
|
|
|
|
| 760 |
prof[k] = getattr(chat, attr, "") or ""
|
| 761 |
photo = getattr(chat, "photo", None)
|
| 762 |
if photo:
|
| 763 |
+
uniq = _photo_uniq(photo)
|
| 764 |
+
# 指纹没变就别碰 Telegram:本地有直接用,本地没有(重启掉了)就从 WebDAV 拉回来
|
| 765 |
+
keep = "" if force else ava_unchanged(uid, "b", uniq, prof.get("photo_uid", ""))
|
| 766 |
+
if keep: prof["photo_uid"] = keep
|
| 767 |
+
else:
|
| 768 |
+
try:
|
| 769 |
got = _save_avatar_from_bot(uid, photo)
|
| 770 |
+
if got: prof["photo_uid"] = ava_mark(uid, "b", uniq, got)
|
| 771 |
+
except Exception as e:
|
| 772 |
+
# 🆕 Bot 下载挂了(多半是 SSL/网络)→ 换 userbot 兜底,别让头像整轮丢掉
|
| 773 |
+
got = _save_avatar_from_tl(uid, int(uid)) if TL_CLIENT else ""
|
| 774 |
+
if got: prof["photo_uid"] = ava_mark(uid, "b", uniq, uniq or got)
|
| 775 |
+
else: print(f"⚠️ 头像下载失败 {uid}: {e}")
|
| 776 |
else:
|
| 777 |
prof["photo_uid"] = ""
|
| 778 |
+
ava_drop(uid)
|
| 779 |
+
elif not os.path.exists(_avatar_path(uid)) and not dav_ava_get(uid):
|
|
|
|
| 780 |
got = _save_avatar_from_tl(uid, int(uid)) if TL_CLIENT else ""
|
| 781 |
if got: prof["photo_uid"] = got
|
| 782 |
prof["has_avatar"] = os.path.exists(_avatar_path(uid))
|
|
|
|
| 789 |
"""🆕 头像/名字一律后台刷:接口不能卡在 Telegram 的往返上(慢就慢在这里)"""
|
| 790 |
uid = str(uid)
|
| 791 |
prof = DATA.get("profiles", {}).get(uid) or {}
|
| 792 |
+
missing = bool(prof.get("photo_uid")) and not os.path.exists(_avatar_path(uid))
|
| 793 |
+
if not force and not missing and time.time() - float(prof.get("checked", 0) or 0) < 21600: return
|
| 794 |
if uid in _prof_busy: return
|
| 795 |
_prof_busy.add(uid)
|
| 796 |
def work():
|
|
|
|
| 808 |
if not p.get(k) and u.get(k): p[k] = u[k]
|
| 809 |
name = " ".join([x for x in (p.get("first_name"), p.get("last_name")) if x]).strip()
|
| 810 |
return {"id": uid, "name": name, "username": p.get("username", ""),
|
| 811 |
+
# 有指纹就给地址:本地图万一丢了,/api/avatar 会自己去 WebDAV 拿,前端不用等下一轮
|
| 812 |
"avatar": (f"/api/avatar/{uid}?v={p.get('photo_uid') or '0'}"
|
| 813 |
+
if (p.get("photo_uid") or os.path.exists(_avatar_path(uid))) else ""),
|
| 814 |
"unknown": bool(p.get("err")) and not name}
|
| 815 |
|
| 816 |
+
def resolve_chat_info(raw, known_uid=""):
|
| 817 |
+
"""🆕 认频道:给 ID / @用户名 / t.me 链接,返回真实名称、用户名、成员数和头像
|
| 818 |
+
known_uid = 上次记下的头像指纹:指纹没变而且本地/WebDAV 有图,就完全不下载"""
|
| 819 |
ident = normalize_chat(raw)
|
| 820 |
if not ident: return {"ok": False, "msg": "请输入频道 ID、@用户名 或 t.me 链接"}
|
| 821 |
target = peer(ident)
|
|
|
|
| 834 |
except Exception: members = 0
|
| 835 |
except Exception: members = 0
|
| 836 |
photo = getattr(chat, "photo", None)
|
| 837 |
+
k0 = "c" + (cid or ident)
|
| 838 |
+
known = str(known_uid or (DATA.get("ch_meta", {}).get(str(cid)) or {}).get("photo_uid", "") or "")
|
| 839 |
if photo:
|
| 840 |
+
uniq = _photo_uniq(photo)
|
| 841 |
+
photo_uid = ava_unchanged(k0, "b", uniq, known) # 头像没换、图也在 ➜ 一个字节都不用下
|
| 842 |
+
if not photo_uid:
|
| 843 |
+
try: photo_uid = ava_mark(k0, "b", uniq, _save_avatar_from_bot(k0, photo))
|
| 844 |
+
except Exception as e:
|
| 845 |
+
# 🆕 Bot 下载挂了 → 换 userbot 兜底(指纹照记 Bot 那��,下轮才对得上)
|
| 846 |
+
got = ""
|
| 847 |
+
try: got = (_save_avatar_from_tl(k0, target) if TL_CLIENT else "") or ""
|
| 848 |
+
except Exception: got = ""
|
| 849 |
+
if got: photo_uid = ava_mark(k0, "b", uniq, uniq or got)
|
| 850 |
+
else:
|
| 851 |
+
photo_uid = _ava_led(k0).get("v", "") if ava_have(k0) else "" # 下不下来先用旧图顶着
|
| 852 |
+
print(f"⚠️ 频道头像下载失败: {e}")
|
| 853 |
+
elif os.path.exists(_avatar_path(k0)) or _ava_led(k0).get("v"):
|
| 854 |
+
ava_drop(k0) # 频道把头像删了:本地和云上那份也清掉
|
| 855 |
else:
|
| 856 |
# Bot 不在这个频道里,换 userbot 认
|
| 857 |
try:
|
|
|
|
| 865 |
raw_id = str(getattr(ent, "id", "") or "")
|
| 866 |
cid = raw_id if raw_id.startswith("-") else f"-100{raw_id}"
|
| 867 |
members = int(getattr(ent, "participants_count", 0) or 0)
|
| 868 |
+
k0 = "c" + cid
|
| 869 |
+
tphoto = getattr(ent, "photo", None)
|
| 870 |
+
uniq = str(getattr(tphoto, "photo_id", "") or "")
|
| 871 |
+
known = str(known_uid or (DATA.get("ch_meta", {}).get(str(cid)) or {}).get("photo_uid", "") or "")
|
| 872 |
+
if tphoto is None or "Empty" in type(tphoto).__name__: # 没头像(ChatPhotoEmpty)
|
| 873 |
+
if os.path.exists(_avatar_path(k0)) or _ava_led(k0).get("v"): ava_drop(k0)
|
| 874 |
+
else:
|
| 875 |
+
photo_uid = ava_unchanged(k0, "t", uniq, known) # photo_id 没变 ➜ 不下载
|
| 876 |
+
if not photo_uid:
|
| 877 |
+
got = _save_avatar_from_tl(k0, ent)
|
| 878 |
+
if got: photo_uid = ava_mark(k0, "t", uniq, uniq or got) # 有 photo_id 就拿它当版本号
|
| 879 |
+
elif ava_have(k0): photo_uid = _ava_led(k0).get("v", "")
|
| 880 |
key = "c" + (cid or ident)
|
| 881 |
return {"ok": True, "id": cid or ident, "title": title or ident, "username": username,
|
| 882 |
+
"members": members, "photo_uid": photo_uid,
|
| 883 |
+
"avatar": (f"/api/avatar/{_avatar_key(key)}?v={photo_uid or '0'}"
|
| 884 |
+
if (photo_uid or os.path.exists(_avatar_path(key))) else "")}
|
| 885 |
|
| 886 |
def refresh_channel_meta(cid, ttl=21600, force=False):
|
| 887 |
"""🆕 地址簿频道的名称/头像缓存,默认 6 小时对一次(频道换头像会跟着变)"""
|
|
|
|
| 889 |
meta = DATA.setdefault("ch_meta", {}).setdefault(cid, {})
|
| 890 |
if not force and time.time() - float(meta.get("checked", 0) or 0) < ttl: return meta
|
| 891 |
meta["checked"] = int(time.time())
|
| 892 |
+
info = resolve_chat_info(cid, known_uid=meta.get("photo_uid", ""))
|
| 893 |
if info.get("ok"):
|
| 894 |
meta.update({"title": info.get("title", ""), "username": info.get("username", ""),
|
| 895 |
+
"members": info.get("members", 0), "avatar": info.get("avatar", ""),
|
| 896 |
+
"photo_uid": info.get("photo_uid", "")})
|
| 897 |
meta.pop("err", None)
|
| 898 |
else:
|
| 899 |
meta["err"] = info.get("msg", "")[:80]
|
|
|
|
| 1050 |
types.BotCommand("session","🧩 Userbot 状态"),
|
| 1051 |
types.BotCommand("login","🔑 登录 Userbot(拿 Session)"),
|
| 1052 |
types.BotCommand("follow","🤝 让 Userbot 跟进频道"),
|
| 1053 |
+
types.BotCommand("joinlink","🔗 借一条邀请链接把 Userbot 拉进去"),
|
| 1054 |
types.BotCommand("cleanlinks","🧹 清理邀请链接")
|
| 1055 |
])
|
| 1056 |
except: pass
|
|
|
|
| 1258 |
except Exception: bot.send_message(message.chat.id, "\n".join(lines), parse_mode="Markdown")
|
| 1259 |
Thread(target=work, daemon=True).start()
|
| 1260 |
|
| 1261 |
+
# 🆕 一次性借链接入群:你把一条邀请链接给我,Userbot 就借它进去,不新建、不撤销、不删除、也不存
|
| 1262 |
+
@bot.message_handler(commands=['joinlink'])
|
| 1263 |
+
def cmd_joinlink(message):
|
| 1264 |
+
uid = str(message.from_user.id)
|
| 1265 |
+
if not is_admin(uid): return
|
| 1266 |
+
args = (message.text or "").split()[1:]
|
| 1267 |
+
usage = ("\n\n用法:`/joinlink <邀请链接>` —— 让 Userbot 借这条链接进去(一次性,不保存)\n"
|
| 1268 |
+
"(把频道 → 邀请链接 里最上面那条主链接复制给我就行,我只是借用:不撤销、不删除、"
|
| 1269 |
+
"面板里不会多出任何记录,只是那条链接的「已加入」会 +1。之后的自动跟进用机器人自己那条主链接)")
|
| 1270 |
+
link = next((a for a in args if "t.me/" in a), "")
|
| 1271 |
+
if not link:
|
| 1272 |
+
return bot.reply_to(message, "🔗 把邀请链接一起发来。" + usage, parse_mode="Markdown")
|
| 1273 |
+
m = bot.reply_to(message, "⏳ 正在用这条链接把 Userbot 拉进去…")
|
| 1274 |
+
Thread(target=_joinlink_apply, args=(link, "", m), daemon=True).start()
|
| 1275 |
+
|
| 1276 |
+
def _joinlink_apply(link, cid="", m=None):
|
| 1277 |
+
"""借用一条现成的邀请链接入群:频道 ID 从入群结果里自动认出来,所以哪个频道都不用你填"""
|
| 1278 |
+
out = []
|
| 1279 |
+
try:
|
| 1280 |
+
gid, title = _tl_join_link(link)
|
| 1281 |
+
key = cid or (f"-100{gid}" if gid else "")
|
| 1282 |
+
out.append(f"✅ Userbot 已进入 **{title or key or '这个频道'}**(借用你给的链接,没新建也没撤销任何链接)")
|
| 1283 |
+
if key:
|
| 1284 |
+
ok, lines = userbot_follow_chat(key, title or key) # 顺手把关联讨论组也进了
|
| 1285 |
+
out += [l for l in lines if "讨论组" in l]
|
| 1286 |
+
except Exception as e:
|
| 1287 |
+
out.append(f"❌ 没进去:{_err_txt(e)}\n(链接失效就到频道里点「撤销链接」换一条新的主链接,再发给我)")
|
| 1288 |
+
txt = "\n".join(out)
|
| 1289 |
+
if m is None: return txt
|
| 1290 |
+
try: bot.edit_message_text(txt, m.chat.id, m.message_id, parse_mode="Markdown")
|
| 1291 |
+
except Exception: bot.send_message(m.chat.id, txt)
|
| 1292 |
+
|
| 1293 |
+
# 🆕 白名单成员在私聊里直接甩一条 t.me/+xxx 邀请链接过来 ➜ 等同 /joinlink,不用记命令
|
| 1294 |
+
@bot.message_handler(func=lambda m: m.chat.type == 'private' and bool(m.text)
|
| 1295 |
+
and bool(_INVITE_RE.search(m.text)) and not (user_states.get(str(m.from_user.id)) or {}).get("step"))
|
| 1296 |
+
def handle_pasted_invite(message):
|
| 1297 |
+
uid = str(message.from_user.id)
|
| 1298 |
+
if not is_admin(uid): return
|
| 1299 |
+
link = _INVITE_RE.search(message.text).group(0)
|
| 1300 |
+
if not link.startswith("http"): link = "https://" + link
|
| 1301 |
+
m = bot.reply_to(message, "🔗 收到一条邀请链接,正在让 Userbot 借它入群…")
|
| 1302 |
+
Thread(target=_joinlink_apply, args=(link, "", m), daemon=True).start()
|
| 1303 |
+
|
| 1304 |
# 🆕 清掉机器人开过的邀请链接(跟进失败时留下的那些)
|
| 1305 |
@bot.message_handler(commands=['cleanlinks'])
|
| 1306 |
def cmd_cleanlinks(message):
|
|
|
|
| 1311 |
m = bot.reply_to(message, "🧹 正在清理邀请链接…")
|
| 1312 |
def work():
|
| 1313 |
out = []
|
| 1314 |
+
done, left = revoke_tracked_links(cid or None)
|
| 1315 |
+
nleft = sum(left.values())
|
| 1316 |
+
out.append(f"记录在案的链接:撤销 {done} 条,其中删除成功 {done - nleft} 条")
|
| 1317 |
+
if nleft:
|
| 1318 |
+
out.append(f"有 {nleft} 条只撤销了、删不掉(这些频道 userbot 不是创建者):\n"
|
| 1319 |
+
+ "、".join(list(left.keys())[:10]))
|
| 1320 |
# 机器人自己没法列出邀请链接(Bot API 没这个方法),借 userbot 扫一遍
|
| 1321 |
bot_id = BOT_ID
|
| 1322 |
targets = [cid] if cid else list((DATA.get("users", {}).get(uid, {}).get("address_book") or {}).keys())
|
|
|
|
| 1330 |
if swept: out.append(f"深度清扫(userbot 是创建者的频道):又清掉 {swept} 条")
|
| 1331 |
if skipped:
|
| 1332 |
out.append(f"这 {len(skipped)} 个频道没权限列出链接(userbot 不是创建者):\n" + "、".join(skipped[:10]))
|
| 1333 |
+
out.append("这些只能你手动删:频道 → 管理 → 邀请链接 → 点「机器人」那一栏 → "
|
| 1334 |
+
"已撤销的链接(旧版本留下的叫 userbot / userbot-1)→ 长按删除")
|
| 1335 |
if not done and not swept and not skipped:
|
| 1336 |
out.append("没有需要清理的链接。")
|
| 1337 |
txt = "🧹 " + "\n".join(out)
|
|
|
|
| 1916 |
remote_dir = f"members/{uid}"
|
| 1917 |
return remote_dir, f"{remote_dir}/{safe_channel}.csv", f"members_{safe_channel}.csv"
|
| 1918 |
|
| 1919 |
+
# ===== 🆕 成员台账:每个频道一份,实时进人就记,只增不删 =====
|
| 1920 |
+
# Telegram 一次最多只让看 200 个频道成员,所以「每次扫 200 就当成全部」会把老成员挤掉:
|
| 1921 |
+
# 200 人 + 1 个新订阅 ➜ 名单还是 200,最早那个被当成退出。
|
| 1922 |
+
# 现在名单是累加的台账(roster 只增不删,退出只打标记),200 只是「单次能看到多少」,不是上限。
|
| 1923 |
BJ_TZ = timezone(timedelta(hours=8))
|
| 1924 |
+
MEMBER_VIEW_CAP = 200 # Telegram 频道成员列表的单次可见上限
|
| 1925 |
+
MEMBER_LOG_LIMIT = 800 # 每个频道保留的动态条数
|
| 1926 |
+
|
| 1927 |
+
def ch_mem(cid):
|
| 1928 |
+
"""频道成员台账:{roster: {uid:[用户名,名,姓,是否bot,首次出现,退出时间]}, log: [...]}"""
|
| 1929 |
+
m = DATA.setdefault("ch_members", {}).setdefault(str(cid), {})
|
| 1930 |
+
m.setdefault("roster", {}); m.setdefault("log", [])
|
| 1931 |
+
return m
|
| 1932 |
+
|
| 1933 |
+
def _member_row(member, first_seen, left_at=0):
|
| 1934 |
+
"""紧凑存储:[username, first_name, last_name, is_bot, 首次出现时间, 退出时间(0=还在)]"""
|
| 1935 |
+
is_bot = getattr(member, 'bot', None)
|
| 1936 |
+
if is_bot is None: is_bot = getattr(member, 'is_bot', False) # Telethon 是 .bot,Bot API 是 .is_bot
|
| 1937 |
+
return [getattr(member, 'username', '') or '', getattr(member, 'first_name', '') or '',
|
| 1938 |
+
getattr(member, 'last_name', '') or '', 1 if is_bot else 0,
|
| 1939 |
+
int(first_seen), int(left_at)]
|
| 1940 |
|
| 1941 |
def _member_display(row):
|
| 1942 |
nm = " ".join([x for x in (row[1], row[2]) if x]).strip()
|
| 1943 |
return nm or (f"@{row[0]}" if row[0] else "")
|
| 1944 |
|
| 1945 |
+
def mem_log(cid, key, row, act, src):
|
| 1946 |
+
"""频道自己的动态流:谁、什么时候、进还是退(持久化,运维面板直接看)"""
|
| 1947 |
+
log = ch_mem(cid)["log"]
|
| 1948 |
+
log.insert(0, {"t": int(time.time()), "u": str(key), "n": _member_display(row),
|
| 1949 |
+
"un": row[0], "a": act, "s": src})
|
| 1950 |
+
del log[MEMBER_LOG_LIMIT:]
|
| 1951 |
+
|
| 1952 |
+
def mem_stats(cid):
|
| 1953 |
+
roster = ch_mem(cid)["roster"]
|
| 1954 |
+
inside = sum(1 for r in roster.values() if not (len(r) > 5 and r[5]))
|
| 1955 |
+
return {"total": len(roster), "in": inside, "left": len(roster) - inside}
|
| 1956 |
+
|
| 1957 |
+
def mem_join(cid, member, ts=None, src="rt"):
|
| 1958 |
+
"""有人进来(实时事件或扫描发现)➜ 记进台账;返回 True 表示这是个新面孔"""
|
| 1959 |
+
ts = int(ts or time.time()); key = str(getattr(member, 'id', member))
|
| 1960 |
+
roster = ch_mem(cid)["roster"]; prev = roster.get(key)
|
| 1961 |
+
if prev and not (len(prev) > 5 and prev[5]):
|
| 1962 |
+
row = _member_row(member, prev[4], 0)
|
| 1963 |
+
if any(row[:3]): roster[key] = row # 只刷新资料,不算新人
|
| 1964 |
+
return False
|
| 1965 |
+
row = _member_row(member, prev[4] if prev else ts, 0)
|
| 1966 |
+
roster[key] = row
|
| 1967 |
+
mem_log(cid, key, row, "join", src)
|
| 1968 |
+
return True
|
| 1969 |
+
|
| 1970 |
+
def mem_left(cid, key, ts=None, src="rt"):
|
| 1971 |
+
"""有人退出 ➜ 只打退出标记,名单里仍然留着(历史可查)"""
|
| 1972 |
+
ts = int(ts or time.time()); key = str(key)
|
| 1973 |
+
roster = ch_mem(cid)["roster"]; row = roster.get(key)
|
| 1974 |
+
if not row: return False
|
| 1975 |
+
if len(row) < 6: row = list(row) + [0]
|
| 1976 |
+
if row[5]: return False
|
| 1977 |
+
row[5] = ts; roster[key] = row
|
| 1978 |
+
mem_log(cid, key, row, "left", src)
|
| 1979 |
+
return True
|
| 1980 |
+
|
| 1981 |
+
def mem_seed(cid, members_index):
|
| 1982 |
+
"""把旧版监控任务里的 members_index 迁进台账(只做一次)"""
|
| 1983 |
+
m = ch_mem(cid)
|
| 1984 |
+
if m["roster"] or not members_index: return
|
| 1985 |
+
for key, row in (members_index or {}).items():
|
| 1986 |
+
r = list(row)
|
| 1987 |
+
while len(r) < 5: r.append(0)
|
| 1988 |
+
m["roster"][str(key)] = r[:5] + [0]
|
| 1989 |
+
m["first_scan_at"] = min([r[4] for r in m["roster"].values()] or [int(time.time())])
|
| 1990 |
+
|
| 1991 |
+
def mem_owners(cid):
|
| 1992 |
+
"""哪些用户在监控这个频道(备份和事件推给他们)"""
|
| 1993 |
+
out = []
|
| 1994 |
+
for uid, u in DATA.get("users", {}).items():
|
| 1995 |
+
if any(str(x.get("channel_id")) == str(cid) for x in (u.get("member_monitors") or [])):
|
| 1996 |
+
out.append(uid)
|
| 1997 |
+
return out
|
| 1998 |
|
| 1999 |
def _write_member_csv(members_index, path):
|
| 2000 |
with open(path, 'w', encoding='utf-8-sig', newline='') as f:
|
| 2001 |
writer = csv.writer(f)
|
| 2002 |
+
writer.writerow(['UserID','Username','FirstName','LastName','IsBot','FirstSeen','Status','LeftAt','BackupTime'])
|
| 2003 |
backup_time = datetime.now(BJ_TZ).strftime("%Y-%m-%d %H:%M:%S")
|
| 2004 |
+
fmt = lambda ts: datetime.fromtimestamp(int(ts), BJ_TZ).strftime("%Y-%m-%d %H:%M:%S") if ts else ''
|
| 2005 |
for uid_str, row in sorted(members_index.items(), key=lambda kv: kv[1][4]):
|
| 2006 |
+
gone = row[5] if len(row) > 5 else 0
|
| 2007 |
writer.writerow([uid_str, f"@{row[0]}" if row[0] else '', row[1], row[2],
|
| 2008 |
+
'是' if row[3] else '否', fmt(row[4]), '已退出' if gone else '在册',
|
| 2009 |
+
fmt(gone), backup_time])
|
| 2010 |
|
| 2011 |
def _upload_member_backup(uid, ch_id, members_index):
|
| 2012 |
remote_dir, remote_path, filename = _member_backup_paths(uid, ch_id)
|
|
|
|
| 2040 |
try: os.remove(temp_path)
|
| 2041 |
except OSError: pass
|
| 2042 |
|
| 2043 |
+
# ===== 🆕 有人进来就备份:去抖上传,连着进 10 个人也只传一次 =====
|
| 2044 |
+
_mem_bk_lock = Lock()
|
| 2045 |
+
_mem_bk_timers = {}
|
| 2046 |
+
|
| 2047 |
+
def mem_backup_now(cid):
|
| 2048 |
+
"""台账 ➜ CSV ➜ 每个监控者自己的 WebDAV 目录"""
|
| 2049 |
+
cid = str(cid); roster = ch_mem(cid)["roster"]
|
| 2050 |
+
if not roster: return
|
| 2051 |
+
st = mem_stats(cid); now = int(time.time())
|
| 2052 |
+
for uid in mem_owners(cid):
|
| 2053 |
+
try:
|
| 2054 |
+
path = _upload_member_backup(uid, cid, roster)
|
| 2055 |
+
for mon in DATA["users"].get(uid, {}).get("member_monitors", []):
|
| 2056 |
+
if str(mon.get("channel_id")) == cid:
|
| 2057 |
+
mon.update({"remote_path": path, "member_count": st["total"],
|
| 2058 |
+
"in_count": st["in"], "left_total": st["left"], "backup_at": now})
|
| 2059 |
+
except Exception as e:
|
| 2060 |
+
print(f"❌ [成员备份] {cid}: {str(e)[:80]}")
|
| 2061 |
+
ch_mem(cid)["backup_at"] = now
|
| 2062 |
+
save_data_soon()
|
| 2063 |
+
|
| 2064 |
+
def mem_backup_soon(cid, delay=15):
|
| 2065 |
+
cid = str(cid)
|
| 2066 |
+
with _mem_bk_lock:
|
| 2067 |
+
t = _mem_bk_timers.get(cid)
|
| 2068 |
+
if t is not None and t.is_alive(): return
|
| 2069 |
+
def run():
|
| 2070 |
+
with _mem_bk_lock: _mem_bk_timers.pop(cid, None)
|
| 2071 |
+
try: mem_backup_now(cid)
|
| 2072 |
+
except Exception as e: print(f"❌ [成员备份] {cid}: {str(e)[:80]}")
|
| 2073 |
+
t = Timer(delay, run); t.daemon = True; _mem_bk_timers[cid] = t; t.start()
|
| 2074 |
+
|
| 2075 |
+
class _MemStub:
|
| 2076 |
+
"""只拿到 user_id 的场合(事件里查不到资料),先占个位,下次扫描补全"""
|
| 2077 |
+
def __init__(self, i): self.id = i; self.username = ''; self.first_name = ''; self.last_name = ''; self.bot = False
|
| 2078 |
+
|
| 2079 |
+
def mem_watch_keys():
|
| 2080 |
+
"""当前被监控的频道标识(原样,可能是 -100… 也可能是 @用户名)"""
|
| 2081 |
+
out = set()
|
| 2082 |
+
for u in DATA.get("users", {}).values():
|
| 2083 |
+
for mon in (u.get("member_monitors") or []):
|
| 2084 |
+
cid = str(mon.get("channel_id", "")).strip()
|
| 2085 |
+
if cid: out.add(cid)
|
| 2086 |
+
return out
|
| 2087 |
+
|
| 2088 |
+
def mem_match(chat_id, username=""):
|
| 2089 |
+
"""这条事件属于哪些被监控频道"""
|
| 2090 |
+
nid = str(chat_id or ""); hits = []
|
| 2091 |
+
nid100 = nid if nid.startswith("-100") else (f"-100{nid.lstrip('-')}" if nid else "")
|
| 2092 |
+
un = ("@" + str(username).lstrip("@")).lower() if username else ""
|
| 2093 |
+
for key in mem_watch_keys():
|
| 2094 |
+
k = normalize_chat(key) or key
|
| 2095 |
+
if k in (nid, nid100) or (un and k.lower() == un): hits.append(key)
|
| 2096 |
+
return hits
|
| 2097 |
+
|
| 2098 |
+
def mem_apply(keys, users, joined, src="rt"):
|
| 2099 |
+
"""一批人进/退 ➜ 写台账 + 推事件 + 触发备份(Telethon 和 Bot API 两条实时通道共用)"""
|
| 2100 |
+
now = int(time.time()); touched = False
|
| 2101 |
+
for key in keys:
|
| 2102 |
+
hit = [u for u in users if (mem_join(key, u, now, src) if joined
|
| 2103 |
+
else mem_left(key, getattr(u, 'id', u), now, src))]
|
| 2104 |
+
if not hit: continue
|
| 2105 |
+
touched = True
|
| 2106 |
+
led = ch_mem(key); led["rt_at"] = now; led["rt_src"] = src
|
| 2107 |
+
st = mem_stats(key)
|
| 2108 |
+
names = "、".join([_member_display(_member_row(u, 0)) or str(getattr(u, 'id', u)) for u in hit[:5]])
|
| 2109 |
+
verb = f"➕ 新成员 {len(hit)}" if joined else f"➖ 退出 {len(hit)}"
|
| 2110 |
+
for owner in mem_owners(key):
|
| 2111 |
+
push_event(owner, "members_done", f"👥 {ch_label(owner, key)} {verb}:{names}"
|
| 2112 |
+
f"{'…' if len(hit) > 5 else ''} · 累计 {st['total']} / 在册 {st['in']}")
|
| 2113 |
+
mem_backup_soon(key) # 记录完就备份(去抖 15 秒,连着进人只传一次)
|
| 2114 |
+
if touched: save_data_soon()
|
| 2115 |
+
return touched
|
| 2116 |
|
| 2117 |
def run_smart_backup_v2(latest_id, uid, src, tgt, wash=False):
|
| 2118 |
global TL_LOOP, TL_CLIENT
|
|
|
|
| 2517 |
TL_CLIENT = TelegramClient(StringSession(user_session), int(api_id_str), api_hash)
|
| 2518 |
|
| 2519 |
async def update_member_backups():
|
| 2520 |
+
"""成员监控:首次全量建档,之后按间隔对一次账(实时事件是主力,这里只兜底核对)
|
| 2521 |
+
关键:名单是累加台账,扫描看到 200 人(Telegram 上限)不代表只有 200 人,
|
| 2522 |
+
没扫到的一律保留,只有在名单完整时才判定谁退出了。"""
|
| 2523 |
current_time = int(time.time()); data_changed = False
|
| 2524 |
for uid, u_data in DATA.get("users", {}).items():
|
| 2525 |
for monitor in u_data.get("member_monitors", []):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2526 |
ch_id = str(monitor.get("channel_id", "")).strip()
|
| 2527 |
if not ch_id: continue
|
| 2528 |
+
if monitor.get("members_index"): # 旧版数据搬进频道台账,之后不再各存一份
|
| 2529 |
+
mem_seed(ch_id, monitor.pop("members_index")); data_changed = True
|
| 2530 |
+
led = ch_mem(ch_id)
|
| 2531 |
+
interval_sec = max(1, int(monitor.get("interval", 60))) * 60
|
| 2532 |
+
is_first_run = not led["roster"]
|
| 2533 |
+
if not is_first_run and current_time - int(monitor.get("last_run", 0)) < interval_sec: continue
|
| 2534 |
try:
|
| 2535 |
members = [member async for member in TL_CLIENT.iter_participants(peer(ch_id))]
|
| 2536 |
+
joined = [m for m in members if mem_join(ch_id, m, current_time, "scan")]
|
| 2537 |
+
seen = {str(m.id) for m in members}
|
| 2538 |
+
capped = len(members) >= MEMBER_VIEW_CAP # 只看得到 200 个 ➜ 名单不完整
|
| 2539 |
+
left = []
|
| 2540 |
+
if not capped: # 名单完整时才敢判退出;被 200 截断时一个都不动
|
| 2541 |
+
for k, r in list(led["roster"].items()):
|
| 2542 |
+
if k not in seen and not (len(r) > 5 and r[5]) and mem_left(ch_id, k, current_time, "scan"):
|
| 2543 |
+
left.append(k)
|
| 2544 |
+
led.update({"scan_at":current_time, "capped":capped, "visible":len(members),
|
| 2545 |
+
"first_scan_at":led.get("first_scan_at") or current_time})
|
| 2546 |
+
st = mem_stats(ch_id)
|
| 2547 |
changed = bool(joined or left) or is_first_run
|
| 2548 |
remote_path = monitor.get("remote_path", "")
|
| 2549 |
if changed: # 只有名单发生变化才重新上传,避免无意义写入
|
| 2550 |
+
remote_path = await asyncio.to_thread(_upload_member_backup, uid, ch_id, led["roster"])
|
| 2551 |
monitor.pop("members", None)
|
| 2552 |
+
monitor.update({"last_run":current_time, "member_count":st["total"], "in_count":st["in"],
|
| 2553 |
+
"left_total":st["left"], "remote_path":remote_path, "last_error":"", "capped":capped,
|
| 2554 |
"joined_count":len(joined), "left_count":len(left),
|
| 2555 |
"first_scan_at":monitor.get("first_scan_at") or current_time})
|
| 2556 |
if is_first_run:
|
| 2557 |
+
tip = f"(Telegram 单次最多可见 {MEMBER_VIEW_CAP} 人,之后有人进来实时累加)" if capped else ""
|
| 2558 |
+
push_event(uid, "members_done", f"✅ {ch_label(uid, ch_id)} 首次建档:{st['total']} 人{tip}")
|
| 2559 |
elif changed:
|
| 2560 |
+
names = "、".join([_member_display(_member_row(m, 0)) for m in joined[:5]])
|
| 2561 |
detail = f"({names}{'…' if len(joined) > 5 else ''})" if names else ""
|
| 2562 |
+
push_event(uid, "members_done",
|
| 2563 |
+
f"👥 {ch_label(uid, ch_id)} 对账:新增 {len(joined)} / 退出 {len(left)}{detail} · 累计 {st['total']}")
|
| 2564 |
except Exception as e:
|
| 2565 |
monitor.update({"last_run":current_time, "last_error":str(e)})
|
| 2566 |
print(f"❌ [成员监控] {ch_id}: {e}")
|
|
|
|
| 2834 |
return
|
| 2835 |
try: print(f"👤 Userbot: {TL_LOOP.run_until_complete(TL_CLIENT.get_me()).first_name}")
|
| 2836 |
except Exception: pass
|
| 2837 |
+
# ===== 🆕 实时成员监控:有人进/退,Telegram 一推过来就记账 + 备份,不等下一轮扫描 =====
|
| 2838 |
+
@TL_CLIENT.on(events.ChatAction)
|
| 2839 |
+
async def _member_realtime(ev):
|
| 2840 |
+
try:
|
| 2841 |
+
joined = bool(getattr(ev, "user_joined", False) or getattr(ev, "user_added", False))
|
| 2842 |
+
gone = bool(getattr(ev, "user_left", False) or getattr(ev, "user_kicked", False))
|
| 2843 |
+
if not (joined or gone): return
|
| 2844 |
+
uname = getattr(getattr(ev, "chat", None), "username", "") or ""
|
| 2845 |
+
keys = mem_match(getattr(ev, "chat_id", None), uname)
|
| 2846 |
+
if not keys: return
|
| 2847 |
+
try: users = list(await ev.get_users() or [])
|
| 2848 |
+
except Exception: users = []
|
| 2849 |
+
if not users: # 资料查不到也不能丢事件,先按 ID 记上
|
| 2850 |
+
users = [_MemStub(i) for i in (getattr(ev, "user_ids", None) or [])]
|
| 2851 |
+
if not users: return
|
| 2852 |
+
mem_apply(keys, users, joined, "rt")
|
| 2853 |
+
except Exception as e:
|
| 2854 |
+
print(f"❌ [实时成员] {str(e)[:100]}")
|
| 2855 |
+
|
| 2856 |
scheduler = AsyncIOScheduler(event_loop=TL_LOOP)
|
| 2857 |
scheduler.add_job(update_channel_msg, 'interval', seconds=10)
|
| 2858 |
scheduler.add_job(update_member_backups, 'interval', seconds=10)
|
|
|
|
| 3101 |
try: return _tl_run(_go(), timeout=60)
|
| 3102 |
except Exception: return None
|
| 3103 |
|
| 3104 |
+
def _tl_join_link(url):
|
| 3105 |
+
"""🆕 直接用一条现成的邀请链接把 userbot 拉进去,返回 (频道内部ID, 标题)
|
| 3106 |
+
一次性用法:只借这一次,不存起来(自动跟进只认机器人自己那条主链接)"""
|
| 3107 |
+
from telethon.tl.functions.messages import ImportChatInviteRequest, CheckChatInviteRequest
|
| 3108 |
+
if not TL_CLIENT: raise RuntimeError("Userbot 没在跑,先 /login")
|
| 3109 |
+
m = _INVITE_RE.search(url or "")
|
| 3110 |
+
if not m: raise ValueError("这不是 t.me/+xxxx 形式的邀请链接")
|
| 3111 |
+
h = m.group(1)
|
| 3112 |
+
async def _go():
|
| 3113 |
+
try: res = await TL_CLIENT(ImportChatInviteRequest(h))
|
| 3114 |
+
except Exception as e:
|
| 3115 |
+
if type(e).__name__ != "UserAlreadyParticipantError": raise
|
| 3116 |
+
res = await TL_CLIENT(CheckChatInviteRequest(h))
|
| 3117 |
+
ch = None
|
| 3118 |
+
for c in (getattr(res, "chats", None) or []): ch = c; break
|
| 3119 |
+
if ch is None: ch = getattr(res, "chat", None)
|
| 3120 |
+
return (int(getattr(ch, "id", 0) or 0), getattr(ch, "title", "") or "") if ch is not None else (0, "")
|
| 3121 |
+
return _tl_run(_go(), timeout=300)
|
| 3122 |
+
|
| 3123 |
def _track_link(cid, url):
|
| 3124 |
"""🆕 记下我们开过的每条邀请链接:万一没当场收回,之后还能一键清掉"""
|
| 3125 |
store = DATA.setdefault("userbot", {}).setdefault("links", {}).setdefault(str(cid), [])
|
|
|
|
| 3133 |
save_data_soon()
|
| 3134 |
|
| 3135 |
def revoke_tracked_links(cid=None):
|
| 3136 |
+
"""把记录在案的邀请链接全部撤销 + 删除(cid 为空 = 所有频道)
|
| 3137 |
+
返回 (处理条数, 删不掉的 {频道: 条数})"""
|
| 3138 |
store = DATA.setdefault("userbot", {}).setdefault("links", {})
|
| 3139 |
+
n = 0; left = {}
|
| 3140 |
for key in ([str(cid)] if cid else list(store.keys())):
|
| 3141 |
urls = list(store.get(key) or [])
|
| 3142 |
if not urls: continue
|
| 3143 |
+
n += len(urls)
|
| 3144 |
+
k = _purge_links(key, urls, quiet=True)
|
| 3145 |
+
if k: left[key] = k
|
| 3146 |
save_data()
|
| 3147 |
+
return n, left
|
| 3148 |
|
| 3149 |
def tl_sweep_bot_links(cid, bot_id):
|
| 3150 |
"""userbot 是频道创建者时,能把机器人建过的邀请链接列出来,逐条撤销并彻底删除
|
|
|
|
| 3158 |
for inv in (getattr(res, "invites", []) or []):
|
| 3159 |
link = getattr(inv, "link", "")
|
| 3160 |
if not link: continue
|
| 3161 |
+
if getattr(inv, "permanent", False):
|
| 3162 |
+
# ⚠️ 绝不能撤销机器人的「主链接」:撤了 Telegram 会立刻另发一条新的(等于白撤),
|
| 3163 |
+
# 而 Bot API 的 getChat 还会继续吐那条已废的 hash ➜ 下次复用主链接必然
|
| 3164 |
+
# InviteHashExpired,只好再建新链接,痕迹就是这么攒出来的
|
| 3165 |
+
print(f"↩️ 跳过机器人主链接(不能撤):{cid}")
|
| 3166 |
+
continue
|
| 3167 |
try: await TL_CLIENT(EditExportedChatInviteRequest(peer=peer(cid), link=link, revoked=True))
|
| 3168 |
except Exception: pass
|
| 3169 |
try: await TL_CLIENT(DeleteExportedChatInviteRequest(peer=peer(cid), link=link))
|
|
|
|
| 3175 |
try: return _tl_run(_go(), timeout=90), ""
|
| 3176 |
except Exception as e: return 0, _err_txt(e)
|
| 3177 |
|
| 3178 |
+
def _purge_links(cid, urls, quiet=False):
|
| 3179 |
+
"""🆕 撤销 + 尽量彻底删除我们开过的邀请链接
|
| 3180 |
+
Bot API 只能「撤销」,撤销完还留在频道的「已撤销的链接」里;真正删除只能靠 userbot,
|
| 3181 |
+
而 userbot 只删得掉自己建的、或它是频道创建者时才删得掉机器人建的。
|
| 3182 |
+
删不掉就如实说 + 继续记账(之后 /cleanlinks 还能再试),返回没删掉的条数(quiet=True)
|
| 3183 |
+
或一句给主人看的说明(默认)"""
|
| 3184 |
urls = [u for u in (urls or []) if u]
|
| 3185 |
+
if not urls: return 0 if quiet else ""
|
| 3186 |
+
try: # 保险:主链接绝不能进这个函数(撤了它 getChat 就会一直吐死 hash)
|
| 3187 |
+
main = getattr(bot.get_chat(peer(cid)), "invite_link", "") or ""
|
| 3188 |
+
if main and main in urls:
|
| 3189 |
+
urls = [u for u in urls if u != main]; _untrack_link(cid, main)
|
| 3190 |
+
print(f"↩️ 跳过主链接,不撤销:{cid}")
|
| 3191 |
+
if not urls: return 0 if quiet else ""
|
| 3192 |
+
except Exception: pass
|
| 3193 |
for u in urls:
|
| 3194 |
try: bot.revoke_chat_invite_link(peer(cid), u)
|
| 3195 |
except Exception as e: print(f"⚠️ 撤销链接失败 {cid}: {str(e)[:70]}")
|
| 3196 |
+
gone = []
|
| 3197 |
+
if TL_CLIENT:
|
| 3198 |
+
from telethon.tl.functions.messages import (DeleteExportedChatInviteRequest,
|
| 3199 |
+
DeleteRevokedExportedChatInvitesRequest)
|
| 3200 |
+
async def _go():
|
| 3201 |
+
out = []
|
| 3202 |
+
for u in urls:
|
| 3203 |
+
try:
|
| 3204 |
+
await TL_CLIENT(DeleteExportedChatInviteRequest(peer=peer(cid), link=u)); out.append(u)
|
| 3205 |
+
except Exception as e: print(f"⚠️ 删链接失败 {cid} …{u[-8:]}: {type(e).__name__}")
|
| 3206 |
+
if BOT_ID and len(out) < len(urls):
|
| 3207 |
+
# 一次把机器人名下所有已撤销的链接清空(userbot 是创建者时才有这个权限)
|
| 3208 |
+
try:
|
| 3209 |
+
await TL_CLIENT(DeleteRevokedExportedChatInvitesRequest(peer=peer(cid), admin_id=BOT_ID))
|
| 3210 |
+
out = list(urls)
|
| 3211 |
+
except Exception as e: print(f"⚠️ 清空已撤销链接失败 {cid}: {type(e).__name__}")
|
| 3212 |
+
return out
|
| 3213 |
+
try: gone = _tl_run(_go(), timeout=60) or []
|
| 3214 |
+
except Exception as e: print(f"⚠️ 删除链接失败 {cid}: {str(e)[:70]}")
|
| 3215 |
+
left = [u for u in urls if u not in gone]
|
| 3216 |
+
for u in gone: _untrack_link(cid, u) # 删不掉的留在账上,/cleanlinks 还能再试
|
| 3217 |
+
if quiet: return len(left)
|
| 3218 |
+
if not left: return ""
|
| 3219 |
+
return (f"🧹 临时邀请链接已撤销(还有 {len(left)} 条删不掉,留在频道『已撤销的链接』里)\n"
|
| 3220 |
+
f"(userbot 不是这个频道的创建者,只有你删得掉:频道 → 管理 → 邀请链接 → "
|
| 3221 |
+
f"点机器人那一栏 → 已撤销的链接 → 长按删除;或者发 /cleanlinks 让我再试一次)")
|
| 3222 |
|
| 3223 |
def _bot_invite_links(cid, chat=None, fresh=False):
|
| 3224 |
"""返回 (候选链接, 其中我们新建的, 失败原因)
|
| 3225 |
+
⓪ 首选:机器人自己现成的主邀请链接 —— 一条新链接都不建,事后也没东西要清
|
| 3226 |
+
① 现成的被判失效后重试(fresh=True):先「原地重开机器人自己的主链接」
|
| 3227 |
+
—— exportChatInviteLink 是替换而不是新增,面板里还是机器人那一条,
|
| 3228 |
+
不会多出「userbot」这种一眼看出是机器人干的记录,事后也不用撤销
|
| 3229 |
+
② 连主链接都开不出来,才建额外链接(不起名字、先不限量:member_limit=1 常被判
|
| 3230 |
+
InviteHashExpired);只有这一类会记账,进去后立刻撤销 + 尽量删除"""
|
| 3231 |
links, created, why = [], [], ""
|
| 3232 |
if not fresh:
|
| 3233 |
+
try: # ⓪ 机器人自己那条现成的主链接:借用,事后什么都不用清
|
| 3234 |
u = getattr(chat if chat is not None else bot.get_chat(cid), "invite_link", "") or ""
|
| 3235 |
+
if u and u not in links: links.append(u)
|
| 3236 |
+
except Exception as e: why = str(e)[:140]
|
| 3237 |
+
else:
|
| 3238 |
+
try: # 原地换一条全新的主链接(旧的那条本来就已经被判失效了)
|
| 3239 |
+
u = bot.export_chat_invite_link(cid) or ""
|
| 3240 |
if u: links.append(u)
|
| 3241 |
except Exception as e: why = str(e)[:140]
|
| 3242 |
if not links:
|
| 3243 |
+
for kw in (dict(), dict(member_limit=1)):
|
| 3244 |
try:
|
| 3245 |
u = getattr(bot.create_chat_invite_link(cid, **kw), "invite_link", "") or ""
|
| 3246 |
if u and u not in links:
|
| 3247 |
links.append(u); created.append(u); _track_link(cid, u)
|
| 3248 |
+
if links: break # 一条够用了,别多留痕迹
|
| 3249 |
except Exception as e:
|
| 3250 |
why = why or str(e)[:140]
|
| 3251 |
if links: return links, created, ""
|
|
|
|
| 3318 |
joined = False; how = ""
|
| 3319 |
if not uname and not invites:
|
| 3320 |
lines.append(f"❌ 拿不到 **{label}** 的邀请链接:{invite_err or '未知原因'}\n"
|
| 3321 |
+
f"(私密频道只能靠邀请链接把 Userbot 拉进去。最省事的办法:把频道 → 邀请链接 "
|
| 3322 |
+
f"最上面那条主链接复制过来,发 `/joinlink <链接>` —— 我借用一下,不撤销也不删除。"
|
| 3323 |
+
f"或者去频道 → 管理员 → 机器人,打开『添加成员/邀请用户』后发 /follow {cid} 重试;"
|
| 3324 |
+
f"也可以你自己把 {_userbot_name()} 拉进这个频道)")
|
| 3325 |
return False, lines
|
| 3326 |
entity = ("@" + uname) if uname else target
|
| 3327 |
try:
|
|
|
|
| 3361 |
except Exception: pass
|
| 3362 |
tail = ("\n" + "\n".join(diag)) if diag else ""
|
| 3363 |
lines.append(f"❌ Userbot 加入 **{label}** 失败:{_err_txt(e)}{hint}{tail}\n"
|
| 3364 |
+
f"(最省事:把频道那条主链接复制过来发 `/joinlink <链接>`,我借用它进去、不撤不删;"
|
| 3365 |
+
f"或者手动把 {_userbot_name()} 拉进这个频道,效果一样)")
|
| 3366 |
# 超时不代表没进去,再确认一次
|
| 3367 |
if not joined and _tl_joined(target):
|
| 3368 |
lines[-1] = f"✅ Userbot 已在 **{label}** 里(前一步只是响应超时)"; joined = True
|
| 3369 |
finally:
|
| 3370 |
# 只清我们自己开的(现成的主链接绝不能动);等审批的先留着,撤销会连带取消申请
|
| 3371 |
+
if created and "申请" not in how:
|
| 3372 |
+
note = _purge_links(cid, created)
|
| 3373 |
+
if note: lines.append(note)
|
| 3374 |
if not joined: return False, lines
|
| 3375 |
# 关联讨论组:榜单统计要读评论,私密讨论组不进就读不到
|
| 3376 |
if with_linked:
|
|
|
|
| 3460 |
else:
|
| 3461 |
print("⚠️ 当前 pyTelegramBotAPI 版本不支持 my_chat_member,userbot 自动跟进只能用 /follow 手动触发")
|
| 3462 |
|
| 3463 |
+
# 🆕 第二条实时通道:机器人自己是管理员时,Bot API 也会推成员进出
|
| 3464 |
+
# (userbot 不是管理员就收不到 Telethon 那条,两条通道同时开,谁先到算谁,台账里天然去重)
|
| 3465 |
+
if hasattr(bot, "chat_member_handler"):
|
| 3466 |
+
@bot.chat_member_handler(func=lambda u: True)
|
| 3467 |
+
def on_chat_member(upd):
|
| 3468 |
+
try:
|
| 3469 |
+
chat = getattr(upd, "chat", None)
|
| 3470 |
+
keys = mem_match(getattr(chat, "id", ""), getattr(chat, "username", "") or "")
|
| 3471 |
+
if not keys: return
|
| 3472 |
+
user = getattr(getattr(upd, "new_chat_member", None), "user", None)
|
| 3473 |
+
if not user: return
|
| 3474 |
+
inside = lambda cm: (getattr(cm, "status", "") in ("member", "administrator", "creator")
|
| 3475 |
+
or (getattr(cm, "status", "") == "restricted" and getattr(cm, "is_member", False)))
|
| 3476 |
+
was, now_in = inside(getattr(upd, "old_chat_member", None)), inside(getattr(upd, "new_chat_member", None))
|
| 3477 |
+
if was == now_in: return # 只是改权限/改称号,不是进出
|
| 3478 |
+
mem_apply(keys, [user], now_in, "bot")
|
| 3479 |
+
except Exception as e:
|
| 3480 |
+
print(f"❌ [实时成员/Bot] {str(e)[:100]}")
|
| 3481 |
+
else:
|
| 3482 |
+
print("⚠️ 当前 pyTelegramBotAPI 版本不支持 chat_member,实时成员监控只走 userbot 那条")
|
| 3483 |
+
|
| 3484 |
# 🆕 给所有「私聊指令 / 内联按钮」处理器套一层白名单检查(频道消息处理器不在此列)
|
| 3485 |
def guard_bot_handlers():
|
| 3486 |
def wrap(fn, kind):
|
|
|
|
| 3513 |
@app.route('/api/avatar/<key>')
|
| 3514 |
def api_avatar(key):
|
| 3515 |
path = _avatar_path(key)
|
| 3516 |
+
if not os.path.exists(path) and not dav_ava_get(key): # 🆕 本地没有先去 WebDAV 拿,别回 404
|
| 3517 |
+
return ("", 404)
|
| 3518 |
st = os.stat(path)
|
| 3519 |
# 🆕 URL 自带头像指纹,同一个地址的内容永远不变 ➜ immutable + ETag,浏览器第二次起直接用本地缓存
|
| 3520 |
+
# ETag 只跟指纹+大小有关(不用 mtime):容器重启后从 WebDAV 拉回来的同一张图,ETag 不变 ➜ 继续 304
|
| 3521 |
+
etag = f'W/"{request.args.get("v", "0")}-{st.st_size}"'
|
| 3522 |
if request.headers.get("If-None-Match") == etag:
|
| 3523 |
resp = Response(status=304)
|
| 3524 |
else:
|
|
|
|
| 3538 |
meta = DATA.setdefault("ch_meta", {}).setdefault(str(info["id"]), {})
|
| 3539 |
meta.update({"checked": int(time.time()), "title": info.get("title", ""),
|
| 3540 |
"username": info.get("username", ""), "members": info.get("members", 0),
|
| 3541 |
+
"avatar": info.get("avatar", ""),
|
| 3542 |
+
"photo_uid": info.get("photo_uid", "")}) # 🆕 指纹要存,下次才知道头像没换
|
| 3543 |
save_data_soon()
|
| 3544 |
return jsonify(info)
|
| 3545 |
|
|
|
|
| 3585 |
@need_auth
|
| 3586 |
def api_get_data(uid):
|
| 3587 |
u = dict(DATA["users"].get(uid,{}))
|
| 3588 |
+
# 成员名单体积大,前端只要统计数字,剔除后再下发(详情走 /api/member_log)
|
| 3589 |
monitors = []
|
| 3590 |
for mon in u.get("member_monitors", []):
|
| 3591 |
slim = {k:v for k,v in mon.items() if k != "members_index"}
|
| 3592 |
+
cid = str(mon.get("channel_id",""))
|
| 3593 |
+
led = DATA.get("ch_members", {}).get(cid) or {}
|
| 3594 |
+
st = mem_stats(cid) if led else {"total": len(mon.get("members_index") or {}) or mon.get("member_count",0), "in":0, "left":0}
|
| 3595 |
+
slim.update({"member_count":st["total"], "in_count":st["in"], "left_total":st["left"],
|
| 3596 |
+
"rt_at":led.get("rt_at",0), "scan_at":led.get("scan_at",0),
|
| 3597 |
+
"capped":bool(led.get("capped")), "log_n":len(led.get("log") or [])})
|
| 3598 |
monitors.append(slim)
|
| 3599 |
if "member_monitors" in u: u["member_monitors"] = monitors
|
| 3600 |
return jsonify({"ok":True,"user":u,"msg_count":len(DATA.get("msg_map",{})),
|
|
|
|
| 4007 |
if any(str(item.get("channel_id")) == ch_id for item in monitors): return jsonify({"ok":False,"msg":"该频道已在监控中"})
|
| 4008 |
try: interval = max(1, int(d.get('interval', 60)))
|
| 4009 |
except (TypeError, ValueError): return jsonify({"ok":False,"msg":"检测间隔必须是整数分钟"})
|
| 4010 |
+
# 台账为空 ➜ 调度器 10 秒内做一次全量建档(首次记录所有人),之后实时事件为主、按 interval 兜底对账
|
| 4011 |
monitors.append({"channel_id":ch_id,"started_at":int(time.time()),"interval":interval,
|
| 4012 |
"last_run":0,"member_count":0,"remote_path":"","last_error":"",
|
| 4013 |
+
"joined_count":0,"left_count":0,"first_scan_at":0})
|
| 4014 |
save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
|
| 4015 |
|
| 4016 |
@app.route('/api/member_monitors/<path:cid>', methods=['PUT'])
|
|
|
|
| 4029 |
def api_download_member_monitor(uid, cid):
|
| 4030 |
monitor = next((m for m in DATA["users"][uid].setdefault("member_monitors", []) if str(m.get("channel_id")) == cid), None)
|
| 4031 |
if not monitor: return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
|
| 4032 |
+
def work():
|
| 4033 |
+
try: mem_backup_now(cid) # 先把台账最新状态写上去,再发,永远是最新名单
|
| 4034 |
+
except Exception as e: print(f"⚠️ 成员备份刷新失败 {cid}: {str(e)[:60]}")
|
| 4035 |
+
send_member_backup(uid, cid)
|
| 4036 |
+
Thread(target=work, daemon=True).start()
|
| 4037 |
return jsonify({"ok":True,"msg":"备份文件将由机器人发送给你"})
|
| 4038 |
|
| 4039 |
+
# 🆕 单个频道的成员台账:累计人数、实时动态、成员列表(分频道各记各的)
|
| 4040 |
+
@app.route('/api/member_log/<path:cid>')
|
| 4041 |
+
@need_auth
|
| 4042 |
+
def api_member_log(uid, cid):
|
| 4043 |
+
if not any(str(m.get("channel_id")) == cid for m in DATA["users"].get(uid, {}).get("member_monitors", [])):
|
| 4044 |
+
return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
|
| 4045 |
+
led = ch_mem(cid); roster = led["roster"]
|
| 4046 |
+
q = (request.args.get("q") or "").strip().lower()
|
| 4047 |
+
rows = sorted(roster.items(), key=lambda kv: (kv[1][4] or 0), reverse=True)
|
| 4048 |
+
if q: rows = [kv for kv in rows if q in (_member_display(kv[1]) + " " + kv[1][0] + " " + kv[0]).lower()]
|
| 4049 |
+
members = [{"id":k, "name":_member_display(r), "un":r[0], "bot":bool(r[3]),
|
| 4050 |
+
"t":r[4], "left":(r[5] if len(r) > 5 else 0)} for k, r in rows[:MEMBER_VIEW_CAP]]
|
| 4051 |
+
return jsonify({"ok":True, "id":cid, "title":ch_label(uid, cid), "stats":mem_stats(cid),
|
| 4052 |
+
"cap":MEMBER_VIEW_CAP, "shown":len(members), "matched":len(rows),
|
| 4053 |
+
"capped":bool(led.get("capped")), "visible":led.get("visible", 0),
|
| 4054 |
+
"scan_at":led.get("scan_at", 0), "rt_at":led.get("rt_at", 0),
|
| 4055 |
+
"rt_src":led.get("rt_src",""), "backup_at":led.get("backup_at", 0),
|
| 4056 |
+
"first_scan_at":led.get("first_scan_at", 0), "log":led["log"][:200]})
|
| 4057 |
+
|
| 4058 |
+
# 🆕 立即对账(不等检测间隔)
|
| 4059 |
+
@app.route('/api/member_monitors/<path:cid>/scan', methods=['POST'])
|
| 4060 |
+
@need_auth
|
| 4061 |
+
def api_scan_member_monitor(uid, cid):
|
| 4062 |
+
for mon in DATA["users"][uid].setdefault("member_monitors", []):
|
| 4063 |
+
if str(mon.get("channel_id")) == cid:
|
| 4064 |
+
mon["last_run"] = 0; save_data_soon()
|
| 4065 |
+
return jsonify({"ok":True,"msg":"已排入队列,10 秒内对一次账"})
|
| 4066 |
+
return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
|
| 4067 |
+
|
| 4068 |
@app.route('/api/member_monitors/<path:cid>', methods=['DELETE'])
|
| 4069 |
@need_auth
|
| 4070 |
def api_del_member_monitor(uid, cid):
|
|
|
|
| 4157 |
if __name__ == "__main__":
|
| 4158 |
Thread(target=lambda: app.run(host="0.0.0.0", port=7860), daemon=True).start()
|
| 4159 |
Thread(target=start_telethon_worker, daemon=True).start()
|
| 4160 |
+
Thread(target=dav_ava_warm, daemon=True).start() # 🆕 头像从 WebDAV 预热到本地,重启不再重下
|
| 4161 |
print("🔄 正在清除旧连接...")
|
| 4162 |
for attempt in range(5):
|
| 4163 |
try: bot.remove_webhook(); bot.get_updates(offset=-1, timeout=1); break
|
|
|
|
| 4166 |
while True:
|
| 4167 |
guard_bot_handlers() # 🆕 上锁:非白名单用户的指令与按钮一律拒绝
|
| 4168 |
try: bot.infinity_polling(timeout=60, long_polling_timeout=60,
|
| 4169 |
+
allowed_updates=["message","callback_query","channel_post","edited_channel_post",
|
| 4170 |
+
"my_chat_member","chat_member"]) # 🆕 chat_member = 实时成员进出
|
| 4171 |
except Exception as e:
|
| 4172 |
print(f"❌ Polling 异常: {e}"); time.sleep(10)
|
| 4173 |
try: bot.remove_webhook(); bot.get_updates(offset=-1, timeout=1)
|
webapp.html
CHANGED
|
@@ -584,8 +584,8 @@ const SEED={
|
|
| 584 |
],
|
| 585 |
address_book:{'-1001682349051':'主频道','-1001733118244':'分发一号','-1001552209873':'存档仓','-1001908744236':'镜像站','-1001445096327':'周刊编辑部','-1001612987450':'索引频道','@Nine_7Eleven':'公开频道 Nine7'},
|
| 586 |
member_monitors:[
|
| 587 |
-
{channel_id:'-1001682349051',started_at:nowS()-86400*5,interval:60,last_run:nowS()-1200,member_count:1284,remote_path:'members/backup.csv',last_error:'',joined_count:12,left_count:3,first_scan_at:nowS()-86400*5},
|
| 588 |
-
{channel_id:'@Nine_7Eleven',started_at:nowS()-86400*2,interval:120,last_run:nowS()-7200,member_count:356,remote_path:'',last_error:'ChatAdminRequired',joined_count:0,left_count:0,first_scan_at:0}
|
| 589 |
]
|
| 590 |
};
|
| 591 |
function mockDB(){try{return JSON.parse(localStorage.getItem('nine7_mock'))||null}catch(e){return null}}
|
|
@@ -671,9 +671,19 @@ async function mockApi(path,{method='GET',body={},form}={}){
|
|
| 671 |
if(method==='POST'&&seg.length===0){u.member_monitors.push({channel_id:d.ch_id,started_at:nowS(),interval:+d.interval||60,last_run:0,member_count:0,remote_path:'',last_error:''});mockSave(db);return out()}
|
| 672 |
const cid=decodeURIComponent(seg[0]||'');
|
| 673 |
if(seg[1]==='download')return{ok:true,msg:'备份文件将由机器人发送给你'};
|
|
|
|
| 674 |
if(method==='PUT'){const m2=u.member_monitors.find(x=>String(x.channel_id)===cid);if(m2){m2.interval=+d.interval;m2.last_run=0}mockSave(db);return out()}
|
| 675 |
if(method==='DELETE'){u.member_monitors=u.member_monitors.filter(x=>String(x.channel_id)!==cid);mockSave(db);return out()}
|
| 676 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
if(['btn_multi','btn_old','btn_new','btn_new_media','gen_dir','replace_tag','backup','batch_create'].includes(res))return{ok:true};
|
| 678 |
return{ok:false,msg:'演示模式不支持该操作'};
|
| 679 |
}
|
|
@@ -1487,17 +1497,19 @@ function vOps(m){
|
|
| 1487 |
</div>
|
| 1488 |
<div>
|
| 1489 |
<div class="ops-c" data-rv>
|
| 1490 |
-
<div class="cmp-t">${I('users')} 成员监控</div>
|
| 1491 |
-
<div class="cmp-s">
|
| 1492 |
<div id="monList">
|
| 1493 |
${mons.length?mons.map(mm=>`<div class="mon-row">
|
| 1494 |
<div><div style="font-weight:600">${esc(S.data.address_book?.[mm.channel_id]||'未登记频道')}</div>
|
| 1495 |
-
<div class="mono" style="color:var(--dim)">${esc(shortId(mm.channel_id))} · 每 ${mm.interval} 分钟 · ${mm.first_scan_at?'
|
| 1496 |
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
| 1497 |
-
<span class="chip">${I('users',11)} ${(mm.member_count||0).toLocaleString()}</span>
|
| 1498 |
-
${mm.
|
| 1499 |
-
${mm.
|
| 1500 |
-
${mm.last_error?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.35)">${I('triangle-alert',11)} 异常</span>`:
|
|
|
|
|
|
|
| 1501 |
<button class="ic-b" data-dl="${esc(mm.channel_id)}" title="下载备份">${I('download',14)}</button>
|
| 1502 |
<button class="ic-b" data-ed="${esc(mm.channel_id)}" title="修改间隔">${I('pencil',14)}</button>
|
| 1503 |
<button class="ic-b danger" data-de="${esc(mm.channel_id)}" title="删除">${I('trash-2',14)}</button>
|
|
@@ -1505,7 +1517,7 @@ function vOps(m){
|
|
| 1505 |
</div>
|
| 1506 |
<div class="sec-t">新增监控</div>
|
| 1507 |
${fld('频道',picker('m_ch',S.data.address_book,''))}
|
| 1508 |
-
${fld('检测间隔(分钟)',inp('m_intv',60,'60','number','min="1"'))}
|
| 1509 |
<button class="btn btn-acc" id="m_add" style="width:100%;justify-content:center">${I('plus',15)} 添加监控</button>
|
| 1510 |
</div>
|
| 1511 |
<!-- 🆕 白名单:在这里随时增删,不用改环境变量 -->
|
|
@@ -1569,14 +1581,57 @@ function vOps(m){
|
|
| 1569 |
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(cid),{method:'PUT',body:{interval:+gv('m_nintv',w)||60}}));toast('间隔已更新');w._close();go('ops')}catch(e){toast(e.message,'err')}
|
| 1570 |
};
|
| 1571 |
});
|
| 1572 |
-
$$('[data-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1573 |
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(b.dataset.de),{method:'DELETE'}));toast('监控已删除');go('ops')}catch(e){toast(e.message,'err')}
|
| 1574 |
}));
|
| 1575 |
loadAcl(m);
|
| 1576 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1577 |
/* 🆕 白名单面板 */
|
| 1578 |
-
async function loadAcl(m){
|
| 1579 |
-
try{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1580 |
catch(e){const b=$('#aclBox',m);if(b)b.innerHTML=`<div class="fld-h" style="padding:14px 0">读取失败:${esc(e.message)}</div>`}
|
| 1581 |
}
|
| 1582 |
function renderAcl(m,j){
|
|
@@ -1632,9 +1687,19 @@ const AVA_CSS=`.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;fl
|
|
| 1632 |
function ava(url,name,cls=''){
|
| 1633 |
const ini=esc(String(name||'?').trim().slice(0,1).toUpperCase());
|
| 1634 |
return url?`<img class="ava ${cls}" src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer"
|
| 1635 |
-
|
| 1636 |
:`<span class="ava ${cls}">${ini}</span>`;
|
| 1637 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1638 |
|
| 1639 |
/* ================= 🆕 08 我的资料(头像 + 名字) ================= */
|
| 1640 |
function meInfo(){
|
|
@@ -1643,7 +1708,7 @@ function meInfo(){
|
|
| 1643 |
const first=p.first_name||(TGU&&TGU.first_name)||'';
|
| 1644 |
const last=p.last_name||(TGU&&TGU.last_name)||'';
|
| 1645 |
const uname=p.username||(TGU&&TGU.username)||'';
|
| 1646 |
-
const photo=p.photo_url||(TGU&&TGU.photo_url)||
|
| 1647 |
const id=p.id||(TGU&&TGU.id)||'';
|
| 1648 |
const name=[first,last].filter(Boolean).join(' ')||cache.name||(uname?'@'+uname:(DEMO?'演示用户':'Telegram 用户'));
|
| 1649 |
return{name,uname,photo,id,premium:!!(p.is_premium||(TGU&&TGU.is_premium))};
|
|
@@ -1652,9 +1717,9 @@ function renderMe(){
|
|
| 1652 |
const box=$('#me');if(!box)return;
|
| 1653 |
const u=meInfo();
|
| 1654 |
const initials=esc((u.name||'U').trim().slice(0,1).toUpperCase());
|
| 1655 |
-
/* 🆕 头像三级回退:TG 的 photo_url(会过期)→
|
| 1656 |
const cached=(S.meCache&&S.meCache.avatar)||'';
|
| 1657 |
-
const srcs=[u.photo
|
| 1658 |
box.innerHTML=`${srcs.length?`<img class="av" src="${esc(srcs[0])}" alt="" referrerpolicy="no-referrer">`:`<span class="av">${initials}</span>`}
|
| 1659 |
<div class="mn"><b>${esc(u.name)}${u.premium?' ⭐':''}</b>
|
| 1660 |
<span>${esc(u.uname?'@'+u.uname:(u.id?'ID '+u.id:'GUEST'))}</span></div>`;
|
|
|
|
| 584 |
],
|
| 585 |
address_book:{'-1001682349051':'主频道','-1001733118244':'分发一号','-1001552209873':'存档仓','-1001908744236':'镜像站','-1001445096327':'周刊编辑部','-1001612987450':'索引频道','@Nine_7Eleven':'公开频道 Nine7'},
|
| 586 |
member_monitors:[
|
| 587 |
+
{channel_id:'-1001682349051',started_at:nowS()-86400*5,interval:60,last_run:nowS()-1200,member_count:1284,in_count:1271,left_total:13,rt_at:nowS()-180,scan_at:nowS()-1200,capped:true,remote_path:'members/backup.csv',last_error:'',joined_count:12,left_count:3,first_scan_at:nowS()-86400*5},
|
| 588 |
+
{channel_id:'@Nine_7Eleven',started_at:nowS()-86400*2,interval:120,last_run:nowS()-7200,member_count:356,in_count:356,left_total:0,rt_at:0,scan_at:nowS()-7200,capped:false,remote_path:'',last_error:'ChatAdminRequired',joined_count:0,left_count:0,first_scan_at:0}
|
| 589 |
]
|
| 590 |
};
|
| 591 |
function mockDB(){try{return JSON.parse(localStorage.getItem('nine7_mock'))||null}catch(e){return null}}
|
|
|
|
| 671 |
if(method==='POST'&&seg.length===0){u.member_monitors.push({channel_id:d.ch_id,started_at:nowS(),interval:+d.interval||60,last_run:0,member_count:0,remote_path:'',last_error:''});mockSave(db);return out()}
|
| 672 |
const cid=decodeURIComponent(seg[0]||'');
|
| 673 |
if(seg[1]==='download')return{ok:true,msg:'备份文件将由机器人发送给你'};
|
| 674 |
+
if(seg[1]==='scan')return{ok:true,msg:'已排入队列,10 秒内对一次账'};
|
| 675 |
if(method==='PUT'){const m2=u.member_monitors.find(x=>String(x.channel_id)===cid);if(m2){m2.interval=+d.interval;m2.last_run=0}mockSave(db);return out()}
|
| 676 |
if(method==='DELETE'){u.member_monitors=u.member_monitors.filter(x=>String(x.channel_id)!==cid);mockSave(db);return out()}
|
| 677 |
}
|
| 678 |
+
if(res==='member_log'){
|
| 679 |
+
const cid=decodeURIComponent(seg[0]||''),mm=u.member_monitors.find(x=>String(x.channel_id)===cid)||{};
|
| 680 |
+
const n=mm.member_count||0,names=['夜航星','雾都夜谈','银月','拾柒','老鹿','K','南风','阿波罗'];
|
| 681 |
+
const mk=(i,left)=>({id:String(70000+i),name:names[i%names.length]+i,un:'user'+i,bot:false,t:nowS()-i*3600,left:left?nowS()-i*600:0});
|
| 682 |
+
const mem=Array.from({length:Math.min(200,n)},(_,i)=>mk(i,i%23===0));
|
| 683 |
+
return{ok:true,id:cid,title:cid,members:mem,stats:{total:n,in:mm.in_count||n,left:mm.left_total||0},cap:200,shown:mem.length,matched:n,
|
| 684 |
+
capped:!!mm.capped,visible:200,scan_at:mm.scan_at||0,rt_at:mm.rt_at||0,rt_src:'bot',backup_at:nowS()-120,first_scan_at:mm.first_scan_at||0,
|
| 685 |
+
log:mem.slice(0,20).map((x,i)=>({t:nowS()-i*900,u:x.id,n:x.name,un:x.un,a:i%5===0?'left':'join',s:i%3===0?'scan':'rt'}))};
|
| 686 |
+
}
|
| 687 |
if(['btn_multi','btn_old','btn_new','btn_new_media','gen_dir','replace_tag','backup','batch_create'].includes(res))return{ok:true};
|
| 688 |
return{ok:false,msg:'演示模式不支持该操作'};
|
| 689 |
}
|
|
|
|
| 1497 |
</div>
|
| 1498 |
<div>
|
| 1499 |
<div class="ops-c" data-rv>
|
| 1500 |
+
<div class="cmp-t">${I('users')} 成员监控 <span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35);margin-left:6px">${I('radio',10)} 实时</span></div>
|
| 1501 |
+
<div class="cmp-s">有人进就立刻记一笔并备份 · 名单只增不删,每个频道各记各的</div>
|
| 1502 |
<div id="monList">
|
| 1503 |
${mons.length?mons.map(mm=>`<div class="mon-row">
|
| 1504 |
<div><div style="font-weight:600">${esc(S.data.address_book?.[mm.channel_id]||'未登记频道')}</div>
|
| 1505 |
+
<div class="mono" style="color:var(--dim)">${esc(shortId(mm.channel_id))} · 兜底对账每 ${mm.interval} 分钟 · ${mm.first_scan_at?(mm.rt_at?('实时 '+fmtT(mm.rt_at)):'等实时事件'):'待首次建档'}</div></div>
|
| 1506 |
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
| 1507 |
+
<span class="chip" title="累计记录过的人">${I('users',11)} ${(mm.member_count||0).toLocaleString()}</span>
|
| 1508 |
+
${mm.in_count?`<span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35)" title="当前在册">${I('user-check',11)} ${mm.in_count.toLocaleString()}</span>`:''}
|
| 1509 |
+
${mm.left_total?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.3)" title="已退出">${I('user-minus',11)} ${mm.left_total.toLocaleString()}</span>`:''}
|
| 1510 |
+
${mm.last_error?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.35)">${I('triangle-alert',11)} 异常</span>`:''}
|
| 1511 |
+
<button class="ic-b" data-mv="${esc(mm.channel_id)}" title="查看名单与动态">${I('list',14)}</button>
|
| 1512 |
+
<button class="ic-b" data-sc="${esc(mm.channel_id)}" title="立即对账">${I('refresh-cw',14)}</button>
|
| 1513 |
<button class="ic-b" data-dl="${esc(mm.channel_id)}" title="下载备份">${I('download',14)}</button>
|
| 1514 |
<button class="ic-b" data-ed="${esc(mm.channel_id)}" title="修改间隔">${I('pencil',14)}</button>
|
| 1515 |
<button class="ic-b danger" data-de="${esc(mm.channel_id)}" title="删除">${I('trash-2',14)}</button>
|
|
|
|
| 1517 |
</div>
|
| 1518 |
<div class="sec-t">新增监控</div>
|
| 1519 |
${fld('频道',picker('m_ch',S.data.address_book,''))}
|
| 1520 |
+
${fld('检测间隔(分钟)',inp('m_intv',60,'60','number','min="1"'),'实时事件为主,这个间隔只是兜底对账')}
|
| 1521 |
<button class="btn btn-acc" id="m_add" style="width:100%;justify-content:center">${I('plus',15)} 添加监控</button>
|
| 1522 |
</div>
|
| 1523 |
<!-- 🆕 白名单:在这里随时增删,不用改环境变量 -->
|
|
|
|
| 1581 |
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(cid),{method:'PUT',body:{interval:+gv('m_nintv',w)||60}}));toast('间隔已更新');w._close();go('ops')}catch(e){toast(e.message,'err')}
|
| 1582 |
};
|
| 1583 |
});
|
| 1584 |
+
$$('[data-sc]',m).forEach(b=>b.onclick=async()=>{
|
| 1585 |
+
try{const j=await api('/api/member_monitors/'+encodeURIComponent(b.dataset.sc)+'/scan',{method:'POST'});toast(j.msg||'已排队对账')}catch(e){toast(e.message,'err')}
|
| 1586 |
+
});
|
| 1587 |
+
$$('[data-mv]',m).forEach(b=>b.onclick=()=>memSheet(b.dataset.mv));
|
| 1588 |
+
$$('[data-de]',m).forEach(b=>b.onclick=()=>confirmSheet('删除该成员监控任务?频道台账与 WebDAV 备份都保留。',async()=>{
|
| 1589 |
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(b.dataset.de),{method:'DELETE'}));toast('监控已删除');go('ops')}catch(e){toast(e.message,'err')}
|
| 1590 |
}));
|
| 1591 |
loadAcl(m);
|
| 1592 |
}
|
| 1593 |
+
|
| 1594 |
+
/* 🆕 单频道成员台账:累计人数、实时动态、成员列表(各频道分开记) */
|
| 1595 |
+
async function memSheet(cid){
|
| 1596 |
+
const w=sheet({title:'成员台账',sub:esc(S.data.address_book?.[cid]||shortId(cid)),
|
| 1597 |
+
body:'<div id="memBox"><div class="fld-h" style="padding:16px 0">读取中…</div></div>'});
|
| 1598 |
+
const draw=async(q='')=>{
|
| 1599 |
+
try{
|
| 1600 |
+
const j=await api('/api/member_log/'+encodeURIComponent(cid)+(q?('?q='+encodeURIComponent(q)):''));
|
| 1601 |
+
const st=j.stats||{},box=$('#memBox',w);
|
| 1602 |
+
const tag=(t,v,c)=>`<div style="flex:1;min-width:86px;background:var(--card2,rgba(255,255,255,.03));border:1px solid var(--line);border-radius:12px;padding:9px 11px">
|
| 1603 |
+
<div style="font-size:11px;color:var(--dim)">${t}</div><div style="font-size:18px;font-weight:700;${c?'color:'+c:''}">${(v||0).toLocaleString()}</div></div>`;
|
| 1604 |
+
const when=ts=>ts?fmtT(ts):'—';
|
| 1605 |
+
box.innerHTML=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
|
| 1606 |
+
${tag('累计记录',st.total)}${tag('当前在册',st.in,'var(--grn)')}${tag('已退出',st.left,'var(--red)')}
|
| 1607 |
+
</div>
|
| 1608 |
+
<div class="fld-h" style="margin-bottom:12px">实时 ${when(j.rt_at)}${j.rt_src?'('+(j.rt_src==='bot'?'机器人':'userbot')+')':''} · 对账 ${when(j.scan_at)} · 备份 ${when(j.backup_at)}
|
| 1609 |
+
${j.capped?`<br>Telegram 单次最多看到 ${j.cap} 人(本次看到 ${j.visible}),名单靠实时事件继续累加,不会把老成员挤掉`:''}</div>
|
| 1610 |
+
<div class="sec-t">最近动态</div>
|
| 1611 |
+
<div style="max-height:190px;overflow:auto">${(j.log||[]).length?(j.log||[]).slice(0,80).map(e=>`<div class="mon-row" style="padding:8px 0">
|
| 1612 |
+
<div><span style="color:${e.a==='join'?'var(--grn)':'var(--red)'}">${e.a==='join'?'+':'-'}</span> ${esc(e.n||e.u)}
|
| 1613 |
+
<span class="mono" style="color:var(--dim)">${e.un?'@'+esc(e.un):esc(e.u)}</span></div>
|
| 1614 |
+
<div class="mono" style="color:var(--dim)">${fmtT(e.t)} · ${e.s==='scan'?'对账':(e.s==='bot'?'实时·机器人':'实时')}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">还没有进出记录</div>'}</div>
|
| 1615 |
+
<div class="sec-t">成���名单(最新 ${j.shown}/${j.matched}${j.matched>j.cap?',一次最多显示 '+j.cap:''})</div>
|
| 1616 |
+
${inp('mem_q',q,'搜索名字 / @用户名 / ID')}
|
| 1617 |
+
<div style="max-height:240px;overflow:auto;margin-top:10px">${(j.members||[]).length?(j.members||[]).map(u=>`<div class="mon-row" style="padding:8px 0">
|
| 1618 |
+
<div>${esc(u.name||u.id)} <span class="mono" style="color:var(--dim)">${u.un?'@'+esc(u.un):esc(u.id)}</span>${u.bot?' <span class="chip">BOT</span>':''}</div>
|
| 1619 |
+
<div class="mono" style="color:${u.left?'var(--red)':'var(--dim)'}">${u.left?'已退 '+fmtT(u.left):'进于 '+fmtT(u.t)}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">没有匹配的成员</div>'}</div>`;
|
| 1620 |
+
icons();
|
| 1621 |
+
const qi=$('#mem_q',w);
|
| 1622 |
+
if(qi){let tm;qi.oninput=()=>{clearTimeout(tm);tm=setTimeout(()=>{const v=qi.value.trim();draw(v).then(()=>{const n=$('#mem_q',w);if(n){n.focus();n.setSelectionRange(v.length,v.length)}})},350)}}
|
| 1623 |
+
}catch(e){const box=$('#memBox',w);if(box)box.innerHTML=`<div class="fld-h" style="padding:16px 0">读取失败:${esc(e.message)}</div>`}
|
| 1624 |
+
};
|
| 1625 |
+
draw();
|
| 1626 |
+
}
|
| 1627 |
/* 🆕 白名单面板 */
|
| 1628 |
+
async function loadAcl(m,retry=0){
|
| 1629 |
+
try{
|
| 1630 |
+
const j=await api('/api/admins');renderAcl(m,j);
|
| 1631 |
+
/* 🆕 名字/头像是后台刷的:还缺就静默再取两次,不用退出面板再进 */
|
| 1632 |
+
const miss=Object.values(j.profiles||{}).some(p=>!p.avatar||!p.name);
|
| 1633 |
+
if(miss&&retry<2&&document.body.contains(m))setTimeout(()=>loadAcl(m,retry+1),retry?6000:2500);
|
| 1634 |
+
}
|
| 1635 |
catch(e){const b=$('#aclBox',m);if(b)b.innerHTML=`<div class="fld-h" style="padding:14px 0">读取失败:${esc(e.message)}</div>`}
|
| 1636 |
}
|
| 1637 |
function renderAcl(m,j){
|
|
|
|
| 1687 |
function ava(url,name,cls=''){
|
| 1688 |
const ini=esc(String(name||'?').trim().slice(0,1).toUpperCase());
|
| 1689 |
return url?`<img class="ava ${cls}" src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer"
|
| 1690 |
+
data-ini="${ini}" data-cls="${esc(cls)}" onerror="avaErr(this)">`
|
| 1691 |
:`<span class="ava ${cls}">${ini}</span>`;
|
| 1692 |
}
|
| 1693 |
+
/* 🆕 头像取失败先重试一次(服务端可能正在从 WebDAV 回源),真没有才退回首字母 */
|
| 1694 |
+
function avaErr(img){
|
| 1695 |
+
if(!img.dataset.retry){
|
| 1696 |
+
img.dataset.retry='1';
|
| 1697 |
+
const u=img.src.split('#')[0].replace(/[?&]r=\d+/,'');
|
| 1698 |
+
setTimeout(()=>{img.src=u+(u.includes('?')?'&':'?')+'r='+Date.now()},1800);
|
| 1699 |
+
return;
|
| 1700 |
+
}
|
| 1701 |
+
img.outerHTML=`<span class="ava ${img.dataset.cls||''}">${img.dataset.ini||'?'}</span>`;
|
| 1702 |
+
}
|
| 1703 |
|
| 1704 |
/* ================= 🆕 08 我的资料(头像 + 名字) ================= */
|
| 1705 |
function meInfo(){
|
|
|
|
| 1708 |
const first=p.first_name||(TGU&&TGU.first_name)||'';
|
| 1709 |
const last=p.last_name||(TGU&&TGU.last_name)||'';
|
| 1710 |
const uname=p.username||(TGU&&TGU.username)||'';
|
| 1711 |
+
const photo=cache.avatar||p.photo_url||(TGU&&TGU.photo_url)||''; /* 🆕 先用服务端缓存(同源、命中浏览器缓存、秒开),TG 的 photo_url 慢且会过期 */
|
| 1712 |
const id=p.id||(TGU&&TGU.id)||'';
|
| 1713 |
const name=[first,last].filter(Boolean).join(' ')||cache.name||(uname?'@'+uname:(DEMO?'演示用户':'Telegram 用户'));
|
| 1714 |
return{name,uname,photo,id,premium:!!(p.is_premium||(TGU&&TGU.is_premium))};
|
|
|
|
| 1717 |
const box=$('#me');if(!box)return;
|
| 1718 |
const u=meInfo();
|
| 1719 |
const initials=esc((u.name||'U').trim().slice(0,1).toUpperCase());
|
| 1720 |
+
/* 🆕 头像三级回退:服务端缓存(快)→ TG 的 photo_url(慢、会过期)→ 首字母 */
|
| 1721 |
const cached=(S.meCache&&S.meCache.avatar)||'';
|
| 1722 |
+
const srcs=[cached,u.photo].filter(Boolean);
|
| 1723 |
box.innerHTML=`${srcs.length?`<img class="av" src="${esc(srcs[0])}" alt="" referrerpolicy="no-referrer">`:`<span class="av">${initials}</span>`}
|
| 1724 |
<div class="mn"><b>${esc(u.name)}${u.premium?' ⭐':''}</b>
|
| 1725 |
<span>${esc(u.uname?'@'+u.uname:(u.id?'ID '+u.id:'GUEST'))}</span></div>`;
|