MENU

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

December 6, 2022 • 瞎折腾

本文介绍如何将前文的i2p-p2p-chat接入Minecraft中。

准备工作

前文中我们完成了i2p-p2p-chat的编写,这个库封装了所有基于I2P的点对点通讯的操作。本文中,我们需要在Minecraft这部分做出修改。

首先是要找到拦截和注入消息的位置,拦截到的消息将有我们的模组发送,而模组接收到的消息需要注入到Minecraft原本的聊天框中。然后就是想办法引入一套命令系统:公屏聊天当然不需要,但我们还得能够私聊,况且我们还得想办法让用户分享他们自己的地址。最后我们还得搞一套简单的配置系统,用来持久化地存储我们的私钥和曾经见过的节点。

配置系统

配置系统应该是整个模组最简单的部分了。我们的模组有两个需要存储的配置:一个是控制模组行为的配置文件;另一个则是存储见过的节点。前者无需过多解释,对于后者,建立这种缓存是为了当我们在服务器中遇到以前见过的用户名,则可以直接向过去见过的地址发起连接。因为我们的隧道私钥是随Minecraft实例持久化保存的,除非用户选择刷新私钥或者更换了实例,在大多数情况下,我们应该可以通过旧的地址直接连上用户,从而跳过了PEX和手工分享地址。这也使得整个mod的体验随着使用时间增加而变好。

配置文件的格式当然是JSON,虽然后者用KV数据库性能会更好,但是我的宗旨是能不用数据库就不用数据库,最好一个map就能解决。两个配置的模型定义如下:

@Serializable
class MCAConfig(
    @Serializable(with = ByteArrayAsBase64Serializer::class)
    private var _i2pKey: ByteArray = I2PHelper.generateDestinationKey(),

    @Serializable(with = DurationSerializer ::class)
    var pexInterval: Duration = Duration.ofSeconds(45),

    @Serializable(with = DurationSerializer::class)
    var lastSeenUpdateInterval: Duration = Duration.ofMinutes(3),

    @Serializable(with = DurationSerializer::class)
    var sessionRemoverInterval: Duration = Duration.ofMinutes(2),

    @Serializable(with = DurationSerializer::class)
    var autoConnectInterval: Duration = Duration.ofSeconds(15),
) {
    val i2pDestinationKey
        get() = _i2pKey

    fun discardI2PKey() {
        _i2pKey = I2PHelper.generateDestinationKey()
    }
}

@Serializable
class PeerRepoEntry(
    @Serializable(with = DestinationSerializer::class)
    var destination: Destination,
    @Serializable(with = ZonedDateTimeAsTimestampSerializer::class)
    private var _lastSeen: ZonedDateTime
) {
    val lastSeen
        get() = _lastSeen

    fun seen(dest: Destination) {
        destination = dest
        _lastSeen = ZonedDateTime.now()
    }
}

其中MCAConfig就是控制行为的配置文件了。里面的_i2pKey就是我们I2P隧道的私钥,他的类型是ByteArray,但实际用的时候需要转换成Destination,于是就有了后面的i2pDestinationKey,当然,为了实现刷新功能,还有discardI2PKey方法。其他的各种Interval则决定了定时任务的执行间隔,比如多久进行一次PEX,多久记录一次最后见到的节点信息,多久进行一次会话检查(关闭不在同一个服务器中的会话),多久尝试一次从缓存中连接节点。

下面的PeerRepoEntry就是一个节点缓存条目。它记录了节点的地址和最后一次见到的时间。这里本来想实现一个简单的最久淘汰策略,但是后来给忘了。不过忘了也不会有太严重的后果,一个条目不会超过1KB,因此见过一千个玩家才占用1MB空间,见过一百万个玩家才占用1GB空间。这个文件可以随时被删掉,唯一的影响就是下一次你见到这个玩家,mod不会自动连接了。

有了模型,我们得想办法提供一个与配置文件交互的接口。这里我们选择使用sealed class来实现。

首先我们需要一个能够输出多行的Json:

private val json = Json {
    prettyPrint = true
    ignoreUnknownKeys = true
    encodeDefaults = true
}

漂亮打印使得用户能够以人类可读的形式修改配置;而忽略未知的键则有助于提供额外的兼容性:让我们在后续版本废弃了一些选项,JSON并不会因为这些已经废弃的选项而报错;最后的编码默认值则是将我们的默认值打印到配置文件中,以便用户修改。我们的sealed class定义如下:

