24/7’365 commited on
Commit
f08deea
·
1 Parent(s): 0e61bae

Add scheduled member backups and message editing

Browse files
Files changed (1) hide show
  1. app.py +124 -23
app.py CHANGED
@@ -33,7 +33,7 @@ from telebot import types, apihelper
33
  from telebot.apihelper import ApiTelegramException
34
  from flask import Flask, request, jsonify, send_file, Response
35
  from webdav4.client import Client as WebDAVClient
36
- from telethon import TelegramClient, events
37
  from telethon.sessions import StringSession
38
  from telethon.extensions import html as tl_html
39
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
@@ -907,6 +907,52 @@ def run_backup_members(uid, ch_id):
907
  except: pass
908
  push_event(uid, "members_done", f"✅ 成员备份完成:{len(members)} 人")
909
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
910
 
911
  def run_smart_backup_v2(latest_id, uid, src, tgt, wash=False):
912
  global TL_LOOP, TL_CLIENT
@@ -1282,21 +1328,26 @@ def start_telethon_worker():
1282
  TL_LOOP = asyncio.new_event_loop(); asyncio.set_event_loop(TL_LOOP)
1283
  TL_CLIENT = TelegramClient(StringSession(user_session), int(api_id_str), api_hash)
1284
 
1285
- @TL_CLIENT.on(events.ChatAction)
1286
- async def record_joined_member(event):
1287
- if not (event.user_joined or event.user_added): return
1288
- channel_id = str(event.chat_id); users = await event.get_users()
1289
- if not isinstance(users, list): users = [users]
1290
- changed = False
1291
- for u_data in DATA.get("users", {}).values():
1292
  for monitor in u_data.get("member_monitors", []):
1293
- if str(monitor.get("channel_id")) != channel_id: continue
1294
- records = monitor.setdefault("members", [])
1295
- for user in users:
1296
- if not user: continue
1297
- records.append({"id":user.id,"username":user.username or "","first_name":user.first_name or "","last_name":user.last_name or "","bot":bool(user.bot),"joined_at":int(time.time())})
1298
- changed = True
1299
- if changed: await asyncio.to_thread(save_data)
 
 
 
 
 
 
 
 
 
1300
 
1301
  async def update_channel_msg():
1302
  current_time = int(time.time()); data_changed = False
@@ -1513,6 +1564,7 @@ def start_telethon_worker():
1513
  TL_CLIENT.start()
1514
  scheduler = AsyncIOScheduler(event_loop=TL_LOOP)
1515
  scheduler.add_job(update_channel_msg, 'interval', seconds=10)
 
1516
  scheduler.add_job(update_channel_dirs, 'interval', seconds=15)
1517
  scheduler.start()
1518
  print("🚀 Telethon 后台已启动!")
@@ -1711,6 +1763,15 @@ def api_edit_dir(uid, idx):
1711
  tis = task.get("targets",[]); ti_idx=int(val)
1712
  if 0<=ti_idx<len(tis):
1713
  rm=tis.pop(ti_idx); task.get("last_html_per_target",{}).pop(f"{rm['channel_id']}_{rm['msg_id']}",None)
 
 
 
 
 
 
 
 
 
1714
  save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
1715
  except Exception as e: return jsonify({"ok":False,"msg":str(e)})
1716
 
@@ -1795,15 +1856,33 @@ def api_btn_new_media(uid):
1795
  @app.route('/api/btn_old', methods=['POST'])
1796
  @need_auth
1797
  def api_btn_old(uid):
1798
- d = request.json; msg_id = d["msg_id"]
 
 
1799
  if msg_id.startswith('http'): msg_id=msg_id.split('/')[-1]
1800
  try:
1801
- if d["btn_text"].strip()=="删除":
1802
- bot.edit_message_reply_markup(chat_id=d["ch_id"],message_id=int(msg_id),reply_markup=None)
1803
- else:
1804
- markup = types.InlineKeyboardMarkup()
1805
- markup.add(types.InlineKeyboardButton(text=d["btn_text"],url=d["url"]))
1806
- bot.edit_message_reply_markup(chat_id=d["ch_id"],message_id=int(msg_id),reply_markup=markup)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1807
  return jsonify({"ok":True})
