MENU

【代码札记】从零开始的点对点聊天系统 P5

December 7, 2022 • 瞎折腾

本文继续介绍如何将前文的i2p-p2p-chat接入Minecraft中。这是本系列的最后一篇文章。

上文说

上文说到了启动节点。但是之后还得发消息,除了公屏发消息,还得有一个私信消息的入口,除此之外还要让用户能够在游戏内复制到他的I2P地址,然后发送给其他玩家,让其他玩家在游戏内连接到这个地址,这就引出了一个指令系统。最后为了便别哪些玩家连上了我们,我们还希望有一个地方能展示这些信息。

拦截消息

为了避免意外发送消息从而被举报/陷害(还记得一开始我提到的gaslighting漏洞吗),最好的办法当然就是直接拦截所有消息,除非用户使用特别的命令要求向MC的公屏中发送消息。经过一番搜索,最终发现在ClientPlayerEntity类中,sendChatMessageInternal方法是整个发送消息过程中最关键的一环,它接收各种形式的调用(例如用户在聊天框中使用回车,或者其他mod以用户的名字发送消息),将实际要发送的消息封装成数据包发送给服务器。也就是说,只要我们能够拦截这个方法,我们就可以终止消息发送,并获得待发送的消息。

得益于mixin的cancellable特性,我们可以指示mixin框架提前终止方法的调用,即插入我们自己的return指令:

@Mixin(ClientPlayerEntity.class)
public abstract class MixinClientPlayerEntity {

    @Inject(method = "sendChatMessageInternal", at = @At("HEAD"), cancellable = true)
    private void onSendChatMessageInternal(String message, Text preview, CallbackInfo ci) {

        if (ChatHelper.shouldSendPlain(message)) {
            // this message should be sent through minecraft's internal chat
            // stop further processing
            return;
        }

        if (CommandHelper.isCommand(message)) {
            CommandHelper.handleCommand(message);
        } else {
            // broadcast message
            var playerList = MinecraftHelper.getPlayerList();
            ClientModEntry.getPeer().sendRequest(
                    new TextMessageRequest("broadcast", message),
                    s -> s.useContextSync(c -> c.getUsername() != null && playerList.contains(c.getUsername()))
            );
            // show the message to ourselves
            ChatHelper.addBroadcastMessageToChat(
                    Objects.requireNonNull(MinecraftHelper.getCurrentUsername()), Text.literal(message)
            );
        }

        // cancel sending
        ci.cancel();
    }
}

这里有三个设计,首先是决定要不要通过MC原本的机制发送消息,如果是,则直接跳过我们下面的代码,进入MC原版的流程。然后是判断用户的输入是待发送的消息,还是输入给mod的指令。指令系统的设计将在下一节描述。最后是发送公屏消息,在原版MC中,消息发送给服务器,服务器转发给包括发送者在内的所有玩家,这个时候玩家自己也能看到消息。但在我们的Mod中,玩家并不与自己建立连接,因此把消息发送给其他人后,还要自己把发送的消息添加到聊天框中。

关于使用MC原本机制发送消息的判断,这里引入了如下机制:

    @JvmStatic
    private val plainMessagePendingQueue = ConcurrentHashMap<String, Int>()

    private fun addPlainMessage(message: String) {
        plainMessagePendingQueue.compute(message) { _, oldCount ->
            // add 1 if is old message, otherwise count as 1 for new messages
            oldCount?.let { it + 1 } ?: 1
        }
    }

    @JvmStatic
    fun shouldSendPlain(message: String): Boolean {
        var isFound = false
        plainMessagePendingQueue.compute(message) { _, oldCount ->
            oldCount?.let { count ->
                isFound = true
                if (count <= 1) {
                    // delete this
                    null
                } else {
                    // count down by 1
                    count - 1
                }
            }
        }
        return isFound
    }

    @JvmStatic
    fun sendPlainMessage(message: String) {
        addPlainMessage(message)
        MinecraftClient.getInstance()?.player?.sendChatMessage(message, null)
            ?: run {// failed to send message
                logger.warn { "Failed to get current player, thus, failed to send message: $message" }
                shouldSendPlain(message) // remove the count
            }
    }