sealed class ConfigFile<T>(
    private val filename: String,
    private val serializer: KSerializer<T>,
    defaultValue: T,
) {
    /**
     * Current value. It use default value unless we overwrite it when [loadOrCreate]
     * */
    private var value: T = defaultValue

    /**
     * The actual [Path] for this config file.
     * */
    private val configPath = FabricLoader.getInstance().configDir.resolve(this.filename)

    init {
        reload()
        // write the latest
        save()
    }

    /**
     * Load from disk, create with default value if not exist.
     * */
    private fun loadOrCreate(): T {
        // ensure we don't read and write against the same config at the same time
        synchronized(this) {
            return if (Files.exists(configPath)) {
                // read it
                json.decodeFromString(serializer, configPath.readText())
            } else {
                //create it
                @Suppress("BlockingMethodInNonBlockingContext")
                Files.createDirectories(configPath.parent)
                configPath.writeText(json.encodeToString(serializer, value))
                value
            }
        }
    }

    /**
     * Use the config sync.
     * */
    fun <R> use(block: T.() -> R): R {
        synchronized(this) {
            return block(value)
        }
    }

    /**
     * Discard changes and reload latest from disk.
     * Same as [loadOrCreate]
     * */
    fun reload() {
        value = loadOrCreate()
    }

    /**
     * Save the current value info disk.
     * */
    fun save() {
        synchronized(this) {
            configPath.writeText(json.encodeToString(serializer, value))
        }
    }
}

ConfigFile有三个构造函数:文件路径、序列化器和一个默认值。每个实例都有一个value,默认是默认值。在实例化过程中,先尝试从磁盘中载入配置,如果配置存在,则从配置中解析并代替默认值;如果文件不存在,则将默认值的内容写入文件,并返回默认值。除了常用的重载和保存,还提供了一个线程安全的use方法。这个方法保证对于value的访问是同步的,这样我们就不必为忘记加锁而烦恼了。

具体的两个实例如下:

    object ModConfig : ConfigFile<MCAConfig>(
        filename = "minecraft-chat-alternative/config.json",
        serializer = MCAConfig.serializer(),
        defaultValue = MCAConfig()
    )

    @Suppress("OPT_IN_USAGE")
    object PeerRepo : ConfigFile<MutableMap<String, PeerRepoEntry>>(
        filename = "minecraft-chat-alternative/peer_repo.json",
        serializer = object : KSerializer<MutableMap<String, PeerRepoEntry>> {
            val delegate = MapSerializer(String.serializer(), PeerRepoEntry.serializer())

            override val descriptor: SerialDescriptor = SerialDescriptor(
                "MutableMap<String, PeerRepoEntry>", delegate.descriptor
            )

            override fun deserialize(decoder: Decoder): MutableMap<String, PeerRepoEntry> {
                return decoder.decodeSerializableValue(delegate).toMutableMap()
            }

            override fun serialize(encoder: Encoder, value: MutableMap<String, PeerRepoEntry>) {
                encoder.encodeSerializableValue(delegate, value)
            }
        },
        defaultValue = mutableMapOf()
    ) {
        /**
         * Update from session list.
         * */
        fun updateLastSeenFromSessions(sessions: List<PeerSession<MCAContext>>) {
            sessions.forEach { s ->
                if (s.isClosed()) return@forEach
                val username = s.useContextSync { username } ?: return@forEach
                this.use {
                    if (containsKey(username))
                        get(username)!!.seen(s.getPeerDestination())
                    else
                        put(username, PeerRepoEntry(s.getPeerDestination(), ZonedDateTime.now()))
                }
            }
        }
    }

MCAConfig那个挺简单的,后面那个PeerRepo就有一些复杂了。对于Map类型,在Java中没有过多的区分,但kotlin这边会区分是只读Map还是可修改的MutableMap。而从json反序列化出来的都是Map(因为内置的MapSerializer)要解决这个问题,有两种方法:一种是实现一个自定义序列化器,底层委托给MapSerializer;另一种则是修改sealed class,加一个mapper,对反序列化结果进行处理。按道理来说,后者应该更好一些(但可能需要更复杂的类型,例如KSerializer<U>(U) -> T),但不知道怎么回事,当时我选了第一种方法。

会话上下文

虽然有了配置系统,但我们还没办法直接启动节点:我们还需要一个上下文来保存会话状态。好在这个mod并不需要什么复杂的状态:

class MCAContext(
    override val sessionSource: SessionSource
) : SessionContext {

    var username: String? = null
        private set

    override val nickname: String? by this::username

    override var peerInfo: PeerInfo? = null
        set(value) {
            username = value?.getUsername()
            field = value
        }

    private var authAccepted: Boolean = false

    override fun onAuthAccepted() {
        authAccepted = true
    }

    override fun isAccepted(): Boolean = authAccepted

}

这里面有个username,其实用nickname也无妨,但是我觉得username对于MC来说更贴切一些,于是使用属性代理把对nickname的访问变成对username的访问。

证书签名与验证

等一下!你以为有了上下文就可以启动节点了?在此之前我们还要处理一个问题:怎么用Mojang的证书来签名我们的PeerInfo,以及用它来验证别人发来的PeerInfo呢?

签名倒是好办:

private fun createPeerInfo(peer: Peer<MCAContext>): PeerInfo {
    val (session, profileKeys) = MinecraftClient.getInstance()
        ?.let { it.session to it.profileKeys }
        ?: error("Minecraft instance is null")
    val (username, uuid) = session
        ?.let { (it.username ?: error("Cannot get current username")) to it.uuidOrNull }
        ?: error("Minecraft session is null")

    val (pubKey, signer) = profileKeys
        ?.let {
            (it.publicKey.orElse(null) ?: error("Cannot get player's public key")
                    ) to (it.signer ?: error("Cannot get signer"))
        }
        ?: error("Cannot get profile key")

    val info = mapOf<String, String>(
        PeerInfo.INFO_KEY_DEST to peer.getMyDestination().toBase64(),
        "minecraft.username" to username,
        "minecraft.profile_uuid" to (uuid?.toString() ?: error("Player's uuid is null")),
    )
    val dataToSign = PeerInfo.getDataForSign(info)
    val signature = signer.sign(dataToSign)
    return PeerInfo(
        info = info,
        signatureBase64 = Base64.getEncoder().encodeToString(signature),
        publicKeyBase64 = pubKey.toBase64()
    )
}

你会发现这里除了用户名,我们还额外加了一个profile uuid。这个东西是你每次使用正版登录,mojang随机给你分配的一个uuid,并不保证和你的账号绑定,但一般情况下是一一对应的,而且可以凭这个uuid在mojang的服务中代表你这个正版账号。

private fun verifyPeerInfo(peerInfo: PeerInfo): Boolean {
    try {
        val profileUUID = peerInfo.info["minecraft.profile_uuid"]
            ?.let { UUID.fromString(it) } ?: return false
        val publicKeyData = peerInfo.getPublicKeyBytes().paresPublicKeyData()
        val pubKey = MinecraftClient.getInstance()?.let {
            PlayerPublicKey.verifyAndDecode(
                it.servicesSignatureVerifier,
                profileUUID, publicKeyData,
                PlayerPublicKey.EXPIRATION_GRACE_PERIOD
            )
        } ?: error("Minecraft instance is null")

        val verifier = pubKey.createSignatureInstance()
        val payload = peerInfo.getInfoBencodeBytes()
        val signature = peerInfo.getSignatureBytes()
        return verifier.validate(payload, signature)
    } catch (_: Throwable) {
        // if any error, invalid peer info
        return false
    }
}

你会发现,在验证过程中我们调用了MC的代码,这里面用到了uuid,而非用户名。

有了签名和验证节点信息的能力,我们就可以启动节点了,对吧?

i18n与注入

我知道你很急,但是你先别急,一会儿有你急的。

我们启动节点之前要提供一个Handler,在接收到TextMessage的时候需要调用我们的代码。而我们的代码需要把接收到的消息注入到MC原本的聊天框中。但是我们不能楞把消息插到聊天框里,我们想要一种格式,例如:

[I2p] Hahaha this is a message!

类似这种,我们希望来自I2P的消息与来自MC的消息区分开。实现这种效果最简单的方式就是用String format,但是直接把格式写死在代码里好像不太好,例如别人发送私信给我们的时候:

[I2P] whispers to you: This is a private message!!!

如果是中文的话,我们想翻译成:

[I2P] 私信对你说:This is a private message!!!

i18n

这显然是硬编码格式做不到的。但好在Mojang也考虑到了这一点,引入了Lang。我们可以在en_us.json中写英文的格式,在zh_cn.json中写中文的格式,只要符合对应的语言代码,我们可以翻译成任何一种语言:

{
  "chat.mca.system": "[MCA] %s",
  "chat.mca.system.narrate": "MCA: %s",
  "chat.mca.i2p.broadcast": "[I2P] <%s> %s",
  "chat.mca.i2p.broadcast.narrate": "%s on I2P says %s",
  "chat.mca.i2p.incoming": "[I2P] <%s> whispers to you: %s",
  "chat.mca.i2p.incoming.narrate": "%s on I2P whispers to you: %s",
  "chat.mca.i2p.outgoing": "You whisper to [I2P] <%s>: %s",
  "chat.mca.i2p.outgoing.narrate": "You whisper to %s on I2P: %s",
  "text.mca.system.connection.incoming": "Accept connection from %s",
  "text.mca.system.connection.outgoing": "Connected to %s",
  "text.mca.system.connection.disconnected": "Disconnected by %s, because %s",
  "text.mca.system.connection.socket_closed": "Socket with %s closed",
  "text.mca.system.error.general": "Error occurred, see log for more info",
  "text.mca.system.error.peer_stopped": "I2P Peer stopped. This should never have happened.",
  "text.mca.system.error.on_error_reply": "Player %s replies an error to your previous request. See log for more info."
}

这个JSON看起来挺结构化的,但是用起来的话完全就是个字符串。例如我需要引用收到私信的格式时,我需要在代码中指定chat.mca.i2p.incoming这个键。这种字符串没法保证这个翻译键存在,而这个键明显有一种树状结构,所以,老规矩,sealed class:

sealed class Lang(
    vararg path: String
) {
    private val fullPath: String = path.joinToString(".")

    protected fun translate(vararg args: Any): MinecraftText {
        require(Language.getInstance().get(fullPath) != fullPath) { "Missing translation key: $fullPath" }
        return MinecraftText.translatable(fullPath, *args)
    }
}

这个sealed class接收一个path数组,用来表示完整的key。例如chat.mca.i2p.incoming就变成了["chat", "mca", "i2p", "incoming"],而后者可以通过树形结构产生,例如表示Chat类的有一个chat.mca前缀,而Chat的子类有i2p的前缀,借助vararg,我们可以这么写:Lang("chat.mca", *path),在Chat类实例化的过程中,把自己的前缀加到前面,然后把子类的路径放在后面,有点类似递归的意思。而那个translate方法则起到了一个检查的效果,如果引用了未找到的翻译键,那么直接报错引发崩溃。

具体来说,Lang下面有两个子类,一个是直接显示到聊天框里面的chat.mca.*,另一类是作为聊天消息的一部分解析的text.mca.*。对于聊天类消息:

 sealed class ChatMessage(
        vararg path: String
    ) : Lang("chat.mca", *path) {

        sealed class SystemMessage(
            vararg path: String
        ) : ChatMessage("system", *path) {
            object Text : SystemMessage()
            object Narrate : SystemMessage("narrate")

            fun translate(message: MinecraftText): MinecraftText =
                super.translate(message)
        }

        sealed class I2PMessage(
            vararg path: String
        ) : ChatMessage("i2p", *path) {

            sealed class Broadcast(
                vararg path: String
            ) : I2PMessage("broadcast", *path) {
                object Text : Broadcast()
                object Narrate : Broadcast("narrate")

                fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
                    super.translate(username, content)
            }

            sealed class Incoming(
                vararg path: String
            ) : I2PMessage("incoming", *path) {
                object Text : Incoming()
                object Narrate : Incoming("narrate")

                fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
                    super.translate(username, content)
            }

            sealed class Outgoing(
                vararg path: String
            ) : I2PMessage("outgoing", *path) {
                object Text : Outgoing()
                object Narrate : Outgoing("narrate")

                fun translate(username: MinecraftText, content: MinecraftText): MinecraftText =
                    super.translate(username, content)
            }
        }
    }

这里面基本上就是对Lang的建模。对于聊天消息,除了显示成文本的键,例如chat.mca.i2p.broadcast,还有对应的需要讲述人念出来的键chat.mca.i2p.broadcast.narrate。对于chat.mca.i2p.broadcast,作为一个可以被引用的键,它应该是Object,但它底下又有一个narrate,它得是sealed class。为了解决这个问题,引入了一个名叫Text,但path为空的object,从而保证Text的路径和父对象的路径相同,而与Text同级的Narrate则用于.narrate后缀。此外每个object还都有一个translate方法,这个调用了超类的translate,保证对引用键的检查,同时用代码约束了参数。

