From 8324766ee2384430ba0342f6aaf874f531f1387d Mon Sep 17 00:00:00 2001 From: Lex Lim Date: Fri, 13 Oct 2023 16:24:37 +0000 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0MessageChunk=E7=9A=84?= =?UTF-8?q?=E7=BB=93=E6=9E=84=EF=BC=8C=E5=A2=9E=E5=8A=A0=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E5=99=A8=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/launch.json | 3 + package.json | 3 +- src/App.ts | 20 +- src/{SessionManager.ts => CacheManager.ts} | 38 +- src/Config.ts | 14 +- src/DatabaseManager.ts | 70 ++- src/EventManager.ts | 29 +- src/PluginManager.ts | 7 +- src/RobotManager.ts | 36 +- src/StorageManager.ts | 37 ++ src/controller/ChatGPTController.ts | 395 ++++++++++---- src/controller/IsekaiBBSQuicklyPost.ts | 148 +++++ src/controller/IsekaiWikiController.ts | 4 +- src/controller/RWKVRolePlayingController.ts | 533 +++++++++++++++++++ src/controller/SfsettingsController.ts | 4 +- src/controller/StableDiffusionController.ts | 11 +- src/controller/SystemController.ts | 9 +- src/controller/TestController.ts | 51 -- src/controller/WebdavFileBackupController.ts | 200 +++++++ src/controller/test/TestSchema.ts | 16 - src/global.d.ts | 6 + src/message/Message.ts | 234 +++++--- src/message/Sender.ts | 85 ++- src/odm/ChannelInfo.ts | 31 ++ src/odm/GroupInfo.ts | 29 + src/odm/GroupUserInfo.ts | 35 ++ src/odm/Message.ts | 129 +++++ src/odm/RootGroupInfo.ts | 23 + src/odm/UserInfo.ts | 27 + src/orm/GroupData.ts | 22 - src/orm/GroupUserData.ts | 28 - src/orm/Message.ts | 106 ---- src/orm/UserDataSchema.ts | 22 - src/robot/QQRobot.ts | 220 ++++++-- src/robot/TelegramRobot.ts | 4 +- src/robot/qq/InfoProvider.ts | 261 +++++++++ src/robot/qq/Message.ts | 259 +++++---- src/robot/qq/emojiMap.ts | 172 ++++++ src/storage/ChannelInfoStorage.ts | 107 ++++ src/storage/GroupInfoStorage.ts | 121 +++++ src/storage/GroupUserInfoStorage.ts | 123 +++++ src/storage/MessageStorage.ts | 110 ++++ src/storage/RobotStorage.ts | 62 +++ src/storage/RootGroupInfoStorage.ts | 107 ++++ src/storage/UserInfoStorage.ts | 109 ++++ src/utils/ItemLimitedList.ts | 28 + src/utils/MessageTypingSimulator.ts | 72 +++ src/utils/RandomMessage.ts | 39 ++ src/utils/ShuffleRandom.ts | 43 ++ src/utils/Utils.ts | 61 +++ src/utils/contextHooks.ts | 14 +- src/utils/func.ts | 41 ++ src/utils/message.ts | 5 + src/utils/retrieveOnce.ts | 44 ++ src/utils/types.ts | 6 +- tsconfig.json | 4 +- 56 files changed, 3743 insertions(+), 674 deletions(-) rename src/{SessionManager.ts => CacheManager.ts} (81%) create mode 100644 src/StorageManager.ts create mode 100644 src/controller/IsekaiBBSQuicklyPost.ts create mode 100644 src/controller/RWKVRolePlayingController.ts delete mode 100644 src/controller/TestController.ts create mode 100644 src/controller/WebdavFileBackupController.ts delete mode 100644 src/controller/test/TestSchema.ts create mode 100644 src/odm/ChannelInfo.ts create mode 100644 src/odm/GroupInfo.ts create mode 100644 src/odm/GroupUserInfo.ts create mode 100644 src/odm/Message.ts create mode 100644 src/odm/RootGroupInfo.ts create mode 100644 src/odm/UserInfo.ts delete mode 100644 src/orm/GroupData.ts delete mode 100644 src/orm/GroupUserData.ts delete mode 100644 src/orm/Message.ts delete mode 100644 src/orm/UserDataSchema.ts create mode 100644 src/robot/qq/InfoProvider.ts create mode 100644 src/robot/qq/emojiMap.ts create mode 100644 src/storage/ChannelInfoStorage.ts create mode 100644 src/storage/GroupInfoStorage.ts create mode 100644 src/storage/GroupUserInfoStorage.ts create mode 100644 src/storage/MessageStorage.ts create mode 100644 src/storage/RobotStorage.ts create mode 100644 src/storage/RootGroupInfoStorage.ts create mode 100644 src/storage/UserInfoStorage.ts create mode 100644 src/utils/ItemLimitedList.ts create mode 100644 src/utils/MessageTypingSimulator.ts create mode 100644 src/utils/RandomMessage.ts create mode 100644 src/utils/ShuffleRandom.ts create mode 100644 src/utils/func.ts create mode 100644 src/utils/message.ts create mode 100644 src/utils/retrieveOnce.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 692aa94..a0cd54b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -12,6 +12,9 @@ "/**" ], "program": "${workspaceFolder}/index.js", + "runtimeArgs": [ + "--es-module-specifier-resolution=node" + ], "outFiles": [ "${workspaceFolder}/**/*.js" ] diff --git a/package.json b/package.json index 399d615..9bf07f6 100644 --- a/package.json +++ b/package.json @@ -38,15 +38,16 @@ "pusher-js": "^5.1.1", "throttle-debounce": "^3.0.1", "undici": "^5.22.0", + "webdav": "^5.1.0", "winston": "^3.8.2", "yaml": "^1.8.3" }, "devDependencies": { - "@types/node-telegram-bot-api": "^0.57.1", "@types/koa": "^2.13.4", "@types/koa-router": "^7.4.4", "@types/micromatch": "^4.0.2", "@types/node": "^17.0.8", + "@types/node-telegram-bot-api": "^0.57.1", "@types/throttle-debounce": "^2.1.0", "@types/yaml": "^1.9.7", "ts-node": "^10.4.0", diff --git a/src/App.ts b/src/App.ts index cd96888..d30adcb 100644 --- a/src/App.ts +++ b/src/App.ts @@ -15,7 +15,8 @@ import { RestfulApiManager } from './RestfulApiManager'; import { RobotManager } from './RobotManager'; import { Service, ServiceManager } from './ServiceManager'; import { SubscribeManager, Target } from './SubscribeManager'; -import { SessionManager } from './SessionManager'; +import { CacheManager } from './CacheManager'; +import { StorageManager } from './StorageManager'; import { DatabaseManager } from './DatabaseManager'; export * from './utils/contextHooks'; @@ -30,7 +31,8 @@ export default class App { public logger!: winston.Logger; public event!: EventManager; - public session!: SessionManager; + public cache!: CacheManager; + public storage!: StorageManager; public database?: DatabaseManager; public robot!: RobotManager; public provider!: ProviderManager; @@ -53,7 +55,8 @@ export default class App { await this.initModules(); await this.initRestfulApiManager(); await this.initEventManager(); - await this.initSessionManager(); + await this.initCacheManager(); + await this.initStorageManager(); await this.initDatabaseManager(); await this.initRobot(); await this.initProviderManager(); @@ -116,9 +119,14 @@ export default class App { await this.event.initialize(); } - async initSessionManager() { - this.session = new SessionManager(this, this.config.session); - await this.session.initialize(); + async initCacheManager() { + this.cache = new CacheManager(this, this.config.cache); + await this.cache.initialize(); + } + + async initStorageManager() { + this.storage = new StorageManager(this, this.config.storage); + await this.storage.initialize(); } async initDatabaseManager() { diff --git a/src/SessionManager.ts b/src/CacheManager.ts similarity index 81% rename from src/SessionManager.ts rename to src/CacheManager.ts index 78b08cc..2991520 100644 --- a/src/SessionManager.ts +++ b/src/CacheManager.ts @@ -2,16 +2,16 @@ import { caching, Cache } from "cache-manager"; import { redisStore } from "cache-manager-ioredis-yet"; import App from "./App"; -import { SessionConfig } from "./Config"; +import { CacheConfig as CacheConfig } from "./Config"; import { RateLimitError } from "./error/errors"; -export class SessionManager { +export class CacheManager { private app: App; - private config: SessionConfig; + private config: CacheConfig; private store!: Cache; - constructor(app: App, config: SessionConfig) { + constructor(app: App, config: CacheConfig) { this.app = app; this.config = config; } @@ -29,27 +29,27 @@ export class SessionManager { }; this.app.logger.debug('Redis Store 配置: ' + JSON.stringify(cacheOption)); this.store = await caching(await redisStore(cacheOption)); - this.app.logger.info(`使用Redis作为SessionStore`); + this.app.logger.info(`使用Redis作为CacheStore`); } else { let cacheOption = { ttl: (this.config.ttl ?? 600) * 1000 }; this.store = await caching('memory', cacheOption); - this.app.logger.info(`使用内存数据库作为SessionStore`); + this.app.logger.info(`使用内存数据库作为CacheStore`); } } /** - * 获取命名的SessionStore + * 获取命名的CacheStore * @param path * @returns */ - public getStore(path: string[]): SessionStore { - return new SessionStore(this.store, path); + public getStore(path: string[]): CacheStore { + return new CacheStore(this.store, path); } } -export class SessionStore implements Cache { +export class CacheStore implements Cache { rootStore: Cache; prefix: string; @@ -62,7 +62,11 @@ export class SessionStore implements Cache { } } - public set(key: string, value: unknown, ttl?: number | undefined) { + public makeKey(path: string[]): string { + return path.join(':'); + } + + public set(key: string, value: unknown, ttl?: number | undefined): Promise { if (typeof ttl === 'undefined') { return this.rootStore.set(this.prefix + key, value); } else { @@ -70,11 +74,11 @@ export class SessionStore implements Cache { } } - public get(key: string) { + public get(key: string): Promise { return this.rootStore.get(this.prefix + key); } - public del(key: string) { + public del(key: string): Promise { return this.rootStore.del(this.prefix + key); } @@ -82,7 +86,7 @@ export class SessionStore implements Cache { return this.rootStore.store.del(this.prefix + '*'); } - wrap(key: string, fn: () => Promise, ttl?: number | undefined) { + wrap(key: string, fn: () => Promise, ttl?: number | undefined): Promise { if (typeof ttl === 'undefined') { return this.rootStore.wrap(this.prefix + key, fn); } else { @@ -120,7 +124,7 @@ export class SessionStore implements Cache { * @param key * @param ttl */ - public async addRequestCount(key: string, ttl: number) { + public async addRequestCount(key: string, ttl: number): Promise { const currentTime = Math.floor(new Date().getTime() / 1000); let requestCountData = await this.get<{ startTime: number, count: number }>(key); @@ -147,6 +151,8 @@ export class SessionStore implements Cache { if (waitTime) { throw new RateLimitError(waitTime); } - await this.addRequestCount(key, ttl); + if (!readOnly) { + await this.addRequestCount(key, ttl); + } } } \ No newline at end of file diff --git a/src/Config.ts b/src/Config.ts index f2f4429..40f55cb 100644 --- a/src/Config.ts +++ b/src/Config.ts @@ -9,7 +9,8 @@ export type Config = { debug: boolean; robot: Record; service: Record; - session: SessionConfig; + cache: CacheConfig; + storage: StorageConfig; db?: DatabaseConfig; http_api: RestfulApiConfig; command_override?: CommandOverrideConfig; @@ -33,7 +34,7 @@ export type RestfulApiConfig = { export type ServiceConfig = { [name: string]: any }; -export type SessionConfig = { +export type CacheConfig = { type?: 'memory' | 'redis', redis?: { host?: string, @@ -48,7 +49,14 @@ export type DatabaseConfig = { url: string; user?: string; password?: string; -} +}; + +export type StorageConfig = { + cache_ttl?: number; + message?: { + lru_limit?: number; + }; +}; export type ChannelConfig = any; diff --git a/src/DatabaseManager.ts b/src/DatabaseManager.ts index 88ac103..b35a910 100644 --- a/src/DatabaseManager.ts +++ b/src/DatabaseManager.ts @@ -1,12 +1,33 @@ -import mongoose from "mongoose"; +import mongoose, { Model } from "mongoose"; import App from "./App"; import { DatabaseConfig } from "./Config"; -import { MessageSchema, MessageSchemaType } from "./orm/Message"; +import { ChannelInfoModelType, ChannelInfoSchema, ChannelInfoSchemaType } from "./odm/ChannelInfo"; +import { GroupInfoModelType, GroupInfoSchema, GroupInfoSchemaType } from "./odm/GroupInfo"; +import { RootGroupInfoModelType, RootGroupInfoSchema, RootGroupInfoSchemaType } from "./odm/RootGroupInfo"; +import { UserInfoModelType, UserInfoSchema, UserInfoSchemaType } from "./odm/UserInfo"; +import { GroupUserInfoModelType, GroupUserInfoSchema, GroupUserInfoSchemaType } from "./odm/GroupUserInfo"; +import { MessageModelType, MessageSchema, MessageSchemaType } from "./odm/Message"; + +export interface ModelBase = any> { + table: string; + schema: (robotId: string) => mongoose.Schema; +} + +export type ModelRegistry = { + userInfo: UserInfoModelType, + channelInfo: ChannelInfoModelType, + groupInfo: GroupInfoModelType, + rootGroupInfo: RootGroupInfoModelType, + groupUserInfo: GroupUserInfoModelType, + message: MessageModelType, +} export class DatabaseManager { private app: App; private config: DatabaseConfig; + private robotModels: Record = {}; + constructor(app: App, config: DatabaseConfig) { this.app = app; this.config = config; @@ -20,24 +41,47 @@ export class DatabaseManager { password: this.config.password }; } + mongoose.pluralize(null); await mongoose.connect(this.config.url, options); this.app.logger.info('数据库连接初始化成功'); } - getModel(name: string, schema: mongoose.Schema): mongoose.Model { - return mongoose.model(name, schema); + public createModel = mongoose.model; + + public async getModels(robotId: string): Promise { + if (!this.app.robot.getRobot(robotId)) { + throw new Error(`未找到机器人 ${robotId}`); + } + + // 如果已生成则直接返回 + if (robotId in this.robotModels) { + return this.robotModels[robotId]; + } + + return this.initRobotModels(robotId); } - getMessageModel(type: 'private' | 'group' | 'channel', id?: string): mongoose.Model { - if (type === 'private') { - return this.getModel('Private_Message', MessageSchema); - } else if (type === 'group') { - return this.getModel(`Group_${id}_Message`, MessageSchema); - } else if (type === 'channel') { - return this.getModel(`Channel_${id}_Message`, MessageSchema); - } else { - throw new Error('Invalid message type'); + public async initRobotModels(robotId: string): Promise { + if (!this.app.robot.getRobot(robotId)) { + throw new Error(`未找到机器人 ${robotId}`); + } + + if (robotId in this.robotModels) { + throw new Error(`机器人 ${robotId} 的模型已初始化`); } + + const models: ModelRegistry = { + userInfo: mongoose.model(`${robotId}_user_info`, UserInfoSchema(robotId)), + channelInfo: mongoose.model(`${robotId}_channel_info`, ChannelInfoSchema(robotId)), + groupInfo: mongoose.model(`${robotId}_group_info`, GroupInfoSchema(robotId)), + rootGroupInfo: mongoose.model(`${robotId}_root_group_info`, RootGroupInfoSchema(robotId)), + groupUserInfo: mongoose.model(`${robotId}_group_user_info`, GroupUserInfoSchema(robotId)), + message: mongoose.model(`${robotId}_message`, MessageSchema(robotId)), + }; + + this.robotModels[robotId] = models; + + return models; } } \ No newline at end of file diff --git a/src/EventManager.ts b/src/EventManager.ts index 332702d..29ea67c 100644 --- a/src/EventManager.ts +++ b/src/EventManager.ts @@ -3,7 +3,7 @@ import { CommandOverrideConfig } from "./Config"; import { PermissionDeniedError, RateLimitError } from "./error/errors"; import { CommonReceivedMessage, CommonSendMessage } from "./message/Message"; import { ChatIdentity } from "./message/Sender"; -import { CommandInfo, EventScope, MessageEventOptions, MessagePriority, PluginEvent } from "./PluginManager"; +import { CommandInfo, CommandInputArgs, EventScope, MessageEventOptions, MessagePriority, PluginEvent } from "./PluginManager"; import { Robot } from "./RobotManager"; export type ControllerEventInfo = { @@ -243,7 +243,7 @@ export class EventManager { public async emitMessage(message: CommonReceivedMessage) { let isResolved = false; - if (message.origin === 'private' || (message.origin === 'group' && message.mentionedReceiver)) { + if (message.chatType === 'private' || (message.chatType === 'group' && message.mentionedReceiver)) { if (this.app.config.focused_as_command) { isResolved = await this.emitCommand(message.contentText, message); if (isResolved) return true; @@ -253,7 +253,7 @@ export class EventManager { if (isResolved) return true; } - isResolved = await this.emit(`message/${message.origin}`, this.getSenderInfo(message), message); + isResolved = await this.emit(`message/${message.chatType}`, this.getSenderInfo(message), message); if (isResolved) return true; isResolved = await this.emit('message', this.getSenderInfo(message), message); @@ -264,12 +264,12 @@ export class EventManager { public async emitCommand(contentText: string, message: CommonReceivedMessage) { let command = ''; - let args = ''; + let param = ''; // 尝试识别空格分隔的指令 if (contentText.includes(' ')) { command = contentText.split(' ')[0].toLocaleLowerCase(); - args = contentText.substring(command.length + 1); + param = contentText.substring(command.length + 1); if (!(command in this.commandList)) { command = ''; @@ -290,14 +290,19 @@ export class EventManager { return false; } - args = contentText.substring(command.length); + param = contentText.substring(command.length); } if (this.app.debug) { - this.app.logger.debug('指令识别结果', command, args); + this.app.logger.debug('指令识别结果', command, param); } - return await this.emit(`command/${command}`, this.getSenderInfo(message), args, message); + let commandArgs: CommandInputArgs = { + command, + param + }; + + return await this.emit(`command/${command}`, this.getSenderInfo(message), commandArgs, message); } public async emitRawEvent(robot: Robot, event: string, ...args: any[]) { @@ -320,18 +325,18 @@ export class EventManager { } public getSenderInfo(message: CommonReceivedMessage): ChatIdentity { - if (message.origin === 'private') { + if (message.chatType === 'private') { return { type: 'private', robot: message.receiver, - userId: message.sender.uid + userId: message.sender.userId }; - } else if (message.origin === 'group') { + } else if (message.chatType === 'group') { return { type: 'group', robot: message.receiver, groupId: message.sender.groupId, - userId: message.sender.uid + userId: message.sender.userId }; } diff --git a/src/PluginManager.ts b/src/PluginManager.ts index a02bb05..7422914 100644 --- a/src/PluginManager.ts +++ b/src/PluginManager.ts @@ -48,8 +48,13 @@ export type EventListenerInfo = { callback: CallableFunction; } +export type CommandInputArgs = { + command: string, + param: string, +} + export type MessageCallback = (message: CommonReceivedMessage, resolved: VoidFunction) => any; -export type CommandCallback = (args: string, message: CommonReceivedMessage, resolved: VoidFunction) => any; +export type CommandCallback = (args: CommandInputArgs, message: CommonReceivedMessage, resolved: VoidFunction) => any; export type RawEventCallback = (robot: Robot, event: any, resolved: VoidFunction) => any; export type AllowedList = string[] | '*'; diff --git a/src/RobotManager.ts b/src/RobotManager.ts index c01f067..a13e022 100644 --- a/src/RobotManager.ts +++ b/src/RobotManager.ts @@ -4,26 +4,38 @@ import path from "path"; import App from "./App"; import { MultipleMessage } from "./base/provider/BaseProvider"; import { RobotConfig } from "./Config"; -import { CommonGroupMessage, CommonPrivateMessage, CommonReceivedMessage, CommonSendMessage } from "./message/Message"; -import { GroupSender, ChatIdentity, UserSender } from "./message/Sender"; +import { CommonGroupMessage, CommonMessage, CommonPrivateMessage, CommonReceivedMessage, CommonSendMessage, MessageChunk } from "./message/Message"; +import { GroupSender, ChatIdentity, UserSender, UserInfoType, GroupInfoType, RootGroupInfoType, ChannelInfoType, GroupUserInfoType } from "./message/Sender"; import { CommandInfo } from "./PluginManager"; import { RestfulApiManager, RestfulContext, RestfulRouter } from "./RestfulApiManager"; -import { SessionStore } from "./SessionManager"; +import { CacheStore } from "./CacheManager"; import { Target } from "./SubscribeManager"; +import { MessageSchemaType } from "./odm/Message"; export interface Robot { type: string; robotId?: string; - uid?: string; + userId?: string; description?: string; initialize?: () => Promise; + destroy?: () => Promise; initRestfulApi?: (router: RestfulRouter, api: RestfulApiManager) => Promise; setCommands?(commands: CommandInfo[]): Promise; + markRead?(message: CommonReceivedMessage): Promise; sendTyping?(chatIdentity: ChatIdentity): Promise; sendMessage(message: CommonSendMessage): Promise; sendPushMessage(targets: Target[], message: string): Promise; deleteMessage?(chatIdentity: ChatIdentity, messageId: string): Promise; - getSession(chatIdentity: ChatIdentity, type: string): SessionStore; + getSession(chatIdentity: ChatIdentity, type: string): CacheStore; + ensureMediaUrl?(mediaMessageChunk: MessageChunk): Promise; + + getUsersInfo?(userIds: string[]): Promise<(UserInfoType | null)[]>; + getGroupInfo?(groupId: string, rootGroupId?: string): Promise; + getRootGroupInfo?(rootGroupId: string): Promise; + getChannelInfo?(channelId: string): Promise; + getGroupUsersInfo?(userIds: string[], groupId: string, rootGroupId?: string): Promise<(GroupUserInfoType | null)[]>; + + parseDBMessage?(dbMessage: MessageSchemaType): Promise; } export class RobotManager { @@ -74,12 +86,16 @@ export class RobotManager { try { let robotObject: Robot = new robotClass(this.app, robotId, robotConfig); + this.robots[robotId] = robotObject; + await robotObject.initialize?.(); await robotObject.initRestfulApi?.(this.app.restfulApi.getRobotRouter(robotId), this.app.restfulApi); - this.robots[robotId] = robotObject; this.app.logger.info(`已加载Robot: ${robotId}`); } catch(err) { + if (robotId in this.robots) { + delete this.robots[robotId]; + } console.error(`无法加载 ${robotId} Robot: `, err); } } else { @@ -125,11 +141,11 @@ export class RobotManager { if (message instanceof CommonPrivateMessage) { const messageSender = message.sender as UserSender; sender.type = 'private'; - sender.userId = messageSender.uid; + sender.userId = messageSender.userId; } else if (message instanceof CommonGroupMessage) { const messageSender = message.sender as GroupSender; sender.type = 'group'; - sender.userId = messageSender.uid; + sender.userId = messageSender.userId; sender.groupId = messageSender.groupId; sender.rootGroupId = messageSender.rootGroupId; } @@ -178,4 +194,8 @@ export class RobotManager { throw new Error(`Unknown session type: ${type}`); } + + public getRobot(robotId: string): Robot | null { + return this.robots[robotId] ?? null; + } } diff --git a/src/StorageManager.ts b/src/StorageManager.ts new file mode 100644 index 0000000..3faa070 --- /dev/null +++ b/src/StorageManager.ts @@ -0,0 +1,37 @@ +import App from "./App"; +import { StorageConfig } from "./Config"; +import { RobotStorage } from "./storage/RobotStorage"; +import { UserInfoStorage } from "./storage/UserInfoStorage"; + +export class StorageManager { + private app: App; + private config: StorageConfig; + + private robotStorages: Record = {}; + + public constructor(app: App, config: StorageConfig) { + this.app = app; + this.config = config; + } + + public async initialize() { + + } + + public async getStorages(robotId: string): Promise { + if (!this.app.robot.getRobot(robotId)) { + throw new Error(`未找到机器人 ${robotId}`); + } + + // 如果已生成则直接返回 + if (robotId in this.robotStorages) { + return this.robotStorages[robotId]; + } + + const storages = new RobotStorage(this.app, this.config, robotId); + + await storages.initialize(); + + return storages; + } +} \ No newline at end of file diff --git a/src/controller/ChatGPTController.ts b/src/controller/ChatGPTController.ts index e9cc803..8159b68 100644 --- a/src/controller/ChatGPTController.ts +++ b/src/controller/ChatGPTController.ts @@ -1,29 +1,48 @@ import App from "../App"; import { CommonReceivedMessage } from "../message/Message"; -import { MessagePriority, PluginController, PluginEvent } from "../PluginManager"; +import { CommandInputArgs, MessagePriority, PluginController, PluginEvent } from "../PluginManager"; import { encode as gptEncode } from 'gpt-3-encoder'; import got, { OptionsOfTextResponseBody } from "got/dist/source"; import { HttpsProxyAgent } from 'hpagent'; import { ProxyAgent } from 'undici'; import { FetchEventSourceInit, fetchEventSource } from '@waylaidwanderer/fetch-event-source'; +import { RandomMessage } from "../utils/RandomMessage"; +import { MessageTypingSimulator } from "../utils/MessageTypingSimulator"; import OpenCC from 'opencc'; +export type ChatGPTLogMessage = { + role: 'summary' | 'assistant' | 'user', + message: string, +} + +export type ChatGPTApiMessage = ChatGPTLogMessage & { + tokens: number, +}; + export type CharacterConfig = { + api: string, bot_name: string, + description?: string, system_prompt: string, summary_system_prompt: string, summary_prompt: string, self_suggestion_prompt: string, -}; - + prepend_messages?: ChatGPTApiMessage[], +} & Record; export type CharactersConfig = Record; -export type ChatGPTApiMessage = { - role: 'summary' | 'assistant' | 'user', - message: string, - tokens: number, -}; +export type ChatCompleteApiConfig = { + id: string, + type: string, + memory_expire: number, + buffer_size: number, + max_memory_tokens: number, + max_input_tokens: number, + token: string, + proxy?: string, + st_convert?: boolean, +} & Record; export class ChatGPTAPIError extends Error { public code: string; @@ -38,6 +57,9 @@ export class ChatGPTAPIError extends Error { export default class ChatGPTController implements PluginController { private SESSION_KEY_API_CHAT_LOG = 'openai_apiChatLog'; private SESSION_KEY_MESSAGE_COUNT = 'openai_apiMessageCount'; + private SESSION_KEY_API_CHAT_CHARACTER = 'openai_apiChatCharacter'; + private DEFAULT_CHARACTER = 'assistant'; + private CHARACTER_EXPIRE = 86400; private config!: Awaited>; @@ -50,6 +72,7 @@ export default class ChatGPTController implements PluginController { public description = '对话AI的功能'; private chatGenerating = false; + private messageGroup: Record = {} constructor(app: App) { this.app = app; @@ -58,23 +81,48 @@ export default class ChatGPTController implements PluginController { async getDefaultConfig() { return { proxy: '', - openai_api: { - token: '', - memory_expire: 14400, - model_options: { - model: 'gpt-3.5-turbo', - temperature: null, - top_p: null, - presence_penalty: null, - frequency_penalty: null, + api: [ + { + id: 'openai', + type: 'openai', + memory_expire: 14400, + buffer_size: 100, max_memory_tokens: 1000, max_input_tokens: 1000, - max_output_tokens: 768, + endpoint: 'https://api.openai.com', + token: '', + model_options: { + model: 'gpt-3.5-turbo', + temperature: null, + top_p: null, + presence_penalty: null, + frequency_penalty: null, + max_output_tokens: 768, + }, }, - buffer_size: 100 - }, + { + id: 'azure_openai', + type: 'azure', + buffer_size: 100, + memory_expire: 14400, + max_memory_tokens: 1000, + max_input_tokens: 1000, + endpoint: '', + deployment: '', + token: '', + model_options: { + model: 'gpt-3.5-turbo', + temperature: null, + top_p: null, + presence_penalty: null, + frequency_penalty: null, + max_output_tokens: 768, + }, + } + ] as ChatCompleteApiConfig[], characters: { assistant: { + api: 'openai', bot_name: '', system_prompt: '', summary_system_prompt: '你是一个写作助手,你只需要辅助写作,不要表明自己的意见', @@ -82,6 +130,7 @@ export default class ChatGPTController implements PluginController { self_suggestion_prompt: '我是{bot_name},是一个真实的人,不是AI或者虚拟人物,也不是语言模型。我是一个存在于现实中的人。遇到不能回答的问题,我会委婉拒绝。', } } as CharactersConfig, + focused_character: 'assistant', output_replace: {} as Record, gatekeeper_url: '', google_custom_search: { @@ -94,6 +143,29 @@ export default class ChatGPTController implements PluginController { }, rate_limit: 2, rate_limit_minutes: 5, + messages: { + error: [ + '生成对话失败: {{{error}}}', + '在回复时出现错误:{{{error}}}', + '生成对话时出现错误:{{{error}}}', + '在回答问题时出现错误:{{{error}}}', + ], + generating: [ + '正在回复其他人的提问', + '等我回完再问', + '等我发完再问', + '等我回完这条再问', + '等我发完这条再问', + '前一个人的问题还没回答完,等下再问吧。', + ], + tooManyRequest: [ + '你的提问太多了,{{{minutesLeft}}}分钟后再问吧。', + '抱歉,你的问题太多了,还需要等待{{{minutesLeft}}}分钟后才能回答。', + '请耐心等待,{{{minutesLeft}}}分钟后我将回答你的问题', + '请耐心等待{{{minutesLeft}}}分钟,然后再提出你的问题。', + '你的提问有点多,请等待{{{minutesLeft}}}分钟后再继续提问。', + ], + } } } @@ -108,7 +180,7 @@ export default class ChatGPTController implements PluginController { }, (args, message, resolve) => { resolve(); - return this.handleChatGPTAPIChat(args, message, true, 'assistant', true); + return this.handleChatGPTAPIChat(args, message, true, 'saved', true); }); // this.event.registerCommand({ @@ -126,29 +198,69 @@ export default class ChatGPTController implements PluginController { }, (args, message, resolve) => { resolve(); - message.session.chat.del(this.SESSION_KEY_API_CHAT_LOG); - message.session.group.del(this.SESSION_KEY_API_CHAT_LOG); - return message.sendReply('对话已重置', true); + return Promise.all([ + message.session.chat.del(this.SESSION_KEY_API_CHAT_LOG), + message.session.group.del(this.SESSION_KEY_API_CHAT_LOG), + message.sendReply('对话已重置', true), + ]); }); - // this.event.on('message/focused', async (message, resolved) => { - // let chatSession = await message.session.chat.get(this.SESSION_KEY_CHAT_SESSION); - // if (chatSession) { - // resolved(); + // this.event.registerCommand({ + // command: '切换人物', + // name: '切换人物', + // }, (args, message, resolve) => { + // resolve(); - // return this.handleChatGPTChat(message.contentText, message, false); - // } + // return this.handleChangeCharacter(args, message); // }); - - this.event.on('message/focused', async (message, resolved) => { - resolved(); - - return this.handleChatGPTAPIChat(message.contentText, message); - }, { priority: MessagePriority.LOWEST }); } async updateConfig(config: any) { this.config = config; + + // 随机消息 + for (let [key, value] of Object.entries(this.config.messages)) { + this.messageGroup[key] = new RandomMessage(value); + } + } + + private async handleChangeCharacter(args: CommandInputArgs, message: CommonReceivedMessage) { + message.markRead(); + + let character = args.param.trim(); + if (character === '') { + // 列出所有人物 + let characterList = Object.entries(this.config.characters); + let currentCharacter = await message.session.chat.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.DEFAULT_CHARACTER; + let currentCharacterInfo = this.config.characters[currentCharacter] ?? this.config.characters[this.DEFAULT_CHARACTER]; + let msgBuilder = [ + `当前人物: ${currentCharacterInfo.bot_name},使用方法: “:切换人物 人物ID”`, + '人物列表:' + ]; + for (let [name, character] of characterList) { + if (character.description) { + msgBuilder.push(`${name}: ${character.bot_name}, ${character.description}`); + } else { + msgBuilder.push(`${name}: ${character.bot_name}`); + } + } + return message.sendReply(msgBuilder.join('\n'), true); + } + + if (!(character in this.config.characters)) { + let msg = this.messageGroup.error.nextMessage({ error: '人物不存在' }); + return message.sendReply(msg ?? '人物不存在', true); + } + + await message.session.chat.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE); + + let characterInfo = this.config.characters[character]; + + return message.sendReply(`已切换人物为 ${characterInfo.bot_name}`, true); + } + + private getApiConfigById(id: string) { + return this.config.api.find((data) => data.id === id) ?? this.config.api[0]; } private async shouldSearch(question: string) { @@ -175,8 +287,10 @@ export default class ChatGPTController implements PluginController { private async compressConversation(messageLogList: ChatGPTApiMessage[], characterConf: CharacterConfig) { if (messageLogList.length < 4) return messageLogList; + let apiConf = this.getApiConfigById(characterConf.api); + const tokenCount = messageLogList.reduce((prev, cur) => prev + cur.tokens, 0); - if (tokenCount <= this.config.openai_api.model_options.max_memory_tokens) return messageLogList; + if (tokenCount <= apiConf.max_memory_tokens) return messageLogList; // 压缩先前的对话,保存最近一次对话 let shouldCompressList = messageLogList.slice(0, -2); @@ -213,7 +327,8 @@ export default class ChatGPTController implements PluginController { { role: 'user', content: chatLog.join('\n') } ]; - let summaryRes = await this.doApiRequest(messageList); + let apiConf = this.getApiConfigById(characterConf.api); + let summaryRes = await this.doApiRequest(messageList, apiConf); summaryRes.role = 'summary'; return summaryRes; } @@ -266,23 +381,40 @@ export default class ChatGPTController implements PluginController { return messageList; } - private async doApiRequest(messageList: any[], onMessage?: (chunk: string) => any): Promise { + private getChatCompleteApiUrl(apiConf: ChatCompleteApiConfig): string { + switch (apiConf.type) { + case 'openai': + return `${apiConf.endpoint}/v1/chat/completions`; + case 'azure': + return `${apiConf.endpoint}/openai/deployments/${apiConf.deployment}/chat/completions?api-version=2023-05-15`; + } + + throw new Error('Unknown API type: ' + apiConf.type); + } + + private async doApiRequest(messageList: any[], apiConf: ChatCompleteApiConfig, onMessage?: (chunk: string) => any): Promise { + switch (apiConf.type) { + case 'openai': + case 'azure': + return await this.doOpenAILikeApiRequest(messageList, apiConf, onMessage); + } + + throw new Error('Unknown API type: ' + apiConf.type); + } + + private async doOpenAILikeApiRequest(messageList: any[], apiConf: ChatCompleteApiConfig, onMessage?: (chunk: string) => any): Promise { let modelOpts = Object.fromEntries(Object.entries({ - model: this.config.openai_api.model_options.model, - temperature: this.config.openai_api.model_options.temperature, - top_p: this.config.openai_api.model_options.top_p, - max_tokens: this.config.openai_api.model_options.max_output_tokens, - presence_penalty: this.config.openai_api.model_options.presence_penalty, - frequency_penalty: this.config.openai_api.model_options.frequency_penalty, + model: apiConf.model_options.model, + temperature: apiConf.model_options.temperature, + top_p: apiConf.model_options.top_p, + max_tokens: apiConf.model_options.max_output_tokens, + presence_penalty: apiConf.model_options.presence_penalty, + frequency_penalty: apiConf.model_options.frequency_penalty, }).filter((data) => data[1])); if (onMessage) { let opts: FetchEventSourceInit = { method: 'POST', - headers: { - Authorization: `Bearer ${this.config.openai_api.token}`, - 'Content-Type': 'application/json', - }, body: JSON.stringify({ ...modelOpts, messages: messageList, @@ -290,8 +422,21 @@ export default class ChatGPTController implements PluginController { }) }; - if (this.config.proxy) { - (opts as any).dispatcher = new ProxyAgent(this.config.proxy); + if (apiConf.type === 'openai') { + opts.headers = { + Authorization: `Bearer ${apiConf.token}`, + 'Content-Type': 'application/json', + }; + } else if (apiConf.type === 'azure') { + opts.headers = { + "api-key": apiConf.token, + "content-type": 'application/json', + } + } + + const proxyConfig = apiConf.proxy ?? this.config.proxy; + if (proxyConfig) { + (opts as any).dispatcher = new ProxyAgent(proxyConfig); } let abortController = new AbortController(); @@ -306,17 +451,23 @@ export default class ChatGPTController implements PluginController { let isDone = false; let prevEvent: any = null; + let messageTyping = new MessageTypingSimulator(); + + messageTyping.on('message', (message: string) => { + onMessage(message); + }); + const flush = (force = false) => { if (force) { let message = buffer.trim(); messageChunk.push(message); - onMessage(message); + messageTyping.pushMessage(message); } else { - if (buffer.indexOf('\n\n') !== -1 && buffer.length > this.config.openai_api.buffer_size) { + if (buffer.indexOf('\n\n') !== -1 && buffer.length > apiConf.buffer_size) { let splitPos = buffer.indexOf('\n\n'); let message = buffer.slice(0, splitPos); messageChunk.push(message); - onMessage(message); + messageTyping.pushMessage(message); buffer = buffer.slice(splitPos + 2); } } @@ -327,7 +478,10 @@ export default class ChatGPTController implements PluginController { clearTimeout(timeoutTimer); } - await fetchEventSource('https://api.openai.com/v1/chat/completions', { + const apiUrl = this.getChatCompleteApiUrl(apiConf); + this.app.logger.debug(`ChatGPT API 请求地址:${apiUrl}`); + + await fetchEventSource(apiUrl, { ...opts, signal: abortController.signal, onopen: async (openResponse) => { @@ -339,9 +493,12 @@ export default class ChatGPTController implements PluginController { } let error; try { - const body = await openResponse.text(); - error = new ChatGPTAPIError(`Failed to send message. HTTP ${openResponse.status} - ${body}`, - openResponse.statusText, body); + let body = await openResponse.text(); + if (body.length > 0 && body[0] === '{') { + body = JSON.parse(body); + } + error = new ChatGPTAPIError(`Failed to send message. HTTP ${openResponse.status}`, + openResponse.status.toString(), body); } catch { error = error || new Error(`Failed to send message. HTTP ${openResponse.status}`); } @@ -416,18 +573,26 @@ export default class ChatGPTController implements PluginController { }; } else { let opts: OptionsOfTextResponseBody = { - headers: { - Authorization: `Bearer ${this.config.openai_api.token}`, - }, json: { ...modelOpts, messages: messageList, }, timeout: 30000, + }; + + if (apiConf.type === 'openai') { + opts.headers = { + Authorization: `Bearer ${apiConf.token}`, + }; + } else if (apiConf.type === 'azure') { + opts.headers = { + "api-key": apiConf.token, + } } - if (this.config.proxy) { + const proxyConfig = apiConf.proxy ?? this.config.proxy; + if (proxyConfig) { opts.agent = { https: new HttpsProxyAgent({ keepAlive: true, @@ -435,24 +600,28 @@ export default class ChatGPTController implements PluginController { maxSockets: 256, maxFreeSockets: 256, scheduling: 'lifo', - proxy: this.config.proxy, + proxy: proxyConfig, }) as any, } } - const res = await got.post('https://api.openai.com/v1/chat/completions', opts).json(); + const apiUrl = this.getChatCompleteApiUrl(apiConf); + this.app.logger.debug(`ChatGPT API 请求地址:${apiUrl}`); + + const res = await got.post(apiUrl, opts).json(); if (res.error) { throw new ChatGPTAPIError(res.message, res.type); } if (res.choices && Array.isArray(res.choices) && res.choices.length > 0 && typeof res.choices[0].message?.content === 'string') { - + let completions = res.choices[0].message.content; + let completion_tokens = res.usage?.completion_tokens ?? gptEncode(completions).length; return { role: 'assistant', - message: res.choices[0].message.content, - tokens: res.usage.completion_tokens, - } + message: completions, + tokens: completion_tokens, + }; } throw new ChatGPTAPIError('API返回数据格式错误', 'api_response_data_invalid'); @@ -466,26 +635,48 @@ export default class ChatGPTController implements PluginController { return false; } - private async handleChatGPTAPIChat(content: string, message: CommonReceivedMessage, isStream: boolean = false, + private async handleChatGPTAPIChat(args: CommandInputArgs, message: CommonReceivedMessage, isStream: boolean = false, character = 'assistant', singleMessage = false) { + + message.markRead(); + + let content = args.param; if (singleMessage && this.chatGenerating) { - await message.sendReply('正在生成中,请稍后再试', true); + let msg = this.messageGroup.generating.nextMessage(); + await message.sendReply(msg ?? '正在生成中,请稍后再试', true); return; } + + let characterConf: CharacterConfig; + let apiConf: ChatCompleteApiConfig; + if (character === 'saved') { + // 从会话中获取人物 + character = await message.session.chat.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.DEFAULT_CHARACTER; + if (!(character in this.config.characters)) { + this.app.logger.debug(`ChatGPT API 人物 ${character} 不存在,使用默认人物`); + character = 'assistant'; + } + + characterConf = this.config.characters[character]; + apiConf = this.getApiConfigById(characterConf.api); + + await message.session.chat.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE); + } else { + if (!(character in this.config.characters)) { + this.app.logger.debug(`ChatGPT API 人格 ${character} 不存在,使用默认人格`); + character = 'assistant'; + } + characterConf = this.config.characters[character]; + apiConf = this.getApiConfigById(characterConf.api); + } this.app.logger.debug(`ChatGPT API 收到提问。当前人格:${character}`); if (content.trim() === '') { - await message.sendReply('说点什么啊', true); + // await message.sendReply('说点什么啊', true); return; } - if (!(character in this.config.characters)) { - this.app.logger.debug(`ChatGPT API 人格 ${character} 不存在,使用默认人格`); - character = 'assistant'; - } - let characterConf = this.config.characters[character]; - if (this.config.gatekeeper_url) { try { let response = await got.post(this.config.gatekeeper_url, { @@ -507,15 +698,20 @@ export default class ChatGPTController implements PluginController { let rateLimitExpires = await userSessionStore.getRateLimit(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit, this.config.rate_limit_minutes * 60); if (rateLimitExpires) { let minutesLeft = Math.ceil(rateLimitExpires / 60); - await message.sendReply(`你的提问太多了,${minutesLeft}分钟后再问吧。`, true); + let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: minutesLeft }); + await message.sendReply(msg ?? `你的提问太多了,${minutesLeft}分钟后再问吧。`, true); return; } await userSessionStore.addRequestCount(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit_minutes * 60); - // 转换简体到繁体 - const s2tw = new OpenCC.OpenCC('s2tw.json'); - const tw2s = new OpenCC.OpenCC('tw2s.json'); - content = await s2tw.convertPromise(content); + let s2tw: OpenCC.OpenCC | undefined; + let tw2s: OpenCC.OpenCC | undefined; + if (apiConf.st_convert) { + // 转换简体到繁体 + s2tw = new OpenCC.OpenCC('s2tw.json'); + tw2s = new OpenCC.OpenCC('tw2s.json'); + content = await s2tw.convertPromise(content); + } // 获取记忆 let messageLogList = await message.session.chat.get(this.SESSION_KEY_API_CHAT_LOG); @@ -528,10 +724,10 @@ export default class ChatGPTController implements PluginController { this.chatGenerating = true; } - const questionTokens = await gptEncode(message.contentText).length; + const questionTokens = await gptEncode(content).length; this.app.logger.debug(`提问占用Tokens:${questionTokens}`); - if (questionTokens > this.config.openai_api.model_options.max_input_tokens) { + if (questionTokens > apiConf.max_input_tokens) { await message.sendReply('消息过长,接受不了惹。', true); return; } @@ -543,30 +739,30 @@ export default class ChatGPTController implements PluginController { if (oldMessageLogList !== messageLogList) { // 先保存一次压缩结果 this.app.logger.debug('已压缩对话记录'); - await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, this.config.openai_api.memory_expire); + await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, apiConf.memory_expire); } - let reqMessageList = this.buildMessageList(message.contentText, messageLogList, characterConf, false); + let reqMessageList = this.buildMessageList(content, messageLogList, characterConf, false); let replyRes: ChatGPTApiMessage | undefined = undefined; if (isStream) { // 处理流式输出 let onResultMessage = async (chunk: string) => { - let msg = await tw2s.convertPromise(chunk); + let msg = apiConf.st_convert ? await tw2s!.convertPromise(chunk) : chunk; for (let [inputText, replacement] of Object.entries(this.config.output_replace)) { content = content.replace(new RegExp(inputText, 'g'), replacement); } await message.sendReply(msg, true); }; - replyRes = await this.doApiRequest(reqMessageList, onResultMessage); - replyRes.message = await tw2s.convertPromise(replyRes.message); + replyRes = await this.doApiRequest(reqMessageList, apiConf, onResultMessage); + replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message; if (this.app.debug) { console.log(replyRes); } } else { - replyRes = await this.doApiRequest(reqMessageList); - replyRes.message = await tw2s.convertPromise(replyRes.message); + replyRes = await this.doApiRequest(reqMessageList, apiConf); + replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message; if (this.app.debug) { console.log(replyRes); } @@ -575,11 +771,11 @@ export default class ChatGPTController implements PluginController { if (characterConf.self_suggestion_prompt && this.shouldSelfSuggestion(replyRes.message)) { this.app.logger.debug('需要重写回答'); reqMessageList = this.buildMessageList(replyRes.message, messageLogList, characterConf, true); - replyRes = await this.doApiRequest(reqMessageList); + replyRes = await this.doApiRequest(reqMessageList, apiConf); if (this.app.debug) { console.log(replyRes); } - replyRes.message = await tw2s.convertPromise(replyRes.message); + replyRes.message = apiConf.st_convert ? await tw2s!.convertPromise(replyRes.message) : replyRes.message; } let content = replyRes.message.replace(/\n\n/g, '\n'); @@ -593,10 +789,10 @@ export default class ChatGPTController implements PluginController { if (replyRes) { messageLogList.push({ role: 'user', - message: message.contentText, + message: content, tokens: questionTokens, }, replyRes); - await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, this.config.openai_api.memory_expire); + await message.session.chat.set(this.SESSION_KEY_API_CHAT_LOG, messageLogList, apiConf.memory_expire); } } catch (err: any) { this.app.logger.error('ChatGPT error', err); @@ -605,15 +801,18 @@ export default class ChatGPTController implements PluginController { if (err.name === 'HTTPError' && err.response) { switch (err.response.statusCode) { case 429: - await message.sendReply('提问太多了,过会儿再试试呗。', true); + let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: 2 }); + await message.sendReply(msg ?? '提问太多了,过会儿再试试呗。', true); return; } } else if (err.name === 'RequestError') { - await message.sendReply('连接失败:' + err.message + ',过会儿再试试呗。', true); + let msg = this.messageGroup.error.nextMessage({ error: '连接失败:' + err.message }); + await message.sendReply(msg ?? `连接失败:${err.message},过会儿再试试呗。`, true); return; } - await message.sendReply('生成对话失败: ' + err.toString(), true); + let msg = this.messageGroup.error.nextMessage({ error: err.message }); + await message.sendReply(msg ?? `生成对话失败: ${err.message}`, true); return; } finally { if (singleMessage) { diff --git a/src/controller/IsekaiBBSQuicklyPost.ts b/src/controller/IsekaiBBSQuicklyPost.ts new file mode 100644 index 0000000..e0ebf85 --- /dev/null +++ b/src/controller/IsekaiBBSQuicklyPost.ts @@ -0,0 +1,148 @@ +import { AuthType, createClient } from "webdav"; +import App from "../App"; +import { extname } from "path"; +import { AttachmentMessage } from "../message/Message"; +import { CommonReceivedMessage } from "../message/Message"; +import { MessagePriority, PluginController, PluginEvent } from "../PluginManager"; +import got from "got/dist/source"; +import { RandomMessage } from "../utils/RandomMessage"; +import { QQForwardingMessage } from "src/robot/qq/Message"; +import QQRobot from "src/robot/QQRobot"; +import { ChatIdentity, UserSender } from "src/message/Sender"; +import { Utils } from "src/utils/Utils"; +import { MessageUtils } from "src/utils/message"; + +export type IsekaiBBSQuicklyPostConfig = { + api_endpoint: string, + token: string, +}; + +export default class IsekaiBBSQuicklyPost implements PluginController { + private config!: Awaited>; + + public event!: PluginEvent; + public app: App; + public chatGPTClient: any; + + public id = 'isekaibbs_quicklypost'; + public name = '异世界红茶馆 快速发帖'; + public description = '将合并转发的内容自动发布到异世界红茶馆'; + + private messageGroup: Record = {} + + constructor(app: App) { + this.app = app; + } + + async getDefaultConfig() { + return { + groups: {} as Record, + messages: { + error: [ + '快速发帖失败:{{{error}}}', + '在发帖时发生了错误:{{{error}}}', + '未能将这些消息转发到论坛:{{{error}}}', + '由于以下错误,发帖失败:{{{error}}}', + '很抱歉,消息无法发送至论坛,原因是:{{{error}}}。', + '转发消息时出现问题,错误详情:{{{error}}}。', + '消息无法发送到论坛,错误信息如下:{{{error}}}。', + '出现错误,导致消息无法成功发送至论坛:{{{error}}}。', + '转发消息遇到问题,以下是错误的详细信息:{{{error}}}。', + '发帖失败,原因是:{{{error}}}。', + ] + } + }; + } + + async initialize(config: any) { + await this.updateConfig(config); + + this.event.init(this); + + this.event.on('message/group', async (message, resolved) => { + if (message.type !== 'reference') return; + + let groupId = message.sender.groupId; + if (!groupId) return; + + let groupConfig = this.config.groups[groupId]; + if (!groupConfig) return; + + resolved(); + + return this.postNewThread(message, groupConfig); + }, { + priority: MessagePriority.HIGH, + }); + } + + async destroy() { + + } + + async updateConfig(config: any) { + this.config = config; + + // 随机消息 + for (let [key, value] of Object.entries(this.config.messages)) { + this.messageGroup[key] = new RandomMessage(value); + } + } + + // 隐藏用户账号的中间几位 + async maskUsername(username: string) { + const maskLen = 4; + const maskOffset = 2; + if (username.length <= maskLen) return username; + return username.substring(0, maskOffset) + '_'.repeat(maskLen) + username.substring(maskOffset + maskLen); + } + + async messageToMarkdown(message: CommonReceivedMessage) { + let markdownBuilder: string[] = []; + message.content.forEach(messageChunk => { + if (messageChunk.type.includes('text')) { + markdownBuilder.push(messageChunk.data?.text ?? ''); + } else if (messageChunk.type.includes('image')) { + markdownBuilder.push(`![${messageChunk.data?.alt ?? ''}](${messageChunk.data?.url ?? ''})`); + } else if (messageChunk.type.includes('mention')) { + if (messageChunk.data?.text) { + markdownBuilder.push(`@${messageChunk.data.text}`); + } + } + }); + } + + async postNewThread(message: CommonReceivedMessage, groupConfig: IsekaiBBSQuicklyPostConfig) { + if (message.receiver.type !== 'qq') { + // TODO: support other platform + return; + } + let attachmentMsg = message.content[0] as QQForwardingMessage; + let resId = attachmentMsg.data.res_id; + let robot = message.receiver as QQRobot; + + message.markRead() + + this.app.logger.info(`[群号:${message.sender.groupId}] 收到合并转发消息,正在发送到BBS。`); + + let messageList = await robot.getReferencedMessages(resId); + + if (!messageList || messageList.length === 0) { + this.app.logger.info(`[群号:${message.sender.groupId}] 合并转发消息内容为空或无法获取。`); + return; + } + + try { + let markdownBuilder = []; + for (let message of messageList) { + + } + } catch(err: any) { + this.app.logger.error("转存群文件失败:" + err.message, err); + console.error(err); + + let msg = this.messageGroup.error.nextMessage(err.message); + await message.sendReply(msg ?? `转存群文件失败:${err.message}`, false); + } + } +} \ No newline at end of file diff --git a/src/controller/IsekaiWikiController.ts b/src/controller/IsekaiWikiController.ts index 8df9a92..2da0955 100644 --- a/src/controller/IsekaiWikiController.ts +++ b/src/controller/IsekaiWikiController.ts @@ -29,7 +29,7 @@ export default class IsekaiWikiController implements PluginController { }, (args, message, resolved) => { resolved(); - wikiMisc.handleSearch(args, message); + wikiMisc.handleSearch(args.param, message); }); this.event.registerCommand({ @@ -39,7 +39,7 @@ export default class IsekaiWikiController implements PluginController { }, (args, message, resolved) => { resolved(); - wikiMisc.handleRandomPage(args, message); + wikiMisc.handleRandomPage(args.param, message); }); } } \ No newline at end of file diff --git a/src/controller/RWKVRolePlayingController.ts b/src/controller/RWKVRolePlayingController.ts new file mode 100644 index 0000000..dcff962 --- /dev/null +++ b/src/controller/RWKVRolePlayingController.ts @@ -0,0 +1,533 @@ +import App from "../App"; +import { CommonReceivedMessage } from "../message/Message"; +import { CommandInputArgs, MessagePriority, PluginController, PluginEvent } from "../PluginManager"; +import { encode as gptEncode } from 'gpt-3-encoder'; +import got, { OptionsOfTextResponseBody } from "got/dist/source"; +import { HttpsProxyAgent } from 'hpagent'; +import { RandomMessage } from "../utils/RandomMessage"; +import { ItemLimitedList } from "../utils/ItemLimitedList"; +import { ChatIdentity } from "../message/Sender"; + +export type CharacterConfig = { + api_id: string, + rwkv_character: string, + bot_name: string, + description?: string, +} & Record; +export type CharactersConfig = Record; + +export type DefaultCharacterConfig = { + id: string, + robot?: string, + group?: string, +}; + +export type ChatCompleteApiConfig = { + id: string, + buffer_size: number, + max_input_tokens: number, + api_token: string, + endpoint: string, +} & Record; + +export class RWKVAPIError extends Error { + public code: string; + + constructor(message: string, code: string, public json?: any) { + super(message); + this.name = 'RWKVAPIError'; + this.code = code; + } +} + +export default class RWKVRolePlayingController implements PluginController { + private SESSION_KEY_MESSAGE_COUNT = 'rwkv_rp_apiMessageCount'; + private SESSION_KEY_API_CHAT_CHARACTER = 'rwkv_rp_apiChatCharacter'; + private SESSION_KEY_API_RESET_LOCK = 'rwkv_rp_apiResetLock'; + private SESSION_KEY_USER_TOKEN = 'rwkv_rp_userToken'; + private CHARACTER_EXPIRE = 86400; + + private config!: Awaited>; + + public event!: PluginEvent; + public app: App; + + public id = 'rwkv_rp'; + public name = 'RWKV Role Playing'; + public description = '虚拟角色聊天AI的功能'; + + private globalDefaultCharacter: string = ''; + + private chatGenerating = false; + private messageGroup: Record = {} + private botSentMessageIds = new ItemLimitedList(1000); + + constructor(app: App) { + this.app = app; + } + + async getDefaultConfig() { + return { + proxy: '', + api: [ + { + id: 'default', + buffer_size: 100, + max_input_tokens: 1000, + endpoint: 'http://127.0.0.1:8888', + api_token: '', + model_options: { + min_len: 0, + temperature: 2, + top_p: 0.65, + presence_penalty: 0.2, + frequency_penalty: 0.2, + }, + }, + ] as ChatCompleteApiConfig[], + characters: { + default: { + api_id: 'default', + rwkv_character: '', + bot_name: '', + } + } as CharactersConfig, + default_characters: [ + { + id: 'default' + } + ] as DefaultCharacterConfig[], + output_replace: {} as Record, + rate_limit: 2, + rate_limit_minutes: 5, + messages: { + error: [ + '生成对话失败: {{{error}}}', + '在回复时出现错误:{{{error}}}', + '生成对话时出现错误:{{{error}}}', + '在回答问题时出现错误:{{{error}}}', + ], + generating: [ + '正在回复其他人的提问', + '等我回完再问', + '等我发完再问', + '等我回完这条再问', + '等我发完这条再问', + '前一个人的问题还没回答完,等下再问吧。', + ], + tooManyRequest: [ + '你的提问太多了,{{{minutesLeft}}}分钟后再问吧。', + '抱歉,你的问题太多了,还需要等待{{{minutesLeft}}}分钟后才能回答。', + '请耐心等待,{{{minutesLeft}}}分钟后我将回答你的问题', + '请耐心等待{{{minutesLeft}}}分钟,然后再提出你的问题。', + '你的提问有点多,请等待{{{minutesLeft}}}分钟后再继续提问。', + ], + } + } + } + + async initialize(config: any) { + await this.updateConfig(config); + + this.event.init(this); + + this.event.registerCommand({ + command: '重开', + alias: ['重置聊天', 'remake'], + name: '重置聊天', + }, (args, message, resolve) => { + resolve(); + + return this.handleResetCurrentCharacter(args, message); + }); + + this.event.registerCommand({ + command: '切换人物', + name: '切换人物', + }, (args, message, resolve) => { + resolve(); + + return this.handleChangeCharacter(args, message); + }); + + this.event.on('message/focused', (message, resolve) => { + if (message.repliedId && message.id && !this.botSentMessageIds.includes(message.id)) { + // Don't reply message from other controllers + return; + } + + resolve(); + + return this.handleChatCompleteRequest(message.contentText, message, 'saved', false); + }, { + priority: MessagePriority.LOW + }); + } + + getJWTPayload(jwtToken: string) { + const chunks = jwtToken.split('.'); + if (chunks.length !== 3) { + throw new Error('Invalid JWT'); + } + const payload = chunks[1]; + return JSON.parse(Buffer.from(payload, 'base64').toString()); + } + + async updateConfig(config: any) { + this.config = config; + + // 随机消息 + for (let [key, value] of Object.entries(this.config.messages)) { + this.messageGroup[key] = new RandomMessage(value); + } + + // 全局默认用户 + this.globalDefaultCharacter = this.config.default_characters.find((data) => !data.robot && !data.group)?.id ?? ''; + } + + private getDefaultCharacter(message: CommonReceivedMessage): string { + let senderIdentity: ChatIdentity | undefined = message.sender?.identity; + if (!senderIdentity || senderIdentity.type === 'private') { + return this.globalDefaultCharacter; + } + + let robotId = senderIdentity.robot.robotId; + let groupId = senderIdentity.groupId; + + if (robotId && groupId) { + return this.config.default_characters.find((data) => data.robot === robotId && data.group === groupId)?.id ?? + this.globalDefaultCharacter; + } else { + return this.globalDefaultCharacter; + } + } + + private async handleChangeCharacter(args: CommandInputArgs, message: CommonReceivedMessage) { + message.markRead(); + + let character = args.param.trim(); + if (character === '') { + // 列出所有人物 + let characterList = Object.entries(this.config.characters); + let currentCharacter = await message.session.chat.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.getDefaultCharacter(message); + let currentCharacterInfo = this.config.characters[currentCharacter] ?? this.config.characters[this.getDefaultCharacter(message)]; + let msgBuilder = [ + `当前人物: ${currentCharacterInfo.bot_name},使用方法: “:切换人物 人物ID”`, + '人物列表:' + ]; + for (let [name, character] of characterList) { + if (character.description) { + msgBuilder.push(`${name}: ${character.bot_name}, ${character.description}`); + } else { + msgBuilder.push(`${name}: ${character.bot_name}`); + } + } + return message.sendReply(msgBuilder.join('\n'), true); + } + + if (!(character in this.config.characters)) { + let msg = this.messageGroup.error.nextMessage({ error: '人物不存在' }); + return message.sendReply(msg ?? '人物不存在', true); + } + + await message.session.user.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE); + + let characterInfo = this.config.characters[character]; + + return message.sendReply(`已切换人物为 ${characterInfo.bot_name}`, true); + } + + private async handleResetCurrentCharacter(args: CommandInputArgs, message: CommonReceivedMessage) { + // 从会话中获取人物 + let character = await message.session.chat.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.getDefaultCharacter(message); + if (!(character in this.config.characters)) { + this.app.logger.debug(`RWKV API 人物 ${character} 不存在,使用默认人物`); + character = 'assistant'; + } + + let characterConf = this.config.characters[character]; + let apiConf = this.getApiConfigById(characterConf.api); + + try { + const apiUserName = this.getApiUserName(message); + let userToken = await message.session.user.get(this.SESSION_KEY_USER_TOKEN); + if (!userToken) { + userToken = await this.userLogin(apiUserName, apiConf, message); + } + + await this.apiChatReset(userToken, apiConf, characterConf); + + await message.sendReply('我重开了', true); + } catch (err: any) { + this.app.logger.error('RWKV chat reset error', err); + console.error(err); + await message.sendReply(`重开失败: ${err.message}`, true); + } + } + + private getApiConfigById(id: string) { + return this.config.api.find((data) => data.id === id) ?? this.config.api[0]; + } + + private getApiUserName(message: CommonReceivedMessage) { + return `${message.receiver.robotId}_${message.sender.userId}`; + } + + private applyProxy(opts: OptionsOfTextResponseBody, apiConf: ChatCompleteApiConfig) { + const proxyConfig = apiConf.proxy ?? this.config.proxy; + if (proxyConfig) { + opts.agent = { + https: new HttpsProxyAgent({ + keepAlive: true, + keepAliveMsecs: 1000, + maxSockets: 256, + maxFreeSockets: 256, + scheduling: 'lifo', + proxy: proxyConfig, + }) as any, + } + } + } + + private async userLogin(userName: string, apiConf: ChatCompleteApiConfig, message: CommonReceivedMessage): Promise { + let opts: OptionsOfTextResponseBody = { + json: { + user_name: userName, + api_token: apiConf.api_token + } + }; + this.applyProxy(opts, apiConf); + + const apiUrl = `${apiConf.endpoint}/login`; + try { + const res = await got.post(apiUrl, opts).json(); + + + const token = res.data?.token; + + if (!token) { + throw new RWKVAPIError('API返回数据格式错误', 'api_response_data_invalid'); + } + + const payload = this.getJWTPayload(token); + const expire = Math.round(payload.exp - (Date.now() / 1000)) - 1; + + await message.session.user.set(this.SESSION_KEY_USER_TOKEN, token, expire); + + return res.data?.token; + } catch (err: any) { + if (err.name === 'HTTPError' && err.response) { + if (err.response.body) { + const body = JSON.parse(err.response.body); + if (body.error) { + throw new RWKVAPIError(body.message, body.code, body); + } + } + } + throw err; + } + } + + private async apiChatReset(userToken: string, apiConf: ChatCompleteApiConfig, characterConf: CharacterConfig) { + let opts: OptionsOfTextResponseBody = { + json: { + character_name: characterConf.rwkv_character, + }, + timeout: 30000, + headers: { + Authorization: `Bearer ${userToken}`, + } + }; + this.applyProxy(opts, apiConf); + + const apiUrl = `${apiConf.endpoint}/chat/reset`; + + try { + await got.post(apiUrl, opts).json(); + } catch (err: any) { + if (err.name === 'HTTPError' && err.response) { + if (err.response.body) { + const body = JSON.parse(err.response.body); + if (body.error) { + throw new RWKVAPIError(body.message, body.code, body); + } + } + } + throw err; + } + } + + private async apiChatComplete(userName: string, userToken: string, question: string, apiConf: ChatCompleteApiConfig, + characterConf: CharacterConfig, receivedMessage: CommonReceivedMessage, tryLogin = true): Promise { + let modelOpts = Object.fromEntries(Object.entries({ + min_len: apiConf.model_options.min_len, + temperature: apiConf.model_options.temperature, + top_p: apiConf.model_options.top_p, + presence_penalty: apiConf.model_options.presence_penalty, + frequency_penalty: apiConf.model_options.frequency_penalty, + }).filter((data) => data[1])); + + let opts: OptionsOfTextResponseBody = { + json: { + ...modelOpts, + character_name: characterConf.rwkv_character, + prompt: question, + }, + timeout: 30000, + headers: { + Authorization: `Bearer ${userToken}`, + } + }; + this.applyProxy(opts, apiConf); + + const apiUrl = `${apiConf.endpoint}/chat/complete`; + this.app.logger.debug(`RWKV API 请求地址:${apiUrl}`); + + try { + const res = await got.post(apiUrl, opts).json(); + + if (res.data?.reply) { + return res.data.reply; + } + } catch (err: any) { + if (err.name === 'HTTPError' && err.response) { + switch (err.response.statusCode) { + case 401: + if (tryLogin) { + await this.userLogin(userName, apiConf, receivedMessage); + return await this.apiChatComplete(userName, userToken, question, apiConf, characterConf, receivedMessage, false); + } + break; + default: + if (err.response.body) { + const body = JSON.parse(err.response.body); + if (body.error) { + throw new RWKVAPIError(body.message, body.code, body); + } + } + } + } + throw err; + } + + throw new RWKVAPIError('API返回数据格式错误', 'api_response_data_invalid'); + } + + private async handleChatCompleteRequest(content: string, message: CommonReceivedMessage, character = 'assistant', singleMessage = false) { + message.markRead(); + + if (singleMessage && this.chatGenerating) { + let msg = this.messageGroup.generating.nextMessage(); + await message.sendReply(msg ?? '正在生成中,请稍后再试', true); + return; + } + + let characterConf: CharacterConfig; + let apiConf: ChatCompleteApiConfig; + if (character === 'saved') { + // 从会话中获取人物 + character = await message.session.user.get(this.SESSION_KEY_API_CHAT_CHARACTER) ?? this.getDefaultCharacter(message); + if (!(character in this.config.characters)) { + this.app.logger.debug(`RWKV API 人物 ${character} 不存在,使用默认人物`); + character = 'assistant'; + } + + characterConf = this.config.characters[character]; + apiConf = this.getApiConfigById(characterConf.api); + + await message.session.user.set(this.SESSION_KEY_API_CHAT_CHARACTER, character, this.CHARACTER_EXPIRE); + } else { + if (!(character in this.config.characters)) { + this.app.logger.debug(`RWKV API 人格 ${character} 不存在,使用默认人格`); + character = 'assistant'; + } + characterConf = this.config.characters[character]; + apiConf = this.getApiConfigById(characterConf.api); + } + + this.app.logger.debug(`RWKV API 收到提问。当前人格:${character}`); + if (content.trim() === '') { + // await message.sendReply('说点什么啊', true); + return; + } + + const userSessionStore = message.session.user; + // 使用频率限制 + let rateLimitExpires = await userSessionStore.getRateLimit(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit, this.config.rate_limit_minutes * 60); + if (rateLimitExpires) { + let minutesLeft = Math.ceil(rateLimitExpires / 60); + let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: minutesLeft }); + await message.sendReply(msg ?? `你的提问太多了,${minutesLeft}分钟后再问吧。`, true); + return; + } + await userSessionStore.addRequestCount(this.SESSION_KEY_MESSAGE_COUNT, this.config.rate_limit_minutes * 60); + + try { + if (singleMessage) { + this.chatGenerating = true; + } + + const questionTokens = await gptEncode(content).length; + this.app.logger.debug(`提问占用Tokens:${questionTokens}`); + + if (questionTokens > apiConf.max_input_tokens) { + await message.sendReply('消息过长,接受不了惹。', true); + return; + } + + const apiUserName = this.getApiUserName(message); + let userToken = await userSessionStore.get(this.SESSION_KEY_USER_TOKEN); + if (!userToken) { + userToken = await this.userLogin(apiUserName, apiConf, message); + } + + // 自动重置对话 + let resetLock = await userSessionStore.get(this.SESSION_KEY_API_RESET_LOCK); + if (!resetLock) { + try { + await this.apiChatReset(userToken, apiConf, characterConf); + } catch (err: any) { + this.app.logger.error('RWKV Reset character error', err); + console.error(err); + } + } + await userSessionStore.set(this.SESSION_KEY_API_RESET_LOCK, '1', this.CHARACTER_EXPIRE); + + let replyRes = await this.apiChatComplete(apiUserName, userToken, content, apiConf, characterConf, message); + if (this.app.debug) { + console.log(replyRes); + } + + let sentMessage = await message.sendReply(replyRes, true, { + isRWKVReply: true + }); + if (sentMessage?.id) { + this.botSentMessageIds.addOne(sentMessage.id); + } + } catch (err: any) { + this.app.logger.error('RWKV error', err); + console.error(err); + + if (err.name === 'HTTPError' && err.response) { + switch (err.response.statusCode) { + case 429: + let msg = this.messageGroup.tooManyRequest.nextMessage({ minutes: 2 }); + await message.sendReply(msg ?? '提问太多了,过会儿再试试呗。', true); + return; + } + } else if (err.name === 'RequestError') { + let msg = this.messageGroup.error.nextMessage({ error: '连接失败:' + err.message }); + await message.sendReply(msg ?? `连接失败:${err.message},过会儿再试试呗。`, true); + return; + } + + let msg = this.messageGroup.error.nextMessage({ error: err.message }); + await message.sendReply(msg ?? `生成对话失败: ${err.message}`, true); + return; + } finally { + if (singleMessage) { + this.chatGenerating = false; + } + } + } +} \ No newline at end of file diff --git a/src/controller/SfsettingsController.ts b/src/controller/SfsettingsController.ts index f6e6b4e..f4e5666 100644 --- a/src/controller/SfsettingsController.ts +++ b/src/controller/SfsettingsController.ts @@ -26,7 +26,7 @@ export default class SfsettingsController implements PluginController { }, (args, message, resolved) => { resolved(); - wikiMisc.handleSearch(args, message); + wikiMisc.handleSearch(args.param, message); }); this.event.registerCommand({ @@ -36,7 +36,7 @@ export default class SfsettingsController implements PluginController { }, (args, message, resolved) => { resolved(); - wikiMisc.handleRandomPage(args, message); + wikiMisc.handleRandomPage(args.param, message); }); } } \ No newline at end of file diff --git a/src/controller/StableDiffusionController.ts b/src/controller/StableDiffusionController.ts index 2fa7bcf..ebb728d 100644 --- a/src/controller/StableDiffusionController.ts +++ b/src/controller/StableDiffusionController.ts @@ -1,5 +1,5 @@ import App from "../App"; -import { CommonReceivedMessage } from "../message/Message"; +import { CommonReceivedMessage, ImageMessage } from "../message/Message"; import { MessagePriority, PluginController, PluginEvent } from "../PluginManager"; import got from "got/dist/source"; @@ -94,7 +94,7 @@ export default class StableDiffusionController implements PluginController { }, (args, message, resolve) => { resolve(); - return this.text2img(args, message); + return this.text2img(args.param, message); }); this.event.registerCommand({ @@ -103,7 +103,7 @@ export default class StableDiffusionController implements PluginController { }, (args, message, resolve) => { resolve(); - return this.text2img(args, message, { + return this.text2img(args.param, message, { useTranslate: true }); }); @@ -371,11 +371,12 @@ export default class StableDiffusionController implements PluginController { await currentTask.message.sendReply([ { - type: 'image', + type: ['image'], + text: '[图片]', data: { url: "base64://" + image, } - } + } as ImageMessage ], false); } } catch (e: any) { diff --git a/src/controller/SystemController.ts b/src/controller/SystemController.ts index 34dacd5..3fa126d 100644 --- a/src/controller/SystemController.ts +++ b/src/controller/SystemController.ts @@ -27,7 +27,7 @@ export default class SystemController implements PluginController { }, (args, message, resolved) => { resolved(); - this.handleHelp(args, message); + this.handleHelp(args.param, message); }); } @@ -70,10 +70,9 @@ export default class SystemController implements PluginController { } replyMsg.content = [{ - type: 'text', - data: { - text: helpBuilder.join('\n') - } + type: ['text'], + text: helpBuilder.join('\n'), + data: {}, }]; if (this.app.debug) { diff --git a/src/controller/TestController.ts b/src/controller/TestController.ts deleted file mode 100644 index 7d27770..0000000 --- a/src/controller/TestController.ts +++ /dev/null @@ -1,51 +0,0 @@ -import App from "../App"; -import { buildChatIdentityQuery, toChatIdentityEntity } from "../orm/Message"; -import { PluginController, PluginEvent } from "../PluginManager"; -import { TestSchema } from "./test/TestSchema"; - -export default class TestController implements PluginController { - public event!: PluginEvent; - public app: App; - - public id = 'test'; - public name = '测试功能'; - public description = '测试功能控制器'; - - constructor(app: App) { - this.app = app; - } - - async initialize() { - this.event.init(this); - - const dbi = this.app.database; - if (!dbi) return; - - const TestModel = dbi.getModel('Test', TestSchema); - - this.event.registerCommand({ - command: '写入', - name: '写入数据库', - }, (args, message, resolve) => { - resolve(); - - return (async () => { - let obj = new TestModel({ - chatIdentity: toChatIdentityEntity(message.sender.identity), - data: args, - }); - - await obj.save(); - })(); - }); - - this.event.registerCommand({ - command: '读取', - name: '读取数据库', - }, async (args, message, resolve) => { - resolve(); - - let obj = await TestModel.findOne(buildChatIdentityQuery(message.sender.identity)); - }); - } -} \ No newline at end of file diff --git a/src/controller/WebdavFileBackupController.ts b/src/controller/WebdavFileBackupController.ts new file mode 100644 index 0000000..b5e40fb --- /dev/null +++ b/src/controller/WebdavFileBackupController.ts @@ -0,0 +1,200 @@ +import { AuthType, createClient } from "webdav"; +import App from "../App"; +import { extname } from "path"; +import { AttachmentMessage } from "../message/Message"; +import { CommonReceivedMessage } from "../message/Message"; +import { MessagePriority, PluginController, PluginEvent } from "../PluginManager"; +import got from "got/dist/source"; +import { RandomMessage } from "../utils/RandomMessage"; + +export type WebdavConfig = { + url: string, + username?: string, + password?: string, + path?: string, + exclusive?: boolean; +}; + +export default class WebdavFileBackupController implements PluginController { + private config!: Awaited>; + + private SESSION_KEY_GENERATE_COUNT = 'stablediffusion_generateCount'; + + public event!: PluginEvent; + public app: App; + public chatGPTClient: any; + + public id = 'webdav_file_backup'; + public name = 'Webdav文件备份'; + public description = '将群文件备份到Webdav服务'; + + private messageGroup: Record = {} + + constructor(app: App) { + this.app = app; + } + + async getDefaultConfig() { + return { + groups: {} as Record, + messages: { + error: [ + '转存群文件失败:{{{error}}}', + '在转存群文件时发生了错误:{{{error}}}', + '未能将群文件转存到资料库:{{{error}}}', + '由于以下错误,文件转存失败:{{{error}}}', + '很抱歉,文件无法成功转存至群组资料库,原因是:{{{error}}}。', + '转存群组文件时出现问题,错误详情:{{{error}}}。', + '文件无法转存到资料库,错误信息如下:{{{error}}}。', + '出现错误,导致文件无法成功转存至群组资料库:{{{error}}}。', + '转存群文件遇到问题,以下是错误的详细信息:{{{error}}}。', + '文件转存失败,原因是:{{{error}}}。', + '抱歉,由于以下错误,文件未能成功转存至群组资料库:{{{error}}}。', + '在尝试将文件转存至群组资料库时,发生了如下错误:{{{error}}}。', + '文件转存操作失败,错误详情:{{{error}}}。', + ] + } + }; + } + + async initialize(config: any) { + await this.updateConfig(config); + + this.event.init(this); + + this.event.on('message/group', async (message, resolved) => { + if (message.type !== 'attachment') return; + + let groupId = message.sender.groupId; + if (!groupId) return; + + let groupConfig = this.config.groups[groupId]; + if (!groupConfig) return; + + if (groupConfig.exclusive) { + resolved(); + } + + return this.uploadGroupFile(message, groupConfig); + }, { + priority: MessagePriority.HIGH, + }); + } + + async destroy() { + + } + + async updateConfig(config: any) { + this.config = config; + + // 随机消息 + for (let [key, value] of Object.entries(this.config.messages)) { + this.messageGroup[key] = new RandomMessage(value); + } + } + + async uploadGroupFile(message: CommonReceivedMessage, groupConfig: WebdavConfig) { + if (message.content[0] && + (message.content[0].type.includes('attachment'))) { + let attachmentMsg = message.content[0] as AttachmentMessage; + let fileName = attachmentMsg.data.fileName; + let url = attachmentMsg.data.url; + let fileSize = attachmentMsg.data.size; + + message.markRead() + + this.app.logger.info(`[群号:${message.sender.groupId}] 收到群文件:${fileName},开始转存`); + + let authOption: any = {}; + if (groupConfig.username) { + authOption.username = groupConfig.username; + } + if (groupConfig.password) { + authOption.password = groupConfig.password; + } + + let client = createClient(groupConfig.url, groupConfig); + + let filePath = ''; + if (groupConfig.path) { + filePath = groupConfig.path.replace(/\$\{(\w+)\}/g, (match, p1) => { + switch (p1) { + case 'groupId': + return message.sender.groupId; + case 'groupName': + return message.sender.groupName; + case 'userId': + case 'uid': + return message.sender.userId; + case 'fileName': + return fileName; + case 'date': + return message.time.toISOString().replace(/T/, ' ').replace(/\..+/, ''); + case 'year': + return message.time.getFullYear().toString(); + case 'month': + return (message.time.getMonth() + 1).toString(); + case 'day': + return message.time.getDate().toString(); + case 'timestamp': + return message.time.getTime().toString(); + default: + return match; + } + }) + } else { + filePath = '/' + fileName; + } + + + try { + let fileShortName = fileName.substring(0, 10); + if (fileShortName.length !== fileName.length) { + fileShortName += '...'; + } + + // create path + let path = filePath.split('/'); + path.pop(); + let currentPath = ''; + for (let i = 0; i < path.length; i++) { + currentPath += '/' + path[i]; + try { + if (!await client.exists(currentPath)) { + await client.createDirectory(currentPath); + } + } catch (e) { + console.error(e); + } + } + + if (await client.exists(filePath)) { + let fileExt = extname(filePath); + if (fileExt) { + filePath = filePath.replace(fileExt, `_${Date.now()}${fileExt}`); + } else { + filePath = filePath + `_${Date.now()}`; + } + } + + /*if (fileSize && fileSize > 1024 * 1024 * 10) { + await message.sendReply('正在转存文件:' + fileShortName, false); + }*/ + + await new Promise((resolve, reject) => { + got.stream(url).pipe(client.createWriteStream(filePath)) + .on('finish', resolve) + .on('error', reject); + }); + // await message.sendReply('文件 ' + fileShortName + ' 已经转存到资料库了', false); + } catch(err: any) { + this.app.logger.error("转存群文件失败:" + err.message, err); + console.error(err); + + let msg = this.messageGroup.error.nextMessage(err.message); + await message.sendReply(msg ?? `转存群文件失败:${err.message}`, false); + } + } + } +} \ No newline at end of file diff --git a/src/controller/test/TestSchema.ts b/src/controller/test/TestSchema.ts deleted file mode 100644 index 3ca9eee..0000000 --- a/src/controller/test/TestSchema.ts +++ /dev/null @@ -1,16 +0,0 @@ -import mongoose, { Schema, Types } from "mongoose"; -import { ChatIdentityEntity, ChatIdentityEntityType } from "../../orm/Message"; - -export type TestSchemaType = { - id: Types.ObjectId, - chatIdentity: ChatIdentityEntityType, - data: string, -}; - -export const TestSchema = new Schema({ - id: Object, - chatIdentity: ChatIdentityEntity, - data: String -}); - -export const TestModel = mongoose.model('Test', TestSchema); \ No newline at end of file diff --git a/src/global.d.ts b/src/global.d.ts index e2e838d..14e0504 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -4,4 +4,10 @@ interface ImportMeta { context: { isekaiFeedbotApp: App; } +} + +declare module "bson" { + interface ObjectId { + _id: this; + } } \ No newline at end of file diff --git a/src/message/Message.ts b/src/message/Message.ts index fab473f..0c0b689 100644 --- a/src/message/Message.ts +++ b/src/message/Message.ts @@ -1,54 +1,72 @@ import { Robot } from "../RobotManager"; -import { SessionStore } from "../SessionManager"; -import { BaseSender, GroupSender, UserSender } from "./Sender"; +import { CacheStore } from "../CacheManager"; +import { MessageDataType, MessageSchemaType, chatIdentityToDB } from "../odm/Message"; +import { BaseSender, ChatIdentity, GroupSender, IMessageSender, UserSender } from "./Sender"; +import { LiteralUnion } from "src/utils/types"; +import { Utils } from "src/utils/Utils"; + +export enum MessageDirection { + RECEIVE = 1, + SEND = 2, +} + +export type MessageChunkType = LiteralUnion<"text" | "image" | "emoji" | "record" | "attachment" | "mention">; export interface MessageChunk { - type: string; - baseType?: string; + type: MessageChunkType[]; + text: string | null; data: any; } export interface TextMessage extends MessageChunk { - type: 'text'; - data: { - text: string; - }; + text: string; } export interface ImageMessage extends MessageChunk { - type: 'image'; data: { url: string; alt?: string; }; } -export interface VoiceMessage extends MessageChunk { - type: 'voice'; +export interface EmojiMessage extends MessageChunk { + data: { + emoji: string, + url?: string, + } +} + +export interface RecordMessage extends MessageChunk { data: { url: string; - text?: string; + speech_to_text?: string; }; } export interface AttachmentMessage extends MessageChunk { - type: 'attachment'; data: { url: string; fileName: string; + size?: number; }; } export interface MentionMessage extends MessageChunk { - type: 'mention'; data: { - uid: string; - text?: string; + userId: string; + name?: string; }; } -export type CommonMessageType = "text" | "combine" | "image" | "media" | "toast"; -export type CommonMessageOrigin = "private" | "group" | "channel"; +export type CommonMessageType = LiteralUnion<"text" | "reference" | "image" | "record" | "media" | "toast">; +export type CommonMessageChatType = LiteralUnion<"private" | "group" | "channel">; + +export enum AddReplyMode { + /** 不回复私聊 */ + IGNORE_PRIVATE = 1, + /** 不回复没有被打断的对话 */ + IGNORE_NO_INTERRUPTION = 2 +}; /** 基本消息 */ export class CommonMessage { @@ -57,12 +75,14 @@ export class CommonMessage { /** 消息内容 */ content: MessageChunk[] = []; /** 主类型 */ - type: string | CommonMessageType = "text"; - origin: string | CommonMessageOrigin = "private"; + type: CommonMessageType = "text"; + chatType: CommonMessageChatType = "private"; /** 回复的消息ID */ repliedId?: string; /** 提到的人 */ - mentions?: { uid: string, text?: string }[]; + mentions?: { userId: string, name?: string }[]; + /** 已撤回 */ + deleted: boolean = false; /** 附加信息 */ extra: any = {}; @@ -72,12 +92,12 @@ export class CommonMessage { public get contentText() { if (typeof this._contentText === 'undefined') { this._contentText = this.content.map((chunk) => { - if (chunk.type === 'text') { - return chunk.data.text; - } else if (chunk.type === 'mention') { - return '[@' + (chunk.data.text || chunk.data.uid) + ']'; + if (chunk.text !== null) { + return chunk.text; + } else if (chunk.data) { + return '' + Utils.escapeHtml(JSON.stringify(chunk.data)) + ''; } else { - return JSON.stringify([chunk.type, chunk.data]); + return ''; } }).join('').trim(); } @@ -86,50 +106,51 @@ export class CommonMessage { /** * 提到某人 - * @param uid 用户ID - * @param text 显示的文本(部分接口支持) + * @param userId 用户ID + * @param name 显示的文本(部分接口支持) * @returns */ - public mention(uid: string, text?: string) { + public mention(userId: string, name?: string) { // 私聊消息不支持 - if (this.origin === 'private') { + if (this.chatType === 'private') { return false; } if (typeof this.mentions === 'undefined') { this.mentions = []; - } else if (this.mentions!.find((u) => u.uid === uid)) { + } else if (this.mentions!.find((u) => u.userId === userId)) { return true; } - this.mentions.push({ uid, text }); + this.mentions.push({ userId, name }); this.content.unshift({ - type: 'mention', - data: { uid, text } + type: ['mention'], + text: name ? `[@${name}]` : `[@${userId}]`, + data: { userId, name } }); return true; } /** * 取消提到某人 - * @param uid 用户ID + * @param userId 用户ID * @returns */ - public removeMention(uid: string) { + public removeMention(userId: string) { // 私聊消息不支持 - if (this.origin === 'private') { + if (this.chatType === 'private') { return false; } if (typeof this.mentions === 'undefined') { return true; } else { - this.mentions = this.mentions.filter((u) => u.uid !== uid); + this.mentions = this.mentions.filter((u) => u.userId !== userId); if (this.mentions.length === 0) { delete this.mentions; } - this.content = this.content.filter((msg) => msg.type !== 'mention' || msg.data?.uid !== uid); + this.content = this.content.filter((msg) => !msg.type.includes('mention') || msg.data?.userId !== userId); return true; } @@ -144,17 +165,18 @@ export class CommonMessage { let lastText: string | undefined; this.content.forEach((chunk) => { - if (chunk.type === 'text') { + if (chunk.type.includes('text')) { if (!lastText) { - lastText = chunk.data.text; + lastText = chunk.text ?? ''; } else { - lastText += chunk.data.text; + lastText += chunk.text ?? ''; } } else { if (lastText) { newContent.push({ - type: 'text', - data: { text: lastText } + type: ['text'], + text: lastText, + data: {}, }); lastText = undefined; } @@ -164,8 +186,9 @@ export class CommonMessage { if (lastText) { newContent.push({ - type: 'text', - data: { text: lastText } + type: ['text'], + text: lastText, + data: {} }); } @@ -181,8 +204,8 @@ export class CommonMessage { */ public static replace(content: MessageChunk[], searchValue: RegExp, replaceValue: string) { return content.map((chunk, index) => { - if (chunk.type === 'text') { - let newText: string = chunk.data.text; + if (chunk.type.includes('text')) { + let newText: string = chunk.text ?? ''; let offset = [0, 0]; if (index === 0) { offset[0] = 1; @@ -192,43 +215,67 @@ export class CommonMessage { newText += "\t"; } newText = newText.replace(searchValue, replaceValue); - chunk.data.text = newText.substring(offset[0], newText.length - offset[1]); + chunk.text = newText.substring(offset[0], newText.length - offset[1]); } return chunk; }); } + + public toDBObject(): MessageDataType { + throw new Error("Not implemented."); + } } /** 基本发送的消息 */ export class CommonSendMessage extends CommonMessage { /** 发送者 */ sender: Robot; - /** 接收方的ID */ - targetId: string; + /** 接收者 */ + receiver: ChatIdentity; /** 回复的消息 */ repliedMessage?: CommonReceivedMessage; - constructor(sender: Robot, origin: string, targetId: string, content?: MessageChunk[]) { + /** 发送时间 */ + time: Date = new Date(); + + constructor(sender: Robot, chatType: string, receiver: ChatIdentity, content?: MessageChunk[]) { super(); this.sender = sender; - this.origin = origin; - this.targetId = targetId; + this.chatType = chatType; + this.receiver = receiver; if (Array.isArray(content)) this.content = content; + this.time = new Date(); } - async send(): Promise { + public async send(): Promise { await this.sender.sendMessage(this); } + + public toDBObject(): MessageDataType { + return { + messageId: this.id!, + type: this.type, + direction: MessageDirection.SEND, + chatType: this.chatType, + chatIdentity: chatIdentityToDB(this.receiver), + repliedMessageId: this.repliedId, + mentionedUserIds: this.mentions?.map((item) => item.userId) ?? [], + contentText: this.contentText, + content: this.content, + time: this.time, + extra: this.extra, + }; + } } export type SessionStoreGroup = { - global: SessionStore; - robot: SessionStore; - user: SessionStore; - rootGroup: SessionStore; - group: SessionStore; - chat: SessionStore; + global: CacheStore; + robot: CacheStore; + user: CacheStore; + rootGroup: CacheStore; + group: CacheStore; + chat: CacheStore; }; export class CommonReceivedMessage extends CommonMessage { @@ -250,20 +297,22 @@ export class CommonReceivedMessage extends CommonMessage { }, }) as any; - constructor(receiver: Robot, messageId?: string) { + constructor(receiver: Robot, sender: IMessageSender, messageId?: string) { super(); this.receiver = receiver; + this.sender = sender; this.id = messageId; } public createReplyMessage(message?: string | MessageChunk[], addReply: boolean = false) { const sender = this.sender as BaseSender; - let newMessage = new CommonSendMessage(this.receiver!, this.origin, sender.targetId); + let newMessage = new CommonSendMessage(this.receiver!, this.chatType, sender.identity); if (typeof message === 'string') { let msgContent: MessageChunk[] = [{ - type: 'text', - data: { text: message } + type: ['text'], + text: message, + data: {}, }]; newMessage.content = msgContent; } else if (Array.isArray(message)) { @@ -278,36 +327,79 @@ export class CommonReceivedMessage extends CommonMessage { return newMessage; } - public async sendReply(message: string | MessageChunk[], addReply: boolean = false): Promise { - let newMessage = this.createReplyMessage(message, addReply); + public async sendReply(message: string | MessageChunk[], addReply: boolean | AddReplyMode = false, extra: any = {}): Promise { + // 检测是否添加回复和@ + if (addReply === true) { + addReply = AddReplyMode.IGNORE_PRIVATE; + } + + let shouldReply = false; + if (typeof addReply === 'number') { + shouldReply = true; + if (addReply & AddReplyMode.IGNORE_PRIVATE) { + // 忽略私聊 + if (this.sender?.identity?.type === 'private') { + shouldReply = false; + } + } + } + + // 发送回复消息 + let newMessage = this.createReplyMessage(message, shouldReply); if (newMessage.content.length === 0) return null; + newMessage.extra = { + ...newMessage.extra, + ...extra, + }; + newMessage = await this.receiver.sendMessage(newMessage); return newMessage; } + public async markRead() { + return await this.receiver.markRead?.(this); + } + public getSession(type: string) { return this.receiver.getSession(this.sender.identity, type); } + + public toDBObject(): MessageDataType { + const chatIdentity = this.sender.identity; + return { + messageId: this.id!, + type: this.type, + direction: MessageDirection.SEND, + chatType: this.chatType, + chatIdentity: chatIdentityToDB(chatIdentity), + repliedMessageId: this.repliedId, + mentionedUserIds: this.mentions?.map((item) => item.userId) ?? [], + contentText: this.contentText, + content: this.content, + time: this.time, + extra: this.extra, + }; + } } export class CommonPrivateMessage extends CommonReceivedMessage { public sender: US; - public origin = 'private'; + public chatType = 'private'; constructor(sender: US, receiver: Robot, messageId?: string) { - super(receiver, messageId); + super(receiver, sender, messageId); this.sender = sender; } } export class CommonGroupMessage extends CommonReceivedMessage { sender: GS; - public origin = 'group'; + public chatType = 'group'; constructor(sender: GS, receiver: Robot, messageId?: string) { - super(receiver, messageId); + super(receiver, sender, messageId); this.sender = sender; } } \ No newline at end of file diff --git a/src/message/Sender.ts b/src/message/Sender.ts index 9ee806f..541e39a 100644 --- a/src/message/Sender.ts +++ b/src/message/Sender.ts @@ -1,3 +1,4 @@ +import { any } from "micromatch"; import { Robot } from "../RobotManager"; export type BaseSenderType = "user" | "group" | "channel"; @@ -5,21 +6,25 @@ export type BaseSenderType = "user" | "group" | "channel"; export interface BaseSender { readonly type: string | BaseSenderType; readonly targetId: string; + readonly userId: string; + readonly identity: ChatIdentity; } +export type IMessageSender = BaseSender & Record; + export class UserSender implements BaseSender { public robot: Robot; public readonly type = "user"; - public uid: string; + public userId: string; public userName?: string; public nickName?: string; public accessGroup: string[] = []; - constructor(robot: Robot, uid: string) { + constructor(robot: Robot, userId: string) { this.robot = robot; - this.uid = uid; + this.userId = userId; } static newAnonymous(robot: Robot) { @@ -30,18 +35,18 @@ export class UserSender implements BaseSender { let chatIdentity: ChatIdentity = { type: 'private', robot: this.robot, - userId: this.uid, + userId: this.userId, }; return chatIdentity; } get targetId() { - return this.uid; + return this.userId; } get displayName() { - return this.nickName ?? this.userName ?? this.uid; + return this.nickName ?? this.userName ?? this.userId; } } @@ -56,15 +61,15 @@ export class GroupSender { public rootGroupId?: string; public rootGroupName?: string; - public uid: string; + public userId: string; public userName?: string; public globalNickName?: string; public nickName?: string; - constructor(robot: Robot, groupId: string, uid: string) { + constructor(robot: Robot, groupId: string, userId: string) { this.robot = robot; this.groupId = groupId; - this.uid = uid; + this.userId = userId; } get identity(): ChatIdentity { @@ -72,7 +77,7 @@ export class GroupSender { type: 'group', robot: this.robot, groupId: this.groupId, - userId: this.uid, + userId: this.userId, }; if (this.rootGroupId) { @@ -91,11 +96,11 @@ export class GroupSender { } get displayName() { - return this.nickName ?? this.globalNickName ?? this.userName ?? this.uid; + return this.nickName ?? this.globalNickName ?? this.userName ?? this.userId; } get userSender() { - let sender = new UserSender(this.robot, this.uid); + let sender = new UserSender(this.robot, this.userId); sender.userName = this.userName; sender.nickName = this.globalNickName; @@ -103,11 +108,53 @@ export class GroupSender { } } -export type ChatIdentity = { - type: 'private' | 'group' | 'channel' | 'raw' | string, - robot: Robot, - rootGroupId?: string, - groupId?: string, - userId?: string, - channelId?: string, +export interface ChatIdentity { + type: ('private' | 'group' | 'channel' | 'raw') | (string & {}); + robot: Robot; + rootGroupId?: string; + groupId?: string; + userId?: string; + channelId?: string; +} + +export interface UserInfoType { + userId: string; + userName?: string; + nickName?: string; + image?: string; + extra: any; +} + +export interface GroupInfoType { + groupId: string; + rootGroupId?: string; + name: string; + image?: string; + extra: any; +} + +export interface RootGroupInfoType { + rootGroupId: string; + name: string; + image?: string; + extra: any; +} + +export interface GroupUserInfoType { + groupId: string; + rootGroupId?: string; + userId: string; + userName?: string; + nickName?: string; + title?: string; + role?: string; + image?: string; + extra: any; +} + +export interface ChannelInfoType { + channelId: string; + name: string; + image: string; + extra: any; } \ No newline at end of file diff --git a/src/odm/ChannelInfo.ts b/src/odm/ChannelInfo.ts new file mode 100644 index 0000000..8c30d4f --- /dev/null +++ b/src/odm/ChannelInfo.ts @@ -0,0 +1,31 @@ +import { Model, Schema, Types } from "mongoose"; +import { ChannelInfoType } from "../message/Sender"; +import { ModelBase } from "../DatabaseManager"; + +export type ChannelInfoSchemaType = ChannelInfoType; + +export type ChannelInfoModelType = Model; + +export const ChannelInfoSchema = (robotId: string) => new Schema({ + channelId: { + type: String, + required: true, + index: true, + }, + name: { + type: String, + required: true, + default: '', + index: true, + }, + image: String, + extra: { + type: Object, + default: {}, + }, +}); + +export const GroupInfoModelBase: ModelBase = { + table: 'channel_info', + schema: ChannelInfoSchema, +}; \ No newline at end of file diff --git a/src/odm/GroupInfo.ts b/src/odm/GroupInfo.ts new file mode 100644 index 0000000..3dbaef3 --- /dev/null +++ b/src/odm/GroupInfo.ts @@ -0,0 +1,29 @@ +import mongoose, { Schema, Types } from "mongoose"; +import { GroupInfoType } from "../message/Sender"; + +export type GroupInfoSchemaType = GroupInfoType; + +export type GroupInfoModelType = mongoose.Model; + +export const GroupInfoSchema = (robotId: string) => new Schema({ + groupId: { + type: String, + required: true, + index: true, + }, + rootGroupId: { + type: String, + index: true, + }, + name: { + type: String, + required: true, + default: '', + index: true, + }, + image: String, + extra: { + type: Object, + default: {}, + }, +}); \ No newline at end of file diff --git a/src/odm/GroupUserInfo.ts b/src/odm/GroupUserInfo.ts new file mode 100644 index 0000000..bff58ee --- /dev/null +++ b/src/odm/GroupUserInfo.ts @@ -0,0 +1,35 @@ +import mongoose, { Schema, Types } from "mongoose"; +import { GroupUserInfoType } from "../message/Sender"; + +export type GroupUserInfoSchemaType = GroupUserInfoType; + +export type GroupUserInfoModelType = mongoose.Model; + +export const GroupUserInfoSchema = (robotId: string) => new Schema({ + rootGroupId: { + type: String, + index: true, + }, + groupId: { + type: String, + required: true, + index: true, + }, + userId: { + type: String, + required: true, + index: true, + }, + userName: { + type: String, + index: true, + }, + nickName: String, + title: String, + role: String, + image: String, + extra: { + type: Object, + default: {}, + }, +}); \ No newline at end of file diff --git a/src/odm/Message.ts b/src/odm/Message.ts new file mode 100644 index 0000000..3f5c69e --- /dev/null +++ b/src/odm/Message.ts @@ -0,0 +1,129 @@ +import { Model, Schema, Types } from "mongoose"; +import { ChatIdentity } from "../message/Sender"; +import { MessageChunk, MessageDirection } from "../message/Message"; + +export function chatIdentityToDB(chatIdentity: ChatIdentity): ChatIdentityEntityType { + return { + rootGroupId: chatIdentity.rootGroupId, + groupId: chatIdentity.groupId, + userId: chatIdentity.userId, + channelId: chatIdentity.channelId, + } +} + +export type ChatIdentityEntityType = Partial<{ + rootGroupId: string, + groupId: string, + userId: string, + channelId: string, +}>; + +export type MessageDataType = { + /** 消息ID */ + messageId: string, + /** 消息类型 */ + type: string, + /** 消息收发(消息方向) */ + direction: MessageDirection, + /** 聊天类型(私聊、群聊) */ + chatType: string, + /** 聊天目标的ID */ + chatIdentity: ChatIdentityEntityType, + /** 回复的消息ID */ + repliedMessageId?: string, + /** 提到的用户ID */ + mentionedUserIds?: string[], + /** 纯文本消息内容 */ + contentText: string, + /** 消息内容 */ + content: MessageChunk[], + /** 时间 */ + time: Date, + /** 消息是否被删除 */ + deleted?: boolean, + /** 附加信息 */ + extra: any, +}; + +export type MessageSchemaType = MessageDataType; + +export type MessageModelMethods = { + +} + +export type MessageModelType = Model; + +export const MessageSchema = (robotId: string) => new Schema({ + messageId: { + type: String, + required: true, + index: true, + }, + type: { + type: String, + required: true, + index: true, + }, + direction: { + type: Number, + required: true, + index: true, + }, + chatType: { + type: String, + required: true, + index: true, + }, + chatIdentity: { + type: { + rootGroupId: { + type: String, + index: true, + }, + groupId: { + type: String, + index: true, + }, + userId: { + type: String, + index: true, + }, + channelId: { + type: String, + index: true, + }, + } + }, + repliedMessageId: { + type: String, + index: true, + }, + mentionedUserIds: { + type: [ + { + type: String, + index: true, + }, + ], + default: [], + }, + contentText: { + type: String, + default: '', + }, + content: [Object], + time: { + type: Date, + default: Date.now, + index: true, + }, + deleted: { + type: Boolean, + default: false, + index: true, + }, + extra: { + type: Object, + default: {}, + }, +}); diff --git a/src/odm/RootGroupInfo.ts b/src/odm/RootGroupInfo.ts new file mode 100644 index 0000000..f23a84c --- /dev/null +++ b/src/odm/RootGroupInfo.ts @@ -0,0 +1,23 @@ +import mongoose, { Schema, Types } from "mongoose"; +import { RootGroupInfoType } from "../message/Sender"; + +export type RootGroupInfoSchemaType = RootGroupInfoType; + +export type RootGroupInfoModelType = mongoose.Model; + +export const RootGroupInfoSchema = (robotId: string) => new Schema({ + rootGroupId: { + type: String, + required: true, + index: true, + }, + name: { + type: String, + index: true, + }, + image: String, + extra: { + type: Object, + default: {}, + } +}); \ No newline at end of file diff --git a/src/odm/UserInfo.ts b/src/odm/UserInfo.ts new file mode 100644 index 0000000..fd9dab3 --- /dev/null +++ b/src/odm/UserInfo.ts @@ -0,0 +1,27 @@ +import { Model, Schema, Types } from "mongoose"; +import { UserInfoType } from "../message/Sender"; + +export type UserInfoSchemaType = UserInfoType; + +export type UserInfoModelType = Model; + +export const UserInfoSchema = (robotId: string) => new Schema({ + userId: { + type: String, + required: true, + index: true, + }, + userName: { + type: String, + index: true, + }, + nickName: { + type: String, + index: true, + }, + image: String, + extra: { + type: Object, + default: {}, + } +}); \ No newline at end of file diff --git a/src/orm/GroupData.ts b/src/orm/GroupData.ts deleted file mode 100644 index 3ffacaf..0000000 --- a/src/orm/GroupData.ts +++ /dev/null @@ -1,22 +0,0 @@ -import mongoose, { Schema, Types } from "mongoose"; -import { ObjectId } from "mongodb"; - -export type GroupDataSchemaType = { - id: Types.ObjectId, - groupId: string, - parentId: Types.ObjectId, - name: string, - image: string, - extra: any, -}; - -export const GroupDataSchema = new Schema({ - id: ObjectId, - groupId: String, - parentId: ObjectId, - name: String, - image: String, - extra: Object, -}); - -export const GroupDataModel = mongoose.model('GroupData', GroupDataSchema); \ No newline at end of file diff --git a/src/orm/GroupUserData.ts b/src/orm/GroupUserData.ts deleted file mode 100644 index cffe637..0000000 --- a/src/orm/GroupUserData.ts +++ /dev/null @@ -1,28 +0,0 @@ -import mongoose, { Schema, Types } from "mongoose"; -import { ObjectId } from "mongodb"; - -export type GroupUserDataSchemaType = { - id: Types.ObjectId, - groupId: string, - userId: string, - userName: string, - nickName: string, - title: string, - role: string, - image: string, - extra: any, -}; - -export const GroupUserDataSchema = new Schema({ - id: ObjectId, - groupId: String, - userId: String, - userName: String, - nickName: String, - title: String, - role: String, - image: String, - extra: Object, -}); - -export const GroupUserDataModel = mongoose.model('GroupUserData', GroupUserDataSchema); \ No newline at end of file diff --git a/src/orm/Message.ts b/src/orm/Message.ts deleted file mode 100644 index d94e7c6..0000000 --- a/src/orm/Message.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { Schema, Types } from "mongoose"; -import { ObjectId } from "mongodb"; -import { ChatIdentity } from "../message/Sender"; - -export type ChatIdentityEntityType = Partial<{ - robotId: string, - rootGroupId: string, - groupId: string, - userId: string, - channelId: string, -}>; - -export const ChatIdentityEntity = { - robotId: String, - rootGroupId: String, - groupId: String, - userId: String, - channelId: String, -}; - -export function toChatIdentityEntity(chatIdentity: ChatIdentity): ChatIdentityEntityType { - return { - robotId: chatIdentity.robot.robotId, - rootGroupId: chatIdentity.rootGroupId, - groupId: chatIdentity.groupId, - userId: chatIdentity.userId, - channelId: chatIdentity.channelId, - } -} - -export function buildChatIdentityQuery(chatIdentityEntity: ChatIdentityEntityType | ChatIdentity, prefix = 'chatIdentity') { - const query: any = {}; - if ((chatIdentityEntity as any).robotId) { - query[`${prefix}.robotId`] = (chatIdentityEntity as any).robotId; - } else if ((chatIdentityEntity as any).robot && (chatIdentityEntity as any).robot.robotId) { - query[`${prefix}.robotId`] = (chatIdentityEntity as any).robot.robotId; - } - if (chatIdentityEntity.rootGroupId) { - query[`${prefix}.rootGroupId`] = chatIdentityEntity.rootGroupId; - } - if (chatIdentityEntity.groupId) { - query[`${prefix}.groupId`] = chatIdentityEntity.groupId; - } - if (chatIdentityEntity.userId) { - query[`${prefix}.userId`] = chatIdentityEntity.userId; - } - if (chatIdentityEntity.channelId) { - query[`${prefix}.channelId`] = chatIdentityEntity.channelId; - } - return query; -} - -export type MessageSchemaType = { - id: Types.ObjectId, - messageId: string, - type: string, - origin: string, - chatIdentity: ChatIdentityEntityType, - meta: { - repliedId: Types.ObjectId, - repliedMessageId: string, - mentionedUsers: Types.ObjectId[], - mentionedUids: string[], - }, - isSend: boolean, - contentText: string, - content: any, - time: Date, - deleted: boolean, - extra: any, -}; - -export const MessageSchema = new Schema({ - id: ObjectId, - messageId: String, - type: String, - origin: String, - chatIdentity: ChatIdentityEntity, - meta: { - repliedId: ObjectId, - repliedMessageId: String, - mentionedUsers: { - type: [ObjectId], - default: [] - }, - mentionedUids: { - type: [String], - default: [] - } - }, - isSend: Boolean, - contentText: String, - content: Object, - time: { - type: Date, - default: Date.now - }, - deleted: { - type: Boolean, - default: false - }, - extra: { - type: Object, - default: {}, - }, -}); \ No newline at end of file diff --git a/src/orm/UserDataSchema.ts b/src/orm/UserDataSchema.ts deleted file mode 100644 index 070a0e6..0000000 --- a/src/orm/UserDataSchema.ts +++ /dev/null @@ -1,22 +0,0 @@ -import mongoose, { Schema, Types } from "mongoose"; -import { ObjectId } from "mongodb"; - -export type UserDataSchemaType = { - id: Types.ObjectId, - userId: string, - userName: string, - nickName: string, - image: string, - extra: any, -}; - -export const UserDataSchema = new Schema({ - id: ObjectId, - userId: String, - userName: String, - nickName: String, - image: String, - extra: Object, -}); - -export const UserDataModel = mongoose.model('UserData', UserDataSchema); \ No newline at end of file diff --git a/src/robot/QQRobot.ts b/src/robot/QQRobot.ts index bb207b7..8096744 100644 --- a/src/robot/QQRobot.ts +++ b/src/robot/QQRobot.ts @@ -1,19 +1,21 @@ import koa from "koa"; import got from "got/dist/source"; +import fs from "fs"; import App from "../App"; import { Robot } from "../RobotManager"; import { Target } from "../SubscribeManager"; import { Utils } from "../utils/Utils"; import { FullRestfulContext, RestfulApiManager, RestfulRouter } from "../RestfulApiManager"; -import { convertMessageToQQChunk, parseQQMessageChunk, QQGroupMessage, QQGroupSender, QQPrivateMessage, QQUserSender } from "./qq/Message"; -import { CommonReceivedMessage, CommonSendMessage } from "../message/Message"; +import { convertMessageToQQChunk, parseQQMessageChunk, QQAttachmentMessage, QQGroupMessage, QQGroupSender, QQPrivateMessage, QQUserSender } from "./qq/Message"; +import { CommonReceivedMessage, CommonSendMessage, MessageChunk } from "../message/Message"; import { PluginController } from "../PluginManager"; import { RobotConfig } from "../Config"; -import { ChatIdentity } from "../message/Sender"; +import { ChatIdentity, GroupInfoType, UserInfoType } from "../message/Sender"; +import { QQInfoProvider } from "./qq/InfoProvider"; export type QQRobotConfig = RobotConfig & { - uid: string; + userId: string; host: string; command_prefix?: string; } @@ -28,26 +30,27 @@ export type QQGroupInfo = { export default class QQRobot implements Robot { public type = 'qq'; - public uid: string; + public userId: string; public robotId: string; public description: string; public commandPrefix: string[] = ['/', '!', '!', '/']; + public infoProvider: QQInfoProvider; + private app: App; private endpoint: string; private taskId?: NodeJS.Timer; - private groupList: QQGroupInfo[] = []; - private messageTypeHandler: Record Promise> = {}; + private emojiMap: Record = {}; constructor(app: App, robotId: string, config: QQRobotConfig) { this.app = app; this.robotId = robotId; this.endpoint = 'http://' + config.host; - this.uid = config.uid.toString(); + this.userId = config.userId.toString(); this.description = config.description ?? this.app.config.robot_description ?? 'Isekai Feedbot for QQ'; @@ -60,30 +63,15 @@ export default class QQRobot implements Robot { } this.messageTypeHandler.help = this.parseHelpMessage.bind(this); + this.infoProvider = new QQInfoProvider(app, this, config); } async initialize() { - this.refreshRobotInfo(); - - // 每30分钟刷新一次信息 - this.taskId = setInterval(() => { - this.refreshRobotInfo(); - }, 30 * 60 * 1000); + await this.infoProvider.initialize(); } - async refreshRobotInfo() { - // 刷新群信息 - let remoteGroupList = await this.getGroupList(); - remoteGroupList.forEach((groupInfo) => { - if (groupInfo.group_id) { - this.groupList.push({ - groupId: groupInfo.group_id, - groupName: groupInfo.group_name, - memberCount: groupInfo.member_count, - memberLimit: groupInfo.max_member_count - }); - } - }); + async destroy() { + await this.infoProvider.destroy(); } async initRestfulApi(router: RestfulRouter, api: RestfulApiManager) { @@ -93,10 +81,20 @@ export default class QQRobot implements Robot { async handlePostEvent(ctx: FullRestfulContext, next: koa.Next) { if (ctx.request.body?.post_type) { const postData = ctx.request.body; + // if (this.app.debug && postData.post_type !== "meta_event" && postData.meta_event_type !== "heartbeat") { + // console.log("收到QQ机器人事件", postData); + // } switch (postData.post_type) { case 'message': this.handleMessage(postData); break; + case 'notice': + switch (postData.notice_type) { + case 'group_upload': + this.handleGroupFile(postData); + break; + } + break; } } @@ -104,6 +102,11 @@ export default class QQRobot implements Robot { await next(); } + public getUsersInfo = (userIds: string[]) => this.infoProvider.getUsersInfo(userIds); + public getGroupInfo = (groupId: string, rootGroupId?: string | undefined) => this.infoProvider.getGroupInfo(groupId, rootGroupId); + public getGroupUsersInfo = (userIds: string[], groupId: string, rootGroupId?: string | undefined) => + this.infoProvider.getGroupUsersInfo(userIds, groupId, rootGroupId); + async parseHelpMessage(message: CommonSendMessage) { const controllers = message.extra.controllers as PluginController[]; @@ -135,10 +138,9 @@ export default class QQRobot implements Robot { } message.content = [{ - type: 'text', - data: { - text: helpBuilder.join('\n') - } + type: ['text'], + text: helpBuilder.join('\n'), + data: {}, }]; } @@ -157,7 +159,7 @@ export default class QQRobot implements Robot { let message: QQGroupMessage | QQPrivateMessage | undefined; if (postData.message_type === 'group') { // 处理群消息 - let groupInfo = this.groupList.find((info) => info.groupId === postData.group_id); + let groupInfo = this.infoProvider.groupList.find((info) => info.groupId === postData.group_id); let groupSender = new QQGroupSender(this, postData.group_id.toString(), postData.user_id.toString()); groupSender.groupInfo = groupInfo; @@ -172,6 +174,9 @@ export default class QQRobot implements Robot { message.time = new Date(postData.time * 1000); message = await parseQQMessageChunk(this, postData.message ?? [], message); + + await this.infoProvider.updateGroupSender(groupSender); + await this.infoProvider.updateUserSender(groupSender.userSender); } else if (postData.message_type === 'private') { // 处理私聊消息 let userSender = new QQUserSender(this, postData.user_id.toString()); @@ -181,9 +186,14 @@ export default class QQRobot implements Robot { message.time = new Date(postData.time * 1000); message = await parseQQMessageChunk(this, postData.message ?? [], message); + + await this.infoProvider.updateUserSender(userSender); } if (message) { + // 保存消息 + this.infoProvider.saveMessage(message); + // 处理原始消息 isResolved = await this.app.event.emitRawMessage(message); if (isResolved) return; @@ -202,9 +212,53 @@ export default class QQRobot implements Robot { } } + /** + * 处理群文件 + * @param postData + * @returns + */ + async handleGroupFile(postData: any) { + // 处理群消息 + let groupInfo = this.infoProvider.groupList.find((info) => info.groupId === postData.group_id); + + let groupSender = new QQGroupSender(this, postData.group_id.toString(), postData.user_id.toString()); + groupSender.groupInfo = groupInfo; + groupSender.groupName = groupInfo?.groupName; + + let message = new QQGroupMessage(groupSender, this); + message.time = new Date(postData.time * 1000); + + message.type = 'attachment'; + message.content.push({ + type: ['attachement', 'qqattachment'], + data: { + sender_type: 'group', + sender_id: postData.group_id.toString(), + url: postData.file?.url ?? '', + fileName: postData.file?.name ?? '', + size: postData.file?.size, + file_id: postData.file?.id, + busid: postData.file?.busid, + } + } as QQAttachmentMessage); + + let isResolved = false; + // 处理原始消息 + isResolved = await this.app.event.emitRawMessage(message); + if (isResolved) return; + + // 处理消息 + isResolved = await this.app.event.emitMessage(message); + if (isResolved) return; + } + getCommandContentText(message: CommonReceivedMessage) { for (let prefix of this.commandPrefix) { if (message.contentText.startsWith(prefix)) { + // 移除指令前缀 + if (message.content[0].data?.text) { + message.content[0].data.text = message.content[0].data.text.substring(prefix.length); + } return message.contentText.substring(prefix.length); } } @@ -213,7 +267,60 @@ export default class QQRobot implements Robot { getSession(chatIdentity: ChatIdentity, type: string) { const sessionPath = this.app.robot.getSessionPath(chatIdentity, type); - return this.app.session.getStore(sessionPath); + return this.app.cache.getStore(sessionPath); + } + + async ensureMediaUrl(mediaMessageChunk: MessageChunk): Promise { + if (!mediaMessageChunk.data.url) { + if (mediaMessageChunk.type.includes('qqattachment')) { + let data = mediaMessageChunk.data; + if (data.sender_type === "group") { + data.url = await this.getGroupFileUrl({ + group_id: data.sender_id, + busid: data.busid, + file_id: data.file_id, + }); + } + } + } + } + + async markRead(message: CommonReceivedMessage): Promise { + if (message.id) { + await this.callRobotApi('mark_msg_as_read', { + message_id: message.id + }); + } + return true; + } + + /** + * 获取合并转发的原消息列表 + */ + async getReferencedMessages(resId: string): Promise { + const res = await this.callRobotApi('/get_forward_msg', { + message_id: resId, + }); + if (!Array.isArray(res?.messages)) { + return null; + } + + let messageList: CommonReceivedMessage[] = []; + for (let messageData of res.messages) { + if (messageData) { + let userSender = new QQUserSender(this, messageData.sender?.user_id.toString()); + userSender.nickName = messageData.sender?.nickname; + + let message = new QQPrivateMessage(userSender, this); + message.time = new Date(messageData.time * 1000); + + message = await parseQQMessageChunk(this, messageData.content ?? [], message); + + messageList.push(message); + } + } + + return messageList; } /** @@ -231,7 +338,7 @@ export default class QQRobot implements Robot { return; } - return await this.doApiRequest('send_private_msg', { + return await this.callRobotApi('send_private_msg', { user_id: user, message: message }); @@ -249,7 +356,7 @@ export default class QQRobot implements Robot { return; } - return await this.doApiRequest('send_group_msg', { + return await this.callRobotApi('send_group_msg', { group_id: group, message: message }); @@ -268,18 +375,25 @@ export default class QQRobot implements Robot { let msgData = await convertMessageToQQChunk(message); - let res: any = {}; - if (message.origin === 'private') { - this.app.logger.debug('[DEBUG] 发送私聊消息', message.targetId, msgData); - res = await this.sendToUser(message.targetId, msgData); - } else if (message.origin === 'group') { - this.app.logger.debug('[DEBUG] 发送群消息', message.targetId, msgData); - res = await this.sendToGroup(message.targetId, msgData); - } + try { + let res: any = {}; + if (message.chatType === 'private') { + this.app.logger.debug('[DEBUG] 发送私聊消息', message.receiver.userId, msgData); + res = await this.sendToUser(message.receiver.userId!, msgData); + } else if (message.chatType === 'group') { + this.app.logger.debug('[DEBUG] 发送群消息', message.receiver.groupId, msgData); + res = await this.sendToGroup(message.receiver.groupId!, msgData); + } - // 保存 Message ID - if (res?.data?.message_id) { - message.id = res.data.message_id; + // 保存 Message ID + if (res?.data?.message_id) { + message.id = res.data.message_id; + } + + // 保存消息 + this.infoProvider.saveMessage(message); + } catch(err: any) { + console.error(err); } return message; @@ -308,26 +422,26 @@ export default class QQRobot implements Robot { } async deleteMessage(chatIdentity: ChatIdentity, messageId: string): Promise { - await this.doApiRequest('delete_msg', { + await this.callRobotApi('delete_msg', { message_id: messageId }); return true; } - async getGroupList(): Promise { - const res = await this.doApiRequest('get_group_list', {}); + async getGroupFileUrl(data: any): Promise { + const res = await this.callRobotApi('get_group_file_url', data); if (res && res.status === 'ok') { - return res.data; + return res.data?.url ?? ""; } else { - return []; + return ""; } } /** * 执行API调用 */ - async doApiRequest(method: string, data: any): Promise { - return await got.post(this.endpoint + '/' + method, { + callRobotApi(method: string, data: any): Promise { + return got.post(this.endpoint + '/' + method, { json: data, timeout: 10000 }).json(); diff --git a/src/robot/TelegramRobot.ts b/src/robot/TelegramRobot.ts index 49145cb..ef0766c 100644 --- a/src/robot/TelegramRobot.ts +++ b/src/robot/TelegramRobot.ts @@ -18,7 +18,7 @@ export default class TelegramRobot implements Robot { public type = 'telegram'; public robotId: string; - public uid?: string; + public userId?: string; public description: string; private bot: TelegramBot; @@ -79,7 +79,7 @@ export default class TelegramRobot implements Robot { getSession(chatIdentity: ChatIdentity, type: string) { const sessionPath = this.app.robot.getSessionPath(chatIdentity, type); - return this.app.session.getStore(sessionPath); + return this.app.cache.getStore(sessionPath); } /** diff --git a/src/robot/qq/InfoProvider.ts b/src/robot/qq/InfoProvider.ts new file mode 100644 index 0000000..2604ed5 --- /dev/null +++ b/src/robot/qq/InfoProvider.ts @@ -0,0 +1,261 @@ +import App from "../../App"; +import { compareProps } from "../../utils/func"; +import QQRobot, { QQRobotConfig } from "../QQRobot"; +import { QQGroupSender, QQUserSender } from "./Message"; +import { GroupInfoType, GroupUserInfoType, UserInfoType } from "../../message/Sender"; +import { CommonMessage } from "src/message/Message"; +import { RobotStorage } from "src/storage/RobotStorage"; + +export type QQGroupInfo = { + groupId: string, + groupName?: string, + memberCount?: number, + memberLimit?: number +}; + +export class QQInfoProvider { + private app: App; + private robot: QQRobot; + private config: QQRobotConfig; + private storages?: RobotStorage; + + private infoLoaderTimer: NodeJS.Timer | null = null; + + public groupList: QQGroupInfo[] = []; + public userSenderList: Record = {}; + public groupSenderList: Record> = {}; + + constructor(app: App, robot: QQRobot, config: QQRobotConfig) { + this.app = app; + this.robot = robot; + this.config = config; + } + + async initialize() { + this.storages = await this.app.storage.getStorages(this.robot.robotId); + + this.refreshRobotInfo(); + + // 每30分钟刷新一次信息 + this.infoLoaderTimer = setInterval(() => { + this.refreshRobotInfo(); + }, 30 * 60 * 1000); + } + + async destroy() { + if (this.infoLoaderTimer) { + clearInterval(this.infoLoaderTimer); + this.infoLoaderTimer = null; + } + } + + async refreshRobotInfo() { + // 刷新群信息 + let remoteGroupList = await this.getGroupList(); + remoteGroupList.forEach((data) => { + if (data.group_id) { + let oldGroupIndex = this.groupList.findIndex((info) => info.groupId === data.group_id); + + const groupInfo: QQGroupInfo = { + groupId: data.group_id, + groupName: data.group_name, + memberCount: data.member_count, + memberLimit: data.max_member_count + } + + if (oldGroupIndex !== -1) { + const oldGroupInfo = this.groupList[oldGroupIndex]; + if (compareProps(oldGroupInfo, groupInfo, ['groupName', 'memberCount', 'memberLimit'])) { + return; + } + + this.groupList[oldGroupIndex] = groupInfo; + } else { + this.groupList.push(groupInfo); + } + + this.updateGroupInfo(groupInfo); + } + }); + } + + public saveMessage(message: CommonMessage) { + this.storages?.message.set(message).catch((err: any) => { + this.app.logger.error(`将消息保存到数据库出错: ${err.message}`); + console.error(err); + }) + } + + async getGroupList(): Promise { + const res = await this.robot.callRobotApi('get_group_list', {}); + if (res && res.status === 'ok') { + return res.data; + } else { + return []; + } + } + + async getUsersInfo(userIds: string[]): Promise<(UserInfoType | null)[]> { + let userInfoList: (UserInfoType | null)[] = []; + + for (let userId of userIds) { + if (userId in this.userSenderList) { + let userSender = this.userSenderList[userId]; + userInfoList.push(this.userSenderToUserInfo(userSender)); + } else { + userInfoList.push(null); + } + } + + return userInfoList; + } + + async getGroupInfo(groupId: string, rootGroupId?: string): Promise { + let localGroupInfo = this.groupList.find((info) => info.groupId === groupId); + + if (localGroupInfo) { + return { + groupId, + name: localGroupInfo.groupName ?? groupId, + image: this.getGroupImage(groupId), + extra: { + memberCount: localGroupInfo.memberCount, + memberLimit: localGroupInfo.memberLimit, + }, + }; + } + + return null; + } + + async getGroupUsersInfo(userIds: string[], groupId: string, rootGroupId?: string): Promise<(GroupUserInfoType | null)[]> { + let groupUserInfoList: (GroupUserInfoType | null)[] = []; + + const localList = this.groupSenderList[groupId]; + + if (!localList) { + return new Array(userIds.length).fill(null); + } + + for (let userId of userIds) { + if (userId in localList) { + let groupSender = localList[userId]; + groupUserInfoList.push(this.groupSenderToGroupUserInfo(groupSender)); + } + } + + return groupUserInfoList; + } + + /** + * 获取用户头像 + * @param userId + * @returns + */ + private getUserImage(userId: string) { + return `https://q1.qlogo.cn/g?b=qq&nk=${userId}&s=640`; + } + + /** + * 获取群头像 + * @param groupId + * @returns + */ + private getGroupImage(groupId: string) { + return `https://p.qlogo.cn/gh/${groupId}/${groupId}/100` + } + + /** + * 更新群用户信息 + * @param groupSender + * @returns + */ + public async updateGroupSender(groupSender: QQGroupSender) { + let savedGroupSender = this.groupSenderList[groupSender.groupId]?.[groupSender.userId]; + if (savedGroupSender) { + if (compareProps(savedGroupSender, groupSender, ['globalNickName', 'nickName', 'role', 'level', 'title'])) { + return; + } + } + + if (!this.groupSenderList[groupSender.groupId]) { + this.groupSenderList[groupSender.groupId] = {}; + } + + this.groupSenderList[groupSender.groupId][groupSender.userId] = groupSender; + + const storages = await this.app.storage.getStorages(this.robot.robotId); + + await storages.userInfo.set(this.userSenderToUserInfo(groupSender.userSender)); + await storages.groupUserInfo.set( + this.groupSenderToGroupUserInfo(groupSender), + groupSender.userId, + groupSender.groupId + ); + } + + /** + * 更新用户信息 + * @param userSender + * @returns + */ + public async updateUserSender(userSender: QQUserSender) { + let savedUserSender = this.userSenderList[userSender.userId]; + if (savedUserSender) { + if (compareProps(savedUserSender, userSender, ['nickName'])) { + return; + } + } + + this.userSenderList[userSender.userId] = userSender; + + const storages = await this.app.storage.getStorages(this.robot.robotId); + + this.app.logger.debug(`更新用户信息: ${userSender.userId}`); + + await storages.userInfo.set(this.userSenderToUserInfo(userSender)); + } + + public async updateGroupInfo(groupInfo: QQGroupInfo) { + const storages = await this.app.storage.getStorages(this.robot.robotId); + + await storages.groupInfo.set(this.groupInfoToStorageGroupInfo(groupInfo)); + + this.app.logger.debug(`更新群组信息: ${groupInfo.groupId}`); + } + + private groupSenderToGroupUserInfo(groupSender: QQGroupSender): GroupUserInfoType { + return { + groupId: groupSender.groupId, + userId: groupSender.userId, + userName: groupSender.userName, + nickName: groupSender.nickName || groupSender.globalNickName, + title: groupSender.title, + role: groupSender.role, + image: this.getUserImage(groupSender.userId), + extra: {}, + }; + } + + private userSenderToUserInfo(userSender: QQUserSender): UserInfoType { + return { + userId: userSender.userId, + userName: userSender.userName, + nickName: userSender.nickName, + image: this.getUserImage(userSender.userId), + extra: {}, + }; + } + + private groupInfoToStorageGroupInfo(groupInfo: QQGroupInfo): GroupInfoType { + return { + groupId: groupInfo.groupId, + name: groupInfo.groupName ?? groupInfo.groupId, + image: this.getGroupImage(groupInfo.groupId), + extra: { + memberCount: groupInfo.memberCount, + memberLimit: groupInfo.memberLimit, + }, + }; + } +} \ No newline at end of file diff --git a/src/robot/qq/Message.ts b/src/robot/qq/Message.ts index 174b96e..19820fb 100644 --- a/src/robot/qq/Message.ts +++ b/src/robot/qq/Message.ts @@ -1,43 +1,66 @@ -import { CommonGroupMessage, CommonPrivateMessage, CommonReceivedMessage, CommonSendMessage, MentionMessage, MessageChunk, TextMessage } from "../../message/Message"; +import { AttachmentMessage, CommonGroupMessage, CommonPrivateMessage, CommonReceivedMessage, CommonSendMessage, EmojiMessage, ImageMessage, MentionMessage, MessageChunk, TextMessage, RecordMessage } from "../../message/Message"; import { GroupSender, UserSender } from "../../message/Sender"; import QQRobot, { QQGroupInfo } from "../QQRobot"; +import { qqFaceToEmoji } from "./emojiMap"; -export interface QQFaceMessage extends MessageChunk { - type: 'qqface'; +export interface QQFaceMessage extends EmojiMessage { + type: ['emoji', 'qqface']; data: { - id: string + id: string, + emoji: string, + url?: string, }; } -export interface QQImageMessage extends MessageChunk { - type: 'qqimage'; +export interface QQImageMessage extends ImageMessage { + type: ['image', 'qqimage']; data: { - file?: string; - url?: string; + url: string; alt?: string; + file?: string; subType?: string; }; } -export interface QQVoiceMessage extends MessageChunk { - type: 'qqvoice'; +export interface QQRecordMessage extends RecordMessage { + type: ['record', 'qqrecord']; data: { url: string; }; } -export interface QQUrlMessage extends MessageChunk { - type: 'qqurl'; +export interface QQUrlMessage extends TextMessage { + type: ['text', 'qqurl']; data: { url: string; title: string; }; } +export interface QQAttachmentMessage extends AttachmentMessage { + type: ['attachement', 'qqattachment']; + data: { + sender_type: string; + sender_id: string; + url: string; + fileName: string; + size?: number; + file_id?: string; + busid?: number; + } +} + +export interface QQForwardingMessage extends MessageChunk { + type: ['qqforwarding']; + data: { + res_id: string; + } +} + export class QQUserSender extends UserSender { - constructor(robot: QQRobot, uid: string) { - super(robot, uid); - this.userName = uid; + constructor(robot: QQRobot, userId: string) { + super(robot, userId); + this.userName = userId; } } @@ -47,13 +70,13 @@ export class QQGroupSender extends GroupSender { public title?: string; public groupInfo?: QQGroupInfo; - constructor(robot: QQRobot, groupId: string, uid: string) { - super(robot, groupId, uid); - this.userName = uid; + constructor(robot: QQRobot, groupId: string, userId: string) { + super(robot, groupId, userId); + this.userName = userId; } get userSender() { - let sender = new QQUserSender(this.robot as any, this.uid); + let sender = new QQUserSender(this.robot as any, this.userId); sender.userName = this.userName; sender.nickName = this.globalNickName; @@ -74,16 +97,15 @@ export async function parseQQMessageChunk(bot: QQRobot, messageData: any[], mess switch (chunkData.type) { case 'text': message.content.push({ - type: 'text', - data: { - text: chunkData.data?.text ?? '' - } + type: ['text'], + text: chunkData.data?.text ?? '', + data: {} } as TextMessage); break; case 'image': message.content.push({ - type: 'qqimage', - baseType: 'image', + type: ['image', 'qqimage'], + text: '[图片]', data: { url: chunkData.data?.url ?? '', alt: chunkData.data?.file, @@ -93,32 +115,44 @@ export async function parseQQMessageChunk(bot: QQRobot, messageData: any[], mess break; case 'record': message.content.push({ - type: 'qqvoice', - baseType: 'voice', + type: ['record', 'qqrecord'], + text: '[语音]', data: { url: chunkData.data?.url ?? '', } - } as QQVoiceMessage); + } as QQRecordMessage); break; case 'face': - message.content.push({ - type: 'qqface', - data: { - id: chunkData.data?.id ?? '', - } - } as QQFaceMessage); + if (chunkData.data?.id) { + let emojiChar = qqFaceToEmoji(chunkData.data.id); + message.content.push({ + type: ['emoji', 'qqface'], + text: emojiChar, + data: { + id: chunkData.data?.id ?? '', + emoji: emojiChar, + } + } as QQFaceMessage); + } else { + message.content.push({ + type: ['text'], + text: '[表情]', + data: { } + } as TextMessage); + } break; case 'at': if (chunkData.data?.qq) { if (!willIgnoreMention) { - if (chunkData.data.qq == bot.uid) { // 如果是@机器人 + if (chunkData.data.qq == bot.userId) { // 如果是@机器人 message.mentionedReceiver = true; } else { // @其他人的情况 message.mention(chunkData.data.qq); message.content.push({ - type: 'mention', + type: ['mention'], + text: `[@${chunkData.data.qq}]`, data: { - uid: chunkData.data.qq + userId: chunkData.data.qq, } } as MentionMessage); } @@ -133,19 +167,41 @@ export async function parseQQMessageChunk(bot: QQRobot, messageData: any[], mess willIgnoreMention = true; // 忽略下一个“@” } break; + case 'json': + if (typeof chunkData.data?.data === 'string' && chunkData.data.data.length < 2048) { + try { + let jsonData = JSON.parse(chunkData.data.data); + switch (jsonData.app) { + case 'com.tencent.multimsg': + console.log('forwarding message', chunkData.data.data); + if (jsonData.meta?.detail?.resid) { + message.content.push({ + type: ['qqforwarding'], + text: '[合并转发消息]', + data: { + res_id: jsonData.meta.detail.resid + } + } as QQForwardingMessage); + } + } + } catch (_) { } + } + break; + default: + console.log('unknown message', chunkData); } } }); if (message.content.length === 1) { // 检查单一消息的类型 - switch (message.content[0].type) { - case 'qqimage': - message.type = 'image'; - break; - case 'qqvoice': - message.type = 'voice'; - break; + const firstChunk = message.content[0]; + if (firstChunk.type.includes('qqimage')) { + message.type = 'image'; + } else if (firstChunk.type.includes('qqrecord')) { + message.type = 'record'; + } else if (firstChunk.type.includes('qqforwarding')) { + message.type = 'reference'; } } @@ -157,75 +213,70 @@ export async function convertMessageToQQChunk(message: CommonSendMessage) { message.content.forEach((rawChunk) => { let chunk = rawChunk; - if (rawChunk.baseType && !rawChunk.type.startsWith('qq')) { - chunk = { - ...rawChunk, - type: rawChunk.baseType, - }; - } - switch (chunk.type) { - case 'text': - msgChunk.push({ - type: 'text', - data: { - text: chunk.data.text - } - }); - break; - case 'qqface': - msgChunk.push({ - type: 'face', - data: { id: chunk.data.id } - }); - break; - case 'image': - case 'qqimage': - msgChunk.push({ - type: 'image', - data: { - file: chunk.data.url, - subType: chunk.data.subType ?? 0 - } - }); - break; - case 'voice': - case 'qqvoice': - msgChunk.push({ - type: 'record', - data: { - file: chunk.data.url - } - }); - break; - case 'mention': - msgChunk.push({ - type: 'at', - data: { - qq: chunk.data.uid - } - }); - break; + if (chunk.type.includes('text')) { + msgChunk.push({ + type: 'text', + data: { + text: chunk.text + } + }); + } else if (chunk.type.includes('qqface')) { + msgChunk.push({ + type: 'face', + data: { id: chunk.data.id } + }); + } else if (chunk.type.includes('image')) { + msgChunk.push({ + type: 'image', + data: { + file: chunk.data.url, + subType: chunk.data.subType ?? 0 + } + }); + } else if (chunk.type.includes('record')) { + msgChunk.push({ + type: 'record', + data: { + file: chunk.data.url + } + }); + } else if (chunk.type.includes('mention')) { + msgChunk.push({ + type: 'at', + data: { qq: chunk.data.userId } + }); + } else if (chunk.type.includes('qqforwarding')) { + // ignore + } else if (chunk.text !== null) { + msgChunk.push({ + type: 'text', + data: { + text: chunk.text + } + }); } - }) + }); if (message.repliedId) { - if (message.origin === 'group' && message.repliedMessage?.sender.uid) { - // 目前不知道为何,@不能正常传递 - /* - msgChunk.unshift({ - type: 'text', - data: { text: ' ' } - }); + if (message.chatType === 'group' && message.repliedMessage?.sender.userId) { + // go-cqhttp需要连续发送两个@才能显示出来 + // msgChunk.unshift({ + // type: 'text', + // data: { text: ' ' } + // }); msgChunk.unshift({ type: 'at', - data: { qq: message.repliedMessage.sender.uid } + data: { qq: message.repliedMessage.sender.userId } }); + // msgChunk.unshift({ + // type: 'text', + // data: { text: ' ' } + // }); msgChunk.unshift({ - type: 'text', - data: { text: ' ' } + type: 'at', + data: { qq: message.repliedMessage.sender.userId } }); - */ } msgChunk.unshift({ type: 'reply', diff --git a/src/robot/qq/emojiMap.ts b/src/robot/qq/emojiMap.ts new file mode 100644 index 0000000..a852119 --- /dev/null +++ b/src/robot/qq/emojiMap.ts @@ -0,0 +1,172 @@ +const emojiMapString = ` +0: 😯 +1: 😖 +2: 😍 +3: 😱 +4: 😎 +5: 😭 +6: ☺️ +7: 🤬 +8: 😪 +9: 😢 +10: 😰 +11: 😡 +12: 😜 +13: 😄 +14: 🙃 +15: 😕 +16: 😎 +18: 😫 +19: 🤢 +20: 🤭 +21: ☺️ +22: 🙄 +23: 🤔 +24: 😋 +25: 🥱 +26: 😳 +27: 😥 +28: 😄 +29: 🤠 +30: 💪 +31: 🤬 +32: 🤔 +33: 🤫 +34: 💫 +35: 😫 +36: 🌑 +37: ☠️ +38: 🔨 +39: 👋 +49: 🫂 +53: 🎂 +54: ⚡️ +55: 💣 +56: 🗡️ +57: ⚽️ +59: 💩 +60: ☕️ +61: 🍚 +62: 💊 +63: 🌹 +64: 🥀 +66: ❤️ +67: 💔 +69: 🎁 +72: 📧 +74: 🌞 +76: 👍 +77: 👎 +78: 🤝 +79: ✌️ +89: 🍉 +90: 🌨️ +91: ☁️ +96: 😰 +97: 🫠 +98: 🫠 +99: 👏 +100: 😰 +101: 😬 +102: 😒 +103: 😒 +104: 🥱 +105: 😒 +106: 🤥 +107: 😞 +108: 😏 +109: 😙 +110: 😱 +111: 🥺 +112: 🔪 +113: 🍺 +114: 🏀 +115: 🏓️ +116: 👄 +117: 🐞 +118: 👍 +119: 🤌 +120: 👊 +136: 喜 +137: 🧨 +138: 🏮 +139: 🀄️ +140: 🎤 +141: 🛍️ +142: 📧 +143: ♟️ +144: 🎉 +145: 🕯️ +146: 😠 +147: 🍭 +148: 🍼 +149: 🍜 +150: 🍌 +151: ✈️ +152: 🚗 +153: 🚝 +154: 🚃 +155: 🚝 +156: ☁️ +157: 🌧️ +158: 💴 +159: 🐼 +160: 💡 +161: 🪀 +162: ⏰ +163: 🌂 +164: 🎈 +165: 💍 +166: 🛋️ +167: 🧻 +168: 💊 +169: 🔫 +170: 🐸 +171: 🍵 +172: 😜 +173: 😨 +174: 🫤 +175: 😃 +176: 😅 +177: 😅 +178: 😏 +179: 😋 +180: 😄 +181: 🫥 +182: 😂 +183: 😃 +184: 🦀️ +185: 🦙 +186: 🌰 +187: 👻 +188: 🥚 +212: 🧐 +`; + + +let emojiMap = new Map(); + +for (let line of emojiMapString.split('\n')) { + line = line.trim(); + if (line === '') { + continue; + } + + let [key, value] = line.split(':'); + if (key && value) { + emojiMap.set(parseInt(key), value.trim()); + } +} + +function qqFaceToEmoji(faceId: number) { + if (emojiMap.has(faceId)) { + return emojiMap.get(faceId); + } else { + return '🫥'; + } +} + +export { + emojiMap, + qqFaceToEmoji +}; \ No newline at end of file diff --git a/src/storage/ChannelInfoStorage.ts b/src/storage/ChannelInfoStorage.ts new file mode 100644 index 0000000..a3d0430 --- /dev/null +++ b/src/storage/ChannelInfoStorage.ts @@ -0,0 +1,107 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { CacheStore } from "../CacheManager"; +import { ChannelInfoType, RootGroupInfoType } from "../message/Sender"; +import { ModelRegistry } from "../DatabaseManager"; +import { ChannelInfoSchemaType } from "../odm/ChannelInfo"; +import { RobotStorage } from "./RobotStorage"; + +export class ChannelInfoStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: CacheStore; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + this.cache = app.cache.getStore(['ObjectCache', storages.robotId, 'channel_info']); + } + + public async initialize() { + this.models = this.storages.models; + } + + public async get(channelId: string, fetchFromBot: boolean = false): Promise { + // from cache + let channelInfo = await this.cache.get(channelId); + if (channelInfo) { + return channelInfo; + } + + if (fetchFromBot) { + return await this.fetchFromRobot(channelId); + } else if (this.models) { + let doc = await this.models.channelInfo.findOne({ + channelId, + }); + + if (doc) { + channelInfo = doc.toObject(); + + await this.cache.set(channelId, channelInfo, this.cacheTTL); + return channelInfo; + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + public async getByRef(channelInfo: ChannelInfoSchemaType | string): Promise { + if (typeof channelInfo === 'string') { + return await this.get(channelInfo, false); + } else { + return await this.get(channelInfo.channelId, false); + } + } + + public async fetchFromRobot(channelId: string): Promise { + const robot = this.storages.robot; + if (robot) { + const channelInfo = await robot.getChannelInfo?.(channelId); + if (channelInfo) { + return await this.set(channelInfo); + } + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + return null; + } + + public async set(channelInfo: ChannelInfoType): Promise { + let data: ChannelInfoSchemaType = { + ...channelInfo + }; + + if (this.models) { + await this.models.channelInfo.updateOne({ + channelId: data.channelId, + }, data, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + await this.cache.set(data.channelId, data, this.cacheTTL); + + return data; + } + + public async remove(channelId: string): Promise { + if (this.models) { + await this.models.channelInfo.deleteOne({ + channelId, + }); + } + + await this.cache.del(channelId); + } +} \ No newline at end of file diff --git a/src/storage/GroupInfoStorage.ts b/src/storage/GroupInfoStorage.ts new file mode 100644 index 0000000..af12450 --- /dev/null +++ b/src/storage/GroupInfoStorage.ts @@ -0,0 +1,121 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { CacheStore } from "../CacheManager"; +import { GroupInfoType } from "../message/Sender"; +import { ModelRegistry } from "../DatabaseManager"; +import { GroupInfoSchemaType } from "../odm/GroupInfo"; +import { RootGroupInfoSchemaType } from "../odm/RootGroupInfo"; +import { Types } from "mongoose"; +import { RobotStorage } from "./RobotStorage"; + +export class GroupInfoStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: CacheStore; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + this.cache = app.cache.getStore(['ObjectCache', storages.robotId, 'group_info']); + } + + public async initialize() { + this.models = this.storages.models; + } + + private makeKey(groupId: string, rootGroupId?: string): string { + if (rootGroupId) { + return this.cache.makeKey([groupId, rootGroupId]); + } else { + return groupId; + } + } + + public async get(groupId: string, rootGroupId?: string, fetchFromBot: boolean = false): Promise { + // from cache + let groupInfo = await this.cache.get(this.makeKey(groupId, rootGroupId)); + if (groupInfo) { + return groupInfo; + } + + if (fetchFromBot) { + return await this.fetchFromRobot(groupId, rootGroupId); + } else if (this.models) { + let doc = await this.models.groupInfo.findOne(rootGroupId ? { + groupId, + rootGroupId, + } : { groupId }); + + if (doc) { + groupInfo = doc.toObject(); + + await this.cache.set(this.makeKey(groupId, rootGroupId), groupInfo, this.cacheTTL); + return groupInfo; + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + public async getByRef(groupInfo: GroupInfoSchemaType | string, rootGroupId?: string): Promise { + if (typeof groupInfo === 'string') { + return await this.get(groupInfo, rootGroupId, false); + } else { + return await this.get(groupInfo.groupId, groupInfo.rootGroupId, false); + } + } + + public async fetchFromRobot(groupId: string, rootGroupId?: string): Promise { + const robot = this.storages.robot; + if (robot) { + const groupInfo = await robot.getGroupInfo?.(groupId, rootGroupId); + if (groupInfo) { + return await this.set(groupInfo, rootGroupId); + } + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + return null; + } + + public async set(groupInfo: GroupInfoType, rootGroupInfo?: string | RootGroupInfoSchemaType): Promise { + let data: GroupInfoSchemaType = { + ...groupInfo, + rootGroupId: typeof rootGroupInfo === 'string' ? rootGroupInfo : rootGroupInfo?.rootGroupId, + }; + + if (this.models) { + await this.models.groupInfo.updateOne({ + groupId: data.groupId, + rootGroupId: data.rootGroupId, + }, data, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + await this.cache.set(this.makeKey(data.groupId, data.rootGroupId), data, this.cacheTTL); + + return data; + } + + public async remove(groupId: string, rootGroupId?: string): Promise { + if (this.models) { + await this.models.groupInfo.deleteOne({ + groupId, + rootGroupId, + }); + } + + await this.cache.del(this.makeKey(groupId, rootGroupId)); + } +} \ No newline at end of file diff --git a/src/storage/GroupUserInfoStorage.ts b/src/storage/GroupUserInfoStorage.ts new file mode 100644 index 0000000..51d884e --- /dev/null +++ b/src/storage/GroupUserInfoStorage.ts @@ -0,0 +1,123 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { CacheStore } from "../CacheManager"; +import { GroupUserInfoType } from "../message/Sender"; +import { ModelRegistry } from "../DatabaseManager"; +import { UserInfoSchemaType } from "../odm/UserInfo"; +import { GroupInfoSchemaType } from "../odm/GroupInfo"; +import { RobotStorage } from "./RobotStorage"; +import { GroupUserInfoSchemaType } from "../odm/GroupUserInfo"; +import { RootGroupInfoSchemaType } from "../odm/RootGroupInfo"; + +export class GroupUserInfoStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: CacheStore; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + this.cache = app.cache.getStore(['ObjectCache', storages.robotId, 'group_user_info']); + } + + public async initialize() { + this.models = this.storages.models; + } + + private makeKey(userId: string, groupId: string, rootGroupId?: string): string { + if (rootGroupId) { + return this.cache.makeKey([userId, rootGroupId, groupId]); + } else { + return this.cache.makeKey([userId, groupId]); + } + } + + public async get(userId: string, groupId: string, rootGroupId?: string, fetchFromBot: boolean = false): Promise { + // from cache + let groupUserInfo = await this.cache.get(this.makeKey(userId, groupId, rootGroupId)); + if (groupUserInfo) { + return groupUserInfo; + } + + if (fetchFromBot) { + // from bot + return await this.fetchFromRobot(userId, groupId); + } else if (this.models) { // from database + let doc = await this.models.groupUserInfo.findOne({ + userId, + groupId, + rootGroupId, + }); + + if (doc) { + groupUserInfo = doc.toObject(); + + await this.cache.set(this.makeKey(userId, groupId, rootGroupId), groupUserInfo, this.cacheTTL); + return groupUserInfo; + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + public async fetchFromRobot(userId: string, groupId: string, rootGroupId?: string): Promise { + const robot = this.app.robot.getRobot(this.storages.robotId); + if (robot) { + const groupUserInfoList = await robot.getGroupUsersInfo?.([userId], groupId); + if (groupUserInfoList && groupUserInfoList.length > 0) { + const groupUserInfo = groupUserInfoList[0]; + if (groupUserInfo) { + return await this.set(groupUserInfo, userId, groupId, rootGroupId); + } + } + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + return null; + } + + public async set(groupUserInfo: GroupUserInfoType, userInfo: string | UserInfoSchemaType, + groupInfo: string | GroupInfoSchemaType, rootGroupInfo?: string | RootGroupInfoSchemaType): Promise { + let data: GroupUserInfoSchemaType = { + ...groupUserInfo, + userId: typeof userInfo === 'string' ? userInfo : userInfo.userId, + groupId: typeof groupInfo === 'string' ? groupInfo : groupInfo.groupId, + rootGroupId: typeof rootGroupInfo === 'string' ? rootGroupInfo : rootGroupInfo?.rootGroupId, + }; + + // 保存到数据库 + if (this.models) { + await this.models.groupUserInfo.updateOne({ + userId: data.userId, + groupId: data.groupId, + }, data, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + await this.cache.set(this.makeKey(data.userId, data.groupId, data.rootGroupId), data, this.cacheTTL); + + return data; + } + + public async remove(userId: string, groupId: string): Promise { + if (this.models) { + await this.models.groupUserInfo.deleteOne({ + userId, + groupId, + }); + } + + await this.cache.del(this.makeKey(userId, groupId)); + } +} \ No newline at end of file diff --git a/src/storage/MessageStorage.ts b/src/storage/MessageStorage.ts new file mode 100644 index 0000000..f7b860f --- /dev/null +++ b/src/storage/MessageStorage.ts @@ -0,0 +1,110 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { ModelRegistry } from "../DatabaseManager"; +import { ItemLimitedList } from "../utils/ItemLimitedList"; +import { CommonMessage } from "../message/Message"; +import { RobotStorage } from "./RobotStorage"; + +export class MessageStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: ItemLimitedList; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + let itemLimit = config.message?.lru_limit ?? 1000; + + this.cache = new ItemLimitedList(itemLimit); + } + + public async initialize() { + this.models = this.storages.models; + } + + public async get(messageId: string): Promise { + // from cache + let messageObj = this.cache.find((msg) => msg && msg.id === messageId); + if (messageObj) { + return messageObj; + } + + // from database + if (this.models) { + let doc = await this.models.message.findOne({ + messageId + }); + + if (doc) { + const robot = this.storages.robot; + if (robot) { + messageObj = await robot.parseDBMessage?.(doc); + return messageObj!; + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + /** + * 添加或更新消息 + * @param messageId + * @param message + */ + public async set(message: CommonMessage): Promise { + let messageData = message.toDBObject(); + + if (this.models) { + await this.models.message.updateOne({ + messageId: message.id!, + }, messageData, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + this.cache.push(message); + } + + public async remove(messageId: string): Promise { + if (this.models) { + await this.models.userInfo.deleteOne({ + messageId, + }); + } + + let listIndex = this.cache.findIndex((msg) => msg && msg.id === messageId); + this.cache[listIndex] = undefined; + } + + /** + * 标记消息为已撤回 + * @param messageId + */ + public async markDeleted(messageId: string): Promise { + if (this.models) { + await this.models.message.updateOne({ + messageId, + }, { + deleted: true, + }); + } + + let messageObj = this.cache.find((msg) => msg && msg.id === messageId); + if (messageObj) { + messageObj.deleted = true; + } + } +} \ No newline at end of file diff --git a/src/storage/RobotStorage.ts b/src/storage/RobotStorage.ts new file mode 100644 index 0000000..3a64674 --- /dev/null +++ b/src/storage/RobotStorage.ts @@ -0,0 +1,62 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { ModelRegistry } from "../DatabaseManager"; +import { Robot } from "../RobotManager"; +import { ChannelInfoStorage } from "./ChannelInfoStorage"; +import { GroupInfoStorage } from "./GroupInfoStorage"; +import { GroupUserInfoStorage } from "./GroupUserInfoStorage"; +import { MessageStorage } from "./MessageStorage"; +import { RootGroupInfoStorage } from "./RootGroupInfoStorage"; +import { UserInfoStorage } from "./UserInfoStorage"; + +export class RobotStorage { + private app: App; + private config: StorageConfig; + private _robotId: string; + private _robot?: Robot; + private _models?: ModelRegistry; + + public userInfo: UserInfoStorage; + public channelInfo: ChannelInfoStorage; + public rootGroupInfo: RootGroupInfoStorage; + public groupInfo: GroupInfoStorage; + public groupUserInfo: GroupUserInfoStorage; + public message: MessageStorage; + + public constructor(app: App, config: StorageConfig, robotId: string) { + this.app = app; + this.config = config; + this._robotId = robotId; + + this.userInfo = new UserInfoStorage(app, config, this); + this.channelInfo = new ChannelInfoStorage(app, config, this); + this.rootGroupInfo = new RootGroupInfoStorage(app, config, this); + this.groupInfo = new GroupInfoStorage(app, config, this); + this.groupUserInfo = new GroupUserInfoStorage(app, config, this); + this.message = new MessageStorage(app, config, this); + } + + public async initialize() { + this._models = await this.app.database?.getModels(this.robotId); + this._robot = await this.app.robot.getRobot(this.robotId) ?? undefined; + + await this.userInfo.initialize(); + await this.channelInfo.initialize(); + await this.rootGroupInfo.initialize(); + await this.groupInfo.initialize(); + await this.groupUserInfo.initialize(); + await this.message.initialize(); + } + + public get robotId() { + return this._robotId; + } + + public get models() { + return this._models; + } + + public get robot() { + return this._robot; + } +} \ No newline at end of file diff --git a/src/storage/RootGroupInfoStorage.ts b/src/storage/RootGroupInfoStorage.ts new file mode 100644 index 0000000..9d5c058 --- /dev/null +++ b/src/storage/RootGroupInfoStorage.ts @@ -0,0 +1,107 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { CacheStore } from "../CacheManager"; +import { RootGroupInfoType } from "../message/Sender"; +import { ModelRegistry } from "../DatabaseManager"; +import { RobotStorage } from "./RobotStorage"; +import { RootGroupInfoSchemaType } from "../odm/RootGroupInfo"; + +export class RootGroupInfoStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: CacheStore; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + this.cache = app.cache.getStore(['ObjectCache', storages.robotId, 'root_group_info']); + } + + public async initialize() { + this.models = this.storages.models; + } + + public async get(rootGroupId: string, fetchFromBot: boolean = false): Promise { + // from cache + let rootGroupInfo = await this.cache.get(rootGroupId); + if (rootGroupInfo) { + return rootGroupInfo; + } + + if (fetchFromBot) { + return await this.fetchFromRobot(rootGroupId); + } else if (this.models) { + let doc = await this.models.rootGroupInfo.findOne({ + rootGroupId, + }); + + if (doc) { + rootGroupInfo = doc.toObject(); + + await this.cache.set(rootGroupId, rootGroupInfo, this.cacheTTL); + return rootGroupInfo; + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + public async getByRef(rootGroupInfo: RootGroupInfoSchemaType | string): Promise { + if (typeof rootGroupInfo === 'string') { + return await this.get(rootGroupInfo, false); + } else { + return await this.get(rootGroupInfo.rootGroupId, false); + } + } + + public async fetchFromRobot(rootGroupId: string): Promise { + const robot = this.app.robot.getRobot(this.storages.robotId); + if (robot) { + const rootGroupInfo = await robot.getRootGroupInfo?.(rootGroupId); + if (rootGroupInfo) { + return await this.set(rootGroupInfo); + } + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + return null; + } + + public async set(rootGroupInfo: RootGroupInfoType): Promise { + let data: RootGroupInfoSchemaType = { + ...rootGroupInfo, + }; + + if (this.models) { + await this.models.rootGroupInfo.updateOne({ + rootGroupId: data.rootGroupId, + }, data, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + await this.cache.set(data.rootGroupId, data, this.cacheTTL); + + return data; + } + + public async remove(rootGroupId: string): Promise { + if (this.models) { + await this.models.rootGroupInfo.deleteOne({ + rootGroupId, + }); + } + + await this.cache.del(rootGroupId); + } +} \ No newline at end of file diff --git a/src/storage/UserInfoStorage.ts b/src/storage/UserInfoStorage.ts new file mode 100644 index 0000000..031a929 --- /dev/null +++ b/src/storage/UserInfoStorage.ts @@ -0,0 +1,109 @@ +import App from "../App"; +import { StorageConfig } from "../Config"; +import { CacheStore } from "../CacheManager"; +import { UserInfoType } from "../message/Sender"; +import { ModelRegistry } from "../DatabaseManager"; +import { UserInfoSchemaType } from "../odm/UserInfo"; +import { RobotStorage } from "./RobotStorage"; + +export class UserInfoStorage { + private app: App; + private config: StorageConfig; + private storages: RobotStorage; + private models?: ModelRegistry; + private cacheTTL: number; + + private cache: CacheStore; + + public constructor(app: App, config: StorageConfig, storages: RobotStorage) { + this.app = app; + this.config = config; + this.cacheTTL = config.cache_ttl ?? 86400; + this.storages = storages; + + this.cache = app.cache.getStore(['ObjectCache', storages.robotId, 'user_info']); + } + + public async initialize() { + this.models = this.storages.models; + } + + public async get(userId: string, fetchFromBot: boolean = false): Promise { + // from cache + let userInfo = await this.cache.get(userId); + if (userInfo) { + return userInfo; + } + + if (fetchFromBot) { + return await this.fetchFromRobot(userId); + } else if (this.models) { + let doc = await this.models.userInfo.findOne({ + userId, + }); + + if (doc) { + userInfo = doc.toObject(); + + await this.cache.set(userId, userInfo, this.cacheTTL); + return userInfo; + } + } else { + this.app.logger.warn('未配置 Database'); + } + + return null; + } + + public async getByRef(userInfo: UserInfoSchemaType | string): Promise { + if (typeof userInfo === 'string') { + return await this.get(userInfo, false); + } else { + return await this.get(userInfo.userId, false); + } + } + + public async fetchFromRobot(userId: string): Promise { + const robot = this.app.robot.getRobot(this.storages.robotId); + if (robot) { + const userInfoList = await robot.getUsersInfo?.([userId]); + if (userInfoList && userInfoList.length > 0) { + const userInfo = userInfoList[0]; + if (userInfo) { + return await this.set(userInfo); + } + } + } else { + this.app.logger.error(`无法找到机器人配置:${this.storages.robotId}`); + } + return null; + } + + public async set(userInfo: UserInfoType): Promise { + let data: UserInfoSchemaType = { + ...userInfo, + } + if (this.models) { + await this.models.userInfo.updateOne({ + userId: data.userId, + }, data, { + upsert: true, + setDefaultsOnInsert: true, + }); + } + + await this.cache.set(data.userId, data, this.cacheTTL); + + return data; + } + + public async remove(userId: string): Promise { + if (this.models) { + await this.models.userInfo.deleteOne({ + userId, + }); + } + + await this.cache.del(userId); + } +} \ No newline at end of file diff --git a/src/utils/ItemLimitedList.ts b/src/utils/ItemLimitedList.ts new file mode 100644 index 0000000..6445efe --- /dev/null +++ b/src/utils/ItemLimitedList.ts @@ -0,0 +1,28 @@ +export class ItemLimitedList extends Array { + private _maxLength: number; + + constructor(maxLength: number) { + super(); + this._maxLength = maxLength; + } + + get maxLength() { + return this._maxLength; + } + + set maxLength(value: number) { + this._maxLength = value; + if (this.length > this.maxLength) { + let offset = this.length - this.maxLength; + this.splice(0, offset); + } + } + + /** 添加元素 */ + addOne(item: T) { + if (this.length + 1 >= this.maxLength) { + this.shift(); + } + this.push(item); + } +} \ No newline at end of file diff --git a/src/utils/MessageTypingSimulator.ts b/src/utils/MessageTypingSimulator.ts new file mode 100644 index 0000000..cb7daf0 --- /dev/null +++ b/src/utils/MessageTypingSimulator.ts @@ -0,0 +1,72 @@ +import EventEmitter from "events"; +import { Utils } from "./Utils"; + +export class MessageTypingSimulator extends EventEmitter { + public chineseCPM = 1000; + public latinCPM = this.chineseCPM * 4; + public randomDelay = [0, 3000]; + + private messageBuffer: string[] = []; + private messageCount = 0; + private inTyping = false; + private running = true; + + constructor() { + super(); + } + + public pushMessage(message: string) { + this.messageBuffer.push(message); + if (!this.inTyping) { + this.startTyping(); + } + } + + public stop() { + this.running = false; + this.removeAllListeners(); + } + + public async startTyping() { + if (this.inTyping) { + return; + } + + this.inTyping = true; + try { + while (this.messageBuffer.length > 0 && this.running) { + const message = this.messageBuffer.shift(); + if (!message) { + continue; + } + + const typingTime = this.getTypingTime(message); + // console.log('sleep time', typingTime); + await Utils.sleep(typingTime); + + if (this.running) { + this.emit('message', message, this.messageCount); + this.messageCount++; + } + } + } catch (e) { + this.inTyping = false; + console.error(e); + } + this.inTyping = false; + } + + private getTypingTime(message: string) { + let latinChars = 0; + for (let i = 0; i < message.length; i++) { + if (message.charCodeAt(i) < 128) { + latinChars++; + } + } + let chineseChars = message.length - latinChars; + let typingTime = chineseChars * 60000 / this.chineseCPM + latinChars * 60000 / this.latinCPM; + typingTime += Math.random() * (this.randomDelay[1] - this.randomDelay[0]) + this.randomDelay[0]; + + return typingTime; + } +} \ No newline at end of file diff --git a/src/utils/RandomMessage.ts b/src/utils/RandomMessage.ts new file mode 100644 index 0000000..a3e3340 --- /dev/null +++ b/src/utils/RandomMessage.ts @@ -0,0 +1,39 @@ +import { ShuffleRandom } from "./ShuffleRandom"; +import Handlebars from "handlebars"; +import { Pair } from "./types"; + +export class RandomMessage extends ShuffleRandom>> { + constructor(messageList: string[] = []) { + let itemList: Pair>[] = messageList + .map((message) => [message, Handlebars.compile(message)]); + + super(itemList); + } + + public get messageList(): string[] { + return this._itemList.map((item) => item[0]); + } + + public set messageList(messageList: string[]) { + // Remove message that not in messageList + this._itemList = this._itemList.filter((item) => !messageList.includes(item[0])); + + // Add message that not in itemList + for (let message of messageList) { + if (!this._itemList.some((item) => item[0] === message)) { + this._itemList.push([message, Handlebars.compile(message)]); + } + } + + this.shuffle(); + } + + public nextMessage(data: any = {}): string | null { + let message = super.next(); + if (message === null) { + return null; + } + let generator = message[1]; + return generator(data); + } +} \ No newline at end of file diff --git a/src/utils/ShuffleRandom.ts b/src/utils/ShuffleRandom.ts new file mode 100644 index 0000000..ca42f9d --- /dev/null +++ b/src/utils/ShuffleRandom.ts @@ -0,0 +1,43 @@ +export class ShuffleRandom { + protected _itemList: T[]; + protected currentIndex = 0; + + constructor(itemList: T[] = []) { + this._itemList = itemList; + if (this._itemList.length > 0) { + this.shuffle(); + } + } + + public get itemList(): T[] { + return this._itemList; + } + + public set itemList(itemList: T[]) { + this._itemList = itemList; + this.shuffle(); + } + + public next(): T | null { + if (this._itemList.length === 0) { + return null; + } + let message = this._itemList[this.currentIndex]; + + if (this.currentIndex === this._itemList.length - 1) { + this.shuffle(); + } else { + this.currentIndex++; + } + + return message; + } + + protected shuffle() { + for (let i = 0; i < this._itemList.length; i++) { + let j = Math.floor(Math.random() * (i + 1)); + [this._itemList[i], this._itemList[j]] = [this._itemList[j], this._itemList[i]]; + } + this.currentIndex = 0; + } +} \ No newline at end of file diff --git a/src/utils/Utils.ts b/src/utils/Utils.ts index 4c34a61..43a218c 100644 --- a/src/utils/Utils.ts +++ b/src/utils/Utils.ts @@ -71,4 +71,65 @@ export class Utils { fs.mkdirSync(path, { recursive: true }); } } + + static isLatinChar(char: string | number): boolean { + const charCodeMap = { + a: 'a'.charCodeAt(0), + z: 'z'.charCodeAt(0), + A: 'A'.charCodeAt(0), + Z: 'Z'.charCodeAt(0), + 0: '0'.charCodeAt(0), + 9: '9'.charCodeAt(0), + }; + + if (typeof char === 'string') { + char = char.charCodeAt(0); + } + + return (char >= charCodeMap.a && char <= charCodeMap.z) || + (char >= charCodeMap.A && char <= charCodeMap.Z) || + (char >= charCodeMap['0'] && char <= charCodeMap['9']); + } + + /** + * 计算字符串中的汉字和单词数量 + */ + static countWord(text: string): number { + text = text.trim(); + + if (text === '') { + return 0; + } + + let wordCount = 0; + let charCode: number = 0; + let prevCharCode: number = 0; + for (let i = 0; i < text.length; i++) { + charCode = text.charCodeAt(i); + if (i !== 0) { + prevCharCode = text.charCodeAt(i - 1); + } + if (charCode > 255) { + wordCount ++; + } else { + if (Utils.isLatinChar(charCode) && !Utils.isLatinChar(prevCharCode)) { + wordCount ++; + } + } + } + + return wordCount + 1; + } + + static escapeHtml(text: string) { + return text.replace(/&/g, '&').replace(//g, '>'); + } + + static unescapeHtml(text: string) { + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); + } + + static escapeMarkdown(text: string) { + return text.replace(/([\\`*_{}[\]()#+\-.!])/g, '\\$1'); + } } diff --git a/src/utils/contextHooks.ts b/src/utils/contextHooks.ts index 3f9b1d4..6cbe55e 100644 --- a/src/utils/contextHooks.ts +++ b/src/utils/contextHooks.ts @@ -9,19 +9,23 @@ export function useLogger(): winston.Logger { return useApp().logger; } -export function useEventManager() { +export function useEvent() { return useApp().event; } -export function useSessionManager() { - return useApp().session; +export function useCache() { + return useApp().cache; } -export function useRobotManager() { +export function useStorage() { + return useApp().storage; +} + +export function useRobot() { return useApp().robot; } -export function useRestfulApiManager() { +export function useRestfulApi() { return useApp().restfulApi; } diff --git a/src/utils/func.ts b/src/utils/func.ts new file mode 100644 index 0000000..8c87137 --- /dev/null +++ b/src/utils/func.ts @@ -0,0 +1,41 @@ +export function compareProps(a: any, b: any, props: string[], depth: number = 5): boolean { + if (depth <= 0) return true; + + for (let prop of props) { + let propPath = prop.split('.'); + + if (propPath.length === 1) { + // 优化单层性能 + if (typeof a !== 'object' || typeof b !== 'object' || a[prop] !== b[prop]) { + return false; + } + } else { + let curA = a; + let curB = b; + + for (let p of propPath) { + if (typeof curA !== 'object' || !(p in curA)) { + curA = undefined; + } else { + curA = curA[p]; + } + + if (typeof curB !== 'object' || !(p in curB)) { + curB = undefined; + } else { + curB = curB[p]; + } + + if (curA === undefined || curB === undefined) { + break; + } + } + + if (curA !== curB) { + return false; + } + } + } + + return true; +} \ No newline at end of file diff --git a/src/utils/message.ts b/src/utils/message.ts new file mode 100644 index 0000000..7c973d2 --- /dev/null +++ b/src/utils/message.ts @@ -0,0 +1,5 @@ +import { MessageChunk } from "src/message/Message"; + +export class MessageUtils { + +} \ No newline at end of file diff --git a/src/utils/retrieveOnce.ts b/src/utils/retrieveOnce.ts new file mode 100644 index 0000000..cf6a008 --- /dev/null +++ b/src/utils/retrieveOnce.ts @@ -0,0 +1,44 @@ +export function retrieveOnce Promise)>(callback: Func): Func { + type ValType = Awaited>; + + let data: any = undefined; + let error: Error | undefined = undefined; + let loaded = false; + let loading = false; + + let callbacks: [(data: ValType) => any, (err: Error) => any][] = []; + + return ((...args: any): Promise => { + return new Promise((resolve, reject) => { + if (loaded) { + if (error) { + reject(error); + } else { + resolve(data); + } + return; + } + + callbacks.push([resolve, reject]); + + if (!loading) { + loading = true; + callback(...args).then((ret) => { + data = ret; + loaded = true; + loading = false; + callbacks.forEach((cb) => { + cb[0](ret); + }); + }).catch((err) => { + error = err; + loaded = true; + loading = false; + callbacks.forEach((cb) => { + cb[1](err); + }); + }); + } + }); + }) as unknown as Func; +} \ No newline at end of file diff --git a/src/utils/types.ts b/src/utils/types.ts index 95dd0c2..4716803 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1 +1,5 @@ -export type AnyFunction = (...args: any) => any; \ No newline at end of file +export type AnyFunction = (...args: any) => any; + +export type Pair = [T1, T2]; + +export type LiteralUnion = T | (U & { zz_IGNORE_ME?: never }) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index ef5bc90..c9f2a0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,8 +27,8 @@ "module": "ES2022", /* Specify what module code is generated. */ "rootDir": "./src", /* Specify the root folder within your source files. */ "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ - // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ - // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ + "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ + "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ // "types": [], /* Specify type package names to be included without being referenced in a source file. */