首先是一个plainMessagePendingQueue队列,其实是一个Map,它记录了待发送消息的次数。例如玩家使用命令发送了5次“哈哈哈”,那只需要记录一个“哈哈哈”的键,其值为5即可。每次查询“哈哈哈”是否应当通过原版机制发送,都会引发计数器减一,减为0后直接删除。这一逻辑是通过sendPlainMessage调用addPlainMessage方法来保证的。这里的时序是这样的:用户通过指令发送一条消息,指令将待发送的消息添加到队列中,然后调用MC原版发送消息的逻辑,原版逻辑执行到我们的注入代码时,注入代码检测到待发送的消息在队列中,于是跳过注入,将控制权交还给原版机制。这里的重点在于我们总是先添加队列,再发送消息。而使用并发安全的Map则是因为触发指令的顺序是不固定的。

指令系统

MC本身有一套指令系统,可以直接拿来用,大多数模组也是这么用的。但这个是客户端模组,也就是说,你注册了原版的MC指令,但加入了服务器,而服务器不知道这个指令,那只有在单机下才能识别指令,加入服务器后指令就不可用了。我们当然不希望这样。但考虑到原版MC的指令也不过是一条特殊前缀的聊天消息,既然我们能够拦截聊天消息,为什么不能一块处理指令?

我们的指令以!开头。例如!msg player123 hahaha是让mod发送一条hahaha给玩家player123。当然,我们可以用when和字符串操作来匹配所有指令,但是这样的话整个mixin代码就会非常臃肿。和Lang一样,我们期望一种树状的指令(指令还可以有子指令,例如!i2p status这种,i2p包含了所有I2P相关的指令,而status则是查询状态的子指令)。但与Lang不同,Lang是一种静态资源,它的键在编译时期就已经固定在对应的json文件中了,所以应当使用sealed class来形成一棵静态的树;而指令则更像是一种动态产生的结构,换句话说,它会更频繁的修改,所以这里使用了类似DSL的方式去构造指令树。

描述指令的类很简单:

class MCACommand(
    val commandName: String,
    val description: String,
    val parameterList: List<Pair<String, String>> = emptyList(),
    val subCommands: Set<MCACommand> = emptySet(),
    actionInternal: ((String) -> Unit)? = null
) {
    private val action: (String) -> Unit = actionInternal ?: { message ->
        // ......
    }

    init {
        require(!commandName.contains(" ")) { "Command name contains space" }
        require(commandName != "help") { "Command help is a internal command" }
        if (actionInternal == null) {
            // contains sub command
            require(subCommands.isNotEmpty()) { "Commands without action should have subcommands" }
        }
    }

    operator fun invoke(parameter: String) = action.invoke(parameter)

    override fun equals(other: Any?): Boolean {
        if (this === other) return true
        if (other !is MCACommand) return false

        if (commandName != other.commandName) return false

        return true
    }

    override fun hashCode(): Int {
        return commandName.hashCode()
    }

}

每个指令有5个参数:指令名、描述、参数列表(参数名、描述)、子命令集和动作。每个指令是否相等是看指令名的,因为不同的指令不会出现在同一层,我们只需要保证一个set中的指令是唯一的即可。通过init方法可以看到,这些参数有一些限制:首先指令名不能包含空格;然后是指令名不能为help,这个指令稍后会说,它是一个自动生成的帮助命令;最后如果action为空(它是一个中间指令,例如!i2p status中的i2p),那么他的子指令必须不为空。

如果构造的时候动作为空,也就是说这个指令是一个中间指令,那么我们给他一个默认的动作来寻找对应的子指令:

    private val action: (String) -> Unit = actionInternal ?: { message ->
        var firstSpace: Int = message.indexOf(' ')
        if (firstSpace == -1) {
            firstSpace = message.length
        }
        val command = message.substring(0, firstSpace)

        subCommands.find { it.commandName == command }
            ?.let { it(message.substring(firstSpace).trimStart()) }
            ?: run {
                ChatHelper.addSystemMessageToChat(Text.literal("Unknown subcommand: $command"))
            }
    }

