feat(plugin-schedule): add DB-driven plugin scheduler and xiuren scheduled push
This commit is contained in:
85
admin/dashboard/blueprints/plugin_schedules.py
Normal file
85
admin/dashboard/blueprints/plugin_schedules.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||
|
||||
from .auth import login_required
|
||||
|
||||
|
||||
plugin_schedules_bp = Blueprint("plugin_schedules", __name__, url_prefix="/plugin_schedules")
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/")
|
||||
@login_required
|
||||
def page_plugin_schedules():
|
||||
return render_template("plugin_schedules.html")
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/schedules", methods=["GET"])
|
||||
@login_required
|
||||
def api_list_schedules():
|
||||
server = current_app.dashboard_server
|
||||
data = server.plugin_schedule_manager.list_schedules_with_runtime()
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/actions", methods=["GET"])
|
||||
@login_required
|
||||
def api_list_actions():
|
||||
server = current_app.dashboard_server
|
||||
data = server.plugin_schedule_manager.get_available_plugin_actions()
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/schedules/<int:schedule_id>", methods=["PUT"])
|
||||
@login_required
|
||||
def api_update_schedule(schedule_id: int):
|
||||
server = current_app.dashboard_server
|
||||
payload = request.get_json(silent=True) or {}
|
||||
|
||||
updates = {}
|
||||
for key in (
|
||||
"action_name",
|
||||
"description",
|
||||
"trigger_type",
|
||||
"trigger_config",
|
||||
"target_scope",
|
||||
"target_config",
|
||||
"payload",
|
||||
"enabled",
|
||||
):
|
||||
if key in payload:
|
||||
updates[key] = payload[key]
|
||||
|
||||
if not updates:
|
||||
return jsonify({"success": False, "message": "没有可更新字段"}), 400
|
||||
|
||||
ok = server.plugin_schedule_manager.update_schedule(schedule_id, updates)
|
||||
if not ok:
|
||||
return jsonify({"success": False, "message": "更新失败"}), 500
|
||||
|
||||
return jsonify({"success": True, "message": "更新成功"})
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/schedules/<int:schedule_id>/trigger", methods=["POST"])
|
||||
@login_required
|
||||
def api_trigger_schedule(schedule_id: int):
|
||||
server = current_app.dashboard_server
|
||||
ok, msg = server.plugin_schedule_manager.trigger_now(schedule_id)
|
||||
code = 200 if ok else 400
|
||||
return jsonify({"success": ok, "message": msg}), code
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/schedules/<int:schedule_id>/logs", methods=["GET"])
|
||||
@login_required
|
||||
def api_schedule_logs(schedule_id: int):
|
||||
server = current_app.dashboard_server
|
||||
limit = int(request.args.get("limit", 100))
|
||||
logs = server.plugin_schedule_manager.get_logs(schedule_id, limit=limit)
|
||||
return jsonify({"success": True, "data": logs})
|
||||
|
||||
|
||||
@plugin_schedules_bp.route("/api/reload", methods=["POST"])
|
||||
@login_required
|
||||
def api_reload_schedules():
|
||||
server = current_app.dashboard_server
|
||||
server.plugin_schedule_manager.reload_from_db()
|
||||
return jsonify({"success": True, "message": "已按数据库配置重载插件调度"})
|
||||
@@ -48,6 +48,8 @@ class DashboardServer:
|
||||
self.task_db: TaskDBOperator = TaskDBOperator(self.db_manager)
|
||||
self.system_job_db = robot_instance.system_job_db
|
||||
self.system_job_loader = robot_instance.system_job_loader
|
||||
self.plugin_schedule_db = robot_instance.plugin_schedule_db
|
||||
self.plugin_schedule_manager = robot_instance.plugin_schedule_manager
|
||||
# 获取联系人管理器实例
|
||||
self.contact_manager = robot_instance.contact_manager
|
||||
self.plugin_manager = robot_instance.plugin_manager
|
||||
@@ -151,6 +153,7 @@ class DashboardServer:
|
||||
from admin.dashboard.blueprints.message_push import message_push_bp
|
||||
from admin.dashboard.blueprints.friend_circle import friend_circle_bp
|
||||
from admin.dashboard.blueprints.system_jobs import system_jobs_bp
|
||||
from admin.dashboard.blueprints.plugin_schedules import plugin_schedules_bp
|
||||
|
||||
# 在app.register_blueprint部分添加
|
||||
app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group')
|
||||
@@ -166,6 +169,7 @@ class DashboardServer:
|
||||
app.register_blueprint(message_push_bp)
|
||||
app.register_blueprint(friend_circle_bp)
|
||||
app.register_blueprint(system_jobs_bp)
|
||||
app.register_blueprint(plugin_schedules_bp)
|
||||
|
||||
self.LOG.info("所有蓝图已注册")
|
||||
|
||||
|
||||
@@ -814,6 +814,7 @@
|
||||
items: [
|
||||
{ label: '插件统计', path: '/plugins' },
|
||||
{ label: '插件管理', path: '/plugins_manage' },
|
||||
{ label: '插件定时任务', path: '/plugin_schedules' },
|
||||
{ label: '接口文档', path: '/api_docs' }
|
||||
]
|
||||
},
|
||||
|
||||
280
admin/dashboard/templates/plugin_schedules.html
Normal file
280
admin/dashboard/templates/plugin_schedules.html
Normal file
@@ -0,0 +1,280 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}插件定时任务 - 机器人管理后台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-shell">
|
||||
<div class="page-hero">
|
||||
<div class="page-hero-copy">
|
||||
<div class="page-eyebrow">Plugin Scheduler</div>
|
||||
<h1>插件定时任务</h1>
|
||||
<p>统一管理插件的定时动作配置(如秀人群发),支持按群范围执行、立即触发与执行日志追踪。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button type="primary" plain @click="reloadSchedules">按表重载</el-button>
|
||||
<el-button type="success" @click="loadSchedules">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="schedules" style="width:100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||
<el-table-column prop="plugin_name" label="插件" min-width="120"></el-table-column>
|
||||
<el-table-column prop="action_name" label="动作" min-width="140"></el-table-column>
|
||||
<el-table-column prop="trigger_text" label="调度" min-width="170"></el-table-column>
|
||||
<el-table-column prop="target_scope" label="目标范围" width="150"></el-table-column>
|
||||
<el-table-column label="启用" width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'">{% raw %}{{ scope.row.enabled ? '是' : '否' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="next_run_at" label="下次执行" min-width="165"></el-table-column>
|
||||
<el-table-column prop="last_run_at" label="上次执行" min-width="165"></el-table-column>
|
||||
<el-table-column label="最近结果" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="280">
|
||||
<template slot-scope="scope">
|
||||
<div class="action-row">
|
||||
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="success" plain @click="triggerNow(scope.row)">立即触发</el-button>
|
||||
<el-button size="mini" type="text" @click="viewLogs(scope.row)">日志</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog title="编辑插件调度" :visible.sync="editDialogVisible" width="560px">
|
||||
<el-form :model="editForm" label-width="110px">
|
||||
<el-form-item label="动作名称"><el-input v-model="editForm.action_name"></el-input></el-form-item>
|
||||
<el-form-item label="动作说明"><el-input v-model="editForm.description"></el-input></el-form-item>
|
||||
<el-form-item label="启用"><el-switch v-model="editForm.enabled"></el-switch></el-form-item>
|
||||
<el-form-item label="触发类型">
|
||||
<el-select v-model="editForm.trigger_type">
|
||||
<el-option label="每天固定时间" value="at_times"></el-option>
|
||||
<el-option label="固定间隔(秒)" value="every_seconds"></el-option>
|
||||
<el-option label="每周固定时间" value="every_weekday_time"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'at_times'" label="时间列表">
|
||||
<el-input v-model="editForm.time_list_text" placeholder="09:00,21:00"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_seconds'" label="间隔秒">
|
||||
<el-input-number v-model="editForm.seconds" :min="1"></el-input-number>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_weekday_time'" label="星期">
|
||||
<el-select v-model="editForm.weekday">
|
||||
<el-option label="周一" :value="0"></el-option>
|
||||
<el-option label="周二" :value="1"></el-option>
|
||||
<el-option label="周三" :value="2"></el-option>
|
||||
<el-option label="周四" :value="3"></el-option>
|
||||
<el-option label="周五" :value="4"></el-option>
|
||||
<el-option label="周六" :value="5"></el-option>
|
||||
<el-option label="周日" :value="6"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.trigger_type === 'every_weekday_time'" label="时间">
|
||||
<el-input v-model="editForm.time_str" placeholder="10:00"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标范围">
|
||||
<el-select v-model="editForm.target_scope">
|
||||
<el-option label="所有开启群" value="all_enabled_groups"></el-option>
|
||||
<el-option label="群白名单" value="group_whitelist"></el-option>
|
||||
<el-option label="单个群" value="single_group"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.target_scope === 'group_whitelist'" label="群ID列表">
|
||||
<el-input v-model="editForm.group_ids_text" placeholder="群ID逗号分隔"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="editForm.target_scope === 'single_group'" label="群ID">
|
||||
<el-input v-model="editForm.single_group_id"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Payload(JSON)">
|
||||
<el-input type="textarea" :rows="4" v-model="editForm.payload_text"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="editDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveEdit">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog title="调度日志" :visible.sync="logsDialogVisible" width="860px">
|
||||
<el-table :data="logs" style="width:100%">
|
||||
<el-table-column prop="triggered_at" label="触发时间" width="180"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="100"></el-table-column>
|
||||
<el-table-column prop="summary" label="摘要" min-width="220"></el-table-column>
|
||||
<el-table-column label="详情">
|
||||
<template slot-scope="scope">
|
||||
<pre class="detail-pre">{% raw %}{{ JSON.stringify(scope.row.detail_json || {}, null, 2) }}{% endraw %}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
schedules: [],
|
||||
editDialogVisible: false,
|
||||
logsDialogVisible: false,
|
||||
logs: [],
|
||||
currentId: 0,
|
||||
editForm: {
|
||||
action_name: '',
|
||||
description: '',
|
||||
enabled: false,
|
||||
trigger_type: 'at_times',
|
||||
time_list_text: '',
|
||||
seconds: 60,
|
||||
weekday: 0,
|
||||
time_str: '09:00',
|
||||
target_scope: 'all_enabled_groups',
|
||||
group_ids_text: '',
|
||||
single_group_id: '',
|
||||
payload_text: '{}'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadSchedules()
|
||||
},
|
||||
methods: {
|
||||
statusTag(status) {
|
||||
if (status === 'success') return 'success'
|
||||
if (status === 'failed') return 'danger'
|
||||
if (status === 'running') return 'warning'
|
||||
return 'info'
|
||||
},
|
||||
async loadSchedules() {
|
||||
this.loading = true
|
||||
try {
|
||||
const resp = await axios.get('/plugin_schedules/api/schedules')
|
||||
if (resp.data.success) this.schedules = resp.data.data || []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
openEdit(row) {
|
||||
this.currentId = row.id
|
||||
const trigger = row.trigger_config || {}
|
||||
const target = row.target_config || {}
|
||||
this.editForm.action_name = row.action_name || ''
|
||||
this.editForm.description = row.description || ''
|
||||
this.editForm.enabled = !!row.enabled
|
||||
this.editForm.trigger_type = row.trigger_type || 'at_times'
|
||||
this.editForm.time_list_text = (trigger.time_list || []).join(',')
|
||||
this.editForm.seconds = Number(trigger.seconds || 60)
|
||||
this.editForm.weekday = Number(trigger.weekday || 0)
|
||||
this.editForm.time_str = trigger.time_str || '09:00'
|
||||
this.editForm.target_scope = row.target_scope || 'all_enabled_groups'
|
||||
this.editForm.group_ids_text = (target.group_ids || []).join(',')
|
||||
this.editForm.single_group_id = target.group_id || ''
|
||||
this.editForm.payload_text = JSON.stringify(row.payload || {}, null, 2)
|
||||
this.editDialogVisible = true
|
||||
},
|
||||
buildTriggerConfig() {
|
||||
if (this.editForm.trigger_type === 'at_times') {
|
||||
return {
|
||||
time_list: String(this.editForm.time_list_text || '').split(',').map(x => x.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
if (this.editForm.trigger_type === 'every_seconds') {
|
||||
return { seconds: Number(this.editForm.seconds || 60) }
|
||||
}
|
||||
if (this.editForm.trigger_type === 'every_weekday_time') {
|
||||
return { weekday: Number(this.editForm.weekday || 0), time_str: String(this.editForm.time_str || '09:00') }
|
||||
}
|
||||
return {}
|
||||
},
|
||||
buildTargetConfig() {
|
||||
if (this.editForm.target_scope === 'single_group') {
|
||||
return { group_id: String(this.editForm.single_group_id || '').trim() }
|
||||
}
|
||||
if (this.editForm.target_scope === 'group_whitelist') {
|
||||
return {
|
||||
group_ids: String(this.editForm.group_ids_text || '').split(',').map(x => x.trim()).filter(Boolean)
|
||||
}
|
||||
}
|
||||
return {}
|
||||
},
|
||||
async saveEdit() {
|
||||
let payloadObj = {}
|
||||
try {
|
||||
payloadObj = JSON.parse(this.editForm.payload_text || '{}')
|
||||
} catch (e) {
|
||||
this.$message.error('Payload 不是合法 JSON')
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
action_name: this.editForm.action_name,
|
||||
description: this.editForm.description,
|
||||
enabled: this.editForm.enabled,
|
||||
trigger_type: this.editForm.trigger_type,
|
||||
trigger_config: this.buildTriggerConfig(),
|
||||
target_scope: this.editForm.target_scope,
|
||||
target_config: this.buildTargetConfig(),
|
||||
payload: payloadObj
|
||||
}
|
||||
const resp = await axios.put(`/plugin_schedules/api/schedules/${this.currentId}`, payload)
|
||||
if (resp.data.success) {
|
||||
this.$message.success('保存成功')
|
||||
this.editDialogVisible = false
|
||||
await this.loadSchedules()
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '保存失败')
|
||||
}
|
||||
},
|
||||
async triggerNow(row) {
|
||||
const resp = await axios.post(`/plugin_schedules/api/schedules/${row.id}/trigger`)
|
||||
if (resp.data.success) {
|
||||
this.$message.success(resp.data.message || '触发成功')
|
||||
} else {
|
||||
this.$message.warning(resp.data.message || '触发失败')
|
||||
}
|
||||
await this.loadSchedules()
|
||||
},
|
||||
async viewLogs(row) {
|
||||
const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`)
|
||||
if (resp.data.success) {
|
||||
this.logs = resp.data.data || []
|
||||
this.logsDialogVisible = true
|
||||
}
|
||||
},
|
||||
async reloadSchedules() {
|
||||
const resp = await axios.post('/plugin_schedules/api/reload')
|
||||
if (resp.data.success) {
|
||||
this.$message.success(resp.data.message || '重载成功')
|
||||
await this.loadSchedules()
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '重载失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.page-shell{display:flex;flex-direction:column;gap:16px}
|
||||
.page-hero{display:flex;align-items:flex-end;justify-content:space-between;gap:18px;padding:24px 26px;border-radius:24px;background:linear-gradient(135deg, rgba(79,70,229,.10), rgba(59,130,246,.08), rgba(255,255,255,.9));border:1px solid rgba(148,163,184,.16);box-shadow:0 18px 40px rgba(15,23,42,.06)}
|
||||
.page-hero-actions{display:flex;align-items:center;gap:12px}
|
||||
.page-eyebrow{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#6366f1;font-weight:700;margin-bottom:8px}
|
||||
.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
|
||||
.page-hero-copy p{color:#64748b;font-size:14px}
|
||||
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -72,3 +72,34 @@ class MessagePluginInterface(PluginInterface):
|
||||
(是否已处理, 处理结果)
|
||||
"""
|
||||
raise NotImplementedError("子类必须实现此方法")
|
||||
|
||||
# ---------------- 插件定时调度能力(可选实现) ----------------
|
||||
def get_schedule_actions(self) -> List[Dict[str, Any]]:
|
||||
"""返回插件支持的可调度动作定义列表。
|
||||
|
||||
每项示例:
|
||||
{
|
||||
"action_key": "daily_push",
|
||||
"name": "每日推送",
|
||||
"description": "给目标群发送每日内容",
|
||||
"trigger_type": "at_times",
|
||||
"trigger_config": {"time_list": ["09:00"]},
|
||||
"target_scope": "all_enabled_groups",
|
||||
"target_config": {},
|
||||
"payload": {},
|
||||
"default_enabled": False
|
||||
}
|
||||
"""
|
||||
return []
|
||||
|
||||
async def run_scheduled_action(self, action_key: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""执行调度动作(插件可覆盖)。
|
||||
|
||||
Returns:
|
||||
dict: {"success": bool, "summary": str, "detail": dict}
|
||||
"""
|
||||
return {
|
||||
"success": False,
|
||||
"summary": f"插件未实现调度动作: {action_key}",
|
||||
"detail": {"action_key": action_key},
|
||||
}
|
||||
|
||||
183
db/plugin_schedule_db.py
Normal file
183
db/plugin_schedule_db.py
Normal file
@@ -0,0 +1,183 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from db.base import BaseDBOperator
|
||||
from db.connection import DBConnectionManager
|
||||
|
||||
|
||||
class PluginScheduleDBOperator(BaseDBOperator):
|
||||
"""插件定时任务配置与日志表操作。"""
|
||||
|
||||
def __init__(self, db_manager: DBConnectionManager):
|
||||
super().__init__(db_manager)
|
||||
|
||||
def init_tables(self) -> bool:
|
||||
try:
|
||||
self.execute_update(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS t_plugin_schedules (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
plugin_name VARCHAR(128) NOT NULL,
|
||||
action_key VARCHAR(64) NOT NULL,
|
||||
action_name VARCHAR(128) NOT NULL,
|
||||
description VARCHAR(255) DEFAULT '',
|
||||
trigger_type VARCHAR(64) NOT NULL,
|
||||
trigger_config JSON NOT NULL,
|
||||
target_scope VARCHAR(64) NOT NULL DEFAULT 'all_enabled_groups',
|
||||
target_config JSON DEFAULT NULL,
|
||||
payload JSON DEFAULT NULL,
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_plugin_action (plugin_name, action_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
self.execute_update(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS t_plugin_schedule_logs (
|
||||
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||
schedule_id BIGINT NOT NULL,
|
||||
triggered_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
status VARCHAR(32) NOT NULL,
|
||||
summary VARCHAR(255) DEFAULT '',
|
||||
detail_json JSON DEFAULT NULL,
|
||||
INDEX idx_schedule_time (schedule_id, triggered_at)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"初始化插件调度表失败: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _parse_json_field(row: Dict[str, Any], key: str):
|
||||
value = row.get(key)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
row[key] = json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
row[key] = {}
|
||||
elif value is None:
|
||||
row[key] = {}
|
||||
|
||||
def list_schedules(self) -> List[Dict[str, Any]]:
|
||||
rows = self.execute_query("SELECT * FROM t_plugin_schedules ORDER BY plugin_name, action_name") or []
|
||||
for row in rows:
|
||||
self._parse_json_field(row, "trigger_config")
|
||||
self._parse_json_field(row, "target_config")
|
||||
self._parse_json_field(row, "payload")
|
||||
return rows
|
||||
|
||||
def list_enabled_schedules(self) -> List[Dict[str, Any]]:
|
||||
rows = self.execute_query(
|
||||
"SELECT * FROM t_plugin_schedules WHERE enabled = 1 ORDER BY plugin_name, action_name"
|
||||
) or []
|
||||
for row in rows:
|
||||
self._parse_json_field(row, "trigger_config")
|
||||
self._parse_json_field(row, "target_config")
|
||||
self._parse_json_field(row, "payload")
|
||||
return rows
|
||||
|
||||
def get_schedule(self, schedule_id: int) -> Optional[Dict[str, Any]]:
|
||||
row = self.execute_query(
|
||||
"SELECT * FROM t_plugin_schedules WHERE id = %s",
|
||||
(schedule_id,),
|
||||
fetch_one=True,
|
||||
)
|
||||
if not row:
|
||||
return None
|
||||
self._parse_json_field(row, "trigger_config")
|
||||
self._parse_json_field(row, "target_config")
|
||||
self._parse_json_field(row, "payload")
|
||||
return row
|
||||
|
||||
def upsert_default_schedule(self, data: Dict[str, Any]) -> bool:
|
||||
try:
|
||||
sql = """
|
||||
INSERT INTO t_plugin_schedules (
|
||||
plugin_name, action_key, action_name, description,
|
||||
trigger_type, trigger_config, target_scope, target_config, payload, enabled
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
action_name = VALUES(action_name),
|
||||
description = VALUES(description)
|
||||
"""
|
||||
params = (
|
||||
data["plugin_name"],
|
||||
data["action_key"],
|
||||
data["action_name"],
|
||||
data.get("description", ""),
|
||||
data["trigger_type"],
|
||||
json.dumps(data.get("trigger_config", {}), ensure_ascii=False),
|
||||
data.get("target_scope", "all_enabled_groups"),
|
||||
json.dumps(data.get("target_config", {}), ensure_ascii=False),
|
||||
json.dumps(data.get("payload", {}), ensure_ascii=False),
|
||||
1 if data.get("enabled", False) else 0,
|
||||
)
|
||||
return self.execute_update(sql, params)
|
||||
except Exception as e:
|
||||
logger.error(f"upsert 插件默认调度失败: {e}, data={data}")
|
||||
return False
|
||||
|
||||
def update_schedule(self, schedule_id: int, updates: Dict[str, Any]) -> bool:
|
||||
fields = []
|
||||
values = []
|
||||
|
||||
for key in (
|
||||
"action_name",
|
||||
"description",
|
||||
"trigger_type",
|
||||
"target_scope",
|
||||
"enabled",
|
||||
):
|
||||
if key in updates:
|
||||
fields.append(f"{key} = %s")
|
||||
if key == "enabled":
|
||||
values.append(1 if updates[key] else 0)
|
||||
else:
|
||||
values.append(updates[key])
|
||||
|
||||
for key in ("trigger_config", "target_config", "payload"):
|
||||
if key in updates:
|
||||
fields.append(f"{key} = %s")
|
||||
values.append(json.dumps(updates.get(key, {}), ensure_ascii=False))
|
||||
|
||||
if not fields:
|
||||
return True
|
||||
|
||||
values.append(schedule_id)
|
||||
sql = f"UPDATE t_plugin_schedules SET {', '.join(fields)} WHERE id = %s"
|
||||
return self.execute_update(sql, tuple(values))
|
||||
|
||||
def create_log(self, schedule_id: int, status: str, summary: str, detail: Dict[str, Any]) -> bool:
|
||||
sql = """
|
||||
INSERT INTO t_plugin_schedule_logs (schedule_id, status, summary, detail_json)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
"""
|
||||
params = (
|
||||
schedule_id,
|
||||
status,
|
||||
summary,
|
||||
json.dumps(detail or {}, ensure_ascii=False),
|
||||
)
|
||||
return self.execute_update(sql, params)
|
||||
|
||||
def get_logs(self, schedule_id: int, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
rows = self.execute_query(
|
||||
"""
|
||||
SELECT * FROM t_plugin_schedule_logs
|
||||
WHERE schedule_id = %s
|
||||
ORDER BY triggered_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(schedule_id, int(limit)),
|
||||
) or []
|
||||
for row in rows:
|
||||
self._parse_json_field(row, "detail_json")
|
||||
return rows
|
||||
@@ -221,6 +221,76 @@ class XiurenImagePlugin(MessagePluginInterface):
|
||||
self.LOG.error(f"从 Redis 获取并删除随机图片失败: {e}")
|
||||
return None
|
||||
|
||||
def get_schedule_actions(self) -> List[Dict[str, Any]]:
|
||||
"""插件可调度动作定义。"""
|
||||
return [
|
||||
{
|
||||
"action_key": "daily_push",
|
||||
"name": "秀人群发推送",
|
||||
"description": "按调度时间向目标群发送秀人图片",
|
||||
"trigger_type": "at_times",
|
||||
"trigger_config": {"time_list": ["17:30"]},
|
||||
"target_scope": "all_enabled_groups",
|
||||
"target_config": {},
|
||||
"payload": {"max_per_group": 1},
|
||||
"default_enabled": False,
|
||||
}
|
||||
]
|
||||
|
||||
async def run_scheduled_action(self, action_key: str, context: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""执行插件定时动作。"""
|
||||
if action_key != "daily_push":
|
||||
return {
|
||||
"success": False,
|
||||
"summary": f"不支持的动作: {action_key}",
|
||||
"detail": {"action_key": action_key},
|
||||
}
|
||||
|
||||
if not self.bot:
|
||||
return {
|
||||
"success": False,
|
||||
"summary": "bot 未注入,无法执行群发",
|
||||
"detail": {},
|
||||
}
|
||||
|
||||
target_groups = context.get("target_groups") or []
|
||||
if not target_groups:
|
||||
return {
|
||||
"success": False,
|
||||
"summary": "没有可发送的目标群",
|
||||
"detail": {"target_groups": []},
|
||||
}
|
||||
|
||||
payload = context.get("payload") or {}
|
||||
max_per_group = max(1, int(payload.get("max_per_group", 1)))
|
||||
success_groups = []
|
||||
failed_groups = {}
|
||||
|
||||
for group_id in target_groups:
|
||||
try:
|
||||
for _ in range(max_per_group):
|
||||
cached_image = self._get_cached_image()
|
||||
if not cached_image:
|
||||
raise RuntimeError("未找到图片资源")
|
||||
await self.bot.send_image_message(group_id, cached_image["bytes"])
|
||||
success_groups.append(group_id)
|
||||
except Exception as e:
|
||||
failed_groups[group_id] = str(e)
|
||||
|
||||
success_count = len(success_groups)
|
||||
fail_count = len(failed_groups)
|
||||
summary = f"秀人群发完成: 成功 {success_count} 群, 失败 {fail_count} 群"
|
||||
return {
|
||||
"success": fail_count == 0,
|
||||
"summary": summary,
|
||||
"detail": {
|
||||
"target_count": len(target_groups),
|
||||
"success_groups": success_groups,
|
||||
"failed_groups": failed_groups,
|
||||
"max_per_group": max_per_group,
|
||||
},
|
||||
}
|
||||
|
||||
# def _get_random_pic(self) -> Optional[str]:
|
||||
# """获取随机图片路径"""
|
||||
# try:
|
||||
|
||||
5
robot.py
5
robot.py
@@ -21,11 +21,13 @@ from base.plugin_common.plugin_registry import PluginRegistry
|
||||
from configuration import Config
|
||||
from db.connection import DBConnectionManager
|
||||
from db.contacts_db import ContactsDBOperator
|
||||
from db.plugin_schedule_db import PluginScheduleDBOperator
|
||||
from db.system_job_db import SystemJobDBOperator
|
||||
from plugins.xiuren_image.meitu_dl import meitu_dowload_pub_pic
|
||||
from plugins.xiuren_image.shenshi_r15 import run_daily_job
|
||||
from utils.system_jobs import SystemJobLoader
|
||||
from utils.email_util import EmailSender
|
||||
from utils.plugin_schedule_manager import PluginScheduleManager
|
||||
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, Feature, PermissionStatus
|
||||
from utils.sehuatang.shehuatang import pdf_file_path
|
||||
@@ -75,6 +77,7 @@ class Robot:
|
||||
self.redis_pool = self.db_manager.redis_pool
|
||||
|
||||
self.contacts_db = ContactsDBOperator(self.db_manager)
|
||||
self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager)
|
||||
self.system_job_db = SystemJobDBOperator(self.db_manager)
|
||||
# 初始化联系人管理器
|
||||
self.contact_manager = ContactManager.get_instance()
|
||||
@@ -103,6 +106,8 @@ class Robot:
|
||||
self.plugins = self.plugin_manager.load_all_plugins()
|
||||
# 热加载改为低频扫描:每 60 秒检查一次插件文件变动
|
||||
self.plugin_manager.start_hot_reload_watcher(interval_seconds=60.0)
|
||||
self.plugin_schedule_manager = PluginScheduleManager(self.plugin_manager, self.plugin_schedule_db)
|
||||
self.plugin_schedule_manager.init_and_load()
|
||||
self.system_job_loader = SystemJobLoader(self, self.system_job_db)
|
||||
self.system_job_loader.init_and_load()
|
||||
|
||||
|
||||
195
utils/plugin_schedule_manager.py
Normal file
195
utils/plugin_schedule_manager.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from db.plugin_schedule_db import PluginScheduleDBOperator
|
||||
from utils.decorator.async_job import async_job
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||||
|
||||
|
||||
class PluginScheduleManager:
|
||||
"""插件定时任务管理器(数据库驱动)。"""
|
||||
|
||||
def __init__(self, plugin_manager, plugin_schedule_db: PluginScheduleDBOperator):
|
||||
self.plugin_manager = plugin_manager
|
||||
self.db = plugin_schedule_db
|
||||
self._schedule_job_map: Dict[int, str] = {}
|
||||
|
||||
def init_and_load(self):
|
||||
self.db.init_tables()
|
||||
self.reload_from_db()
|
||||
|
||||
def _get_plugin_actions(self) -> List[Dict[str, Any]]:
|
||||
actions = []
|
||||
for plugin in self.plugin_manager.plugins.values():
|
||||
if not hasattr(plugin, "get_schedule_actions"):
|
||||
continue
|
||||
try:
|
||||
plugin_actions = plugin.get_schedule_actions() or []
|
||||
except Exception as e:
|
||||
logger.error(f"读取插件 {plugin.name} 调度动作失败: {e}")
|
||||
continue
|
||||
|
||||
for action in plugin_actions:
|
||||
actions.append(
|
||||
{
|
||||
"plugin_name": plugin.name,
|
||||
"action_key": action.get("action_key"),
|
||||
"action_name": action.get("name", action.get("action_key", "")),
|
||||
"description": action.get("description", ""),
|
||||
"trigger_type": action.get("trigger_type", "at_times"),
|
||||
"trigger_config": action.get("trigger_config", {"time_list": ["09:00"]}),
|
||||
"target_scope": action.get("target_scope", "all_enabled_groups"),
|
||||
"target_config": action.get("target_config", {}),
|
||||
"payload": action.get("payload", {}),
|
||||
"enabled": bool(action.get("default_enabled", False)),
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
def sync_defaults(self):
|
||||
for item in self._get_plugin_actions():
|
||||
if not item.get("plugin_name") or not item.get("action_key"):
|
||||
continue
|
||||
self.db.upsert_default_schedule(item)
|
||||
|
||||
def _resolve_targets(self, plugin, schedule_row: Dict[str, Any]) -> List[str]:
|
||||
scope = str(schedule_row.get("target_scope") or "all_enabled_groups")
|
||||
target_cfg = schedule_row.get("target_config") or {}
|
||||
|
||||
if scope == "single_group":
|
||||
gid = str(target_cfg.get("group_id") or "").strip()
|
||||
return [gid] if gid else []
|
||||
|
||||
if scope == "group_whitelist":
|
||||
group_ids = target_cfg.get("group_ids") or []
|
||||
return [str(x).strip() for x in group_ids if str(x).strip()]
|
||||
|
||||
# 默认:所有已启用群
|
||||
all_groups = GroupBotManager.get_group_list()
|
||||
if not getattr(plugin, "feature", None):
|
||||
return all_groups
|
||||
|
||||
enabled_groups = []
|
||||
for gid in all_groups:
|
||||
if GroupBotManager.get_group_permission(gid, plugin.feature) == PermissionStatus.ENABLED:
|
||||
enabled_groups.append(gid)
|
||||
return enabled_groups
|
||||
|
||||
async def _run_one_schedule(self, schedule_row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
schedule_id = int(schedule_row["id"])
|
||||
action_key = schedule_row.get("action_key")
|
||||
plugin_name = schedule_row.get("plugin_name")
|
||||
|
||||
_, plugin = self.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
if not plugin:
|
||||
detail = {"error": f"未找到插件: {plugin_name}"}
|
||||
self.db.create_log(schedule_id, "failed", detail["error"], detail)
|
||||
return {"success": False, "summary": detail["error"], "detail": detail}
|
||||
|
||||
if not hasattr(plugin, "run_scheduled_action"):
|
||||
detail = {"error": f"插件 {plugin.name} 未实现 run_scheduled_action"}
|
||||
self.db.create_log(schedule_id, "failed", detail["error"], detail)
|
||||
return {"success": False, "summary": detail["error"], "detail": detail}
|
||||
|
||||
targets = self._resolve_targets(plugin, schedule_row)
|
||||
payload = schedule_row.get("payload") or {}
|
||||
|
||||
ctx = {
|
||||
"schedule_id": schedule_id,
|
||||
"triggered_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"target_scope": schedule_row.get("target_scope"),
|
||||
"target_config": schedule_row.get("target_config") or {},
|
||||
"target_groups": targets,
|
||||
"payload": payload,
|
||||
"bot": getattr(plugin, "bot", None),
|
||||
}
|
||||
|
||||
try:
|
||||
res = await plugin.run_scheduled_action(action_key, ctx)
|
||||
if not isinstance(res, dict):
|
||||
res = {"success": bool(res), "summary": "插件返回非 dict,已兼容处理", "detail": {"result": str(res)}}
|
||||
except Exception as e:
|
||||
res = {"success": False, "summary": f"执行异常: {e}", "detail": {"error": str(e)}}
|
||||
|
||||
status = "success" if res.get("success") else "failed"
|
||||
summary = str(res.get("summary") or ("执行成功" if status == "success" else "执行失败"))
|
||||
detail = res.get("detail") or {}
|
||||
detail["target_count"] = len(targets)
|
||||
self.db.create_log(schedule_id, status, summary, detail)
|
||||
return {"success": status == "success", "summary": summary, "detail": detail}
|
||||
|
||||
def reload_from_db(self):
|
||||
self.sync_defaults()
|
||||
|
||||
# 清理旧注册,避免重复
|
||||
for job_id in list(self._schedule_job_map.values()):
|
||||
async_job.remove_job(job_id)
|
||||
self._schedule_job_map = {}
|
||||
|
||||
rows = self.db.list_enabled_schedules()
|
||||
for row in rows:
|
||||
schedule_id = int(row["id"])
|
||||
|
||||
async def _runner(_row=row):
|
||||
await self._run_one_schedule(_row)
|
||||
|
||||
job_id = async_job.register_callable(
|
||||
func=_runner,
|
||||
trigger_type=row.get("trigger_type", "at_times"),
|
||||
trigger_config=row.get("trigger_config", {"time_list": ["09:00"]}),
|
||||
job_name=f"[插件调度]{row.get('plugin_name')}:{row.get('action_name')}",
|
||||
description=row.get("description", ""),
|
||||
job_key=f"plugin_schedule:{schedule_id}",
|
||||
)
|
||||
self._schedule_job_map[schedule_id] = job_id
|
||||
|
||||
def list_schedules_with_runtime(self) -> List[Dict[str, Any]]:
|
||||
db_rows = self.db.list_schedules()
|
||||
runtime_rows = async_job.get_jobs_snapshot()
|
||||
runtime_by_key = {row.get("job_key"): row for row in runtime_rows if row.get("job_key")}
|
||||
|
||||
data = []
|
||||
for row in db_rows:
|
||||
key = f"plugin_schedule:{row['id']}"
|
||||
runtime = runtime_by_key.get(key, {})
|
||||
merged = dict(row)
|
||||
merged["runtime_job_id"] = runtime.get("id")
|
||||
merged["running"] = runtime.get("running", False)
|
||||
merged["trigger_text"] = runtime.get("trigger_text", "")
|
||||
merged["next_run_at"] = runtime.get("next_run_at")
|
||||
merged["last_run_at"] = runtime.get("last_run_at")
|
||||
merged["last_status"] = runtime.get("last_status")
|
||||
merged["last_error"] = runtime.get("last_error")
|
||||
merged["last_duration_ms"] = runtime.get("last_duration_ms")
|
||||
merged["run_count"] = runtime.get("run_count", 0)
|
||||
merged["success_count"] = runtime.get("success_count", 0)
|
||||
merged["fail_count"] = runtime.get("fail_count", 0)
|
||||
data.append(merged)
|
||||
return data
|
||||
|
||||
def trigger_now(self, schedule_id: int) -> (bool, str):
|
||||
job_key = f"plugin_schedule:{int(schedule_id)}"
|
||||
job_id = async_job.get_job_id_by_key(job_key)
|
||||
if not job_id:
|
||||
self.reload_from_db()
|
||||
job_id = async_job.get_job_id_by_key(job_key)
|
||||
if not job_id:
|
||||
return False, "该调度未启用或未加载"
|
||||
return async_job.trigger_job_now(job_id, operator="dashboard")
|
||||
|
||||
def update_schedule(self, schedule_id: int, updates: Dict[str, Any]) -> bool:
|
||||
ok = self.db.update_schedule(int(schedule_id), updates)
|
||||
if ok:
|
||||
self.reload_from_db()
|
||||
return ok
|
||||
|
||||
def get_logs(self, schedule_id: int, limit: int = 100) -> List[Dict[str, Any]]:
|
||||
return self.db.get_logs(int(schedule_id), limit=limit)
|
||||
|
||||
def get_available_plugin_actions(self) -> List[Dict[str, Any]]:
|
||||
return self._get_plugin_actions()
|
||||
Reference in New Issue
Block a user