24/7’365 commited on
Commit ·
f08deea
1
Parent(s): 0e61bae
Add scheduled member backups and message editing
Browse files
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
|
| 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 |
-
|
| 1286 |
-
|
| 1287 |
-
|
| 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 |
-
|
| 1294 |
-
|
| 1295 |
-
|
| 1296 |
-
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 1799 |
if msg_id.startswith('http'): msg_id=msg_id.split('/')[-1]
|
| 1800 |
try:
|
| 1801 |
-
|
| 1802 |
-
|
| 1803 |
-
|
| 1804 |
-
|
| 1805 |
-
|
| 1806 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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):
|