ljx77qaq commited on
Commit
d7351be
·
verified ·
1 Parent(s): a0ba027

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +608 -113
  2. 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, 'big_file_id', None) or getattr(photo, 'small_file_id', None)
602
- uniq = getattr(photo, 'big_file_unique_id', None) or getattr(photo, 'small_file_unique_id', None) or ''
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=1800):
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
- if fresh and not force: return prof
 
 
 
 
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
- try:
636
- uniq = getattr(photo, 'big_file_unique_id', '') or ''
637
- if force or prof.get("photo_uid") != uniq or not os.path.exists(_avatar_path(uid)):
 
 
 
638
  got = _save_avatar_from_bot(uid, photo)
639
- if got: prof["photo_uid"] = got
640
- except Exception as e:
641
- # 🆕 Bot 下载挂了(多半是 SSL/网络)→ 换 userbot 兜底,别让头像整轮丢掉
642
- got = _save_avatar_from_tl(uid, int(uid)) if TL_CLIENT else ""
643
- if got: prof["photo_uid"] = got
644
- else: print(f"⚠️ 头像下载失败 {uid}: {e}")
645
  else:
646
  prof["photo_uid"] = ""
647
- try: os.remove(_avatar_path(uid))
648
- except Exception: pass
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
- if not force and time.time() - float(prof.get("checked", 0) or 0) < 1800: return
 
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
- try: photo_uid = _save_avatar_from_bot("c" + (cid or ident), photo) or ""
706
- except Exception as e:
707
- # 🆕 Bot 下载挂了 → 换 userbot 兜底
708
- try: photo_uid = (_save_avatar_from_tl("c" + (cid or ident), target) if TL_CLIENT else "") or ""
709
- except Exception: photo_uid = ""
710
- if not photo_uid: print(f"⚠️ 频道头像下载失败: {e}")
 
 
 
 
 
 
 
 
 
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
- photo_uid = _save_avatar_from_tl("c" + cid, ent)
 
 
 
 
 
 
 
 
 
 
 
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'}" if os.path.exists(_avatar_path(key)) else ""}
 
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
- out.append(f"记录在案的链接:撤销并删除 {done} 条")
 
 
 
 
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("这些只能手动删:频道 → 管理 → 邀请链接 → 找 userbot / userbot-1 几条撤销 → 删除")
 
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
- def _member_row(member, first_seen):
1717
- """紧凑存储:[username, first_name, last_name, is_bot, 首次出现时间]"""
1718
- return [member.username or '', member.first_name or '', member.last_name or '',
1719
- 1 if member.bot else 0, int(first_seen)]
 
 
 
 
 
 
 
 
 
 
 
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 _diff_member_index(old_index, members, now_ts):
1726
- """对比新旧成员索引 ➜ (新索引, 新加入, 已退出)"""
1727
- old_index = old_index or {}
1728
- new_index = {}; joined = []; left = []
1729
- for member in members:
1730
- key = str(member.id)
1731
- prev = old_index.get(key)
1732
- first_seen = prev[4] if (prev and len(prev) > 4) else now_ts
1733
- row = _member_row(member, first_seen)
1734
- new_index[key] = row
1735
- if not prev: joined.append((key, row))
1736
- for key, row in old_index.items():
1737
- if key not in new_index: left.append((key, row))
1738
- return new_index, joined, left
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- first_seen = datetime.fromtimestamp(row[4], BJ_TZ).strftime("%Y-%m-%d %H:%M:%S")
1747
  writer.writerow([uid_str, f"@{row[0]}" if row[0] else '', row[1], row[2],
1748
- '是' if row[3] else '否', first_seen, backup_time])
 
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
- new_index, joined, left = _diff_member_index(members_index, members, current_time)
 
 
 
 
 
 
 
 
 
 
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, new_index)
2204
  monitor.pop("members", None)
