内置示例
一条完整的 QQ ↔ MC 链路要两侧配合:MC 侧的脚本负责产生/执行,Bngeuit 侧的脚本负责收发 QQ 消息。只装一边,消息会在半路停住。
脚本分工
| 位置 | 脚本 | 作用 |
|---|---|---|
MC 侧plugins/MC-BngeuitBridge/script/ | JoinAndQuit.nc | 玩家上下线时发桥接消息 |
CommandRelay.nc | 收到命令请求,在主线程以控制台身份执行 | |
Bngeuit 侧plugins/ | McJoinAndQuit.nc | 上下线消息转发到 QQ 群 |
McCommandRelay.nc | 群内 /mccmd → 转发给 MC,并把结果回给群 | |
McBridgeStatus.nc | 应答 /bngeuitbridge status 的状态查询 |
- MC 侧两个脚本在配置
create script example: true(默认)时首次启动自动释放。不想用就改成false再删文件,重启后不再生成 - Bngeuit 侧三个脚本需要手动放进 Bngeuit 的
plugins/,都声明了pluginDepend = listOf("BngeuitBridge") - 全部是
.nc脚本(nacre 引擎),Bngeuit 早期的 JavaScript 脚本支持已经移除。下面给的就是可直接使用的完整版本
每个示例都附了完整源码
下面的每个脚本后面都有一个「完整源码」折叠块,展开就能整段复制直接落盘使用,只需改里面的群 OpenID、client-id 这类配置。
- MC 侧两个脚本插件启动时已自动释放到
plugins/MC-BngeuitBridge/script/,不用手抄 - Bngeuit 侧三个脚本需要自己放到 Bngeuit 的
plugins/目录
链路总览
【方向一:玩家进服】
PlayerJoinEvent
→ MC 侧 JoinAndQuit.nc 打包 type=mc_event / action=player_join
→ BngeuitBridge 转交 Bngeuit
→ Bngeuit 侧 McJoinAndQuit.nc 发群消息
【方向二:群里敲命令】
群内 @机器人 /mccmd list
→ Bngeuit 侧 McCommandRelay.nc 打包 type=mc_command / action=exec
→ BngeuitBridge 定向发给某台 MC
→ MC 侧 CommandRelay.nc 在主线程以控制台身份执行
→ reply 回传 success,QQ 侧被动回复结果
【方向三:查桥接状态】
游戏内 /bngeuitbridge status
→ MC 侧发 type=bridge / action=status
→ Bngeuit 侧 McBridgeStatus.nc 回复版本 / 运行时长 / 在线客户端示例一:玩家上下线 QQ 群通知
MC 侧:JoinAndQuit.nc
监听 PlayerJoinEvent / PlayerQuitEvent,各发一条消息:
| 字段 | 值 |
|---|---|
type | mc_event |
action | player_join / player_quit |
data.player | 玩家名 |
data.uuid | 玩家 UUID |
通道没连上时直接丢弃、不刷日志 —— 上下线太频繁,断线期间记日志没意义。
想加新的事件类型(死亡、聊天、换世界…),改脚本开头的 EVENTS 列表即可,enable / disable 会自动跟着注册和注销:
val EVENTS = listOf(
EventDef("MCBngeuitBridge:加入信息", "org.bukkit.event.player.PlayerJoinEvent", "player_join"),
EventDef("MCBngeuitBridge:离开信息", "org.bukkit.event.player.PlayerQuitEvent", "player_quit")
// 照着加一行,第三个参数是 action,QQ 侧按它分流
)完整源码:plugins/MC-BngeuitBridge/script/JoinAndQuit.nc
package mc.bridge.joinquit
import org.bukkit.Bukkit
import org.bukkit.event.Event
import org.bukkit.event.player.PlayerEvent
import cn.org.bukkit.bngeuit.bridge.MCBngeuitBridge
import cn.org.bukkit.bngeuit.bridge.utils.ListenerUtils
import java.util.HashMap
// 日志前缀(统一 "[名称] " 格式,尾随空格)
val PREFIX = "[JoinAndQuit] "
// 要上报的事件:key = 监听器注册名(disable 时按此注销),eventClass = Bukkit 事件类名,action = 桥接消息里的 action
// 新增类型:这里加一行即可,enable/disable 会自动同步
data class EventDef(val key: String, val eventClass: String, val action: String)
val EVENTS = listOf(
EventDef("MCBngeuitBridge:加入信息", "org.bukkit.event.player.PlayerJoinEvent", "player_join"),
EventDef("MCBngeuitBridge:离开信息", "org.bukkit.event.player.PlayerQuitEvent", "player_quit")
)
fun enable() {
for (def in EVENTS) {
registerOne(def)
}
Bukkit.getLogger().info("${PREFIX}上下线通知已启用(${EVENTS.size()} 个事件)")
}
fun disable() {
for (def in EVENTS) {
ListenerUtils.unregisterListener(def.key)
}
}
// 监听注册:只负责接住事件并交给 reportEvent,异常不往外抛
// 尾随 null:Kotlin Function1 返回类型是平台类型 Unit!,与 nc 的 Unit 不统一,null 规避(运行时无影响)
fun registerOne(def: EventDef) {
ListenerUtils.registerListener(def.key, def.eventClass, "HIGHEST", false) { e: Event ->
reportEvent(def.action, e)
null
}
}
// 上报:取玩家 → 组 data → 发桥接消息
fun reportEvent(action: String, e: Event) {
val channel = MCBngeuitBridge.INSTANCE.getChannel()
// 通道未连接时直接丢弃:上下线很频繁,刷日志没意义
if (channel == null || !channel.isConnected()) return
// PlayerJoinEvent / PlayerQuitEvent 都继承 PlayerEvent,getPlayer() 一次搞定
// (data 是 nc 关键字,变量不能叫 data)
val player = (e as PlayerEvent).getPlayer()
val payload = HashMap<String, String>()
payload.put("player", player.getName())
payload.put("uuid", player.getUniqueId().toString())
channel.send("mc_event", action, payload)
}Bngeuit 侧:McJoinAndQuit.nc
关键配置在文件顶部:
// 接收消息的群(group_openid)
// 不知道就先在群里 @机器人 发一句话,控制台日志里会打出 (群: xxxx)
val GROUP_ID = "3926E394441C091F19DA8973C5098104"
// action → 群消息文案,{player} 会被替换成玩家名
val MC_EVENT_TEXT = mapOf(
"player_join" to "{player} 加入了游戏",
"player_quit" to "{player} 离开了游戏"
)
// 是否把群 @ 消息打到控制台(调试用)
val LOG_GROUP_AT = true处理逻辑:
fun handleMcEvent(e: BridgeMessageReceiveEvent, action: String) {
val m = e.getData() as Map
val template = MC_EVENT_TEXT.get(action) ?: return
val player = (m.get("player") ?: "未知玩家").toString()
// 来源服务器取认证时的 clientId,多服时用它区分是哪台
val clientId = e.getClientId() ?: ""
val body = template.replace("{player}", player)
val text = if (clientId.isEmpty()) body else "[${clientId}] ${body}"
Bngeuit.sendGroupText(GROUP_ID, text)
}两个细节值得注意:
- 消息里的来源服务器用
e.getClientId()(认证身份)而不是data里的字段,多服时更可靠 - 新增事件类型:
MC_EVENT_TEXT加一行 + MC 侧EVENTS加一行,两端各一个 action 字符串对上就行,不用改监听逻辑
完整源码:plugins/McJoinAndQuit.nc
package bngeuit.mc.joinquit
import cn.org.bukkit.bngeuit.script.NcBridge
import cn.org.bukkit.bngeuit.Bngeuit
import cn.org.bukkit.bngeuit.event.receive.GroupAtMessageEvent
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent
// ── 元信息 ──
val pluginName = "McJoinAndQuit"
val pluginVersion = "1.0.0"
val pluginDescription = "监听 MC 玩家上下线桥接消息,转发到 QQ 群"
val pluginAuthor = "bngeuit"
val pluginDepend = listOf("BngeuitBridge")
// ── 桥接对象(参数必须是本脚本声明的 package)──
val b = NcBridge.of("bngeuit.mc.joinquit")
// 日志前缀(统一 "[名称] " 格式,尾随空格)
val PREFIX = "[上下线通知] "
// ── 配置 ──────────────────────────────────────────────────
// 接收消息的群(group_openid)
// 不知道的话,在群里 @机器人 发一句话,控制台日志里会有 (群: xxxx)
val GROUP_ID = "你的群OpenID"
// mc_event 的 action → 群消息文案({player} 替换成玩家名)
// 新增事件类型:这里加一行 + MC 侧 JoinAndQuit.nc 的 EVENTS 加一行即可
val MC_EVENT_TEXT = mapOf(
"player_join" to "{player} 加入了游戏",
"player_quit" to "{player} 离开了游戏"
)
// 是否把群 @ 消息打到控制台(调试用,不需要就置 false)
val LOG_GROUP_AT = true
// ─────────────────────────────────────────────────────────
fun onLoad() {
// lifecycle start
}
fun onEnable() {
b.info("${PREFIX}已启用,目标群: ${GROUP_ID}")
b.on(BridgeMessageReceiveEvent.class) { e -> dispatchMessage(e) }
// 群聊 @ 机器人:仅打日志,调试用
if (LOG_GROUP_AT) {
b.on(GroupAtMessageEvent.class) { e ->
val text = e.getContent().trim()
b.info("${PREFIX}GroupAtMessageEvent: ${text} (群: ${e.getGroupOpenid()})")
}
}
}
fun onDisable() {
b.info("${PREFIX}已卸载")
}
// 消息分发:只做 type/action 路由,具体活儿交给各处理器
fun dispatchMessage(e: BridgeMessageReceiveEvent) {
val type = e.getType() ?: ""
val action = e.getAction() ?: ""
if (type.isEmpty() || action.isEmpty()) {
b.warn("${PREFIX}消息缺少 type/action,已忽略: type=${type} action=${action}")
return
}
if (type == "mc_event") {
handleMcEvent(e, action)
return
}
// 其他 type 与本脚本无关(bridge/status 由 McBridgeStatus.nc 处理)
}
// mc_event:玩家上线 / 下线 → 转发到群
fun handleMcEvent(e: BridgeMessageReceiveEvent, action: String) {
val evtData = e.getData()
if (!(evtData is Map)) {
b.warn("${PREFIX}mc_event 的 data 不是 Map: ${evtData}")
return
}
val m = evtData as Map
val template = MC_EVENT_TEXT.get(action)
if (template == null) {
b.info("${PREFIX}未知 mc_event 动作: ${action}(已忽略)")
return
}
val playerName = (m.get("player") ?: "未知玩家").toString()
val uuid = uuidSuffix(m.get("uuid"))
// 来源服务器:取连接认证的 clientId(比 data 里的更可靠),多服时用于区分
val clientId = e.getClientId() ?: ""
// Java 的 String.replace 为字面替换(无正则、无 $ 特殊语义)
val body = template.replace("{player}", playerName)
val text = withSource(clientId, body)
b.info("${PREFIX}${text}${uuid}")
Bngeuit.sendGroupText(GROUP_ID, text)
}
fun withSource(clientId: String, body: String): String {
if (clientId.isEmpty()) return body
return "[${clientId}] ${body}"
}
fun uuidSuffix(uuid: Object): String {
if (uuid == null) return ""
return " (uuid=${uuid})"
}效果
[survival] Steve 加入了游戏
[survival] Steve 离开了游戏示例二:QQ 群执行 MC 服务器命令
MC 侧:CommandRelay.nc
收到 type=mc_command、action=exec 的消息后:
- 从
data.command取出命令文本 - 检查首词是否在黑名单里(默认
stop、restart) - 切主线程,用
Bukkit.dispatchCommand(控制台, 命令)执行 - 把结果 reply 回去
回复的 data 结构:
{ "success": true }{ "success": false, "error": "命令被拒绝: stop(在 MC 侧黑名单中)" }几点行为要知道:
- 命令以控制台身份执行,权限全放行,所以黑名单很重要
- 命令自身的输出只进服务器控制台,不会回传到 QQ。群里只能看到「执行成功 / 未被执行」
- 带命名空间的写法(
minecraft:stop)会取冒号后一段再比对黑名单,绕不过去 - 通道断开时只记日志、不执行
黑名单改脚本顶部这一行:
val COMMAND_BLACKLIST = listOf("stop", "restart", "op", "deop", "ban")完整源码:plugins/MC-BngeuitBridge/script/CommandRelay.nc
package mc.bridge.relay
import org.bukkit.Bukkit
import org.bukkit.event.Event
import cn.org.bukkit.bngeuit.bridge.MCBngeuitBridge
import cn.org.bukkit.bngeuit.bridge.channel.BridgeChannel
import cn.org.bukkit.bngeuit.bridge.channel.BridgeMessage
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent
import cn.org.bukkit.bngeuit.bridge.utils.ListenerUtils
import cn.org.bukkit.bngeuit.bridge.utils.SchedulerUtils
import java.util.LinkedHashMap
import java.util.Map
// 日志前缀(统一 "[名称] " 格式,尾随空格)
val PREFIX = "[CommandRelay] "
// ── 配置 ──────────────────────────────────────────────────
// 命令首词黑名单(小写、不带 "/";带命名空间的 "minecraft:xx" 取冒号后一段比对)
val COMMAND_BLACKLIST = listOf("stop", "restart")
// 监听器注册名
val LISTENER_KEY = "MCBngeuitBridge:命令转发"
// ─────────────────────────────────────────────────────────
fun enable() {
// 尾随 null:Kotlin Function1 返回类型是平台类型 Unit!,与 nc 的 Unit 不统一,null 规避(运行时无影响)
ListenerUtils.registerListener(LISTENER_KEY, "cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent", "NORMAL", false) { e: Event ->
dispatchMessage(e as BridgeMessageReceiveEvent)
null
}
Bukkit.getLogger().info("${PREFIX}命令转发已启用 (黑名单: ${COMMAND_BLACKLIST.joinToString("/")})")
}
fun disable() {
ListenerUtils.unregisterListener(LISTENER_KEY)
}
// 消息分发:只做 type/action 路由,具体活儿交给各处理器
// 扩展方式:按 type(必要时再看 action)加分支即可,监听器本身不用改
fun dispatchMessage(event: BridgeMessageReceiveEvent) {
val type = event.getType() ?: ""
val action = event.getAction() ?: ""
if (type.isEmpty() || action.isEmpty()) {
Bukkit.getLogger().warning("${PREFIX}消息缺少 type/action,已忽略: type=${type} action=${action}")
return
}
if (type == "mc_command") {
if (action == "exec") {
handleCommandRequest(event)
} else {
// 命令类消息的未知动作:不静默失败,留个日志方便排查
Bukkit.getLogger().warning("${PREFIX}未知命令动作: ${action}(已忽略)")
}
}
// 其他 type 与本脚本无关(如 mc_event 由 JoinAndQuit.nc 等脚本自行处理)
}
// mc_command/exec:解析命令 → 派发主线程执行 → 回复
fun handleCommandRequest(event: BridgeMessageReceiveEvent) {
val evtData = event.getData()
if (!(evtData is Map)) return
val m = evtData as Map
val raw = m.get("command")
if (raw == null) return
val cmd = raw.toString().trim()
if (cmd.isEmpty()) return
val channel = MCBngeuitBridge.INSTANCE.getChannel()
val message = event.getMessage()
if (channel == null || !channel.isConnected()) {
// 通道已断,回复无意义,仅记日志
Bukkit.getLogger().warning("${PREFIX}通道未连接,丢弃命令请求: ${cmd}")
return
}
// 切回主线程执行(Bukkit API 非线程安全)
SchedulerUtils.sync { executeAndReply(cmd, channel, message) }
}
fun executeAndReply(cmd: String, channel: BridgeChannel, message: BridgeMessage) {
val result = executeCommand(cmd)
channel.send(message.reply(result))
}
// 主线程:以控制台身份执行命令
fun executeCommand(command: String): LinkedHashMap<String, Object> {
val result = LinkedHashMap<String, Object>()
val head = command.split("\\s+")[0].replaceFirst("^/+", "").toLowerCase()
val tail = tailOf(head)
if (COMMAND_BLACKLIST.contains(head) || COMMAND_BLACKLIST.contains(tail)) {
result.put("success", false)
result.put("error", "命令被拒绝: ${head}(在 MC 侧黑名单中)")
return result
}
// 控制台身份:权限全放行,输出直接进服务器控制台
val ok = Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command)
result.put("success", ok)
return result
}
// head.split(':').pop()(取最后一个冒号后的段)
fun tailOf(head: String): String {
if (head.indexOf(':') >= 0) return head.substring(head.lastIndexOf(':') + 1)
return head
}Bngeuit 侧:McCommandRelay.nc
触发方式是群内 @机器人 或私聊机器人 发 /mccmd <命令>:
/mccmd list
/mccmd whitelist add Steve配置:
// 允许触发的群(留空 = 不限制)
val ALLOWED_GROUPS = listOf("3926E394441C091F19DA8973C5098104")
// 目标服务器 clientId;填 "" = 自动选第一个在线客户端
val TARGET_CLIENT_ID = "survival"
// 群内允许使用的成员(member_openid),留空 = 群内所有人
val ADMIN_MEMBER_OPENIDS = listOf("5E4809A76FC71713CC6CB571740EA498")
// 私聊允许使用的用户(user_openid),留空 = 任何人
// 注意:单聊 openid 和群里的 member_openid 不是同一套,要单独填
val ADMIN_USER_OPENIDS = listOf<String>()
// QQ 侧兜底黑名单(MC 侧还有一道)
val COMMAND_BLACKLIST = listOf("stop", "restart")
val TRIGGER = "/mccmd"
val MAX_REPLY_CHARS = 450转发时会带上一份上下文,MC 侧脚本可按需取用:
val payload = LinkedHashMap<String, Object>()
payload.put("command", command)
payload.put("from", "qq")
payload.put("scene", scene) // group / c2c
payload.put("peer", peer) // 群 openid 或用户 openid
payload.put("user", who) // 发送者昵称
channel.send(target, "mc_command", "exec", payload)
.onSuccess { resp -> reply(formatResult(resp)) }
.onFailure { err -> reply("执行失败(MC 未响应): ${err}") }群里可能收到的几种回复:
| 回复 | 原因 |
|---|---|
执行成功 | MC 返回 success=true |
命令未被执行(命令不存在或用法错误) | MC 返回 success=false 且没有 error |
命令执行异常\n... | MC 侧抛异常,带异常信息 |
命令被拒绝: xxx(黑名单) | 首词在任一侧黑名单 |
xxx 没有远程执行命令的权限 | openid 不在白名单 |
目标服务器不在线: xxx\n当前在线: [...] | TARGET_CLIENT_ID 对应的服务器没连上 |
没有已连接的 MC 服务器 | 一台都没连 |
命令块不要以 "/" 开头... | 写成了 /mccmd /list |
执行失败(MC 未响应) | 30 秒超时 |
完整源码:plugins/McCommandRelay.nc
package bngeuit.mc.relay
import cn.org.bukkit.bngeuit.script.NcBridge
import cn.org.bukkit.bngeuit.Bngeuit
import cn.org.bukkit.bngeuit.dto.message.User
import cn.org.bukkit.bngeuit.event.receive.GroupAtMessageEvent
import cn.org.bukkit.bngeuit.event.receive.C2CMessageEvent
import cn.org.bukkit.bngeuit.bridge.BngeuitBridge
import cn.org.bukkit.bngeuit.bridge.channel.BridgeChannel
import cn.org.bukkit.bngeuit.bridge.channel.BridgeMessage
import java.util.LinkedHashMap
// ── 元信息 ──
val pluginName = "McCommandRelay"
val pluginVersion = "1.0.0"
val pluginDescription = "/mccmd → 远程执行 MC 命令并回传结果"
val pluginAuthor = "bngeuit"
val pluginDepend = listOf("BngeuitBridge")
// ── 桥接对象(参数必须是本脚本声明的 package)──
val b = NcBridge.of("bngeuit.mc.relay")
// 日志前缀(统一 "[名称] " 格式,尾随空格)
val PREFIX = "[命令转发] "
// ── 配置 ──────────────────────────────────────────────────
// 允许触发本命令的群(group_openid;不知道就先 @机器人 发一句话,控制台日志里有)
val ALLOWED_GROUPS = listOf("你的群OpenID")
// 目标 MC 服务器 clientId(对应 MC 侧 config 的 client-id)
// 填 "" = 自动选第一个在线客户端;多服时建议明确填写
val TARGET_CLIENT_ID = "survival"
// 群聊允许使用的成员(member_openid,群成员维度)。空列表 = 群内所有人可用
val ADMIN_MEMBER_OPENIDS = listOf("你的member_openid")
// 私聊允许使用的用户(user_openid,单聊维度)。空列表 = 任何人私聊机器人都能执行
// 注意:单聊的 openid 和群里的 member_openid 不是同一套,要单独填
val ADMIN_USER_OPENIDS = listOf<String>()
// 命令首词黑名单(小写、不带 "/";MC 侧还有独立黑名单兜底)
val COMMAND_BLACKLIST = listOf("stop", "restart")
// 触发词
val TRIGGER = "/mccmd"
// 用法提示(裸触发词 / 命令块为空时回复)
val USAGE = "用法: ${TRIGGER} <命令> [参数...]\n例如: ${TRIGGER} list"
// 回复最大长度(QQ 消息别太长)
val MAX_REPLY_CHARS = 450
// ─────────────────────────────────────────────────────────
fun onEnable() {
b.info("${PREFIX}已启用,触发词: ${TRIGGER}(群聊 @机器人 / 私聊)")
b.on(GroupAtMessageEvent.class) { e -> handleGroupAt(e) }
b.on(C2CMessageEvent.class) { e -> handleC2C(e) }
}
// ── 群聊:@机器人 ──
fun handleGroupAt(e: GroupAtMessageEvent) {
val groupOpenid = e.getGroupOpenid()
if (!ALLOWED_GROUPS.isEmpty() && !ALLOWED_GROUPS.contains(groupOpenid)) return
val author = e.getAuthor()
val msgId = e.getId()
val memberOpenid = author.memberOpenid() ?: ""
handleMessage(e.getContent(), displayName(author), memberOpenid, ADMIN_MEMBER_OPENIDS, "group", groupOpenid) { text ->
replyGroup(groupOpenid, msgId, text)
}
}
// ── 私聊:C2C 单聊 ──
fun handleC2C(e: C2CMessageEvent) {
val author = e.getAuthor()
val userOpenid = author.userOpenid() ?: ""
if (userOpenid.isEmpty()) return
val msgId = e.getId()
handleMessage(e.getContent(), displayName(author), userOpenid, ADMIN_USER_OPENIDS, "c2c", userOpenid) { text ->
replyC2C(userOpenid, msgId, text)
}
}
fun displayName(author: User): String {
return author.username() ?: "未知用户"
}
// ── 群聊 / 私聊共用:解析 → 权限 → 黑名单 → 转发 ──
// reply 是函数类型形参:群聊/私聊各自绑定回复通道
fun handleMessage(content: String, who: String, openid: String, adminList: List<String>,
scene: String, peer: String, reply: (String) -> Unit) {
val parsed = parseCommand(content)
if (parsed.action == "ignore") return
if (parsed.action == "usage") {
reply(USAGE)
return
}
if (parsed.action == "slash") {
reply("命令块不要以 \"/\" 开头,直接写命令本体\n例如: ${TRIGGER} list")
return
}
// 权限白名单(留空 = 不限制)
if (!adminList.isEmpty() && !adminList.contains(openid)) {
reply("${who} 没有远程执行命令的权限")
return
}
val command = parsed.command!!
val head = command.split("\\s+")[0].toLowerCase()
val tail = tailOf(head)
if (COMMAND_BLACKLIST.contains(head) || COMMAND_BLACKLIST.contains(tail)) {
reply("命令被拒绝: ${head}(黑名单)")
return
}
sendToMc(command, who, scene, peer, reply)
}
// ── 解析 "/mccmd<空白><命令块>" ──
// 只认触发词 + 至少一个空白 + 非空、且不以 "/" 开头的命令块
data class Parsed(val action: String, val command: String?)
fun parseCommand(raw: String): Parsed {
val text = raw.trim()
if (!text.startsWith(TRIGGER)) return Parsed("ignore", null)
val rest = text.substring(TRIGGER.length)
if (!hasLeadingWhitespace(rest)) {
if (text == TRIGGER) return Parsed("usage", null)
return Parsed("ignore", null)
}
val command = rest.trim()
if (command.isEmpty()) return Parsed("usage", null)
if (command.startsWith("/")) return Parsed("slash", null)
return Parsed("exec", command)
}
fun hasLeadingWhitespace(s: String): Boolean {
if (s.isEmpty()) return false
return Character.isWhitespace(s.charAt(0))
}
// head.split(':').pop()(取最后一个冒号后的段)
fun tailOf(head: String): String {
if (head.indexOf(':') >= 0) return head.substring(head.lastIndexOf(':') + 1)
return head
}
// ── 转发到 MC ──
fun sendToMc(command: String, who: String, scene: String, peer: String, reply: (String) -> Unit) {
val channel: BridgeChannel = BngeuitBridge.getInstance().getChannel()
if (channel == null) {
reply("桥接通道未就绪(BngeuitBridge 未启用)")
return
}
val clients = channel.getConnectedClients()
if (clients.isEmpty()) {
reply("没有已连接的 MC 服务器")
return
}
val target = pickTarget(TARGET_CLIENT_ID, clients, reply)
if (target == null) return
val payload = LinkedHashMap<String, Object>()
payload.put("command", command)
payload.put("from", "qq")
payload.put("scene", scene)
payload.put("peer", peer)
payload.put("user", who)
b.info("${PREFIX}${scene} ${who} → ${target} : ${command}")
channel.send(target, "mc_command", "exec", payload)
.onSuccess { resp -> reply(formatResult(resp)) }
.onFailure { err -> reply("执行失败(MC 未响应): ${truncate(err, 200)}") }
}
// 返回选中的 clientId;无可用目标时回复调用方并返回 null
fun pickTarget(configured: String, clients: List<String>, reply: (String) -> Unit): String? {
if (configured.isEmpty()) {
return clients.get(0)
}
if (!clients.contains(configured)) {
reply("目标服务器不在线: ${configured}\n当前在线: ${clients}")
return null
}
return configured
}
// ── 回复 ──
fun replyGroup(groupOpenid: String, msgId: String, content: String) {
Bngeuit.replyGroupText(groupOpenid, msgId, 1, content)
}
fun replyC2C(userOpenid: String, msgId: String, content: String) {
Bngeuit.replyC2CText(userOpenid, msgId, 1, content)
}
// ── 回复格式化 ──
fun formatResult(resp: BridgeMessage): String {
val result = resp.getData()
if (!(result is Map)) {
return "MC 返回了无法解析的结果"
}
val m = result as Map
val error = m.get("error")
if (error != null) {
return "命令执行异常\n${truncate(error.toString(), MAX_REPLY_CHARS - 20)}"
}
if (String.valueOf(m.get("success")).equals("true")) {
return "执行成功"
}
return "命令未被执行(命令不存在或用法错误)"
}
fun truncate(s: String, maxLen: Int): String {
if (s.length() <= maxLen) return s
return "${s.substring(0, maxLen)}...(已截断)"
}安全建议
群内执行命令本质上是把服务器控制台交给群里的人,务必:
- 限制群:
ALLOWED_GROUPS只放管理群 - 限制人:
ADMIN_MEMBER_OPENIDS(群)/ADMIN_USER_OPENIDS(私聊)填死管理员 openid - 两侧黑名单:MC 侧
COMMAND_BLACKLIST+ QQ 侧COMMAND_BLACKLIST,别只留stop/restart,op、deop、ban、whitelist按需加 - 多服环境:
TARGET_CLIENT_ID明确写死,别用「自动选第一个」,否则可能打到错误的服
示例三:在游戏里查 Bngeuit 侧桥接状态
Bngeuit 侧:McBridgeStatus.nc
把 McBridgeStatus.nc 放进 Bngeuit 的 plugins/,重启 Bngeuit。控制台出现:
[桥接状态] 已启用(应答 bridge/status 查询)没有这个脚本,游戏内 /bngeuitbridge status 一定超时 —— 因为 BngeuitBridge 自己不会应答,全靠这个脚本回复。
使用
/bngeuitbridge status[MC-BngeuitBridge] 正在查询 BngeuitBridge 状态...
[MC-BngeuitBridge] BngeuitBridge 状态:
{version=1.0.1, uptimeSeconds=3612, connectedClients=2, clientIds=[survival, creative]}| 字段 | 含义 |
|---|---|
version | BngeuitBridge 插件版本 |
uptimeSeconds | BngeuitBridge 已运行秒数 |
connectedClients | 当前在线 MC 服务器数量 |
clientIds | 在线服务器列表 |
它是怎么实现的
约定很简单:MC 侧发 type=bridge、action=status,Bngeuit 侧按 requestId 定向回复给查询方(不广播)。
fun handleStatusQuery(e: BridgeMessageReceiveEvent) {
val msg = e.getMessage()
val requestId = msg.getRequestId() ?: ""
if (requestId.isEmpty()) {
b.warn("收到 bridge/status 请求但 requestId 为空,跳过回复")
return
}
val instance = BngeuitBridge.getInstance()
val status = LinkedHashMap<String, Object>()
status.put("version", instance.getDescription().getVersion())
status.put("uptimeSeconds", (System.currentTimeMillis() - instance.getStartTime()) / 1000)
status.put("connectedClients", instance.getChannel().getConnectedClients().size())
status.put("clientIds", instance.getChannel().getConnectedClients())
// 回给发起查询的那台,不广播
instance.getChannel().send(e.getClientId(), msg.reply(status))
}照着这个模式,你可以给它加更多查询项(TPS、在线人数、内存),或者在 type=bridge 下加新的 action。
完整源码:plugins/McBridgeStatus.nc
package bngeuit.mc.bridge
import cn.org.bukkit.bngeuit.script.NcBridge
import cn.org.bukkit.bngeuit.bridge.BngeuitBridge
import cn.org.bukkit.bngeuit.bridge.event.BridgeMessageReceiveEvent
import java.util.LinkedHashMap
// ── 元信息 ──
val pluginName = "McBridgeStatus"
val pluginVersion = "1.0.0"
val pluginDescription = "应答桥接状态查询:版本 / 运行时长 / 在线客户端"
val pluginAuthor = "bngeuit"
val pluginDepend = listOf("BngeuitBridge")
// ── 桥接对象(参数必须是本脚本声明的 package)──
val b = NcBridge.of("bngeuit.mc.bridge")
// 日志前缀(统一 "[名称] " 格式,尾随空格)
val PREFIX = "[桥接状态] "
fun onLoad() {
// lifecycle start
}
fun onEnable() {
b.info("${PREFIX}已启用(应答 bridge/status 查询)")
b.on(BridgeMessageReceiveEvent.class) { e -> dispatchMessage(e) }
}
fun onDisable() {
b.info("${PREFIX}已卸载")
}
// 消息分发:只做 type/action 路由,具体活儿交给各处理器
// 扩展方式:bridge 下加 action 分支即可,监听器本身不用改
fun dispatchMessage(e: BridgeMessageReceiveEvent) {
val type = e.getType() ?: ""
val action = e.getAction() ?: ""
if (type.isEmpty() || action.isEmpty()) {
b.warn("${PREFIX}消息缺少 type/action,已忽略: type=${type} action=${action}")
return
}
if (type == "bridge") {
if (action == "status") {
handleStatusQuery(e)
} else {
b.warn("${PREFIX}未知 bridge 动作: ${action}(已忽略)")
}
return
}
// 其他 type 与本脚本无关(mc_event 由 McJoinAndQuit.nc 处理)
}
// bridge/status:回复桥接状态
fun handleStatusQuery(e: BridgeMessageReceiveEvent) {
val msg = e.getMessage()
val requestId = msg.getRequestId() ?: ""
if (requestId.isEmpty()) {
b.warn("${PREFIX}收到 bridge/status 请求但 requestId 为 null,跳过回复")
return
}
val instance = BngeuitBridge.getInstance()
val uptimeSec = (System.currentTimeMillis() - instance.getStartTime()) / 1000
val status = LinkedHashMap<String, Object>()
status.put("version", instance.getDescription().getVersion())
status.put("uptimeSeconds", uptimeSec)
status.put("connectedClients", instance.getChannel().getConnectedClients().size())
status.put("clientIds", instance.getChannel().getConnectedClients())
instance.getChannel().send(e.getClientId(), msg.reply(status))
b.info("${PREFIX}已回复状态查询: clientId=${e.getClientId()}, requestId=${requestId}, uptime=${uptimeSec}s")
}自己加一条新链路
两边都是同一套模板:约定好 type / action,两端各写一段。
比如「群里问服务器 TPS」:
MC 侧(脚本里监听 mc_query / tps,回一个数字):
if (type == "mc_query" && event.getAction() == "tps") {
SchedulerUtils.sync {
val result = HashMap<String, Object>()
result.put("tps", Bukkit.getTPS()[0])
channel.send(event.getMessage().reply(result))
}
}Bngeuit 侧(群里发 /tps,把回复发出去):
b.on(GroupAtMessageEvent.class) { e ->
if (e.getContent().trim() == "/tps") {
val channel = BngeuitBridge.getInstance().getChannel()
channel.send("survival", "mc_query", "tps", null)
.onSuccess { resp -> Bngeuit.replyGroupText(e.getGroupOpenid(), e.getId(), 1, "当前 TPS: ${resp.getData().get("tps")}") }
.onFailure { err -> Bngeuit.replyGroupText(e.getGroupOpenid(), e.getId(), 1, "查询失败: ${err}") }
}
}自己的业务里 type 建议带前缀(如 myserver_tps),避免和内置的 mc_event、mc_command、bridge 撞车。
更多脚本 API 见 脚本与插件开发。
