diff --git a/admin/dashboard/blueprints/plugin_schedules.py b/admin/dashboard/blueprints/plugin_schedules.py new file mode 100644 index 0000000..8e20734 --- /dev/null +++ b/admin/dashboard/blueprints/plugin_schedules.py @@ -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/", 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//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//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": "已按数据库配置重载插件调度"}) diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index ed16d41..2c61455 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -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("所有蓝图已注册") diff --git a/admin/dashboard/templates/base.html b/admin/dashboard/templates/base.html index 4363749..a60e2dd 100644 --- a/admin/dashboard/templates/base.html +++ b/admin/dashboard/templates/base.html @@ -814,6 +814,7 @@ items: [ { label: '插件统计', path: '/plugins' }, { label: '插件管理', path: '/plugins_manage' }, + { label: '插件定时任务', path: '/plugin_schedules' }, { label: '接口文档', path: '/api_docs' } ] }, diff --git a/admin/dashboard/templates/plugin_schedules.html b/admin/dashboard/templates/plugin_schedules.html new file mode 100644 index 0000000..65b5921 --- /dev/null +++ b/admin/dashboard/templates/plugin_schedules.html @@ -0,0 +1,280 @@ +{% extends "base.html" %} + +{% block title %}插件定时任务 - 机器人管理后台{% endblock %} + +{% block content %} +
+
+
+
Plugin Scheduler
+

插件定时任务

+

统一管理插件的定时动作配置(如秀人群发),支持按群范围执行、立即触发与执行日志追踪。

+
+
+ 按表重载 + 刷新 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 取消 + 保存 +
+
+ + + + + + + + + + + +
+{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/base/plugin_common/message_plugin_interface.py b/base/plugin_common/message_plugin_interface.py index cadc2b8..aa71579 100644 --- a/base/plugin_common/message_plugin_interface.py +++ b/base/plugin_common/message_plugin_interface.py @@ -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}, + } diff --git a/db/plugin_schedule_db.py b/db/plugin_schedule_db.py new file mode 100644 index 0000000..479eb73 --- /dev/null +++ b/db/plugin_schedule_db.py @@ -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 diff --git a/plugins/xiuren_image/main.py b/plugins/xiuren_image/main.py index b098ffe..1a83347 100644 --- a/plugins/xiuren_image/main.py +++ b/plugins/xiuren_image/main.py @@ -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: diff --git a/robot.py b/robot.py index b58d4b3..68fbba8 100644 --- a/robot.py +++ b/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() diff --git a/utils/plugin_schedule_manager.py b/utils/plugin_schedule_manager.py new file mode 100644 index 0000000..40e8846 --- /dev/null +++ b/utils/plugin_schedule_manager.py @@ -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()