跳到主要内容

内置示例 ​

一条完整的 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,各发一条消息:

字段值
typemc_event
actionplayer_join / player_quit
data.player玩家名
data.uuid玩家 UUID

通道没连上时直接丢弃、不刷日志 —— 上下线太频繁,断线期间记日志没意义。

想加新的事件类型(死亡、聊天、换世界…),改脚本开头的 EVENTS 列表即可,enable / disable 会自动跟着注册和注销:

nc
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
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 ​

关键配置在文件顶部:

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

处理逻辑:

nc
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
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 的消息后:

  1. 从 data.command 取出命令文本
  2. 检查首词是否在黑名单里(默认 stop、restart)
  3. 切主线程,用 Bukkit.dispatchCommand(控制台, 命令) 执行
  4. 把结果 reply 回去

回复的 data 结构:

json
{ "success": true }
json
{ "success": false, "error": "命令被拒绝: stop(在 MC 侧黑名单中)" }

几点行为要知道:

  • 命令以控制台身份执行,权限全放行,所以黑名单很重要
  • 命令自身的输出只进服务器控制台,不会回传到 QQ。群里只能看到「执行成功 / 未被执行」
  • 带命名空间的写法(minecraft:stop)会取冒号后一段再比对黑名单,绕不过去
  • 通道断开时只记日志、不执行

黑名单改脚本顶部这一行:

nc
val COMMAND_BLACKLIST = listOf("stop", "restart", "op", "deop", "ban")
完整源码:plugins/MC-BngeuitBridge/script/CommandRelay.nc
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

配置:

nc
// 允许触发的群(留空 = 不限制)
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 侧脚本可按需取用:

nc
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
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)}...(已截断)"
}

安全建议 ​

群内执行命令本质上是把服务器控制台交给群里的人,务必:

  1. 限制群:ALLOWED_GROUPS 只放管理群
  2. 限制人:ADMIN_MEMBER_OPENIDS(群)/ ADMIN_USER_OPENIDS(私聊)填死管理员 openid
  3. 两侧黑名单:MC 侧 COMMAND_BLACKLIST + QQ 侧 COMMAND_BLACKLIST,别只留 stop/restart,op、deop、ban、whitelist 按需加
  4. 多服环境: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]}
字段含义
versionBngeuitBridge 插件版本
uptimeSecondsBngeuitBridge 已运行秒数
connectedClients当前在线 MC 服务器数量
clientIds在线服务器列表

它是怎么实现的 ​

约定很简单:MC 侧发 type=bridge、action=status,Bngeuit 侧按 requestId 定向回复给查询方(不广播)。

nc
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
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,回一个数字):

nc
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,把回复发出去):

nc
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 见 脚本与插件开发。