这里提取消息中的第一个命令,也就是第一个空格前面的内容,然后按照寻找与之匹配的子指令,然后把余下的部分交给子指令处理。也就是说,如果模组收到一个i2p status,那么会把i2p对应的指令匹配出来,把剩余的status交给I2P指令去处理,形成一个递归调用。考虑到指令应该不会太长,所以大概率不会爆栈。

接下来我们看看DSL语法。最终我们要得到一系列一级命令,而这些命令可能又有自己的子命令,二级三级等等,但那是一级命令本身去调用处理的,我们需要解析用户输入的消息,并找到这些一级命令。我们可以直接setOf(...),但是这样的话DSL不太好写,因为这里面可以执行任何命令,而我们想让DSL只在特定范围内执行。

为了解决这个问题,我们可以用buildSet {...}。与setOf不同之处在于,前者提供了一种操作Set本身的能力。使用setOf只是简单的把参数变成一个Set,而buildSet则传给你一个MutableSet,这个就是最终的结果,你可以在lambda里面增加、删除、对这个set做任何操作,因此我们可以在里面注册命令:

    private fun MutableSet<MCACommand>.registerCommand(
        commandName: String,
        description: String,
        parameterList: List<Pair<String, String>>,
        action: (String) -> Unit
    ) = require(
        this.add(
            MCACommand(
                commandName = commandName,
                description = description,
                parameterList = parameterList,
                actionInternal = action
            )
        )
    ) { "Duplicated command name: $commandName" }

这里我们注册了一个命令,使用起来类似这样:

        registerCommand(
            commandName = "foo",
            description = "test",
            parameterList = listOf()
        ) {
            logger.info { "Bar!" }
        }

当然,我们也可以注册子命令:

    private fun MutableSet<MCACommand>.registerCommand(
        path: String,
        description: String,
        block: MutableSet<MCACommand>.() -> Unit
    ) = require(
        this.add(
            MCACommand(
                commandName = path,
                description = description,
                subCommands = buildSet(block)
            )
        )
    ) { "Duplicated command name: $path" }

用起来是这样的:

        registerCommand(
            path = "i2p",
            description = "I2P related operations"
        ) {
            registerCommand(
                commandName = "regen",
                description = "generate a new i2p identity",
                parameterList = listOf()
            ) {
                ConfigFile.ModConfig.use { this.discardI2PKey() }
                ConfigFile.ModConfig.save()
                ChatHelper.addSystemMessageToChat(
                    Text.literal("Done. Please restart your game.")
                )
            }
        }

值得注意的是,注册中间命令的时候我们也用到了buildSet,也就是说,中间命令的子命令既可以是中间命令,也可以是具有动作的实际命令。只需要两个类似DSL的函数,我们就可以方便且灵活的定义命令了。

很优雅,不是么?

具体的命令就不一一介绍了,有兴趣的话可以参考这里的代码。这些指令提供了连接命令和将连接命令复制到剪切板的功能。接下来介绍一下处理命令的逻辑:

    @JvmStatic
    fun handleCommand(rawMessage: String) {
        if (!isCommand(rawMessage)) return
        val message = rawMessage.removePrefix(commandPrefix)
        // find first word
        var firstSpace: Int = message.indexOf(' ')
        if (firstSpace == -1) {
            firstSpace = message.length
        }
        val command = message.substring(0, firstSpace)

        if (command == "help") {
            // ......
        } else {
            commands.find { it.commandName == command }
                ?.let { it(message.substring(firstSpace).trimStart()) }
                ?: run {
                    ChatHelper.addSystemMessageToChat(Text.literal("Unknown command: $command"))
                }
        }
    }

首先需要确保传入的消息是!开头的,去掉开头的叹号,拿到第一个命令。如果是help的话,我们需要特殊处理一下。如果不是的话,那我们直接从命令列表里找命令,然后开始递归调用。

