返回

解决 Discord 机器人重启失忆:Python 持久化方案

Ai

让你的 Discord 机器人记住自定义回复:持久化 Python Bot 数据

搞 Discord 机器人的时候,经常遇到一个头疼事儿:用 !learn 教会了机器人新回复,结果机器人一重启,刚学的全忘了,跟失忆了一样。这体验可不太好。就像下面这段代码遇到的情况:机器人能从 intents.json 加载预设意图,也能加载 custom_responses.json 里的自定义回复,但是通过 !learn 命令新加的回复,关掉程序再开就没了。

# (这里省略了原始代码,因为文章后面会提供修改后的版本)
# ... 原始代码尝试在 on_disconnect 时保存 ...

问题就在于,机器人学到的东西只存在内存里,重启之后内存里的东西自然就清空了。虽然代码里尝试在 on_disconnect 事件里保存,但这方法不太靠谱。

问题出在哪儿?

机器人运行的时候,custom_responses 是一个 Python 字典,存放在内存里。当你用 !learn 命令添加新的自定义回复时,程序只是更新了内存里的这个字典。

代码里试图用 on_disconnect 事件来保存 custom_responses 字典到 custom_responses.json 文件。理论上似乎可行,但实际上 on_disconnect 这个事件触发的时机很不稳定:

  1. 不是总能触发: 如果机器人是崩溃了,或者被系统强制杀掉(比如 kill -9),或者你用 Ctrl+C 停掉程序但处理不当,on_disconnect 可能根本来不及执行。
  2. 时机太晚: 就算正常关闭,它也是在最后时刻才执行。如果保存过程中出任何问题(比如磁盘满了),数据也可能丢了。

所以,依赖 on_disconnect 来做数据持久化,就像是把所有鸡蛋放在一个不怎么结实的篮子里,风险挺大。一旦中间出了岔子,机器人学到的东西就白费了。

如何解决?

想让机器人真正“记住”东西,关键在于及时 并且可靠地 把内存里的数据写到硬盘(文件)上。下面提供几个方案,从简单直接到更完善。

方案一:学到就存(推荐)

最直接有效的办法就是,每次成功执行 !learn 命令后,立刻就把更新后的 custom_responses 字典写回 custom_responses.json 文件。这样一来,就算机器人下一秒就挂了,最后一次学习的内容也已经安全落地了。

原理:
把保存文件的操作,从不靠谱的 on_disconnect 移到 !learn 命令函数内部。学完即存,简单粗暴但有效。

代码修改:
你需要修改 learn 命令函数。

import discord
from discord.ext import commands
import json
import asyncio
import random
import os # 引入 os 模块,后面可能用到

# --- 文件路径定义 ---
INTENTS_FILE = 'intents.json'
CUSTOM_RESPONSES_FILE = 'custom_responses.json'

# --- 加载意图 ---
try:
    with open(INTENTS_FILE, 'r', encoding='utf-8') as f: # 指定 utf-8 编码是个好习惯
        intents_data = json.load(f)
    intents = intents_data.get('intents', [])
    print(f"成功从 {INTENTS_FILE} 加载 {len(intents)} 条意图。")
except FileNotFoundError:
    print(f"警告: {INTENTS_FILE} 未找到,将使用空意图列表。")
    intents = []
except json.JSONDecodeError:
    print(f"错误: {INTENTS_FILE} 文件格式错误,请检查 JSON 语法。")
    intents = []

# --- 加载或初始化自定义回复 ---
custom_responses = {} # 先初始化为空字典
if os.path.exists(CUSTOM_RESPONSES_FILE):
    try:
        with open(CUSTOM_RESPONSES_FILE, 'r', encoding='utf-8') as f:
            custom_responses = json.load(f)
            print(f"成功从 {CUSTOM_RESPONSES_FILE} 加载 {len(custom_responses)} 条自定义回复。")
    except json.JSONDecodeError:
        print(f"错误: {CUSTOM_RESPONSES_FILE} 文件格式错误,将使用空字典。请修复或删除该文件。")
        custom_responses = {} # 保证是个字典
    except Exception as e:
        print(f"加载 {CUSTOM_RESPONSES_FILE} 时发生未知错误: {e},将使用空字典。")
        custom_responses = {}