2205
- monitor.update({"last_run":current_time, "member_count":len(new_index),
2206
- "members_index":new_index, "remote_path":remote_path, "last_error":"",
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
- push_event(uid, "members_done", f" {ch_id} 首建档:{len(new_index)} 人")
 
2211
  elif changed:
2212
- names = "、".join([_member_display(r) for _, r in joined[:5]])
2213
  detail = f"({names}{'…' if len(joined) > 5 else ''})" if names else ""
2214
- push_event(uid, "members_done", f"👥 {ch_id} 新增 {len(joined)} / 退出 {len(left)}{detail}")
 
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
- _purge_links(key, urls); n += len(urls)
 
 
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
- Bot API 只能「撤销」,撤销留在「已撤销的链接」里;真正删除得用 userbot)"""
 
 
 
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
- if not TL_CLIENT: return
2791
- from telethon.tl.functions.messages import (DeleteExportedChatInviteRequest,
2792
- DeleteRevokedExportedChatInvitesRequest)
2793
- async def _go():
2794
- for u in urls:
2795
- try: await TL_CLIENT(DeleteExportedChatInviteRequest(peer=peer(cid), link=u))
2796
- except Exception: pass
2797
- if BOT_ID: # 一次把机器人名下所有已撤销的链接清空(userbot 是创建者时才有权限)
2798
- try: await TL_CLIENT(DeleteRevokedExportedChatInvitesRequest(peer=peer(cid), admin_id=BOT_ID))
2799
- except Exception: pass
2800
- try: _tl_run(_go(), timeout=60)
2801
- except Exception as e: print(f"⚠️ 删除链接失败 {cid}: {str(e)[:70]}")
2802
- for u in urls: _untrack_link(cid, u)
 
 
 
 
 
 
 
 
 
 
 
 
 
2803
 
2804
  def _bot_invite_links(cid, chat=None, fresh=False):
2805
  """返回 (候选链接, 其中我们新建的, 失败原因)
2806
- 先用频道现成的主邀请链接 —— 一条新链接都不建,事后也没东西要清,等于没发生过
2807
- 现成的没有、或它被判失效后重试(fresh=True)自己
2808
- (普通链接优先,member_limit=1 的限量链接常被判 InviteHashExpired)
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(name="userbot"), dict(name="userbot-1", member_limit=1)):
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"打开『添加成员/邀请用户』然后发 /follow {cid} 重试;"
2896
- f"或者你自己把 {_userbot_name()} 拉进这个频道)")
 
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"(顶不住就手动 {_userbot_name()} 拉进这个频道,效果一样)")
 
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: _purge_links(cid, created)
 
 
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): return ("", 404)
 
3065
  st = os.stat(path)
3066
  # 🆕 URL 自带头像指纹,同一个地址的内容永远不变 ➜ immutable + ETag,浏览器第二次起直接用本地缓存
3067
- etag = f'W/"{int(st.st_mtime)}-{st.st_size}"'
 
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
- slim["member_count"] = len(mon.get("members_index") or {}) or mon.get("member_count",0)
 
 
 
 
 
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
- # members_index 为空 ➜ 调度器会立刻做一次全量建档,之后按 interval 增量扫描
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
- "members_index":{},"joined_count":0,"left_count":0,"first_scan_at":0})
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
- Thread(target=send_member_backup, args=(uid, cid)).start()
 
 
 
 
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","my_chat_member"])
 
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">周期性备份频道成员名单至 WebDAV</div>
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?'增量扫描':'待首次建档'}</div></div>
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.joined_count?`<span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35)">${I('user-plus',11)} +${mm.joined_count}</span>`:''}
1499
- ${mm.left_count?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.3)">${I('user-minus',11)} -${mm.left_count}</span>`:''}
1500
- ${mm.last_error?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.35)">${I('triangle-alert',11)} 异常</span>`:`<span class="chip">${I('history',11)} ${fmtT(mm.last_run)}</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-de]',m).forEach(b=>b.onclick=()=>confirmSheet('删除该成员监控任务?历史备份文件保留在 WebDAV。',async()=>{
 
 
 
 
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{renderAcl(m,await api('/api/admins'))}
 
 
 
 
 
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
- onerror="this.outerHTML='<span class=&quot;ava ${cls}&quot;>${ini}</span>'">`
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)||cache.avatar||''; /* 🆕 退回服务端缓存头像 */
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,cached].filter(Boolean);
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>`;