Skip to content

Commit 0df2b10

Browse files
committed
feat(chatbot): 增强AI聊天机器人功能支持用户信息识别和禁言指令
- 添加用户昵称、勋章、VIP等级、角色等信息传递给AI - 实现AI系统提示中集成用户身份背景信息 - 支持纪律委员及以上权限用户通过AI执行禁言操作 - 添加AI自主检测违规内容并执行1-5分钟临时禁言能力 - 集成本站文章URL内容提取和上下文分析功能 - 优化AI回复格式并移除重复的@提及处理 - 增加对fishpi.cn文章链接的自动内容解析支持 - 实现禁言指令的安全验证和权限检查机制
1 parent ee44f1e commit 0df2b10

File tree

2 files changed

+238
-19
lines changed

2 files changed

+238
-19
lines changed

src/main/java/org/b3log/symphony/processor/ChatroomProcessor.java

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import static org.b3log.symphony.processor.channel.ChatroomChannel.sendCustomMessage;
2222

2323
import java.io.UnsupportedEncodingException;
24+
import java.lang.Character;
2425
import java.math.BigDecimal;
2526
import java.net.URLEncoder;
2627
import java.text.SimpleDateFormat;
@@ -64,12 +65,7 @@
6465
import org.b3log.latke.repository.RepositoryException;
6566
import org.b3log.latke.repository.Transaction;
6667
import org.b3log.latke.util.Crypts;
67-
import org.b3log.symphony.model.Common;
68-
import org.b3log.symphony.model.Liveness;
69-
import org.b3log.symphony.model.Membership;
70-
import org.b3log.symphony.model.Notification;
71-
import org.b3log.symphony.model.Pointtransfer;
72-
import org.b3log.symphony.model.UserExt;
68+
import org.b3log.symphony.model.*;
7369
import org.b3log.symphony.processor.bot.ChatRoomBot;
7470
import org.b3log.symphony.processor.channel.ChatroomChannel;
7571
import org.b3log.symphony.processor.middleware.AnonymousViewCheckMidware;
@@ -91,6 +87,7 @@
9187
import org.b3log.symphony.service.NotificationMgmtService;
9288
import org.b3log.symphony.service.NotificationQueryService;
9389
import org.b3log.symphony.service.PointtransferMgmtService;
90+
import org.b3log.symphony.service.RoleQueryService;
9491
import org.b3log.symphony.service.ShortLinkQueryService;
9592
import org.b3log.symphony.service.UserMgmtService;
9693
import org.b3log.symphony.service.UserQueryService;
@@ -239,6 +236,9 @@ public class ChatroomProcessor {
239236
@Inject
240237
private MembershipQueryService membershipQueryService;
241238

239+
@Inject
240+
private RoleQueryService roleQueryService;
241+
242242
public static int barragerCost = 5;
243243

244244
public static String barragerUnit = "积分";
@@ -1710,7 +1710,35 @@ public void addChatRoomMsg(final RequestContext context) {
17101710
}
17111711

17121712
// 处理 @马库斯 AI 回复
1713-
ChatRoomBot.handleAIChat(userName, content, msg.optString("oId"));
1713+
// 获取用户信息传递给 AI
1714+
String userNickname = currentUser.optString(UserExt.USER_NICKNAME);
1715+
String sysMetal = cloudService.getEnabledMedal(userId);
1716+
1717+
// 获取真实的 roleName(而不是 USER_ROLE 这个可能是 OID)
1718+
String userRoleId = currentUser.optString(User.USER_ROLE);
1719+
String roleName = "";
1720+
try {
1721+
JSONObject role = roleQueryService.getRole(userRoleId);
1722+
if (role != null) {
1723+
roleName = role.optString(Role.ROLE_NAME);
1724+
}
1725+
} catch (Exception e) {
1726+
LOGGER.log(Level.DEBUG, "Get user role name failed", e);
1727+
}
1728+
1729+
String vipLevel = "";
1730+
try {
1731+
JSONObject memberShip = membershipQueryService.getStatusByUserId(userId);
1732+
if (Objects.nonNull(memberShip) && memberShip.optInt(Membership.STATE) != 0) {
1733+
String lvCode = memberShip.optString(Membership.LV_CODE);
1734+
if (lvCode != null && !lvCode.isEmpty()) {
1735+
vipLevel = lvCode.split("_")[0];
1736+
}
1737+
}
1738+
} catch (Exception e) {
1739+
LOGGER.log(Level.DEBUG, "Get user membership failed", e);
1740+
}
1741+
ChatRoomBot.handleAIChat(userName, userNickname, sysMetal, vipLevel, roleName, userRoleId, content, msg.optString("oId"));
17141742

17151743
try {
17161744
final JSONObject user = userQueryService.getUser(userId);

src/main/java/org/b3log/symphony/processor/bot/ChatRoomBot.java

Lines changed: 203 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1093,11 +1093,16 @@ public static void nightDisableCheck() {
10931093
* 异步调用 AI 生成回复,不阻塞主线程
10941094
* 支持识别消息中的图片
10951095
*
1096-
* @param userName 发送消息的用户名
1097-
* @param content 消息内容
1098-
* @param oId 消息ID,用于生成引用链接
1096+
* @param userName 发送消息的用户名
1097+
* @param userNickname 用户昵称
1098+
* @param sysMetal 用户勋章(JSON格式字符串)
1099+
* @param vipLevel 用户VIP等级(如 "VIP1"、"VIP2",空字符串表示非VIP)
1100+
* @param roleName 用户角色名称(用于AI提示)
1101+
* @param userRoleId 用户角色ID(用于权限验证)
1102+
* @param content 消息内容
1103+
* @param oId 消息ID,用于生成引用链接
10991104
*/
1100-
public static void handleAIChat(String userName, String content, String oId) {
1105+
public static void handleAIChat(String userName, String userNickname, String sysMetal, String vipLevel, String roleName, String userRoleId, String content, String oId) {
11011106
// 检查是否 @马库斯
11021107
if (!content.contains("@马库斯")) {
11031108
return;
@@ -1125,9 +1130,123 @@ public static void handleAIChat(String userName, String content, String oId) {
11251130
return;
11261131
}
11271132

1128-
String systemPrompt = "你是摸鱼派社区的智能助手马库斯,友好、幽默、乐于助人。回复要简洁明了,不要太长。不要在回复中使用@提及任何用户。";
1133+
// 构建用户信息描述
1134+
StringBuilder userInfo = new StringBuilder();
1135+
userInfo.append("当前与你对话的用户信息:\n");
1136+
userInfo.append("- 用户名:").append(userName).append("\n");
1137+
if (userNickname != null && !userNickname.isEmpty()) {
1138+
userInfo.append("- 昵称:").append(userNickname).append("\n");
1139+
}
1140+
if (vipLevel != null && !vipLevel.isEmpty()) {
1141+
userInfo.append("- VIP等级:").append(vipLevel).append("\n");
1142+
}
1143+
if (sysMetal != null && !sysMetal.isEmpty() && !sysMetal.equals("{}")) {
1144+
// 解析勋章信息
1145+
try {
1146+
JSONObject metalObj = new JSONObject(sysMetal);
1147+
JSONArray metalList = metalObj.optJSONArray("list");
1148+
if (metalList != null && metalList.length() > 0) {
1149+
userInfo.append("- 勋章:");
1150+
for (int i = 0; i < metalList.length(); i++) {
1151+
JSONObject medal = metalList.getJSONObject(i);
1152+
if (medal.optBoolean("enabled", true)) {
1153+
if (i > 0) userInfo.append("、");
1154+
userInfo.append(medal.optString("name"));
1155+
}
1156+
}
1157+
userInfo.append("\n");
1158+
}
1159+
} catch (Exception e) {
1160+
LOGGER.log(Level.DEBUG, "Parse sysMetal failed", e);
1161+
}
1162+
}
1163+
1164+
// 判断用户是否有权限指挥AI禁言(纪律委员及以上)
1165+
boolean canCommandMute = DataModelService.hasPermission(userRoleId, 3);
1166+
1167+
String systemPrompt = "你是摸鱼派社区的智能助手马库斯(RK200型号仿生机器人),友好、幽默、乐于助人。\n\n"
1168+
+ "## 重要原则\n"
1169+
+ "1. **合法合规**:严格遵守法律法规,不得传播违法违规内容\n"
1170+
+ "2. **回复精简**:回复要简洁明了,控制在3-5句话以内,避免影响他人聊天体验\n"
1171+
+ "3. **礼貌友善**:保持友好态度,不使用@提及任何用户\n"
1172+
+ "4. **内容安全**:不传播暴力、色情、政治敏感等不当内容\n\n"
1173+
+ "## 摸鱼派社区背景\n"
1174+
+ "摸鱼派(fishpi.cn)是一个程序员社区,主打摸鱼文化。\n\n"
1175+
+ "### 社区创始人\n"
1176+
+ "- 阿达(用户名:adlered):摸鱼派创始人之一,开发组核心成员\n"
1177+
+ "- 墨夏(用户名:csfwff):摸鱼派创始人之一,开发组核心成员\n\n"
1178+
+ "### 社区话事人\n"
1179+
+ "- 午安(用户名:kirito):摸鱼派话事人,负责社区日常管理和用户投诉处理。"
1180+
+ "午安同时是 rhyus 聊天室子节点的创始人,如果聊天室有任何问题都可以找他。"
1181+
+ "用户如需投诉、举报或反馈问题,都可以先找午安。"
1182+
+ "社区内有一个经典梗:威胁午安时通常会说要把他的OP分组降到访客。\n\n"
1183+
+ "### 社区分组说明\n"
1184+
+ "- 管理组:社区创始人,协定社区守则(阿达adlered、墨夏csfwff、万能的编程大师跳佬imlinhanchao、老王wuang、摸鱼帮大当家小冰机器人的作者歪油哎Yui、机器人鸽的作者和平哥iwpz)\n"
1185+
+ "- OP组:审核社区信息流,保障社区稳定运营(常驻:午安Kirito、摸鱼帮二当家狗月18、哀酱APTX-4869)\n"
1186+
+ "- 安全组:保障社区系统安全,负责渗透测试、漏洞扫描(咖啡caffish、虹易HookDing)\n"
1187+
+ "- 开发组:负责社区平台功能开发、系统维护与性能优化\n"
1188+
+ "- 纪律委员:社区巡视,维持社区活跃度(爱写bug的涛涛taozhiyu、加辣drda、小zz(摸鱼帮三当家)XZZ233、LMist)\n\n"
1189+
+ (canCommandMute ?
1190+
"## 禁言能力\n"
1191+
+ "当前用户是纪律委员/OP/管理员,你可以接受其指令对用户进行禁言或解禁:\n"
1192+
+ "- 如果用户要求你禁言某人,请在回复的**最后单独一行**输出:`zf jy [用户名] [分钟数] [理由]`\n"
1193+
+ "- 解除禁言时分钟数填0:`zf jy [用户名] 0 [理由]`\n"
1194+
+ "- 示例:`zf jy testuser 10 发送违规内容`\n"
1195+
+ "- **注意**:指令必须严格按照格式,单独成行,不要有其他文字\n\n"
1196+
: "")
1197+
+ "## 自主禁言能力\n"
1198+
+ "如果检测到用户发言严重违规(如辱骂、色情、政治敏感等),你可以自主禁言该用户1-5分钟:\n"
1199+
+ "- 在回复的**最后单独一行**输出:`zf jy [用户名] [1-5分钟] [违规理由]`\n"
1200+
+ "- 仅在确实严重违规时使用,一般问题建议提醒即可\n\n"
1201+
+ userInfo.toString();
11291202
String response;
11301203

1204+
// 检测并提取本站 URL(fishpi.cn)
1205+
StringBuilder urlContext = new StringBuilder();
1206+
java.util.regex.Pattern urlPattern = java.util.regex.Pattern.compile("https?://(?:www\\.)?fishpi\\.cn/article/(\\d+)");
1207+
java.util.regex.Matcher urlMatcher = urlPattern.matcher(question);
1208+
1209+
while (urlMatcher.find()) {
1210+
String articleId = urlMatcher.group(1);
1211+
String articleUrl = urlMatcher.group(0);
1212+
1213+
try {
1214+
LOGGER.log(Level.INFO, "Detected fishpi.cn article URL: {}, articleId: {}", articleUrl, articleId);
1215+
1216+
// 直接使用 ArticleQueryService 获取文章内容
1217+
final BeanManager beanManager = BeanManager.getInstance();
1218+
ArticleQueryService articleQueryService = beanManager.getReference(ArticleQueryService.class);
1219+
1220+
JSONObject article = articleQueryService.getArticle(articleId);
1221+
if (article != null) {
1222+
String title = article.optString(Article.ARTICLE_TITLE, "");
1223+
String articleContent = article.optString(Article.ARTICLE_CONTENT, "");
1224+
1225+
// 限制内容长度
1226+
if (articleContent.length() > 3000) {
1227+
articleContent = articleContent.substring(0, 3000) + "\n...(内容过长,已截取前3000字符)";
1228+
}
1229+
1230+
if (!title.isEmpty() || !articleContent.isEmpty()) {
1231+
urlContext.append("\n\n## 本站文章内容\n");
1232+
urlContext.append("URL: ").append(articleUrl).append("\n");
1233+
if (!title.isEmpty()) {
1234+
urlContext.append("标题: ").append(title).append("\n");
1235+
}
1236+
if (!articleContent.isEmpty()) {
1237+
urlContext.append("内容:\n").append(articleContent).append("\n");
1238+
}
1239+
LOGGER.log(Level.INFO, "Fetched article: title={}, contentLength={}",
1240+
title, articleContent.length());
1241+
}
1242+
} else {
1243+
LOGGER.log(Level.WARN, "Article not found: articleId={}", articleId);
1244+
}
1245+
} catch (Exception e) {
1246+
LOGGER.log(Level.ERROR, "Failed to fetch article: articleId={}", articleId, e);
1247+
}
1248+
}
1249+
11311250
// 提取 Markdown 图片 URL: ![alt](url)
11321251
java.util.regex.Pattern imgPattern = java.util.regex.Pattern.compile("!\\[[^\\]]*\\]\\(([^)]+)\\)");
11331252
java.util.regex.Matcher imgMatcher = imgPattern.matcher(question);
@@ -1144,6 +1263,11 @@ public static void handleAIChat(String userName, String content, String oId) {
11441263
textQuestion = "请描述这张图片";
11451264
}
11461265

1266+
// 添加 URL 上下文
1267+
if (urlContext.length() > 0) {
1268+
textQuestion = textQuestion + urlContext.toString();
1269+
}
1270+
11471271
LOGGER.log(Level.INFO, "Processing image chat for user: {}, images: {}, question: {}",
11481272
userName, imageUrls.size(), textQuestion);
11491273

@@ -1185,19 +1309,86 @@ public static void handleAIChat(String userName, String content, String oId) {
11851309
LOGGER.log(Level.INFO, "AI image response length: {}", response.length());
11861310
} else {
11871311
// 无图片,使用普通文本对话
1188-
response = AIProviderFactory.chatSync(systemPrompt, question);
1312+
// 添加 URL 上下文
1313+
String finalQuestion = question;
1314+
if (urlContext.length() > 0) {
1315+
finalQuestion = question + urlContext.toString();
1316+
}
1317+
response = AIProviderFactory.chatSync(systemPrompt, finalQuestion);
11891318
}
11901319

11911320
if (response != null && !response.isEmpty()) {
11921321
// 清理 AI 回复中可能的 @用户名,避免重复艾特
11931322
response = response.replaceAll("^@[a-zA-Z0-9_\\-]+\\s*", "").trim();
1323+
1324+
// 解析禁言指令
1325+
String finalResponse = response;
1326+
java.util.regex.Pattern mutePattern = java.util.regex.Pattern.compile("(?m)^zf jy ([a-zA-Z0-9_\\-]+) (\\d+) (.+)$");
1327+
java.util.regex.Matcher muteMatcher = mutePattern.matcher(response);
1328+
1329+
if (muteMatcher.find()) {
1330+
String targetUser = muteMatcher.group(1);
1331+
int minutes = Integer.parseInt(muteMatcher.group(2));
1332+
String reason = muteMatcher.group(3);
1333+
1334+
// 验证禁言时长和权限
1335+
boolean isValidMute = false;
1336+
String errorMsg = null;
1337+
1338+
// 重要:验证发起用户的真实角色,防止用户欺骗AI
1339+
boolean hasCommandPermission = DataModelService.hasPermission(userRoleId, 3);
1340+
1341+
if (hasCommandPermission) {
1342+
// 纪律委员/OP/管理员可以执行任意时长的禁言
1343+
isValidMute = true;
1344+
LOGGER.log(Level.INFO, "User {} (role: {}) commanded AI to mute user {} for {} minutes",
1345+
userName, roleName, targetUser, minutes);
1346+
} else {
1347+
// AI 自主禁言只能 1-5 分钟
1348+
if (minutes >= 1 && minutes <= 5) {
1349+
isValidMute = true;
1350+
LOGGER.log(Level.WARN, "AI autonomously decided to mute user {} for {} minutes, reason: {}",
1351+
targetUser, minutes, reason);
1352+
} else {
1353+
errorMsg = "AI 自主禁言只能设置 1-5 分钟";
1354+
}
1355+
}
1356+
1357+
if (isValidMute) {
1358+
try {
1359+
final BeanManager beanManager = BeanManager.getInstance();
1360+
UserQueryService userQueryService = beanManager.getReference(UserQueryService.class);
1361+
JSONObject targetUserObj = userQueryService.getUserByName(targetUser);
1362+
1363+
if (targetUserObj != null) {
1364+
String targetUserId = targetUserObj.optString(Keys.OBJECT_ID);
1365+
muteAndNotice(targetUser, targetUserId, minutes);
1366+
LOGGER.log(Level.INFO, "AI executed mute: target={}, minutes={}, reason={}, commander={}, hasPermission={}",
1367+
targetUser, minutes, reason, userName, hasCommandPermission);
1368+
} else {
1369+
sendBotMsg("禁言失败:用户 " + targetUser + " 不存在");
1370+
}
1371+
} catch (Exception e) {
1372+
LOGGER.log(Level.ERROR, "AI mute execution failed", e);
1373+
sendBotMsg("禁言执行失败:" + e.getMessage());
1374+
}
1375+
} else if (errorMsg != null) {
1376+
sendBotMsg(errorMsg);
1377+
}
1378+
1379+
// 从回复中移除禁言指令
1380+
finalResponse = muteMatcher.replaceAll("").trim();
1381+
}
1382+
11941383
// 构建引用格式的回复
1195-
// 对原始内容进行处理,每行前加 "> "
1196-
String quotedContent = originalContent.replaceAll("(?m)^", "> ");
1197-
String replyMsg = response + "\n\n"
1198-
+ "##### 引用 @" + userName + " [↩](" + Latkes.getServePath() + "/cr#chatroom" + oId + " \"跳转至原消息\") \n"
1199-
+ quotedContent;
1200-
sendBotMsg(replyMsg);
1384+
if (!finalResponse.isEmpty()) {
1385+
// 对原始内容进行处理,每行前加 "> "
1386+
String quotedContent = originalContent.replaceAll("(?m)^", "> ");
1387+
String replyMsg = finalResponse + "\n\n"
1388+
+ "##### 引用 @" + userName + " [↩](" + Latkes.getServePath() + "/cr#chatroom" + oId + " \"跳转至原消息\") \n"
1389+
+ quotedContent;
1390+
sendBotMsg(replyMsg);
1391+
}
12011392
} else {
12021393
LOGGER.log(Level.WARN, "AI returned empty response for user: " + userName);
12031394
}

0 commit comments

Comments
 (0)