Files
wxauto_api/plugins/advanced_sender_plugin.py
2026-04-07 14:11:45 +08:00

839 lines
32 KiB
Python

# ///
# advanced_sender_plugin.py
# 描述:高级多节点数据推送插件,支持表单化配置和变量赋值
# 功能:多数据源获取 → 去重入库 → 按记录时间路由发送到微信
# 作者:User
# 创建日期:2026-04-06
# requires: requests, pymysql
# ///
import os
import sys
import re
import time
import json
import logging
import asyncio
import threading
from datetime import datetime, time as dtime
from typing import Any, Dict, List, Optional
from contextlib import contextmanager
from croniter import croniter
from plugins.base import ScheduledTaskPlugin
from plugins.plugin_config_schema import (
PluginConfigSchema, ConfigSection, ConfigField, FieldType,
create_variable_assignment_field, cron_validator
)
PLUGIN_LOG_DIR = "/app/data/logs"
os.makedirs(PLUGIN_LOG_DIR, exist_ok=True)
plugin_logger = logging.getLogger("advanced_sender")
plugin_logger.setLevel(logging.INFO)
file_handler = logging.FileHandler(os.path.join(PLUGIN_LOG_DIR, "advanced_sender.log"), encoding='utf-8')
file_handler.setLevel(logging.INFO)
file_handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
if not plugin_logger.handlers:
plugin_logger.addHandler(file_handler)
console_handler = logging.StreamHandler()
console_handler.setLevel(logging.INFO)
plugin_logger.addHandler(console_handler)
logger = plugin_logger
def get_config_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("advanced_sender")
schema.add_section(ConfigSection(
name="basic",
label="基本设置",
fields=[
ConfigField(
name="cron",
label="执行周期",
field_type=FieldType.CRON,
required=True,
default="*/2 * * * *",
description="Cron表达式"
),
ConfigField(
name="enable_cron",
label="启用定时任务",
field_type=FieldType.BOOLEAN,
default=False
)
]
))
schema.add_section(ConfigSection(
name="node",
label="默认设置(可被数据源覆盖)",
fields=[
ConfigField(
name="node_id",
label="默认节点ID",
field_type=FieldType.STRING,
default="wx1",
placeholder="如 wx1"
),
ConfigField(
name="receiver",
label="默认接收人",
field_type=FieldType.STRING,
default="asq",
placeholder="联系人名称"
)
]
))
schema.add_section(ConfigSection(
name="database",
label="外部数据库",
fields=[
ConfigField(
name="db_host",
label="数据库地址",
field_type=FieldType.STRING,
required=True,
default="192.168.2.27",
placeholder="192.168.1.100"
),
ConfigField(
name="db_port",
label="端口",
field_type=FieldType.NUMBER,
default=3306,
min_value=1,
max_value=65535
),
ConfigField(
name="db_user",
label="用户名",
field_type=FieldType.STRING,
required=True,
default="root2"
),
ConfigField(
name="db_password",
label="密码",
field_type=FieldType.PASSWORD,
default="root@root"
),
ConfigField(
name="db_name",
label="数据库名",
field_type=FieldType.STRING,
required=True,
default="addb"
),
ConfigField(
name="db_table",
label="表名",
field_type=FieldType.STRING,
required=True,
default="user_data"
),
ConfigField(
name="sendto_field",
label="发送状态字段",
field_type=FieldType.STRING,
default="sendto"
)
]
))
schema.add_section(ConfigSection(
name="cookie_source1",
label="数据源1",
fields=[
ConfigField(
name="cookie_1",
label="Cookie",
field_type=FieldType.TEXTAREA,
placeholder="PHPSESSID=xxx; think_var=zh-cn; keeplogin=...",
description="数据源1的完整Cookie"
),
ConfigField(
name="node_id_1",
label="节点ID",
field_type=FieldType.STRING,
default="",
placeholder="留空使用默认节点"
),
ConfigField(
name="receiver_1",
label="接收人",
field_type=FieldType.STRING,
default="",
placeholder="留空使用默认接收人"
)
]
))
schema.add_section(ConfigSection(
name="cookie_source2",
label="数据源2",
fields=[
ConfigField(
name="cookie_2",
label="Cookie",
field_type=FieldType.TEXTAREA,
placeholder="PHPSESSID=xxx; think_var=zh-cn; keeplogin=..."
),
ConfigField(
name="node_id_2",
label="节点ID",
field_type=FieldType.STRING,
default="",
placeholder="留空使用默认节点"
),
ConfigField(
name="receiver_2",
label="接收人",
field_type=FieldType.STRING,
default="",
placeholder="留空使用默认接收人"
)
]
))
schema.add_section(ConfigSection(
name="fetch",
label="抓取设置",
fields=[
ConfigField(
name="fetch_timeout",
label="请求超时(秒)",
field_type=FieldType.NUMBER,
default=15,
min_value=5,
max_value=60
),
ConfigField(
name="send_interval",
label="数据源间隔(秒)",
field_type=FieldType.NUMBER,
default=20,
min_value=0,
max_value=60,
description="两个数据源之间的等待时间"
)
]
))
return schema
class CircuitBreaker:
def __init__(self, failure_threshold: int = 5, timeout: int = 60):
self.failure_threshold = failure_threshold
self.timeout = timeout
self.failures = 0
self.last_failure_time: Optional[float] = None
self.state = "closed"
self._lock = threading.Lock()
def call(self, func, *args, **kwargs):
with self._lock:
if self.state == "open":
if time.time() - self.last_failure_time >= self.timeout:
self.state = "half-open"
logger.info("Circuit breaker: OPEN -> HALF-OPEN")
else:
raise CircuitBreakerOpen("Circuit breaker is OPEN")
try:
result = func(*args, **kwargs)
with self._lock:
if self.state == "half-open":
self.state = "closed"
self.failures = 0
logger.info("Circuit breaker: HALF-OPEN -> CLOSED")
return result
except Exception as e:
with self._lock:
self.failures += 1
self.last_failure_time = time.time()
if self.failures >= self.failure_threshold:
self.state = "open"
logger.warning(f"Circuit breaker: CLOSED -> OPEN (failures={self.failures})")
raise
class CircuitBreakerOpen(Exception):
pass
class AdvancedSenderPlugin(ScheduledTaskPlugin):
plugin_name = "advanced_sender"
plugin_version = "6.0.0"
plugin_description = "高级多节点数据推送插件,支持表单化配置和Cookie变量"
plugin_author = "User"
config_schema = staticmethod(get_config_schema)
_instance_lock = threading.Lock()
def __init__(self):
super().__init__()
self._enabled = False
self._last_run_time: Optional[datetime] = None
self._running = False
self._semaphore: Optional[asyncio.Semaphore] = None
self._cookie_error_reported_1 = False
self._cookie_error_reported_2 = False
self.cron_expression = "*/2 * * * *"
self.db_host = "192.168.2.27"
self.db_port = 3306
self.db_user = "root2"
self.db_password = "root@root"
self.db_name = "addb"
self.db_table = "user_data"
self.sendto_field = "sendto"
self.cookie_1 = ""
self.cookie_2 = ""
self.node_id = "wx1"
self.receiver = "asq"
self.node_id_1 = ""
self.receiver_1 = ""
self.node_id_2 = ""
self.receiver_2 = ""
self.fetch_timeout = 15
self.send_interval = 20
self._api_circuit = CircuitBreaker(failure_threshold=3, timeout=30)
self._db_circuit = CircuitBreaker(failure_threshold=3, timeout=30)
def _get_external_db_config(self) -> Dict[str, Any]:
return {
"host": self.db_host,
"user": self.db_user,
"password": self.db_password,
"database": self.db_name,
"port": self.db_port,
"charset": "utf8mb4"
}
def _get_datasources(self) -> List[Dict[str, Any]]:
return [
{
"name": "source1",
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
"headers": {
"Cookie": self.cookie_1,
"Content-Type": "application/json",
"Accept": "application/json, text/javascript, */*; q=0.01",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36 Edg/140.0.0.0",
"X-Requested-With": "XMLHttpRequest"
},
"verify_ssl": True,
"zhid": "1",
"node_id": self.node_id_1 or self.node_id,
"receiver": self.receiver_1 or self.receiver
},
{
"name": "source2",
"url": "https://xiansuo2.lfgzyx.com/admin/customer/sales/index?addtabs=1&sort=id&order=desc&offset=0&limit=50",
"headers": {
"Cookie": self.cookie_2,
"Content-Type": "application/json",
"Accept": "application/json, text/javascript, */*; q=0.01",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36",
"X-Requested-With": "XMLHttpRequest"
},
"verify_ssl": True,
"zhid": "2",
"node_id": self.node_id_2 or self.node_id,
"receiver": self.receiver_2 or self.receiver
}
]
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.cron_expression = config.get("cron", "*/2 * * * *")
self.db_host = config.get("db_host", self.db_host)
self.db_port = config.get("db_port", 3306)
self.db_user = config.get("db_user", self.db_user)
self.db_password = config.get("db_password", self.db_password)
self.db_name = config.get("db_name", self.db_name)
self.db_table = config.get("db_table", "user_data")
self.sendto_field = config.get("sendto_field", "sendto")
self.cookie_1 = config.get("cookie_1", "")
self.cookie_2 = config.get("cookie_2", "")
self.node_id = config.get("node_id", "wx1")
self.receiver = config.get("receiver", "asq")
self.node_id_1 = config.get("node_id_1", "")
self.receiver_1 = config.get("receiver_1", "")
self.node_id_2 = config.get("node_id_2", "")
self.receiver_2 = config.get("receiver_2", "")
self.fetch_timeout = max(5, min(60, config.get("fetch_timeout", 15)))
self.send_interval = config.get("send_interval", 20)
logger.info(f"AdvancedSenderPlugin initialized: cron={self.cron_expression}")
logger.info(f"Cookie1: {self.cookie_1[:30] if self.cookie_1 else 'empty'}..., Cookie2: {self.cookie_2[:30] if self.cookie_2 else 'empty'}...")
return True
def enable(self):
self._enabled = True
self.enabled = True
logger.info("AdvancedSenderPlugin enabled")
def disable(self):
self._enabled = False
self.enabled = False
logger.info("AdvancedSenderPlugin disabled")
def get_cron_expression(self) -> str:
return self.cron_expression
def should_run_and_get_next(self) -> tuple:
try:
now = datetime.now()
current_hour = now.hour
if current_hour < 8 or current_hour >= 22:
interval_minutes = 15
else:
interval_minutes = 2
cron = croniter(self.cron_expression, now)
prev_run = cron.get_prev(datetime)
next_run = cron.get_next(datetime)
if self._last_run_time is None or self._last_run_time != prev_run:
self._last_run_time = prev_run
return True, next_run
return False, next_run
except Exception as e:
logger.error(f"should_run_and_get_next error: {e}")
return False, None
@contextmanager
def _db_connection(self):
conn = None
try:
import pymysql
conn = pymysql.connect(
host=self.db_host,
user=self.db_user,
password=self.db_password,
database=self.db_name,
port=self.db_port,
charset="utf8mb4",
cursorclass=pymysql.cursors.DictCursor,
connect_timeout=5,
read_timeout=10,
write_timeout=10
)
yield conn
finally:
if conn:
try:
conn.close()
except Exception:
pass
def _fetch_from_datasource(self, datasource: Dict[str, Any]) -> List[Dict[str, Any]]:
source_name = datasource.get('name', 'unknown')
source_num = datasource.get('zhid', '0')
try:
import requests
url = datasource["url"]
if "_=" in url:
url = url + str(int(time.time() * 1000))
logger.info(f"Fetching from {source_name}: {url}")
logger.info(f"Cookie: {datasource['headers'].get('Cookie', '')[:50]}...")
response = requests.get(
url,
headers=datasource.get("headers", {}),
verify=datasource.get("verify_ssl", True),
timeout=self.fetch_timeout
)
if response.status_code == 403 or response.status_code == 401:
error_msg = f"数据源{source_name}Cookie失效 (HTTP {response.status_code})"
logger.error(error_msg)
if source_num == '1' and not self._cookie_error_reported_1:
self._cookie_error_reported_1 = True
self.notify_error("Cookie失效告警", error_msg)
elif source_num == '2' and not self._cookie_error_reported_2:
self._cookie_error_reported_2 = True
self.notify_error("Cookie失效告警", error_msg)
return []
response.raise_for_status()
data = response.json()
total = data.get('total', 0)
rows = data.get("rows", []) if isinstance(data, dict) else []
if total == 0 and len(rows) == 0:
error_msg = f"数据源{source_name}返回空数据,Cookie可能已失效"
logger.warning(error_msg)
if source_num == '1' and not self._cookie_error_reported_1:
self._cookie_error_reported_1 = True
self.notify_warning("数据源为空告警", error_msg)
elif source_num == '2' and not self._cookie_error_reported_2:
self._cookie_error_reported_2 = True
self.notify_warning("数据源为空告警", error_msg)
return []
if source_num == '1' and self._cookie_error_reported_1:
self._cookie_error_reported_1 = False
self.notify_success("数据源恢复", f"数据源{source_name}已恢复正常")
elif source_num == '2' and self._cookie_error_reported_2:
self._cookie_error_reported_2 = False
self.notify_success("数据源恢复", f"数据源{source_name}已恢复正常")
logger.info(f"Fetched data: total={total}")
return rows
except Exception as e:
error_msg = f"数据源{source_name}请求失败: {str(e)}"
logger.error(error_msg)
if source_num == '1' and not self._cookie_error_reported_1:
self._cookie_error_reported_1 = True
self.notify_error("数据源请求失败", error_msg)
elif source_num == '2' and not self._cookie_error_reported_2:
self._cookie_error_reported_2 = True
self.notify_error("数据源请求失败", error_msg)
return []
def _check_id_exists(self, conn, id_value: Any) -> bool:
try:
with conn.cursor() as cursor:
sql = f"SELECT id FROM {self.db_table} WHERE id = %s"
cursor.execute(sql, (id_value,))
result = cursor.fetchone()
return result is not None
except Exception as e:
logger.error(f"Check ID exists failed: {e}")
return False
def _insert_data(self, conn, data: Dict[str, Any], zhid: str) -> bool:
try:
def get_val(field):
val = data.get(field)
return val if val is not None else None
other_text = json.dumps(data.get("other_text")) if data.get("other_text") else None
sql = f"""
INSERT INTO {self.db_table} (
id, source_id, name, phone, location, province_id, city_id, address,
app_name, keshi, create_time, update_time, admin_id, distribute_admin_id,
distribute_time, follow_up_status, is_recover, recover_time, `desc`,
other, wx, ggmc, gid, recover_time_text, weight_id_text,
follow_up_status_text, follow_text, origin_id_text, admin_id_text,
sendto, zhid
) VALUES (
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s, %s, %s, %s, %s, 0, %s
)
"""
values = (
get_val("id"), get_val("source_id"), get_val("name"), get_val("phone"),
get_val("location"), get_val("province_id"), get_val("city_id"), get_val("address"),
get_val("app_name"), get_val("keshi"), get_val("create_time"), get_val("update_time"),
get_val("admin_id"), get_val("distribute_admin_id"), get_val("distribute_time"),
get_val("follow_up_status"), get_val("is_recover"), get_val("recover_time"),
get_val("desc"), other_text, get_val("wx"), get_val("ggmc"),
get_val("gid"), get_val("recover_time_text"), get_val("weight_id_text"),
get_val("follow_up_status_text"), get_val("follow_text"), get_val("origin_id_text"),
get_val("admin_id_text"), zhid
)
with conn.cursor() as cursor:
cursor.execute(sql, values)
conn.commit()
logger.info(f"Successfully inserted data, ID: {data.get('id')}")
return True
except Exception as e:
conn.rollback()
logger.error(f"Insert data failed (ID: {data.get('id')}): {e}")
return False
def _get_pending_records(self, conn) -> List[Dict[str, Any]]:
try:
with conn.cursor() as cursor:
sql = f"SELECT * FROM {self.db_table} WHERE {self.sendto_field} = 0 ORDER BY id DESC"
cursor.execute(sql)
return cursor.fetchall()
except Exception as e:
logger.error(f"Get pending records failed: {e}")
return []
def _get_pending_records_by_zhid(self, conn, zhid: str) -> List[Dict[str, Any]]:
try:
with conn.cursor() as cursor:
sql = f"SELECT * FROM {self.db_table} WHERE {self.sendto_field} = 0 AND zhid = %s ORDER BY id DESC"
cursor.execute(sql, (zhid,))
return cursor.fetchall()
except Exception as e:
logger.error(f"Get pending records by zhid failed: {e}")
return []
def _update_sendto(self, conn, ids: List[int]) -> bool:
if not ids:
return True
try:
with conn.cursor() as cursor:
placeholders = ", ".join(["%s"] * len(ids))
sql = f"UPDATE {self.db_table} SET {self.sendto_field} = 1 WHERE id IN ({placeholders})"
cursor.execute(sql, ids)
conn.commit()
return True
except Exception as e:
conn.rollback()
logger.error(f"Update sendto failed: {e}")
return False
def _sync_node_to_manager(self):
from services.node_manager import node_manager
from database_service import get_db_service
db_service = get_db_service()
if db_service:
node = db_service.get_node(self.node_id)
if node:
existing = node_manager.nodes.get(self.node_id)
if existing:
existing.api_url = node.api_url
existing.api_key = node.api_key
else:
from services.node_manager import Node
new_node = Node(
node_id=node.node_id,
name=node.name,
api_url=node.api_url,
api_key=node.api_key,
description=node.description or "",
group=node.group or "default"
)
node_manager.nodes[node.node_id] = new_node
logger.info(f"Node {self.node_id} synced to node_manager")
def _send_via_center(self, node_id: str, who: str, msg: str) -> Dict[str, Any]:
from services.node_manager import node_manager
from concurrent.futures import ThreadPoolExecutor
self._sync_node_to_manager()
effective_node_id = node_id or self.node_id
def _send_in_thread():
async def _send_async():
return await node_manager.send_message(
node_id=effective_node_id,
who=who,
msg=msg,
msg_type="text"
)
return asyncio.run(_send_async())
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_send_in_thread)
result = future.result(timeout=15)
logger.info(f"Message sent via center to {who} via {effective_node_id}: {result}")
return result
except Exception as e:
logger.error(f"Send via center failed: {e}")
return {"success": False, "error": str(e)}
def _process_pending_records(self, conn, records: List[Dict[str, Any]], datasource: Dict[str, Any]) -> Dict[str, Any]:
if not records:
logger.info("No records to process")
return {"success": True, "sent": 0, "skipped": 0}
target_node_id = datasource.get("node_id", self.node_id)
target_receiver = datasource.get("receiver", self.receiver)
source_name = datasource.get("name", "unknown")
logger.info(f"Processing {len(records)} pending records for {source_name} -> {target_node_id}/{target_receiver}")
sent_count = 0
skipped_count = 0
for record in records:
record_id = record.get("id")
phone = record.get("phone", "")
create_time = record.get("create_time")
myid = record.get("myid", 0)
logger.info(f"Record ID: {record_id}")
if create_time:
create_time_dt = create_time if hasattr(create_time, 'hour') else datetime.now()
hour = create_time_dt.hour
if 0 <= hour <= 7:
logger.info(f"Hour {hour} in 0-7: skip")
skipped_count += 1
continue
elif 8 <= hour <= 16:
if myid % 2 == 0:
target = target_receiver
else:
target = target_receiver
logger.info(f"Hour {hour}, myid={myid}: sending to {target}")
elif 17 <= hour <= 21:
target = target_receiver
logger.info(f"Hour {hour}: sending to {target}")
elif 22 <= hour <= 23:
logger.info(f"Hour {hour} in 22-23: skip")
skipped_count += 1
continue
else:
target = target_receiver
msg = f"{phone}\r\n时间:{create_time}"
result = self._send_via_center(target_node_id, target, msg)
if result.get("success"):
sent_count += 1
return {"success": True, "sent": sent_count, "skipped": skipped_count, "total": len(records)}
def run_task(self) -> Dict[str, Any]:
if self._running:
logger.warning("Previous task still running, skipping")
return {"success": False, "error": "上一次执行尚未完成"}
self._running = True
try:
logger.info(f"Starting task at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
try:
with self._db_connection() as conn:
datasources = self._get_datasources()
all_pending_records = []
for idx, datasource in enumerate(datasources):
rows = self._fetch_from_datasource(datasource)
logger.info(f"Source {datasource.get('name')}: fetched {len(rows)} rows")
for row in rows:
record_id = row.get("id")
if not record_id:
logger.warning("Record missing ID, skip")
continue
if self._check_id_exists(conn, record_id):
logger.info(f"ID {record_id} exists, skip")
continue
self._insert_data(conn, row, datasource.get("zhid", "0"))
if idx < len(datasources) - 1 and self.send_interval > 0:
logger.info(f"Waiting {self.send_interval}s before next source...")
time.sleep(self.send_interval)
for datasource in datasources:
records = self._get_pending_records_by_zhid(conn, datasource.get("zhid", "0"))
if records:
send_result = self._process_pending_records(conn, records, datasource)
logger.info(f"Source {datasource.get('name')} send result: {send_result}")
all_pending_records.extend(records)
if all_pending_records:
ids = [r["id"] for r in all_pending_records]
self._update_sendto(conn, ids)
logger.info(f"Updated {len(ids)} records sendto=1")
return {"success": True, "message": "Task completed", "total": len(all_pending_records)}
except Exception as e:
logger.error(f"Database operation error: {e}")
return {"success": False, "error": str(e)}
except Exception as e:
logger.error(f"run_task error: {e}")
return {"success": False, "error": str(e)}
finally:
self._running = False
def aggregate_results(self, results: List[Dict[str, Any]], shift_name: str = "") -> Dict[str, Any]:
success_count = sum(1 for r in results if isinstance(r, dict) and r.get("success"))
total_sent = sum(r.get("sent", 0) for r in results if isinstance(r, dict))
total_skipped = sum(r.get("skipped", 0) for r in results if isinstance(r, dict))
return {
"success": success_count == len(results),
"shift": shift_name,
"total": len(results),
"success_count": success_count,
"total_sent": total_sent,
"total_skipped": total_skipped,
"results": results
}
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
action = params.get("action", "send")
if action == "send":
return self.run_task()
elif action == "test":
return self.test_config()
elif action == "test_send":
msg = params.get("msg", "测试消息")
node_id = params.get("node_id", self.node_id)
receiver = params.get("receiver", self.receiver)
return self._send_via_center(node_id, receiver, msg)
elif action == "query_pending":
try:
with self._db_connection() as conn:
records = self._get_pending_records(conn)
return {"success": True, "count": len(records), "records": records[:10]}
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": "未知操作"}
def test_config(self) -> Dict[str, Any]:
try:
cron = croniter(self.cron_expression, datetime.now())
prev_run = cron.get_prev(datetime)
next_run = cron.get_next(datetime)
return {
"success": True,
"cron": self.cron_expression,
"prev_run": str(prev_run),
"next_run": str(next_run),
"node_id": self.node_id,
"receiver": self.receiver,
"datasource1": {
"node_id": self.node_id_1 or "(default)",
"receiver": self.receiver_1 or "(default)"
},
"datasource2": {
"node_id": self.node_id_2 or "(default)",
"receiver": self.receiver_2 or "(default)"
},
"db_host": self.db_host,
"db_name": self.db_name,
"db_table": self.db_table,
"cookie_1": self.cookie_1[:50] + "..." if len(self.cookie_1) > 50 else self.cookie_1,
"cookie_2": self.cookie_2[:50] + "..." if len(self.cookie_2) > 50 else self.cookie_2,
"config": {
"fetch_timeout": self.fetch_timeout,
"send_interval": self.send_interval
}
}
except Exception as e:
return {"success": False, "error": str(e)}