diff --git a/admin/dashboard/blueprints/group_plugin_config.py b/admin/dashboard/blueprints/group_plugin_config.py new file mode 100644 index 0000000..56a0773 --- /dev/null +++ b/admin/dashboard/blueprints/group_plugin_config.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +import json +from datetime import datetime +from flask import Blueprint, current_app, jsonify, render_template, request + +from .auth import login_required + +group_plugin_config_bp = Blueprint("group_plugin_config", __name__, url_prefix="/group_plugin_config") + + +def _normalize_datetime_text(value): + if value is None: + return value + if isinstance(value, datetime): + return value.strftime("%Y-%m-%d %H:%M:%S") + text = str(value) + if "T" in text: + return text.replace("T", " ")[:19] + return text + + +@group_plugin_config_bp.route("/") +@login_required +def page_group_plugin_config(): + return render_template("group_plugin_config.html") + + +@group_plugin_config_bp.route("/api/list", methods=["GET"]) +@login_required +def api_list_group_plugin_config(): + server = current_app.dashboard_server + service = server.group_plugin_config_service + group_id = str(request.args.get("group_id", "") or "").strip() + plugin_name = str(request.args.get("plugin_name", "") or "").strip() + rows = service.list_configs(group_id=group_id, plugin_name=plugin_name) + for row in rows: + row["created_at"] = _normalize_datetime_text(row.get("created_at")) + row["updated_at"] = _normalize_datetime_text(row.get("updated_at")) + return jsonify({"success": True, "data": rows}) + + +@group_plugin_config_bp.route("/api/plugins", methods=["GET"]) +@login_required +def api_list_plugins(): + server = current_app.dashboard_server + plugin_names = sorted([str(p.name) for p in server.plugin_manager.plugins.values()]) + return jsonify({"success": True, "data": plugin_names}) + + +@group_plugin_config_bp.route("/api/upsert", methods=["POST"]) +@login_required +def api_upsert_group_plugin_config(): + server = current_app.dashboard_server + service = server.group_plugin_config_service + payload = request.get_json(silent=True) or {} + + group_id = str(payload.get("group_id") or "").strip() + plugin_name = str(payload.get("plugin_name") or "").strip() + config_key = str(payload.get("config_key") or "default").strip() or "default" + enabled = bool(payload.get("enabled", True)) + config_data = payload.get("config_json") + updated_by = str(payload.get("updated_by") or "dashboard").strip() or "dashboard" + + if not group_id or not plugin_name: + return jsonify({"success": False, "message": "group_id 或 plugin_name 不能为空"}), 400 + + if isinstance(config_data, str): + try: + config_data = json.loads(config_data) + except Exception: + return jsonify({"success": False, "message": "config_json 不是合法 JSON"}), 400 + if not isinstance(config_data, dict): + return jsonify({"success": False, "message": "config_json 必须是对象"}), 400 + + ok = service.upsert_config( + group_id=group_id, + plugin_name=plugin_name, + config_key=config_key, + config_data=config_data, + enabled=enabled, + updated_by=updated_by, + ) + if not ok: + return jsonify({"success": False, "message": "保存失败"}), 500 + return jsonify({"success": True, "message": "保存成功(MySQL + Redis已刷新)"}) + + +@group_plugin_config_bp.route("/api/delete", methods=["POST"]) +@login_required +def api_delete_group_plugin_config(): + server = current_app.dashboard_server + service = server.group_plugin_config_service + payload = request.get_json(silent=True) or {} + group_id = str(payload.get("group_id") or "").strip() + plugin_name = str(payload.get("plugin_name") or "").strip() + config_key = str(payload.get("config_key") or "default").strip() or "default" + if not group_id or not plugin_name: + return jsonify({"success": False, "message": "group_id 或 plugin_name 不能为空"}), 400 + + ok = service.delete_config(group_id=group_id, plugin_name=plugin_name, config_key=config_key) + if not ok: + return jsonify({"success": False, "message": "删除失败"}), 500 + return jsonify({"success": True, "message": "删除成功(MySQL + Redis已同步)"}) diff --git a/admin/dashboard/server.py b/admin/dashboard/server.py index 2c61455..493da51 100644 --- a/admin/dashboard/server.py +++ b/admin/dashboard/server.py @@ -50,6 +50,8 @@ class DashboardServer: 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.group_plugin_config_db = robot_instance.group_plugin_config_db + self.group_plugin_config_service = robot_instance.group_plugin_config_service # 获取联系人管理器实例 self.contact_manager = robot_instance.contact_manager self.plugin_manager = robot_instance.plugin_manager @@ -154,6 +156,7 @@ class DashboardServer: 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 + from admin.dashboard.blueprints.group_plugin_config import group_plugin_config_bp # 在app.register_blueprint部分添加 app.register_blueprint(virtual_group_bp, url_prefix='/virtual_group') @@ -170,6 +173,7 @@ class DashboardServer: app.register_blueprint(friend_circle_bp) app.register_blueprint(system_jobs_bp) app.register_blueprint(plugin_schedules_bp) + app.register_blueprint(group_plugin_config_bp) self.LOG.info("所有蓝图已注册") diff --git a/admin/dashboard/templates/base.html b/admin/dashboard/templates/base.html index 16c7f09..1b578d9 100644 --- a/admin/dashboard/templates/base.html +++ b/admin/dashboard/templates/base.html @@ -842,6 +842,7 @@ { label: '插件统计', path: '/plugins' }, { label: '插件管理', path: '/plugins_manage' }, { label: '插件定时任务', path: '/plugin_schedules' }, + { label: '群级插件配置', path: '/group_plugin_config' }, { label: '接口文档', path: '/api_docs' } ] }, diff --git a/admin/dashboard/templates/group_plugin_config.html b/admin/dashboard/templates/group_plugin_config.html new file mode 100644 index 0000000..6a23464 --- /dev/null +++ b/admin/dashboard/templates/group_plugin_config.html @@ -0,0 +1,223 @@ +{% extends "base.html" %} + +{% block title %}群级插件配置 - 机器人管理后台{% endblock %} + +{% block content %} +
+
+
+
Group Plugin Config
+