else:
    print(f"{CUSTOM_RESPONSES_FILE} 不存在,将创建一个新的空文件(如果执行 !learn)。")
    # 不需要在这里创建,保存时会自动创建

# --- Bot 初始化 ---
# 确保你有启用 Message Content Intent
intents_discord = discord.Intents.default()
intents_discord.messages = True
intents_discord.message_content = True # !!!非常重要,必须开启这个 Intent !!!

bot = commands.Bot(command_prefix='!', intents=intents_discord)

# --- 事件处理 ---
@bot.event
async def on_ready():
    print(f'机器人已上线: {bot.user.name} ({bot.user.id})')
    print('------')

@bot.event
async def on_message(message):
    # 忽略机器人自己的消息
    if message.author == bot.user:
        return
    # 忽略其他机器人的消息 (可选,看需求)
    # if message.author.bot:
    #     return

    content_lower = message.content.lower()

    # 1. 检查自定义回复 (优先级最高)
    if content_lower in custom_responses:
        await message.channel.send(custom_responses[content_lower])
        # 如果匹配了自定义回复,可以选择是否还继续处理命令或其他意图
        # return # 如果想匹配后就结束,取消这行注释

    # 2. 如果没有自定义回复,检查预设意图
    else:
        found_intent = False
        for intent in intents:
            # 检查 pattern 是否在消息中
            if any(pattern.lower() in content_lower for pattern in intent.get('patterns', [])):
                # 从 responses 随机选一个
                responses = intent.get('responses', [])
                if responses:
                    response = random.choice(responses)
                    await message.channel.send(response)
                    found_intent = True
                    break # 找到一个匹配的意图就结束

        # 3. 如果上面都没匹配到,可以给个默认回复 (或者什么都不做)
        # if not found_intent and not content_lower.startswith(bot.command_prefix): # 避免干扰命令
        #     print(f"未匹配到自定义回复或意图: '{message.content}'")
            # 可以取消下面这行的注释,来给一个“不知道怎么回”的提示
            # await message.channel.send("抱歉,我不太明白你的意思。")

    # !!! 关键:让 discord.py 继续处理命令 !!!
    # 无论上面是否回复,都需要调用这句,否则 `!learn` 等命令会失效
    await bot.process_commands(message)


# --- 保存自定义回复的辅助函数 ---
def save_custom_responses():
    """将 custom_responses 字典保存到 JSON 文件"""
    try:
        # 使用 'w' 模式,如果文件不存在会创建,如果存在会覆盖
        with open(CUSTOM_RESPONSES_FILE, 'w', encoding='utf-8') as f:
            # indent=4 让 JSON 文件更易读
            json.dump(custom_responses, f, indent=4, ensure_ascii=False) # ensure_ascii=False 支持中文等非 ASCII 字符
        print(f"自定义回复已成功保存到 {CUSTOM_RESPONSES_FILE}")
        return True
    except IOError as e:
        print(f"错误: 无法写入文件 {CUSTOM_RESPONSES_FILE}。请检查权限或磁盘空间。错误详情: {e}")
        return False
    except Exception as e:
        print(f"保存自定义回复时发生未知错误: {e}")
        return False

