框架:
思路:
这里,我们已经成功构建了一个可以监听QQ群聊消息的Bot啦,那么如何让“他”参与进来呢?
这里有两种方案:
PlanA:管他三七二十一,直接接入API,
Token毁灭者……PlanB:让Bot自我“学习”……
当然,接入现有语言模型的方案比比皆是,那么今天主要带来的思路便是如何让Bot自我学习~
准备动手:
功能框架:事件监听 → 消息处理流水线 → 多策略响应
构建数据库:
-- 创建数据库
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;
构建语言学习部分:
监听与处理
// 消息处理入口
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 '未知命令'
}
})
后期数据库维护:
定期删除(可以修改自动删除策略)
写入限制词等
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(/&/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()
}