diff --git a/.vscode/settings.json b/.vscode/settings.json index 6a18bf9..0bb386f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,10 +33,12 @@ "qwen", "tseslint", "userid", + "wangyifei", "wlpbbgiky", "Xauthor", "Xicon", "Xname", + "Yingbo", "Yoav", "zhaoyingbo" ], diff --git a/bun.lockb b/bun.lockb index b5538e6..4704e0b 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/constant/card.ts b/constant/card.ts index b96d4b9..44e6118 100644 --- a/constant/card.ts +++ b/constant/card.ts @@ -47,9 +47,27 @@ const autoReport = { }, } +const markdownSuccessCard = { + config: { + update_multi: true, + }, + elements: [ + { + tag: "markdown", + content: "${content}", + }, + { + tag: "hr", + }, + cardComponent.commonNote, + ], + header: cardComponent.successHeader, +} + const cardMap = { resultReport, autoReport, + markdownSuccessCard, } export default cardMap diff --git a/constant/function.ts b/constant/function.ts index 8e0fc2e..4445ba1 100644 --- a/constant/function.ts +++ b/constant/function.ts @@ -19,6 +19,11 @@ const functionMap = { xAuthor: "zhaoyingbo", xIcon: "🐙", }, + soupAgent: { + xName: "海龟汤 Agent", + xAuthor: "wangyifei15 🕹️ Yingbo", + xIcon: "🕹️", + }, } export default functionMap diff --git a/constant/message.ts b/constant/message.ts index 9498ca1..8f4ed25 100644 --- a/constant/message.ts +++ b/constant/message.ts @@ -9,3 +9,10 @@ export enum RespMessage { cancelFailed = "取消订阅失败", summaryFailed = "总结失败", } + +export enum SoupGameMessage { + startFailed = "游戏启动失败", + hasStarted = "游戏已经在进行中啦!", + hasStopped = "游戏已经结束啦!", + chatFailed = "模型调用失败,请再试一次吧!", +} diff --git a/controller/soupAgent/index.ts b/controller/soupAgent/index.ts index 6ba73b9..8100683 100644 --- a/controller/soupAgent/index.ts +++ b/controller/soupAgent/index.ts @@ -1,13 +1,206 @@ +import { SoupGameMessage } from "../../constant/message" import db from "../../db" +import { SoupGame } from "../../db/soupGame" import { Context } from "../../types" +import { isP2POrAtBot } from "../../utils/message" + /** * 开启或者停止游戏 * @param ctx * @param value */ -const startOrStopGame = async (ctx: Context, value: boolean) => { - const chat = await db.chat.getAndCreate(ctx) - if (!chat) { - throw new Error("chat not found") +const startOrStopGame = async ( + ctx: Context, + value: boolean, + which: "auto" | "manual" = "manual" +) => { + const { + logger, + larkBody: { chatId, messageId }, + attachService, + larkCard, + larkService, + } = ctx + const cardGender = larkCard.child("soupAgent") + if (!chatId) { + logger.error("chatId is required") + return } + // 获取正在进行中的游戏 + const activeGame = await db.soupGame.getActiveOneByChatId(chatId) + if (!activeGame) { + logger.info(`chatId: ${chatId} has no active game`) + } + // 停止游戏 + if (!value) { + // 没有进行中的游戏 + if (!activeGame) { + await larkService.message.replyCard( + messageId, + cardGender.genSuccessCard(SoupGameMessage.hasStopped) + ) + return + } + // 有进行中的游戏,关闭游戏 + logger.info(`chatId: ${chatId} is closing the game`) + const res = await db.soupGame.close(activeGame.id) + if (!res) { + logger.error(`chatId: ${chatId} failed to close the game`) + await larkService.message.replyCard( + messageId, + cardGender.genErrorCard(SoupGameMessage.startFailed) + ) + } + // 手动结束 + if (which === "manual") { + await larkService.message.replyCard( + messageId, + cardGender.genCard("markdownSuccessCard", { + content: ` +游戏结束! + +**汤面:**${activeGame?.query} + +**汤底:**${activeGame?.answer}`, + }) + ) + } else { + // 自动结束 + await larkService.message.replyCard( + messageId, + cardGender.genCard("markdownSuccessCard", { + llmRes: ` +恭喜您回答正确!游戏结束! + +**汤面:**${activeGame?.query} + +**汤底:**${activeGame?.answer}`, + }) + ) + } + return + } + + // 开始游戏,有进行中的游戏 + if (activeGame) { + logger.info(`chatId: ${chatId} has an active game`) + await larkService.message.replyCard( + messageId, + cardGender.genSuccessCard(SoupGameMessage.hasStarted) + ) + return + } + logger.info(`chatId: ${chatId} is starting a new game`) + // 没有进行中的游戏,开始新游戏 + const game = await attachService.startSoup() + if (!game) { + logger.error(`chatId: ${chatId} failed to start a new game`) + await larkService.message.replyCard( + messageId, + cardGender.genErrorCard(SoupGameMessage.startFailed) + ) + return + } + // 写到数据库 + const newSoupGame: SoupGame = { + ...game, + chatId, + history: [], + active: true, + } + const res = await db.soupGame.create(newSoupGame) + if (!res) { + logger.error(`chatId: ${chatId} failed to create a new game`) + await larkService.message.replyCard( + messageId, + cardGender.genErrorCard(SoupGameMessage.startFailed) + ) + return + } + logger.info(`chatId: ${chatId} created a new game`) + // 回复用户模型的消息 + await larkService.message.replyCard( + messageId, + cardGender.genCard("markdownSuccessCard", { + content: ` +游戏开始啦! + +**题目:**${game.title} + +**汤面:**${game.query} + +艾特机器人说“结束游戏”即可结束游戏 + `, + }) + ) } + +const chat2Soup = async (ctx: Context) => { + const { + larkBody: { msgText, chatId, messageId }, + logger, + attachService, + larkCard, + larkService, + } = ctx + const cardGender = larkCard.child("soupAgent") + const activeGame = await db.soupGame.getActiveOneByChatId(chatId) + if (!activeGame) { + logger.info(`chatId: ${chatId} has no active game`) + return + } + const { + data: { message_id }, + } = await larkService.message.reply(messageId, "text", "模型生成中...") + + const res = await attachService.chat2Soup({ + user_query: msgText, + soup_id: activeGame.title, + history: "", + }) + if (!res) { + logger.error(`chatId: ${chatId} failed to get soup result`) + await larkService.message.replyCard( + messageId, + cardGender.genErrorCard(SoupGameMessage.chatFailed) + ) + return + } + // 用户答对了 + if (res.type === "END") { + await startOrStopGame(ctx, false, "auto") + return + } + // 继续游戏,更新历史记录 + await db.soupGame.insertHistory(activeGame.id, [ + ...activeGame.history, + msgText, + ]) + // 回复用户模型的消息 + await larkService.message.update(message_id, res.content, true) +} + +/** + * 海龟汤游戏 + * @param ctx + */ +const soupAgent = async (ctx: Context) => { + const { + larkBody: { msgText, chatId }, + } = ctx + if (!chatId) return + if (msgText === "开始游戏" && isP2POrAtBot(ctx)) { + startOrStopGame(ctx, true) + return true + } + if (msgText === "结束游戏" && isP2POrAtBot(ctx)) { + startOrStopGame(ctx, false) + return true + } + const activeGame = await db.soupGame.getActiveOneByChatId(chatId) + if (!activeGame) return false + chat2Soup(ctx) + return true +} + +export default soupAgent diff --git a/db/index.ts b/db/index.ts index 2ca54b0..bba1bf3 100644 --- a/db/index.ts +++ b/db/index.ts @@ -4,6 +4,7 @@ import gitlabProject from "./gitlabProject/index." import grpSumLog from "./grpSumLog" import log from "./log" import receiveGroup from "./receiveGroup" +import soupGame from "./soupGame" import user from "./user" const db = { @@ -14,6 +15,7 @@ const db = { user, grpSumLog, gitlabProject, + soupGame, } export default db diff --git a/db/soupGame/index.ts b/db/soupGame/index.ts new file mode 100644 index 0000000..bbf64ed --- /dev/null +++ b/db/soupGame/index.ts @@ -0,0 +1,69 @@ +import { RecordModel } from "pocketbase" + +import { managePbError } from "../../utils/pbTools" +import pbClient from "../pbClient" + +const DB_NAME = "soupGame" + +export interface SoupGame { + chatId: string + title: string + query: string + answer: string + history: string[] + active: boolean +} + +export type SoupGameModel = SoupGame & RecordModel + +/** + * 创建一个新的SoupGame记录 + * @param {SoupGame} soupGame - SoupGame对象 + * @returns {Promise} - 创建的SoupGame记录 + */ +const create = (soupGame: SoupGame) => + managePbError(() => + pbClient.collection(DB_NAME).create(soupGame) + ) + +/** + * 根据chatId获取一个活跃的SoupGame记录 + * @param {string} chatId - 聊天ID + * @returns {Promise} - 获取的SoupGame记录 + */ +const getActiveOneByChatId = (chatId: string) => + managePbError(() => + pbClient + .collection(DB_NAME) + .getFirstListItem(`chatId = "${chatId}" && active = true`) + ) + +/** + * 根据chatId关闭一个SoupGame记录 + * @param {string} chatId - 聊天ID + * @returns {Promise} - 更新的SoupGame记录 + */ +const close = (id: string) => + managePbError(() => + pbClient.collection(DB_NAME).update(id, { active: false }) + ) + +/** + * 根据chatId插入历史记录 + * @param {string} chatId - 聊天ID + * @param {string} history - 历史记录 + * @returns {Promise} - 更新的SoupGame记录 + */ +const insertHistory = (id: string, history: string[]) => + managePbError(() => + pbClient.collection(DB_NAME).update(id, { history }) + ) + +const soupGame = { + create, + getActiveOneByChatId, + close, + insertHistory, +} + +export default soupGame diff --git a/package.json b/package.json index 5956d69..b5beb78 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "lint-staged": "^15.3.0", "oxlint": "^0.13.2", "prettier": "^3.4.2", - "typescript-eslint": "^8.19.1" + "typescript-eslint": "^8.20.0" }, "peerDependencies": { "typescript": "^5.5.4" @@ -39,9 +39,9 @@ "@egg/hooks": "^1.2.0", "@egg/lark-msg-tool": "^1.21.0", "@egg/logger": "^1.6.0", - "@egg/net-tool": "^1.22.0", + "@egg/net-tool": "^1.23.0", "@egg/path-tool": "^1.4.1", - "@langchain/core": "^0.3.29", + "@langchain/core": "^0.3.30", "@langchain/langgraph": "^0.2.39", "@langchain/openai": "^0.3.17", "joi": "^17.13.3", diff --git a/routes/bot/eventMsg.ts b/routes/bot/eventMsg.ts index c176c7b..e762105 100644 --- a/routes/bot/eventMsg.ts +++ b/routes/bot/eventMsg.ts @@ -2,17 +2,9 @@ import tempMap from "../../constant/template" import gitlabEvent from "../../controller/gitlabEvent" import groupAgent from "../../controller/groupAgent" import createKVTemp from "../../controller/sheet/createKVTemp" +import soupAgent from "../../controller/soupAgent" import { Context } from "../../types" - -/** - * 判断是否为非群聊和非艾特机器人的消息 - * @param {Context} ctx - 上下文数据,包含body, logger和larkService - * @returns {boolean} 是否为非法消息 - */ -const isNotP2POrAtBot = (ctx: Context) => { - const { larkBody, appInfo } = ctx - return !larkBody.isP2P && !larkBody.isAtBot(appInfo.appName) -} +import { isNotP2POrAtBot } from "../../utils/message" /** * 过滤出非法消息,如果发表情包就直接发回去 @@ -247,7 +239,8 @@ const manageCMDMsg = async (ctx: Context) => { export const manageEventMsg = async (ctx: Context) => { // 过滤非法消息 if (await filterIllegalMsg(ctx)) return - // TODO: 海龟汤 + // 海龟汤 + if (await soupAgent(ctx)) return // 非群聊和非艾特机器人的消息不处理 if (isNotP2POrAtBot(ctx)) return // 处理命令消息 diff --git a/services/attach/index.ts b/services/attach/index.ts index d0e5239..f9ea160 100644 --- a/services/attach/index.ts +++ b/services/attach/index.ts @@ -13,8 +13,17 @@ interface Chat2SoupResp { } interface Soup { + /** + * 海龟汤ID + */ title: string + /** + * 海龟汤内容 + */ query: string + /** + * 海龟汤答案 + */ answer: string } diff --git a/test/soupAgent/chat.http b/test/soupAgent/chat.http new file mode 100644 index 0000000..00e2198 --- /dev/null +++ b/test/soupAgent/chat.http @@ -0,0 +1,4 @@ +POST http://localhost:3000/bot?app=egg HTTP/1.1 +content-type: application/json + +{"schema":"2.0","header":{"event_id":"a953974feed34108023c8b93b2050ee8","token":"tV9djUKSjzVnekV7xTg2Od06NFTcsBnj","create_time":"1736840977726","event_type":"im.message.receive_v1","tenant_key":"2ee61fe50f4f1657","app_id":"cli_a1eff35b43b89063"},"event":{"message":{"chat_id":"oc_8c789ce8f4ecc6695bb63ca6ec4c61ea","chat_type":"group","content":"{\"text\":\"高跟鞋上带刀子么\"}","create_time":"1736840977558","message_id":"om_d8740d16da00fb65ece605492f8d0c9a","message_type":"text","update_time":"1736840977558"},"sender":{"sender_id":{"open_id":"ou_470ac13b8b50fc472d9d8ee71e03de26","union_id":"on_9dacc59a539023df8b168492f5e5433c","user_id":"zhaoyingbo"},"sender_type":"user","tenant_key":"2ee61fe50f4f1657"}}} \ No newline at end of file diff --git a/test/soupAgent/startGame.http b/test/soupAgent/startGame.http new file mode 100644 index 0000000..a64c521 --- /dev/null +++ b/test/soupAgent/startGame.http @@ -0,0 +1,4 @@ +POST http://localhost:3000/bot?app=egg HTTP/1.1 +content-type: application/json + +{"schema":"2.0","header":{"event_id":"5831cd388aa714f2c7a1169116f7713e","token":"tV9djUKSjzVnekV7xTg2Od06NFTcsBnj","create_time":"1736839850758","event_type":"im.message.receive_v1","tenant_key":"2ee61fe50f4f1657","app_id":"cli_a1eff35b43b89063"},"event":{"message":{"chat_id":"oc_8c789ce8f4ecc6695bb63ca6ec4c61ea","chat_type":"group","content":"{\"text\":\"@_user_1 开始游戏\"}","create_time":"1736839850411","mentions":[{"id":{"open_id":"ou_032f507d08f9a7f28b042fcd086daef5","union_id":"on_7111660fddd8302ce47bf1999147c011","user_id":""},"key":"@_user_1","name":"小煎蛋","tenant_key":"2ee61fe50f4f1657"}],"message_id":"om_150b786da5e9fa5f1adcd9aa0b2a2caf","message_type":"text","update_time":"1736839850411"},"sender":{"sender_id":{"open_id":"ou_470ac13b8b50fc472d9d8ee71e03de26","union_id":"on_9dacc59a539023df8b168492f5e5433c","user_id":"zhaoyingbo"},"sender_type":"user","tenant_key":"2ee61fe50f4f1657"}}} \ No newline at end of file diff --git a/test/soupAgent/stopGame.http b/test/soupAgent/stopGame.http new file mode 100644 index 0000000..01b8122 --- /dev/null +++ b/test/soupAgent/stopGame.http @@ -0,0 +1,4 @@ +POST http://localhost:3000/bot?app=egg HTTP/1.1 +content-type: application/json + +{"schema":"2.0","header":{"event_id":"774d0a7ef767c8414f31d3da3372fea7","token":"tV9djUKSjzVnekV7xTg2Od06NFTcsBnj","create_time":"1736840049741","event_type":"im.message.receive_v1","tenant_key":"2ee61fe50f4f1657","app_id":"cli_a1eff35b43b89063"},"event":{"message":{"chat_id":"oc_8c789ce8f4ecc6695bb63ca6ec4c61ea","chat_type":"group","content":"{\"text\":\"@_user_1 结束游戏\"}","create_time":"1736840049538","mentions":[{"id":{"open_id":"ou_032f507d08f9a7f28b042fcd086daef5","union_id":"on_7111660fddd8302ce47bf1999147c011","user_id":""},"key":"@_user_1","name":"小煎蛋","tenant_key":"2ee61fe50f4f1657"}],"message_id":"om_9fda842ec5ede6f97fcdcc0fa6231203","message_type":"text","update_time":"1736840049538"},"sender":{"sender_id":{"open_id":"ou_470ac13b8b50fc472d9d8ee71e03de26","union_id":"on_9dacc59a539023df8b168492f5e5433c","user_id":"zhaoyingbo"},"sender_type":"user","tenant_key":"2ee61fe50f4f1657"}}} \ No newline at end of file diff --git a/utils/message.ts b/utils/message.ts new file mode 100644 index 0000000..a9fe74b --- /dev/null +++ b/utils/message.ts @@ -0,0 +1,20 @@ +import { Context } from "../types" + +/** + * 判断是否为非群聊和非艾特机器人的消息 + * @param {Context} ctx - 上下文数据,包含body, logger和larkService + * @returns {boolean} 是否为非法消息 + */ +export const isNotP2POrAtBot = (ctx: Context) => { + const { larkBody, appInfo } = ctx + return !larkBody.isP2P && !larkBody.isAtBot(appInfo.appName) +} + +/** + * 判断是否为群聊或者艾特机器人的消息 + * @param ctx + * @returns + */ +export const isP2POrAtBot = (ctx: Context) => { + return !isNotP2POrAtBot(ctx) +}