# --- 命令定义 ---
@bot.command(name='learn')
async def learn_command(ctx, *, args: str):
    """
    让机器人学习一个新的自定义回复。
    用法: !learn <触发词> <回复内容>
    例如: !learn 你好呀 你也好!
    注意:触发词是大小写不敏感的,会被转换成小写存储。
    """
    try:
        # 按第一个空格分割,最多分一次
        user_input, custom_response = args.split(maxsplit=1)
        input_lower = user_input.lower() # 将触发词转为小写

        # 更新内存中的字典
        custom_responses[input_lower] = custom_response
        print(f"内存更新: '{input_lower}' -> '{custom_response}'")

        # *** 关键步骤:立即保存到文件 ** *
        if save_custom_responses():
            await ctx.send(f"学到了!下次你说 '{user_input}',我会回复 '{custom_response}'。")
        else:
            # 如果保存失败,告知用户,并可以选择是否回滚内存中的修改
            await ctx.send("哎呀,学习时出了点问题,没能保存下来。请检查后台日志。")
            # 考虑回滚:如果保存失败,是否应该移除刚才添加的?
            # del custom_responses[input_lower]
            # print(f"由于保存失败,已从内存中移除 '{input_lower}'")

    except ValueError:
        await ctx.send("命令格式好像不对哦?试试 `!learn <你想让我记住的话> <我应该回复的内容>`,记得中间加个空格。")
    except Exception as e:
        await ctx.send("处理学习命令时发生了预期之外的错误,麻烦看看后台日志。")
        print(f"处理 !learn 命令时出错: {e}")

# --- 不再需要在 on_disconnect 中保存 ---
# @bot.event
# async def on_disconnect():
#     print("机器人断开连接,尝试保存自定义回复...") # 这条日志可以保留,观察事件是否触发
#     # save_custom_responses() # 不需要在这里调用了

# --- 启动 Bot ---
if __name__ == "__main__":
    # 从环境变量或配置文件读取 Token 是更安全的做法
    # TOKEN = os.getenv("DISCORD_BOT_TOKEN")
    TOKEN = "YOUR_BOT_TOKEN" # 在这里替换成你的 Bot Token
    if TOKEN == "YOUR_BOT_TOKEN" or not TOKEN:
        print("错误:请将 'YOUR_BOT_TOKEN' 替换成你真实的 Bot Token!")
    else:
        print("机器人正在启动...")
        try:
            # run_until_complete 不推荐直接使用,bot.run() 会处理好事件循环
            # loop = asyncio.get_event_loop()
            # loop.run_until_complete(bot.start(TOKEN)) # bot.start() 不是协程
            bot.run(TOKEN)
        except discord.LoginFailure:
            print("错误:无效的 Bot Token,请检查 Token 是否正确。")
        except Exception as e:
            print(f"启动机器人时发生错误: {e}")