群级插件配置

+

后台维护按群差异化配置(文案、URL、发送内容)。保存时写入 MySQL 并刷新 Redis 缓存(TTL 永久)。

+
+
+ 刷新 + 新增配置 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ 取消 + 保存 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} + +{% block styles %} + +{% endblock %} diff --git a/db/group_plugin_config_db.py b/db/group_plugin_config_db.py new file mode 100644 index 0000000..f861503 --- /dev/null +++ b/db/group_plugin_config_db.py @@ -0,0 +1,132 @@ +# -*- 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 GroupPluginConfigDBOperator(BaseDBOperator): + """群级插件配置数据库操作器。""" + + def __init__(self, db_manager: DBConnectionManager): + super().__init__(db_manager) + + def init_tables(self) -> bool: + """初始化群级插件配置表。""" + try: + return self.execute_update( + """ + CREATE TABLE IF NOT EXISTS t_group_plugin_config ( + id BIGINT PRIMARY KEY AUTO_INCREMENT, + group_id VARCHAR(100) NOT NULL, + plugin_name VARCHAR(128) NOT NULL, + config_key VARCHAR(128) NOT NULL DEFAULT 'default', + config_json JSON NOT NULL, + enabled TINYINT(1) NOT NULL DEFAULT 1, + version INT NOT NULL DEFAULT 1, + updated_by VARCHAR(100) NOT NULL DEFAULT 'system', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_group_plugin_key (group_id, plugin_name, config_key), + INDEX idx_group_plugin (group_id, plugin_name), + INDEX idx_plugin_name (plugin_name) + ) + """ + ) + except Exception as e: + logger.error(f"初始化群级插件配置表失败: {e}") + return False + + @staticmethod + def _parse_json_field(row: Dict[str, Any], key: str) -> None: + value = row.get(key) + if isinstance(value, str): + try: + row[key] = json.loads(value) + except Exception: + row[key] = {} + elif value is None: + row[key] = {} + + def get_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> Optional[Dict[str, Any]]: + """查询单条群级插件配置。""" + row = self.execute_query( + """ + SELECT * FROM t_group_plugin_config + WHERE group_id = %s AND plugin_name = %s AND config_key = %s + LIMIT 1 + """, + (group_id, plugin_name, config_key), + fetch_one=True, + ) + if not row: + return None + self._parse_json_field(row, "config_json") + return row + + def list_configs(self, group_id: str = "", plugin_name: str = "") -> List[Dict[str, Any]]: + """按条件列出配置。""" + where_sql = [] + params = [] + if group_id: + where_sql.append("group_id = %s") + params.append(group_id) + if plugin_name: + where_sql.append("plugin_name = %s") + params.append(plugin_name) + where_clause = f"WHERE {' AND '.join(where_sql)}" if where_sql else "" + rows = self.execute_query( + f""" + SELECT * FROM t_group_plugin_config + {where_clause} + ORDER BY updated_at DESC, id DESC + """, + tuple(params) if params else None, + ) or [] + for row in rows: + self._parse_json_field(row, "config_json") + return rows + + def upsert_config( + self, + group_id: str, + plugin_name: str, + config_key: str, + config_data: Dict[str, Any], + enabled: bool = True, + updated_by: str = "system", + ) -> bool: + """新增或更新配置。""" + return self.execute_update( + """ + INSERT INTO t_group_plugin_config ( + group_id, plugin_name, config_key, config_json, enabled, version, updated_by + ) VALUES (%s, %s, %s, %s, %s, 1, %s) + ON DUPLICATE KEY UPDATE + config_json = VALUES(config_json), + enabled = VALUES(enabled), + updated_by = VALUES(updated_by), + version = version + 1 + """, + ( + group_id, + plugin_name, + config_key, + json.dumps(config_data or {}, ensure_ascii=False), + 1 if enabled else 0, + updated_by, + ), + ) + + def delete_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> bool: + """删除指定配置。""" + return self.execute_update( + """ + DELETE FROM t_group_plugin_config + WHERE group_id = %s AND plugin_name = %s AND config_key = %s + """, + (group_id, plugin_name, config_key), + ) diff --git a/plugins/group_member_change/main.py b/plugins/group_member_change/main.py index e5a18d9..9d720ce 100644 --- a/plugins/group_member_change/main.py +++ b/plugins/group_member_change/main.py @@ -6,10 +6,11 @@ from base.plugin_common.message_plugin_interface import MessagePluginInterface from base.plugin_common.plugin_interface import PluginStatus from db.connection import DBConnectionManager from db.contacts_db import ContactsDBOperator +from utils.group_plugin_config_service import GroupPluginConfigService from utils.robot_cmd.robot_command import PermissionStatus, GroupBotManager from utils.wechat.contact_manager import ContactManager from wechat_ipad import WechatAPIClient -from wechat_ipad.models.appmsg_xml import LINK_XML_WELCOME +from wechat_ipad.models.appmsg_xml import LINK_XML_NORMAL, LINK_XML_WELCOME class GroupMemberChangePlugin(MessagePluginInterface): @@ -62,6 +63,10 @@ class GroupMemberChangePlugin(MessagePluginInterface): def initialize(self, context: Dict[str, Any]) -> bool: """初始化插件""" self.LOG.debug(f"正在初始化 {self.name} 插件...") + # 注入群级插件配置服务(由机器人系统上下文提供): + # 1. 优先通过该服务读取“按群差异化”的欢迎文案与卡片配置; + # 2. 未配置时保持原有默认欢迎行为,确保兼容老群。 + self.group_plugin_config_service: Optional[GroupPluginConfigService] = context.get("group_plugin_config_service") self.LOG.debug(f"{self.name} 插件初始化完成") return True @@ -157,9 +162,23 @@ class GroupMemberChangePlugin(MessagePluginInterface): now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") member_wxids = [wxid] - await bot.send_at_message(roomid, f"👏欢迎 {nickname} 加入群聊!🎉", member_wxids) members = await bot.get_chatroom_member_detail(wxid, roomid) head_url = members.get("SmallHeadImgUrl") or members.get("BigHeadImgUrl") or "" + welcome_cfg = self._get_group_welcome_config(roomid) + variables = { + "nickname": nickname, + "wxid": wxid, + "group_id": roomid, + "now": now, + "head_url": head_url, + } + # 文本欢迎:支持后台按群关闭和模板自定义。 + if bool(welcome_cfg.get("welcome_text_enabled", True)): + welcome_text = self._safe_format( + welcome_cfg.get("welcome_text_template", "👏欢迎 {nickname} 加入群聊!🎉"), + variables + ) + await bot.send_at_message(roomid, welcome_text, member_wxids) try: # 更新联系人信息 ContactManager.get_instance().update_head_image(wxid, head_url) @@ -170,9 +189,14 @@ class GroupMemberChangePlugin(MessagePluginInterface): contact_db.save_chatroom_member_simple(roomid, member_details) except Exception as e: self.LOG.warning(f"新增群员信息失败: {e}") - xml_content = f"{LINK_XML_WELCOME}".format(nickname=nickname, now=now, head_url=head_url) - - await bot.send_link_xml_message(xml_content, roomid) + # 欢迎卡片:支持后台按群关闭,且可配置标题/描述/URL/缩略图。 + if bool(welcome_cfg.get("welcome_card_enabled", True)): + if self.group_plugin_config_service: + xml_content = self._build_custom_welcome_card_xml(welcome_cfg, variables) + else: + # 老流程兼容:当未接入配置服务时沿用原模板。 + xml_content = f"{LINK_XML_WELCOME}".format(nickname=nickname, now=now, head_url=head_url) + await bot.send_link_xml_message(xml_content, roomid) return True, "已发送进群欢迎语" return False, "无需执行" @@ -254,3 +278,52 @@ class GroupMemberChangePlugin(MessagePluginInterface): self.LOG.warning(f"解析新成员信息失败: {e}") return new_members + + @staticmethod + def _safe_format(template: str, variables: Dict[str, Any]) -> str: + """安全格式化模板,缺失变量时保留原占位符。""" + text = str(template or "") + for key, value in (variables or {}).items(): + text = text.replace(f"{{{key}}}", str(value or "")) + return text + + def _get_group_welcome_config(self, group_id: str) -> Dict[str, Any]: + """读取群级欢迎配置,未配置时返回默认值。""" + default_cfg = { + "welcome_text_enabled": True, + "welcome_text_template": "👏欢迎 {nickname} 加入群聊!🎉", + "welcome_card_enabled": True, + "card_title_template": "👏欢迎 {nickname} 加入群聊!🎉", + "card_desc_template": "⌚时间:{now}", + "card_url": "https://newsnow.busiyi.world/", + "card_thumb_url": "{head_url}", + } + if not self.group_plugin_config_service: + return default_cfg + try: + cfg = self.group_plugin_config_service.get_config( + group_id=group_id, + plugin_name=self.name, + config_key="welcome", + default=default_cfg, + ) + if not isinstance(cfg, dict): + return default_cfg + # 默认值兜底,避免后台只配置部分字段时缺项。 + return {**default_cfg, **cfg} + except Exception as e: + self.LOG.warning(f"读取群级欢迎配置失败,回退默认配置: group={group_id}, error={e}") + return default_cfg + + def _build_custom_welcome_card_xml(self, cfg: Dict[str, Any], variables: Dict[str, Any]) -> str: + """根据群级配置构建欢迎卡片 XML。""" + title = self._safe_format(cfg.get("card_title_template", ""), variables) + desc = self._safe_format(cfg.get("card_desc_template", ""), variables) + url = self._safe_format(cfg.get("card_url", ""), variables) + thumb_url = self._safe_format(cfg.get("card_thumb_url", ""), variables) + return LINK_XML_NORMAL.format( + title=title, + des=desc, + url=url, + thumburl=thumb_url, + ) diff --git a/robot.py b/robot.py index ac91466..a8b558c 100644 --- a/robot.py +++ b/robot.py @@ -17,10 +17,12 @@ 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.group_plugin_config_db import GroupPluginConfigDBOperator from db.plugin_schedule_db import PluginScheduleDBOperator from db.system_job_db import SystemJobDBOperator from utils.system_jobs import SystemJobLoader from utils.email_util import EmailSender +from utils.group_plugin_config_service import GroupPluginConfigService 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 @@ -68,8 +70,14 @@ class Robot: self.redis_pool = self.db_manager.redis_pool self.contacts_db = ContactsDBOperator(self.db_manager) + self.group_plugin_config_db = GroupPluginConfigDBOperator(self.db_manager) self.plugin_schedule_db = PluginScheduleDBOperator(self.db_manager) self.system_job_db = SystemJobDBOperator(self.db_manager) + self.group_plugin_config_db.init_tables() + self.group_plugin_config_service = GroupPluginConfigService( + db_operator=self.group_plugin_config_db, + redis_client=self.db_manager.get_redis_connection(), + ) # 初始化联系人管理器 self.contact_manager = ContactManager.get_instance() self.allContacts = {} # 将在登录后填充 @@ -89,7 +97,8 @@ class Robot: "plugin_registry": self.plugin_registry, "db_manager": self.db_manager, "db_pool": self.db_pool, - "redis_pool": self.redis_pool + "redis_pool": self.redis_pool, + "group_plugin_config_service": self.group_plugin_config_service, } self.plugin_manager = PluginManager(plugin_dir=getattr(self.config, "plugin_dir", "plugins")) diff --git a/utils/group_plugin_config_service.py b/utils/group_plugin_config_service.py new file mode 100644 index 0000000..43a5051 --- /dev/null +++ b/utils/group_plugin_config_service.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +import json +from typing import Any, Dict, List, Optional + +from loguru import logger + +from db.group_plugin_config_db import GroupPluginConfigDBOperator + + +class GroupPluginConfigService: + """群级插件配置服务(MySQL 持久化 + Redis 永久缓存)。""" + + REDIS_KEY_PREFIX = "group:plugin:cfg" + + def __init__(self, db_operator: GroupPluginConfigDBOperator, redis_client): + self.db = db_operator + self.redis = redis_client + + @classmethod + def _build_cache_key(cls, group_id: str, plugin_name: str, config_key: str = "default") -> str: + return f"{cls.REDIS_KEY_PREFIX}:{group_id}:{plugin_name}:{config_key}" + + def _write_cache(self, key: str, payload: Dict[str, Any]) -> None: + """写入 Redis 缓存(TTL=-1,永久有效)。""" + # 需求明确要求长期缓存,这里不设置过期时间,保持 TTL=-1。 + self.redis.set(key, json.dumps(payload, ensure_ascii=False)) + + def _delete_cache(self, key: str) -> None: + """删除 Redis 缓存。""" + self.redis.delete(key) + + def get_config( + self, + group_id: str, + plugin_name: str, + config_key: str = "default", + default: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """读取配置:先 Redis,未命中再查 MySQL 并回填 Redis。""" + cache_key = self._build_cache_key(group_id, plugin_name, config_key) + cached = self.redis.get(cache_key) + if cached: + try: + payload = json.loads(cached) + if isinstance(payload, dict): + return payload + except Exception as e: + logger.warning(f"群插件配置缓存解析失败,将回源数据库: key={cache_key}, error={e}") + + row = self.db.get_config(group_id, plugin_name, config_key) + if not row: + result = dict(default or {}) + # 缓存空结果可减少热点穿透;后续后台更新会主动刷新。 + self._write_cache(cache_key, result) + return result + + result = row.get("config_json") or {} + if not isinstance(result, dict): + result = {} + self._write_cache(cache_key, result) + return result + + def list_configs(self, group_id: str = "", plugin_name: str = "") -> List[Dict[str, Any]]: + """列出配置(后台展示用)。""" + return self.db.list_configs(group_id=group_id, plugin_name=plugin_name) + + def upsert_config( + self, + group_id: str, + plugin_name: str, + config_data: Dict[str, Any], + config_key: str = "default", + enabled: bool = True, + updated_by: str = "system", + ) -> bool: + """写配置:先落 MySQL,再刷新 Redis。""" + ok = self.db.upsert_config( + group_id=group_id, + plugin_name=plugin_name, + config_key=config_key, + config_data=config_data or {}, + enabled=enabled, + updated_by=updated_by, + ) + if not ok: + return False + + # 需求要求“修改后刷新 redis 内容”,这里直接回填最新值。 + cache_key = self._build_cache_key(group_id, plugin_name, config_key) + self._write_cache(cache_key, config_data or {}) + return True + + def delete_config(self, group_id: str, plugin_name: str, config_key: str = "default") -> bool: + """删除配置:删 MySQL 后同步清理 Redis。""" + ok = self.db.delete_config(group_id, plugin_name, config_key) + if ok: + cache_key = self._build_cache_key(group_id, plugin_name, config_key) + self._delete_cache(cache_key) + return ok