1808
  except Exception as e: return jsonify({"ok":False,"msg":str(e)})
1809
 
@@ -1912,9 +1991,31 @@ def api_add_member_monitor(uid):
1912
  if not ch_id: return jsonify({"ok":False,"msg":"请输入频道ID"})
1913
  monitors = DATA["users"][uid].setdefault("member_monitors", [])
1914
  if any(str(item.get("channel_id")) == ch_id for item in monitors): return jsonify({"ok":False,"msg":"该频道已在监控中"})
1915
- monitors.append({"channel_id":ch_id,"started_at":int(time.time()),"members":[]})
 
 
 
1916
  save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
1917
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1918
  @app.route('/api/member_monitors/<path:cid>', methods=['DELETE'])
1919
  @need_auth
1920
  def api_del_member_monitor(uid, cid):
 
33
  from telebot.apihelper import ApiTelegramException
34
  from flask import Flask, request, jsonify, send_file, Response
35
  from webdav4.client import Client as WebDAVClient
36
+ from telethon import TelegramClient
37
  from telethon.sessions import StringSession
38
  from telethon.extensions import html as tl_html
39
  from apscheduler.schedulers.asyncio import AsyncIOScheduler
 
907
  except: pass
908
  push_event(uid, "members_done", f"✅ 成员备份完成:{len(members)} 人")
909
 
910
+ def _member_backup_paths(uid, ch_id):
911
+ safe_channel = re.sub(r'[^0-9A-Za-z_-]+', '_', str(ch_id)).strip('_') or "channel"
912
+ remote_dir = f"members/{uid}"
913
+ return remote_dir, f"{remote_dir}/{safe_channel}.csv", f"members_{safe_channel}.csv"
914
+
915
+ def _write_member_csv(members, path):
916
+ with open(path, 'w', encoding='utf-8-sig', newline='') as f:
917
+ writer = csv.writer(f)
918
+ writer.writerow(['UserID','Username','FirstName','LastName','IsBot','BackupTime'])
919
+ backup_time = datetime.now(timezone.utc).isoformat()
920
+ for member in members:
921
+ writer.writerow([member.id, f"@{member.username}" if member.username else '',
922
+ member.first_name or '', member.last_name or '', '是' if member.bot else '否', backup_time])
923
+
924
+ def _upload_member_backup(uid, ch_id, members):
925
+ remote_dir, remote_path, filename = _member_backup_paths(uid, ch_id)
926
+ temp_path = os.path.join(tempfile.gettempdir(), filename)
927
+ try:
928
+ _write_member_csv(members, temp_path)
929
+ dav = get_dav_client()
930
+ for directory in ("members", remote_dir):
931
+ try:
932
+ if not dav.exists(directory): dav.mkdir(directory)
933
+ except Exception: pass
934
+ dav.upload_file(temp_path, remote_path, overwrite=True)
935
+ return remote_path
936
+ finally:
937
+ try: os.remove(temp_path)
938
+ except OSError: pass
939
+
940
+ def send_member_backup(uid, ch_id):
941
+ _, remote_path, filename = _member_backup_paths(uid, ch_id)
942
+ temp_path = os.path.join(tempfile.gettempdir(), f"download_{uid}_{filename}")
943
+ try:
944
+ dav = get_dav_client()
945
+ if not dav.exists(remote_path):
946
+ return bot.send_message(uid, "⚠️ 该频道还没有可下载的成员备份,请等待首次检测完成。")
947
+ dav.download_file(remote_path, temp_path)
948
+ with open(temp_path, 'rb') as f:
949
+ bot.send_document(uid, f, caption=f"📋 频道 `{ch_id}` 成员备份", parse_mode="Markdown")
950
+ except Exception as e:
951
+ bot.send_message(uid, f"❌ 下载成员备份失败: {e}")
952
+ finally:
953
+ try: os.remove(temp_path)
954
+ except OSError: pass
955
+
956
 
957
  def run_smart_backup_v2(latest_id, uid, src, tgt, wash=False):
958
  global TL_LOOP, TL_CLIENT
 