对于help命令,处理逻辑如下:

            val path = mutableListOf<MCACommand>()
            var searchScope = commands.toList()
            var rest = if (firstSpace >= message.length) "" else message.substring(firstSpace + 1)
            while (searchScope.isNotEmpty() && rest.isNotEmpty()) {
                val c = searchScope.filter { rest.startsWith(it.commandName) }.maxByOrNull { it.commandName.length }
                if (c != null) {
                    path.add(c)
                    searchScope = c.subCommands.toList()
                    rest = rest.substring(c.commandName.length).trim()
                } else {
                    val commandFullPath = path.joinToString(separator = " ", prefix = commandPrefix) { it.commandName }
                    ChatHelper.addSystemMessageToChat(
                        Text.literal(
                            "Command $commandFullPath don't have sub command ${rest.split(" ")[0]}"
                        )
                    )
                    return
                }
            }
            val commandFullPath = path.joinToString(separator = " ", prefix = commandPrefix) { it.commandName }
            if (path.isEmpty()) {
                // a !help with no parameter
                ChatHelper.addSystemMessageToChat(Text.literal(
                    "Commands:\n" + commands.joinToString("\n") {
                        "    " + it.commandName + ": " + it.description
                    }
                ))
            } else {
                if (path.last().subCommands.isNotEmpty()) {
                    ChatHelper.addSystemMessageToChat(Text.literal(
                        "Sub commands for $commandFullPath:\n" + path.last().subCommands.joinToString("\n") { "    ${it.commandName}: ${it.description}" }
                    ))
                } else {
                    val c = path.last()
                    ChatHelper.addSystemMessageToChat(
                        Text.literal(
                            "Command $commandFullPath ${
                                c.parameterList.joinToString(" ") { "<${it.first}>" }
                            }: " + c.description + "\n" + c.parameterList.joinToString("\n") { "    ${it.first}: ${it.second}" }
                        )
                    )
                }
            }

首先有个path来保存搜索的路径。!help会打印所有可用的一级命令,而!help i2p会打印出i2p命令的所有子命令,而!help i2p status会打印关于status子命令的帮助。但status并不知道它之前的命令是什么,因此我们需要想办法保存下来。searchScope是当前搜索的范围,默认从一级命令开始搜索,如果匹配到了二级命令,那就用二级命令的子命令作为搜索范围,以此类推。rest则是剩余的消息,例如!help i2p status的rest首先是i2p status,在搜索到i2p后,rest就变成了status,最后变成空字符串,搜索结束。之后的代码就相当于把递归调用转换成了循环,不断搜索匹配到的命令,并保存到path中。如果中间没有找到命令,则直接提示失败并提前返回。

搜索结束后,如果path是空的,则直接打印所有一级命令即可。如果不是的话,取决于最后一个命令的类型:如果是一个具体的命令,那就打印它的参数列表和描述;如果是一个中间命令,那就打印它所有的子命令。

至此,让这个模组正常运转的功能就都已经实现了。使用模组的时候可以加入一个服务器,然后使用!gen命令,就会把对应的连接命令复制到剪切板,你可以通过微信等方法发送给你的朋友,然后你的朋友直接在聊天框中贴入,这个会作为!connect命令执行。这样你和朋友就可以连接并发送消息了。借助PEX,你和你的朋友会自动交换双方已经连上的节点,从而省去了和其他玩家一一交换connect命令的麻烦。同时,模组会定期保存已连接的会话地址。当你下次上线时,模组会自动从缓存中找到你朋友的地址并连接——再也不需要!connect命令了。按照设计,这个模组随着使用时间增长,体验也会变得越来越好。

增强体验

话是这么说,但有没有可能让体验更进一步呢?

玩家列表

比如说多人模式下按Tab,可以列出玩家列表。能不能在这个玩家列表中标注出谁与我相连了呢?当然可以!

阅读了一些mojang的屎山之后,我发现可以借助PlayerListHud类的getPlayerName方法来实现。这个方法原本是针对每个待显示玩家名称进行装饰的。也就是说,每一个玩家名在显示前都会经过这个方法处理,得到的就是即将显示在玩家列表中的名字。修改如下:

@Mixin(PlayerListHud.class)
public abstract class MixinPlayerListHud {
    @Inject(method = "getPlayerName", at = @At("RETURN"), cancellable = true)
    private void onGetPlayerName(PlayerListEntry entry, CallbackInfoReturnable<Text> cir) {
        var text = cir.getReturnValue();
        var realName = entry.getProfile().getName();

        if (realName.equals(MinecraftHelper.getCurrentUsername())) {
            cir.setReturnValue(Text.translatable("%s (%s)", text, Text.literal("self")));
            cir.cancel();
            return;
        }

        try {
            // potential error: getPeer() not initialized
            var peerSession = ClientModEntry.getPeer().dumpSessions().stream()
                    .filter(s -> !s.isClosed() && s.useContextSync(c -> realName.equals(c.getUsername())))
                    .findAny();

            if (peerSession.isPresent()) {
                var source = peerSession.get().useContextSync(MCAContext::getSessionSource);
                var direction = "";
                switch (source) {
                    case CLIENT -> direction = "out";
                    case SERVER -> direction = "in";
                }
                cir.setReturnValue(Text.translatable("%s (%s)", text, Text.literal(direction)));
                cir.cancel();
            }
        } catch (Throwable ignored) {

        }
    }
}

这里写的比较粗糙,因为我默认用户名就是玩家名,但像是Hypixel这种会修改玩家名的,我不知道用起来是什么样的,大概率会不好使吧。

总之这段代码的做用就是判断给定的用户名是否与已知的会话匹配,如果匹配则标注出方向(出入站,即谁先发起的连接),不匹配的话就什么也不做。这样当我们按下Tab时,就可以直接从玩家列表中看到谁与我们相连了。

聊天内容检测地址

前面说过,玩家可以通过!gen命令获得连接命令,然后发送给其他玩家。但有时候大家在一起玩,还没有好到要加微信的地步,这怎么办呢?我们可以从过MC本身发送这个命令,但是70个字符长的地址,MC又没有从聊天框复制内容的能力,你指望用户一个一个敲?这不好吧。如果我们可以监听聊天消息,在搜索到地址的时候提示用户,这岂不是更好一些?

又看了很久mojang的屎山,我发现MessageHandler类的onChatMessage可以用于截获收到的消息。这里我们不对消息进行修改,只是进行监听:

@Mixin(MessageHandler.class)
public abstract class MixinMessageHandler {

    @Inject(method = "onChatMessage", at = @At("RETURN"))
    private void onChatMessage(SignedMessage message, MessageType.Parameters params, CallbackInfo ci) {
        var text = message.signedBody().content().plain();
        if (text.contains(".b32.i2p")) {
            // the b32 address: {52 chars}.b32.i2p
            var prefix = text.split("\\.b32\\.i2p")[0];
            if (prefix.length() >= 52) {
                var b32 = prefix.substring(prefix.length() - 52) + ".b32.i2p";
                if (b32.equals(ClientModEntry.getPeer().getMyB32Address())) {
                    // is ourselves
                    return;
                }
                ChatHelper.addSystemMessageToChat(Text.translatable(
                        "%s sent a b32 address in chat, %s",
                        params.name(),
                        Text.literal("click [here] to connect").fillStyle(Style.EMPTY.withClickEvent(
                                new ClickEvent(
                                        ClickEvent.Action.SUGGEST_COMMAND,
                                        "!connect " + b32
                                )
                        ))
                ));
            }
        }
    }
}

如果我们在聊天消息中发现了I2P的b32地址,也就是包含在connect命令里面的地址,那我们就插入一条消息,告诉用户。并且利用ClickEvent功能让用户在点击消息的时候,自动生成一个connect命令,用户直接回车就可以连接。岂不美哉?

后记

这一个系列到此为止终于完结了。可喜可贺,可喜可贺。

接下来我可能会玩一玩kotlinx coroutine相关的东西,也可能深入一下I2P,谁知道呢。我对未来之事无法给出任何保证,因为我是好奇心驱动的。也许过几天看到了更加感兴趣的事情也说不定。

总之,感谢大家的阅读,我们下一篇文章见~(考虑到我每月一更的习惯,可能得明年1月了,LOL)

-全文完-


知识共享许可协议
【代码札记】从零开始的点对点聊天系统 P5天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code