类似地,具有text.mca.*前缀的TextMessage也是如此,只不过没有了narrate,更加简单一些:

    sealed class TextMessage(
        vararg path: String
    ) : Lang("text.mca", *path) {
        
        sealed class SystemMessage(
            vararg path: String
        ) : TextMessage("system", *path) {

            sealed class Connection(
                vararg path: String
            ) : SystemMessage("connection", *path) {

                object Incoming : Connection("incoming") {
                    fun translate(username: MinecraftText): MinecraftText =
                        super.translate(username)
                }

                object Outgoing : Connection("outgoing") {
                    fun translate(username: MinecraftText): MinecraftText =
                        super.translate(username)
                }

                object Disconnected : Connection("disconnected") {
                    fun translate(username: MinecraftText, reason: MinecraftText): MinecraftText =
                        super.translate(username, reason)
                }

                object SocketClosed : Connection("socket_closed") {
                    fun translate(username: MinecraftText): MinecraftText =
                        super.translate(username)
                }
            }

            sealed class Error(
                vararg path: String
            ) : SystemMessage("error", *path) {

                object General : Error("general") {
                    fun translate(): MinecraftText = super.translate()
                }

                object PeerStopped : Error("peer_stopped") {
                    fun translate(): MinecraftText = super.translate()
                }

                object PeerReportedError : Error("on_error_reply") {
                    fun translate(username: MinecraftText): MinecraftText =
                        super.translate(username)
                }
            }
        }
    }

当然,这里需要注意的就是不要引用错父类,不然整个路径就都变了。

此外,定义sealed class不一定要定义在父类里面,但这样有一个好处,使得我们可以用类似键的语法来使用:

Lang.ChatMessage.I2PMessage.Outgoing.Text.translate(......)

这就像是chat.mca.i2p.outgoing,我觉得很好。

消息注入

有了这个方便的i18n接口,我们就可以考虑消息注入了。经过一番摸索,可以使用如下代码插入一条消息:

    fun addRawMessage(message: Text, narrateMessage: Text) {
        val minecraft = MinecraftClient.getInstance()!!
        minecraft.inGameHud?.chatHud?.addMessage(message)
        minecraft.narratorManager?.narrateChatMessage { narrateMessage }
    }

当然,narrator是可选的,不过既然原版MC都做了,我们也不应该忽略。每个人都应当能够享受游戏的乐趣,对吧?

这个方法只是将一条消息插入到聊天框中,为了应用格式,我们可以针对不同的消息类型使用不同的方法,例如:

    fun addSystemMessageToChat(content: Text) {
        val textChatComponent = Lang.ChatMessage.SystemMessage.Text
            .translate(content)
        val narrateChatComponent = Lang.ChatMessage.SystemMessage.Narrate
            .translate(content)
        addRawMessage(textChatComponent, narrateChatComponent)
    }

    fun addBroadcastMessageToChat(username: String, content: Text) {
        val textChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Text.translate(
            Text.literal(username).fillStyle(
                Style.EMPTY.withClickEvent(
                    ClickEvent(
                        ClickEvent.Action.SUGGEST_COMMAND,
                        String.format("!dm %s ", username)
                    )
                )
            ), content
        )
        val narrateChatComponent = Lang.ChatMessage.I2PMessage.Broadcast.Narrate.translate(
            Text.literal(username), content
        )
        addRawMessage(textChatComponent, narrateChatComponent)
    }

诸如此类的。

启动节点

好了,现在可以考虑启动节点了。我们可以在mod加载时启动节点并启动各种定时任务:

@Environment(EnvType.CLIENT)
object ClientModEntry : ClientModInitializer {
    private val logger = KotlinLogging.logger { }
    private val json = Json {
        ignoreUnknownKeys = true
        serializersModule = getSerializersModule()
    }
    private val threadPool = I2PHelper.createThreadPool(Runtime.getRuntime().availableProcessors())
    private var peer: Peer<MCAContext>? = null

    @JvmStatic
    fun getPeer(): Peer<MCAContext> = peer ?: error("Peer not initialized")