步骤:

  1. 替换 learn 函数: 把上面代码里的 learn_command 函数复制粘贴,替换掉你原来代码中的 @bot.command() 下的 learn 函数。
  2. 添加 save_custom_responses 函数: 把上面代码里的 save_custom_responses 函数也复制到你的 Python 文件里。这个函数负责实际的保存操作。
  3. 删除或注释掉 on_disconnect 里的保存逻辑: 找到 @bot.event async def on_disconnect():,把它里面的 save_custom_responses()json.dump(...) 这行代码删除或者注释掉(前面加 #)。
  4. 检查 on_message 确保你的 on_message 函数最后调用了 await bot.process_commands(message),不然你的 !learn 命令会没反应。代码示例中已经包含了。
  5. 开启 Message Content Intent: 在 Discord Developer Portal 里,为你的 Bot 开启 "MESSAGE CONTENT INTENT"。这是读取消息内容所必需的。同时,在初始化 discord.Intents 时要包含它,如代码示例所示 (intents_discord.message_content = True)。
  6. 运行机器人:python your_bot_file.py 启动。

安全和注意事项:

  • 文件权限: 确保机器人运行的目录有写入 custom_responses.json 的权限。不然保存会失败。
  • 错误处理: 上面的 save_custom_responses 函数加入了基本的 try...except 来捕获可能的 IOError(比如磁盘满了、没权限),防止程序崩溃。你可以根据需要添加更详细的错误处理或日志记录。
  • 频繁写入?: 对于小型机器人,每次 !learn 都写入一次文件通常没啥性能问题。但如果你的机器人学习频率非常高(比如每秒好几次),或者 custom_responses.json 文件变得特别大(几百 MB 上 G),那可能需要考虑更高级的策略,比如攒一批再写,或者用数据库。但对付这个“失忆”问题,即时保存是最简单的。
  • 并发问题(简单场景下通常没事): 如果多个用户同时执行 !learn,理论上可能存在并发写入同一个文件的问题。对于简单的 JSON 文件写入,Python 的文件操作通常能处理好(后写入的会覆盖)。但极端情况下可能导致文件损坏(比如写入过程中程序被强行中断)。下面的“进阶技巧”会提到更稳妥的方法。
  • JSON 格式: 确保存储的 custom_response 内容是合法的 JSON 字符串。一般文本没问题,但如果你存复杂对象,需要注意序列化。代码里用了 ensure_ascii=False 来更好地支持中文等字符。

方案二:更健壮的文件写入

方案一虽然解决了核心问题,但在文件写入的健壮性上可以做得更好。比如,防止在写入过程中因意外中断导致 JSON 文件损坏。

原理:
采用“原子写入”的策略:先将数据写入一个临时文件,只有当临时文件完全写入成功后,再用它替换掉(重命名)原来的 custom_responses.json 文件。这样可以最大限度地保证 custom_responses.json 文件要么是旧的完整版本,要么是新的完整版本,避免了“写一半”的损坏状态。

代码修改(改进 save_custom_responses 函数):

import os
import json
import tempfile # 用于创建临时文件
import shutil # 用于文件移动(重命名)

# ... (其他代码保持不变) ...

CUSTOM_RESPONSES_FILE = 'custom_responses.json'
custom_responses = {} # 假设已从文件加载或初始化

def save_custom_responses_atomic():
    """将 custom_responses 字典原子地保存到 JSON 文件"""
    # 使用临时文件来实现原子写入
    try:
        # 1. 创建一个临时文件 (在同一目录下,保证 rename 操作原子性,跨盘不行)
        #    'delete=False' 是因为我们需要手动关闭和重命名它
        #    'dir=.' 指定在当前目录创建,方便 rename
        fd, temp_path = tempfile.mkstemp(suffix='.tmp', prefix='responses_', dir='.')
        print(f"创建临时文件: {temp_path}")

        # 2. 将数据写入临时文件
        with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
            json.dump(custom_responses, tmp_file, indent=4, ensure_ascii=False)
            # tmp_file 在 with 块结束时自动关闭

        # 3. (核心步骤) 将临时文件重命名为目标文件
        #    os.replace 在多数系统上是原子操作,或者至少比直接写入目标文件更安全
        shutil.move(temp_path, CUSTOM_RESPONSES_FILE) # 用 shutil.move 更跨平台
        # os.replace(temp_path, CUSTOM_RESPONSES_FILE) # 或者用 os.replace

        print(f"自定义回复已通过原子写入成功保存到 {CUSTOM_RESPONSES_FILE}")
        return True

    except IOError as e:
        print(f"错误: 写入临时文件或重命名时发生 IO 错误。请检查权限或磁盘空间。错误详情: {e}")
        # 清理可能遗留的临时文件
        if 'temp_path' in locals() and os.path.exists(temp_path):
            try:
                os.remove(temp_path)
                print(f"已清理临时文件: {temp_path}")
            except OSError as remove_err:
                print(f"警告: 清理临时文件 {temp_path} 失败: {remove_err}")
        return False
    except Exception as e:
        print(f"原子保存自定义回复时发生未知错误: {e}")
        # 同样尝试清理临时文件
        if 'temp_path' in locals() and os.path.exists(temp_path):
             try:
                os.remove(temp_path)
                print(f"已清理临时文件: {temp_path}")
             except OSError as remove_err:
                 print(f"警告: 清理临时文件 {temp_path} 失败: {remove_err}")
        return False

# --- 在 !learn 命令中调用这个新的保存函数 ---
@bot.command(name='learn')
async def learn_command(ctx, *, args: str):
    # ... (前半部分解析参数、更新内存字典的代码不变) ...
    try:
        user_input, custom_response = args.split(maxsplit=1)
        input_lower = user_input.lower()
        custom_responses[input_lower] = custom_response
        print(f"内存更新: '{input_lower}' -> '{custom_response}'")

        # *** 使用原子保存函数 ** *
        if save_custom_responses_atomic():
            await ctx.send(f"学到了!下次你说 '{user_input}',我会回复 '{custom_response}'。")
        else:
            await ctx.send("哎呀,学习时出了点问题,没能保存下来。请检查后台日志。")
            # 考虑回滚
            # del custom_responses[input_lower]
            # print(f"由于保存失败,已从内存中移除 '{input_lower}'")

    except ValueError:
        await ctx.send("命令格式好像不对哦?试试 `!learn <你想让我记住的话> <我应该回复的内容>`,记得中间加个空格。")
    except Exception as e:
        await ctx.send("处理学习命令时发生了预期之外的错误,麻烦看看后台日志。")
        print(f"处理 !learn 命令时出错: {e}")

# ... (其他 Bot 代码保持不变, 记得启动时要加载 CUSTOM_RESPONSES_FILE) ...

步骤:

  1. 用上面提供的 save_custom_responses_atomic 函数替换掉原来的 save_custom_responses 函数,或者简单地把原函数里的实现换成原子写入的逻辑。
  2. 确保在 learn_command 里调用的是这个更新后的、带有原子写入逻辑的保存函数。
  3. 确保你的代码开头导入了 os, tempfile, 和 shutil 模块。

进阶技巧(已包含在上面代码中):

  • 临时文件位置: 最好在目标文件 custom_responses.json 所在的同一文件系统(通常是同一目录)创建临时文件,这样 os.replaceshutil.move 才能大概率保证是原子操作(只是移动指针,而不是跨盘复制)。代码里用 dir='.' 来实现这一点。
  • 错误处理与清理: 原子写入过程中如果失败(比如写入临时文件时磁盘满了),需要确保不会留下无用的 .tmp 文件。上面的代码在 except 块中加入了清理逻辑。

澄清:intents.json vs custom_responses.json

看你的问题,似乎有提到想把学到的东西放到 intents.json 里。这里需要理清一下这两个文件的不同用途:

  • intents.json: 通常用来定义意图(Intent) 。一个意图代表一类用户输入(比如“打招呼”、“问天气”),它有一组模式(Patterns) (用户可能说的话,支持模糊匹配)和一组回复(Responses) (机器人可以选一个来回答)。它更像是一种规则库,用于理解用户的目的
  • custom_responses.json: 在你的场景里,它用来存储精确匹配键值对 。用户说的话(键)直接对应一个固定的回复(值)。!learn 命令做的就是添加这种精确的键值对。

!learn 学到的 key -> value 式的回复硬塞进 intents.json 的结构里不太合适:

  1. 结构不匹配: intents.json 的结构是 { "tag": "...", "patterns": [...], "responses": [...] }。你要怎么把 "你好" -> "你好呀" 这种对应塞进去?难道为每个学习的回复创建一个新的 intent?这会让 intents.json 变得异常臃肿且难以管理。
  2. 查询效率: 检查精确匹配(字典查找)通常比遍历 intents 列表、再遍历每个 intentpatterns 列表进行模糊匹配要快得多。

所以,保持 custom_responses.json 用于 !learn 的精确回复,intents.json 用于预设的、基于模式匹配的意图,是更清晰、更高效的做法。你的原始代码结构在这一点上其实是对的,只是持久化没做好。

进阶选项:使用数据库

如果你的机器人需要处理大量的自定义回复,或者未来可能需要更复杂的数据操作(比如按用户学习、统计使用频率等),或者有多人协作维护、甚至未来可能有多个机器人实例共享数据,那么使用数据库会是比 JSON 文件更强大、更可靠的选择。

原理:
数据库(如 SQLite、PostgreSQL、MongoDB 等)提供了更专业的数据存储、查询、索引和事务管理功能。

  • SQLite: 一个轻量级的文件数据库,不需要单独的数据库服务器,非常适合嵌入到 Python 应用中。标准库 sqlite3 就支持。对于异步的 discord.py,可以使用 aiosqlite
  • PostgreSQL/MySQL: 功能更强大的关系型数据库,需要单独运行数据库服务。适合大型应用。
  • MongoDB: NoSQL 文档数据库,存储 JSON 类数据很方便。

怎么做(以 aiosqlite 为例,概念性):

  1. 安装库: pip install aiosqlite
  2. 初始化数据库: 在机器人启动时连接数据库,并创建表(如果不存在)。
import aiosqlite

DB_FILE = 'bot_memory.db'

async def initialize_db():
    async with aiosqlite.connect(DB_FILE) as db:
        # 创建一个表来存储自定义回复
        await db.execute('''
            CREATE TABLE IF NOT EXISTS custom_responses (
                trigger TEXT PRIMARY KEY, -- 触发词,设为主键,保证唯一性且小写
                response TEXT NOT NULL     -- 回复内容
            )
        ''')
        await db.commit() # 提交事务
        print(f"数据库 {DB_FILE} 初始化完成。")

# 在 bot 启动前调用 await initialize_db()
  1. 修改 !learn 命令: 把写入 JSON 文件改成插入或更新数据库记录。
# 在 learn_command 函数内
async def learn_command(ctx, *, args: str):
    # ... 解析 user_input, custom_response ...
    input_lower = user_input.lower()
    try:
        async with aiosqlite.connect(DB_FILE) as db:
            # 使用 INSERT OR REPLACE,如果 trigger 已存在则更新,不存在则插入
            await db.execute(
                "INSERT OR REPLACE INTO custom_responses (trigger, response) VALUES (?, ?)",
                (input_lower, custom_response)
            )
            await db.commit()
        await ctx.send(f"学到了!下次你说 '{user_input}',我会回复 '{custom_response}'。")
        print(f"数据库更新: '{input_lower}' -> '{custom_response}'")
    except Exception as e:
        await ctx.send("哎呀,学习时数据库操作出错了。看看后台日志。")
        print(f"数据库写入错误: {e}")
  1. 修改 on_message: 从数据库加载自定义回复。
# 在 on_message 函数内,检查自定义回复的部分
async def on_message(message):
    # ... 忽略机器人消息等 ...
    content_lower = message.content.lower()
    custom_response_content = None
    try:
        async with aiosqlite.connect(DB_FILE) as db:
            # 查询是否有匹配的 trigger
            async with db.execute("SELECT response FROM custom_responses WHERE trigger = ?", (content_lower,)) as cursor:
                row = await cursor.fetchone()
                if row:
                    custom_response_content = row[0] # 获取回复内容
    except Exception as e:
        print(f"查询自定义回复时数据库错误: {e}") # 查询出错不应阻止后续逻辑

    if custom_response_content:
        await message.channel.send(custom_response_content)
        # return # 同样,根据需要决定是否在此处结束处理
    else:
        # ... 继续检查 intents.json 或其他逻辑 ...
        pass

    await bot.process_commands(message) # 不要忘了这句

适用场景:

  • 自定义回复数量非常大时(成千上万条)。
  • 需要更复杂的查询,比如按时间、按用户过滤。
  • 对数据一致性和防止损坏的要求非常高。
  • 预期未来功能会扩展,需要结构化存储更多信息。

使用数据库会增加一点点设置复杂度,但能换来长期的稳定性和扩展性。

选哪个方案取决于你的具体需求和机器人的复杂度。对于解决眼前这个“失忆”问题,方案一(即时保存)通常就够用了,最多加上方案二的原子写入优化一下。数据库则是更长远的考虑。