初始化提交
This commit is contained in:
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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!"}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": "未知操作"}
|
||||
@@ -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": "未知操作"}
|
||||
@@ -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="从原始值中提取的规则,支持正则或字符串截取"
|
||||
)
|
||||
]
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user