1328
  TL_LOOP = asyncio.new_event_loop(); asyncio.set_event_loop(TL_LOOP)
1329
  TL_CLIENT = TelegramClient(StringSession(user_session), int(api_id_str), api_hash)
1330
 
1331
+ async def update_member_backups():
1332
+ current_time = int(time.time()); data_changed = False
1333
+ for uid, u_data in DATA.get("users", {}).items():
 
 
 
 
1334
  for monitor in u_data.get("member_monitors", []):
1335
+ interval_sec = max(1, int(monitor.get("interval", 60))) * 60
1336
+ if current_time - int(monitor.get("last_run", 0)) < interval_sec: continue
1337
+ ch_id = str(monitor.get("channel_id", "")).strip()
1338
+ if not ch_id: continue
1339
+ try:
1340
+ members = [member async for member in TL_CLIENT.iter_participants(int(ch_id))]
1341
+ remote_path = await asyncio.to_thread(_upload_member_backup, uid, ch_id, members)
1342
+ monitor.pop("members", None)
1343
+ monitor.update({"last_run":current_time, "member_count":len(members),
1344
+ "remote_path":remote_path, "last_error":""})
1345
+ except Exception as e:
1346
+ monitor.update({"last_run":current_time, "last_error":str(e)})
1347
+ print(f"❌ [成员备份] {ch_id}: {e}")
1348
+ data_changed = True
1349
+ await asyncio.sleep(1)
1350
+ if data_changed: await asyncio.to_thread(save_data)
1351
 
1352
  async def update_channel_msg():
1353
  current_time = int(time.time()); data_changed = False
 
1564
  TL_CLIENT.start()
1565
  scheduler = AsyncIOScheduler(event_loop=TL_LOOP)
1566
  scheduler.add_job(update_channel_msg, 'interval', seconds=10)
1567
+ scheduler.add_job(update_member_backups, 'interval', seconds=10)
1568
  scheduler.add_job(update_channel_dirs, 'interval', seconds=15)
1569
  scheduler.start()
1570
  print("🚀 Telethon 后台已启动!")
 
1763
  tis = task.get("targets",[]); ti_idx=int(val)
1764
  if 0<=ti_idx<len(tis):
1765
  rm=tis.pop(ti_idx); task.get("last_html_per_target",{}).pop(f"{rm['channel_id']}_{rm['msg_id']}",None)
1766
+ elif field == "target_msg_id":
1767
+ update = json.loads(val); ti_idx = int(update.get("index", -1))
1768
+ tis = task.get("targets", [])
1769
+ if not 0 <= ti_idx < len(tis): raise ValueError("目标索引无效")
1770
+ mid = str(update.get("msg_id", "")).strip()
1771
+ if mid.startswith('http'): mid = mid.split('/')[-1]
1772
+ if not mid.isdigit(): raise ValueError("消息 ID 必须是数字或消息链接")
1773
+ old = tis[ti_idx]; task.get("last_html_per_target",{}).pop(f"{old['channel_id']}_{old['msg_id']}",None)
1774
+ old["msg_id"] = mid
1775
  save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
1776
  except Exception as e: return jsonify({"ok":False,"msg":str(e)})
1777
 
 
1856
  @app.route('/api/btn_old', methods=['POST'])
1857
  @need_auth
1858
  def api_btn_old(uid):
1859
+ is_multipart = bool(request.files) or request.mimetype == 'multipart/form-data'
1860
+ d = request.form if is_multipart else (request.get_json(silent=True) or {})
1861
+ msg_id = str(d["msg_id"])
1862
  if msg_id.startswith('http'): msg_id=msg_id.split('/')[-1]
1863
  try:
1864
+ buttons = _parse_button_payload(d.get("buttons", []))
1865
+ if not buttons and d.get("btn_text") and str(d.get("btn_text")).strip() != "删除":
1866
+ buttons = _parse_button_payload([{"text":d.get("btn_text"), "url":d.get("url")}])
1867
+ markup = _button_markup(buttons)
1868
+ ch_id = str(d["ch_id"]); text = str(d.get("text", "")); media = request.files.get("media") if is_multipart else None
1869
+ if media and media.filename:
1870
+ suffix = os.path.splitext(media.filename)[1].lower()
1871
+ tmp = tempfile.NamedTemporaryFile(prefix="edit_media_", suffix=suffix, delete=False); tmp_path=tmp.name; tmp.close(); media.save(tmp_path)
1872
+ try:
1873
+ with open(tmp_path, 'rb') as f:
1874
+ if suffix in ('.jpg','.jpeg','.png','.webp'): input_media=types.InputMediaPhoto(f, caption=text, parse_mode='HTML')
1875
+ elif suffix in ('.mp4','.mov','.avi','.mkv','.webm'): input_media=types.InputMediaVideo(f, caption=text, parse_mode='HTML')
1876
+ elif suffix == '.gif': input_media=types.InputMediaAnimation(f, caption=text, parse_mode='HTML')
1877
+ else: input_media=types.InputMediaDocument(f, caption=text, parse_mode='HTML')
1878
+ bot.edit_message_media(input_media, chat_id=ch_id, message_id=int(msg_id), reply_markup=markup)
1879
+ finally:
1880
+ try: os.unlink(tmp_path)
1881
+ except OSError: pass
1882
+ elif text:
1883
+ try: bot.edit_message_text(text, chat_id=ch_id, message_id=int(msg_id), parse_mode='HTML', reply_markup=markup)
1884
+ except Exception: bot.edit_message_caption(caption=text, chat_id=ch_id, message_id=int(msg_id), parse_mode='HTML', reply_markup=markup)
1885
+ else: bot.edit_message_reply_markup(chat_id=ch_id, message_id=int(msg_id), reply_markup=markup)
1886
  return jsonify({"ok":True})
1887
  except Exception as e: return jsonify({"ok":False,"msg":str(e)})
1888
 
 
1991
  if not ch_id: return jsonify({"ok":False,"msg":"请输入频道ID"})
1992
  monitors = DATA["users"][uid].setdefault("member_monitors", [])
1993
  if any(str(item.get("channel_id")) == ch_id for item in monitors): return jsonify({"ok":False,"msg":"该频道已在监控中"})
1994
+ try: interval = max(1, int(d.get('interval', 60)))
1995
+ except (TypeError, ValueError): return jsonify({"ok":False,"msg":"检测间隔必须是整数分钟"})
1996
+ monitors.append({"channel_id":ch_id,"started_at":int(time.time()),"interval":interval,
1997
+ "last_run":0,"member_count":0,"remote_path":"","last_error":""})
1998
  save_data(); return jsonify({"ok":True,"user":DATA["users"][uid]})
1999
 
2000
+ @app.route('/api/member_monitors/<path:cid>', methods=['PUT'])
2001
+ @need_auth
2002
+ def api_edit_member_monitor(uid, cid):
2003
+ try: interval = max(1, int((request.json or {}).get('interval', 60)))
2004
+ except (TypeError, ValueError): return jsonify({"ok":False,"msg":"检测间隔必须是整数分钟"})
2005
+ for monitor in DATA["users"][uid].setdefault("member_monitors", []):
2006
+ if str(monitor.get("channel_id")) == cid:
2007
+ monitor["interval"] = interval; monitor["last_run"] = 0; save_data()
2008
+ return jsonify({"ok":True,"user":DATA["users"][uid]})
2009
+ return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
2010
+
2011
+ @app.route('/api/member_monitors/<path:cid>/download', methods=['POST'])
2012
+ @need_auth
2013
+ def api_download_member_monitor(uid, cid):
2014
+ monitor = next((m for m in DATA["users"][uid].setdefault("member_monitors", []) if str(m.get("channel_id")) == cid), None)
2015
+ if not monitor: return jsonify({"ok":False,"msg":"监控任务不存在"}), 404
2016
+ Thread(target=send_member_backup, args=(uid, cid)).start()
2017
+ return jsonify({"ok":True,"msg":"备份文件将由机器人发送给你"})
2018
+
2019
  @app.route('/api/member_monitors/<path:cid>', methods=['DELETE'])
2020
  @need_auth
2021
  def api_del_member_monitor(uid, cid):