diff --git a/mirai-console/backend/integration-test/src/IntegrationTestBootstrap.kt b/mirai-console/backend/integration-test/src/IntegrationTestBootstrap.kt
index b86a212c0..cbdaca7bb 100644
--- a/mirai-console/backend/integration-test/src/IntegrationTestBootstrap.kt
+++ b/mirai-console/backend/integration-test/src/IntegrationTestBootstrap.kt
@@ -54,6 +54,7 @@ internal fun main() {
             error("Don't launch IntegrationTestBootstrap directly. See /test/MiraiConsoleIntegrationTestBootstrap.kt")
         }
     }
+    System.setProperty("mirai.console.skip-end-user-readme", "")
     // @context: env.testunit = true
     // @context: env.inJUnitProcess = false
     // @context: env.exitProcessSafety = true
diff --git a/mirai-console/backend/mirai-console/build.gradle.kts b/mirai-console/backend/mirai-console/build.gradle.kts
index 6598fcbeb..ef5813ffa 100644
--- a/mirai-console/backend/mirai-console/build.gradle.kts
+++ b/mirai-console/backend/mirai-console/build.gradle.kts
@@ -103,6 +103,10 @@ tasks {
     }
 }
 
+tasks.withType<Test> {
+    this.jvmArgs("-Dmirai.console.skip-end-user-readme")
+}
+
 tasks.getByName("compileKotlin").dependsOn(
     DependencyDumper.registerDumpTaskKtSrc(
         project,
diff --git a/mirai-console/backend/mirai-console/compatibility-validation/jvm/api/jvm.api b/mirai-console/backend/mirai-console/compatibility-validation/jvm/api/jvm.api
index c4f2b7e31..f91dec8db 100644
--- a/mirai-console/backend/mirai-console/compatibility-validation/jvm/api/jvm.api
+++ b/mirai-console/backend/mirai-console/compatibility-validation/jvm/api/jvm.api
@@ -1287,6 +1287,29 @@ public final class net/mamoe/mirai/console/data/java/JavaAutoSavePluginData$Comp
 	public final fun createKType (Ljava/lang/Class;[Lkotlin/reflect/KType;)Lkotlin/reflect/KType;
 }
 
+public final class net/mamoe/mirai/console/enduserreadme/EndUserReadme {
+	public static final field Companion Lnet/mamoe/mirai/console/enduserreadme/EndUserReadme$Companion;
+	public static final field DELAY Ljava/lang/String;
+	public static final field PAUSE Ljava/lang/String;
+	public fun <init> ()V
+	public final fun put (Ljava/lang/String;Lkotlin/jvm/functions/Function1;)V
+	public final fun putAll (Ljava/lang/String;)V
+}
+
+public final class net/mamoe/mirai/console/enduserreadme/EndUserReadme$Companion {
+}
+
+public final class net/mamoe/mirai/console/enduserreadme/EndUserReadme$Render {
+	public fun <init> ()V
+	public final fun delay ()V
+	public final fun delay (I)V
+	public final fun msg (Ljava/lang/String;)V
+	public final fun pause ()V
+	public final fun plusAssign (Ljava/lang/String;)V
+	public final fun render ()Ljava/lang/String;
+	public final fun unaryPlus (Ljava/lang/String;)V
+}
+
 public abstract class net/mamoe/mirai/console/events/AutoLoginEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/console/events/ConsoleEvent, net/mamoe/mirai/event/events/BotEvent {
 }
 
@@ -1302,6 +1325,10 @@ public final class net/mamoe/mirai/console/events/AutoLoginEvent$Success : net/m
 public abstract interface class net/mamoe/mirai/console/events/ConsoleEvent : net/mamoe/mirai/event/Event {
 }
 
+public final class net/mamoe/mirai/console/events/EndUserReadmeInitializeEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/console/events/ConsoleEvent {
+	public final fun getReadme ()Lnet/mamoe/mirai/console/enduserreadme/EndUserReadme;
+}
+
 public final class net/mamoe/mirai/console/events/StartupEvent : net/mamoe/mirai/event/AbstractEvent, net/mamoe/mirai/console/events/ConsoleEvent {
 	public final fun getTimestamp ()J
 }
diff --git a/mirai-console/backend/mirai-console/resources/net/mamoe/mirai/console/internal/enduserreadme/readme.txt b/mirai-console/backend/mirai-console/resources/net/mamoe/mirai/console/internal/enduserreadme/readme.txt
new file mode 100644
index 000000000..e2b61eb8d
--- /dev/null
+++ b/mirai-console/backend/mirai-console/resources/net/mamoe/mirai/console/internal/enduserreadme/readme.txt
@@ -0,0 +1,129 @@
+::mirai-console.greeting
+
+欢迎使用 mirai-console。
+在您正式开始使用 mirai-console 前,您需要完整阅读此用户须知。
+
+此用户须知包含 mirai-console 本体及其所安装的插件的用户须知。
+当相关的最终用户须知更新时,mirai-console 只会显示已更新部分,而不会重新完整显示整个用户须知。
+
+::mirai-console.usage
+
+在使用 mirai-console 前,您需要完整阅读用户手册。
+<delay>2
+用户手册地址:
+    GitHub:   https://github.com/mamoe/mirai/blob/dev/docs/UserManual.md
+    VuePress: https://docs.mirai.mamoe.net/UserManual.html
+<delay>3
+当您遇到问题前,请先查阅
+<delay>2
+    常见问题参考: https://docs.mirai.mamoe.net/Questions.html
+<delay>1
+    mirai 历史问题提问: https://github.com/mamoe/mirai/issues?q=is%3Aissue
+<delay>3
+
+如果您使用的 mirai-console 来自一个单独整合包,您需要参考该整合包内的 `readme` 文件
+
+::mirai-console.issuing
+
+在使用 mirai-console 的过程中,您可能会遇到各种问题。
+在您向他人咨询前,您需要做好以下准备。
+<delay>2
+无论是
+<delay>2
+`- 在 mirai 主仓库发起 issue
+<delay>1
+`- 在 mirai 论坛发起帖子
+<delay>1
+`- 在群聊向他人咨询
+<delay>1
+`- 在私聊向他人咨询
+<delay>1
+`- 或者更多
+<delay>1
+您都需要做好以下准备。
+<delay>1
+这不仅能让您更快解决问题,也是对被询问者的尊重。
+<delay>1
+
+1. 说明您正在使用的版本
+<delay>2
+版本号是确定问题的关键信息,
+<delay>1
+mirai-console 的版本号会在 mirai-console 运行时就打印至控制台。
+其他组件版本可以通过执行 /status 命令获取
+
+<delay>3
+2. 携带报错信息 / 携带日志
+<delay>3
+报错信息是分析问题的关键,没有日志相当于闭眼开车。
+<delay>3
+当您咨询时,一定要携带当时的日志
+<delay>3
+「没有日志我能做的事只有帮你算一卦」
+<delay>3
+
+标准的咨询模板参考:
+https://github.com/mamoe/mirai/issues/new?template=bug.yml
+
+::mirai-core.EncryptService.alert
+
+Reference: https://github.com/mamoe/mirai/releases/tag/v2.15.0
+
+关于包数据加密 / 签名 (Internal)(#2716)
+<delay>2
+mirai 不会内置任何第三方 签名/加密 服务,而是提供 SPI 让用户自行实现。
+<delay>2
+mirai 已经提供了外部 EncryptService SPI 供用户对接。如果您没有能力自行对接,您可以考虑到论坛寻找社区对接。
+<delay>2
+在使用社区服务前,您需要了解并理解以下内容
+<delay>2
+<pause>
+
+1. 确认服务来源
+<delay>2
+   当您安装此服务后,所有的信息都会经过此消息服务。
+   <delay>2
+   这其中包括
+     Bot 的登录请求(包含密码,登录凭证等)
+     <delay>2
+     Bot 发出去的所有信息
+     <delay>2
+     更多.....
+<delay>2
+<pause>
+2. 保护好网络,建立通讯防火墙
+<delay>2
+部分服务通讯链路是无加密的
+<delay>1
+如果您访问的服务位于公开网络,您的数据有被窃取、拦截的风险。
+
+<delay>2
+<pause>
+3. 保护好日志。
+<delay>2
+并非所有日志都能直接传递给他人
+<pause>
+
+在您公开您的日志前,请先对日志中的关键信息进行抹除。
+<pause>
+
+部分相关服务使用 HTTP GET 请求传递数据体,
+当远程服务出错时,服务对接可能会直接将此次请求的连接直接输出到日志中,
+此日志可能包含了此次尝试 签名/加密 的内容,
+而此内容可能包含关键信息。
+<pause>
+
+如果您无法分辨哪些请求需要被抹除时,您可以参考以下规则:
+<pause>
+
+    请求连接包含大量 Hex 文本,抹除 (Hex: 由 0-9 和 ABCDEF 组成的序列 )
+    <delay>2
+    <pause>
+    请求包含大量 Base64 文本,抹除 (如您不知道什么是 Base64 文本,您可以简单当做是超长的英文与符号组合)
+    <delay>2
+    <pause>
+    请求连接过长,抹除(如连接日志换行了三次都还没有显示完全)
+    <delay>2
+    <pause>
+
+
diff --git a/mirai-console/backend/mirai-console/src/enduserreadme/EndUserReadme.kt b/mirai-console/backend/mirai-console/src/enduserreadme/EndUserReadme.kt
new file mode 100644
index 000000000..7cbaef001
--- /dev/null
+++ b/mirai-console/backend/mirai-console/src/enduserreadme/EndUserReadme.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright 2019-2023 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.console.enduserreadme
+
+import java.util.*
+
+/**
+ * 最终用户须知
+ *
+ * @since 2.16.0
+ */
+public class EndUserReadme {
+    public companion object {
+        public const val PAUSE: String = "<pause>"
+        public const val DELAY: String = "<delay>"
+    }
+
+    internal val pages: MutableMap<String, String> = linkedMapOf()
+
+    public class Render {
+        private val msgs = mutableListOf<String>()
+
+        @KeepDetermination
+        public operator fun String.unaryPlus() {
+            msg(this)
+        }
+
+        @KeepDetermination
+        public operator fun plusAssign(s: String) {
+            msg(s)
+        }
+
+        @KeepDetermination
+        public fun pause() {
+            msg(PAUSE)
+        }
+
+        @KeepDetermination
+        public fun delay() {
+            msg(DELAY)
+        }
+
+        /**
+         * 延迟一段时间
+         *
+         * @param time 单位:秒
+         */
+        @KeepDetermination
+        public fun delay(time: Int) {
+            msg(DELAY + time)
+        }
+
+        @KeepDetermination
+        public fun msg(message: String) {
+            msgs.add(message)
+        }
+
+        public fun render(): String = msgs.joinToString(separator = "\n")
+    }
+
+    @KeepDetermination
+    public fun put(category: String, render: Render.() -> Unit) {
+        pages[category] = Render().also(render).render()
+    }
+
+    /**
+     * 同时添加多个须知定义
+     *
+     * 格式:
+     * ```text
+     *
+     * ::category.c1
+     *
+     * Here is c1
+     *
+     * delay 2s
+     * <delay>2
+     *
+     * paused
+     * <pause>
+     *
+     * ::category.c1
+     *
+     * Here is c2
+     *
+     * ```
+     */
+    @KeepDetermination
+    public fun putAll(fullText: String) {
+        if (fullText.isBlank()) return
+        val lines = LinkedList(fullText.lines())
+
+        var category: String
+        val buffer = mutableListOf<String>()
+
+        while (true) {
+            if (lines.isEmpty()) return
+            val rm = lines.removeFirst()
+
+            if (rm.isBlank()) continue
+            if (rm.startsWith("::")) {
+                category = rm.substring(2)
+                break
+            }
+            throw IllegalArgumentException("First non-empty line must be category define: $rm")
+        }
+
+        fun flush() {
+            while (buffer.isNotEmpty()) {
+                if (buffer.first().isBlank()) {
+                    buffer.removeAt(0)
+                    continue
+                }
+                break
+            }
+
+
+            while (buffer.isNotEmpty()) {
+                if (buffer.last().isBlank()) {
+                    buffer.removeAt(buffer.lastIndex)
+                    continue
+                }
+                break
+            }
+
+            pages[category] = buffer.joinToString(separator = "\n")
+            buffer.clear()
+        }
+
+        while (lines.isNotEmpty()) {
+            val rm = lines.removeFirst()
+            if (rm.startsWith("::")) {
+                flush()
+                category = rm.substring(2)
+                continue
+            }
+            buffer.add(rm)
+        }
+
+        flush()
+    }
+
+    @DslMarker
+    private annotation class KeepDetermination
+}
\ No newline at end of file
diff --git a/mirai-console/backend/mirai-console/src/events/EndUserReadmeInitializeEvent.kt b/mirai-console/backend/mirai-console/src/events/EndUserReadmeInitializeEvent.kt
new file mode 100644
index 000000000..c1d92e8e7
--- /dev/null
+++ b/mirai-console/backend/mirai-console/src/events/EndUserReadmeInitializeEvent.kt
@@ -0,0 +1,18 @@
+/*
+ * Copyright 2019-2023 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.console.events
+
+import net.mamoe.mirai.console.enduserreadme.EndUserReadme
+import net.mamoe.mirai.event.AbstractEvent
+import net.mamoe.mirai.utils.MiraiInternalApi
+
+public class EndUserReadmeInitializeEvent @MiraiInternalApi constructor(
+    public val readme: EndUserReadme,
+) : ConsoleEvent, AbstractEvent()
diff --git a/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt b/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
index bd9596ad7..a016aa03a 100644
--- a/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
+++ b/mirai-console/backend/mirai-console/src/internal/MiraiConsoleImplementationBridge.kt
@@ -46,6 +46,7 @@ import net.mamoe.mirai.console.internal.data.builtins.AutoLoginConfig.Account.Pa
 import net.mamoe.mirai.console.internal.data.builtins.DataScope
 import net.mamoe.mirai.console.internal.data.builtins.LoggerConfig
 import net.mamoe.mirai.console.internal.data.builtins.PluginDependenciesConfig
+import net.mamoe.mirai.console.internal.enduserreadme.EndUserReadmeProcessor
 import net.mamoe.mirai.console.internal.extension.GlobalComponentStorage
 import net.mamoe.mirai.console.internal.extension.GlobalComponentStorageImpl
 import net.mamoe.mirai.console.internal.logging.LoggerControllerImpl
@@ -365,6 +366,10 @@ ___  ____           _   _____                       _
             mainLogger.info { "${pluginManager.plugins.count { it.isEnabled }} plugin(s) enabled." }
         }
 
+        phase("end-user-readme") {
+            EndUserReadmeProcessor.process(this)
+        }
+
         phase("auto-login bots") {
             runBlocking {
                 val config = DataScope.get<AutoLoginConfig>()
diff --git a/mirai-console/backend/mirai-console/src/internal/data/builtins/EndUserReadmeData.kt b/mirai-console/backend/mirai-console/src/internal/data/builtins/EndUserReadmeData.kt
new file mode 100644
index 000000000..a7a6e61d3
--- /dev/null
+++ b/mirai-console/backend/mirai-console/src/internal/data/builtins/EndUserReadmeData.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2019-2023 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.console.internal.data.builtins
+
+import net.mamoe.mirai.console.data.PluginDataHolder
+import net.mamoe.mirai.console.data.PluginDataStorage
+import net.mamoe.mirai.console.data.ReadOnlyPluginConfig
+import net.mamoe.mirai.console.data.value
+import net.mamoe.mirai.console.util.ConsoleExperimentalApi
+
+@OptIn(ConsoleExperimentalApi::class)
+internal class EndUserReadmeData : ReadOnlyPluginConfig("EndUserReadme") {
+    val data: MutableMap<String, String> by value()
+
+    private lateinit var storage_: PluginDataStorage
+    private lateinit var owner_: PluginDataHolder
+    override fun onInit(owner: PluginDataHolder, storage: PluginDataStorage) {
+        this.storage_ = storage
+        this.owner_ = owner
+    }
+
+    internal fun saveNow() {
+        storage_.store(owner_, this)
+    }
+}
diff --git a/mirai-console/backend/mirai-console/src/internal/enduserreadme/EndUserReadmeProcessor.kt b/mirai-console/backend/mirai-console/src/internal/enduserreadme/EndUserReadmeProcessor.kt
new file mode 100644
index 000000000..0f556dd5b
--- /dev/null
+++ b/mirai-console/backend/mirai-console/src/internal/enduserreadme/EndUserReadmeProcessor.kt
@@ -0,0 +1,175 @@
+/*
+ * Copyright 2019-2023 Mamoe Technologies and contributors.
+ *
+ * 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
+ * Use of this source code is governed by the GNU AGPLv3 license that can be found through the following link.
+ *
+ * https://github.com/mamoe/mirai/blob/dev/LICENSE
+ */
+
+package net.mamoe.mirai.console.internal.enduserreadme
+
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeoutOrNull
+import net.mamoe.mirai.console.ConsoleFrontEndImplementation
+import net.mamoe.mirai.console.command.ConsoleCommandSender
+import net.mamoe.mirai.console.enduserreadme.EndUserReadme
+import net.mamoe.mirai.console.events.EndUserReadmeInitializeEvent
+import net.mamoe.mirai.console.internal.MiraiConsoleImplementationBridge
+import net.mamoe.mirai.console.internal.data.builtins.EndUserReadmeData
+import net.mamoe.mirai.console.util.ConsoleInput
+import net.mamoe.mirai.console.util.sendAnsiMessage
+import net.mamoe.mirai.event.broadcast
+import net.mamoe.mirai.utils.MiraiInternalApi
+import net.mamoe.mirai.utils.sha256
+import net.mamoe.mirai.utils.toUHexString
+import java.io.File
+import java.net.InetAddress
+
+internal object EndUserReadmeProcessor {
+    private val PADDING = "=".repeat(100)
+    private fun StringBuilder.pad(size: Int) {
+        var size0 = size
+
+        while (size0 > 0) {
+            val padded = size0.coerceAtMost(PADDING.length)
+            append(PADDING, 0, padded)
+            size0 -= padded
+        }
+    }
+
+    private fun header(title: String): String {
+        val padding = 100 - title.length
+
+        val lpadding = padding / 2
+        val rpadding = padding - lpadding
+
+        return buildString {
+            pad(lpadding)
+            append(" [ ").append(title).append(" ] ")
+            pad(rpadding)
+        }
+    }
+
+    private val systemDefaultNames = hashSetOf<String>(
+        "ubuntu", "debian", "arch",
+        "centos", "fedora", "localhost",
+    )
+
+    private fun getComputerName(): String {
+        System.getenv("COMPUTERNAME")?.takeUnless(String::isBlank)?.let { return it }
+        System.getenv("HOSTNAME")?.takeUnless(String::isBlank)?.let { return it }
+
+        runCatching {
+            InetAddress.getLocalHost().hostName
+                ?.takeIf { it.lowercase() !in systemDefaultNames }
+                ?.takeUnless(String::isBlank)
+                ?.let { return it }
+        }
+
+        runCatching {
+            File("/etc/machine-id").readText().takeUnless(String::isBlank)?.let { return it.trim() }
+        }
+        return "Unknown Computer"
+    }
+
+    @OptIn(MiraiInternalApi::class, ConsoleFrontEndImplementation::class)
+    fun process(console: MiraiConsoleImplementationBridge) {
+        if (System.getenv("CI") == "true") return
+        if (System.getProperty("mirai.console.skip-end-user-readme") in listOf<String?>("", "true", "yes")) return
+
+        val pcName = getComputerName()
+        val dataObject = EndUserReadmeData()
+        console.consoleDataScope.addAndReloadConfig(dataObject)
+
+
+        runBlocking {
+            val readme = EndUserReadme()
+            runCatching {
+                EndUserReadmeProcessor::class.java.getResourceAsStream("readme.txt")?.bufferedReader()?.use {
+                    readme.putAll(it.readText())
+                }
+            }.onFailure { console.mainLogger.error(it) }
+
+            EndUserReadmeInitializeEvent(readme).broadcast()
+
+            // region Remove already read
+
+            val pcNameBCode = pcName.toByteArray()
+            var changed = false
+
+            readme.pages.asSequence().map { (key, value) ->
+                return@map key to value.sha256()
+            }.onEach { (_, hash) ->
+                for (i in hash.indices) {
+                    hash[i] = hash[i].toInt().xor(pcNameBCode[i % pcNameBCode.size].toInt()).toByte()
+                }
+            }.map { (k, v) ->
+                return@map k to v.toUHexString()
+            }.toList().forEach { (key, hash) ->
+                if (dataObject.data[key] == hash) {
+                    readme.pages.remove(key)
+                } else {
+                    dataObject.data[key] = hash
+                    changed = true
+                }
+            }
+            // endregion
+
+            suspend fun wait(seconds: Int) {
+                if (seconds < 1) return
+
+                var printWaiting = true
+
+                repeat(seconds) { counter ->
+                    val suffix = (seconds - counter).toString() + "s"
+                    withTimeoutOrNull(1000L) {
+                        if (printWaiting) {
+                            ConsoleInput.requestInput("Please wait $suffix...")
+                            printWaiting = false
+                        }
+                        while (true) {
+                            ConsoleInput.requestInput("Please read before continuing ($suffix)")
+                        }
+                    }
+                }
+
+            }
+
+            suspend fun pause() {
+                ConsoleInput.requestInput("Enter to continue")
+            }
+
+            if (readme.pages.isNotEmpty()) {
+                listOf(
+                    header("End User Readme"),
+                    "最终用户须知有更新,在您继续使用前,您必须完整阅读新的用户须知。",
+                ).forEach { ConsoleCommandSender.sendMessage(it) }
+            }
+
+            readme.pages.forEach { (category, message) ->
+                ConsoleCommandSender.sendMessage(header(category))
+                message.lines().forEach { command ->
+                    val ctrim = command.trim()
+                    if (ctrim == EndUserReadme.PAUSE) {
+                        pause()
+                    } else if (ctrim == EndUserReadme.DELAY) {
+                        wait(3)
+                    } else if (ctrim.startsWith(EndUserReadme.DELAY)) {
+                        wait(ctrim.removePrefix(EndUserReadme.DELAY).trim().toIntOrNull() ?: 3)
+                    } else {
+                        ConsoleCommandSender.sendAnsiMessage(command)
+                    }
+                }
+                wait(3)
+                pause()
+            }
+
+
+            if (changed) {
+                dataObject.saveNow()
+            }
+
+        }
+    }
+}
\ No newline at end of file