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 永久)。
+
+
+ 刷新
+ 新增配置
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% raw %}{{ scope.row.enabled ? '是' : '否' }}{% endraw %}
+
+
+
+
+
+
+
+ {% raw %}{{ JSON.stringify(scope.row.config_json || {}, null, 2) }}{% endraw %}
+
+
+
+
+ 编辑
+ 删除
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 取消
+ 保存
+
+
+
+{% 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