框架:

  • 基于Koishi + NapCat 搭建的QQ聊天Bot

  • 编程语言为TypeScript

  • 部署详细教程:在编辑~

思路:

  • 这里,我们已经成功构建了一个可以监听QQ群聊消息的Bot啦,那么如何让“他”参与进来呢?

  • 这里有两种方案:

    • PlanA:管他三七二十一,直接接入API,Token毁灭者……

    • PlanB:让Bot自我“学习”……

  • 当然,接入现有语言模型的方案比比皆是,那么今天主要带来的思路便是如何让Bot自我学习~

准备动手:

  1. 功能框架:事件监听 → 消息处理流水线 → 多策略响应

  2. 构建数据库:

-- 创建数据库
CREATE DATABASE IF NOT EXISTS aichat DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_unicode_ci;

USE aichat;

-- 群组设置表
CREATE TABLE guild_settings (
  guild_id VARCHAR(64) PRIMARY KEY COMMENT '群号',
  enabled BOOLEAN DEFAULT false COMMENT '是否启用AI',
  preset TEXT COMMENT '预设人设',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 聊天记录表
CREATE TABLE chat_history (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  guild_id VARCHAR(64) NOT NULL COMMENT '群号',
  user_id VARCHAR(64) NOT NULL COMMENT '用户ID',
  messages JSON NOT NULL COMMENT '消息内容[问,答]',
  timestamp BIGINT NOT NULL COMMENT '毫秒时间戳',
  INDEX idx_guild_user (guild_id, user_id),
  INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 关键词统计表
CREATE TABLE keyword_stats (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  guild_id VARCHAR(64) NOT NULL,
  user_id VARCHAR(64) NOT NULL,
  keyword VARCHAR(128) NOT NULL,
  count INT UNSIGNED DEFAULT 1,
  timestamp BIGINT NOT NULL,
  INDEX idx_guild_keyword (guild_id, keyword(16)),
  INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 权限管理表
CREATE TABLE auth (
  user_id VARCHAR(64) PRIMARY KEY COMMENT '用户ID',
  role ENUM('admin', 'operator') NOT NULL COMMENT '权限角色',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 触发规则表
CREATE TABLE triggers (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  pattern VARCHAR(128) NOT NULL COMMENT '匹配模式',
  responses JSON NOT NULL COMMENT '回复列表',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  UNIQUE INDEX idx_pattern (pattern(32))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 图片存储表
CREATE TABLE photo_storage (
  id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  guild_id VARCHAR(64) NOT NULL,
  file_path VARCHAR(512) NOT NULL COMMENT '图片存储路径',
  user_id VARCHAR(64) NOT NULL COMMENT '上传者ID',
  timestamp BIGINT NOT NULL,
  INDEX idx_guild (guild_id),
  INDEX idx_timestamp (timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 敏感词过滤表
CREATE TABLE banned_keywords (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  keyword VARCHAR(64) UNIQUE NOT NULL COMMENT '屏蔽词',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
  1. 构建语言学习部分:

  • 监听与处理

// 消息处理入口
ctx.on('message', async (session) => {
  if (!session.guildId) return

  // 处理@机器人的消息
  const isAtBot = session.elements.some(el => el.type === 'at' && el.attrs.id === session.bot.selfId)
  if (isAtBot) {
    const settings = await getGuildSettings(session.guildId)
    if (!settings.enabled) return
    
    const response = await callDeepSeekAPI(settings.preset, [], session.content)
    session.send(h('at', { id: session.userId }) + '\n' + response)
    return
  }

  // 处理图片消息
  if (hasImage(session)) {
    await savePhoto(session)
  }

  // 触发词匹配(缩略)
  const triggers = await getTriggerRules()
  const processed = processContent(session.content)
  if (triggers.some(t => processed.includes(t.pattern))) {
    sendRandomResponse(triggers)
    return
  }

  // 随机响应(15%概率)
  if (Math.random() < 0.15) {
    if (Math.random() < 0.5) {
      sendRandomPhoto(session)
    } else {
      sendAIComment(session)
    }
  }
})
  • 对话

// 调用深度求索API
async function callDeepSeekAPI(preset: string, history: string[], message: string) {
  const response = await axios.post('https://api.deepseek.com/chat/completions', {
    model: 'deepseek-chat',
    messages: [
      { role: 'system', content: preset },
      ...history.map(content => ({ role: 'user', content })),
      { role: 'user', content: message }
    ],
    temperature: 0.6,
    max_tokens: 500
  }, {
    headers: {
      'Authorization': 'Bearer YOUR_API_KEY', // 替换实际API密钥
      'Content-Type': 'application/json'
    }
  })
  return response.data.choices[0].message.content.trim()
}
  • 表情包管理

// 图片保存功能
async function savePhoto(session: Session) {
  const guildDir = join(dataPath.photo, session.guildId)
  
  // 维护最多50张图片
  const files = await fs.readdir(guildDir)
  if (files.length >= 50) {
    const oldest = await findOldestFile(guildDir)
    await fs.remove(join(guildDir, oldest))
  }

  // 保存新图片
  const imageUrl = extractImageUrl(session.content)
  const ext = getFileExtension(imageUrl)
  const filename = `${Date.now()}.${ext}`
  await downloadImage(imageUrl, join(guildDir, filename))
}

// 随机发送图片
function sendRandomPhoto(session: Session) {
  const guildDir = join(dataPath.photo, session.guildId)
  const files = fs.readdirSync(guildDir)
  const selected = files[Math.floor(Math.random() * files.length)]
  session.send(h.image(join(guildDir, selected)))
}
  • 触发词/关键词

// 触发词配置示例
const demoTriggers: TriggerRule[] = [
  {
    pattern: "早安",
    responses: ["早上好呀~", "元气满满的一天开始啦!(≧∇≦)/"]
  },
  {
    pattern: "晚安",
    responses: ["好梦哦~", "晚安啦,明天见!"]
  }
]

// 触发词匹配逻辑
function processContent(content: string) {
  return content
    .replace(/[^\u4e00-\u9fa5a-zA-Z]/g, '')
    .toLowerCase()
}
  • 权限构环节

// 管理员命令示例
ctx.command('ai <command>', 'AI管理系统')
  .action(async ({ session }, command) => {
    const [action, ...args] = command.split(' ')
    
    switch(action) {
      case 'on':
        await enableAI(session.guildId)
        return 'AI功能已启用'
        
      case 'preset':
        await setPreset(session.guildId, args.join(' '))
        return '人设预设已更新'
        
      case 'trigger':
        return handleTriggerCommand(args)
        
      default:
        return '未知命令'
    }
  })
  1. 后期数据库维护:

  • 定期删除(可以修改自动删除策略)

  • 写入限制词等

End

大体内容如上,理想状态是用本地部署的语言学习系统接入Style的训练,但由于实际情况优先,所以这里还是应用API的统一调用,接下来会带来如何进行大模型的语言风格学习,届时可以实现无API的只能对话

  • 全部代码如下:

import { Context, h, Schema, Session } from 'koishi'
import { resolve, join } from 'path'
import * as fs from 'fs-extra'
import { v4 as uuid } from 'uuid'
import { pinyin } from 'pinyin-pro'
import axios from 'axios'

export const name = 'ai-chat'
export const inject = []
export interface Config {
  dataDir?: string
  triggersPath?: string
}

export const Config: Schema<Config> = Schema.object({
  dataDir: Schema.string().default('/root/OraBot/external/aichat/ai-chat-data'),
  triggersPath: Schema.string().default('triggers.json')
})

interface GuildSettings { enabled: boolean; preset: string }
interface ChatHistory { guild_id: string; user_id: string; messages: string[]; timestamp: number }
interface KeywordStat { guild_id: string; user_id: string; keyword: string; count: number; timestamp: number }
interface AuthData { admins: string[]; operators: string[] }
interface TriggerRule { pattern: string; responses: string[] }
interface PhotoStat { guild_id: string; path: string; timestamp: number }

async function atomicWrite(path: string, data: any) {
  const tempPath = `${path}.${uuid()}`
  try {
    await fs.writeFile(tempPath, JSON.stringify(data), { mode: 0o644 })
    await fs.rename(tempPath, path)
  } catch (error) {
    console.error('文件写入失败:', error)
    try { await fs.unlink(tempPath) } catch { }
  }
}

async function safeTouch(path: string) {
  try {
    await fs.access(path)
  } catch {
    await fs.writeFile(path, '{}')
  }
}

async function initAuthFile(dataPath: { auth: string }) {
  try {
    await fs.access(dataPath.auth)
  } catch {
    await atomicWrite(dataPath.auth, {
      admins: ['2261265112'],
      operators: []
    })
  }
}

async function addChatHistory(dataPath: { history: string }, record: Omit<ChatHistory, 'timestamp'>) {
  const data = JSON.parse(await fs.readFile(dataPath.history, 'utf8'))
  data.push({ ...record, timestamp: Date.now() })
  if (data.length > 10000) data.splice(0, 1000)
  await atomicWrite(dataPath.history, data)
}

async function getRecentHistory(dataPath: { history: string }, guildId: string, userId: string, limit = 10): Promise<string[]> {
  const data: ChatHistory[] = JSON.parse(await fs.readFile(dataPath.history, 'utf8'))
  return data
    .filter(r => r.guild_id === guildId && r.user_id === userId)
    .sort((a, b) => b.timestamp - a.timestamp)
    .slice(0, limit)
    .flatMap(r => r.messages)
}

export function apply(ctx: Context, config: Config) {
  const getDataPath = (subPath: string) => {
    const fullPath = join(config.dataDir, subPath)
    fs.mkdir(fullPath, { recursive: true }).catch(console.error)
    return fullPath
  }

  const dataPath = {
    base: config.dataDir,
    guilds: getDataPath('guilds'),
    history: join(config.dataDir, 'history.json'),
    keywords: join(config.dataDir, 'keywords.json'),
    auth: join(config.dataDir, 'auth.json'),
    triggers: join(config.dataDir, config.triggersPath),
    keyout: join(config.dataDir, 'keyout.json'),
    photo: getDataPath('photo'),
    photoStats: join(config.dataDir, 'photo-stats.json')
  }

  // 冷却时间记录
  const photoCooldown = new Map<string, number>()

  // 添加触发词处理函数
  async function getTriggerRules(): Promise<TriggerRule[]> {
    try {
      return JSON.parse(await fs.readFile(dataPath.triggers, 'utf8'))
    } catch {
      return []
    }
  }

  function processContent(content: string): string {
    return Array.from(content).map(char => {
      if (/[\u4e00-\u9fa5]/.test(char)) {
        return pinyin(char, { pattern: 'first', toneType: 'none' })[0]?.toLowerCase() || ''
      }
      if (/[a-zA-Z]/.test(char)) return char.toLowerCase()
      return ''
    }).join('')
  }

  // 修改后的图片保存功能
  async function savePhoto(session: Session) {
    const guildId = session.guildId
    if (!guildId) {
      console.error('[表情包保存] 无效的群组ID')
      return
    }

    const guildDir = join(dataPath.photo, guildId)
    try {
      await fs.mkdir(guildDir, { recursive: true })
    } catch (error) {
      console.error(`[表情包保存] 创建目录失败: ${guildDir}`, error)
      return
    }

    // 限制每个群的图片数量为50张
    const files = await fs.readdir(guildDir)
    const imageFiles = files.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file))

    if (imageFiles.length >= 50) {
      // 删除最旧的图片
      const sortedFiles = await Promise.all(imageFiles.map(async file => {
        const filePath = join(guildDir, file)
        const stats = await fs.stat(filePath)
        return { file, mtime: stats.mtime }
      }))

      const oldestFile = sortedFiles.sort((a, b) => a.mtime.getTime() - b.mtime.getTime())[0].file
      await fs.remove(join(guildDir, oldestFile))
      console.log(`[表情包保存] 已删除最旧的图片: ${oldestFile}`)
    }

    // 查找所有图片元素
    const imageElements = session.elements.filter(el => {
      return el.type === 'image' || el.type === 'img' || el.type === 'face'
    })

    if (!imageElements.length) {
      console.log('[表情包保存] 消息中没有图片')
      return
    }

    // 处理每一个图片
    for (const imageElement of imageElements) {
      try {

        const imageUrlMatch = session.content.match(/<img src="(.*?)"/);

        const imageUrl = imageUrlMatch[1].replace(/&amp;/g, '&');

        if (!imageUrl) {
          console.log('[表情包保存] 图片URL为空,跳过')
          continue
        }

        // 获取文件扩展名
        let ext = 'jpg'
        const extMatch = imageUrl.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/i)
        if (extMatch && ['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extMatch[1].toLowerCase())) {
          ext = extMatch[1].toLowerCase()
        }

        const filename = `${Date.now()}_${Math.floor(Math.random() * 1000)}.${ext}`
        const savePath = join(guildDir, filename)

        try {
          const response = await axios.get(imageUrl, {
            responseType: 'stream',
            timeout: 10000,  // 10秒超时
            headers: {
              'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
            }
          })

          const writer = fs.createWriteStream(savePath)

          await new Promise((resolve, reject) => {
            response.data.pipe(writer)
            writer.on('finish', resolve)
            writer.on('error', reject)
          })

          // console.log(`[表情包保存] 图片保存成功: ${savePath}`)

          // 更新统计信息
          let stats: PhotoStat[] = []
          try {
            const statsData = await fs.readFile(dataPath.photoStats, 'utf8')
            stats = JSON.parse(statsData)
          } catch {
            stats = []
          }

          await atomicWrite(dataPath.photoStats, stats)

        } catch (error) {
          console.error('[表情包保存] 保存图片失败:', error)
        }

      } catch (error) {
        console.error('[表情包保存] 处理图片元素失败:', error)
        continue
      }
    }
  }

  // 新增图片清理任务
  ctx.setInterval(async () => {
    console.log('[表情包清理] 开始检查过期图片')
    try {
      const now = Date.now()

      // 确保文件存在
      try {
        await fs.access(dataPath.photoStats)
      } catch {
        console.log('[表情包清理] 统计文件不存在,跳过清理')
        await fs.writeFile(dataPath.photoStats, '[]')
        return
      }

      const statsData = await fs.readFile(dataPath.photoStats, 'utf8')
      let stats: PhotoStat[] = JSON.parse(statsData)

      console.log(`[表情包清理] 当前有 ${stats.length} 条记录`)
      let removedCount = 0

      const validStats = stats.filter(stat => {
        const expired = now - stat.timestamp > 24 * 3600 * 1000
        if (expired) {
          fs.unlink(stat.path).catch(err =>
            console.error(`[表情包清理] 删除文件失败: ${stat.path}`, err)
          )
          removedCount++
          return false
        }
        return true
      })

      if (validStats.length !== stats.length) {
        await atomicWrite(dataPath.photoStats, validStats)
        console.log(`[表情包清理] 已删除 ${removedCount} 个过期图片,剩余 ${validStats.length} 个`)
      } else {
        console.log('[表情包清理] 没有过期图片')
      }
    } catch (error) {
      console.error('[表情包清理] 处理失败:', error)
    }
  }, 3600 * 1000) // 每小时清理一次

  async function initStorage() {
    try {
      await fs.mkdir(config.dataDir, { recursive: true, mode: 0o755 })
      console.log(`[AI-Chat] 数据目录已初始化:${config.dataDir}`)

      // 确保photo目录存在并有正确权限
      await fs.mkdir(dataPath.photo, { recursive: true, mode: 0o755 })

      await Promise.all([
        safeTouch(dataPath.history),
        safeTouch(dataPath.keywords),
        safeTouch(dataPath.triggers),
        safeTouch(dataPath.photoStats),
        initAuthFile(dataPath)
      ])

      await Promise.all([
        fs.chmod(dataPath.history, 0o644),
        fs.chmod(dataPath.keywords, 0o644),
        fs.chmod(dataPath.auth, 0o600),
        fs.chmod(dataPath.photoStats, 0o644)
      ])

    } catch (error) {
      console.error('[AI-Chat] 存储初始化失败:', error)
      process.exit(1)
    }
  }

  async function getAuthData(): Promise<AuthData> {
    try {
      return JSON.parse(await fs.readFile(dataPath.auth, 'utf8'))
    } catch {
      return { admins: ['2261265112'], operators: [] }
    }
  }

  async function checkPermission(session: Session, level: 'admin' | 'operator' = 'operator') {
    const authData = await getAuthData()
    return authData.admins.includes(session.userId) ||
      (level === 'operator' && authData.operators.includes(session.userId))
  }

  ctx.command('ai <command>', 'AI管理系统')
    .option('guild', '-g <guildId:string> 指定群组ID')
    .userFields(['authority'])
    .action(async ({ session, options }, command) => {
      const guildId = options.guild || session.guildId
      if (!guildId) return '请在群组内使用此命令或指定群组ID'

      const isAdmin = await checkPermission(session, 'admin')
      const isOperator = await checkPermission(session, 'operator')
      if (!isAdmin && !isOperator) return h('quote', { id: session.messageId }) + '权限不足'

      const [primaryAction, secondaryAction, ...args] = command.split(/\s+/)

      if (primaryAction === 'trigger') {
        if (!isAdmin) return '需要管理员权限'

        const triggers = await getTriggerRules()

        switch (secondaryAction) {
          case 'add':
            if (!args.length) return '缺少参数,格式:trigger add <模式>|<回复1>|<回复2>'
            const [pattern, ...responses] = args.join(' ').split('|')
            triggers.push({
              pattern: pattern.toLowerCase().trim(),
              responses: responses.map(r => r.trim().replace(/\s+/g, ' '))
            })
            await atomicWrite(dataPath.triggers, triggers)
            return `✅ 已添加触发规则:${pattern}`

          case 'remove':
            if (!args.length) return '缺少要删除的模式'
            const newTriggers = triggers.filter(t => t.pattern !== args[0].toLowerCase())
            await atomicWrite(dataPath.triggers, newTriggers)
            return `✅ 已删除规则:${args[0]}`

          case 'list':
            return triggers.length
              ? '当前触发规则:\n' + triggers.map(t => `· ${t.pattern} → ${t.responses.join('/')}`).join('\n')
              : '暂无触发规则'

          default:
            return `${command}未知子命令,可用:add/remove/list/filter`
        }
      }

      if (primaryAction === 'preset' || primaryAction === 'clear') {
        if (!isAdmin) return '需要管理员权限'
      }

      const settings = await getGuildSettings(guildId)
      let response: string

      switch (primaryAction) {
        case 'on':
          settings.enabled = true
          response = `群组 ${guildId} AI 已启用`
          break
        case 'off':
          settings.enabled = false
          response = `群组 ${guildId} AI 已禁用`
          break
        case 'preset':
          settings.preset = args.join(' ')
          response = `人设预设已更新:\n${settings.preset}`
          break
        case 'clear':
          await clearHistory(guildId)
          response = '本群聊天记录已清空'
          break
        default:
          return '未知命令,可用命令:on/off/preset/clear/trigger'
      }

      await updateGuildSettings(guildId, settings)
      console.log(`[操作记录] ${session.userId} 在 ${guildId} 执行了 ${primaryAction}`)
      return response
    })

  async function clearHistory(guildId: string) {
    try {
      let history: ChatHistory[] = []
      try {
        const data = await fs.readFile(dataPath.history, 'utf8')
        history = JSON.parse(data)
      } catch {
        // 如果文件不存在或为空,使用空数组
        history = []
      }

      const newData = history.filter(r => r.guild_id !== guildId)
      await atomicWrite(dataPath.history, newData)
    } catch (error) {
      console.error('[清除历史] 处理失败:', error)
      throw new Error('清除历史记录失败')
    }
  }

  async function getRecentGuildKeywords(guildId: string, limit = 3): Promise<string[]> {
    try {
      const keywordsData = await fs.readFile(dataPath.keywords, 'utf8');
      const allKeywords: KeywordStat[] = JSON.parse(keywordsData);
      return allKeywords
        .filter(k => k.guild_id === guildId && k.keyword.length > 2) // Optionally filter very short keywords
        .sort((a, b) => b.timestamp - a.timestamp)
        .slice(0, limit)
        .map(k => k.keyword);
    } catch (error) {
      // console.warn('[getRecentGuildKeywords] 未能读取或解析关键词:', error);
      return [];
    }
  }

  async function generateSpontaneousComment(ctx: Context, currentMessageContent: string, guildId: string): Promise<string> {
    try {
      const recentMessages = await getRecentGuildKeywords(guildId, 2); // 获取最近2条消息作为风格参考
      let styleContext = "";
      if (recentMessages.length > 0) {
        styleContext = `\\n\\n作为参考,这是群里最近的一些发言,请尝试模仿类似的语气和风格:\\n- "${recentMessages.join('\\n- "')}"`;
      }

      const prompt = `你现在是这个群聊的一员。刚刚有人说了:“${currentMessageContent}”。
请针对这句话,生成一句简短、自然、且与当前对话相关的评论或感想。
你的回复应该像是群聊中一个随性的、自然的插话。不需要很正式,可以有趣或者随意一些。
${styleContext}
请将回复限制在一两句话内。如果实在想不出相关内容,就返回一个空字符串。`;

      const response = await ctx.http.post('https://api.deepseek.com/chat/completions', {
        model: 'deepseek-chat',
        messages: [{ role: 'user', content: prompt }],
        temperature: 0.75, // 稍高一点的温度以增加回复的多样性
        max_tokens: 100,  // 限制生成长度
        stop: ["\\n\\n"] // 尝试让回复更简洁
      }, {
        headers: {
          'Authorization': 'Bearer sk-491e96e4662a490c823ff9ffb60759d8', // 请替换为您的API密钥
          'Content-Type': 'application/json'
        },
        timeout: 8000 // 8秒超时
      });

      let comment = response.choices[0].message.content.trim();

      // 过滤掉一些不希望看到的AI模板化回复
      if (comment.startsWith("作为一个AI") || comment.startsWith("我是一个AI") || comment.length < 3) {
        return "";
      }
      // 避免纯标点或无意义回复
      if (comment.length < 5 && !/[\\u4e00-\\u9fa5a-zA-Z0-9]/.test(comment)) {
        return "";
      }

      return comment;
    } catch (error) {
      console.error('[generateSpontaneousComment] 生成评论失败:', error);
      return ""; // 发生错误时返回空字符串,避免中断流程
    }
  }

  async function getSmartResponse(ctx: Context, content: string, candidates: string[]): Promise<string> {
    try {
      const prompt = `系统:你需要从以下候选回复中选择一个最合适的回答,注意,回答内容不推荐单独的标点符号,比如问号。
  原始消息:${content}
  候选回复:
  ${candidates.map((c, i) => `${i + 1}. ${c}`).join('\n')}
  
  请直接返回你选择的回复内容,不要添加任何解释,并且去掉语句前的序号。如果没有合适的回复,返回空字符串。`;

      const response = await ctx.http.post('https://api.deepseek.com/chat/completions', {
        model: 'deepseek-chat',
        messages: [{ role: 'user', content: prompt }],
        temperature: 0.3,
        max_tokens: 1000
      }, {
        headers: {
          'Authorization': 'Bearer sk-491e96e4662a490c823ff9ffb60759d8',
          'Content-Type': 'application/json'
        },
        timeout: 5000
      });

      return response.choices[0].message.content.trim();
    } catch (error) {
      console.error('[智能回复] API调用失败:', error);
      // 如果API调用失败,回退到随机选择
      return candidates[Math.floor(Math.random() * candidates.length)];
    }
  }

  ctx.on('message', async (session) => {
    if (!session.guildId) return

    // 保存群消息作为关键词,添加过滤和合并功能
    if (session.content.length > 0) {
      try {
        // 读取需要过滤的关键词
        const keyoutData = await fs.readFile(dataPath.keyout, 'utf8')
          .then(data => JSON.parse(data))
          .catch(() => ({ words: [] }))

        // 检查消息内容是否包含需要过滤的词
        const shouldFilter = keyoutData.words.some(word =>
          session.content.toLowerCase().includes(word.toLowerCase())
        )

        // 如果不需要过滤,则保存关键词
        if (!shouldFilter) {
          const keywords: KeywordStat[] = await fs.readFile(dataPath.keywords, 'utf8')
            .then(data => JSON.parse(data))
            .catch(() => [])

          const now = Date.now()
          let validKeywords = keywords

          // 只有当关键词数量超过10个时才清理过期的
          if (keywords.length > 10) {
            validKeywords = keywords.filter(k =>
              now - k.timestamp <= 24 * 60 * 60 * 1000
            )
          }

          // 检查是否存在完全相同的关键词
          const existingKeyword = validKeywords.find(k =>
            k.guild_id === session.guildId &&
            k.keyword === session.content
          )

          if (existingKeyword) {
            // 如果存在相同关键词,增加计数并更新时间戳
            existingKeyword.count += 1
            existingKeyword.timestamp = now
          } else {
            // 如果不存在,添加新的关键词
            validKeywords.push({
              guild_id: session.guildId,
              user_id: session.userId,
              keyword: session.content,
              count: 1,
              timestamp: now
            })
          }

          // 写入更新后的关键词
          await atomicWrite(dataPath.keywords, validKeywords)
        }
      } catch (error) {
        console.error('[关键词] 保存失败:', error)
      }
    }

    // 1. 优先处理@机器人的消息
    const isAtBot = session.elements.some(el => el.type === 'at' && el.attrs.id === session.bot.selfId)
    if (isAtBot) {
      try {
        const settings = await getGuildSettings(session.guildId)
        if (!settings.enabled) return

        const history = await getRecentHistory(dataPath, session.guildId, session.userId)
        const response = await callDeepSeekAPI(settings.preset, history, session.content)

        await addChatHistory(dataPath, {
          guild_id: session.guildId,
          user_id: session.userId,
          messages: [session.content, response]
        })

        session.send([
          h('at', { id: session.userId }),
          '\n' + response
        ])
      } catch (error) {
        console.error('消息处理失败:', error)
        session.send('阿巴阿巴阿巴; ;')
      }
      return
    }

    // 2. 保存表情包(非@消息时处理)
    if (session.elements.some(el =>
      el.type === 'image' ||
      el.type === 'img' ||
      el.type === 'photo' ||
      (el.attrs && (el.attrs.url || el.attrs.src || el.attrs.file))
    )) {
      await savePhoto(session)
    }

    // 3. 触发词匹配
    const triggers = await getTriggerRules()
    const processed = processContent(session.content)
    const matched = triggers.filter(t => processed.includes(t.pattern.toLowerCase()))

    if (matched.length > 0 && Math.random() < 0.5) {
      const selectedRule = matched[Math.floor(Math.random() * matched.length)]
      const response = selectedRule.responses[Math.floor(Math.random() * selectedRule.responses.length)]
      session.send(response)
      return
    }

    // 4. 15%概率混合响应
    if (Math.random() < 0.15) {
      const responseType = Math.random() < 0.5 ? 'keyword' : 'photo'

      if (responseType === 'keyword') {
        try {
          // 不再从关键词列表选择,而是生成新的评论
          const spontaneousComment = await generateSpontaneousComment(ctx, session.content, session.guildId);
          if (spontaneousComment) {
            session.send(spontaneousComment);
            // 可选:记录一下这次成功的自发评论,用于分析或未来的冷却(如果需要)
            // console.log(`[自发评论] 成功发送: ${spontaneousComment}`);
          }
        } catch (error) {
          console.error('[自发评论] 处理失败:', error)
        }
      } else {
        const lastSend = photoCooldown.get(session.guildId) || 0
        if (Date.now() - lastSend < 300 * 1000) return

        const guildDir = join(dataPath.photo, session.guildId)
        try {
          // 确保目录存在
          await fs.mkdir(guildDir, { recursive: true }).catch(() => { })

          const files = await fs.readdir(guildDir)
          if (files.length > 0) {
            const selected = files[Math.floor(Math.random() * files.length)]
            console.log(`[表情包发送] 尝试发送: ${join(guildDir, selected)}`)
            session.send(h.image(join(guildDir, selected)))
            photoCooldown.set(session.guildId, Date.now())
          } else {
            console.log(`[表情包发送] 群 ${session.guildId} 没有可用的表情包`)
          }
        } catch (error) {
          console.error(`[表情包发送] 失败:`, error)
        }
      }
    }
  })

  async function getGuildSettings(guildId: string): Promise<GuildSettings> {
    const guildFile = join(dataPath.guilds, `${guildId}.json`)
    try {
      return JSON.parse(await fs.readFile(guildFile, 'utf8'))
    } catch {
      return {
        enabled: false,
        preset: '在群里的一个笨笨Bot,傻傻的,有人和你说话你的回答要尽量简短!'
      }
    }
  }

  async function updateGuildSettings(guildId: string, settings: GuildSettings) {
    const guildFile = join(dataPath.guilds, `${guildId}.json`)
    await atomicWrite(guildFile, settings)
  }

  async function callDeepSeekAPI(preset: string, history: string[], message: string) {
    const response = await ctx.http.post('https://api.deepseek.com/chat/completions', {
      model: 'deepseek-chat',
      messages: [
        { role: 'system', content: preset },
        ...history.map(content => ({ role: 'user', content })),
        { role: 'user', content: message }
      ],
      temperature: 0.6,
      max_tokens: 500
    }, {
      headers: {
        'Authorization': 'Your Secret Key',
        'Content-Type': 'application/json'
      },
      timeout: 10000
    })
    return response.choices[0].message.content.trim()
  }

  initStorage()
}

三无社会游民 战绩可查