feat(群级配置): 新增MySQL+Redis持久缓存并接入进群欢迎差异化配置
新增群级插件配置表与服务层,采用MySQL持久化+Redis长期缓存(TTL=-1);后台新增群级插件配置管理页面与API,支持按群按插件维护JSON配置并在修改后同步回填MySQL和刷新Redis;已将群成员变更监控插件接入该配置,支持欢迎文案与卡片URL等按群差异化。
This commit is contained in:
103
admin/dashboard/blueprints/group_plugin_config.py
Normal file
103
admin/dashboard/blueprints/group_plugin_config.py
Normal file
@@ -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已同步)"})
|
||||
@@ -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("所有蓝图已注册")
|
||||
|
||||
|
||||
@@ -842,6 +842,7 @@
|
||||
{ label: '插件统计', path: '/plugins' },
|
||||
{ label: '插件管理', path: '/plugins_manage' },
|
||||
{ label: '插件定时任务', path: '/plugin_schedules' },
|
||||
{ label: '群级插件配置', path: '/group_plugin_config' },
|
||||
{ label: '接口文档', path: '/api_docs' }
|
||||
]
|
||||
},
|
||||
|
||||
223
admin/dashboard/templates/group_plugin_config.html
Normal file
223
admin/dashboard/templates/group_plugin_config.html
Normal file
@@ -0,0 +1,223 @@
|
||||
{% 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">Group Plugin Config</div>
|
||||
<h1>群级插件配置</h1>
|
||||
<p>后台维护按群差异化配置(文案、URL、发送内容)。保存时写入 MySQL 并刷新 Redis 缓存(TTL 永久)。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button type="success" @click="loadRows">刷新</el-button>
|
||||
<el-button type="primary" @click="openCreate">新增配置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-card shadow="hover" style="margin-bottom: 14px;">
|
||||
<el-form inline>
|
||||
<el-form-item label="群">
|
||||
<el-select v-model="filters.group_id" filterable clearable placeholder="全部群" style="width:280px" @change="loadRows">
|
||||
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="插件">
|
||||
<el-select v-model="filters.plugin_name" filterable clearable placeholder="全部插件" style="width:220px" @change="loadRows">
|
||||
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="hover">
|
||||
<el-table :data="rows" style="width:100%" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="70"></el-table-column>
|
||||
<el-table-column prop="group_id" label="群ID" min-width="190"></el-table-column>
|
||||
<el-table-column prop="plugin_name" label="插件" min-width="140"></el-table-column>
|
||||
<el-table-column prop="config_key" label="配置键" width="120"></el-table-column>
|
||||
<el-table-column label="启用" width="90">
|
||||
<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="version" label="版本" width="90"></el-table-column>
|
||||
<el-table-column prop="updated_by" label="更新人" width="120"></el-table-column>
|
||||
<el-table-column prop="updated_at" label="更新时间" width="180"></el-table-column>
|
||||
<el-table-column label="配置预览" min-width="260">
|
||||
<template slot-scope="scope">
|
||||
<pre class="detail-pre">{% raw %}{{ JSON.stringify(scope.row.config_json || {}, null, 2) }}{% endraw %}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="danger" plain @click="removeRow(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog :title="editing ? '编辑配置' : '新增配置'" :visible.sync="dialogVisible" width="720px">
|
||||
<el-form label-width="95px">
|
||||
<el-form-item label="群ID">
|
||||
<el-select v-model="form.group_id" filterable placeholder="请选择群" style="width:100%">
|
||||
<el-option v-for="item in groupOptions" :key="item.wxid" :label="item.name" :value="item.wxid"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="插件">
|
||||
<el-select v-model="form.plugin_name" filterable placeholder="请选择插件" style="width:100%">
|
||||
<el-option v-for="name in pluginOptions" :key="name" :label="name" :value="name"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="配置键">
|
||||
<el-input v-model="form.config_key" placeholder="default"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.enabled"></el-switch>
|
||||
</el-form-item>
|
||||
<el-form-item label="JSON配置">
|
||||
<el-input type="textarea" :rows="12" v-model="form.config_json_text"></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div slot="footer">
|
||||
<el-button @click="dialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveForm">保存</el-button>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
rows: [],
|
||||
groupOptions: [],
|
||||
pluginOptions: [],
|
||||
filters: { group_id: '', plugin_name: '' },
|
||||
dialogVisible: false,
|
||||
editing: false,
|
||||
form: {
|
||||
group_id: '',
|
||||
plugin_name: '',
|
||||
config_key: 'default',
|
||||
enabled: true,
|
||||
config_json_text: '{}'
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.loadGroups()
|
||||
this.loadPlugins()
|
||||
this.loadRows()
|
||||
},
|
||||
methods: {
|
||||
async loadGroups() {
|
||||
const resp = await axios.get('/contacts/api/groups')
|
||||
const groups = (resp.data && resp.data.data && resp.data.data.groups) || {}
|
||||
this.groupOptions = Object.entries(groups).map(([wxid, name]) => ({ wxid, name: String(name || wxid) }))
|
||||
},
|
||||
async loadPlugins() {
|
||||
const resp = await axios.get('/group_plugin_config/api/plugins')
|
||||
if (resp.data && resp.data.success) {
|
||||
this.pluginOptions = resp.data.data || []
|
||||
}
|
||||
},
|
||||
async loadRows() {
|
||||
this.loading = true
|
||||
try {
|
||||
const resp = await axios.get('/group_plugin_config/api/list', { params: this.filters })
|
||||
if (resp.data && resp.data.success) {
|
||||
this.rows = resp.data.data || []
|
||||
}
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
openCreate() {
|
||||
this.editing = false
|
||||
this.form = {
|
||||
group_id: '',
|
||||
plugin_name: '',
|
||||
config_key: 'default',
|
||||
enabled: true,
|
||||
config_json_text: '{}'
|
||||
}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
openEdit(row) {
|
||||
this.editing = true
|
||||
this.form = {
|
||||
group_id: row.group_id || '',
|
||||
plugin_name: row.plugin_name || '',
|
||||
config_key: row.config_key || 'default',
|
||||
enabled: !!row.enabled,
|
||||
config_json_text: JSON.stringify(row.config_json || {}, null, 2)
|
||||
}
|
||||
this.dialogVisible = true
|
||||
},
|
||||
async saveForm() {
|
||||
let parsed = {}
|
||||
try {
|
||||
parsed = JSON.parse(this.form.config_json_text || '{}')
|
||||
} catch (e) {
|
||||
this.$message.error('JSON 配置格式错误')
|
||||
return
|
||||
}
|
||||
const payload = {
|
||||
group_id: this.form.group_id,
|
||||
plugin_name: this.form.plugin_name,
|
||||
config_key: this.form.config_key || 'default',
|
||||
enabled: !!this.form.enabled,
|
||||
config_json: parsed,
|
||||
updated_by: 'dashboard'
|
||||
}
|
||||
const resp = await axios.post('/group_plugin_config/api/upsert', payload)
|
||||
if (resp.data && resp.data.success) {
|
||||
this.$message.success(resp.data.message || '保存成功')
|
||||
this.dialogVisible = false
|
||||
await this.loadRows()
|
||||
return
|
||||
}
|
||||
this.$message.error((resp.data && resp.data.message) || '保存失败')
|
||||
},
|
||||
async removeRow(row) {
|
||||
try {
|
||||
await this.$confirm('确认删除该配置吗?', '提示', { type: 'warning' })
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
const resp = await axios.post('/group_plugin_config/api/delete', {
|
||||
group_id: row.group_id,
|
||||
plugin_name: row.plugin_name,
|
||||
config_key: row.config_key
|
||||
})
|
||||
if (resp.data && resp.data.success) {
|
||||
this.$message.success(resp.data.message || '删除成功')
|
||||
await this.loadRows()
|
||||
return
|
||||
}
|
||||
this.$message.error((resp.data && 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}
|
||||
.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;max-height:180px;overflow:auto}
|
||||
</style>
|
||||
{% endblock %}
|
||||
132
db/group_plugin_config_db.py
Normal file
132
db/group_plugin_config_db.py
Normal file
@@ -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),
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
11
robot.py
11
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"))
|
||||
|
||||
99
utils/group_plugin_config_service.py
Normal file
99
utils/group_plugin_config_service.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user