    override fun onInitializeClient() {
        logger.info { "Hello!" }

        logger.info { "Creating peer..." }
        peer = createPeer(
            json = json, username = MinecraftHelper.getCurrentUsername()!!,
            protocolName = "minecraft-chat-alternative-protocol-20221121",
            destinationKey = ConfigFile.ModConfig.use { i2pDestinationKey },
            threadPool = threadPool, pexInterval = ConfigFile.ModConfig.use { pexInterval }
        )
        logger.info { "Peer created!" }

        // start doing things with that peer
        // update last seen
        runTask({ lastSeenUpdateInterval }) { peer ->
            ConfigFile.PeerRepo.updateLastSeenFromSessions(peer.dumpSessions())
            ConfigFile.PeerRepo.save()
        }
        // remove invalid session
        runTask({ sessionRemoverInterval }) { peer ->
            peer.disconnect("not in same server") {
                val username = it.useContextSync { username } ?: return@disconnect false
                username !in MinecraftHelper.getPlayerList()
            }
        }
        // connect from peer repo
        runTask({ autoConnectInterval }) { peer ->
            val alreadyConnected = peer.dumpSessions().mapNotNull { it.useContextSync { username } }
            ConfigFile.PeerRepo.use {
                entries.filter {
                    it.key in MinecraftHelper.getPlayerList()
                            && it.key !in alreadyConnected
                }.forEach { entry ->
                    I2PHelper.runThread {
                        try {
                            peer.connect(entry.value.destination)
                        } catch (_: Throwable) {
                        }
                    }
                }
            }
        }
    }

    private fun runTask(duration: MCAConfig.() -> Duration, block: (Peer<MCAContext>) -> Unit) {
        I2PHelper.runThread {
            val peer = getPeer()
            val interval = ConfigFile.ModConfig.use { duration() }.toMillis()
            while (!peer.isClosed()) {
                MinecraftHelper.sleep(interval)
                block(peer)
            }
            logger.warn { "Peer stopped! Trigger crash" }
            MinecraftHelper.crash(IllegalStateException(Lang.TextMessage.SystemMessage.Error.PeerStopped.translate().string))
        }
    }
}

这里我们启动节点,并拉起了一系列定时运行的任务,例如每隔一段时间就记录当前已经连接的会话;每隔一段时间与不在玩家列表中的玩家断开连接;定时从缓存的列表中寻找玩家地址并连接。

在创建节点时,主要的Handler有这些:

    // for AuthRequest
    createMessageHandlerWithReply { _, session, messageId, payload: AuthRequest, _ ->
        if (verifyPeerInfo(payload.peerInfo)) {
            val username = payload.peerInfo.getUsername()
            if (username in MinecraftHelper.getPlayerList()) {
                logger.info { "Authed ${session.getDisplayName()} as $username" }
                ChatHelper.addSystemMessageToChat(
                    Lang.TextMessage.SystemMessage.Connection.Incoming
                        .translate(Text.literal(payload.peerInfo.getUsername()))
                )
                AuthAcceptedReply(messageId)
            } else {
                // not in the same server
                AuthenticationFailedError("Not in the same server", messageId)
            }
        } else {
            AuthenticationFailedError("Verification failed", messageId)
        }
    },
    // for AuthAcceptedReply
    createMessageHandlerNoReply { _, session, _: AuthAcceptedReply, _ ->
        ChatHelper.addSystemMessageToChat(
            Lang.TextMessage.SystemMessage.Connection.Outgoing
                .translate(Text.literal(session.useContextSync { username }))
        )
    },
    // for TextRequest
    createMessageHandlerWithReply { _, session, messageId, payload: TextMessageRequest, _ ->
        val username = session.useContextSync { username } ?: error("Unauthorized message")
        when (payload.scope) {
            "broadcast" -> ChatHelper.addBroadcastMessageToChat(
                username, Text.literal(payload.content)
            )

            "whisper" -> ChatHelper.addIncomingMessageToChat(
                username, Text.literal(payload.content)
            )

            else -> logger.warn { "Unknown message scope '${payload.scope}', content: ${payload.content}" }
        }
        NoContentReply(messageId)
    },
    // for ByeRequest
    createMessageHandlerNoReply { _, session, payload: ByeRequest, _ ->
        ChatHelper.addSystemMessageToChat(
            Lang.TextMessage.SystemMessage.Connection.Disconnected
                .translate(
                    Text.literal(session.getDisplayName()),
                    Text.literal(payload.reason)
                )
        )
    },

大多数回复都是在聊天框中打印对应的提示信息,比如收到了谁的消息,谁断开了连接,我们连上了谁,谁连上了我们,之类的。在验证部分,除了确保对方是正版账号之外,还得确认他确实和我们处于同一个世界/服务器,不然真就跨服聊天了。

后续

考虑到篇幅,这一篇文章中引用了不少代码,而余下的内容还有不少。主要是拦截消息和一套指令系统,还有一些提升体验的mixin,这些我打算留到P5再写。

那么这篇文章先到此为止,我们下一篇再见。

-全文完-


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

Archives QR Code
QR Code for this page
Tipping QR Code