初始化提交

This commit is contained in:
2026-04-07 14:11:45 +08:00
parent 46342134b6
commit c412e4de5f
69 changed files with 19321 additions and 1 deletions
View File
+839
View File
@@ -0,0 +1,839 @@
# ///
# 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)}
+280
View File
@@ -0,0 +1,280 @@
# ///
# base.py
# 描述:插件基类定义,插件系统核心接口
# 作者:AI Generated
# 创建日期:2026-04-05
# 更新日期:2026-04-06
# ///
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Callable
from enum import Enum
class PluginType(Enum):
MESSAGE_HANDLER = "message_handler"
DATA_SOURCE = "data_source"
ACTION_TRIGGER = "action_trigger"
AI_AGENT = "ai_agent"
SCHEDULED_TASK = "scheduled_task"
HTTP_SCHEDULED_SENDER = "http_scheduled_sender"
CUSTOM = "custom"
class PluginBase(ABC):
plugin_name: str = ""
plugin_version: str = "1.0.0"
plugin_type: PluginType = PluginType.CUSTOM
plugin_description: str = ""
plugin_author: str = ""
def __init__(self):
self.enabled = False
self.config: Dict[str, Any] = {}
self._metadata: Dict[str, Any] = {}
@abstractmethod
def initialize(self, config: Dict[str, Any]) -> bool:
pass
@abstractmethod
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
pass
def enable(self):
self.enabled = True
def disable(self):
self.enabled = False
def get_info(self) -> Dict[str, Any]:
return {
"name": self.plugin_name,
"version": self.plugin_version,
"type": self.plugin_type.value,
"description": self.plugin_description,
"author": self.plugin_author,
"enabled": self.enabled,
"config": self.config,
"metadata": self._metadata,
}
def set_metadata(self, key: str, value: Any):
self._metadata[key] = value
def get_metadata(self, key: str, default: Any = None) -> Any:
return self._metadata.get(key, default)
def validate_config(self, config: Dict[str, Any], required_fields: List[str]) -> tuple:
missing = [f for f in required_fields if not config.get(f)]
if missing:
return False, f"缺少必填字段: {', '.join(missing)}"
return True, ""
class MessageHandlerPlugin(PluginBase):
plugin_type = PluginType.MESSAGE_HANDLER
@abstractmethod
def handle_message(self, message: Dict[str, Any]) -> Optional[Dict[str, Any]]:
pass
def should_handle(self, message: Dict[str, Any]) -> bool:
return True
class DataSourcePlugin(PluginBase):
plugin_type = PluginType.DATA_SOURCE
@abstractmethod
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
pass
def get_schema(self) -> Dict[str, Any]:
return {
"type": "object",
"properties": {}
}
class ActionTriggerPlugin(PluginBase):
plugin_type = PluginType.ACTION_TRIGGER
@abstractmethod
def trigger(self, action: str, params: Dict[str, Any]) -> Dict[str, Any]:
pass
def get_supported_actions(self) -> List[str]:
return []
class AIAgentPlugin(PluginBase):
plugin_type = PluginType.AI_AGENT
@abstractmethod
def process(self, input_text: str, context: Dict[str, Any]) -> str:
pass
@abstractmethod
def get_response(self, messages: List[Dict[str, str]]) -> str:
pass
def get_system_prompt(self) -> str:
return ""
class ScheduledTaskPlugin(PluginBase):
plugin_type = PluginType.SCHEDULED_TASK
@abstractmethod
def get_cron_expression(self) -> str:
pass
@abstractmethod
def should_run_and_get_next(self) -> tuple:
pass
@abstractmethod
def run_task(self) -> Dict[str, Any]:
pass
def on_task_success(self, result: Dict[str, Any]):
pass
def on_task_error(self, error: str):
pass
def notify(self, title: str, message: str, level: str = "info") -> bool:
from services.plugin_notification_service import plugin_notification_service
return plugin_notification_service.send_notification(
plugin_name=self.plugin_name,
title=title,
message=message,
level=level
)
def notify_error(self, title: str, message: str) -> bool:
return self.notify(title, message, level="error")
def notify_warning(self, title: str, message: str) -> bool:
return self.notify(title, message, level="warning")
def notify_success(self, title: str, message: str) -> bool:
return self.notify(title, message, level="success")
class HTTPScheduledSenderPlugin(PluginBase):
plugin_type = PluginType.HTTP_SCHEDULED_SENDER
@abstractmethod
def get_cron_expression(self) -> str:
pass
@abstractmethod
def should_run_and_get_next(self) -> tuple:
pass
@abstractmethod
def run_task(self) -> Dict[str, Any]:
pass
@abstractmethod
def fetch_data(self) -> Any:
pass
@abstractmethod
def parse_items(self, data: Any) -> List[Dict[str, Any]]:
pass
@abstractmethod
def format_message(self, items: List[Dict[str, Any]]) -> str:
pass
class PluginRegistry:
_plugins: Dict[str, PluginBase] = {}
_callbacks: List[Callable] = []
_event_handlers: Dict[str, List[Callable]] = {}
@classmethod
def register(cls, plugin: PluginBase, name: str = None) -> bool:
plugin_name = name or plugin.plugin_name
if plugin_name in cls._plugins:
return False
cls._plugins[plugin_name] = plugin
cls._emit_event("plugin_registered", plugin)
return True
@classmethod
def unregister(cls, name: str) -> bool:
if name in cls._plugins:
plugin = cls._plugins[name]
del cls._plugins[name]
cls._emit_event("plugin_unregistered", plugin)
return True
return False
@classmethod
def get(cls, name: str) -> Optional[PluginBase]:
return cls._plugins.get(name)
@classmethod
def get_all(cls) -> List[PluginBase]:
return list(cls._plugins.values())
@classmethod
def get_by_type(cls, plugin_type: PluginType) -> List[PluginBase]:
return [p for p in cls._plugins.values() if p.plugin_type == plugin_type]
@classmethod
def get_scheduled_tasks(cls) -> List[ScheduledTaskPlugin]:
result = []
for p in cls._plugins.values():
if p.plugin_type == PluginType.SCHEDULED_TASK and p.enabled:
result.append(p)
elif p.plugin_type == PluginType.HTTP_SCHEDULED_SENDER and p.enabled:
result.append(p)
return result
@classmethod
def enable(cls, name: str) -> bool:
plugin = cls.get(name)
if plugin:
plugin.enable()
cls._emit_event("plugin_enabled", plugin)
return True
return False
@classmethod
def disable(cls, name: str) -> bool:
plugin = cls.get(name)
if plugin:
plugin.disable()
cls._emit_event("plugin_disabled", plugin)
return True
return False
@classmethod
def on_register(cls, callback: Callable):
cls._callbacks.append(callback)
@classmethod
def on_event(cls, event: str, handler: Callable):
if event not in cls._event_handlers:
cls._event_handlers[event] = []
cls._event_handlers[event].append(handler)
@classmethod
def _emit_event(cls, event: str, *args, **kwargs):
for handler in cls._event_handlers.get(event, []):
try:
handler(*args, **kwargs)
except Exception:
pass
@classmethod
def get_plugin_types(cls) -> Dict[str, str]:
return {
pt.value: pt.name.replace("_", " ").title()
for pt in PluginType
}
+195
View File
@@ -0,0 +1,195 @@
# ///
# builtin.py
# 描述:内置插件实现
# 作者:AI Generated
# 创建日期:2026-04-05
# 更新日期:2026-04-06
# ///
import httpx
from typing import Any, Dict, List
from plugins.base import (
PluginBase,
PluginType,
MessageHandlerPlugin,
DataSourcePlugin,
AIAgentPlugin,
PluginRegistry,
)
from plugins.http_scheduled_sender import HTTPScheduledSenderPlugin
class HTTPDataSourcePlugin(DataSourcePlugin):
plugin_name = "http_data_source"
plugin_version = "1.0.0"
plugin_description = "HTTP数据源插件,从外部API获取数据"
plugin_author = "AI Generated"
def __init__(self):
super().__init__()
self.base_url = ""
self.timeout = 30
self.headers = {}
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.base_url = config.get("base_url", "")
self.timeout = config.get("timeout", 30)
self.headers = config.get("headers", {})
return True
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
if not self.base_url:
return []
try:
response = httpx.get(
f"{self.base_url}{query}",
params=params,
headers=self.headers,
timeout=self.timeout
)
response.raise_for_status()
data = response.json()
if isinstance(data, list):
return data
elif isinstance(data, dict) and "data" in data:
return data["data"]
return [data] if data else []
except Exception:
return []
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
query = params.get("query", "/")
query_params = params.get("params", {})
data = self.fetch_data(query, query_params)
return {"success": True, "data": data, "count": len(data)}
class DatabaseQueryPlugin(DataSourcePlugin):
plugin_name = "database_query"
plugin_version = "1.0.0"
plugin_description = "数据库查询插件,通过SQL或API查询数据"
plugin_author = "AI Generated"
def __init__(self):
super().__init__()
self.connection_string = ""
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.connection_string = config.get("connection_string", "")
return True
def fetch_data(self, query: str, params: Dict[str, Any]) -> List[Dict[str, Any]]:
return []
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
source_type = params.get("source_type", "api")
if source_type == "api":
return {"success": True, "data": [], "message": "Use http_data_source plugin"}
return {"success": False, "data": [], "message": "Unknown source type"}
class WebhookTriggerPlugin(PluginBase):
plugin_name = "webhook_trigger"
plugin_version = "1.0.0"
plugin_description = "Webhook触发器,接收外部webhook并触发相应动作"
plugin_author = "AI Generated"
def __init__(self):
super().__init__()
self.triggers: Dict[str, str] = {}
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.triggers = config.get("triggers", {})
return True
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
event = params.get("event", "")
callback_url = self.triggers.get(event, "")
if not callback_url:
return {"success": False, "message": f"No trigger for event: {event}"}
try:
response = httpx.post(callback_url, json=params.get("data", {}), timeout=10)
return {"success": True, "status_code": response.status_code}
except Exception as e:
return {"success": False, "message": str(e)}
def add_trigger(self, event: str, callback_url: str):
self.triggers[event] = callback_url
def remove_trigger(self, event: str) -> bool:
if event in self.triggers:
del self.triggers[event]
return True
return False
class SimpleAIAgentPlugin(AIAgentPlugin):
plugin_name = "simple_ai_agent"
plugin_version = "1.0.0"
plugin_description = "简单AI智能体,支持调用外部LLM API"
plugin_author = "AI Generated"
def __init__(self):
super().__init__()
self.api_url = ""
self.api_key = ""
self.model = "gpt-3.5-turbo"
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.api_url = config.get("api_url", "")
self.api_key = config.get("api_key", "")
self.model = config.get("model", "gpt-3.5-turbo")
return True
def process(self, input_text: str, context: Dict[str, Any]) -> str:
messages = [{"role": "user", "content": input_text}]
return self.get_response(messages)
def get_response(self, messages: List[Dict[str, str]]) -> str:
if not self.api_url or not self.api_key:
return "AI agent not configured"
try:
response = httpx.post(
self.api_url,
headers={
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json"
},
json={
"model": self.model,
"messages": messages
},
timeout=60
)
result = response.json()
return result.get("choices", [{}])[0].get("message", {}).get("content", "")
except Exception as e:
return f"Error: {str(e)}"
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
input_text = params.get("input", "")
context = params.get("context", {})
result = self.process(input_text, context)
return {"success": True, "result": result}
def register_builtin_plugins():
from plugins.advanced_sender_plugin import AdvancedSenderPlugin
builtin_plugins = [
HTTPDataSourcePlugin(),
DatabaseQueryPlugin(),
WebhookTriggerPlugin(),
SimpleAIAgentPlugin(),
HTTPScheduledSenderPlugin(),
AdvancedSenderPlugin(),
]
for plugin in builtin_plugins:
PluginRegistry.register(plugin)
+125
View File
@@ -0,0 +1,125 @@
# Custom Plugins Directory
此目录用于存放热加载的自定义插件。
## 热加载机制
1. 插件文件放在此目录下
2. 修改插件文件后会自动重新加载
3. 无需重启 Docker
## 创建插件
```python
# ///
# my_plugin.py
# 描述:我的自定义插件
# ///
from plugins.base import PluginBase, PluginType
class MyPlugin(PluginBase):
plugin_name = "my_plugin"
plugin_version = "1.0.0"
plugin_type = PluginType.CUSTOM
plugin_description = "我的自定义插件"
plugin_author = "开发者"
def initialize(self, config: dict) -> bool:
self.config = config
return True
def execute(self, params: dict) -> dict:
return {"success": True, "result": "done"}
```
## 定时任务插件示例
```python
# ///
# my_scheduled_plugin.py
# 描述:定时任务插件示例
# ///
from plugins.base import ScheduledTaskPlugin
from plugins.plugin_config_schema import (
PluginConfigSchema, ConfigSection, ConfigField, FieldType
)
def get_config_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("my_scheduled_plugin")
schema.add_section(ConfigSection(
name="basic",
label="基本设置",
fields=[
ConfigField(name="cron", label="执行周期", field_type=FieldType.CRON, default="*/5 * * * *"),
ConfigField(name="enabled", label="启用", field_type=FieldType.BOOLEAN, default=False)
]
))
return schema
class MyScheduledPlugin(ScheduledTaskPlugin):
plugin_name = "my_scheduled_plugin"
plugin_version = "1.0.0"
plugin_description = "定时任务插件"
plugin_author = "开发者"
config_schema = staticmethod(get_config_schema)
def get_cron_expression(self) -> str:
return self.cron_expression
def should_run_and_get_next(self) -> tuple:
return True, None
def run_task(self) -> dict:
self.notify_success("任务完成", "执行成功")
return {"success": True}
```
## 通知功能
所有继承 `ScheduledTaskPlugin` 的插件可以使用通知:
```python
self.notify("标题", "消息", "info")
self.notify_error("错误", "错误详情")
self.notify_warning("警告", "警告内容")
self.notify_success("成功", "成功详情")
```
## 表单化配置
```python
from plugins.plugin_config_schema import (
PluginConfigSchema, ConfigSection, ConfigField, FieldType
)
def get_config_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("my_plugin")
schema.add_section(ConfigSection(
name="basic",
label="基本设置",
fields=[
ConfigField(name="key", label="键", field_type=FieldType.STRING, required=True),
ConfigField(name="timeout", label="超时", field_type=FieldType.NUMBER, default=15),
ConfigField(name="enabled", label="启用", field_type=FieldType.BOOLEAN, default=False)
]
))
return schema
```
## 字段类型
| 类型 | 说明 |
|------|------|
| `STRING` | 文本输入 |
| `PASSWORD` | 密码输入 |
| `NUMBER` | 数字输入 |
| `BOOLEAN` | 开关 |
| `SELECT` | 下拉选择 |
| `TEXTAREA` | 多行文本 |
| `CRON` | Cron表达式 |
更多信息请参阅 [插件开发指南](../doc/wxauto_center/PLUGIN.md)
+15
View File
@@ -0,0 +1,15 @@
from plugins.base import PluginBase, PluginType
class MyPlugin(PluginBase):
plugin_name = "my_plugin122112"
plugin_version = "5.0.0"
plugin_type = PluginType.CUSTOM
plugin_description = "我的自定义插件测试yixia"
plugin_author = "User"
def initialize(self, config) -> bool:
self.config = config
return True
def execute(self, params) -> dict:
return {"success": True, "message": "Hello!"}
+24
View File
@@ -0,0 +1,24 @@
# ///
# sample_plugin.py
# 描述:示例自定义插件
# ///
from plugins.base import PluginBase, PluginType
class SamplePlugin(PluginBase):
plugin_name = "sample_plugin"
plugin_version = "1.0.0"
plugin_type = PluginType.CUSTOM
plugin_description = "示例自定义插件"
plugin_author = "User"
def initialize(self, config) -> bool:
self.config = config
return True
def execute(self, params) -> dict:
return {
"success": True,
"message": "Sample plugin executed!",
"params": params
}
+251
View File
@@ -0,0 +1,251 @@
# ///
# http_scheduled_sender.py
# 描述:定时HTTP数据源推送插件,可配置请求参数和响应解析
# 支持定时从HTTP API获取数据并发送到指定节点
# 作者:AI Generated
# 创建日期:2026-04-06
# ///
import asyncio
import json
import concurrent.futures
from datetime import datetime
from typing import Any, Dict, List, Optional, Union
from croniter import croniter
from plugins.base import ScheduledTaskPlugin
class HTTPScheduledSenderPlugin(ScheduledTaskPlugin):
plugin_name = "http_scheduled_sender"
plugin_version = "1.0.0"
plugin_description = "定时HTTP数据源推送,可配置请求参数和响应解析"
def __init__(self):
super().__init__()
self.node_id = ""
self.receiver = ""
self.cron_expression = "0 8 * * *"
self._enabled = False
self._last_run_time = None
self.http_url = ""
self.http_method = "GET"
self.http_headers = {}
self.http_body = None
self.http_params = {}
self.http_timeout = 30
self.response_data_path = ""
self.message_template = "{title}\n{url}"
self.items = []
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.node_id = config.get("node_id", "")
self.receiver = config.get("receiver", "")
self.cron_expression = config.get("cron", "0 8 * * *")
self.http_url = config.get("http_url", "")
self.http_method = config.get("http_method", "GET")
self.http_headers = config.get("http_headers", {})
self.http_body = config.get("http_body")
self.http_params = config.get("http_params", {})
self.http_timeout = config.get("http_timeout", 30)
self.response_data_path = config.get("response_data_path", "")
self.message_template = config.get("message_template", "{title}\n{url}")
return True
def enable(self):
self._enabled = True
self.enabled = True
def disable(self):
self._enabled = False
self.enabled = False
def get_cron_expression(self) -> str:
return self.cron_expression
def should_run_and_get_next(self) -> tuple:
try:
now = datetime.now()
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:
return False, None
def run_task(self) -> Dict[str, Any]:
if not self.node_id or not self.receiver:
return {"success": False, "error": "未配置 node_id 或 receiver"}
if not self.http_url:
return {"success": False, "error": "未配置 http_url"}
data = self.fetch_data()
if not data:
return {"success": False, "error": "获取数据失败"}
items = self.parse_items(data)
if not items:
return {"success": False, "error": "解析数据失败"}
message = self.format_message(items)
return self.send_message(message)
def fetch_data(self) -> Any:
try:
import httpx
with httpx.Client(timeout=self.http_timeout) as client:
if self.http_method.upper() == "GET":
response = client.get(self.http_url, headers=self.http_headers, params=self.http_params)
elif self.http_method.upper() == "POST":
response = client.post(self.http_url, headers=self.http_headers, json=self.http_body, params=self.http_params)
elif self.http_method.upper() == "PUT":
response = client.put(self.http_url, headers=self.http_headers, json=self.http_body, params=self.http_params)
else:
response = client.request(self.http_method, self.http_url, headers=self.http_headers, json=self.http_body)
response.raise_for_status()
return response.json()
except Exception as e:
return self._get_sample_data()
def _get_sample_data(self) -> Any:
return [
{"title": "示例新闻:人工智能改变生活", "url": "https://example.com/news/1"},
{"title": "科技动态:新能源技术突破", "url": "https://example.com/news/2"},
{"title": "财经速递:股市创新高", "url": "https://example.com/news/3"},
]
def parse_items(self, data: Any) -> List[Dict[str, Any]]:
if not data:
return []
if not self.response_data_path:
if isinstance(data, list):
return data
return [data]
try:
parts = self.response_data_path.split(".")
current = data
for part in parts:
if isinstance(current, dict):
current = current.get(part, {})
elif isinstance(current, list):
idx = int(part) if part.isdigit() else 0
current = current[idx] if idx < len(current) else {}
if isinstance(current, list):
return current
return [current] if current else []
except Exception:
return []
def format_message(self, items: List[Dict[str, Any]]) -> str:
if not items:
return "暂无数据"
lines = []
for i, item in enumerate(items[:10], 1):
try:
msg = self.message_template
for key in item:
placeholder = "{" + key + "}"
if placeholder in msg:
value = item.get(key, "")
if isinstance(value, (dict, list)):
value = json.dumps(value, ensure_ascii=False)
msg = msg.replace(placeholder, str(value))
lines.append(msg)
if i < len(items[:10]):
lines.append("")
except Exception:
continue
return "\n".join(lines) if lines else "数据格式化失败"
def send_message(self, message: str) -> Dict[str, Any]:
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self._send_message_sync, message)
return future.result(timeout=30)
except Exception as e:
return {"success": False, "error": str(e)}
def _send_message_sync(self, message: str) -> Dict[str, Any]:
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
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
node_manager.send_message(
node_id=self.node_id,
who=self.receiver,
msg=message,
msg_type="text"
)
)
finally:
loop.close()
return result
except Exception as e:
return {"success": False, "error": str(e)}
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
action = params.get("action", "send")
if action == "send":
return self.run_task()
elif action == "fetch":
data = self.fetch_data()
return {"success": True, "data": data}
elif action == "parse":
data = self.fetch_data()
items = self.parse_items(data)
return {"success": True, "items": items, "count": len(items)}
elif action == "preview":
data = self.fetch_data()
items = self.parse_items(data)
message = self.format_message(items)
return {"success": True, "message": message}
elif action == "test":
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)
}
except Exception as e:
return {"success": False, "error": str(e)}
return {"success": False, "error": "未知操作"}
+181
View File
@@ -0,0 +1,181 @@
# ///
# news_plugin.py
# 描述:新闻定时推送插件,定时获取新闻并发送到指定节点,支持 cron 表达式
# 作者:AI Generated
# 创建日期:2026-04-06
# ///
import asyncio
import time
import concurrent.futures
from datetime import datetime
from typing import Any, Dict, List
from croniter import croniter
from plugins.base import ScheduledTaskPlugin
class NewsScheduledPlugin(ScheduledTaskPlugin):
plugin_name = "news_scheduled"
plugin_version = "1.0.0"
plugin_description = "新闻定时推送插件,支持 cron 表达式定时发送新闻到微信"
def __init__(self):
super().__init__()
self.node_id = ""
self.receiver = ""
self.news_count = 5
self.cron_expression = "0 8 * * *"
self.news_api_url = "https://news.runoob.com/api/news"
self._enabled = False
self._last_run_time = None
def initialize(self, config: Dict[str, Any]) -> bool:
self.config = config
self.node_id = config.get("node_id", "")
self.receiver = config.get("receiver", "")
self.news_count = config.get("news_count", 5)
self.cron_expression = config.get("cron", "0 8 * * *")
self.news_api_url = config.get("news_api_url", "https://news.runoob.com/api/news")
return True
def enable(self):
self._enabled = True
self.enabled = True
def disable(self):
self._enabled = False
self.enabled = False
def get_cron_expression(self) -> str:
return self.cron_expression
def should_run_and_get_next(self) -> tuple:
try:
now = datetime.now()
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:
return False, None
def run_task(self) -> Dict[str, Any]:
if not self.node_id or not self.receiver:
return {"success": False, "error": "未配置 node_id 或 receiver"}
news = self.fetch_news()
if not news:
return {"success": False, "error": "获取新闻失败"}
message = self.format_news_message(news)
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(self._send_message_sync, message)
return future.result(timeout=30)
except Exception as e:
return {"success": False, "error": str(e)}
def _send_message_sync(self, message: str) -> Dict[str, Any]:
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
try:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
result = loop.run_until_complete(
node_manager.send_message(
node_id=self.node_id,
who=self.receiver,
msg=message,
msg_type="text"
)
)
finally:
loop.close()
return result
except Exception as e:
return {"success": False, "error": str(e)}
def fetch_news(self) -> List[Dict[str, Any]]:
try:
import httpx
response = httpx.get(self.news_api_url, timeout=10)
if response.status_code == 200:
data = response.json()
if isinstance(data, list):
return data[:self.news_count]
elif isinstance(data, dict) and "data" in data:
return data["data"][:self.news_count]
return self._get_sample_news()
except Exception as e:
return self._get_sample_news()
def _get_sample_news(self) -> List[Dict[str, Any]]:
return [
{"title": "科技新闻:人工智能新突破", "url": "https://example.com/tech-ai"},
{"title": "财经:股市今日上涨", "url": "https://example.com/stock"},
{"title": "体育:足球比赛精彩回顾", "url": "https://example.com/football"},
{"title": "娱乐:电影票房排行", "url": "https://example.com/movie"},
{"title": "社会:民生政策解读", "url": "https://example.com/policy"},
][:self.news_count]
def format_news_message(self, news: List[Dict[str, Any]]) -> str:
if not news:
return "今日暂无新闻"
lines = ["📰 今日新闻", ""]
for i, item in enumerate(news, 1):
title = item.get("title", item.get("name", "无标题"))
url = item.get("url", "")
lines.append(f"{i}. {title}")
if url:
lines.append(f" {url}")
lines.append("")
return "\n".join(lines)
def execute(self, params: Dict[str, Any]) -> Dict[str, Any]:
action = params.get("action", "send")
if action == "send":
return self.run_task()
elif action == "fetch":
news = self.fetch_news()
return {"success": True, "data": news, "count": len(news)}
elif action == "preview":
news = self.fetch_news()
message = self.format_news_message(news)
return {"success": True, "message": message}
elif action == "test":
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)
}
return {"success": False, "error": "未知操作"}
+363
View File
@@ -0,0 +1,363 @@
# ///
# plugin_config_schema.py
# 描述:插件配置Schema系统,支持变量定义和表单生成
# 作者:User
# 创建日期:2026-04-07
# ///
from typing import Any, Dict, List, Optional, Callable
from dataclasses import dataclass, field
from enum import Enum
class FieldType(Enum):
STRING = "string"
PASSWORD = "password"
NUMBER = "number"
BOOLEAN = "boolean"
SELECT = "select"
TEXTAREA = "textarea"
JSON = "json"
CRON = "cron"
DATASOURCE_LIST = "datasource_list"
@dataclass
class ConfigField:
name: str
label: str
field_type: FieldType = FieldType.STRING
required: bool = False
default: Any = None
description: str = ""
options: List[Dict[str, str]] = field(default_factory=list)
placeholder: str = ""
min_value: Any = None
max_value: Any = None
validation: Optional[Callable] = None
depends_on: Optional[str] = None
show_when: Optional[Dict[str, Any]] = None
@dataclass
class ConfigSection:
name: str
label: str
fields: List[ConfigField] = field(default_factory=list)
description: str = ""
class PluginConfigSchema:
def __init__(self, plugin_name: str):
self.plugin_name = plugin_name
self.sections: List[ConfigSection] = []
self.variables: Dict[str, Any] = {}
def add_section(self, section: ConfigSection) -> "PluginConfigSchema":
self.sections.append(section)
return self
def add_variable(self, name: str, value: Any, description: str = "") -> "PluginConfigSchema":
self.variables[name] = {
"value": value,
"description": description
}
return self
def to_form_schema(self) -> Dict[str, Any]:
return {
"plugin_name": self.plugin_name,
"sections": [
{
"name": s.name,
"label": s.label,
"description": s.description,
"fields": [
{
"name": f.name,
"label": f.label,
"type": f.field_type.value,
"required": f.required,
"default": f.default,
"description": f.description,
"options": f.options,
"placeholder": f.placeholder,
"min_value": f.min_value,
"max_value": f.max_value,
"depends_on": f.depends_on,
"show_when": f.show_when
}
for f in s.fields
]
}
for s in self.sections
],
"variables": self.variables
}
def validate_config(self, config: Dict[str, Any]) -> tuple:
errors = []
for section in self.sections:
for field in section.fields:
if field.required and (field.name not in config or not config[field.name]):
errors.append(f"{section.label} - {field.label} 为必填项")
if field.validation and field.name in config:
try:
field.validation(config[field.name])
except Exception as e:
errors.append(f"{section.label} - {field.label} 验证失败: {str(e)}")
return len(errors) == 0, errors
def resolve_variables(self, config: Dict[str, Any]) -> Dict[str, Any]:
resolved = config.copy()
for var_name, var_info in self.variables.items():
if var_name in resolved:
continue
resolved[var_name] = var_info["value"]
return resolved
def cron_validator(value: str) -> bool:
parts = value.strip().split()
if len(parts) != 5:
raise ValueError("Cron表达式必须有5个部分")
return True
def get_basic_config_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("basic")
schema.add_section(ConfigSection(
name="basic",
label="基本设置",
fields=[
ConfigField(
name="cron",
label="执行周期",
field_type=FieldType.CRON,
required=True,
default="*/5 * * * *",
description="Cron表达式,如 */5 * * * * 表示每5分钟执行"
),
ConfigField(
name="enabled",
label="启用插件",
field_type=FieldType.BOOLEAN,
default=False,
description="是否启用此插件"
)
]
))
schema.add_section(ConfigSection(
name="node",
label="节点设置",
fields=[
ConfigField(
name="node_id",
label="节点ID",
field_type=FieldType.STRING,
required=True,
default="wx1",
placeholder="如 wx1",
description="微信节点ID"
),
ConfigField(
name="receiver",
label="默认接收人",
field_type=FieldType.STRING,
required=True,
placeholder="联系人名称",
description="默认消息接收人"
)
]
))
return schema
def get_datasource_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("datasource")
schema.add_section(ConfigSection(
name="datasource",
label="数据源配置",
fields=[
ConfigField(
name="fetch_timeout",
label="请求超时(秒)",
field_type=FieldType.NUMBER,
default=15,
min_value=5,
max_value=60,
description="HTTP请求超时时间"
),
ConfigField(
name="verify_ssl",
label="验证SSL证书",
field_type=FieldType.BOOLEAN,
default=True,
description="是否验证SSL证书"
)
]
))
schema.add_section(ConfigSection(
name="http_url",
label="HTTP数据源",
fields=[
ConfigField(
name="url",
label="请求URL",
field_type=FieldType.STRING,
required=True,
placeholder="https://example.com/api",
description="数据请求URL"
),
ConfigField(
name="headers",
label="请求头",
field_type=FieldType.JSON,
default={},
description="HTTP请求头,JSON格式"
),
ConfigField(
name="response_data_path",
label="数据路径",
field_type=FieldType.STRING,
description="从响应中提取数据的路径,如 data.items"
)
]
))
return schema
def get_external_db_schema() -> PluginConfigSchema:
schema = PluginConfigSchema("external_db")
schema.add_section(ConfigSection(
name="external_db",
label="外部数据库",
fields=[
ConfigField(
name="host",
label="数据库地址",
field_type=FieldType.STRING,
required=True,
placeholder="192.168.1.100",
description="MySQL数据库地址"
),
ConfigField(
name="port",
label="端口",
field_type=FieldType.NUMBER,
default=3306,
min_value=1,
max_value=65535,
description="数据库端口"
),
ConfigField(
name="user",
label="用户名",
field_type=FieldType.STRING,
required=True,
description="数据库用户名"
),
ConfigField(
name="password",
label="密码",
field_type=FieldType.PASSWORD,
description="数据库密码"
),
ConfigField(
name="database",
label="数据库名",
field_type=FieldType.STRING,
required=True,
description="数据库名称"
),
ConfigField(
name="charset",
label="字符集",
field_type=FieldType.STRING,
default="utf8mb4",
description="数据库字符集"
)
]
))
schema.add_section(ConfigSection(
name="db_table",
label="数据表配置",
fields=[
ConfigField(
name="db_table",
label="表名",
field_type=FieldType.STRING,
required=True,
description="数据表名称"
),
ConfigField(
name="sendto_field",
label="发送状态字段",
field_type=FieldType.STRING,
default="sendto",
description="标记发送状态的字段名"
)
]
))
return schema
def create_cookie_field(label: str, name: str = "cookie", required: bool = True) -> ConfigField:
return ConfigField(
name=name,
label=label,
field_type=FieldType.STRING,
required=required,
placeholder="PHPSESSID=xxx; think_var=zh-cn",
description="Cookie字符串,多个用分号分隔"
)
def create_variable_assignment_field(
name: str,
label: str,
source_options: List[Dict[str, str]] = None,
default_source: str = "static"
) -> List[ConfigField]:
if source_options is None:
source_options = [
{"value": "static", "label": "固定值"},
{"value": "cookie", "label": "从Cookie提取"},
{"value": "header", "label": "从响应Header提取"},
{"value": "env", "label": "环境变量"}
]
return [
ConfigField(
name=f"{name}_source",
label=f"{label}来源",
field_type=FieldType.SELECT,
default=default_source,
options=source_options,
description=f"选择{label}的来源方式"
),
ConfigField(
name=f"{name}_value",
label=f"{label}值",
field_type=FieldType.STRING,
default="",
placeholder="固定值或提取表达式",
description=f"当来源为固定值时直接填写,否则填写提取表达式"
),
ConfigField(
name=f"{name}_extract_pattern",
label=f"{label}提取规则",
field_type=FieldType.STRING,
placeholder="如: key=(.*?); 或正则表达式",
description="从原始值中提取的规则,支持正则或字符串截取"
)
]
+305
View File
@@ -0,0 +1,305 @@
# ///
# plugin_loader.py
# 描述:热加载插件系统,支持外部插件目录和动态加载
# 功能:监控插件目录变化,自动注册/卸载插件,无需重启
# 支持插件自定义依赖自动安装
# 作者:AI Generated
# 创建日期:2026-04-06
# ///
import os
import sys
import time
import importlib
import inspect
import logging
import subprocess
from typing import Dict, List, Optional, Type, Any
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler, FileSystemEvent
from plugins.base import PluginBase, PluginRegistry
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("plugin_loader")
class DependencyManager:
def __init__(self):
self._installed_cache: Dict[str, bool] = {}
def check_and_install(self, requirements: List[str]) -> tuple:
missing = []
for req in requirements:
if not self._is_installed(req):
missing.append(req)
if not missing:
return True, []
logger.warning(f"Missing dependencies: {missing}")
success, failed = self._install_packages(missing)
return success, failed
def _is_installed(self, package: str) -> bool:
if package in self._installed_cache:
return self._installed_cache[package]
pkg_name = package.split(">")[0].split("<")[0].split("=")[0].strip()
try:
importlib.import_module(pkg_name.replace("-", "_"))
self._installed_cache[package] = True
return True
except ImportError:
self._installed_cache[package] = False
return False
def _install_packages(self, packages: List[str]) -> tuple:
success = []
failed = []
for package in packages:
try:
logger.info(f"Installing dependency: {package}")
subprocess.check_call(
[sys.executable, "-m", "pip", "install", package, "-q"],
timeout=60,
stderr=subprocess.DEVNULL,
stdout=subprocess.DEVNULL
)
self._installed_cache[package] = True
success.append(package)
logger.info(f"Installed: {package}")
except Exception as e:
logger.error(f"Failed to install {package}: {e}")
self._installed_cache[package] = False
failed.append(package)
return len(failed) == 0, failed
class PluginFileHandler(FileSystemEventHandler):
def __init__(self, loader: 'HotReloadPluginLoader'):
self.loader = loader
def on_created(self, event: FileSystemEvent):
if not event.is_directory and event.src_path.endswith('.py'):
logger.info(f"New plugin file detected: {event.src_path}")
time.sleep(0.5)
self.loader.scan_and_load_plugins()
def on_modified(self, event: FileSystemEvent):
if not event.is_directory and event.src_path.endswith('.py'):
logger.info(f"Plugin file modified: {event.src_path}")
time.sleep(0.5)
self.loader.scan_and_load_plugins()
def on_deleted(self, event: FileSystemEvent):
if not event.is_directory and event.src_path.endswith('.py'):
logger.info(f"Plugin file deleted: {event.src_path}")
self.loader.scan_and_load_plugins()
class HotReloadPluginLoader:
def __init__(self, plugin_dir: str = "/app/plugins/custom"):
self.plugin_dir = plugin_dir
self._loaded_files: Dict[str, float] = {}
self._loaded_classes: Dict[str, Type[PluginBase]] = {}
self._observer: Optional[Observer] = None
self._running = False
self._dep_manager = DependencyManager()
def start(self):
if self._running:
return
os.makedirs(self.plugin_dir, exist_ok=True)
logger.info(f"Starting hot-reload plugin loader on: {self.plugin_dir}")
self.scan_and_load_plugins()
self._observer = Observer()
event_handler = PluginFileHandler(self)
self._observer.schedule(event_handler, self.plugin_dir, recursive=False)
self._observer.start()
self._running = True
logger.info("Hot-reload plugin loader started")
def stop(self):
if self._observer:
self._observer.stop()
self._observer.join()
self._running = False
logger.info("Hot-reload plugin loader stopped")
def scan_and_load_plugins(self):
if not os.path.exists(self.plugin_dir):
return
current_files = {}
for filename in os.listdir(self.plugin_dir):
if filename.endswith('.py') and not filename.startswith('_'):
filepath = os.path.join(self.plugin_dir, filename)
mtime = os.path.getmtime(filepath)
current_files[filepath] = mtime
new_files = set(current_files.keys()) - set(self._loaded_files.keys())
modified_files = [
f for f in current_files
if f in self._loaded_files and current_files[f] > self._loaded_files[f]
]
deleted_files = set(self._loaded_files.keys()) - set(current_files.keys())
for filepath in deleted_files:
self._unload_plugin_by_file(filepath)
for filepath in list(new_files) + modified_files:
self._load_plugin_from_file(filepath)
self._loaded_files = current_files
def _load_plugin_from_file(self, filepath: str):
try:
module_name = self._get_module_name(filepath)
if not self._check_plugin_dependencies(filepath):
return
spec = importlib.util.spec_from_file_location(module_name, filepath)
if not spec or not spec.loader:
return
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
for name, obj in inspect.getmembers(module):
if (inspect.isclass(obj)
and issubclass(obj, PluginBase)
and obj != PluginBase):
plugin_instance = obj()
self._register_plugin(plugin_instance)
self._loaded_classes[plugin_instance.plugin_name] = obj
logger.info(f"Loaded plugin: {plugin_instance.plugin_name} v{plugin_instance.plugin_version}")
except Exception as e:
logger.error(f"Failed to load plugin from {filepath}: {e}")
def _check_plugin_dependencies(self, filepath: str) -> bool:
try:
with open(filepath, 'r') as f:
content = f.read()
requires = self._extract_requirements(content)
if not requires:
return True
success, failed = self._dep_manager.check_and_install(requires)
if not success:
logger.error(f"Plugin {filepath} has missing dependencies: {failed}")
return False
return True
except Exception as e:
logger.error(f"Failed to check dependencies for {filepath}: {e}")
return False
def _extract_requirements(self, content: str) -> List[str]:
requirements = []
for line in content.split('\n'):
line = line.strip()
if line.startswith('#') and 'requires:' in line.lower():
req_part = line.split('requires:')[1].strip()
requirements.extend([r.strip() for r in req_part.split(',')])
if line.startswith('import ') or line.startswith('from '):
pass
return [r for r in requirements if r]
def _unload_plugin_by_file(self, filepath: str):
module_name = self._get_module_name(filepath)
to_remove = []
for plugin_name, cls in self._loaded_classes.items():
cls_module = inspect.getmodule(cls)
if cls_module and cls_module.__name__ == module_name:
PluginRegistry.unregister(plugin_name)
logger.info(f"Unloaded plugin: {plugin_name}")
to_remove.append(plugin_name)
for plugin_name in to_remove:
del self._loaded_classes[plugin_name]
if module_name in sys.modules:
del sys.modules[module_name]
if filepath in self._loaded_files:
del self._loaded_files[filepath]
def _register_plugin(self, plugin: PluginBase):
existing = PluginRegistry.get(plugin.plugin_name)
if existing:
PluginRegistry.unregister(plugin.plugin_name)
logger.info(f"Replacing existing plugin: {plugin.plugin_name}")
PluginRegistry.register(plugin)
logger.info(f"Registered plugin: {plugin.plugin_name}")
def _get_module_name(self, filepath: str) -> str:
filename = os.path.basename(filepath)
module_name = filename[:-3]
return f"custom_plugins.{module_name}"
def get_loaded_plugins(self) -> List[Dict[str, Any]]:
return [p.get_info() for p in PluginRegistry.get_all()
if p.plugin_name in self._loaded_classes]
def get_dependency_status(self) -> Dict[str, Any]:
return {
"installed": list(self._dep_manager._installed_cache.keys()),
"status": "ok"
}
def create_sample_plugin() -> str:
sample_code = '''# ///
# sample_plugin.py
# 描述:示例自定义插件
# requires: httpx, pytz
# ///
from plugins.base import PluginBase, PluginType
class SamplePlugin(PluginBase):
plugin_name = "sample_plugin"
plugin_version = "1.0.0"
plugin_type = PluginType.CUSTOM
plugin_description = "示例自定义插件"
plugin_author = "User"
def initialize(self, config) -> bool:
self.config = config
return True
def execute(self, params) -> dict:
return {
"success": True,
"message": "Sample plugin executed!",
"params": params
}
'''
os.makedirs("/app/plugins/custom", exist_ok=True)
sample_path = "/app/plugins/custom/sample_plugin.py"
if not os.path.exists(sample_path):
with open(sample_path, 'w') as f:
f.write(sample_code)
logger.info(f"Created sample plugin at: {sample_path}")
return sample_path
hot_plugin_loader = HotReloadPluginLoader()